Public: anyone can read this; the crew can change it.
The home page is the feed: server-rendered, live, on phone and desk #368
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/361-the-home-page-is-the-feed-server-rendere → main 1 commit fork
8 files
changed +323
−3
docs/FEED.md +12 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # The feed |
| 2 | 2 | |
| 3 | 3 | A per-viewer read model of what happened on the forge, ranked, each item with |
| 4 | one sentence saying why it is there. Data only: there is no page yet. | |
| 4 | one sentence saying why it is there. | |
| 5 | 5 | |
| 6 | 6 | `GET /api/v1/feed?limit=&cursor=` → `{ items, next_cursor }`, for the signed-in |
| 7 | 7 | viewer only. An item is `{ id, kind, app, actor, at, link, reason, score }`; |
| @@ -41,3 +41,14 @@ Muting hides an app's items except asks: what waits on a person is never | ||
| 41 | 41 | muted away. Dismissing hides one item. The cursor is `score|id` of the last |
| 42 | 42 | item; the feed is recomputed per request and resumes by id (or by score if |
| 43 | 43 | that item has gone). |
| 44 | ||
| 45 | ## The page (`src/web/studio/feed.ts`) | |
| 46 | ||
| 47 | `/` for a signed-in viewer renders the first 20 items as cards above the shelf, | |
| 48 | server-side, and `/live/feed` answers the same region for the live-regions | |
| 49 | client (etag, 304). A card: the reason as a link into the thing, Steer (run | |
| 50 | cards, posts to the run's `steer`) or Tell the crew (everything else, posts to | |
| 51 | the app's `tell`), Dismiss and Mute app (the interactions API, same-origin). | |
| 52 | Keys: `j`/`k`/Enter are the shell's, `x` dismisses the focused card. | |
| 53 | Not yet: the `choose` deck card (nothing in the feed produces one), stop from | |
| 54 | a card, and paging past the first 20. |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/http/server.ts +2
| @@ -106,6 +106,7 @@ import { | ||
| 106 | 106 | tellSubmit, |
| 107 | 107 | } from "../web/studio/actions.ts"; |
| 108 | 108 | import { asksPage } from "../web/studio/asks_page.ts"; |
| 109 | import { liveFeedFragment } from "../web/studio/feed.ts"; | |
| 109 | 110 | import { liveAppsFragment, liveOnYouFragment } from "../web/studio/home.ts"; |
| 110 | 111 | import { appNowFragment } from "../web/studio/now.ts"; |
| 111 | 112 | import { appStateFragment } from "../web/studio/previews.ts"; |
| @@ -446,6 +447,7 @@ export function buildRouter(): Router<Handler> { | ||
| 446 | 447 | // fetches a region and not the page. Literal, above /:owner. |
| 447 | 448 | router.add("GET", "/live/apps", liveAppsFragment); |
| 448 | 449 | router.add("GET", "/live/on-you", liveOnYouFragment); |
| 450 | router.add("GET", "/live/feed", liveFeedFragment); | |
| 449 | 451 | // Whether a hostname is an app's verified domain — what Caddy's |
| 450 | 452 | // `on_demand_tls { ask … }` asks before it fetches a certificate for a |
| 451 | 453 | // name it has never seen (docs/EXTRACT.md). 200 says yes, 404 says no, |
src/web/studio/feed.ts added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | /** | |
| 2 | * The home's feed: the ranked read model (`feed/feed.ts`) as cards. Each card | |
| 3 | * is one sentence of why, a link straight into the thing, a way to say | |
| 4 | * something to the crew from where you are, and dismiss / mute. The first | |
| 5 | * page is server-rendered; the same region answers at `/live/feed` so a | |
| 6 | * forge event refreshes it through the live-regions client, like the shelf. | |
| 7 | * Every string on a card is plain text and is escaped here, once. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { User } from "../../core/users.ts"; | |
| 11 | import type { Ctx } from "../../http/context.ts"; | |
| 12 | import { readFeed } from "../../feed/feed.ts"; | |
| 13 | import type { FeedItem } from "../../feed/rank.ts"; | |
| 14 | import { html, type Html } from "../html.ts"; | |
| 15 | import { fragment } from "../layout.ts"; | |
| 16 | import { btn, empty } from "../ui.ts"; | |
| 17 | ||
| 18 | /** Events that can change what the feed says. */ | |
| 19 | export const FEED_LIVE = | |
| 20 | "run.finished,pull.opened,pull.merged,pull.reviewed,pull.closed,look.taken,app.serving,issue.blocked,issue.unblocked,decision.*"; | |
| 21 | ||
| 22 | export const FEED_FIRST_PAGE = 20; | |
| 23 | ||
| 24 | const RUN_LINK = /^\/[^/]+\/[^/]+\/runs\/[A-Za-z0-9_-]+$/; | |
| 25 | ||
| 26 | const KIND_LABEL: Record<FeedItem["kind"], string> = { | |
| 27 | run: "Run", | |
| 28 | ask: "Needs you", | |
| 29 | pull: "Change", | |
| 30 | decision: "Decision", | |
| 31 | map: "Map", | |
| 32 | cost: "Cost", | |
| 33 | }; | |
| 34 | ||
| 35 | /** One card. A run's card steers the run; every card can tell its app. */ | |
| 36 | export function feedCard(item: FeedItem): Html { | |
| 37 | const steer = item.kind === "run" || item.kind === "cost"; | |
| 38 | const target = steer && RUN_LINK.test(item.link) ? `${item.link}/steer` : null; | |
| 39 | return html`<article | |
| 40 | class="fcard" | |
| 41 | data-nav | |
| 42 | tabindex="0" | |
| 43 | data-item="${item.id}" | |
| 44 | data-app="${item.app}" | |
| 45 | data-kind="${item.kind}" | |
| 46 | > | |
| 47 | <div class="fcard-head"> | |
| 48 | <span class="badge">${KIND_LABEL[item.kind]}</span> | |
| 49 | <span class="fcard-app muted small">${item.app}</span> | |
| 50 | <span class="spacer"></span> | |
| 51 | <button type="button" class="btn btn-sm btn-ghost" data-feed="dismissed" title="Dismiss (x)">Dismiss</button> | |
| 52 | <button type="button" class="btn btn-sm btn-ghost" data-feed="muted" title="Mute ${item.app}">Mute app</button> | |
| 53 | </div> | |
| 54 | <p class="fcard-reason"><a href="${item.link}" data-feed-open>${item.reason}</a></p> | |
| 55 | <details class="fcard-say"> | |
| 56 | <summary class="small">${target ? "Steer" : "Tell the crew"}</summary> | |
| 57 | <form method="post" action="${target ?? `/${item.app}/tell`}" class="fcard-form"> | |
| 58 | <textarea name="text" rows="2" maxlength="2000" required aria-label="${target ? "Steer this run" : "Tell the crew"}"></textarea> | |
| 59 | ${btn(target ? "Steer" : "Tell", { size: "sm", variant: "primary" })} | |
| 60 | </form> | |
| 61 | </details> | |
| 62 | </article>`; | |
| 63 | } | |
| 64 | ||
| 65 | /** The region: cards, or one line saying there is nothing to do. */ | |
| 66 | export function feedRegion(user: User, ctx: Ctx): Html { | |
| 67 | const { items } = readFeed(ctx.app.db, user, { limit: FEED_FIRST_PAGE }); | |
| 68 | return html`<section | |
| 69 | id="live-feed" | |
| 70 | class="feed" | |
| 71 | aria-label="Feed" | |
| 72 | data-live="${FEED_LIVE}" | |
| 73 | data-live-src="/live/feed" | |
| 74 | > | |
| 75 | ${items.length === 0 | |
| 76 | ? empty({ | |
| 77 | icon: "activity", | |
| 78 | title: "Nothing new", | |
| 79 | note: "When something happens in your apps, it lands here.", | |
| 80 | }) | |
| 81 | : items.map(feedCard)} | |
| 82 | </section>`; | |
| 83 | } | |
| 84 | ||
| 85 | // GET /live/feed — the feed alone, for the region that shows it. | |
| 86 | export function liveFeedFragment(ctx: Ctx): Response { | |
| 87 | const user = ctx.auth.user; | |
| 88 | if (!user) return fragment(html``, { status: 404 }); | |
| 89 | return fragment(feedRegion(user, ctx), { request: ctx.request }); | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * j/k and enter are the shell's (`data-nav`). Here: `x` dismisses the card | |
| 94 | * in focus, the buttons dismiss or mute, and opening a card is recorded. | |
| 95 | * Interactions go to the feed API, same-origin with the session cookie. | |
| 96 | */ | |
| 97 | export const feedJs = /* js */ ` | |
| 98 | (function () { | |
| 99 | var feed = document.getElementById('live-feed'); | |
| 100 | if (!feed || typeof fetch === 'undefined') return; | |
| 101 | function send(card, kind, keepalive) { | |
| 102 | var body = { item: card.getAttribute('data-item'), kind: kind, app: card.getAttribute('data-app') }; | |
| 103 | return fetch('/api/v1/feed/interactions', { | |
| 104 | method: 'POST', credentials: 'same-origin', keepalive: !!keepalive, | |
| 105 | headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) | |
| 106 | }); | |
| 107 | } | |
| 108 | function gone(card) { | |
| 109 | var cards = Array.prototype.slice.call(feed.querySelectorAll('.fcard')); | |
| 110 | var next = cards[cards.indexOf(card) + 1] || cards[cards.indexOf(card) - 1]; | |
| 111 | card.hidden = true; | |
| 112 | if (next) next.focus(); | |
| 113 | } | |
| 114 | function act(card, kind) { | |
| 115 | send(card, kind).then(function (r) { if (r.ok) gone(card); }, function () {}); | |
| 116 | } | |
| 117 | feed.addEventListener('click', function (e) { | |
| 118 | var t = e.target && e.target.closest ? e.target.closest('[data-feed],[data-feed-open]') : null; | |
| 119 | var card = t && t.closest('.fcard'); | |
| 120 | if (!card) return; | |
| 121 | if (t.hasAttribute('data-feed-open')) { send(card, 'opened', true).catch(function () {}); return; } | |
| 122 | act(card, t.getAttribute('data-feed')); | |
| 123 | }); | |
| 124 | document.addEventListener('keydown', function (e) { | |
| 125 | if (e.key !== 'x' || e.metaKey || e.ctrlKey || e.altKey) return; | |
| 126 | var a = document.activeElement; | |
| 127 | if (!a || !a.matches || !a.matches('.fcard')) return; | |
| 128 | e.preventDefault(); | |
| 129 | act(a, 'dismissed'); | |
| 130 | }); | |
| 131 | })(); | |
| 132 | `; |
src/web/studio/feed_style.ts added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | /** The feed cards' slice of the served stylesheet (no imports: `style.ts` reads this). */ | |
| 2 | ||
| 3 | export const feedCss = /* css */ ` | |
| 4 | .feed { display: grid; gap: 10px; } | |
| 5 | .fcard { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--card, var(--bg)); } | |
| 6 | .fcard:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } | |
| 7 | .fcard[hidden] { display: none; } | |
| 8 | .fcard-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } | |
| 9 | .fcard-reason { margin: 8px 0 4px; font-size: 1.05em; overflow-wrap: anywhere; } | |
| 10 | .fcard-say summary { cursor: pointer; color: var(--muted-fg, inherit); padding: 4px 0; } | |
| 11 | .fcard-form { display: grid; gap: 8px; margin-top: 6px; } | |
| 12 | .fcard-form textarea { width: 100%; min-height: 56px; resize: vertical; } | |
| 13 | @media (max-width: 600px) { | |
| 14 | .fcard { padding: 12px; } | |
| 15 | .fcard-head .btn { padding: 8px 10px; } | |
| 16 | .fcard-say summary { padding: 10px 0; } | |
| 17 | } | |
| 18 | `; |
src/web/studio/home.ts +3 −1
| @@ -21,6 +21,7 @@ import { onYouCard } from "../pages/site.ts"; | ||
| 21 | 21 | import { writableAppsOf } from "./asks.ts"; |
| 22 | 22 | import { productionFactsForRepos, shelfRows } from "./shelf.ts"; |
| 23 | 23 | import { servingHistory } from "./words.ts"; |
| 24 | import { feedJs, feedRegion } from "./feed.ts"; | |
| 24 | 25 | |
| 25 | 26 | /** |
| 26 | 27 | * The home in the studio: your apps as a shelf of |
| @@ -147,6 +148,7 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 147 | 148 | ${costLine} |
| 148 | 149 | <div class="dash"> |
| 149 | 150 | <div class="dash-main stack"> |
| 151 | ${feedRegion(user, ctx)} | |
| 150 | 152 | ${appsRegion(apps)} |
| 151 | 153 | </div> |
| 152 | 154 | <aside class="dash-rail stack"> |
| @@ -157,6 +159,6 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 157 | 159 | ctx, |
| 158 | 160 | title: "Studio", |
| 159 | 161 | body, |
| 160 | scriptJs: `${liveClientJs}\n${liveRegionsJs}`, | |
| 162 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}`, | |
| 161 | 163 | }); |
| 162 | 164 | } |
src/web/studio/style.ts +3
| @@ -89,6 +89,8 @@ const rail = part(/* css */ ` | ||
| 89 | 89 | .studio-crew li span { opacity: .85; } |
| 90 | 90 | `); |
| 91 | 91 | |
| 92 | import { feedCss } from "./feed_style.ts"; | |
| 93 | ||
| 92 | 94 | export const studioCss = `@layer components {\n${[ |
| 93 | 95 | shell, |
| 94 | 96 | tellBox, |
| @@ -98,4 +100,5 @@ export const studioCss = `@layer components {\n${[ | ||
| 98 | 100 | rows, |
| 99 | 101 | rowActions, |
| 100 | 102 | rail, |
| 103 | part(feedCss), | |
| 101 | 104 | ].join("\n\n")}\n}`; |
test/feed-page.test.ts added +152
| @@ -0,0 +1,152 @@ | ||
| 1 | /** | |
| 2 | * The home page is the feed: server-rendered cards for the signed-in viewer, | |
| 3 | * every string escaped, card actions pointing at the routes that already | |
| 4 | * enforce them, and a fragment address the live client refreshes. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import type { Repo } from "../src/core/repos.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { readFeed } from "../src/feed/feed.ts"; | |
| 17 | import { feedCard } from "../src/web/studio/feed.ts"; | |
| 18 | ||
| 19 | describe("the feed on the home page", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let alice: User; | |
| 24 | let bob: User; | |
| 25 | let aliceCookie: string; | |
| 26 | let bobCookie: string; | |
| 27 | let secret: Repo; | |
| 28 | let open: Repo; | |
| 29 | const emit = (repo: Repo, kind: string, payload = {}) => | |
| 30 | app.bus.append({ kind, repoId: repo.id, actorId: alice.id, payload }); | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-feed-page-")); | |
| 34 | app = createApp({ dataDir, port: 0 }); | |
| 35 | servers = await serve(app); | |
| 36 | const mk = async (username: string, first = false) => { | |
| 37 | const r = await createUser(app.db, { | |
| 38 | username, | |
| 39 | email: `${username}@forge.local`, | |
| 40 | password: "a-good-passphrase", | |
| 41 | adminIfFirst: first, | |
| 42 | }); | |
| 43 | if (!r.ok) throw new Error(r.error); | |
| 44 | return r.value; | |
| 45 | }; | |
| 46 | await mk("root", true); | |
| 47 | alice = await mk("alice"); | |
| 48 | bob = await mk("bob"); | |
| 49 | aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 50 | bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 51 | const s = await createRepo(app, alice, { name: "secret", visibility: "private" }); | |
| 52 | const o = await createRepo(app, alice, { name: "open", visibility: "public" }); | |
| 53 | if (!s.ok || !o.ok) throw new Error("repos"); | |
| 54 | secret = s.value; | |
| 55 | open = o.value; | |
| 56 | emit(secret, "run.finished", { kind: "agent", state: "ok", summary: "hidden work", runId: "rs" }); | |
| 57 | emit(open, "run.finished", { | |
| 58 | kind: "agent", | |
| 59 | state: "failed", | |
| 60 | summary: `<script>alert(1)</script> "x"`, | |
| 61 | runId: "r1", | |
| 62 | }); | |
| 63 | }); | |
| 64 | ||
| 65 | afterAll(async () => { | |
| 66 | servers.stop(); | |
| 67 | app.close(); | |
| 68 | await rm(dataDir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | const get = (path: string, cookie?: string, headers: Record<string, string> = {}) => | |
| 72 | fetch(`${servers.url}${path}`, { | |
| 73 | headers: { ...(cookie ? { cookie } : {}), ...headers }, | |
| 74 | }); | |
| 75 | ||
| 76 | test("/ renders the ranked feed on first paint, escaped", async () => { | |
| 77 | const res = await get("/", aliceCookie); | |
| 78 | expect(res.status).toBe(200); | |
| 79 | const text = await res.text(); | |
| 80 | expect(text).toContain('id="live-feed"'); | |
| 81 | expect(text).toContain('data-live-src="/live/feed"'); | |
| 82 | expect(text).toContain("alice/open"); | |
| 83 | expect(text).not.toContain("<script>alert(1)</script>"); | |
| 84 | expect(text).toContain("<script>alert(1)</script>"); | |
| 85 | const order = [...text.matchAll(/class="fcard"[^>]*data-item="([^"]+)"/g)].map((m) => m[1]); | |
| 86 | expect(order).toEqual(readFeed(app.db, alice).items.map((i) => i.id)); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a card links into the thing and steers or tells from where it is", async () => { | |
| 90 | const text = await (await get("/", aliceCookie)).text(); | |
| 91 | expect(text).toContain('href="/alice/open/runs/r1"'); | |
| 92 | expect(text).toContain('action="/alice/open/runs/r1/steer"'); | |
| 93 | const item = readFeed(app.db, alice).items[0]!; | |
| 94 | const tell = feedCard({ ...item, kind: "pull", link: "/alice/open/changes/1" }).text; | |
| 95 | expect(tell).toContain('action="/alice/open/tell"'); | |
| 96 | expect(tell).toContain('data-feed="dismissed"'); | |
| 97 | expect(tell).toContain('data-feed="muted"'); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a hostile app or reason cannot break out of a card", () => { | |
| 101 | const card = feedCard({ | |
| 102 | id: "event:e1", | |
| 103 | kind: "run", | |
| 104 | app: 'a"><img src=x onerror=1>/b', | |
| 105 | actor: null, | |
| 106 | at: new Date().toISOString(), | |
| 107 | link: "/a/b/runs/r", | |
| 108 | reason: "<b>x</b>", | |
| 109 | score: 1, | |
| 110 | }).text; | |
| 111 | expect(card).not.toContain("<img"); | |
| 112 | expect(card).not.toContain("<b>x</b>"); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a viewer sees only their own feed: no leak from a private app", async () => { | |
| 116 | const text = await (await get("/", bobCookie)).text(); | |
| 117 | expect(text).not.toContain("alice/secret"); | |
| 118 | expect(text).not.toContain("hidden work"); | |
| 119 | expect(text).toContain("alice/open"); | |
| 120 | }); | |
| 121 | ||
| 122 | test("the live address answers the region alone, conditionally", async () => { | |
| 123 | const res = await get("/live/feed", aliceCookie); | |
| 124 | expect(res.status).toBe(200); | |
| 125 | const text = await res.text(); | |
| 126 | expect(text.trimStart().startsWith("<section")).toBe(true); | |
| 127 | expect(text).not.toContain("<html"); | |
| 128 | const again = await get("/live/feed", aliceCookie, { "if-none-match": res.headers.get("etag")! }); | |
| 129 | expect(again.status).toBe(304); | |
| 130 | expect((await get("/live/feed")).status).toBe(404); | |
| 131 | }); | |
| 132 | ||
| 133 | test("dismissing a card takes it out of the next render", async () => { | |
| 134 | const item = readFeed(app.db, bob).items.find((i) => i.kind === "run")!; | |
| 135 | const post = (origin: string) => | |
| 136 | fetch(`${servers.url}/api/v1/feed/interactions`, { | |
| 137 | method: "POST", | |
| 138 | headers: { cookie: bobCookie, origin, "content-type": "application/json" }, | |
| 139 | body: JSON.stringify({ item: item.id, kind: "dismissed", app: item.app }), | |
| 140 | }); | |
| 141 | expect((await post("http://evil.example")).status).toBe(403); | |
| 142 | expect((await post(servers.url)).status).toBe(201); | |
| 143 | const text = await (await get("/live/feed", bobCookie)).text(); | |
| 144 | expect(text).not.toContain(`data-item="${item.id}"`); | |
| 145 | }); | |
| 146 | ||
| 147 | test("the page carries the feed script and a card is focusable for j/k", async () => { | |
| 148 | const text = await (await get("/", aliceCookie)).text(); | |
| 149 | expect(text).toContain("data-nav"); | |
| 150 | expect(text).toContain("/api/v1/feed/interactions"); | |
| 151 | }); | |
| 152 | }); |
new above
offline