Public: anyone can read this; the crew can change it.
One test proves every kind of feed post reaches its files, diff, run or terminal in two taps #7759
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7754-one-test-proves-every-kind-of-feed-post → main 2 commits fork
2 files
changed +313
−2
src/feed/items.ts +8 −2
| @@ -13,7 +13,7 @@ import { listDecidedIn } from "../core/decisions.ts"; | ||
| 13 | 13 | import { audienceFilter } from "../core/event_audience.ts"; |
| 14 | 14 | import { listEventsVisibleTo, type ForgeEvent } from "../core/events.ts"; |
| 15 | 15 | import { getReview } from "../core/reviews.ts"; |
| 16 | import { commentAnchor, getIssuesByNumbers, numberKey, type Issue } from "../core/issues.ts"; | |
| 16 | import { commentAnchor, getIssuesByIds, getIssuesByNumbers, numberKey, type Issue } from "../core/issues.ts"; | |
| 17 | 17 | import { |
| 18 | 18 | listDetachedMirrors, |
| 19 | 19 | listOrigins, |
| @@ -423,9 +423,13 @@ export function candidatesFor( | ||
| 423 | 423 | out.push(...frozenCandidates(db, viewer, now)); |
| 424 | 424 | out.push(...chooseCandidates(db, writable)); |
| 425 | 425 | out.push(...hotCandidates(writable)); |
| 426 | // A decision about a pull or a tell links to it, one tap from its diff; | |
| 427 | // one about nothing in particular keeps the pulls list. | |
| 428 | const about = getIssuesByIds(db, decided.flatMap((d) => (d.issueId === null ? [] : [d.issueId]))); | |
| 426 | 429 | for (const d of decided) { |
| 427 | 430 | const repo = repos.get(d.repoId); |
| 428 | 431 | if (!repo) continue; |
| 432 | const subject = d.issueId === null ? undefined : about.get(d.issueId); | |
| 429 | 433 | out.push({ |
| 430 | 434 | id: `decision:${d.id}`, |
| 431 | 435 | kind: "decision", |
| @@ -434,7 +438,9 @@ export function candidatesFor( | ||
| 434 | 438 | actorId: null, |
| 435 | 439 | actor: null, |
| 436 | 440 | at: d.outcomeAt ?? d.createdAt, |
| 437 | link: `${base(repo)}/pulls`, | |
| 441 | link: subject === undefined | |
| 442 | ? `${base(repo)}/pulls` | |
| 443 | : `${base(repo)}/${subject.kind === "pull" ? "pulls" : "issues"}/${subject.number}`, | |
| 438 | 444 | what: clip(`${d.by} chose “${clip(d.answer, 40)}” on a ${d.kind} and it ended ${d.outcome}`), |
| 439 | 445 | urgency: 0, |
| 440 | 446 | }); |
test/feed-two-taps.test.ts added +305
| @@ -0,0 +1,305 @@ | ||
| 1 | /** | |
| 2 | * From any post a person reaches its files, diff, run or terminal in two taps | |
| 3 | * (docs/VISION.md, P1): one repository with an event of each kind the feed | |
| 4 | * builds; for each post, its own link is tap 1 and a link on that page to the | |
| 5 | * promised depth is tap 2, and both answer 200. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 15 | import { recordDecision } from "../src/core/decisions.ts"; | |
| 16 | import { recordLook } from "../src/core/looks.ts"; | |
| 17 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 19 | import { createSession } from "../src/core/sessions.ts"; | |
| 20 | import { createToken } from "../src/core/tokens.ts"; | |
| 21 | import { createUser, type User } from "../src/core/users.ts"; | |
| 22 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | let root = ""; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let alice: User; | |
| 32 | let repo: Repo; | |
| 33 | let cookie = ""; | |
| 34 | const base = "/alice/forge"; | |
| 35 | let mergedNumber = 0; | |
| 36 | let openNumber = 0; | |
| 37 | let runId = ""; | |
| 38 | let headOid = ""; | |
| 39 | let mergeOid = ""; | |
| 40 | let token = ""; | |
| 41 | ||
| 42 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 43 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 44 | return r.value; | |
| 45 | }; | |
| 46 | ||
| 47 | beforeAll(async () => { | |
| 48 | root = mkdtempSync(join(tmpdir(), "forge-feed-two-taps-")); | |
| 49 | app = createApp({ dataDir: root, port: 0, disk: () => ({ freeBytes: 95, totalBytes: 100 }) }); | |
| 50 | servers = await serve(app); | |
| 51 | alice = must( | |
| 52 | await createUser(app.db, { | |
| 53 | origin: "seed", | |
| 54 | trusted: true, | |
| 55 | username: "alice", | |
| 56 | email: "[email protected]", | |
| 57 | password: "a-good-passphrase", | |
| 58 | adminIfFirst: true, | |
| 59 | }), | |
| 60 | ); | |
| 61 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 62 | repo = must(await createRepo(app, alice, { name: "forge", visibility: "public" })); | |
| 63 | must( | |
| 64 | await commitToBranch(app, alice, repo, { | |
| 65 | message: "seed", | |
| 66 | files: [{ path: "README.md", content: "# forge\n" }], | |
| 67 | }), | |
| 68 | ); | |
| 69 | const pull = async (branch: string, title: string) => { | |
| 70 | must( | |
| 71 | await commitToBranch(app, alice, repo, { | |
| 72 | branch, | |
| 73 | base: "main", | |
| 74 | message: title, | |
| 75 | files: [{ path: `${branch}.md`, content: `${branch}\n` }], | |
| 76 | }), | |
| 77 | ); | |
| 78 | return must( | |
| 79 | await openPull(app, alice, repo, { title, body: "", baseBranch: "main", headBranch: branch }), | |
| 80 | ); | |
| 81 | }; | |
| 82 | ||
| 83 | // A pull opened and merged through the real actions, so `pull.opened` and | |
| 84 | // `pull.merged` carry the payloads the forge writes. A decision to land it | |
| 85 | // is recorded first; the merge resolves it. | |
| 86 | const merged = await pull("done", "Merged change"); | |
| 87 | mergedNumber = merged.number; | |
| 88 | headOid = merged.headOid; | |
| 89 | recordDecision(app.db, { | |
| 90 | kind: "land", | |
| 91 | repoId: repo.id, | |
| 92 | issueId: merged.id, | |
| 93 | state: "x", | |
| 94 | answer: "yes", | |
| 95 | p: 1, | |
| 96 | by: "alice", | |
| 97 | ms: 1, | |
| 98 | }); | |
| 99 | mergeOid = must(await mergePull(app, alice, repo, merged, "merge")).mergeOid; | |
| 100 | ||
| 101 | // A green open pull: an ask. | |
| 102 | const open = await pull("feat", "Green change"); | |
| 103 | openNumber = open.number; | |
| 104 | const check = createRun(app.db, { | |
| 105 | repoId: repo.id, | |
| 106 | kind: "check", | |
| 107 | issueId: open.id, | |
| 108 | env: `pull-${open.number}`, | |
| 109 | commitOid: open.headOid, | |
| 110 | reason: "pull", | |
| 111 | }); | |
| 112 | claimRun(app.db, check.id); | |
| 113 | finishRun(app.db, check.id, { state: "ok", summary: "green" }); | |
| 114 | ||
| 115 | // A run. `run.finished`, `look.taken` and `app.serving` are written by the | |
| 116 | // runner, the look service and the release, which a test cannot start, so | |
| 117 | // these three events are appended with the payloads those write. | |
| 118 | const run = createRun(app.db, { repoId: repo.id, kind: "check", summary: "worked" }); | |
| 119 | claimRun(app.db, run.id); | |
| 120 | finishRun(app.db, run.id, { state: "ok", summary: "worked", costUsd: 0 }); | |
| 121 | runId = run.id; | |
| 122 | app.bus.append({ | |
| 123 | kind: "run.finished", | |
| 124 | repoId: repo.id, | |
| 125 | actorId: alice.id, | |
| 126 | payload: { kind: "check", state: "ok", summary: "worked", runId }, | |
| 127 | }); | |
| 128 | ||
| 129 | // A look of the merged pull. | |
| 130 | const record = must(insertApp(app.db, { repoId: repo.id, slug: "garden", access: "public" })); | |
| 131 | const env = `pull-${mergedNumber}`; | |
| 132 | const path = `looks/${record.id}/${env}/${headOid}-thumb.png`; | |
| 133 | mkdirSync(join(root, "looks", record.id, env), { recursive: true }); | |
| 134 | writeFileSync(join(root, path), "png"); | |
| 135 | recordLook(app.db, { appId: record.id, env, commitOid: headOid, kind: "thumb", path, takenAs: "nobody" }); | |
| 136 | const phone = `looks/${record.id}/${env}/${headOid}-phone.png`; | |
| 137 | writeFileSync(join(root, phone), "png"); | |
| 138 | recordLook(app.db, { appId: record.id, env, commitOid: headOid, kind: "phone", path: phone, takenAs: "nobody" }); | |
| 139 | app.bus.append({ kind: "look.taken", repoId: repo.id, payload: { env, commitOid: headOid } }); | |
| 140 | ||
| 141 | // A release. | |
| 142 | app.bus.append({ | |
| 143 | kind: "app.serving", | |
| 144 | repoId: repo.id, | |
| 145 | payload: { version: "0.1.1", commitOid: mergeOid, pulls: [{ title: "Merged change" }] }, | |
| 146 | }); | |
| 147 | ||
| 148 | // A private repository with a run and a pull: none of it is a guest's. | |
| 149 | const secret = must(await createRepo(app, alice, { name: "secret", visibility: "private" })); | |
| 150 | must( | |
| 151 | await commitToBranch(app, alice, secret, { | |
| 152 | message: "seed", | |
| 153 | files: [{ path: "README.md", content: "# secret\n" }], | |
| 154 | }), | |
| 155 | ); | |
| 156 | must( | |
| 157 | await commitToBranch(app, alice, secret, { | |
| 158 | branch: "hidden", | |
| 159 | base: "main", | |
| 160 | message: "Hidden change", | |
| 161 | files: [{ path: "h.md", content: "h\n" }], | |
| 162 | }), | |
| 163 | ); | |
| 164 | must( | |
| 165 | await openPull(app, alice, secret, { | |
| 166 | title: "Hidden change", | |
| 167 | body: "", | |
| 168 | baseBranch: "main", | |
| 169 | headBranch: "hidden", | |
| 170 | }), | |
| 171 | ); | |
| 172 | app.bus.append({ | |
| 173 | kind: "run.finished", | |
| 174 | repoId: secret.id, | |
| 175 | actorId: alice.id, | |
| 176 | payload: { kind: "check", state: "ok", summary: "hidden", runId: "hidden-run" }, | |
| 177 | }); | |
| 178 | token = createToken(app.db, alice.id, "ro", "ro").token; | |
| 179 | }, 30_000); | |
| 180 | ||
| 181 | afterAll(async () => { | |
| 182 | await servers.stop(); | |
| 183 | app.close(); | |
| 184 | rmSync(root, { recursive: true, force: true }); | |
| 185 | }); | |
| 186 | ||
| 187 | const get = (path: string, as: string | null = cookie) => | |
| 188 | fetch(`${servers.url}${path}`, { headers: as === null ? {} : { cookie: as }, redirect: "manual" }); | |
| 189 | const guest = (path: string) => get(path, null); | |
| 190 | ||
| 191 | interface Item { | |
| 192 | id: string; | |
| 193 | app: string; | |
| 194 | kind: string; | |
| 195 | link: string; | |
| 196 | what: string; | |
| 197 | } | |
| 198 | const feed = async (): Promise<Item[]> => { | |
| 199 | const res = await get("/api/v1/feed?limit=50"); | |
| 200 | expect(res.status).toBe(200); | |
| 201 | return ((await res.json()) as { items: Item[] }).items; | |
| 202 | }; | |
| 203 | ||
| 204 | const hrefs = (html: string): string[] => | |
| 205 | [...html.matchAll(/href="([^"]+)"/g)].map((m) => m[1]!.replaceAll("&", "&")); | |
| 206 | ||
| 207 | /** Kinds this file proves. The feed builds milestone, map, cost, tell, reply and choose too: they need | |
| 208 | * a watched milestone, a blocked issue, an agent run over $1 and so on, and are not covered here. */ | |
| 209 | const COVERED = ["pull", "run", "look", "release", "ask", "decision"]; | |
| 210 | const NOT_COVERED = ["milestone", "map", "cost", "tell", "reply", "choose"]; | |
| 211 | ||
| 212 | let home: string[] | undefined; | |
| 213 | const homeHrefs = async (): Promise<string[]> => (home ??= hrefs(await (await get("/")).text())); | |
| 214 | ||
| 215 | /** | |
| 216 | * Tap 1: the post's `link` is an href on the home page a person sees, and it | |
| 217 | * answers. Tap 2: that page holds a link matching `depth`, which answers too. | |
| 218 | * A guest gets the same two answers for a public repository. | |
| 219 | */ | |
| 220 | async function twoTaps(item: Item, depth: RegExp): Promise<string> { | |
| 221 | expect(await homeHrefs(), `home carries a card href for ${item.kind} ${item.link}`).toContain(item.link); | |
| 222 | const first = await get(item.link); | |
| 223 | expect(first.status).toBe(200); | |
| 224 | const second = hrefs(await first.text()).find((h) => depth.test(h)); | |
| 225 | expect(second, `${item.kind} ${item.link} links to ${depth}`).toBeDefined(); | |
| 226 | expect((await get(second!)).status).toBe(200); | |
| 227 | expect((await guest(item.link)).status).toBe(200); | |
| 228 | expect((await guest(second!)).status).toBe(200); | |
| 229 | return second!; | |
| 230 | } | |
| 231 | ||
| 232 | const one = (items: Item[], kind: string, says: RegExp): Item => { | |
| 233 | const found = items.find((i) => i.kind === kind && says.test(i.what)); | |
| 234 | expect(found, `a ${kind} post that says ${says}`).toBeDefined(); | |
| 235 | return found!; | |
| 236 | }; | |
| 237 | ||
| 238 | test("the feed carries a post of every covered kind and no kind unaccounted for", async () => { | |
| 239 | expect((await get("/")).status).toBe(200); | |
| 240 | const kinds = new Set((await feed()).map((i) => i.kind)); | |
| 241 | for (const k of COVERED) expect(kinds.has(k), k).toBe(true); | |
| 242 | for (const k of kinds) expect([...COVERED, ...NOT_COVERED], `kind ${k} needs a two-taps test`).toContain(k); | |
| 243 | }); | |
| 244 | ||
| 245 | test("a pull reaches its diff in two taps", async () => { | |
| 246 | const item = one(await feed(), "pull", /merged a pull request/); | |
| 247 | const to = await twoTaps(item, new RegExp(`^${base}/pulls/${mergedNumber}/files`)); | |
| 248 | expect(to).toContain(`/pulls/${mergedNumber}/files`); | |
| 249 | }); | |
| 250 | ||
| 251 | test("a run's page is its log: one tap, not two", async () => { | |
| 252 | const item = one(await feed(), "run", /check run finished/); | |
| 253 | expect(await homeHrefs()).toContain(item.link); | |
| 254 | expect(item.link).toBe(`${base}/runs/${runId}`); | |
| 255 | const page = await get(item.link); | |
| 256 | expect(page.status).toBe(200); | |
| 257 | expect(await page.text()).toContain('id="run-log"'); | |
| 258 | expect((await get(`/api/v1/repos/alice/forge/runs/${runId}/log`)).status).toBe(200); | |
| 259 | expect((await guest(item.link)).status).toBe(200); | |
| 260 | }); | |
| 261 | ||
| 262 | test("a look reaches its picture in two taps", async () => { | |
| 263 | const item = one(await feed(), "look", /a new picture of #/); | |
| 264 | await twoTaps(item, new RegExp(`^${base}/looks/pull-${mergedNumber}/${headOid}/`)); | |
| 265 | }); | |
| 266 | ||
| 267 | test("a release reaches its commit tree in two taps", async () => { | |
| 268 | const item = one(await feed(), "release", /0\.1\.1 is serving/); | |
| 269 | expect(item.link).toBe(`${base}/commit/${mergeOid}`); | |
| 270 | await twoTaps(item, new RegExp(`^${base}/tree/${mergeOid}`)); | |
| 271 | }); | |
| 272 | ||
| 273 | test("an ask reaches the thing asked about, and from it the diff", async () => { | |
| 274 | const item = one(await feed(), "ask", /./); | |
| 275 | expect(item.link).toBe(`${base}/pulls/${openNumber}`); | |
| 276 | await twoTaps(item, new RegExp(`^${base}/pulls/${openNumber}/files`)); | |
| 277 | }); | |
| 278 | ||
| 279 | test("a decision reaches the pull it was about, and from it the diff (it was the list of pulls, three taps)", async () => { | |
| 280 | const item = one(await feed(), "decision", /chose/); | |
| 281 | await twoTaps(item, new RegExp(`^${base}/pulls/${mergedNumber}/files`)); | |
| 282 | }); | |
| 283 | ||
| 284 | test("the MCP feed tool carries the same posts with the same links", async () => { | |
| 285 | const api = await feed(); | |
| 286 | const mcp = await mcpTool(servers.url, token, "feed", { limit: 50 }); | |
| 287 | expect(mcp.error).toBeUndefined(); | |
| 288 | const seen = new Map<string, string>((mcp.value.items as Item[]).map((i) => [i.id, i.link])); | |
| 289 | for (const i of api) expect(seen.get(i.id), `${i.kind} ${i.id} over MCP`).toBe(i.link); | |
| 290 | for (const k of COVERED) expect((mcp.value.items as Item[]).some((i) => i.kind === k), k).toBe(true); | |
| 291 | }); | |
| 292 | ||
| 293 | test("a guest gets no private post, and the private repository answers 404", async () => { | |
| 294 | const items = await feed(); | |
| 295 | expect(items.some((i) => i.app === "alice/secret")).toBe(true); | |
| 296 | expect((await guest("/api/v1/feed")).status).toBe(401); | |
| 297 | const page = await (await guest("/")).text(); | |
| 298 | expect(page).not.toContain("alice/secret"); | |
| 299 | expect(page).not.toContain("Hidden change"); | |
| 300 | for (const i of items.filter((x) => x.app === "alice/secret")) { | |
| 301 | expect(i.link.startsWith("/alice/secret"), i.link).toBe(true); | |
| 302 | expect((await guest(i.link)).status).toBe(404); | |
| 303 | } | |
| 304 | expect((await guest("/alice/secret/pulls/1/files")).status).toBe(404); | |
| 305 | }); |
new above
offline