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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/97-the-vocabulary-table-covers-the-studio-s → main 2 commits fork
2 files
changed +277
−39
src/web/studio/lists.ts +1 −1
| @@ -290,7 +290,7 @@ export async function studioChanges( | ||
| 290 | 290 | </div> |
| 291 | 291 | <p class="muted small mt"> |
| 292 | 292 | <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. | |
| 294 | 294 | </p> |
| 295 | 295 | ${liveMount({ repo: base })}`; |
| 296 | 296 |
test/studio-words.test.ts +276 −38
| @@ -6,13 +6,19 @@ | ||
| 6 | 6 | * test that nothing else says the git word instead. |
| 7 | 7 | * |
| 8 | 8 | * 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. | |
| 11 | 11 | * 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. | |
| 16 | 22 | */ |
| 17 | 23 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 18 | 24 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -109,6 +115,31 @@ const pageContent = (html: string): string => | ||
| 109 | 115 | * file diff cost the studio pull page before it had its own render. */ |
| 110 | 116 | const MAX_BODY_BYTES = 300_000; |
| 111 | 117 | |
| 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 | ||
| 112 | 143 | describe("dev's vocabulary never reaches a studio page", () => { |
| 113 | 144 | let dataDir: string; |
| 114 | 145 | let app: App; |
| @@ -118,11 +149,23 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 118 | 149 | let cookie: string; |
| 119 | 150 | let record: AppRecord; |
| 120 | 151 | let tellNumber: number; |
| 152 | let tellTitle: string; | |
| 153 | let untouchedNumber: number; | |
| 154 | let untouchedTitle: string; | |
| 121 | 155 | let runId: string; |
| 156 | let stoppedRunId: string; | |
| 157 | let workingRunId: string; | |
| 122 | 158 | let openPullNumber: number; |
| 159 | let openPullTitle: string; | |
| 160 | let secondChangeNumber: number; | |
| 161 | let secondChangeTitle: string; | |
| 123 | 162 | let mergedPullNumber: number; |
| 124 | 163 | let openBranch: string; |
| 125 | 164 | 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[]; | |
| 126 | 169 | |
| 127 | 170 | interface EnvSlot { |
| 128 | 171 | readonly app: AppRecord; |
| @@ -206,9 +249,22 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 206 | 249 | expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true); |
| 207 | 250 | const scoutUser = getUserByName(app.db, "scout")!; |
| 208 | 251 | |
| 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 | ||
| 209 | 264 | // A tell, assigned, with a run — the studio's *tell* and *working*. |
| 265 | tellTitle = "Make the export button work"; | |
| 210 | 266 | const told = openIssue(app, a.value, repo, { |
| 211 | title: "Make the export button work", | |
| 267 | title: tellTitle, | |
| 212 | 268 | body: "It does nothing right now.", |
| 213 | 269 | }); |
| 214 | 270 | if (!told.ok) throw new Error(String(told.error)); |
| @@ -234,6 +290,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 234 | 290 | |
| 235 | 291 | // An open pull with a green check and a preview — the studio's *look*. |
| 236 | 292 | openBranch = "add-export"; |
| 293 | branches.push(openBranch); | |
| 237 | 294 | const openBranchMade = await commitToBranch(app, a.value, repo, { |
| 238 | 295 | branch: openBranch, |
| 239 | 296 | base: repo.defaultBranch, |
| @@ -241,8 +298,9 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 241 | 298 | files: [{ path: "export.ts", content: "export {};\n" }], |
| 242 | 299 | }); |
| 243 | 300 | if (!openBranchMade.ok) throw new Error(JSON.stringify(openBranchMade.error)); |
| 301 | openPullTitle = "Add the export button"; | |
| 244 | 302 | const opened = await openPull(app, scoutUser, repo, { |
| 245 | title: "Add the export button", | |
| 303 | title: openPullTitle, | |
| 246 | 304 | body: `Closes #${tellNumber}.`, |
| 247 | 305 | baseBranch: repo.defaultBranch, |
| 248 | 306 | headBranch: openBranch, |
| @@ -269,6 +327,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 269 | 327 | |
| 270 | 328 | // A merged change, production caught up to it — the studio's *shipped*. |
| 271 | 329 | mergedBranch = "faster-notes"; |
| 330 | branches.push(mergedBranch); | |
| 272 | 331 | const mergedBranchMade = await commitToBranch(app, a.value, repo, { |
| 273 | 332 | branch: mergedBranch, |
| 274 | 333 | base: repo.defaultBranch, |
| @@ -317,6 +376,160 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 317 | 376 | since: new Date().toISOString(), |
| 318 | 377 | manifest: { tty: null }, |
| 319 | 378 | }); |
| 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 | ]; | |
| 320 | 533 | }); |
| 321 | 534 | |
| 322 | 535 | afterAll(async () => { |
| @@ -325,19 +538,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 325 | 538 | await rm(dataDir, { recursive: true, force: true }); |
| 326 | 539 | }); |
| 327 | 540 | |
| 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 () => { | |
| 341 | 542 | for (const p of pages) { |
| 342 | 543 | const res = await fetch(`${base}${p.path}`, { |
| 343 | 544 | headers: { cookie: `${cookie}; forge_mode=studio` }, |
| @@ -372,17 +573,29 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 372 | 573 | `${p.word} (${p.path}) said dev's word ${pattern} — ${because}`, |
| 373 | 574 | ).not.toMatch(pattern); |
| 374 | 575 | } |
| 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 | } | |
| 386 | 599 | } |
| 387 | 600 | |
| 388 | 601 | // 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", () => { | ||
| 400 | 613 | }); |
| 401 | 614 | |
| 402 | 615 | 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)}`, { | |
| 405 | 620 | headers: { cookie }, |
| 406 | 621 | 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); | |
| 411 | 649 | }); |
| 412 | 650 | }); |
new above
offline