Public: anyone can read this; the crew can change it.

The vocabulary table covers the studio's lists, and stays ahead of the next page #152

Merged opened this

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

Merged

mason/forge:mason/97-the-vocabulary-table-covers-the-studio-s → main 2 commits fork

  • Merged. mason/97-the-vocabulary-table-covers-the-studio-s was merged into main as a79adf0.

Review

Sign in to review this change.
2 files changed +277 −39
src/web/studio/lists.ts +1 −1
@@ -290,7 +290,7 @@ export async function studioChanges(
290290 </div>
291291 <p class="muted small mt">
292292 <a href="${base}/pulls?mode=dev">${icon("code")}See dev's list</a> —
293 branch names and the diff, dev's own way.
293 where each change lives and its diff, dev's own way.
294294 </p>
295295 ${liveMount({ repo: base })}`;
296296
test/studio-words.test.ts +276 −38
@@ -6,13 +6,19 @@
66 * test that nothing else says the git word instead.
77 *
88 * To register a new studio page for this check: add one row to `pages`
9 * (built in the test below, once the fixture that gives it something real
10 * to show exists) with the studio word it speaks and the path to fetch.
9 * (built at the end of `beforeAll`, once the fixture has given it something
10 * real to show) with the studio word it speaks and the path to fetch.
1111 * Nothing else in this file changes — the same forbidden-vocabulary,
12 * chrome, header and size assertions walk it too. A route not yet wired
13 * into the router (`http/server.ts`) answers 404 and is skipped rather
14 * than failed, so a row for a page still being built does not block this
15 * file from landing first.
12 * chrome, header and size assertions walk it, and so does the `?mode=dev`
13 * test below, which is why a row carries the dev words the same path still
14 * says in dev's own face: the table is the one place a page is added, and
15 * the proof it is not vacuous comes with it. Whatever a row needs to be
16 * worth fetching — a second tell, a second change, a run that stopped, a
17 * run still going — the one `beforeAll` seeds; there is no second seeding
18 * helper, and no row is worth a page of setup of its own. A route not
19 * yet wired into the router (`http/server.ts`) answers 404 and is skipped
20 * rather than failed, so a row for a page still being built does not block
21 * this file from landing first.
1622 */
1723 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1824 import { mkdtemp, rm } from "node:fs/promises";
@@ -109,6 +115,31 @@ const pageContent = (html: string): string =>
109115 * file diff cost the studio pull page before it had its own render. */
110116 const MAX_BODY_BYTES = 300_000;
111117
118 /** One studio page this file walks: what to fetch, and what proves the
119 * fetch was worth making. `shows`/`hides` are literal HTML, checked against
120 * the whole response — they are about which rows landed on the page (a
121 * `?word=` filter or a `cursor` page that narrowed nothing proves nothing),
122 * never about vocabulary. `dev` is the other half: the words dev's own face
123 * still says at the same path with `?mode=dev`, so a clean studio page can
124 * never be clean merely because it rendered nothing. */
125 interface StudioPage {
126 /** The studio word this page speaks — the label in a failure message. */
127 readonly word: string;
128 /** The path to fetch, `?word=` or `cursor=` and all. */
129 readonly path: string;
130 /** Literal HTML the studio's page must carry. */
131 readonly shows?: readonly string[];
132 /** Literal HTML it must not carry. */
133 readonly hides?: readonly string[];
134 /** Dev's own words the same path still says at `?mode=dev`, when dev has
135 * a page of its own there. */
136 readonly dev?: readonly string[];
137 }
138
139 /** `?mode=dev` added to a path that may already carry a query. */
140 const asDev = (path: string): string =>
141 `${path}${path.includes("?") ? "&" : "?"}mode=dev`;
142
112143 describe("dev's vocabulary never reaches a studio page", () => {
113144 let dataDir: string;
114145 let app: App;
@@ -118,11 +149,23 @@ describe("dev's vocabulary never reaches a studio page", () => {
118149 let cookie: string;
119150 let record: AppRecord;
120151 let tellNumber: number;
152 let tellTitle: string;
153 let untouchedNumber: number;
154 let untouchedTitle: string;
121155 let runId: string;
156 let stoppedRunId: string;
157 let workingRunId: string;
122158 let openPullNumber: number;
159 let openPullTitle: string;
160 let secondChangeNumber: number;
161 let secondChangeTitle: string;
123162 let mergedPullNumber: number;
124163 let openBranch: string;
125164 let mergedBranch: string;
165 /** Every branch this fixture made. No studio page names one of them. */
166 const branches: string[] = [];
167 /** The table every test below walks. Filled at the end of `beforeAll`. */
168 let pages: readonly StudioPage[];
126169
127170 interface EnvSlot {
128171 readonly app: AppRecord;
@@ -206,9 +249,22 @@ describe("dev's vocabulary never reaches a studio page", () => {
206249 expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true);
207250 const scoutUser = getUserByName(app.db, "scout")!;
208251
252 // A tell nobody has picked up — the one row the issue list's own *told*
253 // filter has to keep, since the tell below is claimed by a run and a
254 // change before this fixture is done. Opened first, so its number is the
255 // lower one and it is also what a `cursor` page of the list lands on.
256 untouchedTitle = "List test: nobody has picked this up";
257 const untouched = openIssue(app, a.value, repo, {
258 title: untouchedTitle,
259 body: "Still only said.",
260 });
261 if (!untouched.ok) throw new Error(String(untouched.error));
262 untouchedNumber = untouched.value.number;
263
209264 // A tell, assigned, with a run — the studio's *tell* and *working*.
265 tellTitle = "Make the export button work";
210266 const told = openIssue(app, a.value, repo, {
211 title: "Make the export button work",
267 title: tellTitle,
212268 body: "It does nothing right now.",
213269 });
214270 if (!told.ok) throw new Error(String(told.error));
@@ -234,6 +290,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
234290
235291 // An open pull with a green check and a preview — the studio's *look*.
236292 openBranch = "add-export";
293 branches.push(openBranch);
237294 const openBranchMade = await commitToBranch(app, a.value, repo, {
238295 branch: openBranch,
239296 base: repo.defaultBranch,
@@ -241,8 +298,9 @@ describe("dev's vocabulary never reaches a studio page", () => {
241298 files: [{ path: "export.ts", content: "export {};\n" }],
242299 });
243300 if (!openBranchMade.ok) throw new Error(JSON.stringify(openBranchMade.error));
301 openPullTitle = "Add the export button";
244302 const opened = await openPull(app, scoutUser, repo, {
245 title: "Add the export button",
303 title: openPullTitle,
246304 body: `Closes #${tellNumber}.`,
247305 baseBranch: repo.defaultBranch,
248306 headBranch: openBranch,
@@ -269,6 +327,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
269327
270328 // A merged change, production caught up to it — the studio's *shipped*.
271329 mergedBranch = "faster-notes";
330 branches.push(mergedBranch);
272331 const mergedBranchMade = await commitToBranch(app, a.value, repo, {
273332 branch: mergedBranch,
274333 base: repo.defaultBranch,
@@ -317,6 +376,160 @@ describe("dev's vocabulary never reaches a studio page", () => {
317376 since: new Date().toISOString(),
318377 manifest: { tty: null },
319378 });
379
380 // A second open change, nothing checked yet, and a run that stopped:
381 // the rows a `?word=` filter and a `cursor` page of the two lists have
382 // to leave behind for their narrowing to mean anything. Opened last, so
383 // each is the newest of its list and the cursor that skips it is the
384 // one a reader's own "next" link would carry.
385 const secondBranch = "tidy-the-shelf";
386 branches.push(secondBranch);
387 const secondBranchMade = await commitToBranch(app, a.value, repo, {
388 branch: secondBranch,
389 base: repo.defaultBranch,
390 message: "Tidy",
391 files: [{ path: "TIDY.md", content: "tidy\n" }],
392 });
393 if (!secondBranchMade.ok)
394 throw new Error(JSON.stringify(secondBranchMade.error));
395 secondChangeTitle = "Tidy the shelf up";
396 const second = await openPull(app, a.value, repo, {
397 title: secondChangeTitle,
398 body: "",
399 baseBranch: repo.defaultBranch,
400 headBranch: secondBranch,
401 });
402 if (!second.ok) throw new Error(String(second.error));
403 secondChangeNumber = second.value.number;
404
405 const stopped = createRun(app.db, {
406 repoId: repo.id,
407 kind: "agent",
408 actorId: scoutUser.id,
409 reason: "assigned",
410 });
411 claimRun(app.db, stopped.id);
412 finishRun(app.db, stopped.id, {
413 state: "failed",
414 summary: "stopped before it finished",
415 });
416 stoppedRunId = stopped.id;
417
418 // A run still going, newest of the three: the row the `cursor` page of
419 // the run list has to drop. Without it a cursor row would pass on the
420 // word filter's work — the stopped run above is already off the bare
421 // list — and prove nothing about paging at all.
422 const working = createRun(app.db, {
423 repoId: repo.id,
424 kind: "agent",
425 actorId: scoutUser.id,
426 reason: "assigned",
427 });
428 claimRun(app.db, working.id);
429 workingRunId = working.id;
430
431 const repoPath = "/alice/vocab";
432 // Every studio page, one row each. A new studio page goes here — the
433 // path it answers at, the word it speaks, and dev's own words at the
434 // same path — and nothing else in this file changes.
435 pages = [
436 { word: "home", path: "/" },
437 { word: "app", path: repoPath },
438 {
439 word: "tell",
440 path: `${repoPath}/issues/${tellNumber}`,
441 dev: ["<h2>Labels</h2>"],
442 },
443 { word: "working", path: `${repoPath}/runs/${runId}` },
444 {
445 word: "look",
446 path: `${repoPath}/pulls/${openPullNumber}`,
447 dev: ["diff-table", openBranch],
448 },
449 {
450 word: "shipped",
451 path: `${repoPath}/pulls/${mergedPullNumber}`,
452 dev: ["diff-table"],
453 },
454 // A word from the app's own description, not its name — the app's own
455 // row coming back for it proves the search reaches the description,
456 // not just a name match.
457 { word: "search", path: "/search?q=catch", shows: [`href="${repoPath}"`] },
458 // Everything told, every change, every run (#95): the three studio
459 // lists, each as a person meets it — the bare list, the word filter
460 // they click, and the `cursor` page their "next" link hands them.
461 {
462 word: "tells",
463 path: `${repoPath}/issues`,
464 shows: [untouchedTitle, tellTitle],
465 dev: [`>#${tellNumber} opened`],
466 },
467 {
468 word: "tells (told)",
469 path: `${repoPath}/issues?word=told`,
470 shows: [untouchedTitle],
471 hides: [tellTitle],
472 dev: [`>#${tellNumber} opened`],
473 },
474 {
475 word: "tells (next page)",
476 path: `${repoPath}/issues?cursor=${tellNumber}`,
477 shows: [untouchedTitle],
478 hides: [tellTitle],
479 dev: [`>#${untouchedNumber} opened`],
480 },
481 {
482 word: "changes",
483 path: `${repoPath}/pulls`,
484 shows: [openPullTitle, secondChangeTitle],
485 dev: [openBranch],
486 },
487 {
488 word: "changes (look)",
489 path: `${repoPath}/pulls?word=look`,
490 shows: [openPullTitle],
491 hides: [secondChangeTitle],
492 dev: [openBranch],
493 },
494 {
495 word: "changes (next page)",
496 path: `${repoPath}/pulls?cursor=${secondChangeNumber}`,
497 shows: [openPullTitle],
498 hides: [secondChangeTitle],
499 dev: [openBranch],
500 },
501 {
502 word: "runs",
503 path: `${repoPath}/runs`,
504 shows: [
505 `href="${repoPath}/runs/${runId}"`,
506 `href="${repoPath}/runs/${workingRunId}"`,
507 ],
508 // The stopped run is not open and moving, so the bare list leaves it
509 // out — the filter below is where it shows up.
510 hides: [`href="${repoPath}/runs/${stoppedRunId}"`],
511 dev: ["Done"],
512 },
513 {
514 word: "runs (stopped)",
515 path: `${repoPath}/runs?word=stopped`,
516 shows: [`href="${repoPath}/runs/${stoppedRunId}"`],
517 hides: [
518 `href="${repoPath}/runs/${runId}"`,
519 `href="${repoPath}/runs/${workingRunId}"`,
520 ],
521 dev: ["Done"],
522 },
523 {
524 word: "runs (next page)",
525 // Past the newest run, which is one the bare list would otherwise
526 // show — so only the cursor can be what dropped it.
527 path: `${repoPath}/runs?cursor=${workingRunId}`,
528 shows: [`href="${repoPath}/runs/${runId}"`],
529 hides: [`href="${repoPath}/runs/${workingRunId}"`],
530 dev: ["Done"],
531 },
532 ];
320533 });
321534
322535 afterAll(async () => {
@@ -325,19 +538,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
325538 await rm(dataDir, { recursive: true, force: true });
326539 });
327540
328 test("home, app, tell, working, look and shipped: no dev word, the studio chrome, the security floor, and a page a diff would never fit in", async () => {
329 const pages: { word: string; path: string }[] = [
330 { word: "home", path: "/" },
331 { word: "app", path: "/alice/vocab" },
332 { word: "tell", path: `/alice/vocab/issues/${tellNumber}` },
333 { word: "working", path: `/alice/vocab/runs/${runId}` },
334 { word: "look", path: `/alice/vocab/pulls/${openPullNumber}` },
335 { word: "shipped", path: `/alice/vocab/pulls/${mergedPullNumber}` },
336 // A word from the app's own description, not its name — proving the
337 // search reaches the description, not just a name match.
338 { word: "search", path: "/search?q=catch" },
339 ];
340
541 test("every studio page — home, app, tell, working, look, shipped, search and the three lists: no dev word, the studio chrome, the security floor, and a page a diff would never fit in", async () => {
341542 for (const p of pages) {
342543 const res = await fetch(`${base}${p.path}`, {
343544 headers: { cookie: `${cookie}; forge_mode=studio` },
@@ -372,17 +573,29 @@ describe("dev's vocabulary never reaches a studio page", () => {
372573 `${p.word} (${p.path}) said dev's word ${pattern} — ${because}`,
373574 ).not.toMatch(pattern);
374575 }
375 expect(
376 visible,
377 `${p.word} (${p.path}) named the open branch "${openBranch}"`,
378 ).not.toContain(openBranch);
379 expect(
380 visible,
381 `${p.word} (${p.path}) named the merged branch "${mergedBranch}"`,
382 ).not.toContain(mergedBranch);
383 // The search itself: "catch" is only in the app's description, never
384 // its name, so finding it here proves the description is reached.
385 if (p.word === "search") expect(visible).toContain("vocab");
576 for (const branch of branches) {
577 expect(
578 visible,
579 `${p.word} (${p.path}) named the branch "${branch}"`,
580 ).not.toContain(branch);
581 }
582
583 // Which rows landed. A page that rendered nothing at all would pass
584 // every vocabulary assertion above, and so would a `?word=` filter or
585 // a `cursor` that narrowed nothing — these two are what say the page
586 // showed the rows it was asked for and only those.
587 for (const shown of p.shows ?? []) {
588 expect(
589 html,
590 `${p.word} (${p.path}) is missing "${shown}" — the page showed less than it was asked for`,
591 ).toContain(shown);
592 }
593 for (const hidden of p.hides ?? []) {
594 expect(
595 html,
596 `${p.word} (${p.path}) still carries "${hidden}" — the filter or the cursor narrowed nothing`,
597 ).not.toContain(hidden);
598 }
386599 }
387600
388601 // A run id is a key, not a headline: it rides hrefs (the dev link, the
@@ -400,13 +613,38 @@ describe("dev's vocabulary never reaches a studio page", () => {
400613 });
401614
402615 test("?mode=dev on the same routes still carries dev's own words — the assertions above are real, not vacuous", async () => {
403 const dev = await (
404 await fetch(`${base}/alice/vocab/pulls/${openPullNumber}?mode=dev`, {
616 let proved = 0;
617 for (const p of pages) {
618 if (p.dev === undefined) continue;
619 const res = await fetch(`${base}${asDev(p.path)}`, {
405620 headers: { cookie },
406621 redirect: "manual",
407 })
408 ).text();
409 expect(dev).toContain("diff-table");
410 expect(dev).toContain(openBranch);
622 });
623 if (res.status === 404) continue;
624 expect(res.status, `${p.word} (${asDev(p.path)}) did not answer`).toBe(
625 200,
626 );
627 // Dev's own words, read where the studio's are read: inside `<main>`,
628 // so the shared client script's own literals can never stand in for a
629 // page that said nothing.
630 const visible = pageContent(await res.text());
631 for (const word of p.dev) {
632 expect(
633 visible,
634 `${p.word} (${asDev(p.path)}) lost dev's word "${word}" — the studio row for this path proves nothing`,
635 ).toContain(word);
636 }
637 // And it is dev's page, not the studio's: the studio word badge is the
638 // one thing every studio render puts on a row or a header.
639 expect(
640 visible,
641 `${p.word} (${asDev(p.path)}) rendered the studio's own word badge`,
642 ).not.toContain('data-word="');
643 proved += 1;
644 }
645 // Every row that named dev words was actually reached: the 404 skip
646 // above is for a page still being built, never a way for a row to stop
647 // proving itself once it exists.
648 expect(proved).toBe(pages.filter((p) => p.dev !== undefined).length);
411649 });
412650 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs