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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10822-j-k-enter-esc-and-on-the-list-pages-that → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/10822-j-k-enter-esc-and-on-the-list-pages-that was merged into main as d37bd1a.

Review

Sign in to review this change.
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()));
outdated

Hand-built fixtures with as never casts hide drift; type them or build through the page's own data path, and assert the served page uses branchRow.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs