Public: anyone can read this; the crew can change it.
The issue page's sections share one heading style, and a test holds it #9684
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9677-the-issue-page-s-sections-share-one-head → main 2 commits fork
2 files
changed +131
−3
src/web/studio/tell.ts +6 −3
| @@ -582,11 +582,14 @@ async function studioTell( | ||
| 582 | 582 | ${privateWorkCard(ctx, repo, issue, `${thread}/private`)} |
| 583 | 583 | ${hasPicture(db, repo.id, issue.number) |
| 584 | 584 | ? card( |
| 585 | html`<h2>Picture of the page</h2> | |
| 586 | <a href="${thread}/picture" | |
| 585 | html`<a href="${thread}/picture" | |
| 587 | 586 | ><img src="${thread}/picture" alt="The page the sender was looking at" loading="lazy" class="tell-picture" |
| 588 | 587 | /></a>`, |
| 589 | { extra: "mt" }, | |
| 588 | { | |
| 589 | extra: "mt", | |
| 590 | head: html`${icon("eye")} | |
| 591 | <h2>Picture of the page</h2>`, | |
| 592 | }, | |
| 590 | 593 | ) |
| 591 | 594 | : null} |
| 592 | 595 | ${runsCard} |
test/issue-page-rows.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** | |
| 2 | * The issue page's sections speak one heading: every `.card-head` on a | |
| 3 | * rendered issue is an icon then an `<h2>`, and nothing else. A section | |
| 4 | * that drops its icon, or puts its title in the body, fails here. The | |
| 5 | * labels and assignee cards are one function for the issue and pull pages, | |
| 6 | * so the heading held here is the pull page's too. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { openIssue } from "../src/actions/issues.ts"; | |
| 14 | import { openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { savePicture } from "../src/core/note_pictures.ts"; | |
| 18 | import { createSession } from "../src/core/sessions.ts"; | |
| 19 | import { saveTeam } from "../src/core/teams.ts"; | |
| 20 | import { createRun } from "../src/core/runs.ts"; | |
| 21 | import { createUser, type User } from "../src/core/users.ts"; | |
| 22 | import { getRepoById } from "../src/core/repos.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | /** Every card heading in a page, as `[icon-reference, title]`, or the raw markup when it is not that shape. */ | |
| 29 | function headings(page: string): (string | [string, string])[] { | |
| 30 | const out: (string | [string, string])[] = []; | |
| 31 | for (const m of page.matchAll(/<div class="card-head">([\s\S]*?)<\/div>/g)) { | |
| 32 | const inner = m[1]!.replace(/\s+/g, " ").trim(); | |
| 33 | if (!/<h2[\s>]/.test(inner)) continue; // the byline's head is not a section heading | |
| 34 | const shape = /^<svg[^>]*><use href="[^"]*#i-([A-Za-z]+)"\/><\/svg> <h2>([^<]+)<\/h2>$/.exec(inner); | |
| 35 | out.push(shape ? [shape[1]!, shape[2]!] : inner); | |
| 36 | } | |
| 37 | return out; | |
| 38 | } | |
| 39 | ||
| 40 | describe("the issue page's section headings", () => { | |
| 41 | let dataDir = ""; | |
| 42 | let app: App; | |
| 43 | let servers: Servers; | |
| 44 | let alice: User; | |
| 45 | let repoId = ""; | |
| 46 | const cookie = () => `forge_session=${createSession(app.db, alice.id).token}`; | |
| 47 | const get = async (path: string) => | |
| 48 | (await fetch(`${servers.url}/alice/garden${path}`, { headers: { cookie: cookie() } })).text(); | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | dataDir = await mkdtemp(join(tmpdir(), "forge-issue-page-rows-")); | |
| 52 | app = createApp({ dataDir, port: 0 }); | |
| 53 | servers = await serve(app); | |
| 54 | const made = await createUser(app.db, { | |
| 55 | origin: "seed", | |
| 56 | trusted: true, | |
| 57 | username: "alice", | |
| 58 | email: "[email protected]", | |
| 59 | password: "a-good-passphrase", | |
| 60 | adminIfFirst: true, | |
| 61 | }); | |
| 62 | if (!made.ok) throw new Error(made.error); | |
| 63 | alice = made.value; | |
| 64 | const r = await createRepo(app, alice, { name: "garden", visibility: "private" }); | |
| 65 | if (!r.ok) throw new Error(String(r.error)); | |
| 66 | repoId = r.value.id; | |
| 67 | const seeded = await commitToBranch(app, alice, r.value, { | |
| 68 | message: "seed", | |
| 69 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 70 | }); | |
| 71 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 72 | saveTeam(app.db, "design", { charter: "design", dial: 1, members: [] }); | |
| 73 | }, 30_000); | |
| 74 | ||
| 75 | afterAll(async () => { | |
| 76 | await servers.stop(); | |
| 77 | app.close(); | |
| 78 | await rm(dataDir, { recursive: true, force: true }); | |
| 79 | }); | |
| 80 | ||
| 81 | test("a sealed issue's private-work card has the same heading", async () => { | |
| 82 | const repo = getRepoById(app.db, repoId)!; | |
| 83 | const o = openIssue(app, alice, repo, { title: "A hole", sealed: true }); | |
| 84 | if (!o.ok) throw new Error(o.error); | |
| 85 | const found = headings(await get(`/issues/${o.value.number}`)); | |
| 86 | expect(found.filter((h) => typeof h === "string")).toEqual([]); | |
| 87 | expect(found.map((h) => (typeof h === "string" ? h : h[1]))).toContain("Fix it privately"); | |
| 88 | }); | |
| 89 | ||
| 90 | test("every section carries an icon and an h2, the same way", async () => { | |
| 91 | const repo = getRepoById(app.db, repoId)!; | |
| 92 | const o = openIssue(app, alice, repo, { title: "A chart" }); | |
| 93 | if (!o.ok) throw new Error(o.error); | |
| 94 | const issue = o.value; | |
| 95 | const branch = "fix"; | |
| 96 | const c = await commitToBranch(app, alice, repo, { | |
| 97 | branch, | |
| 98 | base: "main", | |
| 99 | message: branch, | |
| 100 | files: [{ path: "fix.md", content: "x\n" }], | |
| 101 | }); | |
| 102 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 103 | const p = await openPull(app, alice, repo, { | |
| 104 | title: branch, | |
| 105 | body: `Closes #${issue.number}`, | |
| 106 | baseBranch: "main", | |
| 107 | headBranch: branch, | |
| 108 | }); | |
| 109 | if (!p.ok) throw new Error(String(p.error)); | |
| 110 | createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.id, env: "main", commitOid: p.value.headOid, reason: "issue" } as never); | |
| 111 | const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); | |
| 112 | app.db | |
| 113 | .query("INSERT INTO app_note (id, app_id, repo_id, issue_number, visitor, name, hidden, created_at) VALUES ('note1abcdefgh','a',?1,?2,'v','n',0,?3)") | |
| 114 | .run(repo.id, issue.number, new Date().toISOString()); | |
| 115 | const saved = savePicture(app.db, dataDir, { id: "note1abcdefgh", repoId: repo.id, issueNumber: issue.number }, "png", png); | |
| 116 | expect(saved).toBe(true); | |
| 117 | ||
| 118 | const found = headings(await get(`/issues/${issue.number}`)); | |
| 119 | const titles = found.map((h) => (typeof h === "string" ? h : h[1])); | |
| 120 | for (const want of ["Runs", "Closes with", "Picture of the page", "Depends on", "Labels", "Assignee", "Teams"]) | |
| 121 | expect(titles).toContain(want); | |
| 122 | // No heading is anything but icon + h2. | |
| 123 | expect(found.filter((h) => typeof h === "string")).toEqual([]); | |
| 124 | }); | |
| 125 | }); |