Public: anyone can read this; the crew can change it.
The teams list is keyboard-first: j/k move between rows, enter opens one #10368
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10356-the-teams-list-is-keyboard-first-j-k-mov → main 9 commits fork 6 threads · 6 unresolved
src/http/settings_routes.ts +2 −1
| @@ -12,7 +12,7 @@ import type { Router } from "./router.ts"; | ||
| 12 | 12 | import { parentLinkStart } from "./rp.ts"; |
| 13 | 13 | import { accessPage } from "../web/pages/access.ts"; |
| 14 | 14 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 15 | import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts"; | |
| 15 | import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts"; | |
| 16 | 16 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 17 | 17 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 18 | 18 | import { economicsPage, economicsSet } from "../web/pages/economics.ts"; |
| @@ -250,6 +250,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 250 | 250 | router.add("POST", "/settings/crew", agentCreate); |
| 251 | 251 | router.add("GET", "/settings/teams", teamsPage); |
| 252 | 252 | router.add("GET", "/settings/teams/stream", teamsStream); |
| 253 | router.add("GET", "/settings/teams/:slug", teamPage); | |
| 253 | 254 | router.add("POST", "/settings/teams/:slug", teamSet); |
| 254 | 255 | router.add("GET", "/settings/crew/:username", agentPage); |
| 255 | 256 | router.add("POST", "/settings/crew/:username", agentUpdate); |
src/web/pages/teams.ts +89 −50
| @@ -1,11 +1,14 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Crew teams, for a forge admin (`core/teams.ts`): each team's charter, its |
| 3 | * dial and who is on it, set on one page linked from the crew page. Anyone | |
| 4 | * else gets the not-found page, before a body is read. | |
| 3 | * dial and who is on it. The list at /settings/teams is one j/k row per team; | |
| 4 | * Enter opens the team's own page, where it is set. Anyone else gets the | |
| 5 | * not-found page, before a body is read. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import { changeTeam, readTeamQueue, readTeams, teamChanges, TEAM_SAYS } from "../../actions/teams.ts"; |
| 8 | import { CHARTER_MAX, DIAL_MAX } from "../../core/teams.ts"; | |
| 9 | import type { Agent } from "../../core/agents.ts"; | |
| 10 | import { CHARTER_MAX, DIAL_MAX, type Team } from "../../core/teams.ts"; | |
| 11 | import type { QueuedPull } from "../../core/team_queue.ts"; | |
| 9 | 12 | import { timeAgo } from "../ui.ts"; |
| 10 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 11 | 14 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| @@ -14,7 +17,7 @@ import { field, readForm } from "../form.ts"; | ||
| 14 | 17 | import { eventStream } from "../../http/sse.ts"; |
| 15 | 18 | import { html, raw, type Html } from "../html.ts"; |
| 16 | 19 | import { flashTo, page } from "../layout.ts"; |
| 17 | import { btn, icon } from "../ui.ts"; | |
| 20 | import { btn, icon, row } from "../ui.ts"; | |
| 18 | 21 | import { accountSections } from "./origins.ts"; |
| 19 | 22 | |
| 20 | 23 | const isAdminPerson = (ctx: Ctx): boolean => |
| @@ -26,7 +29,7 @@ function regions(ctx: Ctx, timed = false): Record<string, string> | undefined { | ||
| 26 | 29 | timed ? timePhase(ctx.request, name, work) : work(); |
| 27 | 30 | const read = phase("teams", () => readTeams(ctx.app, ctx.auth.user)); |
| 28 | 31 | if (!read.ok) return undefined; |
| 29 | const { teams, agents } = read.value; | |
| 32 | const { teams } = read.value; | |
| 30 | 33 | const changes = phase("changes", () => teamChanges(ctx.app, 10)); |
| 31 | 34 | const total = teams.reduce((n, t) => n + t.dial, 0); |
| 32 | 35 | const queues = phase("queue", () => |
| @@ -36,50 +39,18 @@ function regions(ctx: Ctx, timed = false): Record<string, string> | undefined { | ||
| 36 | 39 | })), |
| 37 | 40 | ); |
| 38 | 41 | const cards = teams.map((t) => { |
outdated
Principle 7: cards with forms, not rows. Also the id plus self-anchor link means Enter goes nowhere new; give the heading a real destination. |
||
| 39 | const waiting = queues.get(t.slug) ?? []; | |
| 40 | return html`<section> | |
| 41 | <h2>${icon("bot")} ${t.name}</h2> | |
| 42 | ${waiting.length === 0 | |
| 43 | ? html`<p class="muted small">Nothing is waiting on this team.</p>` | |
| 44 | : html`<p class="muted small">Waiting on this team, oldest first</p> | |
| 45 | <dl class="status-rows">${waiting.map( | |
| 46 | (w) => html`<div class="status-row"> | |
| 47 | <dt><a href="/${w.owner}/${w.repo}/pulls/${String(w.number)}">${w.owner}/${w.repo} #${String(w.number)}</a></dt> | |
| 48 | <dd>${w.title} <span class="muted">${timeAgo(w.createdAt)}</span></dd> | |
| 49 | </div>`, | |
| 50 | )}</dl>`} | |
| 51 | <form method="post" action="/settings/teams/${t.slug}"> | |
| 52 | <div class="field"> | |
| 53 | <label for="charter-${t.slug}">Charter</label> | |
| 54 | <textarea class="input" id="charter-${t.slug}" name="charter" rows="3" maxlength="${String(CHARTER_MAX)}">${t.charter}</textarea> | |
| 55 | <span class="hint">What it owns, what it reviews.</span> | |
| 56 | </div> | |
| 57 | <div class="field"> | |
| 58 | <label for="dial-${t.slug}">Dial (%)</label> | |
| 59 | <input class="input mono" id="dial-${t.slug}" type="number" name="dial" min="0" max="${String(DIAL_MAX)}" step="1" value="${String(t.dial)}" /> | |
| 60 | <span class="hint">Its share of builder slots and budget.</span> | |
| 61 | </div> | |
| 62 | <fieldset class="field"> | |
| 63 | <legend>Members</legend> | |
| 64 | ${agents.length === 0 | |
| 65 | ? html`<span class="muted small">No agents yet.</span>` | |
| 66 | : agents.map( | |
| 67 | (a) => html`<label class="small"> | |
| 68 | <input type="checkbox" name="member" value="${a.user.id}" ${t.members.includes(a.user.id) ? "checked" : ""} /> | |
| 69 | ${a.user.username} | |
| 70 | <span class="muted mono">${a.model === "" ? "default" : a.model}</span> | |
| 71 | </label><br />`, | |
| 72 | )} | |
| 73 | </fieldset> | |
| 74 | <div class="form-actions"> | |
| 75 | ${btn("Save", { variant: "primary", label: `Save ${t.name}` })} | |
| 76 | </div> | |
| 77 | </form> | |
| 78 | </section>`; | |
| 42 | const waiting = queues.get(t.slug)?.length ?? 0; | |
outdated
Principle 1: a heading link to its own anchor says nothing twice and does nothing. |
||
| 43 | return row({ | |
| 44 | nav: true, | |
| 45 | href: `/settings/teams/${t.slug}`, | |
| 46 | lead: icon("bot"), | |
| 47 | title: t.name, | |
| 48 | meta: html`${String(t.dial)}% · ${String(t.members.length)} ${t.members.length === 1 ? "member" : "members"} · ${String(waiting)} waiting`, | |
| 49 | }); | |
| 79 | 50 | }); |
| 80 | 51 | const out: Record<string, Html> = { |
| 81 | 52 | "teams-total": html`${String(total)}%`, |
| 82 | "teams-cards": html`${cards}`, | |
| 53 | "teams-cards": cards.length === 0 ? html`<p class="muted small">No teams yet.</p>` : html`<ul class="list">${cards}</ul>`, | |
| 83 | 54 | "teams-changes": |
| 84 | 55 | changes.length === 0 |
| 85 | 56 | ? html`` |
| @@ -93,10 +64,74 @@ function regions(ctx: Ctx, timed = false): Record<string, string> | undefined { | ||
| 93 | 64 | return Object.fromEntries(Object.entries(out).map(([k, h]) => [k, h.text])); |
| 94 | 65 | } |
| 95 | 66 | |
| 67 | /** One team's page: who is waiting on it, and the form that sets its charter, dial and members. */ | |
| 68 | function teamBody(t: Team, agents: readonly Agent[], waiting: readonly QueuedPull[]): Html { | |
| 69 | return html`${waiting.length === 0 | |
| 70 | ? html`<p class="muted small">Nothing is waiting on this team.</p>` | |
| 71 | : html`<p class="muted small">Waiting on this team, oldest first</p> | |
| 72 | <dl class="status-rows">${waiting.map( | |
| 73 | (w) => html`<div class="status-row"> | |
| 74 | <dt><a href="/${w.owner}/${w.repo}/pulls/${String(w.number)}">${w.owner}/${w.repo} #${String(w.number)}</a></dt> | |
| 75 | <dd>${w.title} <span class="muted">${timeAgo(w.createdAt)}</span></dd> | |
| 76 | </div>`, | |
| 77 | )}</dl>`} | |
| 78 | <form method="post" action="/settings/teams/${t.slug}"> | |
| 79 | <div class="field"> | |
| 80 | <label for="charter-${t.slug}">Charter</label> | |
| 81 | <textarea class="input" id="charter-${t.slug}" name="charter" rows="3" maxlength="${String(CHARTER_MAX)}">${t.charter}</textarea> | |
| 82 | <span class="hint">What it owns, what it reviews.</span> | |
| 83 | </div> | |
| 84 | <div class="field"> | |
| 85 | <label for="dial-${t.slug}">Dial (%)</label> | |
| 86 | <input class="input mono" id="dial-${t.slug}" type="number" name="dial" min="0" max="${String(DIAL_MAX)}" step="1" value="${String(t.dial)}" /> | |
| 87 | <span class="hint">Its share of builder slots and budget.</span> | |
| 88 | </div> | |
| 89 | <fieldset class="field"> | |
| 90 | <legend>Members</legend> | |
| 91 | ${agents.length === 0 | |
| 92 | ? html`<span class="muted small">No agents yet.</span>` | |
| 93 | : agents.map( | |
| 94 | (a) => html`<label class="small"> | |
| 95 | <input type="checkbox" name="member" value="${a.user.id}" ${t.members.includes(a.user.id) ? "checked" : ""} /> | |
| 96 | ${a.user.username} | |
| 97 | <span class="muted mono">${a.model === "" ? "default" : a.model}</span> | |
| 98 | </label><br />`, | |
| 99 | )} | |
| 100 | </fieldset> | |
| 101 | <div class="form-actions"> | |
| 102 | ${btn("Save", { variant: "primary", label: `Save ${t.name}` })} | |
| 103 | </div> | |
| 104 | </form>`; | |
| 105 | } | |
| 106 | ||
| 107 | // GET /settings/teams/:slug | |
| 108 | export function teamPage(ctx: Ctx): Response { | |
| 109 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 110 | const read = timePhase(ctx.request, "teams", () => readTeams(ctx.app, ctx.auth.user)); | |
| 111 | if (!read.ok) return notFoundPage(ctx); | |
| 112 | const team = read.value.teams.find((t) => t.slug === (ctx.params.slug ?? "")); | |
| 113 | if (!team) return notFoundPage(ctx); | |
| 114 | const queue = timePhase(ctx.request, "queue", () => readTeamQueue(ctx.app, ctx.auth.user, team.slug)); | |
| 115 | const rendering = performance.now(); | |
| 116 | const out = page({ | |
| 117 | ctx, | |
| 118 | title: team.name, | |
| 119 | body: html`<div class="page-head"><h1>${team.name}</h1></div> | |
| 120 | ${settingsLayout( | |
| 121 | accountSections("crew", { admin: true }), | |
| 122 | html`<p class="muted small">${btn("All teams", { href: "/settings/teams", variant: "ghost" })}</p> | |
| 123 | ${teamBody(team, read.value.agents, queue.ok ? queue.value : [])}`, | |
| 124 | "Account settings", | |
| 125 | )}`, | |
| 126 | }); | |
| 127 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 128 | return out; | |
| 129 | } | |
| 130 | ||
| 96 | 131 | /** |
| 97 | 132 | * The client: one EventSource; a pushed region replaces the element of its |
| 98 | * id. The cards are left alone while one of their fields has focus, so a | |
| 99 | * viewer's half-typed edit is never lost. Our own server's escaped markup. | |
| 133 | * id. The list is redrawn whole, so the focused row (found by its link) gets | |
| 134 | * focus back. Our own server's escaped markup. | |
| 100 | 135 | */ |
| 101 | 136 | export const teamsLiveJs = /* js */ ` |
| 102 | 137 | (function () { |
| @@ -110,8 +145,12 @@ export const teamsLiveJs = /* js */ ` | ||
| 110 | 145 | for (var id in regions) { |
| 111 | 146 | var el = document.getElementById(id); |
| 112 | 147 | if (!el || el.innerHTML === regions[id]) continue; |
| 113 | if (id === 'teams-cards' && el.contains(document.activeElement)) continue; | |
| 148 | var a = document.activeElement, kept = a && el.contains(a) ? a.getAttribute('href') : null; | |
| 114 | 149 | el.innerHTML = regions[id]; |
| 150 | if (kept) { | |
| 151 | var again = el.querySelectorAll('a[href]'); | |
| 152 | for (var i = 0; i < again.length; i++) if (again[i].getAttribute('href') === kept) { again[i].focus(); break; } | |
| 153 | } | |
| 115 | 154 | } |
| 116 | 155 | }); |
| 117 | 156 | })(); |
| @@ -175,7 +214,7 @@ export async function teamSet(ctx: Ctx): Promise<Response> { | ||
| 175 | 214 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); |
| 176 | 215 | return flashTo( |
| 177 | 216 | ctx, |
| 178 | "/settings/teams", | |
| 217 | `/settings/teams/${slug}`, | |
| 179 | 218 | changed.ok |
| 180 | 219 | ? { message: `${changed.value.name} saved at ${changed.value.dial}%.` } |
| 181 | 220 | : { tone: "danger", message: TEAM_SAYS[changed.error] }, |
test/crew-teams.test.ts +1 −1
| @@ -73,7 +73,7 @@ describe("crew teams", () => { | ||
| 73 | 73 | const res = await post(alice, "design", new URLSearchParams({ charter: "Owns <b>looks</b>", dial: "30" })); |
| 74 | 74 | expect(res.status).toBe(303); |
| 75 | 75 | expect(dialOf("design")).toBe(30); |
| 76 | const text = await (await fetch(`${servers.url}/settings/teams`, { headers: { cookie: cookie(alice) } })).text(); | |
| 76 | const text = await (await fetch(`${servers.url}/settings/teams/design`, { headers: { cookie: cookie(alice) } })).text(); | |
| 77 | 77 | expect(text).toContain('value="30"'); |
| 78 | 78 | expect(text).toContain("Owns <b>looks"); |
| 79 | 79 | expect(text).not.toContain("Owns <b>looks"); |
test/keys-teams-list.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** The teams list joins the shell's j/k row traversal: one data-nav row per team, each linking to the team's own page. */ | |
| 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 { listTeams } from "../src/core/teams.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 10 | import { teamsLiveJs } from "../src/web/pages/teams.ts"; | |
| 11 | ||
| 12 | let dataDir = ""; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | let bearer = ""; | |
| 16 | ||
| 17 | const get = async (path = "/settings/teams"): Promise<Response> => | |
| 18 | fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${bearer}` } }); | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | dataDir = await mkdtemp(join(tmpdir(), "forge-keys-teams-")); | |
| 22 | app = createApp({ dataDir, port: 0 }); | |
| 23 | servers = await serve(app); | |
| 24 | await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 25 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 26 | method: "POST", | |
| 27 | headers: { "content-type": "application/json" }, | |
| 28 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 29 | }); | |
| 30 | bearer = ((await minted.json()) as { token: string }).token; | |
| 31 | }); | |
| 32 | ||
| 33 | afterAll(async () => { | |
| 34 | servers.stop(); | |
| 35 | app.close(); | |
| 36 | await rm(dataDir, { recursive: true, force: true }); | |
| 37 | }); | |
| 38 | ||
| 39 | test("each team is one data-nav row whose link is that team's own page, and that page answers", async () => { | |
| 40 | const slugs = listTeams(app.db).map((t) => t.slug); | |
| 41 | expect(slugs.length).toBeGreaterThan(0); | |
| 42 | const text = await (await get()).text(); | |
| 43 | expect(text.match(/data-nav tabindex="-1"/g)!.length).toBe(slugs.length); | |
| 44 | for (const s of slugs) { | |
| 45 | expect(text).toMatch(new RegExp(`<a href="/settings/teams/${s}"[^>]*data-nav tabindex="-1"`)); | |
| 46 | const res = await get(`/settings/teams/${s}`); | |
| 47 | expect(res.status).toBe(200); | |
| 48 | expect(await res.text()).toContain(`action="/settings/teams/${s}"`); | |
| 49 | } | |
| 50 | expect((await get("/settings/teams/no-such-team")).status).toBe(404); | |
| 51 | }); | |
| 52 | ||
outdated
Asserting no tabindex=-1 anywhere is brittle; assert no data-nav instead. This only checks that the script text contains getAttribute(href) and .focus(); it would pass with a broken restore. Execute teamsLiveJs against a small DOM stub (a list with two links, focus on the second, apply a region message, assert the matching link has focus), so it fails without the change. |
||
| 53 | /** Runs the page's live script against a minimal DOM: the region is redrawn and the row that had focus gets it back. */ | |
| 54 | function runLive(): { region: { innerHTML: string }; push: (html: string) => void; focusHref: (h: string) => void; focused: () => string | null } { | |
| 55 | type Link = { href: string; getAttribute: (n: string) => string | null; focus: () => void }; | |
| 56 | let active: Link | null = null; | |
| 57 | let links: Link[] = []; | |
| 58 | let inner = ""; | |
| 59 | const region = { | |
| 60 | get innerHTML() { return inner; }, | |
| 61 | set innerHTML(v: string) { | |
outdated
Box 2 asks for none data-nav on the empty page, but this asserts only an absent href. Assert the page data-nav count equals the shell baseline (count on another admin page like /settings/capacity), so a stray row would fail. |
||
| 62 | inner = v; | |
| 63 | links = [...v.matchAll(/<a href="([^"]+)"/g)].map((m) => { | |
| 64 | const link: Link = { href: m[1]!, getAttribute: (n) => (n === "href" ? m[1]! : null), focus: () => { active = link; } }; | |
| 65 | return link; | |
| 66 | }); | |
| 67 | if (active && !links.includes(active)) active = null; // the old node is gone, as in a browser | |
| 68 | }, | |
| 69 | contains: (node: unknown) => links.includes(node as Link), | |
| 70 | querySelectorAll: () => links, | |
| 71 | }; | |
| 72 | let listener: ((e: { data: string }) => void) | null = null; | |
| 73 | class FakeEventSource { | |
| 74 | constructor(_url: string) {} | |
| 75 | addEventListener(_kind: string, fn: (e: { data: string }) => void) { listener = fn; } | |
| 76 | } | |
| 77 | const document = { | |
| 78 | getElementById: (id: string) => (id === "teams-cards" ? region : null), | |
| 79 | get activeElement() { return active; }, | |
| 80 | }; | |
| 81 | new Function("EventSource", "document", teamsLiveJs)(FakeEventSource, document); | |
| 82 | return { | |
| 83 | region, | |
| 84 | push: (html) => listener!({ data: JSON.stringify({ regions: { "teams-cards": html } }) }), | |
| 85 | focusHref: (h) => links.find((l) => l.href === h)!.focus(), | |
| 86 | focused: () => (active ? (active as Link).href : null), | |
| 87 | }; | |
| 88 | } | |
| 89 | ||
| 90 | const rows = (extra = "") => | |
| 91 | `<ul><li><a href="/settings/teams/a" data-nav tabindex="-1">a</a></li><li><a href="/settings/teams/b" data-nav tabindex="-1">b${extra}</a></li></ul>`; | |
| 92 | ||
| 93 | test("a live redraw gives focus back to the row that had it", () => { | |
| 94 | const live = runLive(); | |
| 95 | live.region.innerHTML = rows(); | |
| 96 | live.focusHref("/settings/teams/b"); | |
| 97 | live.push(rows(" (changed)")); | |
| 98 | expect(live.region.innerHTML).toContain("(changed)"); | |
| 99 | expect(live.focused()).toBe("/settings/teams/b"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a live redraw does not steal focus when none of its rows had it", () => { | |
| 103 | const live = runLive(); | |
| 104 | live.region.innerHTML = rows(); | |
| 105 | live.push(rows(" (changed)")); | |
| 106 | expect(live.focused()).toBeNull(); | |
| 107 | }); | |
| 108 | ||
| 109 | // Last: it deletes every team. | |
| 110 | test("with no teams the list draws no data-nav", async () => { | |
| 111 | app.db.query("DELETE FROM crew_team_member").run(); | |
| 112 | app.db.query("DELETE FROM crew_team").run(); | |
| 113 | const navs = (t: string) => (t.match(/\bdata-nav\b/g) ?? []).length; | |
| 114 | const baseline = navs(await (await get("/settings/capacity")).text()); | |
| 115 | const text = await (await get()).text(); | |
| 116 | expect(navs(text)).toBe(baseline); | |
| 117 | expect(text).not.toContain('href="/settings/teams/'); | |
| 118 | expect(text).toContain("No teams yet."); | |
| 119 | }); |
test/settings-routes.test.ts +1
| @@ -101,6 +101,7 @@ GET /settings/crew | ||
| 101 | 101 | POST /settings/crew |
| 102 | 102 | GET /settings/teams |
| 103 | 103 | GET /settings/teams/stream |
| 104 | GET /settings/teams/:slug | |
| 104 | 105 | POST /settings/teams/:slug |
| 105 | 106 | GET /settings/crew/:username |
| 106 | 107 | POST /settings/crew/:username |
test/team-queue.test.ts +5 −2
| @@ -113,7 +113,7 @@ describe("pulls waiting on a team", () => { | ||
| 113 | 113 | test("the web page escapes the title, links the pull and says so when a team has nothing", async () => { |
| 114 | 114 | const first = await pullNaming("<b>First</b>", ["design"]); |
| 115 | 115 | servers = await serve(app); |
| 116 | const res = await fetch(`${servers.url}/settings/teams`, { | |
| 116 | const res = await fetch(`${servers.url}/settings/teams/design`, { | |
| 117 | 117 | headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` }, |
| 118 | 118 | }); |
| 119 | 119 | const body = await res.text(); |
| @@ -122,7 +122,10 @@ describe("pulls waiting on a team", () => { | ||
| 122 | 122 | expect(body).toContain("<b>First</b>"); |
| 123 | 123 | expect(body).not.toContain("<b>First</b>"); |
| 124 | 124 | expect(body).toContain(`/alice/garden/pulls/${first.number}`); |
| 125 | expect(body).toContain("Nothing is waiting on this team."); | |
| 125 | const empty = await fetch(`${servers.url}/settings/teams/security`, { | |
| 126 | headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` }, | |
| 127 | }); | |
| 128 | expect(await empty.text()).toContain("Nothing is waiting on this team."); | |
| 126 | 129 | expect(body).not.toContain("<h3>Waiting on this team"); |
| 127 | 130 | }); |
| 128 | 131 |
test/teams-language.test.ts +22 −9
| @@ -1,4 +1,4 @@ | ||
| 1 | /** Settings › Teams speaks the home page's language: one heading, a section per team, a plain list; no card. */ | |
| 1 | /** Settings › Teams speaks the home page's language: one heading, a row per team, its own page with the forms; no card. */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| @@ -46,22 +46,33 @@ const teamsMain = async (): Promise<string> => { | ||
| 46 | 46 | return page.slice(page.indexOf("<main"), page.indexOf("</main>")); |
| 47 | 47 | }; |
| 48 | 48 | |
| 49 | test("one heading, a section per team with its forms, no card", async () => { | |
| 49 | const teamMain = async (slug: string): Promise<string> => { | |
| 50 | const res = await fetch(`${servers.url}/settings/teams/${slug}`, { headers: { cookie }, redirect: "manual" }); | |
| 51 | expect(res.status).toBe(200); | |
| 52 | const page = await res.text(); | |
| 53 | return page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 54 | }; | |
| 55 | ||
| 56 | test("the list is one heading and a row per team; each team's page holds its form, no card", async () => { | |
| 50 | 57 | const main = await teamsMain(); |
| 51 | 58 | expect(main.match(/<h1[\s>]/g)?.length).toBe(1); |
| 52 | 59 | expect(main).not.toContain('class="card'); |
| 53 | expect(main).not.toContain("card-body"); | |
| 54 | expect(main).toContain("<section>"); | |
| 55 | expect(main).toMatch(/<h2>[^]*?<\/h2>/); | |
| 56 | expect(main).toMatch(/action="\/settings\/teams\/[a-z0-9-]+"/); | |
| 60 | expect(main).toContain('<ul class="list">'); | |
| 61 | expect(main).not.toContain("<form"); | |
| 57 | 62 | expect(main).toMatch(/id="teams-total">\d+%</); |
| 63 | const slug = /href="\/settings\/teams\/([a-z0-9-]+)"/.exec(main)?.[1] ?? ""; | |
| 64 | expect(slug).not.toBe(""); | |
| 65 | const team = await teamMain(slug); | |
| 66 | expect(team.match(/<h1[\s>]/g)?.length).toBe(1); | |
| 67 | expect(team).not.toContain('class="card'); | |
| 68 | expect(team).toContain(`action="/settings/teams/${slug}"`); | |
| 58 | 69 | }); |
| 59 | 70 | |
| 60 | 71 | test("one POST saves; Recent changes is then a plain h2 and ul, and the total is still a percentage", async () => { |
| 61 | 72 | const before = await teamsMain(); |
| 62 | 73 | expect(before).not.toContain("Recent changes"); |
| 63 | const slug = /action="\/settings\/teams\/([a-z0-9-]+)"/.exec(before)?.[1] ?? ""; | |
| 64 | const dial = new RegExp(`id="dial-${slug}"[^>]*value="(\\d+)"`).exec(before)?.[1] ?? "0"; | |
| 74 | const slug = /href="\/settings\/teams\/([a-z0-9-]+)"/.exec(before)?.[1] ?? ""; | |
| 75 | const dial = new RegExp(`id="dial-${slug}"[^>]*value="(\\d+)"`).exec(await teamMain(slug))?.[1] ?? "0"; | |
| 65 | 76 | const post = await fetch(`${servers.url}/settings/teams/${slug}`, { |
| 66 | 77 | method: "POST", |
| 67 | 78 | headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, |
| @@ -69,8 +80,8 @@ test("one POST saves; Recent changes is then a plain h2 and ul, and the total is | ||
| 69 | 80 | redirect: "manual", |
| 70 | 81 | }); |
| 71 | 82 | expect(post.status).toBeLessThan(400); |
| 83 | expect(await teamMain(slug)).toContain("Ship small things."); | |
| 72 | 84 | const main = await teamsMain(); |
| 73 | expect(main).toContain("Ship small things."); | |
| 74 | 85 | expect(main).toMatch(/<h2>Recent changes<\/h2>\s*<ul[^>]*>\s*<li>/); |
| 75 | 86 | const section = main.slice(main.indexOf("<h2>Recent changes</h2>")); |
| 76 | 87 | expect(section).not.toContain("card"); |
| @@ -80,6 +91,8 @@ test("one POST saves; Recent changes is then a plain h2 and ul, and the total is | ||
| 80 | 91 | test("a non-admin still gets 404, on page and post", async () => { |
| 81 | 92 | const res = await fetch(`${servers.url}/settings/teams`, { headers: { cookie: other }, redirect: "manual" }); |
| 82 | 93 | expect(res.status).toBe(404); |
| 94 | const own = await fetch(`${servers.url}/settings/teams/design`, { headers: { cookie: other }, redirect: "manual" }); | |
| 95 | expect(own.status).toBe(404); | |
| 83 | 96 | const post = await fetch(`${servers.url}/settings/teams/x`, { method: "POST", headers: { cookie: other }, redirect: "manual" }); |
| 84 | 97 | expect(post.status).toBe(404); |
| 85 | 98 | }); |
Comment explains the deviation; put it in the pull, not the code.