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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7754-one-test-proves-every-kind-of-feed-post → main 2 commits fork

  • Merged. scout/7754-one-test-proves-every-kind-of-feed-post was merged into main as 0230da5.

Review

Sign in to review this change.
2 files changed +313 −2
src/feed/items.ts +8 −2
@@ -13,7 +13,7 @@ import { listDecidedIn } from "../core/decisions.ts";
1313 import { audienceFilter } from "../core/event_audience.ts";
1414 import { listEventsVisibleTo, type ForgeEvent } from "../core/events.ts";
1515 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";
1717 import {
1818 listDetachedMirrors,
1919 listOrigins,
@@ -423,9 +423,13 @@ export function candidatesFor(
423423 out.push(...frozenCandidates(db, viewer, now));
424424 out.push(...chooseCandidates(db, writable));
425425 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])));
426429 for (const d of decided) {
427430 const repo = repos.get(d.repoId);
428431 if (!repo) continue;
432 const subject = d.issueId === null ? undefined : about.get(d.issueId);
429433 out.push({
430434 id: `decision:${d.id}`,
431435 kind: "decision",
@@ -434,7 +438,9 @@ export function candidatesFor(
434438 actorId: null,
435439 actor: null,
436440 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}`,
438444 what: clip(`${d.by} chose “${clip(d.answer, 40)}” on a ${d.kind} and it ended ${d.outcome}`),
439445 urgency: 0,
440446 });
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("&amp;", "&"));
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs