Public: anyone can read this; the crew can change it.
j/k, enter, esc and ? on features, with test/keys-<page>.test.ts #10811
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10799-j-k-enter-esc-and-on-features-with-test → main 2 commits fork
2 files
changed +102
−1
src/web/pages/features.ts +3 −1
| @@ -67,6 +67,8 @@ export function provenanceRows(p: FeatureView["provenance"]) { | ||
| 67 | 67 | function featureRow(f: FeatureView) { |
| 68 | 68 | const base = `/settings/features/${encodeURIComponent(f.slug)}`; |
| 69 | 69 | return row({ |
| 70 | href: base, | |
| 71 | nav: true, | |
| 70 | 72 | title: html`${f.name} <span class="muted small mono">${f.repo}</span> |
| 71 | 73 | ${f.absorbed ? badge("absorbed", { tone: "success" }) : null} |
| 72 | 74 | ${f.pending !== null ? badge("declaration changed: review", { tone: "attn" }) : null}`, |
| @@ -81,7 +83,7 @@ function featureRow(f: FeatureView) { | ||
| 81 | 83 | : html`${f.summary ?? ""}${when(f.summary !== null, () => html`<br />`)}${f.settings.length} setting${f.settings.length === 1 ? "" : "s"}${f.absorbed |
| 82 | 84 | ? html` · absorbed by ${f.absorbedBy ?? "someone"}` |
| 83 | 85 | : null}`}<br /><span id="sw-${f.slug}">${switchNote(f)}</span>`, |
| 84 | trail: html`${when(f.pending !== null, () => html`<form class="row-fields" method="post" action="${base}/accept"> | |
| 86 | after: html`${when(f.pending !== null, () => html`<form class="row-fields" method="post" action="${base}/accept"> | |
| 85 | 87 | <input type="hidden" name="name" value="${f.pending?.name ?? ""}" /> |
| 86 | 88 | <input type="hidden" name="page" value="${f.pending?.page ?? ""}" /> |
| 87 | 89 | ${btn("Accept", { size: "sm", label: `Accept ${f.pending?.name ?? ""} as ${f.name}'s menu entry` })} |
test/keys-features.test.ts added +99
| @@ -0,0 +1,99 @@ | ||
| 1 | /** | |
| 2 | * The real /settings/features page and the shell's keys. Each declared feature | |
| 3 | * is a j/k row whose link opens its own settings page; its Absorb / Settings / | |
| 4 | * Accept forms sit beside the link (the row's `after`), not inside it, so they | |
| 5 | * are not rows and Enter never submits one. The settings sections share the | |
| 6 | * same walk, as on every settings page. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { mainOf, shellPage } from "./helpers/shell-page.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir = ""; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let html = ""; | |
| 26 | let detail = ""; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dir = await mkdtemp(join(tmpdir(), "forge-keys-features-")); | |
| 30 | app = createApp({ dataDir: dir, port: 0, host: "forge.test" }); | |
| 31 | servers = await serve(app); | |
| 32 | const made = await createUser(app.db, { | |
| 33 | origin: "seed", trusted: true, username: "ada", email: "[email protected]", | |
| 34 | password: "a-good-passphrase", adminIfFirst: true, | |
| 35 | }); | |
| 36 | if (!made.ok) throw new Error(made.error); | |
| 37 | const repo = await createRepo(app, made.value, { name: "radar", visibility: "private", description: "" }); | |
| 38 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 39 | const forgeJson = JSON.stringify({ app: { run: "bun run x.ts" }, feature: { name: "Radar", page: "/", settings: {} } }); | |
| 40 | await commitToBranch(app, made.value, repo.value, { message: "feature", files: [{ path: "forge.json", content: forgeJson }] }); | |
| 41 | insertApp(app.db, { repoId: repo.value.id, slug: "radar", access: "private" }); | |
| 42 | const signed = await fetch(`${servers.url}/signin`, { | |
| 43 | method: "POST", | |
| 44 | body: new URLSearchParams({ username: "ada", password: "a-good-passphrase" }), | |
| 45 | redirect: "manual", | |
| 46 | }); | |
| 47 | const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 48 | html = mainOf(await (await fetch(`${servers.url}/settings/features`, { headers: { cookie } })).text()); | |
| 49 | detail = mainOf(await (await fetch(`${servers.url}/settings/features/radar`, { headers: { cookie } })).text()); | |
| 50 | }, 60_000); | |
| 51 | afterAll(async () => { | |
| 52 | servers?.stop(); | |
| 53 | app?.close(); | |
| 54 | if (dir) await rm(dir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a feature is one row linking its page; its forms are beside the link, not rows", () => { | |
| 58 | expect(html).toContain("Radar"); | |
| 59 | const row = html.slice(html.indexOf('href="/settings/features/radar"') - 3); | |
| 60 | expect(row.slice(0, 2)).toBe("<a"); | |
| 61 | expect(row.slice(0, row.indexOf("</a>"))).toContain("data-nav"); | |
| 62 | expect(row.slice(0, row.indexOf("</a>"))).not.toContain("<form"); | |
| 63 | expect(row).toContain('action="/settings/features/radar/absorb"'); | |
| 64 | }); | |
| 65 | ||
| 66 | test("j/k reach the feature row with the focus marker, Enter opens it, fields keep their keys, `?` helps", async () => { | |
| 67 | const p = await shellPage("/settings/features", html + "<input id=f>"); | |
| 68 | const rows = [...p.doc.querySelectorAll("[data-nav]")] as any[]; | |
| 69 | const at = rows.findIndex((r) => r.getAttribute("href") === "/settings/features/radar"); | |
| 70 | expect(at).toBeGreaterThan(0); | |
| 71 | expect(rows[at].getAttribute("tabindex")).toBe("-1"); | |
| 72 | ||
| 73 | for (let i = 0; i < rows.length && p.doc.activeElement !== rows[at]; i++) p.key("j"); | |
| 74 | expect(p.doc.activeElement).toBe(rows[at]); | |
| 75 | p.key("k"); | |
| 76 | expect(p.doc.activeElement).toBe(rows[at - 1]); | |
| 77 | p.key("j"); | |
| 78 | p.key("Enter"); | |
| 79 | expect(p.clicks).toEqual(["/settings/features/radar"]); | |
| 80 | const stored = JSON.parse(p.win.sessionStorage.getItem("nav-from")!); | |
| 81 | expect(stored).toMatchObject({ list: "/settings/features", href: "/settings/features/radar" }); | |
| 82 | ||
| 83 | const f = p.doc.getElementById("f") as any; | |
| 84 | f.focus(); | |
| 85 | const before = p.clicks.length; | |
| 86 | p.key("j", f); | |
| 87 | p.key("Enter", f); | |
| 88 | expect(p.doc.activeElement).toBe(f); | |
| 89 | expect(p.clicks.length).toBe(before); | |
| 90 | ||
| 91 | expect(p.key("?").defaultPrevented).toBe(true); | |
| 92 | }); | |
| 93 | ||
| 94 | test("Esc on the feature's page: it carries the settings sections, which are rows, so it stays put", async () => { | |
| 95 | const stored = JSON.stringify({ list: "/settings/features", to: "/settings/features/radar", href: "/settings/features/radar" }); | |
| 96 | const back = await shellPage("/settings/features/radar", detail, { "nav-from": stored }); | |
| 97 | back.key("Escape"); | |
| 98 | expect(back.assigned).toEqual([]); | |
| 99 | }); |