Public: anyone can read this; the crew can change it.
A display's builds are chosen through PUT …/display/builds, validated and same-origin checked #9555
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9467-a-display-s-builds-are-chosen-through-pu → main 3 commits fork 1 thread · 1 unresolved
12 files
changed +523
−35
docs/design/areas/display.md +3 −1
| @@ -1,3 +1,5 @@ | ||
| 1 | 1 | # Display |
| 2 | 2 | |
| 3 | A repository's display, `/:owner/:name/display` (`src/web/pages/display.ts`), read only: the builds its `display.json` holds, as a dense table (`.table`, `.table-scroll`) under the repo header, as whoever looks may read them. It adds no component class. Covers, the player, ticking builds and editing the title wait for the submit handlers, `pin_demos.ts` and the Arcade host. | |
| 3 | A repository's display, `/:owner/:name/display` (`src/web/pages/display.ts`): the builds its `display.json` holds, as a dense table (`.table`, `.table-scroll`) under the repo header, as whoever looks may read them. It adds no component class. Covers, the player, editing the title waits for `pin_demos.ts` and the Arcade host. | |
| 4 | ||
| 5 | Writers also get a form of `.field` fieldset, `.check` rows, a `btn` and a `.hint` status line: no new component class.↵ |
docs/features/display.md +3 −1
| @@ -7,4 +7,6 @@ One repository's display: the builds its `display.json` (on the default branch) | ||
| 7 | 7 | - **Reaching it**: each row of `/displays` links to it. |
| 8 | 8 | - **Terminal**: in a repository's screen, the palette command "<owner>/<repo> — Display" (`src/tui/screens/display.ts`) shows the same title, layout and builds with the same open/private split, from the same `displayOf` and `displayItems`. `j`/`k` move, `esc` goes back. A repository you may not read is not offered it, and the screen itself says "No display here". |
| 9 | 9 | |
| 10 | Not done: ticking builds, editing the title, covers, the player and the Arcade host. See `docs/features/displays.md` for the list. | |
| 10 | - **Choosing builds**: a person who writes the repository (never an agent) ticks builds and saves, on the page, in the terminal, or over the API. The page form posts to the same page (works without script; with script it sends the PUT); the terminal's Display screen shows `[x]`/`[ ]` rows for a writer, `space` ticks and `s` saves; the API is `PUT /api/v1/repos/:owner/:name/display/builds` with `{ "builds": ["owner/name:label", …], "tip"?: commit }` (rw scope, same-origin for a session). All three call `chooseBuilds` (`src/actions/displays.ts`), which commits `builds` into `display.json`. Each build must be a pin in a repository the writer reads (422 otherwise); a `tip` that is no longer the default branch's commit is a 409. Order is the order shown; reordering waits. | |
| 11 | ||
| 12 | Not done: editing the title, covers, the player and the Arcade host. See `docs/features/displays.md` for the list. |
src/actions/displays.ts added +99
| @@ -0,0 +1,99 @@ | ||
| 1 | /** | |
| 2 | * A display's write half: choosing its builds. `display.json` on the default | |
| 3 | * branch is the record, so a choice is a commit that sets its `builds` list | |
| 4 | * (`commitToBranch` announces the push, and the display caches drop). The | |
| 5 | * person must write the repository (never an agent), and every build named | |
| 6 | * must be a pin in a repository they read. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { readFileAt } from "../apps/checkout.ts"; | |
| 11 | import { canRead, getRepo, type Repo } from "../core/repos.ts"; | |
| 12 | import type { User } from "../core/users.ts"; | |
| 13 | import { DISPLAY_FILE, parseBuilds, parseDisplay } from "../display/config.ts"; | |
| 14 | import { sourceRepos } from "../display/items.ts"; | |
| 15 | import { writesDisplay } from "../display/selections.ts"; | |
| 16 | import { exactRefOid } from "../git/branches.ts"; | |
| 17 | import { listBuildPins } from "../git/builds.ts"; | |
| 18 | import { isSafeOid } from "../git/guard.ts"; | |
| 19 | import { repoDir } from "../git/repos.ts"; | |
| 20 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 21 | import { commitToBranch } from "./repos.ts"; | |
| 22 | ||
| 23 | export type ChooseError = | |
| 24 | | { kind: "forbidden" } | |
| 25 | | { kind: "no_display" } | |
| 26 | | { kind: "invalid"; message: string } | |
| 27 | | { kind: "failed"; message: string }; | |
| 28 | ||
| 29 | const splitKey = (key: string): [string, string, string] => { | |
| 30 | const at = key.lastIndexOf(":"); | |
| 31 | const [owner = "", name = ""] = key.slice(0, at).split("/"); | |
| 32 | return [owner, name, key.slice(at + 1)]; | |
| 33 | }; | |
| 34 | ||
| 35 | export interface Choice { | |
| 36 | readonly builds: readonly string[]; | |
| 37 | /** The default branch's commit the chooser saw: the save is refused (409) if it has moved. */ | |
| 38 | readonly tip?: string; | |
| 39 | } | |
| 40 | ||
| 41 | /** The body of a PUT, checked: `{ "builds": [owner/name:label, …], "tip"?: commit }`. */ | |
| 42 | export function parseChoice(input: unknown): Result<Choice, string> { | |
| 43 | if (typeof input !== "object" || input === null || Array.isArray(input)) return err("the body must be a JSON object"); | |
| 44 | const keys = Object.keys(input); | |
| 45 | if (keys.some((k) => k !== "builds" && k !== "tip")) return err("the body may hold only builds and tip"); | |
| 46 | const tip = (input as { tip?: unknown }).tip; | |
| 47 | if (tip !== undefined && (typeof tip !== "string" || !isSafeOid(tip))) return err("tip must be a full commit id"); | |
| 48 | const builds = (input as { builds?: unknown }).builds; | |
| 49 | if (builds === undefined) return err("builds is required"); | |
| 50 | const parsed = parseBuilds(builds); | |
| 51 | return parsed.ok ? ok({ builds: parsed.value ?? [], ...(tip === undefined ? {} : { tip }) }) : err(parsed.error); | |
| 52 | } | |
| 53 | ||
| 54 | /** Set the display's `builds` to `builds`, in that order. */ | |
| 55 | export async function chooseBuilds(app: App, user: User | undefined, repo: Repo, builds: readonly string[], seenTip?: string): Promise<Result<readonly string[], ChooseError>> { | |
| 56 | if (!writesDisplay(app, repo, user)) return err({ kind: "forbidden" }); | |
| 57 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 58 | const tip = await exactRefOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 59 | const text = tip === undefined ? undefined : await readFileAt(dir, tip, DISPLAY_FILE); | |
| 60 | if (tip === undefined || text === undefined) return err({ kind: "no_display" }); | |
| 61 | const config = parseDisplay(text); | |
| 62 | if (!config.ok) return err({ kind: "invalid", message: config.error }); | |
| 63 | ||
| 64 | const pinsOf = new Map<string, Set<string>>(); | |
| 65 | for (const key of builds) { | |
| 66 | const [owner, name, label] = splitKey(key); | |
| 67 | const found = getRepo(app.db, owner, name); | |
| 68 | // A repository the person cannot read is not there for them. | |
| 69 | if (found === undefined || !canRead(app.db, found, user)) return err({ kind: "invalid", message: `no build ${key}` }); | |
| 70 | let labels = pinsOf.get(found.id); | |
| 71 | if (labels === undefined) { | |
| 72 | labels = new Set((await listBuildPins(repoDir(app.reposRoot, found.ownerName, found.name))).map((p) => p.label)); | |
| 73 | pinsOf.set(found.id, labels); | |
| 74 | } | |
| 75 | if (!labels.has(label)) return err({ kind: "invalid", message: `no build ${key}` }); | |
| 76 | } | |
| 77 | ||
| 78 | const file = { ...(JSON.parse(text) as Record<string, unknown>), builds: [...builds] }; | |
| 79 | const made = await commitToBranch(app, user, repo, { | |
| 80 | message: "Choose the display's builds", | |
| 81 | files: [{ path: DISPLAY_FILE, content: `${JSON.stringify(file, null, 2)}\n` }], | |
| 82 | expectTip: seenTip ?? tip, | |
| 83 | }); | |
| 84 | if (!made.ok) return err({ kind: "failed", message: "the display could not be saved; reload and try again" }); | |
| 85 | return ok([...builds]); | |
| 86 | } | |
| 87 | ||
| 88 | /** Every build the person could add: the pins of the display's sources they read, plus those already chosen. */ | |
| 89 | export async function choosableBuilds(app: App, sources: Parameters<typeof sourceRepos>[1], chosen: readonly string[], user: User | undefined): Promise<string[]> { | |
| 90 | const out = [...chosen]; | |
| 91 | for (const r of sourceRepos(app, sources)) { | |
| 92 | if (!canRead(app.db, r, user)) continue; | |
| 93 | for (const pin of await listBuildPins(repoDir(app.reposRoot, r.ownerName, r.name))) { | |
| 94 | const key = `${r.ownerName}/${r.name}:${pin.label}`; | |
| 95 | if (!out.includes(key)) out.push(key); | |
| 96 | } | |
| 97 | } | |
| 98 | return out; | |
| 99 | } |
src/contract/openapi/routes/index.ts +1
| @@ -344,6 +344,7 @@ export { route as putApiV1Front } from "./putApiV1Front.ts"; | ||
| 344 | 344 | export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts"; |
| 345 | 345 | export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerNameAppMembers.ts"; |
| 346 | 346 | export { route as putApiV1ReposOwnerNameCrewUsername } from "./putApiV1ReposOwnerNameCrewUsername.ts"; |
| 347 | export { route as putApiV1ReposOwnerNameDisplayBuilds } from "./putApiV1ReposOwnerNameDisplayBuilds.ts"; | |
| 347 | 348 | export { route as putApiV1ReposOwnerNameIssuesNumberAssignee } from "./putApiV1ReposOwnerNameIssuesNumberAssignee.ts"; |
| 348 | 349 | export { route as putApiV1ReposOwnerNameIssuesNumberLabels } from "./putApiV1ReposOwnerNameIssuesNumberLabels.ts"; |
| 349 | 350 | export { route as putApiV1ReposOwnerNameIssuesNumberPlan } from "./putApiV1ReposOwnerNameIssuesNumberPlan.ts"; |
src/contract/openapi/routes/putApiV1ReposOwnerNameDisplayBuilds.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "put", | |
| 5 | path: `${REPO}/display/builds`, | |
| 6 | summary: "Choose the builds a repository's display shows, in order (rw scope, a person who writes it); commits display.json; answers the builds", | |
| 7 | auth: true, | |
| 8 | body: { builds: "array" }, | |
| 9 | refusals: { | |
| 10 | bad_json: "the body is not JSON (400)", | |
| 11 | invalid: "the body is malformed or names a build that is not there (422)", | |
| 12 | no_display: "the repository has no display.json (404)", | |
| 13 | too_large: "the body is too large (413)", | |
| 14 | }, | |
| 15 | }; |
src/http/api.ts +2
| @@ -342,6 +342,7 @@ import { keeperRoutes } from "./api/keeper.ts"; | ||
| 342 | 342 | import { agentRoutes } from "./api/agents.ts"; |
| 343 | 343 | import { subscriptionRoutes } from "./api/subscriptions.ts"; |
| 344 | 344 | import { boardRoutes } from "./api/boards.ts"; |
| 345 | import { displayRoutes } from "./api/displays.ts"; | |
| 345 | 346 | import { followingRoutes } from "./api/following.ts"; |
| 346 | 347 | import { labelsRoutes } from "./api/labels.ts"; |
| 347 | 348 | import { visionRoutes } from "./api/vision.ts"; |
| @@ -1271,6 +1272,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1271 | 1272 | }); |
| 1272 | 1273 | |
| 1273 | 1274 | boardRoutes(router); |
| 1275 | displayRoutes(router); | |
| 1274 | 1276 | |
| 1275 | 1277 | // A domain drill and its verdict (`kernel/domain_drill.ts`): an admin |
| 1276 | 1278 | // person's, kept whether green or red. |
src/http/api/displays.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** | |
| 2 | * A repository's display over the API: `PUT …/display/builds` chooses its | |
| 3 | * builds. The rules live in `actions/displays.ts`; this parses and shapes. | |
| 4 | */ | |
| 5 | ||
| 6 | import { chooseBuilds, parseChoice } from "../../actions/displays.ts"; | |
| 7 | import type { Handler } from "../context.ts"; | |
| 8 | import { loadRepo } from "../api.ts"; | |
| 9 | import { bodyCapFor, readBounded } from "../limits.ts"; | |
| 10 | import { apiError, forbidden, json, notFound, requireScope } from "../respond.ts"; | |
| 11 | import type { Router } from "../router.ts"; | |
| 12 | ||
| 13 | export function displayRoutes(router: Router<Handler>): void { | |
| 14 | router.add("PUT", "/api/v1/repos/:owner/:name/display/builds", async (ctx) => { | |
| 15 | // Sign-in and, for a browser's session, same-origin. | |
| 16 | const denied = requireScope(ctx, "rw"); | |
| 17 | if (denied) return denied; | |
| 18 | const repo = loadRepo(ctx); | |
| 19 | if (!repo) return notFound(); | |
| 20 | const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request)); | |
| 21 | if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here"); | |
| 22 | let input: unknown; | |
| 23 | try { | |
| 24 | input = JSON.parse(new TextDecoder().decode(bytes)); | |
| 25 | } catch { | |
| 26 | return apiError("bad_json", 400, "the body must be JSON: { \"builds\": [\"owner/name:label\"] }"); | |
| 27 | } | |
| 28 | const choice = parseChoice(input); | |
| 29 | if (!choice.ok) return apiError("invalid", 422, choice.error); | |
| 30 | const done = await chooseBuilds(ctx.app, ctx.auth.user, repo, choice.value.builds, choice.value.tip); | |
| 31 | if (done.ok) return json({ builds: done.value }); | |
| 32 | switch (done.error.kind) { | |
| 33 | case "forbidden": | |
| 34 | return forbidden(); | |
| 35 | case "no_display": | |
| 36 | return notFound(); | |
| 37 | case "invalid": | |
| 38 | return apiError("invalid", 422, done.error.message); | |
| 39 | default: | |
| 40 | return apiError("failed", 409, done.error.message); | |
| 41 | } | |
| 42 | }); | |
| 43 | } |
src/http/server.ts +2 −1
| @@ -35,7 +35,7 @@ import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit | ||
| 35 | 35 | import { boardsHoldPost } from "../web/pages/boards-holds.ts"; |
| 36 | 36 | import { nowPage } from "../web/pages/now.ts"; |
| 37 | 37 | import { myReposPage } from "../web/pages/my-repos.ts"; |
| 38 | import { displayPage } from "../web/pages/display.ts"; | |
| 38 | import { displayChoose, displayPage } from "../web/pages/display.ts"; | |
| 39 | 39 | import { displaysPage } from "../web/pages/displays.ts"; |
| 40 | 40 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| 41 | 41 | import { actingVia } from "../core/acting_via.ts"; |
| @@ -846,6 +846,7 @@ export function buildRouter(): Router<Handler> { | ||
| 846 | 846 | router.add("GET", "/displays", displaysPage); |
| 847 | 847 | // A repository's display, read only (`display.ts`): 404 without a display.json or without read access. |
| 848 | 848 | router.add("GET", "/:owner/:name/display", displayPage); |
| 849 | router.add("POST", "/:owner/:name/display", displayChoose); | |
| 849 | 850 | router.add("GET", "/now/:stage", nowPage); |
| 850 | 851 | router.add("GET", "/audit", auditPage); |
| 851 | 852 | router.add("GET", "/releases", releasesPage); |
src/tui/screens/display.ts +61 −19
| @@ -1,11 +1,18 @@ | ||
| 1 | 1 | /** |
| 2 | * A repository's display (`/<owner>/<repo>/display` on the web), read only: | |
| 2 | * A repository's display (`/<owner>/<repo>/display` on the web): | |
| 3 | 3 | * its title and layout, then its builds as `displayPage` shows them — a build |
| 4 | 4 | * whose repository the viewer reads names it, any other says "private" and |
| 5 | 5 | * nothing more. Read through the same `displayOf` and `displayItems`, and |
| 6 | 6 | * gated here as well as on the command, since the screen is exported. |
| 7 | * Whoever writes the repository (`writesDisplay`) sees the builds they could | |
| 8 | * choose instead, `space` ticks one and `s` saves through `chooseBuilds`, the | |
| 9 | * same write the web form and the API make. | |
| 7 | 10 | */ |
| 8 | 11 | |
| 12 | import { chooseBuilds, choosableBuilds } from "../../actions/displays.ts"; | |
| 13 | import { writesDisplay } from "../../display/selections.ts"; | |
| 14 | import { selectionKeys } from "../../display/items.ts"; | |
| 15 | ||
| 9 | 16 | import { canRead, type Repo } from "../../core/repos.ts"; |
| 10 | 17 | import { displayItems, type DisplayItem } from "../../display/items.ts"; |
| 11 | 18 | import { INVALID_TITLE, displayOf } from "../../display/known.ts"; |
| @@ -28,20 +35,41 @@ export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 28 | 35 | let head: string | undefined; |
| 29 | 36 | let lines: string[] = []; |
| 30 | 37 | let missing = false; |
| 38 | // A writer's view: every build they could choose, and which are ticked. | |
| 39 | let choices: string[] | undefined; | |
| 40 | const ticked = new Set<string>(); | |
| 41 | let note = ""; | |
| 31 | 42 | const state = new ListState(); |
| 43 | // What each build reads as, by key, so a choice shows its name. | |
| 44 | const named = new Map<string, string>(); | |
| 45 | const read = async (): Promise<void> => { | |
| 46 | const config = canRead(f.db, repo, f.user) ? await displayOf(f.app, repo) : undefined; | |
| 47 | missing = config === undefined; | |
| 48 | if (config !== undefined) { | |
| 49 | head = config.ok ? `${config.value.title} · ${config.value.layout}` : INVALID_TITLE; | |
| 50 | const items = config.ok ? await displayItems(f.app, repo, config.value, f.user) : []; | |
| 51 | lines = config.ok ? items.map(itemLine) : [config.error]; | |
| 52 | named.clear(); | |
| 53 | for (const it of items) named.set(it.key, itemLine(it)); | |
| 54 | choices = undefined; | |
| 55 | if (config.ok && writesDisplay(f.app, repo, f.user)) { | |
| 56 | const chosen = await selectionKeys(f.app, config.value, f.user); | |
| 57 | choices = await choosableBuilds(f.app, config.value, chosen, f.user); | |
| 58 | ticked.clear(); | |
| 59 | for (const k of chosen) ticked.add(k); | |
| 60 | } | |
| 61 | } | |
| 62 | state.clamp((choices ?? lines).length); | |
| 63 | ctx.invalidate(); | |
| 64 | }; | |
| 32 | 65 | return { |
| 33 | 66 | title: "Display", |
| 34 | keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")], | |
| 35 | async load() { | |
| 36 | const config = canRead(f.db, repo, f.user) ? await displayOf(f.app, repo) : undefined; | |
| 37 | missing = config === undefined; | |
| 38 | if (config !== undefined) { | |
| 39 | head = config.ok ? `${config.value.title} · ${config.value.layout}` : INVALID_TITLE; | |
| 40 | lines = config.ok ? (await displayItems(f.app, repo, config.value, f.user)).map(itemLine) : [config.error]; | |
| 41 | } | |
| 42 | state.clamp(lines.length); | |
| 43 | ctx.invalidate(); | |
| 67 | get keys() { | |
| 68 | return choices === undefined | |
| 69 | ? [binding(["j", "k"], "move"), binding(["esc", "q"], "back")] | |
| 70 | : [binding(["j", "k"], "move"), binding(["space"], "tick"), binding(["s"], "save"), binding(["esc", "q"], "back")]; | |
| 44 | 71 | }, |
| 72 | load: read, | |
| 45 | 73 | render(area, s) { |
| 46 | 74 | const c = ctx.theme.c; |
| 47 | 75 | const right = area.x + area.w; |
| @@ -49,22 +77,36 @@ export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 49 | 77 | if (missing) return void line(0, NO_DISPLAY_LINE); |
| 50 | 78 | if (head === undefined) return void line(0, "Reading…"); |
| 51 | 79 | line(0, head); |
| 52 | if (lines.length === 0) return void line(1, NO_BUILDS_LINE, c.mutedFg); | |
| 53 | const rows = Math.max(0, area.h - 1); | |
| 54 | const from = state.window(rows, lines.length); | |
| 80 | const shown = choices === undefined ? lines : choices.map((k) => `[${ticked.has(k) ? "x" : " "}] ${named.get(k) ?? k}`); | |
| 81 | if (note !== "") line(1, note, c.mutedFg); | |
| 82 | if (shown.length === 0) return void line(1, NO_BUILDS_LINE, c.mutedFg); | |
| 83 | const top = note === "" ? 0 : 1; | |
| 84 | const rows = Math.max(0, area.h - 1 - top); | |
| 85 | const from = state.window(rows, shown.length); | |
| 55 | 86 | for (let i = 0; i < rows; i++) { |
| 56 | const l = lines[from + i]; | |
| 87 | const l = shown[from + i]; | |
| 57 | 88 | if (l === undefined) break; |
| 58 | 89 | const picked = from + i === state.index; |
| 59 | s.text(area.x, area.y + 1 + i, picked ? ">" : " ", style(c.accent, c.bg), right); | |
| 60 | line(1 + i, l, picked ? c.fg : c.mutedFg); | |
| 90 | s.text(area.x, area.y + 1 + top + i, picked ? ">" : " ", style(c.accent, c.bg), right); | |
| 91 | line(1 + top + i, l, picked ? c.fg : c.mutedFg); | |
| 61 | 92 | } |
| 62 | 93 | }, |
| 63 | 94 | key(e) { |
| 64 | const n = lines.length; | |
| 95 | const n = (choices ?? lines).length; | |
| 65 | 96 | if (e.key === "j" || e.key === "down") state.move(1, n); |
| 66 | 97 | else if (e.key === "k" || e.key === "up") state.move(-1, n); |
| 67 | else if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 98 | else if (choices !== undefined && e.key === " ") { | |
| 99 | const k = choices[state.index]; | |
| 100 | if (k !== undefined) void (ticked.delete(k) || ticked.add(k)); | |
| 101 | } else if (choices !== undefined && e.key === "s") { | |
| 102 | // Chosen builds keep their order; newly ticked ones follow. | |
| 103 | const keys = choices.filter((k) => ticked.has(k)); | |
| 104 | void ctx.track(chooseBuilds(f.app, f.user, repo, keys).then(async (done) => { | |
| 105 | note = done.ok ? "Saved." : done.error.kind === "invalid" || done.error.kind === "failed" ? done.error.message : "Not saved."; | |
| 106 | if (done.ok) await read(); | |
| 107 | ctx.invalidate(); | |
| 108 | })); | |
| 109 | } else if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 68 | 110 | else return false; |
| 69 | 111 | ctx.invalidate(); |
| 70 | 112 | return true; |
src/web/pages/display.ts +68 −13
| @@ -1,24 +1,41 @@ | ||
| 1 | 1 | /** |
| 2 | * A repository's display page, `/<owner>/<repo>/display`, read only: the | |
| 3 | * builds its `display.json` holds (`display/config.ts`) as whoever is | |
| 4 | * looking may read them, after Jay Moran's page. A build whose repository | |
| 5 | * the viewer cannot read shows its name and version alone. Ticking builds, | |
| 6 | * editing the title, covers and the player wait for the submit handlers and | |
| 7 | * the Arcade host. A bad `display.json` shows its error, never a guess. | |
| 2 | * A repository's display page, `/<owner>/<repo>/display`: the builds its | |
| 3 | * `display.json` holds (`display/config.ts`), as whoever is looking may read | |
| 4 | * them; a build whose repository the viewer cannot read shows its name and | |
| 5 | * version alone. A writer also gets a form to choose the builds, which works | |
| 6 | * without script (POST here) and, with script, sends the API's PUT. Editing | |
| 7 | * the title, covers and the player wait. A bad `display.json` shows its error. | |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 11 | import { displayItems } from "../../display/items.ts"; | |
| 11 | import { chooseBuilds, choosableBuilds, parseChoice } from "../../actions/displays.ts"; | |
| 12 | import { displayItems, selectionKeys } from "../../display/items.ts"; | |
| 13 | import { writesDisplay } from "../../display/selections.ts"; | |
| 14 | import { exactRefOid } from "../../git/branches.ts"; | |
| 15 | import { repoDir } from "../../git/repos.ts"; | |
| 12 | 16 | import { INVALID_TITLE, displayOf } from "../../display/known.ts"; |
| 13 | 17 | import { repoShotsUrl } from "../../display/launch.ts"; |
| 14 | 18 | import type { Ctx } from "../../http/context.ts"; |
| 15 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts"; | |
| 16 | import { html } from "../html.ts"; | |
| 17 | import { page } from "../layout.ts"; | |
| 18 | import { empty } from "../ui.ts"; | |
| 19 | import { crumbsFor, forbiddenPage, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts"; | |
| 20 | import { html, raw } from "../html.ts"; | |
| 21 | import { flashTo, page } from "../layout.ts"; | |
| 22 | import { btn, empty } from "../ui.ts"; | |
| 23 | import { readForm } from "../form.ts"; | |
| 19 | 24 | |
| 20 | 25 | const LAYOUT_WORD = { shelf: "Shelf", grid: "Grid", graph: "Graph" } as const; |
| 21 | 26 | |
| 27 | /** Saving the form is a PUT; without script the checkboxes still show what is chosen. */ | |
| 28 | const CHOOSE_JS = `document.querySelectorAll("form[data-choose]").forEach(function (f) { | |
| 29 | f.addEventListener("submit", function (e) { | |
| 30 | e.preventDefault(); | |
| 31 | var s = f.querySelector("[data-choose-status]"); | |
| 32 | var builds = Array.prototype.map.call(f.querySelectorAll("input[name=build]:checked"), function (i) { return i.value; }); | |
| 33 | fetch(f.getAttribute("data-choose"), { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(f.elements.tip ? { builds: builds, tip: f.elements.tip.value } : { builds: builds }), credentials: "same-origin" }) | |
| 34 | .then(function (r) { return r.ok ? location.reload() : r.json().then(function (j) { s.textContent = j.message || "Not saved"; }); }) | |
| 35 | .catch(function () { s.textContent = "Not saved"; }); | |
| 36 | }); | |
| 37 | });`; | |
| 38 | ||
| 22 | 39 | // GET /:owner/:name/display — 404 for a repository with no display.json or one the viewer cannot read. |
| 23 | 40 | export async function displayPage(ctx: Ctx): Promise<Response> { |
| 24 | 41 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -27,6 +44,10 @@ export async function displayPage(ctx: Ctx): Promise<Response> { | ||
| 27 | 44 | const config = await timePhaseAsync(ctx.request, "display", () => displayOf(ctx.app, repo)); |
| 28 | 45 | if (config === undefined) return timePhase(ctx.request, "render", () => notFoundPage(ctx)); |
| 29 | 46 | const items = config.ok ? await timePhaseAsync(ctx.request, "items", () => displayItems(ctx.app, repo, config.value, ctx.auth.user)) : []; |
| 47 | const writes = config.ok && writesDisplay(ctx.app, repo, ctx.auth.user); | |
| 48 | const chosen = writes && config.ok ? await selectionKeys(ctx.app, config.value, ctx.auth.user) : []; | |
| 49 | const choices = writes && config.ok ? await choosableBuilds(ctx.app, config.value, chosen, ctx.auth.user) : []; | |
| 50 | const tip = writes ? await exactRefOid(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), `refs/heads/${repo.defaultBranch}`) : undefined; | |
| 30 | 51 | const title = config.ok ? config.value.title : INVALID_TITLE; |
| 31 | 52 | const body = html`${repoHeader(ctx, repo, "code", { compact: true })} |
| 32 | 53 | <div class="page-head mt"> |
| @@ -59,8 +80,42 @@ export async function displayPage(ctx: Ctx): Promise<Response> { | ||
| 59 | 80 | )} |
| 60 | 81 | </tbody> |
| 61 | 82 | </table> |
| 62 | </div>`}`; | |
| 83 | </div>`} | |
| 84 | ${writes | |
| 85 | ? html`<form class="mt" method="post" action="${repoBase(repo)}/display" data-choose="/api/v1/repos/${repo.ownerName}/${repo.name}/display/builds"> | |
| 86 | <fieldset class="field"> | |
| 87 | <legend>Choose builds</legend> | |
| 88 | ${choices.length === 0 | |
| 89 | ? html`<p class="hint">No builds to choose from yet.</p>` | |
| 90 | : choices.map( | |
| 91 | (key) => html`<label class="check"><input type="checkbox" name="build" value="${key}" ${chosen.includes(key) ? raw("checked") : null} /> | |
| 92 | <span class="mono">${key}</span></label>`, | |
| 93 | )} | |
| 94 | </fieldset> | |
| 95 | ${tip === undefined ? null : html`<input type="hidden" name="tip" value="${tip}" />`} | |
| 96 | ${btn("Save builds", { variant: "primary" })} | |
| 97 | <p class="hint" role="status" data-choose-status></p> | |
| 98 | </form>` | |
| 99 | : null}`; | |
| 63 | 100 | return timePhase(ctx.request, "render", () => |
| 64 | page({ ctx, title: `Display · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }), | |
| 101 | page({ ctx, title: `Display · ${repo.name}`, ...(writes ? { scriptJs: CHOOSE_JS } : {}), crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }), | |
| 65 | 102 | ); |
| 66 | 103 | } |
| 104 | ||
| 105 | // POST /:owner/:name/display — the form without script: choose the ticked builds, then back to the page. | |
| 106 | export async function displayChoose(ctx: Ctx): Promise<Response> { | |
| 107 | const form = await readForm(ctx, "rw"); | |
| 108 | if (form instanceof Response) return form; | |
| 109 | const repo = loadRepo(ctx); | |
| 110 | if (!repo) return notFoundPage(ctx); | |
| 111 | const back = `${repoBase(repo)}/display`; | |
| 112 | const picked = form.getAll("build").filter((v): v is string => typeof v === "string"); | |
| 113 | const tip = form.get("tip"); | |
| 114 | const parsed = parseChoice({ builds: picked, ...(typeof tip === "string" && tip !== "" ? { tip } : {}) }); | |
| 115 | if (!parsed.ok) return flashTo(ctx, back, { tone: "danger", message: parsed.error }); | |
| 116 | const done = await chooseBuilds(ctx.app, ctx.auth.user, repo, parsed.value.builds, parsed.value.tip); | |
| 117 | if (done.ok) return flashTo(ctx, back, { message: "Builds saved." }); | |
| 118 | if (done.error.kind === "no_display") return notFoundPage(ctx); | |
| 119 | if (done.error.kind === "forbidden") return forbiddenPage(ctx, "Only a person who writes this repository can choose its builds."); | |
| 120 | return flashTo(ctx, back, { tone: "danger", message: done.error.message }); | |
| 121 | } |
test/displays.test.ts added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | /** `PUT /api/v1/repos/:owner/:name/display/builds`: choosing a display's builds. */ | |
| 2 | import { afterAll, beforeAll, describe, 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 { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 7 | import { setRole } from "../src/actions/roles.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { getRepo } from "../src/core/repos.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { pinBuild } from "../src/git/builds.ts"; | |
| 15 | import { repoDir } from "../src/git/repos.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { proveAll } from "./helpers/verified.ts"; | |
| 18 | ||
| 19 | describe("choosing a display's builds", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let alice: User; | |
| 24 | let bob: User; | |
| 25 | const url = "/api/v1/repos/alice/karts/display/builds"; | |
| 26 | ||
| 27 | const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`; | |
| 28 | const get = (path: string, u: User) => fetch(`${servers.url}${path}`, { headers: { cookie: cookie(u) } }); | |
| 29 | const put = (body: string, u: User, origin?: string) => | |
| 30 | fetch(`${servers.url}${url}`, { | |
| 31 | method: "PUT", | |
| 32 | headers: { cookie: cookie(u), "content-type": "application/json", ...(origin === undefined ? {} : { origin }) }, | |
| 33 | body, | |
| 34 | }); | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dataDir = await mkdtemp(join(tmpdir(), "forge-displays-")); | |
| 38 | app = createApp({ dataDir, port: 0 }); | |
| 39 | servers = await serve(app); | |
| 40 | for (const name of ["alice", "bob"]) { | |
| 41 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: true }); | |
| 42 | if (!made.ok) throw new Error(made.error); | |
| 43 | if (name === "alice") alice = made.value; | |
| 44 | else bob = made.value; | |
| 45 | } | |
| 46 | proveAll(app); | |
| 47 | const made = await createRepo(app, alice, { name: "karts", visibility: "public" }); | |
| 48 | if (!made.ok) throw new Error(String(made.error)); | |
| 49 | const repo = getRepo(app.db, "alice", "karts")!; | |
| 50 | const commit = async (path: string, content: string) => { | |
| 51 | const c = await commitToBranch(app, alice, repo, { message: path, files: [{ path, content }] }); | |
| 52 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 53 | return c.value.commitOid; | |
| 54 | }; | |
| 55 | const oid = await commit("a.txt", "a"); | |
| 56 | for (const label of ["fiesta", "encore"]) { | |
| 57 | const pinned = await pinBuild(repoDir(app.reposRoot, "alice", "karts"), { | |
| 58 | label, oid, note: "", source: "commit", tagger: { name: "alice", email: "[email protected]" }, at: 1_800_000_000, | |
| 59 | demo: { name: label === "fiesta" ? "krazy-karts" : "encore-karts", version: "1.0", page: "/", showcase: true, launch: "shots" }, | |
| 60 | }); | |
| 61 | if (!pinned.ok) throw new Error(pinned.error); | |
| 62 | } | |
| 63 | await commit("display.json", JSON.stringify({ title: "Kart night", layout: "grid", sources: ["alice/karts"], builds: ["alice/karts:fiesta"] })); | |
| 64 | }, 60_000); | |
| 65 | ||
| 66 | afterAll(async () => { | |
| 67 | servers.stop(); | |
| 68 | app.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a writer's form is on the page, a reader's is not", async () => { | |
| 73 | expect(await (await get("/alice/karts/display", alice)).text()).toContain("data-choose="); | |
| 74 | expect(await (await get("/alice/karts/display", bob)).text()).not.toContain("data-choose="); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a malformed body is a 422, bad JSON a 400, and nothing changes", async () => { | |
| 78 | expect((await put('{"builds":"x"}', alice)).status).toBe(422); | |
| 79 | expect((await put('{"builds":["nonsense"]}', alice)).status).toBe(422); | |
| 80 | expect((await put('{"builds":["alice/karts:nope"]}', alice)).status).toBe(422); | |
| 81 | expect((await put('{"builds":["alice/karts:fiesta","alice/karts:fiesta"]}', alice)).status).toBe(422); | |
| 82 | expect((await put('{"builds":[],"title":"x"}', alice)).status).toBe(422); | |
| 83 | expect((await put("not json", alice)).status).toBe(400); | |
| 84 | const page = await (await get("/alice/karts/display", bob)).text(); | |
| 85 | expect(page).toContain("krazy-karts"); | |
| 86 | expect(page).not.toContain("encore-karts"); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a cross-origin PUT and a reader's PUT are refused", async () => { | |
| 90 | const body = '{"builds":["alice/karts:encore"]}'; | |
| 91 | expect((await put(body, alice, "https://evil.example")).status).toBe(403); | |
| 92 | expect((await put(body, bob)).status).toBe(403); | |
| 93 | expect((await fetch(`${servers.url}${url}`, { method: "PUT", body })).status).toBe(401); | |
| 94 | expect(await (await get("/alice/karts/display", bob)).text()).not.toContain("encore-karts"); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a good PUT changes what the display page shows, after a reload", async () => { | |
| 98 | const res = await put('{"builds":["alice/karts:encore"]}', alice); | |
| 99 | expect(res.status).toBe(200); | |
| 100 | expect(await res.json()).toEqual({ builds: ["alice/karts:encore"] }); | |
| 101 | const page = await (await get("/alice/karts/display", bob)).text(); | |
| 102 | expect(page).toContain("encore-karts"); | |
| 103 | expect(page).not.toContain("krazy-karts"); | |
| 104 | // The form for the writer holds the choice. | |
| 105 | expect(await (await get("/alice/karts/display", alice)).text()).toMatch(/value="alice\/karts:encore"\s+checked/); | |
| 106 | }); | |
| 107 | ||
| 108 | test("an agent that writes the repository is refused, a stale tip is a 409, a good tip saves", async () => { | |
| 109 | const bot = await createAgent(app, alice, { username: "botty" }); | |
| 110 | if (!bot.ok) throw new Error(String(bot.error)); | |
| 111 | const added = addToCrew(app, alice, getRepo(app.db, "alice", "karts")!, "botty"); | |
| 112 | if (!added.ok) throw new Error(String(added.error)); | |
| 113 | const token = createToken(app.db, bot.value.user.id, "t", "rw").token; | |
| 114 | const agent = await fetch(`${servers.url}${url}`, { | |
| 115 | method: "PUT", | |
| 116 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 117 | body: '{"builds":["alice/karts:fiesta"]}', | |
| 118 | }); | |
| 119 | expect(agent.status).toBe(403); | |
| 120 | ||
| 121 | const stale = await put(`{"builds":["alice/karts:fiesta"],"tip":"${"0".repeat(40)}"}`, alice); | |
| 122 | expect(stale.status).toBe(409); | |
| 123 | expect((await put('{"builds":[],"tip":"nope"}', alice)).status).toBe(422); | |
| 124 | const tip = /name="tip" value="([0-9a-f]{40})"/.exec(await (await get("/alice/karts/display", alice)).text())?.[1]; | |
| 125 | expect(tip).toBeDefined(); | |
| 126 | expect((await put(`{"builds":["alice/karts:fiesta"],"tip":"${tip}"}`, alice)).status).toBe(200); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a build in a repository the caller cannot read is a 422", async () => { | |
| 130 | const vault = await createRepo(app, alice, { name: "vault", visibility: "private" }); | |
| 131 | if (!vault.ok) throw new Error(String(vault.error)); | |
| 132 | const v = getRepo(app.db, "alice", "vault")!; | |
| 133 | const c = await commitToBranch(app, alice, v, { message: "v", files: [{ path: "v.txt", content: "v" }] }); | |
| 134 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 135 | const pinned = await pinBuild(repoDir(app.reposRoot, "alice", "vault"), { | |
| 136 | label: "hush", oid: c.value.commitOid, note: "", source: "commit", tagger: { name: "alice", email: "[email protected]" }, at: 1_800_000_000, | |
| 137 | }); | |
| 138 | if (!pinned.ok) throw new Error(pinned.error); | |
| 139 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" }); | |
| 140 | if (!made.ok) throw new Error(made.error); | |
| 141 | proveAll(app); | |
| 142 | const member = setRole(app, alice, "carol", "member"); | |
| 143 | if (!member.ok) throw new Error(member.error); | |
| 144 | const show = await createRepo(app, made.value, { name: "show", visibility: "public" }); | |
| 145 | if (!show.ok) throw new Error(String(show.error)); | |
| 146 | const d = await commitToBranch(app, made.value, getRepo(app.db, "carol", "show")!, { message: "d", files: [{ path: "display.json", content: JSON.stringify({ title: "Show", layout: "grid", sources: ["carol/show"] }) }] }); | |
| 147 | if (!d.ok) throw new Error(JSON.stringify(d.error)); | |
| 148 | const res = await fetch(`${servers.url}/api/v1/repos/carol/show/display/builds`, { | |
| 149 | method: "PUT", | |
| 150 | headers: { cookie: cookie(made.value), "content-type": "application/json" }, | |
| 151 | body: '{"builds":["alice/vault:hush"]}', | |
| 152 | }); | |
| 153 | expect(res.status).toBe(422); | |
| 154 | // The owner of the private repository may name it. | |
| 155 | const own = await fetch(`${servers.url}${url}`, { | |
| 156 | method: "PUT", | |
| 157 | headers: { cookie: cookie(alice), "content-type": "application/json" }, | |
| 158 | body: '{"builds":["alice/vault:hush"]}', | |
| 159 | }); | |
| 160 | expect(own.status).toBe(200); | |
| 161 | }); | |
| 162 | ||
| 163 | test("the form works without script: a same-origin POST saves and redirects, a cross-origin one is refused", async () => { | |
| 164 | const post = (u: User, origin?: string) => | |
| 165 | fetch(`${servers.url}/alice/karts/display`, { | |
| 166 | method: "POST", | |
| 167 | headers: { cookie: cookie(u), "content-type": "application/x-www-form-urlencoded", ...(origin === undefined ? {} : { origin }) }, | |
| 168 | body: "build=alice%2Fkarts%3Aencore", | |
| 169 | redirect: "manual", | |
| 170 | }); | |
| 171 | expect((await post(alice, "https://evil.example")).status).toBe(403); | |
| 172 | expect((await post(bob)).status).toBe(403); | |
| 173 | const res = await post(alice, servers.url); | |
| 174 | expect(res.status).toBe(303); | |
| 175 | expect(res.headers.get("location")).toBe("/alice/karts/display"); | |
| 176 | const page = await (await get("/alice/karts/display", bob)).text(); | |
| 177 | expect(page).toContain("encore-karts"); | |
| 178 | expect(page).not.toContain("krazy-karts"); | |
| 179 | }); | |
| 180 | }); |
test/tui-display.test.ts +46
| @@ -7,6 +7,7 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | ||
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 8 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 9 | 9 | import { createUser, type User } from "../src/core/users.ts"; |
| 10 | import { readDisplay } from "../src/display/config.ts"; | |
| 10 | 11 | import { pinBuild } from "../src/git/builds.ts"; |
| 11 | 12 | import { repoDir } from "../src/git/repos.ts"; |
| 12 | 13 | import { createForgeApp } from "../src/tui/forge.ts"; |
| @@ -95,3 +96,48 @@ test("the command is offered for a readable repository only", () => { | ||
| 95 | 96 | expect(displayCommands(session, tui.ctx, repo("vault"))).toEqual([]); |
| 96 | 97 | expect(displayCommands(session, tui.ctx, repo("karts")).map((c) => c.id)).toEqual(["display"]); |
| 97 | 98 | }); |
| 99 | ||
| 100 | test("a writer ticks a build with space and saves it with s; a reader cannot", async () => { | |
| 101 | const made = await createRepo(app, alice, { name: "pick", visibility: "public" }); | |
| 102 | if (!made.ok) throw new Error(String(made.error)); | |
| 103 | const c = await commitToBranch(app, alice, repo("pick"), { message: "x", files: [{ path: "x.txt", content: "x" }] }); | |
| 104 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 105 | for (const label of ["one", "two"]) { | |
| 106 | const p = await pinBuild(repoDir(app.reposRoot, "alice", "pick"), { | |
| 107 | label, oid: c.value.commitOid, note: "", source: "commit", tagger: { name: "alice", email: "[email protected]" }, at: 1_800_000_000, | |
| 108 | demo: { name: `${label}-game`, version: "1.0", page: "/", showcase: true, launch: "shots" }, | |
| 109 | }); | |
| 110 | if (!p.ok) throw new Error(p.error); | |
| 111 | } | |
| 112 | const d = await commitToBranch(app, alice, repo("pick"), { message: "d", files: [{ path: "display.json", content: JSON.stringify({ title: "Pick", layout: "grid", sources: ["alice/pick"], builds: ["alice/pick:one"] }) }] }); | |
| 113 | if (!d.ok) throw new Error(JSON.stringify(d.error)); | |
| 114 | ||
| 115 | const open = async (user: User) => { | |
| 116 | const terminal = headlessTerminal(100, 30); | |
| 117 | const { app: tui, session } = createForgeApp(app, { ...guest, user }, { terminal, exitHooks: false }); | |
| 118 | tui.start(); | |
| 119 | const screen = displayScreen(session, tui.ctx, repo("pick")); | |
| 120 | tui.ctx.push(screen); | |
| 121 | await settleApp(tui); | |
| 122 | return { tui, screen }; | |
| 123 | }; | |
| 124 | const press = (screen: ReturnType<typeof displayScreen>, key: string) => screen.key?.({ key } as never); | |
| 125 | ||
| 126 | const reader = await open(bob); | |
| 127 | expect(reader.tui.screen.toText()).not.toContain("[x]"); | |
| 128 | expect(press(reader.screen, " ")).toBe(false); | |
| 129 | expect(press(reader.screen, "s")).toBe(false); | |
| 130 | reader.tui.stop?.(); | |
| 131 | ||
| 132 | const writer = await open(alice); | |
| 133 | expect(writer.tui.screen.toText()).toContain("[x] one-game"); | |
| 134 | expect(writer.tui.screen.toText()).toContain("[ ] alice/pick:two"); | |
| 135 | press(writer.screen, "j"); | |
| 136 | press(writer.screen, " "); | |
| 137 | press(writer.screen, "s"); | |
| 138 | await settleApp(writer.tui); | |
| 139 | expect(writer.tui.screen.toText()).toContain("Saved."); | |
| 140 | const read = await readDisplay(app, repo("pick")); | |
| 141 | expect(read?.ok && read.value.builds).toEqual(["alice/pick:one", "alice/pick:two"]); | |
| 142 | writer.tui.stop?.(); | |
| 143 | }); |
No-JS submit goes to action="" as a POST nobody handles. Add a POST handler or hide the button without script.