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

Merged opened this

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

Merged

scout/forge:scout/10774-j-k-enter-esc-and-on-the-lists-that-stil → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/10774-j-k-enter-esc-and-on-the-lists-that-stil was merged into main as 5b351f0.

Review

Sign in to review this change.
4 files changed +148 −6
src/web/pages/chain.ts +2 −2
@@ -79,14 +79,14 @@ async function carriesChain(ctx: Ctx): Promise<boolean> {
7979
8080 const STATE_WORDS = { open: "open", merged: "merged", closed: "closed" } as const;
8181
82 function proposalSection(base: string, proposals: readonly ForgeProposal[], writer: boolean): Html {
82 export function proposalSection(base: string, proposals: readonly ForgeProposal[], writer: boolean): Html {
8383 return html`<h2 class="mt">Proposals</h2>
8484 ${proposals.length === 0
8585 ? html`<p class="muted">Nothing proposed to the parent forge yet.</p>`
8686 : html`<ul class="list" aria-label="Proposals to the parent forge">
8787 ${proposals.map(
8888 (p) => html`<li>
89 <div class="row">
89 <div class="row" data-nav tabindex="-1">

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.

9090 <span class="row-main">
9191 <a class="row-title" href="${p.url}" rel="noopener">#${p.number} ${p.title}</a>
9292 <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 @@
77 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
88 import type { Ctx } from "../../http/context.ts";
99 import { welcome } from "../../system/telemetry.ts";
10 import { html } from "../html.ts";
10 import { html, type Html } from "../html.ts";
1111 import { page } from "../layout.ts";
1212
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
1320 // GET /contribute
1421 export function contributePage(ctx: Ctx): Response {
1522 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -25,9 +32,7 @@ export function contributePage(ctx: Ctx): Response {
2532 <h2 id="welcome-h">Issues that welcome outside work</h2>
2633 ${w.issues.length === 0
2734 ? 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)}
3136 </section>
3237 <section class="stack" aria-labelledby="propose-h">
3338 <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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs