Public: anyone can read this; the crew can change it.
`/:owner/:name/display` shows a repository's display on main, read-only #9540
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9531-owner-name-display-shows-a-repository-s → main 3 commits fork 1 thread · 1 unresolved
10 files
changed +379
−1
docs/design/areas/display.md added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | # Display | |
| 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. |
docs/features/display.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # Display | |
| 2 | ||
| 3 | One repository's display: the builds its `display.json` (on the default branch) shows. Read only. | |
| 4 | ||
| 5 | - **Page**: `/<owner>/<repo>/display`. Server-rendered. The title and layout, then one row per build: name, version, and, when you may read the build's repository, links to its screenshots and the repository. A build whose repository you cannot read shows its name and version and "Private", nothing more. A `display.json` that does not parse shows its error. | |
| 6 | - **404**: a repository with no `display.json`, one that does not exist, and one you may not read all answer the same 404. | |
| 7 | - **Reaching it**: each row of `/displays` links to it. | |
| 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 | ||
| 10 | Not done: ticking builds, editing the title, covers, the player and the Arcade host. See `docs/features/displays.md` for the list. |
src/display/launch.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** | |
| 2 | * Where a display item opens, as far as main reads it: a pin's screenshots | |
| 3 | * on its own repository. Jay Moran's launch.ts (`origin/incoming/jay-main`) | |
| 4 | * also signs grants for captured pages on the display origin and names the | |
| 5 | * covers and downloads; those wait for the Arcade host and for `pin_demos.ts`. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Repo } from "../core/repos.ts"; | |
| 9 | import type { BuildPin } from "../git/builds.ts"; | |
| 10 | ||
| 11 | /** A pin's screenshots, on its repository. */ | |
| 12 | export const repoShotsUrl = (repo: Pick<Repo, "ownerName" | "name">, pin: Pick<BuildPin, "label">): string => | |
| 13 | `/${repo.ownerName}/${repo.name}/builds/${encodeURIComponent(pin.label)}/shots`; |
src/http/server.ts +3
| @@ -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 { displayPage } from "../web/pages/display.ts"; | |
| 38 | 39 | import { displaysPage } from "../web/pages/displays.ts"; |
| 39 | 40 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| 40 | 41 | import { actingVia } from "../core/acting_via.ts"; |
| @@ -838,6 +839,8 @@ export function buildRouter(): Router<Handler> { | ||
| 838 | 839 | router.add("GET", "/new/issue", newIssueAnywhere); |
| 839 | 840 | router.add("GET", "/repos", myReposPage); |
| 840 | 841 | router.add("GET", "/displays", displaysPage); |
| 842 | // A repository's display, read only (`display.ts`): 404 without a display.json or without read access. | |
| 843 | router.add("GET", "/:owner/:name/display", displayPage); | |
| 841 | 844 | router.add("GET", "/now/:stage", nowPage); |
| 842 | 845 | router.add("GET", "/audit", auditPage); |
| 843 | 846 | router.add("GET", "/releases", releasesPage); |
src/tui/screens/display.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * A repository's display (`/<owner>/<repo>/display` on the web), read only: | |
| 3 | * its title and layout, then its builds as `displayPage` shows them — a build | |
| 4 | * whose repository the viewer reads names it, any other says "private" and | |
| 5 | * nothing more. Read through the same `displayOf` and `displayItems`, and | |
| 6 | * gated here as well as on the command, since the screen is exported. | |
| 7 | */ | |
| 8 | ||
| 9 | import { canRead, type Repo } from "../../core/repos.ts"; | |
| 10 | import { displayItems, type DisplayItem } from "../../display/items.ts"; | |
| 11 | import { INVALID_TITLE, displayOf } from "../../display/known.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import type { Command } from "../app/palette.ts"; | |
| 15 | import type { Session } from "../session.ts"; | |
| 16 | import { style } from "../term/style.ts"; | |
| 17 | import { truncate } from "../term/width.ts"; | |
| 18 | import { ListState } from "./common.ts"; | |
| 19 | ||
| 20 | export const NO_DISPLAY_LINE = "No display here"; | |
| 21 | export const NO_BUILDS_LINE = "This display shows no builds yet."; | |
| 22 | ||
| 23 | /** One build as a line: name and version, then its repository, or "private". */ | |
| 24 | export const itemLine = (it: DisplayItem): string => | |
| 25 | `${it.demo.name} ${it.demo.version} ${it.open ? `${it.repo.ownerName}/${it.repo.name}` : "private"}`; | |
| 26 | ||
| 27 | export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 28 | let head: string | undefined; | |
| 29 | let lines: string[] = []; | |
| 30 | let missing = false; | |
| 31 | const state = new ListState(); | |
| 32 | return { | |
| 33 | 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(); | |
| 44 | }, | |
| 45 | render(area, s) { | |
| 46 | const c = ctx.theme.c; | |
| 47 | const right = area.x + area.w; | |
| 48 | const line = (y: number, text: string, fg = c.fg, x = 1) => s.text(area.x + x, area.y + y, truncate(text, Math.max(1, area.w - x)), style(fg, c.bg), right); | |
| 49 | if (missing) return void line(0, NO_DISPLAY_LINE); | |
| 50 | if (head === undefined) return void line(0, "Reading…"); | |
| 51 | 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); | |
| 55 | for (let i = 0; i < rows; i++) { | |
| 56 | const l = lines[from + i]; | |
| 57 | if (l === undefined) break; | |
| 58 | 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); | |
| 61 | } | |
| 62 | }, | |
| 63 | key(e) { | |
| 64 | const n = lines.length; | |
| 65 | if (e.key === "j" || e.key === "down") state.move(1, n); | |
| 66 | else if (e.key === "k" || e.key === "up") state.move(-1, n); | |
| 67 | else if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 68 | else return false; | |
| 69 | ctx.invalidate(); | |
| 70 | return true; | |
| 71 | }, | |
| 72 | }; | |
| 73 | } | |
| 74 | ||
| 75 | /** The palette's door on a repository screen, for whoever may read it. */ | |
| 76 | export function displayCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 77 | if (repo === undefined || !canRead(f.db, repo, f.user)) return []; | |
| 78 | return [ | |
| 79 | { | |
| 80 | id: "display", | |
| 81 | title: `${repo.ownerName}/${repo.name} — Display`, | |
| 82 | hint: "the builds its display.json shows", | |
| 83 | run: () => ctx.push(displayScreen(f, ctx, repo)), | |
| 84 | }, | |
| 85 | ]; | |
| 86 | } |
src/tui/screens/workspace.ts +2
| @@ -134,6 +134,7 @@ import { deleteRepoCommands } from "./deleterepo.ts"; | ||
| 134 | 134 | import { grantsCommands } from "./grants.ts"; |
| 135 | 135 | import { visionCommands } from "./vision.ts"; |
| 136 | 136 | import { buildsCommands } from "./builds.ts"; |
| 137 | import { displayCommands } from "./display.ts"; | |
| 137 | 138 | import { iterationsCommands } from "./iterations.ts"; |
| 138 | 139 | import { labelsCommands } from "./labels.ts"; |
| 139 | 140 | import { appEnvCommands } from "./appenv.ts"; |
| @@ -1455,6 +1456,7 @@ export function workspaceScreen( | ||
| 1455 | 1456 | ...labelsCommands(f, ctx, repo), |
| 1456 | 1457 | ...buildsCommands(f, ctx, repo), |
| 1457 | 1458 | ...iterationsCommands(f, ctx, repo), |
| 1459 | ...displayCommands(f, ctx, repo), | |
| 1458 | 1460 | ...appEnvCommands(f, ctx, repo), |
| 1459 | 1461 | ...autonomyCommands(f, ctx, repo), |
| 1460 | 1462 | ...trafficCommands(f, ctx, repo), |
src/web/pages/display.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 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. | |
| 8 | */ | |
| 9 | ||
| 10 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 11 | import { displayItems } from "../../display/items.ts"; | |
| 12 | import { INVALID_TITLE, displayOf } from "../../display/known.ts"; | |
| 13 | import { repoShotsUrl } from "../../display/launch.ts"; | |
| 14 | 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 | ||
| 20 | const LAYOUT_WORD = { shelf: "Shelf", grid: "Grid", graph: "Graph" } as const; | |
| 21 | ||
| 22 | // GET /:owner/:name/display — 404 for a repository with no display.json or one the viewer cannot read. | |
| 23 | export async function displayPage(ctx: Ctx): Promise<Response> { | |
| 24 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 25 | const repo = loadRepo(ctx); | |
| 26 | if (!repo) return timePhase(ctx.request, "render", () => notFoundPage(ctx)); | |
| 27 | const config = await timePhaseAsync(ctx.request, "display", () => displayOf(ctx.app, repo)); | |
| 28 | if (config === undefined) return timePhase(ctx.request, "render", () => notFoundPage(ctx)); | |
| 29 | const items = config.ok ? await timePhaseAsync(ctx.request, "items", () => displayItems(ctx.app, repo, config.value, ctx.auth.user)) : []; | |
| 30 | const title = config.ok ? config.value.title : INVALID_TITLE; | |
| 31 | const body = html`${repoHeader(ctx, repo, "code", { compact: true })} | |
| 32 | <div class="page-head mt"> | |
| 33 | <h2>${title}</h2> | |
| 34 | <a href="/displays">All displays</a> | |
| 35 | </div> | |
| 36 | ${!config.ok | |
| 37 | ? html`<p role="alert">${config.error}</p>` | |
| 38 | : items.length === 0 | |
| 39 | ? empty({ icon: "pin", title: "No builds", note: "This display shows no builds yet." }) | |
| 40 | : html`<p>${LAYOUT_WORD[config.value.layout]} · ${String(items.length)} ${items.length === 1 ? "build" : "builds"}</p> | |
| 41 | <div class="table-scroll" role="region" tabindex="0" aria-label="Builds"> | |
| 42 | <table class="table" data-dense data-by="display-builds"> | |
| 43 | <thead> | |
| 44 | <tr><th scope="col">Build</th><th scope="col">Version</th><th scope="col">Repository</th></tr> | |
| 45 | </thead> | |
| 46 | <tbody> | |
| 47 | ${items.map((it) => | |
| 48 | it.open | |
| 49 | ? html`<tr data-build="${it.key}"> | |
| 50 | <td><a href="${repoShotsUrl(it.repo, it.pin)}">${it.demo.name}</a></td> | |
| 51 | <td class="mono">${it.demo.version}</td> | |
| 52 | <td><a class="mono" href="${repoBase(it.repo)}">${it.repo.ownerName}/${it.repo.name}</a></td> | |
| 53 | </tr>` | |
| 54 | : html`<tr data-build="private"> | |
| 55 | <td>${it.demo.name}</td> | |
| 56 | <td class="mono">${it.demo.version}</td> | |
| 57 | <td>Private</td> | |
| 58 | </tr>`, | |
| 59 | )} | |
| 60 | </tbody> | |
| 61 | </table> | |
| 62 | </div>`}`; | |
| 63 | return timePhase(ctx.request, "render", () => | |
| 64 | page({ ctx, title: `Display · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }), | |
| 65 | ); | |
| 66 | } |
src/web/pages/displays.ts +1 −1
| @@ -43,7 +43,7 @@ export async function displaysPage(ctx: Ctx): Promise<Response> { | ||
| 43 | 43 | const base = repoBase(d.repo); |
| 44 | 44 | const name = `${d.repo.ownerName}/${d.repo.name}`; |
| 45 | 45 | return html`<tr data-display="${name}"> |
| 46 | <td><a href="${base}">${d.config.ok ? d.config.value.title : INVALID_TITLE}</a></td> | |
| 46 | <td><a href="${base}/display">${d.config.ok ? d.config.value.title : INVALID_TITLE}</a></td> | |
| 47 | 47 | <td>${d.config.ok ? LAYOUT_WORD[d.config.value.layout] : "None"}</td> |
| 48 | 48 | <td><a class="mono" href="${base}">${name}</a></td> |
| 49 | 49 | <td data-demos>${d.demos === undefined ? "None" : String(d.demos)}</td> |
test/display.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | /** `/:owner/:name/display`: a repository's display, read only. */ | |
| 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 { createApp, type App } from "../src/app.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { getRepo } from "../src/core/repos.ts"; | |
| 9 | import { createSession } from "../src/core/sessions.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { pinBuild } from "../src/git/builds.ts"; | |
| 12 | import { repoDir } from "../src/git/repos.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { proveAll } from "./helpers/verified.ts"; | |
| 15 | ||
| 16 | describe("the display page", () => { | |
| 17 | let dataDir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let alice: User; | |
| 21 | let bob: User; | |
| 22 | ||
| 23 | const get = (path: string, u?: User) => | |
| 24 | fetch(`${servers.url}${path}`, { | |
| 25 | headers: u === undefined ? {} : { cookie: `forge_session=${createSession(app.db, u.id).token}` }, | |
| 26 | redirect: "manual", | |
| 27 | }); | |
| 28 | const commit = async (name: string, path: string, content: string) => { | |
| 29 | const made = await commitToBranch(app, alice, getRepo(app.db, "alice", name)!, { message: path, files: [{ path, content }] }); | |
| 30 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 31 | return made.value.commitOid; | |
| 32 | }; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-display-")); | |
| 36 | app = createApp({ dataDir, port: 0 }); | |
| 37 | servers = await serve(app); | |
| 38 | for (const name of ["alice", "bob"]) { | |
| 39 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: true }); | |
| 40 | if (!made.ok) throw new Error(made.error); | |
| 41 | if (name === "alice") alice = made.value; | |
| 42 | else bob = made.value; | |
| 43 | } | |
| 44 | proveAll(app); | |
| 45 | for (const [name, visibility] of [["karts", "public"], ["vault", "private"], ["plain", "public"]] as const) { | |
| 46 | const made = await createRepo(app, alice, { name, visibility }); | |
| 47 | if (!made.ok) throw new Error(String(made.error)); | |
| 48 | } | |
| 49 | const oid = await commit("karts", "a.txt", "a"); | |
| 50 | const pinned = await pinBuild(repoDir(app.reposRoot, "alice", "karts"), { | |
| 51 | label: "fiesta", | |
| 52 | oid, | |
| 53 | note: "", | |
| 54 | source: "commit", | |
| 55 | tagger: { name: "alice", email: "[email protected]" }, | |
| 56 | at: 1_800_000_000, | |
| 57 | demo: { name: "krazy-karts", version: "1.0", page: "/", showcase: true, launch: "shots" }, | |
| 58 | }); | |
| 59 | if (!pinned.ok) throw new Error(pinned.error); | |
| 60 | await commit("karts", "display.json", JSON.stringify({ title: "Kart night", layout: "grid", sources: ["alice/karts"], builds: ["alice/karts:fiesta"] })); | |
| 61 | await commit("vault", "display.json", JSON.stringify({ title: "Vault night", layout: "shelf", sources: ["alice/vault"] })); | |
| 62 | }, 60_000); | |
| 63 | ||
| 64 | afterAll(async () => { | |
| 65 | servers.stop(); | |
| 66 | app.close(); | |
| 67 | await rm(dataDir, { recursive: true, force: true }); | |
| 68 | }); | |
| 69 | ||
| 70 | test("shows the title and the pinned build, server-rendered, with the security headers", async () => { | |
| 71 | const res = await get("/alice/karts/display", bob); | |
| 72 | expect(res.status).toBe(200); | |
| 73 | expect(res.headers.get("content-security-policy")).toBeTruthy(); | |
| 74 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 75 | expect(res.headers.get("referrer-policy")).toBeTruthy(); | |
| 76 | const page = await res.text(); | |
| 77 | expect(page).toContain("Kart night"); | |
| 78 | expect(page).toContain("krazy-karts"); | |
| 79 | expect(page).toContain('href="/alice/karts/builds/fiesta/shots"'); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a repository without display.json is a 404", async () => { | |
| 83 | expect((await get("/alice/plain/display", alice)).status).toBe(404); | |
| 84 | expect((await get("/alice/nope/display", alice)).status).toBe(404); | |
| 85 | }); | |
| 86 | ||
| 87 | test("an unreadable repository is a 404, a readable one is not", async () => { | |
| 88 | expect((await get("/alice/vault/display", bob)).status).toBe(404); | |
| 89 | expect((await get("/alice/vault/display")).status).toBe(404); | |
| 90 | expect((await get("/alice/vault/display", alice)).status).toBe(200); | |
| 91 | }); | |
| 92 | ||
| 93 | test("/displays links each entry to its display page", async () => { | |
| 94 | const list = await (await get("/displays", alice)).text(); | |
| 95 | expect(list).toContain('href="/alice/karts/display"'); | |
| 96 | expect(list).toContain('href="/alice/vault/display"'); | |
| 97 | }); | |
| 98 | }); |
test/tui-display.test.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | /** The terminal's Display screen: title, layout and builds, the open/private split, 404-alike for the unreadable. */ | |
| 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 { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { pinBuild } from "../src/git/builds.ts"; | |
| 11 | import { repoDir } from "../src/git/repos.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { displayCommands, displayScreen, NO_DISPLAY_LINE } from "../src/tui/screens/display.ts"; | |
| 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dir: string; | |
| 21 | let app: App; | |
| 22 | let alice: User; | |
| 23 | let bob: User; | |
| 24 | const guest = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" }; | |
| 25 | const repo = (n: string): Repo => getRepo(app.db, "alice", n)!; | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | dir = await mkdtemp(join(tmpdir(), "forge-tui-display-")); | |
| 29 | app = createApp({ dataDir: dir, port: 0 }); | |
| 30 | const mk = async (username: string) => { | |
| 31 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: true }); | |
| 32 | if (!made.ok) throw new Error(made.error); | |
| 33 | return made.value; | |
| 34 | }; | |
| 35 | alice = await mk("alice"); | |
| 36 | bob = await mk("bob"); | |
| 37 | for (const [name, visibility] of [["karts", "public"], ["vault", "private"], ["plain", "public"]] as const) { | |
| 38 | const made = await createRepo(app, alice, { name, visibility }); | |
| 39 | if (!made.ok) throw new Error(String(made.error)); | |
| 40 | } | |
| 41 | const commit = async (name: string, path: string, content: string) => { | |
| 42 | const c = await commitToBranch(app, alice, repo(name), { message: path, files: [{ path, content }] }); | |
| 43 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 44 | return c.value.commitOid; | |
| 45 | }; | |
| 46 | const pin = async (name: string, label: string, demo: string) => { | |
| 47 | const p = await pinBuild(repoDir(app.reposRoot, "alice", name), { | |
| 48 | label, oid: await commit(name, `${label}.txt`, label), note: "", source: "commit", | |
| 49 | tagger: { name: "alice", email: "[email protected]" }, at: 1_800_000_000, | |
| 50 | demo: { name: demo, version: "1.0", page: "/", showcase: true, launch: "shots" }, | |
| 51 | }); | |
| 52 | if (!p.ok) throw new Error(p.error); | |
| 53 | }; | |
| 54 | await pin("karts", "fiesta", "krazy-karts"); | |
| 55 | await pin("vault", "hush", "secret-game"); | |
| 56 | await commit("karts", "display.json", JSON.stringify({ title: "Kart night", layout: "grid", sources: ["alice/karts", "alice/vault"] })); | |
| 57 | await commit("vault", "display.json", JSON.stringify({ title: "Vault night", layout: "shelf", sources: ["alice/vault"] })); | |
| 58 | }, 60_000); | |
| 59 | ||
| 60 | afterAll(async () => { | |
| 61 | app.close(); | |
| 62 | await rm(dir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | async function drive(r: Repo, user: User | undefined): Promise<string> { | |
| 66 | const terminal = headlessTerminal(100, 30); | |
| 67 | const { app: tui, session } = createForgeApp(app, user ? { ...guest, user } : guest, { terminal, exitHooks: false }); | |
| 68 | tui.start(); | |
| 69 | tui.ctx.push(displayScreen(session, tui.ctx, r)); | |
| 70 | await settleApp(tui); | |
| 71 | const text = tui.screen.toText(); | |
| 72 | tui.stop?.(); | |
| 73 | return text; | |
| 74 | } | |
| 75 | ||
| 76 | test("a reader sees the title, layout and builds; a private build's repository is named only to its readers", async () => { | |
| 77 | const mine = await drive(repo("karts"), alice); | |
| 78 | for (const w of ["Kart night", "grid", "krazy-karts 1.0", "alice/karts", "secret-game 1.0", "alice/vault"]) expect(mine).toContain(w); | |
| 79 | const other = await drive(repo("karts"), bob); | |
| 80 | expect(other).toContain("krazy-karts 1.0"); | |
| 81 | expect(other).toContain("secret-game 1.0 private"); | |
| 82 | expect(other).not.toContain("alice/vault"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("no display.json, or an unreadable repository, reads as no display", async () => { | |
| 86 | expect(await drive(repo("plain"), alice)).toContain(NO_DISPLAY_LINE); | |
| 87 | const text = await drive(repo("vault"), undefined); | |
| 88 | expect(text).toContain(NO_DISPLAY_LINE); | |
| 89 | expect(text).not.toContain("Vault night"); | |
| 90 | }); | |
| 91 | ||
| 92 | test("the command is offered for a readable repository only", () => { | |
| 93 | const terminal = headlessTerminal(100, 30); | |
| 94 | const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false }); | |
| 95 | expect(displayCommands(session, tui.ctx, repo("vault"))).toEqual([]); | |
| 96 | expect(displayCommands(session, tui.ctx, repo("karts")).map((c) => c.id)).toEqual(["display"]); | |
| 97 | }); |
The page has no terminal counterpart. Add a TUI screen for one repository's display (same gating) and a feature doc docs/features/display.md.