Public: anyone can read this; the crew can change it.
j/k, enter, esc and ? on the list pages that still lack test/keys-<page>.test.ts #10833
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10822-j-k-enter-esc-and-on-the-list-pages-that → main 4 commits fork 2 threads · 2 unresolved
5 files
changed +159
−0
test/helpers/listed-forge.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | /** A forge with alice's `notes`: two commits on main, a branch `feature/x`, and the crew running it, | |
| 2 | * served so a test reads the pages as a browser gets them. */ | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { commitToBranch, createRepo } from "../../src/actions/repos.ts"; | |
| 7 | import { createBranch } from "../../src/actions/branches.ts"; | |
| 8 | import { createApp, type App } from "../../src/app.ts"; | |
| 9 | import { createSession } from "../../src/core/sessions.ts"; | |
| 10 | import { forgeHttp, type ForgeHttp } from "../../src/http/server.ts"; | |
| 11 | import { seedAdmin } from "./admin.ts"; | |
| 12 | ||
| 13 | export interface ListedForge { | |
| 14 | app: App; | |
| 15 | /** The body of a GET as alice. */ | |
| 16 | get(path: string): Promise<string>; | |
| 17 | close(): Promise<void>; | |
| 18 | } | |
| 19 | ||
| 20 | export async function listedForge(): Promise<ListedForge> { | |
| 21 | const dataDir = await mkdtemp(join(tmpdir(), "forge-keys-listed-")); | |
| 22 | const app = createApp({ dataDir, port: 0 }); | |
| 23 | const http: ForgeHttp = forgeHttp(app); | |
| 24 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 25 | if (!admin.ok) throw new Error(admin.error); | |
| 26 | const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" }); | |
| 27 | if (!made.ok) throw new Error(String(made.error)); | |
| 28 | for (const n of [1, 2]) { | |
| 29 | const done = await commitToBranch(app, admin.value, made.value, { message: `Change ${n}`, files: [{ path: `f${n}.txt`, content: String(n) }] }); | |
| 30 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 31 | } | |
| 32 | const branch = await createBranch(app, admin.value, made.value, { name: "feature/x" }); | |
| 33 | if (!branch.ok) throw new Error(String(branch.error)); | |
| 34 | app.db.run("UPDATE repo SET autonomy = 'autonomous' WHERE id = ?1", [made.value.id]); | |
| 35 | const cookie = `forge_session=${createSession(app.db, admin.value.id).token}`; | |
| 36 | return { | |
| 37 | app, | |
| 38 | get: async (path) => (await http.handle(new Request(`http://forge.test${path}`, { headers: { cookie } }))).text(), | |
| 39 | close: async () => { | |
| 40 | app.close(); | |
| 41 | await rm(dataDir, { recursive: true, force: true }); | |
| 42 | }, | |
| 43 | }; | |
| 44 | } |
test/keys-branches.test.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | /** The branches page joins the shell's j/k traversal: one data-nav row per branch, Enter opening its tree, Esc coming back, no key firing in a field. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { quietLogs } from "./helpers/globals.ts"; | |
| 4 | import { listedForge, type ListedForge } from "./helpers/listed-forge.ts"; | |
| 5 | import { expectListKeys, mainOf, navHrefs, shellPage } from "./helpers/shell-page.ts"; | |
| 6 | ||
| 7 | quietLogs(); | |
| 8 | let forge: ListedForge; | |
| 9 | beforeAll(async () => void (forge = await listedForge())); | |
| 10 | afterAll(() => forge.close()); | |
| 11 | ||
| 12 | const served = async () => mainOf(await forge.get("/alice/notes/branches")); | |
| 13 | ||
| 14 | test("the served page draws one focusable row per branch; j/k focus it, Enter opens its tree, Esc comes back, fields keep their keys", async () => { | |
| 15 | const html = await served(); | |
| 16 | const hrefs = await navHrefs(html); | |
| 17 | expect([...hrefs].sort()).toEqual(["/alice/notes/tree/feature%2Fx", "/alice/notes/tree/main"]); | |
| 18 | await expectListKeys({ path: "/alice/notes/branches", html, hrefs, opened: { path: hrefs[0]!, body: "<main>tree</main>" } }); | |
| 19 | }); | |
| 20 | ||
| 21 | test("the focus marker is the row itself, and `?` lists the keys", async () => { | |
| 22 | const p = await shellPage("/alice/notes/branches", await served()); | |
| 23 | p.key("j"); | |
| 24 | expect(p.doc.activeElement.getAttribute("tabindex")).toBe("-1"); | |
| 25 | expect(p.doc.activeElement.hasAttribute("data-nav")).toBe(true); | |
| 26 | expect(p.key("?").defaultPrevented).toBe(true); | |
| 27 | }); |
test/keys-chain.test.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | /** The proposals to the parent forge join the shell's j/k traversal: one data-nav row each, Enter opening the parent's pull, no key firing in a field. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import type { ForgeProposal } from "../src/actions/upstream.ts"; | |
| 4 | import { proposalSection } from "../src/web/pages/chain.ts"; | |
| 5 | import { expectListKeys, navHrefs, shellPage } from "./helpers/shell-page.ts"; | |
| 6 | ||
| 7 | const proposal = (n: number): ForgeProposal => ({ | |
| 8 | number: n, title: `Proposal ${n}`, branch: `b${n}`, state: "open", reachable: true, url: `https://parent.test/a/b/pulls/${n}`, | |
| 9 | }); | |
| 10 | const markup = () => String(proposalSection("/alice/notes", [proposal(1), proposal(2)], false)); | |
| 11 | ||
| 12 | test("each proposal is a focusable row; j/k focus it, Enter opens it, fields keep their keys", async () => { | |
| 13 | const hrefs = await navHrefs(markup()); | |
| 14 | expect(hrefs).toEqual(["https://parent.test/a/b/pulls/1", "https://parent.test/a/b/pulls/2"]); | |
| 15 | // The links leave for the parent forge, so there is no page of ours to press Esc on. | |
| 16 | await expectListKeys({ path: "/alice/notes/following", html: markup(), hrefs, opened: null }); | |
| 17 | }); | |
| 18 | ||
| 19 | test("the focus marker is the row itself, and `?` lists the keys", async () => { | |
| 20 | const p = await shellPage("/alice/notes/following", markup()); | |
| 21 | p.key("j"); | |
| 22 | expect(p.doc.activeElement.getAttribute("tabindex")).toBe("-1"); | |
| 23 | expect(p.key("?").defaultPrevented).toBe(true); | |
| 24 | }); | |
| 25 | ||
| 26 | test("no proposals, no data-nav row", () => { | |
| 27 | expect(String(proposalSection("/alice/notes", [], false))).not.toContain("data-nav"); | |
| 28 | }); |
test/keys-commits.test.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | /** The commit log joins the shell's j/k traversal: one data-nav link row per commit, Enter opening it, Esc coming back, no key firing in a field. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { quietLogs } from "./helpers/globals.ts"; | |
| 4 | import { listedForge, type ListedForge } from "./helpers/listed-forge.ts"; | |
| 5 | import { expectListKeys, mainOf, navHrefs, shellPage } from "./helpers/shell-page.ts"; | |
| 6 | ||
| 7 | quietLogs(); | |
| 8 | let forge: ListedForge; | |
| 9 | beforeAll(async () => void (forge = await listedForge())); | |
| 10 | afterAll(() => forge.close()); | |
| 11 | ||
| 12 | const served = async () => mainOf(await forge.get("/alice/notes/commits?ref=main")); | |
| 13 | ||
| 14 | test("the served log draws one focusable row per commit; j/k focus it, Enter opens it, Esc comes back, fields keep their keys", async () => { | |
| 15 | const html = await served(); | |
| 16 | const hrefs = await navHrefs(html); | |
| 17 | expect(hrefs.length).toBe(2); | |
| 18 | for (const h of hrefs) expect(h).toMatch(/^\/alice\/notes\/commit\/[0-9a-f]{40}$/); | |
| 19 | await expectListKeys({ path: "/alice/notes/commits", html, hrefs, opened: { path: hrefs[0]!, body: "<main>commit</main>" } }); | |
| 20 | }); | |
| 21 | ||
| 22 | test("the focus marker is the row itself, and `?` lists the keys", async () => { | |
| 23 | const p = await shellPage("/alice/notes/commits?ref=main", await served()); | |
| 24 | p.key("j"); | |
| 25 | expect(p.doc.activeElement.getAttribute("tabindex")).toBe("-1"); | |
| 26 | expect(p.key("?").defaultPrevented).toBe(true); | |
| 27 | }); |
test/keys-crew.test.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** The crew page's spending rows join the shell's j/k traversal: one data-nav row per autonomous app, Enter opening its autonomy settings, | |
| 2 | * Esc coming back, no key firing in a field. */ | |
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { quietLogs } from "./helpers/globals.ts"; | |
| 5 | import { listedForge, type ListedForge } from "./helpers/listed-forge.ts"; | |
| 6 | import { expectListKeys, mainOf, navHrefs, shellPage } from "./helpers/shell-page.ts"; | |
| 7 | ||
| 8 | quietLogs(); | |
| 9 | let forge: ListedForge; | |
| 10 | beforeAll(async () => void (forge = await listedForge())); | |
| 11 | afterAll(() => forge.close()); | |
| 12 | ||
| 13 | /** The spending list out of the served page: the settings menu around it has rows of its own. */ | |
| 14 | async function served(): Promise<string> { | |
| 15 | const { Window } = await import("happy-dom"); | |
| 16 | const doc = new Window({ url: "http://forge.test/" }).document as any; | |
| 17 | doc.body.innerHTML = mainOf(await forge.get("/settings/crew")); | |
| 18 | return doc.querySelector('a[href$="/settings/autonomy"]').closest("ul").outerHTML; | |
| 19 | } | |
| 20 | ||
| 21 | test("the served page draws one focusable spending row per autonomous app; j/k focus it, Enter opens its autonomy page, Esc comes back, fields keep their keys", async () => { | |
| 22 | const html = await served(); | |
| 23 | const hrefs = await navHrefs(html); | |
| 24 | expect(hrefs).toEqual(["/alice/notes/settings/autonomy"]); | |
| 25 | await expectListKeys({ path: "/settings/crew", html, hrefs, opened: { path: hrefs[0]!, body: "<main>autonomy</main>" } }); | |
| 26 | }); | |
| 27 | ||
| 28 | test("the focus marker is the row itself, and `?` lists the keys", async () => { | |
| 29 | const p = await shellPage("/settings/crew", await served()); | |
| 30 | p.key("j"); | |
| 31 | expect(p.doc.activeElement.getAttribute("tabindex")).toBe("-1"); | |
| 32 | expect(p.key("?").defaultPrevented).toBe(true); | |
| 33 | }); |
Hand-built fixtures with
as nevercasts hide drift; type them or build through the page's own data path, and assert the served page uses branchRow.