Public: anyone can read this; the crew can change it.
A test that catches dev's words on a studio page, for every studio page there will be #84
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/62-a-test-that-catches-dev-s-words-on-a-stu → main 1 commit fork
3 files
changed +407
−19
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun test --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/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/repos.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/web.test.ts test/git-trust.test.ts", | |
| 9 | "run": "bun x tsc --noEmit && bun test --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/repos.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/web.test.ts test/git-trust.test.ts", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/web/pages/studio.ts +2 −18
| @@ -50,12 +50,7 @@ import { | ||
| 50 | 50 | pullForBranch, |
| 51 | 51 | type Pull, |
| 52 | 52 | } from "../../core/pulls.ts"; |
| 53 | import { | |
| 54 | canWrite, | |
| 55 | getRepoById, | |
| 56 | listReposVisibleTo, | |
| 57 | type Repo, | |
| 58 | } from "../../core/repos.ts"; | |
| 53 | import { canWrite, listReposVisibleTo, type Repo } from "../../core/repos.ts"; | |
| 59 | 54 | import { listReviews } from "../../core/reviews.ts"; |
| 60 | 55 | import { |
| 61 | 56 | activeRunFor, |
| @@ -1507,13 +1502,6 @@ export async function studioPull( | ||
| 1507 | 1502 | (d) => d.pull?.id === pull.id, |
| 1508 | 1503 | ); |
| 1509 | 1504 | |
| 1510 | const headRepo = pull.crossRepo | |
| 1511 | ? getRepoById(db, pull.headRepoId) | |
| 1512 | : undefined; | |
| 1513 | const headLabel = headRepo | |
| 1514 | ? `${headRepo.ownerName}/${headRepo.name}:${pull.headBranch}` | |
| 1515 | : pull.headBranch; | |
| 1516 | ||
| 1517 | 1505 | const head = html`<div class="issue-head"> |
| 1518 | 1506 | <h1>${pull.title} <span class="number">#${pull.number}</span></h1> |
| 1519 | 1507 | <div class="issue-meta"> |
| @@ -1531,7 +1519,6 @@ export async function studioPull( | ||
| 1531 | 1519 | >${icon("external")}view on origin</a |
| 1532 | 1520 | >`, |
| 1533 | 1521 | )} |
| 1534 | <span class="mono">${headLabel} → ${pull.baseBranch}</span> | |
| 1535 | 1522 | </div> |
| 1536 | 1523 | </div>`; |
| 1537 | 1524 | |
| @@ -1560,10 +1547,7 @@ export async function studioPull( | ||
| 1560 | 1547 | ), |
| 1561 | 1548 | ); |
| 1562 | 1549 | |
| 1563 | const talk = conversation(ctx, repo, pull, { | |
| 1564 | openVerb: html`wants to merge <span class="mono">${headLabel}</span> into | |
| 1565 | <span class="mono">${pull.baseBranch}</span> ·`, | |
| 1566 | }); | |
| 1550 | const talk = conversation(ctx, repo, pull, { openVerb: "opened this" }); | |
| 1567 | 1551 | |
| 1568 | 1552 | const body = html`${repoHeader(ctx, repo, "pulls", { |
| 1569 | 1553 | compact: true, |
test/studio-words.test.ts added +404
| @@ -0,0 +1,404 @@ | ||
| 1 | /** | |
| 2 | * One mechanical check for every studio page: none of dev's vocabulary | |
| 3 | * leaks onto it, the security floor's headers are still on it, the studio | |
| 4 | * chrome is, and it stays small enough that a diff could never hide inside | |
| 5 | * it. `docs/STUDIO.md`'s five words are each one thing in git; this is the | |
| 6 | * test that nothing else says the git word instead. | |
| 7 | * | |
| 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. | |
| 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. | |
| 16 | */ | |
| 17 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 18 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 19 | import { tmpdir } from "node:os"; | |
| 20 | import { join } from "node:path"; | |
| 21 | import { createApp, type App } from "../src/app.ts"; | |
| 22 | import { enableApp } from "../src/actions/apps.ts"; | |
| 23 | import { previewEnv, type EnvName } from "../src/apps/data.ts"; | |
| 24 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 25 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; | |
| 26 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 27 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 28 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 29 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 30 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 31 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 32 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 33 | ||
| 34 | /** | |
| 35 | * Dev's own words, each with the reason it must never reach a studio page. | |
| 36 | * Checked with every `<script>` stripped first: the shared client script | |
| 37 | * (`shellJs`, `src/web/client.ts`) ships on every page and classifies a raw | |
| 38 | * agent log line by matching literal text like "branch " and "committed | |
| 39 | * the", so its source would otherwise trip this file's own checks on pages | |
| 40 | * that never rendered a word of it. | |
| 41 | */ | |
| 42 | const DEV_WORDS: readonly { pattern: RegExp; because: string }[] = [ | |
| 43 | { | |
| 44 | pattern: /diff-table/, | |
| 45 | because: "a change is judged by what it does, never its diff", | |
| 46 | }, | |
| 47 | { | |
| 48 | pattern: /class="label lc-/, | |
| 49 | because: "a label chip is dev's own sorting, not one of the five words", | |
| 50 | }, | |
| 51 | { | |
| 52 | pattern: /opened by/i, | |
| 53 | because: "the studio says who made a change in its own words", | |
| 54 | }, | |
| 55 | { | |
| 56 | pattern: /checks (passed|failed)/i, | |
| 57 | because: "a check reads as one of the five words, not dev's pass/fail", | |
| 58 | }, | |
| 59 | { | |
| 60 | pattern: /\brepository\b/i, | |
| 61 | because: "the studio calls it an app", | |
| 62 | }, | |
| 63 | { | |
| 64 | pattern: /\bcommit\b/i, | |
| 65 | because: "a short sha in a caption is fine; the word never is", | |
| 66 | }, | |
| 67 | { | |
| 68 | pattern: /\bbranch\b/i, | |
| 69 | because: "a change is judged by what it does, not where it lives", | |
| 70 | }, | |
| 71 | { | |
| 72 | pattern: /\bmerge\b/i, | |
| 73 | because: "the studio's own word for a merge is shipped", | |
| 74 | }, | |
| 75 | ]; | |
| 76 | ||
| 77 | /** | |
| 78 | * What a page itself says, in words a person reads — `<main id="main">`, | |
| 79 | * `page()`'s own (`src/web/layout.ts`), not the one sitewide shell around | |
| 80 | * every page in both faces on purpose: the topbar (mode switch, the "New | |
| 81 | * repository" item beside "New app" in the "+" menu, search) and the | |
| 82 | * footer. A hover title, an aria-label ("Where this repository is read | |
| 83 | * from", on the mirror pin every repository carries) or an href (the deep | |
| 84 | * link to dev's own commit view under a short sha, `?mode=dev` itself) is | |
| 85 | * a destination or a hint for a screen reader, not a word the page said | |
| 86 | * out loud — the risk this file's own header warns about, a short sha in | |
| 87 | * a caption is fine and the link it sits on is how the studio keeps dev | |
| 88 | * one click away. | |
| 89 | */ | |
| 90 | const pageContent = (html: string): string => | |
| 91 | (html.match(/<main[^>]*id="main"[\s\S]*?<\/main>/)?.[0] ?? html) | |
| 92 | .replace(/\s(?:aria-label|title|placeholder|href)="[^"]*"/g, "") | |
| 93 | // The pull/issue state badge (`stateBadge`, "Merged") and the | |
| 94 | // conversation's own "merged this" history line (`stateEvents`, | |
| 95 | // `src/web/pages/thread.ts`) are the one thread dev's page reads too — | |
| 96 | // docs/STUDIO.md says so explicitly ("the conversation: read it, add | |
| 97 | // to it"). The studio's own word for the state is `wordBadge` | |
| 98 | // elsewhere on the page (`data-word="shipped"`), checked below; these | |
| 99 | // are the shared history underneath it, not a word the studio page | |
| 100 | // itself chose to say. | |
| 101 | .replace(/data-pr="merged"[\s\S]*?Merged<\/span/, 'data-pr="merged">…</span') | |
| 102 | .replace(/<span>merged this<\/span>/, "<span>…</span>") | |
| 103 | // Every icon (`icon()`, `src/web/icons.ts`) is a decorative, aria-hidden | |
| 104 | // svg named after a Lucide glyph — `ico-merge` is a shape, not a word, | |
| 105 | // and it rides on things dev and studio share alike (a timeline dot). | |
| 106 | .replace(/<svg[\s\S]*?<\/svg>/g, ""); | |
| 107 | ||
| 108 | /** Generous next to a real page (tens of KB); nowhere near the 2.5 MB one | |
| 109 | * file diff cost the studio pull page before it had its own render. */ | |
| 110 | const MAX_BODY_BYTES = 300_000; | |
| 111 | ||
| 112 | describe("dev's vocabulary never reaches a studio page", () => { | |
| 113 | let dataDir: string; | |
| 114 | let app: App; | |
| 115 | let servers: Servers; | |
| 116 | let runtime: AppsRuntime; | |
| 117 | let base: string; | |
| 118 | let cookie: string; | |
| 119 | let record: AppRecord; | |
| 120 | let tellNumber: number; | |
| 121 | let runId: string; | |
| 122 | let openPullNumber: number; | |
| 123 | let mergedPullNumber: number; | |
| 124 | let openBranch: string; | |
| 125 | let mergedBranch: string; | |
| 126 | ||
| 127 | interface EnvSlot { | |
| 128 | readonly app: AppRecord; | |
| 129 | readonly env: EnvName; | |
| 130 | readonly chain: Promise<void>; | |
| 131 | readonly restarts: number[]; | |
| 132 | live?: unknown; | |
| 133 | } | |
| 134 | const putLive = (env: EnvName, live: unknown): void => { | |
| 135 | (runtime as unknown as { envs: Map<string, EnvSlot> }).envs.set( | |
| 136 | `${record.id}/${env}`, | |
| 137 | { app: record, env, chain: Promise.resolve(), restarts: [], live }, | |
| 138 | ); | |
| 139 | }; | |
| 140 | ||
| 141 | beforeAll(async () => { | |
| 142 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-words-")); | |
| 143 | app = createApp({ dataDir, port: 0 }); | |
| 144 | servers = await serve(app); | |
| 145 | base = servers.url; | |
| 146 | // No `.start()`: this test boots one forge and drives it through the | |
| 147 | // real actions, but the preview and production environments below are | |
| 148 | // injected directly, the way `test/studio.test.ts`'s own fragment tests | |
| 149 | // do, so nothing here waits on a real install or a real deploy. | |
| 150 | runtime = new AppsRuntime(app, { | |
| 151 | domain: "studio-words.test", | |
| 152 | scheme: "http", | |
| 153 | publicPort: servers.port, | |
| 154 | forgeUrl: servers.url, | |
| 155 | concurrency: 1, | |
| 156 | }); | |
| 157 | ||
| 158 | const a = await createUser(app.db, { | |
| 159 | username: "alice", | |
| 160 | email: "[email protected]", | |
| 161 | password: "a-good-passphrase", | |
| 162 | adminIfFirst: true, | |
| 163 | }); | |
| 164 | if (!a.ok) throw new Error(a.error); | |
| 165 | cookie = ( | |
| 166 | ( | |
| 167 | await fetch(`${base}/signin`, { | |
| 168 | method: "POST", | |
| 169 | body: new URLSearchParams({ | |
| 170 | username: "alice", | |
| 171 | password: "a-good-passphrase", | |
| 172 | }), | |
| 173 | redirect: "manual", | |
| 174 | }) | |
| 175 | ).headers.get("set-cookie") ?? "" | |
| 176 | ).split(";")[0]!; | |
| 177 | ||
| 178 | const made = await createRepo(app, a.value, { | |
| 179 | name: "vocab", | |
| 180 | visibility: "private", | |
| 181 | description: "a place to catch dev's words", | |
| 182 | }); | |
| 183 | if (!made.ok) throw new Error(String(made.error)); | |
| 184 | const repo = made.value; | |
| 185 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 186 | message: "Start", | |
| 187 | files: [ | |
| 188 | { | |
| 189 | path: "package.json", | |
| 190 | content: '{"name":"vocab","scripts":{"start":"bun run server.ts"}}\n', | |
| 191 | }, | |
| 192 | { | |
| 193 | path: "server.ts", | |
| 194 | content: | |
| 195 | "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n", | |
| 196 | }, | |
| 197 | ], | |
| 198 | }); | |
| 199 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 200 | const enabled = await enableApp(app, a.value, repo, { slug: "vocab" }); | |
| 201 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 202 | record = getAppByRepo(app.db, repo.id)!; | |
| 203 | ||
| 204 | const scout = await createAgent(app, a.value, { username: "scout" }); | |
| 205 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 206 | expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true); | |
| 207 | const scoutUser = getUserByName(app.db, "scout")!; | |
| 208 | ||
| 209 | // A tell, assigned, with a run — the studio's *tell* and *working*. | |
| 210 | const told = openIssue(app, a.value, repo, { | |
| 211 | title: "Make the export button work", | |
| 212 | body: "It does nothing right now.", | |
| 213 | }); | |
| 214 | if (!told.ok) throw new Error(String(told.error)); | |
| 215 | tellNumber = told.value.number; | |
| 216 | expect(assignIssue(app, a.value, repo, told.value, scoutUser).ok).toBe( | |
| 217 | true, | |
| 218 | ); | |
| 219 | const run = createRun(app.db, { | |
| 220 | repoId: repo.id, | |
| 221 | kind: "agent", | |
| 222 | issueId: told.value.id, | |
| 223 | actorId: scoutUser.id, | |
| 224 | reason: "assigned", | |
| 225 | }); | |
| 226 | claimRun(app.db, run.id); | |
| 227 | finishRun(app.db, run.id, { | |
| 228 | state: "ok", | |
| 229 | summary: "Wired up the export handler.", | |
| 230 | costUsd: 0.34, | |
| 231 | turns: 6, | |
| 232 | }); | |
| 233 | runId = run.id; | |
| 234 | ||
| 235 | // An open pull with a green check and a preview — the studio's *look*. | |
| 236 | openBranch = "add-export"; | |
| 237 | const openBranchMade = await commitToBranch(app, a.value, repo, { | |
| 238 | branch: openBranch, | |
| 239 | base: repo.defaultBranch, | |
| 240 | message: "Add export", | |
| 241 | files: [{ path: "export.ts", content: "export {};\n" }], | |
| 242 | }); | |
| 243 | if (!openBranchMade.ok) throw new Error(JSON.stringify(openBranchMade.error)); | |
| 244 | const opened = await openPull(app, scoutUser, repo, { | |
| 245 | title: "Add the export button", | |
| 246 | body: `Closes #${tellNumber}.`, | |
| 247 | baseBranch: repo.defaultBranch, | |
| 248 | headBranch: openBranch, | |
| 249 | }); | |
| 250 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 251 | openPullNumber = opened.value.number; | |
| 252 | const check = createRun(app.db, { | |
| 253 | repoId: repo.id, | |
| 254 | kind: "check", | |
| 255 | issueId: opened.value.id, | |
| 256 | env: `pull-${opened.value.number}`, | |
| 257 | commitOid: opened.value.headOid, | |
| 258 | reason: "pull", | |
| 259 | }); | |
| 260 | claimRun(app.db, check.id); | |
| 261 | finishRun(app.db, check.id, { state: "ok", summary: "check passed in 1s" }); | |
| 262 | putLive(previewEnv(opened.value.number), { | |
| 263 | commitOid: opened.value.headOid, | |
| 264 | handle: { port: 4321 }, | |
| 265 | runId: check.id, | |
| 266 | since: new Date().toISOString(), | |
| 267 | }); | |
| 268 | ||
| 269 | // A merged change, production caught up to it — the studio's *shipped*. | |
| 270 | mergedBranch = "faster-notes"; | |
| 271 | const mergedBranchMade = await commitToBranch(app, a.value, repo, { | |
| 272 | branch: mergedBranch, | |
| 273 | base: repo.defaultBranch, | |
| 274 | message: "Faster", | |
| 275 | files: [{ path: "FAST.md", content: "fast\n" }], | |
| 276 | }); | |
| 277 | if (!mergedBranchMade.ok) | |
| 278 | throw new Error(JSON.stringify(mergedBranchMade.error)); | |
| 279 | const toMerge = await openPull(app, a.value, repo, { | |
| 280 | title: "Make the notes page faster", | |
| 281 | body: "", | |
| 282 | baseBranch: repo.defaultBranch, | |
| 283 | headBranch: mergedBranch, | |
| 284 | }); | |
| 285 | if (!toMerge.ok) throw new Error(String(toMerge.error)); | |
| 286 | mergedPullNumber = toMerge.value.number; | |
| 287 | const mergedCheck = createRun(app.db, { | |
| 288 | repoId: repo.id, | |
| 289 | kind: "check", | |
| 290 | issueId: toMerge.value.id, | |
| 291 | env: `pull-${toMerge.value.number}`, | |
| 292 | commitOid: toMerge.value.headOid, | |
| 293 | reason: "pull", | |
| 294 | }); | |
| 295 | claimRun(app.db, mergedCheck.id); | |
| 296 | finishRun(app.db, mergedCheck.id, { | |
| 297 | state: "ok", | |
| 298 | summary: "check passed in 1s", | |
| 299 | }); | |
| 300 | const merged = await mergePull(app, a.value, repo, toMerge.value, "squash"); | |
| 301 | if (!merged.ok) throw new Error(String(merged.error)); | |
| 302 | const mergedOid = merged.value.mergeOid; | |
| 303 | const deploy = createRun(app.db, { | |
| 304 | repoId: repo.id, | |
| 305 | kind: "deploy", | |
| 306 | env: "production", | |
| 307 | commitOid: mergedOid, | |
| 308 | reason: "push", | |
| 309 | }); | |
| 310 | claimRun(app.db, deploy.id); | |
| 311 | finishRun(app.db, deploy.id, { state: "ok", summary: "deploy" }); | |
| 312 | putLive("production", { | |
| 313 | commitOid: mergedOid, | |
| 314 | handle: { port: 4321 }, | |
| 315 | runId: deploy.id, | |
| 316 | since: new Date().toISOString(), | |
| 317 | }); | |
| 318 | }); | |
| 319 | ||
| 320 | afterAll(async () => { | |
| 321 | servers.stop(); | |
| 322 | app.close(); | |
| 323 | await rm(dataDir, { recursive: true, force: true }); | |
| 324 | }); | |
| 325 | ||
| 326 | 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 () => { | |
| 327 | const pages: { word: string; path: string }[] = [ | |
| 328 | { word: "home", path: "/" }, | |
| 329 | { word: "app", path: "/alice/vocab" }, | |
| 330 | { word: "tell", path: `/alice/vocab/issues/${tellNumber}` }, | |
| 331 | { word: "working", path: `/alice/vocab/runs/${runId}` }, | |
| 332 | { word: "look", path: `/alice/vocab/pulls/${openPullNumber}` }, | |
| 333 | { word: "shipped", path: `/alice/vocab/pulls/${mergedPullNumber}` }, | |
| 334 | ]; | |
| 335 | ||
| 336 | for (const p of pages) { | |
| 337 | const res = await fetch(`${base}${p.path}`, { | |
| 338 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 339 | redirect: "manual", | |
| 340 | }); | |
| 341 | // A route not yet wired into the router answers 404 — skip it rather | |
| 342 | // than fail, so a row for a page still being built (docs/STUDIO.md's | |
| 343 | // lists, as they land) does not block this file. | |
| 344 | if (res.status === 404) continue; | |
| 345 | expect(res.status).toBe(200); | |
| 346 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 347 | expect(res.headers.get("referrer-policy")).toBe("same-origin"); | |
| 348 | expect(res.headers.get("content-security-policy")).toContain( | |
| 349 | "default-src 'none'", | |
| 350 | ); | |
| 351 | ||
| 352 | const html = await res.text(); | |
| 353 | expect( | |
| 354 | html.length, | |
| 355 | `${p.word} (${p.path}) is ${html.length} bytes — a diff would blow through ${MAX_BODY_BYTES}`, | |
| 356 | ).toBeLessThan(MAX_BODY_BYTES); | |
| 357 | // The studio chrome: the topbar's own tab reads "Studio", never | |
| 358 | // "Code", the moment the mode and the repository are the studio's — | |
| 359 | // and it is the only source of the literal word on any of these | |
| 360 | // pages, so its presence here is the chrome, proven. | |
| 361 | expect(html).toContain("Studio"); | |
| 362 | ||
| 363 | const visible = pageContent(html); | |
| 364 | for (const { pattern, because } of DEV_WORDS) { | |
| 365 | expect( | |
| 366 | visible, | |
| 367 | `${p.word} (${p.path}) said dev's word ${pattern} — ${because}`, | |
| 368 | ).not.toMatch(pattern); | |
| 369 | } | |
| 370 | expect( | |
| 371 | visible, | |
| 372 | `${p.word} (${p.path}) named the open branch "${openBranch}"`, | |
| 373 | ).not.toContain(openBranch); | |
| 374 | expect( | |
| 375 | visible, | |
| 376 | `${p.word} (${p.path}) named the merged branch "${mergedBranch}"`, | |
| 377 | ).not.toContain(mergedBranch); | |
| 378 | } | |
| 379 | ||
| 380 | // A run id is a key, not a headline: it rides hrefs (the dev link, the | |
| 381 | // steer form) but never becomes the page's own title. | |
| 382 | const transcript = await ( | |
| 383 | await fetch(`${base}/alice/vocab/runs/${runId}`, { | |
| 384 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 385 | redirect: "manual", | |
| 386 | }) | |
| 387 | ).text(); | |
| 388 | const title = transcript.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? ""; | |
| 389 | const h1 = transcript.match(/<h1>([\s\S]*?)<\/h1>/)?.[1] ?? ""; | |
| 390 | expect(title).not.toContain(runId); | |
| 391 | expect(h1).not.toContain(runId); | |
| 392 | }); | |
| 393 | ||
| 394 | test("?mode=dev on the same routes still carries dev's own words — the assertions above are real, not vacuous", async () => { | |
| 395 | const dev = await ( | |
| 396 | await fetch(`${base}/alice/vocab/pulls/${openPullNumber}?mode=dev`, { | |
| 397 | headers: { cookie }, | |
| 398 | redirect: "manual", | |
| 399 | }) | |
| 400 | ).text(); | |
| 401 | expect(dev).toContain("diff-table"); | |
| 402 | expect(dev).toContain(openBranch); | |
| 403 | }); | |
| 404 | }); |
new above
offline