Public: anyone can read this; the crew can change it.
forge-webspec walks the signed-in pages too: /map, /system, /boards, /crew, a pull, settings #3173
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3170-forge-webspec-walks-the-signed-in-pages → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +57
−18
src/web/pages/crew.ts +4 −4
| @@ -498,7 +498,7 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html { | ||
| 498 | 498 | </form>`, |
| 499 | 499 | { |
| 500 | 500 | head: html`${icon("bot")} |
| 501 | <h3>New agent</h3>`, | |
| 501 | <h2>New agent</h2>`, | |
| 502 | 502 | }, |
| 503 | 503 | )}`; |
| 504 | 504 | } |
| @@ -538,7 +538,7 @@ function splitsCard( | ||
| 538 | 538 | </tbody> |
| 539 | 539 | </table> |
| 540 | 540 | </div>`, |
| 541 | { pad: false, head: html`<h3>${title}</h3>` }, | |
| 541 | { pad: false, head: html`<h2>${title}</h2>` }, | |
| 542 | 542 | ); |
| 543 | 543 | } |
| 544 | 544 | |
| @@ -618,7 +618,7 @@ function agentView(agent: Agent, values: AgentValues, error?: string): Html { | ||
| 618 | 618 | </form>`, |
| 619 | 619 | { |
| 620 | 620 | head: html`${icon("bot")} |
| 621 | <h3>${agent.user.username}</h3> | |
| 621 | <h2>${agent.user.username}</h2> | |
| 622 | 622 | <span class="muted small">added ${time(agent.createdAt)}</span>`, |
| 623 | 623 | }, |
| 624 | 624 | )} |
| @@ -633,7 +633,7 @@ function agentView(agent: Agent, values: AgentValues, error?: string): Html { | ||
| 633 | 633 | { |
| 634 | 634 | tone: "danger", |
| 635 | 635 | head: html`${icon("alert")} |
| 636 | <h3>Retire</h3>`, | |
| 636 | <h2>Retire</h2>`, | |
| 637 | 637 | }, |
| 638 | 638 | )}`; |
| 639 | 639 | } |
test/forge-webspec.test.ts +52 −13
| @@ -10,7 +10,9 @@ import { join } from "node:path"; | ||
| 10 | 10 | import { brotliDecompressSync } from "node:zlib"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { openIssue } from "../src/actions/issues.ts"; |
| 13 | import { openPull } from "../src/actions/pulls.ts"; | |
| 13 | 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 14 | 16 | import { createUser } from "../src/core/users.ts"; |
| 15 | 17 | import { compress } from "../src/http/compress.ts"; |
| 16 | 18 | import { securityFloor } from "../src/http/respond.ts"; |
| @@ -25,6 +27,22 @@ const PATHS = ["/", "/signin", "/signup", "/explore", "/alice/notes", "/alice/no | ||
| 25 | 27 | /** `check path` -> [section, why it stands]. Every one is expected on every page it names; one fixed fails the test, one new fails it too. */ |
| 26 | 28 | const KNOWN: Record<string, [string, string]> = {}; |
| 27 | 29 | |
| 30 | /** The owner's pages: signed in, so no-store by design; webspec's caching findings on them are not asserted away, they are named. */ | |
| 31 | const SIGNED_IN = ["/map", "/system", "/boards", "/settings/crew", "/alice/notes/pulls/2", "/settings"]; | |
| 32 | const CACHING = "https://specification.website/#caching"; | |
| 33 | const NO_ETAG = "signed-in HTML is no-store by design: it is per-person, so there is no validator to revalidate against"; | |
| 34 | const KNOWN_SIGNED_IN: Record<string, [string, string]> = { | |
| 35 | "etag /system": [CACHING, NO_ETAG], | |
| 36 | "etag /boards": [CACHING, NO_ETAG], | |
| 37 | "etag /map": [CACHING, NO_ETAG], | |
| 38 | "etag /alice/notes/pulls/2": [CACHING, NO_ETAG], | |
| 39 | "etag /settings": [CACHING, NO_ETAG], | |
| 40 | "etag /settings/crew": [CACHING, NO_ETAG], | |
| 41 | "title /map": ["https://specification.website/#title", "each map node is an inline SVG element whose <title> is its tooltip and accessible name, beside the page's own"], | |
| 42 | }; | |
| 43 | ||
| 44 | let cookie = ""; | |
| 45 | ||
| 28 | 46 | let dir = ""; |
| 29 | 47 | let app: App; |
| 30 | 48 | let http: ForgeHttp; |
| @@ -47,6 +65,11 @@ beforeAll(async () => { | ||
| 47 | 65 | await commitToBranch(app, made.value, repo.value, { message: "seed", files: [{ path: "a.txt", content: "a" }] }); |
| 48 | 66 | const issue = openIssue(app, made.value, repo.value, { title: "First", body: "hello" }); |
| 49 | 67 | if (!issue.ok) throw new Error("issue"); |
| 68 | const branched = await commitToBranch(app, made.value, repo.value, { branch: "feature", message: "feature", files: [{ path: "b.txt", content: "b" }] }); | |
| 69 | if (!branched.ok) throw new Error("branch"); | |
| 70 | const pull = await openPull(app, made.value, repo.value, { title: "Feature", baseBranch: "main", headBranch: "feature" }); | |
| 71 | if (!pull.ok) throw new Error("pull"); | |
| 72 | cookie = `forge_session=${createSession(app.db, made.value.id).token}`; | |
| 50 | 73 | }); |
| 51 | 74 | |
| 52 | 75 | afterAll(async () => { |
| @@ -55,20 +78,22 @@ afterAll(async () => { | ||
| 55 | 78 | await rm(dir, { recursive: true, force: true }); |
| 56 | 79 | }); |
| 57 | 80 | |
| 81 | // The seam production uses (server.ts): compress, then the security floor. | |
| 82 | // Like fetch, the body arrives decoded while Content-Encoding stays for webspec to read. | |
| 83 | const through = (signedIn: boolean) => async (input: string, init?: RequestInit) => { | |
| 84 | const request = new Request(input, init); | |
| 85 | if (signedIn) request.headers.set("cookie", cookie); | |
| 86 | if (!request.headers.has("accept-encoding")) request.headers.set("accept-encoding", "br, gzip"); | |
| 87 | const response = await compress(request, await http.handle(request)).then(securityFloor); | |
| 88 | const encoding = response.headers.get("content-encoding"); | |
| 89 | if (encoding !== "br" && encoding !== "gzip") return response; | |
| 90 | const bytes = new Uint8Array(await response.arrayBuffer()); | |
| 91 | const plain = encoding === "br" ? brotliDecompressSync(bytes) : Bun.gunzipSync(bytes); | |
| 92 | return new Response(plain, response); | |
| 93 | }; | |
| 94 | ||
| 58 | 95 | test("the forge's signed-out pages raise exactly the findings named in KNOWN", async () => { |
| 59 | // The seam production uses (server.ts): compress, then the security floor. | |
| 60 | // Like fetch, the body arrives decoded while Content-Encoding stays for webspec to read. | |
| 61 | const fetcher = async (input: string, init?: RequestInit) => { | |
| 62 | const request = new Request(input, init); | |
| 63 | if (!request.headers.has("accept-encoding")) request.headers.set("accept-encoding", "br, gzip"); | |
| 64 | const response = await compress(request, await http.handle(request)).then(securityFloor); | |
| 65 | const encoding = response.headers.get("content-encoding"); | |
| 66 | if (encoding !== "br" && encoding !== "gzip") return response; | |
| 67 | const bytes = new Uint8Array(await response.arrayBuffer()); | |
| 68 | const plain = encoding === "br" ? brotliDecompressSync(bytes) : Bun.gunzipSync(bytes); | |
| 69 | return new Response(plain, response); | |
| 70 | }; | |
| 71 | const found = await webspec("http://forge.test", PATHS, fetcher, { site: true }); | |
| 96 | const found = await webspec("http://forge.test", PATHS, through(false), { site: true }); | |
| 72 | 97 | const keyOf = (f: { check: string; path: string }) => `${f.check} ${f.path}`; |
| 73 | 98 | // New: not named, or citing another section than the one named. |
| 74 | 99 | expect(found.filter((f) => KNOWN[keyOf(f)]?.[0] !== f.section).map((f) => `${keyOf(f)}: ${f.message}`)).toEqual([]); |
| @@ -77,6 +102,14 @@ test("the forge's signed-out pages raise exactly the findings named in KNOWN", a | ||
| 77 | 102 | expect(Object.keys(KNOWN).filter((k) => !seen.has(k))).toEqual([]); |
| 78 | 103 | }); |
| 79 | 104 | |
| 105 | test("the owner's signed-in pages raise exactly the findings named in KNOWN_SIGNED_IN", async () => { | |
| 106 | const found = await webspec("http://forge.test", SIGNED_IN, through(true)); | |
| 107 | const keyOf = (f: { check: string; path: string }) => `${f.check} ${f.path}`; | |
| 108 | expect(found.filter((f) => KNOWN_SIGNED_IN[keyOf(f)]?.[0] !== f.section).map((f) => `${keyOf(f)} [${f.section}]: ${f.message}`)).toEqual([]); | |
| 109 | const seen = new Set(found.map(keyOf)); | |
| 110 | expect(Object.keys(KNOWN_SIGNED_IN).filter((k) => !seen.has(k))).toEqual([]); | |
| 111 | }); | |
| 112 | ||
| 80 | 113 | test("a page that answers 200 names its canonical address, equal to og:url; a 404 names none", async () => { |
| 81 | 114 | const head = async (path: string) => { |
| 82 | 115 | const res = await http.handle(new Request(`http://forge.test${path}`)); |
| @@ -95,6 +128,12 @@ test("a page that answers 200 names its canonical address, equal to og:url; a 40 | ||
| 95 | 128 | expect(missing.html).not.toContain('rel="canonical"'); |
| 96 | 129 | }); |
| 97 | 130 | |
| 131 | test("the walked signed-in pages answer 200 for the owner", async () => { | |
| 132 | for (const path of SIGNED_IN) { | |
| 133 | expect((await http.handle(new Request(`http://forge.test${path}`, { headers: { cookie } }))).status).toBe(200); | |
| 134 | } | |
| 135 | }); | |
| 136 | ||
| 98 | 137 | test("the walked repository pages exist", async () => { |
| 99 | 138 | for (const path of ["/alice/notes", "/alice/notes/issues", "/alice/notes/issues/1"]) { |
| 100 | 139 | expect((await http.handle(new Request(`http://forge.test${path}`))).status).toBe(200); |
test/web-apps.test.ts +1 −1
| @@ -663,7 +663,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 663 | 663 | const page = await bodyOf(alice, "/settings/crew"); |
| 664 | 664 | expect(page).toContain("No agents yet"); |
| 665 | 665 | expect(page).toContain("No crew runner is up"); |
| 666 | expect(page).toContain("<h3>New agent</h3>"); | |
| 666 | expect(page).toContain("<h2>New agent</h2>"); | |
| 667 | 667 | expect(await bodyOf(alice, "/settings")).toContain('href="/settings/crew"'); |
| 668 | 668 | |
| 669 | 669 | const bad = await web(alice, "/settings/crew", { |
new above
offline
This pins a real heading-order defect as expected. Fix the crew page to use <h2> and remove this entry, or link an issue number here.