Public: anyone can read this; the crew can change it.
j/k, enter, esc and `?` on the lists that still lack them, one test file each #10785
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10774-j-k-enter-esc-and-on-the-lists-that-stil → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +148
−6
src/web/pages/chain.ts +2 −2
| @@ -79,14 +79,14 @@ async function carriesChain(ctx: Ctx): Promise<boolean> { | ||
| 79 | 79 | |
| 80 | 80 | const STATE_WORDS = { open: "open", merged: "merged", closed: "closed" } as const; |
| 81 | 81 | |
| 82 | function proposalSection(base: string, proposals: readonly ForgeProposal[], writer: boolean): Html { | |
| 82 | export function proposalSection(base: string, proposals: readonly ForgeProposal[], writer: boolean): Html { | |
| 83 | 83 | return html`<h2 class="mt">Proposals</h2> |
| 84 | 84 | ${proposals.length === 0 |
| 85 | 85 | ? html`<p class="muted">Nothing proposed to the parent forge yet.</p>` |
| 86 | 86 | : html`<ul class="list" aria-label="Proposals to the parent forge"> |
| 87 | 87 | ${proposals.map( |
| 88 | 88 | (p) => html`<li> |
| 89 | <div class="row"> | |
| 89 | <div class="row" data-nav tabindex="-1"> | |
| 90 | 90 | <span class="row-main"> |
| 91 | 91 | <a class="row-title" href="${p.url}" rel="noopener">#${p.number} ${p.title}</a> |
| 92 | 92 | <span class="row-meta">${STATE_WORDS[p.state]}${p.reachable ? null : html` · parent unreachable`}</span> |
src/web/pages/contribute.ts +9 −4
| @@ -7,9 +7,16 @@ | ||
| 7 | 7 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 8 | 8 | import type { Ctx } from "../../http/context.ts"; |
| 9 | 9 | import { welcome } from "../../system/telemetry.ts"; |
| 10 | import { html } from "../html.ts"; | |
| 10 | import { html, type Html } from "../html.ts"; | |
| 11 | 11 | import { page } from "../layout.ts"; |
| 12 | 12 | |
| 13 | /** The welcomed issues as rows the shell's j/k walk; Enter opens the issue. */ | |
| 14 | export function welcomedList(issues: readonly { path: string; repo: string; number: number; title: string }[]): Html { | |
| 15 | return html`<ul class="list">${issues.map( | |
| 16 | (i) => html`<li class="row" data-nav tabindex="-1"><a href="${i.path}">${i.repo}#${String(i.number)}</a> ${i.title}</li>`, | |
| 17 | )}</ul>`; | |
| 18 | } | |
| 19 | ||
| 13 | 20 | // GET /contribute |
| 14 | 21 | export function contributePage(ctx: Ctx): Response { |
| 15 | 22 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -25,9 +32,7 @@ export function contributePage(ctx: Ctx): Response { | ||
| 25 | 32 | <h2 id="welcome-h">Issues that welcome outside work</h2> |
| 26 | 33 | ${w.issues.length === 0 |
| 27 | 34 | ? html`<p class="muted">No open issue carries the “${w.welcomeLabel}” label right now.</p>` |
| 28 | : html`<ul class="list">${w.issues.map( | |
| 29 | (i) => html`<li class="row"><a href="${i.path}">${i.repo}#${String(i.number)}</a> ${i.title}</li>`, | |
| 30 | )}</ul>`} | |
| 35 | : welcomedList(w.issues)} | |
| 31 | 36 | </section> |
| 32 | 37 | <section class="stack" aria-labelledby="propose-h"> |
| 33 | 38 | <h2 id="propose-h">How to propose</h2> |
test/keys-contribute.test.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** The real /contribute page: its welcomed issues are j/k rows (#10774). Focus marker, Enter, Esc, field guard; no row when none. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { quietLogs } from "./helpers/globals.ts"; | |
| 11 | import { expectListKeys, mainOf, navHrefs, shellPage } from "./helpers/shell-page.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | let dir = ""; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let ownerId = ""; | |
| 19 | let repoId = ""; | |
| 20 | const page = async () => mainOf(await (await fetch(`${servers.url}/contribute`)).text()); | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dir = await mkdtemp(join(tmpdir(), "forge-keys-contribute-")); | |
| 24 | app = createApp({ dataDir: dir, port: 0 }); | |
| 25 | servers = await serve(app); | |
| 26 | const u = await createUser(app.db, { | |
| 27 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true, | |
| 28 | }); | |
outdated
Chain proposals share a file named for /contribute. The issue wants one test file per page: split into its own keys file, and cover Esc (opened is null) or explain. |
||
| 29 | if (!u.ok) throw new Error(u.error); | |
| 30 | const repo = await createRepo(app, u.value, { name: "garden", visibility: "public" }); | |
| 31 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 32 | ownerId = u.value.id; | |
| 33 | repoId = repo.value.id; | |
| 34 | }); | |
| 35 | afterAll(async () => { | |
| 36 | servers.stop(); | |
| 37 | app.close(); | |
| 38 | await rm(dir, { recursive: true, force: true }); | |
| 39 | }); | |
| 40 | ||
| 41 | test("with no welcomed issue the page draws no data-nav row", async () => { | |
| 42 | const html = await page(); | |
| 43 | expect(html).toContain("No open issue carries"); | |
| 44 | expect(html).not.toContain("data-nav"); | |
| 45 | }); | |
| 46 | ||
| 47 | test("each welcomed issue is a row: j/k focus it, Enter opens it, Esc on the issue returns, fields keep their keys", async () => { | |
| 48 | app.db.query("INSERT INTO label (id, repo_id, name) VALUES ('LBLW', ?1, 'help wanted')").run(repoId); | |
| 49 | for (const n of [1, 2]) { | |
| 50 | app.db.query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?1, ?2, ?3, 'issue', ?4, ?5)").run(`ISS${n}`, repoId, n, `tidy ${n}`, ownerId); | |
| 51 | app.db.query("INSERT INTO issue_label (issue_id, label_id) VALUES (?1, 'LBLW')").run(`ISS${n}`); | |
| 52 | } | |
| 53 | const html = await page(); | |
| 54 | const hrefs = await navHrefs(html); | |
| 55 | expect(hrefs.sort()).toEqual(["/alice/garden/issues/1", "/alice/garden/issues/2"]); | |
| 56 | await expectListKeys({ path: "/contribute", html, hrefs: await navHrefs(html), opened: { path: hrefs[0]!, body: "<main>issue</main>" } }); | |
| 57 | const p = await shellPage("/contribute", html); | |
| 58 | p.key("j"); | |
| 59 | expect(p.doc.activeElement.getAttribute("tabindex")).toBe("-1"); | |
| 60 | }); |
test/keys-following.test.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** The real /:owner/:name/following page: its proposals to the parent forge are j/k rows (#10774). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | import { expectListKeys, mainOf, navHrefs } from "./helpers/shell-page.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | let dir = ""; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let repoId = ""; | |
| 20 | let cookie = ""; | |
| 21 | const FAR = Date.now() + 10 * 365 * 86_400_000; // checked "recently": the parent is not asked | |
| 22 | const add = (n: number) => | |
| 23 | app.db | |
| 24 | .query( | |
| 25 | `INSERT INTO upstream_proposal (repo_id, branch, number, title, source, state, reachable, checked_at, created_at) | |
| 26 | VALUES (?1, ?2, ?3, 't', 's', 'open', 1, ?4, ?4)`, | |
| 27 | ) | |
| 28 | .run(repoId, `b${n}`, n, FAR); | |
| 29 | const page = async () => { | |
| 30 | const res = await fetch(`${servers.url}/alice/up/following`, { headers: { cookie } }); | |
| 31 | expect(res.status).toBe(200); | |
| 32 | return mainOf(await res.text()); | |
| 33 | }; | |
| 34 | ||
| 35 | beforeAll(async () => { | |
| 36 | dir = await mkdtemp(join(tmpdir(), "forge-keys-following-")); | |
| 37 | app = createApp({ dataDir: dir, port: 0 }); | |
| 38 | servers = await serve(app); | |
| 39 | const u = await createUser(app.db, { | |
| 40 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true, | |
| 41 | }); | |
| 42 | if (!u.ok) throw new Error(u.error); | |
| 43 | const repo = await createRepo(app, u.value, { name: "up", visibility: "public" }); | |
| 44 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 45 | repoId = repo.value.id; | |
| 46 | await Bun.write( | |
| 47 | join(dir, "parent.json"), | |
| 48 | JSON.stringify({ | |
| 49 | upstream: "https://parent.invalid", | |
| 50 | proposal: { child: "kid", token: "tok-abcdefgh" }, | |
| 51 | forgeCode: { repo: "alice/up", upstream: "root/forge", mode: "tell-me", tip: "a".repeat(40), chain: [] }, | |
| 52 | }), | |
| 53 | ); | |
| 54 | cookie = `forge_session=${createSession(app.db, u.value.id).token}`; | |
| 55 | }, 60_000); | |
| 56 | afterAll(async () => { | |
| 57 | servers.stop(); | |
| 58 | app.close(); | |
| 59 | await rm(dir, { recursive: true, force: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | test("with no proposal the page draws the empty sentence and no proposal row", async () => { | |
| 63 | const html = await page(); | |
| 64 | expect(html).toContain("Nothing proposed to the parent forge yet"); | |
| 65 | expect(await navHrefs(html)).toEqual([]); | |
| 66 | }); | |
| 67 | ||
| 68 | test("each proposal is a row: j/k focus it, Enter opens it, fields keep their keys; Esc has nowhere to go (the parent forge is another site)", async () => { | |
| 69 | add(1); | |
| 70 | add(2); | |
| 71 | const html = await page(); | |
| 72 | const hrefs = await navHrefs(html); | |
| 73 | expect(hrefs.length).toBe(2); | |
| 74 | expect(hrefs.every((h) => h.startsWith("https://parent.invalid/"))).toBe(true); | |
| 75 | // opened: null asserts Esc on the page does not leave: the opened page is off-site, so no return is stored for it. | |
| 76 | await expectListKeys({ path: "/alice/up/following", html, hrefs, opened: null }); | |
| 77 | }); |
Row gets data-nav, but nothing tests the real chain page renders this section; export plus fragment test would pass even if the page stopped calling proposalSection.