Public: anyone can read this; the crew can change it.
The front door is the one studio page with no heading #162
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/142-the-front-door-is-the-one-studio-page-wi → main 1 commit fork
2 files
changed +158
−1
src/web/studio/home.ts +2 −1
| @@ -124,7 +124,8 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 124 | 124 | { head: nowHead }, |
| 125 | 125 | ); |
| 126 | 126 | |
| 127 | const body = html`<div class="dash"> | |
| 127 | const body = html`<h1 class="sr-only">Studio</h1> | |
| 128 | <div class="dash"> | |
| 128 | 129 | <div class="dash-main stack"> |
| 129 | 130 | <div id="live-asks" data-live="run.*,pull.*,issue.*,check.*">${asks}</div> |
| 130 | 131 | <div |
test/studio.test.ts +156
| @@ -4448,3 +4448,159 @@ describe("finding things in the studio: /search", () => { | ||
| 4448 | 4448 | expect(html).toContain("Issues and pull requests"); |
| 4449 | 4449 | }); |
| 4450 | 4450 | }); |
| 4451 | ||
| 4452 | describe("every studio page's headings form one outline", () => { | |
| 4453 | let dataDir: string; | |
| 4454 | let app: App; | |
| 4455 | let servers: Servers; | |
| 4456 | let base: string; | |
| 4457 | let alice: User; | |
| 4458 | let repo: Repo; | |
| 4459 | let cookie: string; | |
| 4460 | let issueNumber: number; | |
| 4461 | let pullNumber: number; | |
| 4462 | let runId: string; | |
| 4463 | ||
| 4464 | const get = (path: string) => | |
| 4465 | fetch(`${base}${path}`, { | |
| 4466 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 4467 | redirect: "manual", | |
| 4468 | }); | |
| 4469 | ||
| 4470 | /** Every `<hN>` tag in document order, and whether the outline it forms | |
| 4471 | * is one a heading-jumping reader can follow: exactly one `<h1>`, and | |
| 4472 | * never a level deeper than one past the deepest seen so far. */ | |
| 4473 | function headingOutline(html: string): { levels: number[]; ok: boolean } { | |
| 4474 | const levels = [...html.matchAll(/<h([1-6])(?:[ >])/g)].map((m) => | |
| 4475 | Number(m[1]), | |
| 4476 | ); | |
| 4477 | const h1s = levels.filter((l) => l === 1).length; | |
| 4478 | let deepest = 0; | |
| 4479 | let skipped = false; | |
| 4480 | for (const level of levels) { | |
| 4481 | if (level > deepest + 1) skipped = true; | |
| 4482 | deepest = Math.max(deepest, level); | |
| 4483 | } | |
| 4484 | return { levels, ok: h1s === 1 && !skipped }; | |
| 4485 | } | |
| 4486 | ||
| 4487 | beforeAll(async () => { | |
| 4488 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-outline-")); | |
| 4489 | app = createApp({ dataDir, port: 0 }); | |
| 4490 | servers = await serve(app); | |
| 4491 | base = servers.url; | |
| 4492 | const a = await createUser(app.db, { | |
| 4493 | username: "alice", | |
| 4494 | email: "[email protected]", | |
| 4495 | password: "a-good-passphrase", | |
| 4496 | adminIfFirst: true, | |
| 4497 | }); | |
| 4498 | if (!a.ok) throw new Error(a.error); | |
| 4499 | alice = a.value; | |
| 4500 | cookie = ( | |
| 4501 | ( | |
| 4502 | await fetch(`${base}/signin`, { | |
| 4503 | method: "POST", | |
| 4504 | body: new URLSearchParams({ | |
| 4505 | username: "alice", | |
| 4506 | password: "a-good-passphrase", | |
| 4507 | }), | |
| 4508 | redirect: "manual", | |
| 4509 | }) | |
| 4510 | ).headers.get("set-cookie") ?? "" | |
| 4511 | ).split(";")[0]!; | |
| 4512 | const made = await createRepo(app, alice, { | |
| 4513 | name: "outline", | |
| 4514 | visibility: "private", | |
| 4515 | description: "for the outline", | |
| 4516 | }); | |
| 4517 | if (!made.ok) throw new Error(String(made.error)); | |
| 4518 | repo = made.value; | |
| 4519 | const seeded = await commitToBranch(app, alice, repo, { | |
| 4520 | message: "Start", | |
| 4521 | files: [ | |
| 4522 | { | |
| 4523 | path: "package.json", | |
| 4524 | content: '{"name":"outline","scripts":{"start":"bun run server.ts"}}\n', | |
| 4525 | }, | |
| 4526 | { | |
| 4527 | path: "server.ts", | |
| 4528 | content: | |
| 4529 | "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n", | |
| 4530 | }, | |
| 4531 | ], | |
| 4532 | }); | |
| 4533 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 4534 | const enabled = await enableApp(app, alice, repo, { slug: "outline" }); | |
| 4535 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 4536 | ||
| 4537 | const told = openIssue(app, alice, repo, { | |
| 4538 | title: "Outline test tell", | |
| 4539 | body: "for the heading walk", | |
| 4540 | }); | |
| 4541 | if (!told.ok) throw new Error(String(told.error)); | |
| 4542 | issueNumber = told.value.number; | |
| 4543 | ||
| 4544 | const branch = await commitToBranch(app, alice, repo, { | |
| 4545 | branch: "feature/outline", | |
| 4546 | base: "main", | |
| 4547 | message: "Outline change", | |
| 4548 | files: [{ path: "OUTLINE.md", content: "outline\n" }], | |
| 4549 | }); | |
| 4550 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 4551 | const opened = await openPull(app, alice, repo, { | |
| 4552 | title: "Outline test change", | |
| 4553 | body: "", | |
| 4554 | baseBranch: "main", | |
| 4555 | headBranch: "feature/outline", | |
| 4556 | }); | |
| 4557 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 4558 | pullNumber = opened.value.number; | |
| 4559 | const run = createRun(app.db, { | |
| 4560 | repoId: repo.id, | |
| 4561 | kind: "agent", | |
| 4562 | actorId: alice.id, | |
| 4563 | reason: "assigned", | |
| 4564 | }); | |
| 4565 | claimRun(app.db, run.id); | |
| 4566 | finishRun(app.db, run.id, { state: "ok", summary: "done" }); | |
| 4567 | runId = run.id; | |
| 4568 | }); | |
| 4569 | ||
| 4570 | afterAll(async () => { | |
| 4571 | servers.stop(); | |
| 4572 | app.close(); | |
| 4573 | await rm(dataDir, { recursive: true, force: true }); | |
| 4574 | }); | |
| 4575 | ||
| 4576 | test("the studio home has exactly one h1, said in the studio's own words, and no skipped heading level", async () => { | |
| 4577 | const html = await (await get("/")).text(); | |
| 4578 | const outline = headingOutline(html); | |
| 4579 | expect(outline.levels.filter((l) => l === 1).length).toBe(1); | |
| 4580 | expect(outline.ok).toBe(true); | |
| 4581 | expect(html).toMatch(/<h1 class="sr-only">Studio<\/h1>/); | |
| 4582 | }); | |
| 4583 | ||
| 4584 | test("the app page, the three lists, a tell, a change and a run each have one outline with no skipped level", async () => { | |
| 4585 | const pages: Record<string, string> = { | |
| 4586 | "the app page": "/alice/outline", | |
| 4587 | "everything told": "/alice/outline/tells", | |
| 4588 | "every change": "/alice/outline/changes", | |
| 4589 | "every run": "/alice/outline/runs", | |
| 4590 | "a tell": `/alice/outline/issues/${issueNumber}`, | |
| 4591 | "a change": `/alice/outline/pulls/${pullNumber}`, | |
| 4592 | "a run": `/alice/outline/runs/${runId}`, | |
| 4593 | "settings": "/alice/outline/settings", | |
| 4594 | }; | |
| 4595 | for (const [name, path] of Object.entries(pages)) { | |
| 4596 | const html = await (await get(path)).text(); | |
| 4597 | const outline = headingOutline(html); | |
| 4598 | if ( | |
| 4599 | outline.levels[0] !== 1 || | |
| 4600 | outline.levels.filter((l) => l === 1).length !== 1 || | |
| 4601 | !outline.ok | |
| 4602 | ) | |
| 4603 | throw new Error(`${name} (${path}): ${JSON.stringify(outline)}`); | |
| 4604 | } | |
| 4605 | }); | |
| 4606 | }); |
new above
offline