Public: anyone can read this; the crew can change it.
`/displays` lists every display the viewer may read, on main #9532
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9530-displays-lists-every-display-the-viewer → main 5 commits fork 2 threads · 2 unresolved
11 files
changed +357
−0
docs/design/audit.md +1
| @@ -69,6 +69,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 69 | 69 | - `/repos` — `pages/my-repos.ts` · the repositories you may write, from the account menu · `/` (your apps), `/:owner` (a profile's repositories) · none seen. |
| 70 | 70 | - `/releases` — `pages/releases.ts` · the forge's kept releases and rollback · `/settings/backups` (both are "go back") · none seen. |
| 71 | 71 | - `/changelog` — `pages/changelog.ts` · the last 30 releases with their dates and pulls, public · `/releases` (the signed-in kept releases) · none seen. |
| 72 | - `/displays` — `pages/displays.ts` · every display the viewer may read, each with its layout, repository and showcased demos; a stranger signs in first · none yet (read only; a repository's display is its own page) · none seen. | |
| 72 | 73 | - `/api` — `pages/api.ts` · the forge's OpenAPI operations by area, with search and Try it, public · the footer, the OpenAPI document, the terminal's API screen · none seen. An app's own spec is its `/:owner/:name/api` tab. |
| 73 | 74 | - `/contribute` — `pages/contribute.ts` · what this forge is and where outside work is welcome, open to anyone · `/llms.txt`, MCP `contribute` tool · none seen. |
| 74 | 75 | - `/agents`: `pages/agents.ts` · `/llms.txt` drawn as a page for an assistant whose reader takes only HTML, and for the person beside it, public · the home page's prompt and its Open in ChatGPT, Claude and Grok links · none seen. |
docs/features/displays.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # Displays list | |
| 2 | ||
| 3 | A display is a repository with a `display.json` on its default branch (title, layout `shelf`, `grid` or `graph`, and the repositories it draws demos from). The list shows every display you may read, and nothing you may not. | |
| 4 | ||
| 5 | - **Page**: `/displays`. One row per display: title (linking to the repository), layout, repository, and how many showcased demos it shows. A stranger is sent to sign in. A repository whose `display.json` does not parse is listed as "display.json is invalid", linking to that repository so its owner can fix the file. | |
| 6 | - **Terminal**: the "Displays" screen (`src/tui/screens/displays.ts`), opened from the palette. The same rows, from the same `listedDisplays` call. `j`/`k` move. | |
| 7 | ||
| 8 | With no displays the page says what one is and how to make one: commit a `display.json` to a repository's default branch. | |
| 9 | ||
| 10 | Not done: the list is read only. Creating or editing a display, a picker, and a build sidebar are separate work. |
src/core/reserved.ts +1
| @@ -135,6 +135,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 135 | 135 | "ssh", |
| 136 | 136 | "status", |
| 137 | 137 | "changelog", |
| 138 | "displays", | |
| 138 | 139 | "docs", |
| 139 | 140 | "open-platform", |
| 140 | 141 | ]; |
src/display/known.ts +5
| @@ -251,3 +251,8 @@ export async function withoutDisplay(app: App, repos: readonly Repo[]): Promise< | ||
| 251 | 251 | const read = await inTurn(repos, (repo) => displayOf(app, repo)); |
| 252 | 252 | return repos.filter((_, i) => read[i] === undefined); |
| 253 | 253 | } |
| 254 | ||
| 255 | /** What a display is and where one comes from; the page and the terminal screen share it. */ | |
| 256 | export const DISPLAYS_NOTE = "A display shows a repository's showcased demos. Add a display.json to a repository's default branch to make one."; | |
| 257 | /** The link text for a display whose file does not parse. */ | |
| 258 | export const INVALID_TITLE = "display.json is invalid"; |
src/http/server.ts +2
| @@ -35,6 +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 { displaysPage } from "../web/pages/displays.ts"; | |
| 38 | 39 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| 39 | 40 | import { actingVia } from "../core/acting_via.ts"; |
| 40 | 41 | import { |
| @@ -833,6 +834,7 @@ export function buildRouter(): Router<Handler> { | ||
| 833 | 834 | router.add("POST", "/bring/:id/:job/env", bringEnvSubmit); |
| 834 | 835 | router.add("GET", "/new/issue", newIssueAnywhere); |
| 835 | 836 | router.add("GET", "/repos", myReposPage); |
| 837 | router.add("GET", "/displays", displaysPage); | |
| 836 | 838 | router.add("GET", "/now/:stage", nowPage); |
| 837 | 839 | router.add("GET", "/audit", auditPage); |
| 838 | 840 | router.add("GET", "/releases", releasesPage); |
src/tui/screens/displays.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** | |
| 2 | * Displays (/displays on the web): every display the viewer may read, one | |
| 3 | * row each — title, layout, repository, showcased demo count — from the same | |
| 4 | * `listedDisplays` call as the page. Read only. | |
| 5 | */ | |
| 6 | ||
| 7 | import { DISPLAYS_NOTE, INVALID_TITLE, listedDisplays, type ListedDisplay } from "../../display/known.ts"; | |
| 8 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 9 | import { binding } from "../app/keys.ts"; | |
| 10 | import { style } from "../term/style.ts"; | |
| 11 | import { truncate } from "../term/width.ts"; | |
| 12 | import type { Session } from "../session.ts"; | |
| 13 | import { fill, len, split } from "../ui/layout.ts"; | |
| 14 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 15 | import type { PaletteEntry } from "./entries.ts"; | |
| 16 | ||
| 17 | /** One display as a row: its title (or that its file is invalid), then layout, repository and demos. */ | |
| 18 | export function displayRow(d: ListedDisplay): ListRow { | |
| 19 | const name = `${d.repo.ownerName}/${d.repo.name}`; | |
| 20 | if (!d.config.ok) return { title: INVALID_TITLE, secondary: `${name} · no layout · no demos` }; | |
| 21 | const demos = d.demos ?? 0; | |
| 22 | return { title: d.config.value.title, secondary: `${d.config.value.layout} · ${name} · ${demos} ${demos === 1 ? "demo" : "demos"}` }; | |
| 23 | } | |
| 24 | ||
| 25 | export function displaysScreen(f: Session, ctx: Ctx): Screen { | |
| 26 | const state = new ListState(); | |
| 27 | let shown: ListedDisplay[] | undefined; | |
| 28 | return { | |
| 29 | title: "Displays", | |
| 30 | keys: [binding(["j", "k"], "move cursor")], | |
| 31 | async load() { | |
| 32 | shown = await listedDisplays(f.app, f.user); | |
| 33 | }, | |
| 34 | render(area, s) { | |
| 35 | const c = ctx.theme.c; | |
| 36 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 37 | const text = shown === undefined ? "Loading displays…" : shown.length === 0 ? DISPLAYS_NOTE : `${shown.length} displays you may read`; | |
| 38 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 39 | drawList(s, list!, c, state, (shown ?? []).map(displayRow)); | |
| 40 | }, | |
| 41 | key(e) { | |
| 42 | return state.key(e, shown?.length ?? 0); | |
| 43 | }, | |
| 44 | }; | |
| 45 | } | |
| 46 | ||
| 47 | /** Displays, as /displays lists them: anyone signed in. */ | |
| 48 | export const entry: PaletteEntry = { | |
| 49 | id: "displays", | |
| 50 | title: "Displays", | |
| 51 | hint: "the displays you may read", | |
| 52 | after: ["repos"], | |
| 53 | offered: (session) => session.user !== undefined, | |
| 54 | run: (session, ctx) => ctx.push(displaysScreen(session, ctx)), | |
| 55 | }; |
src/tui/screens/entries.ts +2
| @@ -20,6 +20,7 @@ import { entry as cloud } from "./cloud.ts"; | ||
| 20 | 20 | import { entry as connections } from "./connections.ts"; |
| 21 | 21 | import { entry as crewHandover } from "./crewhandover.ts"; |
| 22 | 22 | import { entry as crewSplits } from "./crewsplits.ts"; |
| 23 | import { entry as displays } from "./displays.ts"; | |
| 23 | 24 | import { entry as domains } from "./domains.ts"; |
| 24 | 25 | import { entry as edgeOnly } from "./edgeonly.ts"; |
| 25 | 26 | import { entry as feed } from "./feed.ts"; |
| @@ -114,6 +115,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 114 | 115 | now, |
| 115 | 116 | forgeView, |
| 116 | 117 | repos, |
| 118 | displays, | |
| 117 | 119 | newIssue, |
| 118 | 120 | ]; |
| 119 | 121 |
src/web/pages/displays.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** | |
| 2 | * `/displays`: every display the viewer may read — each repository with a | |
| 3 | * `display.json` on its default branch (`display/config.ts`) — one row | |
| 4 | * each: title, layout, repository, and how many showcased demos it shows. | |
| 5 | * Read through the display cache (`display/known.ts`), so a warm page reads | |
| 6 | * no git. Read only: making a display is not here. | |
| 7 | */ | |
| 8 | ||
| 9 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 10 | import { DISPLAYS_NOTE, INVALID_TITLE, listedDisplays } from "../../display/known.ts"; | |
| 11 | import type { Ctx } from "../../http/context.ts"; | |
| 12 | import { pageHead, repoBase, signInFirst } from "../chrome.ts"; | |
| 13 | import { html } from "../html.ts"; | |
| 14 | import { page } from "../layout.ts"; | |
| 15 | import { empty } from "../ui.ts"; | |
| 16 | ||
| 17 | const LAYOUT_WORD = { shelf: "Shelf", grid: "Grid", graph: "Graph" } as const; | |
| 18 | ||
| 19 | // GET /displays — every display the viewer may read; a stranger signs in first. | |
| 20 | export async function displaysPage(ctx: Ctx): Promise<Response> { | |
| 21 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 22 | if (ctx.auth.user === undefined) return signInFirst(ctx); | |
| 23 | const rows = await timePhaseAsync(ctx.request, "displays", () => listedDisplays(ctx.app, ctx.auth.user)); | |
| 24 | return timePhase(ctx.request, "render", () => | |
| 25 | page({ | |
| 26 | ctx, | |
| 27 | title: "Displays", | |
| 28 | body: html`${pageHead("Displays")} | |
| 29 | ${rows.length === 0 | |
| 30 | ? empty({ icon: "pin", title: "No displays", note: DISPLAYS_NOTE }) | |
| 31 | : html`<div class="table-scroll" role="region" tabindex="0" aria-label="Displays"> | |
| 32 | <table class="table" data-dense data-by="displays"> | |
| 33 | <thead> | |
| 34 | <tr> | |
| 35 | <th scope="col">Title</th> | |
| 36 | <th scope="col">Layout</th> | |
| 37 | <th scope="col">Repository</th> | |
| 38 | <th scope="col">Demos</th> | |
| 39 | </tr> | |
| 40 | </thead> | |
| 41 | <tbody> | |
| 42 | ${rows.map((d) => { | |
| 43 | const base = repoBase(d.repo); | |
| 44 | const name = `${d.repo.ownerName}/${d.repo.name}`; | |
| 45 | return html`<tr data-display="${name}"> | |
| 46 | <td><a href="${base}">${d.config.ok ? d.config.value.title : INVALID_TITLE}</a></td> | |
| 47 | <td>${d.config.ok ? LAYOUT_WORD[d.config.value.layout] : "None"}</td> | |
| 48 | <td><a class="mono" href="${base}">${name}</a></td> | |
| 49 | <td data-demos>${d.demos === undefined ? "None" : String(d.demos)}</td> | |
| 50 | </tr>`; | |
| 51 | })} | |
| 52 | </tbody> | |
| 53 | </table> | |
| 54 | </div>`}`, | |
| 55 | }), | |
| 56 | ); | |
| 57 | } |
test/displays-list.test.ts added +220
| @@ -0,0 +1,220 @@ | ||
| 1 | /** | |
| 2 | * `/displays` and its terminal screen: both list the displays the viewer may | |
outdated
This comment describes behaviour (New display, build sidebar, picker) that is not in this pull. Rewrite it. |
||
| 3 | * read through `listedDisplays` — title, layout, repository, showcased demo | |
| 4 | * count — hide what the viewer cannot read, name a broken display.json as | |
| 5 | * invalid, say what a display is when there are none, and send a stranger to | |
| 6 | * sign in. Making a display is not tested here: it is not in this pull. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, 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 { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 15 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 16 | import { createSession } from "../src/core/sessions.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { pinBuild, type Demo } from "../src/git/builds.ts"; | |
| 19 | import { repoDir } from "../src/git/repos.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 22 | import { entry as displaysEntry, displaysScreen } from "../src/tui/screens/displays.ts"; | |
| 23 | import { ENTRIES } from "../src/tui/screens/entries.ts"; | |
| 24 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 25 | import { settleApp } from "./helpers/tui.ts"; | |
| 26 | import { statements } from "./helpers/statements.ts"; | |
| 27 | import { proveAll } from "./helpers/verified.ts"; | |
| 28 | ||
| 29 | const DOMAIN = "apps.test"; | |
| 30 | const T0 = 1_800_000_000; | |
| 31 | const demo = (name: string, version: string, showcase: boolean): Demo => ({ name, version, page: "/", showcase, launch: "shots" }); | |
| 32 | ||
| 33 | describe("the displays list, page and terminal screen", () => { | |
| 34 | let dataDir: string; | |
| 35 | let app: App; | |
| 36 | let servers: Servers; | |
| 37 | let runtime: AppsRuntime; | |
| 38 | let alice: User; | |
| 39 | let bob: User; | |
| 40 | let tick = 0; | |
| 41 | ||
| 42 | const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`; | |
| 43 | const get = (path: string, u?: User, extra: Record<string, string> = {}) => | |
| 44 | fetch(`${servers.url}${path}`, { headers: { ...(u === undefined ? {} : { cookie: cookie(u) }), ...extra }, redirect: "manual" }); | |
| 45 | const text = async (path: string, u?: User) => { | |
| 46 | const r = await get(path, u); | |
| 47 | expect(r.status, path).toBe(200); | |
| 48 | return r.text(); | |
| 49 | }; | |
| 50 | const repo = (name: string): Repo => getRepo(app.db, "alice", name)!; | |
| 51 | const writeDisplay = async (name: string, config: unknown) => { | |
| 52 | const made = await commitToBranch(app, alice, repo(name), { | |
| 53 | message: "display", | |
| 54 | files: [{ path: "display.json", content: typeof config === "string" ? config : JSON.stringify(config) }], | |
| 55 | }); | |
| 56 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 57 | }; | |
| 58 | const pin = async (name: string, label: string, d?: Demo, parent?: string) => { | |
| 59 | const made = await commitToBranch(app, alice, repo(name), { message: label, files: [{ path: `${label}.txt`, content: label }] }); | |
| 60 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 61 | const pinned = await pinBuild(repoDir(app.reposRoot, "alice", name), { | |
| 62 | label, | |
| 63 | oid: made.value.commitOid, | |
| 64 | note: "", | |
| 65 | source: "commit", | |
| 66 | tagger: { name: "alice", email: "[email protected]" }, | |
| 67 | at: T0 + tick++ * 60, | |
| 68 | ...(d === undefined ? {} : { demo: d }), | |
| 69 | ...(parent === undefined ? {} : { parent }), | |
| 70 | }); | |
| 71 | if (!pinned.ok) throw new Error(pinned.error); | |
| 72 | }; | |
| 73 | /** The displays a page lists, by repository, with their demo counts. */ | |
| 74 | const listed = (page: string) => | |
| 75 | Object.fromEntries([...page.matchAll(/<tr data-display="([^"]+)">[\s\S]*?<td data-demos>([^<]*)<\/td>/g)].map((m) => [m[1]!, m[2]!])); | |
| 76 | beforeAll(async () => { | |
| 77 | dataDir = await mkdtemp(join(tmpdir(), "forge-displays-")); | |
| 78 | app = createApp({ dataDir, port: 0 }); | |
| 79 | servers = await serve(app); | |
| 80 | runtime = new AppsRuntime(app, { domain: DOMAIN, scheme: "http", publicPort: servers.port, forgeUrl: servers.url }); | |
| 81 | await runtime.start(); | |
| 82 | for (const name of ["alice", "bob"]) { | |
| 83 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: true }); | |
| 84 | if (!made.ok) throw new Error(made.error); | |
| 85 | if (name === "alice") alice = made.value; | |
| 86 | else bob = made.value; | |
| 87 | } | |
| 88 | proveAll(app); | |
| 89 | for (const [name, visibility] of [ | |
| 90 | ["karts", "public"], | |
| 91 | ["secret", "private"], | |
| 92 | ["arcade", "public"], | |
| 93 | ["shelf", "public"], | |
| 94 | ["hidden", "private"], | |
| 95 | ["broken", "public"], | |
| 96 | ["fresh", "public"], | |
| 97 | ["nova-vanilla", "public"], | |
| 98 | ["plainfam", "public"], | |
| 99 | ] as const) { | |
| 100 | const made = await createRepo(app, alice, { name, visibility }); | |
| 101 | if (!made.ok) throw new Error(String(made.error)); | |
| 102 | } | |
| 103 | // karts: two showcased demos and a draft; secret: one showcased, private. | |
| 104 | await pin("karts", "fiesta", demo("krazy-karts", "1.0", true)); | |
| 105 | await pin("karts", "london", demo("krazy-karts", "2.0", true)); | |
| 106 | await pin("karts", "draft", demo("krazy-karts", "3.0", false)); | |
| 107 | await pin("secret", "hush", demo("secret-game", "0.1", true)); | |
| 108 | await writeDisplay("arcade", { title: "Arcade night", layout: "grid", sources: ["alice/karts", "alice/secret"] }); | |
| 109 | await writeDisplay("shelf", { title: "Shelf night", layout: "shelf", sources: ["alice/karts"] }); | |
| 110 | await writeDisplay("hidden", { title: "Hidden night", layout: "graph", sources: ["alice/karts"] }); | |
| 111 | await writeDisplay("broken", "{ nope"); | |
| 112 | ||
| 113 | // A repository with pins and no display.json is no display. | |
| 114 | await pin("nova-vanilla", "v1"); | |
| 115 | await pin("nova-vanilla", "v2", demo("nova", "1", true)); | |
| 116 | // plainfam: pins and a demo, none showcased. | |
| 117 | await pin("plainfam", "p1", demo("plain", "1", false)); | |
| 118 | }, 60_000); | |
| 119 | ||
| 120 | afterAll(async () => { | |
| 121 | await runtime.stop(); | |
| 122 | servers.stop(); | |
| 123 | app.close(); | |
| 124 | await rm(dataDir, { recursive: true, force: true }); | |
| 125 | }); | |
| 126 | ||
| 127 | describe("/displays", () => { | |
| 128 | test("lists only the displays the viewer reads, each with its showcased demos counted", async () => { | |
| 129 | const mine = await text("/displays", alice); | |
| 130 | expect(listed(mine)).toEqual({ "alice/arcade": "3", "alice/shelf": "2", "alice/hidden": "2", "alice/broken": "None" }); | |
| 131 | expect(mine).toContain('href="/alice/arcade"'); | |
| 132 | expect(mine).toContain("Arcade night"); | |
| 133 | expect(mine).toContain("display.json is invalid"); | |
| 134 | // A private source's showcased demo is published: it counts for another reader too. | |
| 135 | expect(listed(await text("/displays", bob))).toEqual({ "alice/arcade": "3", "alice/shelf": "2", "alice/broken": "None" }); | |
| 136 | // Iterations are no display.json display. | |
| 137 | expect(mine).not.toContain('data-display="alice/nova'); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a pin that joins the showcase, or a display that changes, is counted again", async () => { | |
| 141 | await pin("karts", "rome", demo("krazy-karts", "5.0", true)); | |
| 142 | expect(listed(await text("/displays", alice))["alice/shelf"]).toBe("3"); | |
| 143 | }); | |
| 144 | ||
| 145 | test("its statements do not grow with its rows", async () => { | |
| 146 | const cost = () => statements(app.db, async () => void (await get("/displays", alice)).arrayBuffer()); | |
| 147 | await get("/displays", alice); | |
| 148 | const few = await cost(); | |
| 149 | for (const name of ["more1", "more2", "more3"]) { | |
| 150 | const made = await createRepo(app, alice, { name, visibility: "public" }); | |
| 151 | if (!made.ok) throw new Error(String(made.error)); | |
| 152 | await writeDisplay(name, { title: name, layout: "grid", sources: ["alice/karts"] }); | |
| 153 | } | |
| 154 | await get("/displays", alice); | |
| 155 | expect(Object.keys(listed(await text("/displays", alice)))).toContain("alice/more3"); | |
| 156 | expect(await cost()).toBe(few); | |
| 157 | }); | |
| 158 | ||
| 159 | test("the empty page says what a display is and where one comes from", async () => { | |
| 160 | const dir = await mkdtemp(join(tmpdir(), "forge-displays-empty-")); | |
| 161 | const bare = createApp({ dataDir: dir, port: 0 }); | |
| 162 | const bareServers = await serve(bare); | |
| 163 | try { | |
| 164 | const made = await createUser(bare.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 165 | if (!made.ok) throw new Error(made.error); | |
| 166 | const r = await fetch(`${bareServers.url}/displays`, { headers: { cookie: `forge_session=${createSession(bare.db, made.value.id).token}` } }); | |
| 167 | const page = await r.text(); | |
| 168 | expect(page).toContain("No displays"); | |
| 169 | expect(page).toContain("A display shows a repository's showcased demos."); | |
| 170 | expect(page).toContain("Add a display.json to a repository's default branch"); | |
| 171 | } finally { | |
| 172 | bareServers.stop(); | |
| 173 | bare.close(); | |
| 174 | await rm(dir, { recursive: true, force: true }); | |
| 175 | } | |
| 176 | }); | |
| 177 | ||
| 178 | test("the terminal screen lists the same rows as the page, and is in the palette", async () => { | |
| 179 | const screenText = async (u: User) => { | |
| 180 | const { app: tui, session } = createForgeApp( | |
| 181 | app, | |
| 182 | { sshUsername: u.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: u }, | |
| 183 | { terminal: headlessTerminal(200, 40), exitHooks: false }, | |
| 184 | ); | |
| 185 | tui.start(); | |
| 186 | tui.ctx.push(displaysScreen(session, tui.ctx)); | |
| 187 | await settleApp(tui); | |
| 188 | const out = tui.screen.toText(); | |
| 189 | tui.stop?.(); | |
| 190 | return out; | |
| 191 | }; | |
| 192 | const mine = await screenText(alice); | |
| 193 | for (const name of Object.keys(listed(await text("/displays", alice)))) expect(mine).toContain(name); | |
| 194 | expect(mine).toContain("Arcade night"); | |
| 195 | expect(mine).toContain("display.json is invalid"); | |
| 196 | expect(mine).toContain("alice/hidden"); | |
| 197 | expect(await screenText(bob)).not.toContain("alice/hidden"); | |
| 198 | expect(ENTRIES).toContain(displaysEntry); | |
| 199 | }); | |
| 200 | ||
| 201 | test("a stranger is sent to sign in", async () => { | |
| 202 | const r = await get("/displays"); | |
| 203 | expect(r.status).toBe(303); | |
| 204 | expect(r.headers.get("location")).toContain("/signin"); | |
| 205 | }); | |
| 206 | ||
| 207 | test("carries the security floor", async () => { | |
| 208 | const r = await get("/displays", alice); | |
| 209 | expect(r.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 210 | expect(r.headers.get("content-security-policy")).not.toBeNull(); | |
| 211 | expect(r.headers.get("referrer-policy")).not.toBeNull(); | |
| 212 | }); | |
| 213 | ||
| 214 | test("names its Server-Timing phases", async () => { | |
| 215 | const r = await get("/displays", alice); | |
| 216 | const names = (r.headers.get("server-timing") ?? "").split(",").map((p) => p.trim().split(";")[0]); | |
| 217 | expect(names).toEqual(expect.arrayContaining(["session", "displays", "render"])); | |
| 218 | }); | |
| 219 | }); | |
| 220 | }); |
test/forge-looks.test.ts +1
| @@ -58,6 +58,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 58 | 58 | "/events": "a live event stream that never ends", |
| 59 | 59 | "/boards": "the list of boards; /board is pictured, and the slot went to the preview seed's repository as its owner (#8865); covered by test/boards-page.test.ts", |
| 60 | 60 | "/changelog": "the merged changes in words; /releases is pictured, and the slot went to the preview seed's pull as its owner (#8865); covered by test/changelog.test.ts", |
| 61 | "/displays": "a list of displays behind sign-in; its slot is not pictured; covered by test/displays-list.test.ts", | |
| 61 | 62 | "/search": "a search box; its slot went to the preview seed's /next as its owner (#8866)", |
| 62 | 63 | "/contribute": "a static page about how to help; its slot went to the preview seed's /bets as its owner (#8866)", |
| 63 | 64 | "/migrate": "an import form, nothing to picture until a job runs; the look slot went to /repos", |
test/palette-roster.test.ts +3
| @@ -49,6 +49,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 49 | 49 | ["explore", "Explore", "on the web"], |
| 50 | 50 | ["profile", "Open a profile", "username or org"], |
| 51 | 51 | ["repos", "Repositories", "the repositories you may write"], |
| 52 | ["displays", "Displays", "the displays you may read"], | |
| 52 | 53 | ["new-issue-anywhere", "New issue", "pick a repository"], |
| 53 | 54 | ["repo", "Open a repository", "owner/name"], |
| 54 | 55 | ["issues", "Issues in a repository", "owner/name"], |
| @@ -150,6 +151,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 150 | 151 | ["explore", "Explore", "on the web"], |
| 151 | 152 | ["profile", "Open a profile", "username or org"], |
| 152 | 153 | ["repos", "Repositories", "the repositories you may write"], |
| 154 | ["displays", "Displays", "the displays you may read"], | |
| 153 | 155 | ["new-issue-anywhere", "New issue", "pick a repository"], |
| 154 | 156 | ["repo", "Open a repository", "owner/name"], |
| 155 | 157 | ["issues", "Issues in a repository", "owner/name"], |
| @@ -215,6 +217,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 215 | 217 | ["explore", "Explore", "on the web"], |
| 216 | 218 | ["profile", "Open a profile", "username or org"], |
| 217 | 219 | ["repos", "Repositories", "the repositories you may write"], |
| 220 | ["displays", "Displays", "the displays you may read"], | |
| 218 | 221 | ["new-issue-anywhere", "New issue", "pick a repository"], |
| 219 | 222 | ["repo", "Open a repository", "owner/name"], |
| 220 | 223 | ["issues", "Issues in a repository", "owner/name"], |
Link text for a broken config reads as an error label, not a title. Make it say the file is invalid and keep the link to the repository so the owner can fix it.