Public: anyone can read this; the crew can change it.
The terminal shows the front page as a stranger sees it, ranked with its numbers, and a site admin pins or hides an app after a plan, as MCP `front_page` does #7745
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7290-the-terminal-shows-the-front-page-as-a-s → main 3 commits fork
6 files
changed +434
−5
docs/TOUR.md +18
| @@ -253,6 +253,24 @@ The terminal's issue and pull screens stay whole on purpose: they are a | ||
| 253 | 253 | scrolling text view of one thread already in memory, not a page a phone |
| 254 | 254 | downloads, so there is no weight to cut and no second key to learn. |
| 255 | 255 | |
| 256 | ## The front page | |
| 257 | ||
| 258 | What a stranger is shown at `/`, ranked with its numbers, and a site admin's | |
| 259 | say over it: pinned apps first (the first public pin is featured), hidden apps | |
| 260 | never, a private app never. Every door reads `frontJson` in | |
| 261 | `src/actions/front.ts` and sets through `setFront`, so they never disagree. | |
| 262 | ||
| 263 | - **Web:** `/` for everyone; the admin's front-page settings pin, hide and weigh. | |
| 264 | - **API:** `GET` and `PUT /api/v1/front`. | |
| 265 | - **MCP:** `front_page` reads the list; a site admin passes `pinned`, `hidden` | |
| 266 | or `weights` (a dry run unless `dryRun: false`). | |
| 267 | - **Terminal:** palette → "Front page" lists the same apps in the same order, | |
| 268 | each with forks, ships, use and fresh. For a site admin who is a person, `p` | |
| 269 | pins the selected app first (or unpins it) and `h` hides it (or shows it | |
| 270 | again, from the hidden rows at the end); each shows the plan, the dry run of | |
| 271 | `setFront`, and applies on Apply. Anyone else, signed out included, has the | |
| 272 | list and neither key. | |
| 273 | ||
| 256 | 274 | ## Who can see it |
| 257 | 275 | |
| 258 | 276 | Every repository page says, beside the Private/Public badge, who can see it in |
src/actions/front.ts +22 −5
| @@ -486,13 +486,30 @@ export function frontInput( | ||
| 486 | 486 | return { pinned, hidden, weights }; |
| 487 | 487 | } |
| 488 | 488 | |
| 489 | /** One app on the front page as JSON. */ | |
| 490 | export type FrontRowJson = { | |
| 491 | readonly repo: string; | |
| 492 | readonly description: string; | |
| 493 | readonly url: string; | |
| 494 | readonly state: EnvState | null; | |
| 495 | readonly picture: string | null; | |
| 496 | readonly pinned: boolean; | |
| 497 | readonly score: number; | |
| 498 | readonly signals: FrontSignals; | |
| 499 | }; | |
| 500 | ||
| 501 | /** The front page as JSON, as `frontJson` answers it. */ | |
| 502 | export type FrontJson = { | |
| 503 | readonly featured: (FrontRowJson & { readonly crew: CrewAtWork }) | null; | |
| 504 | readonly apps: readonly FrontRowJson[]; | |
| 505 | readonly total: number; | |
| 506 | readonly picks?: FrontPicksJson; | |
| 507 | }; | |
| 508 | ||
| 489 | 509 | /** The front page as JSON: what a stranger is shown; a site admin also gets the picks. */ |
| 490 | export function frontJson( | |
| 491 | app: App, | |
| 492 | viewer: User | undefined, | |
| 493 | ): Record<string, unknown> { | |
| 510 | export function frontJson(app: App, viewer: User | undefined): FrontJson { | |
| 494 | 511 | const front = frontPage(app); |
| 495 | const row = (e: FrontEntry) => ({ | |
| 512 | const row = (e: FrontEntry): FrontRowJson => ({ | |
| 496 | 513 | repo: `${e.repo.ownerName}/${e.repo.name}`, |
| 497 | 514 | description: e.repo.description, |
| 498 | 515 | url: `/${e.repo.ownerName}/${e.repo.name}`, |
src/tui/screens/entries.ts +2
| @@ -14,6 +14,7 @@ import { entry as cloud } from "./cloud.ts"; | ||
| 14 | 14 | import { entry as crewHandover } from "./crewhandover.ts"; |
| 15 | 15 | import { entry as crewSplits } from "./crewsplits.ts"; |
| 16 | 16 | import { entry as domains } from "./domains.ts"; |
| 17 | import { entry as front } from "./front.ts"; | |
| 17 | 18 | import { entry as forges } from "./forges.ts"; |
| 18 | 19 | import { entry as invites } from "./invites.ts"; |
| 19 | 20 | import { entry as limits } from "./limits.ts"; |
| @@ -67,6 +68,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 67 | 68 | subscriptions, |
| 68 | 69 | cloud, |
| 69 | 70 | domains, |
| 71 | front, | |
| 70 | 72 | owner, |
| 71 | 73 | forges, |
| 72 | 74 | ]; |
src/tui/screens/front.ts added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | /** | |
| 2 | * The front page in the terminal: the apps a signed-out visitor is shown, in | |
| 3 | * order, each with its score parts, as MCP `front_page` answers. A site admin | |
| 4 | * who is a person also pins and hides after a plan; `setFront` decides, and | |
| 5 | * a private app is never listed because `frontJson` never lists it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { frontJson, type FrontRowJson, frontPicksJson, setFront } from "../../actions/front.ts"; | |
| 9 | import { canRead, getRepo } from "../../core/repos.ts"; | |
| 10 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 11 | import { binding } from "../app/keys.ts"; | |
| 12 | import { fill, len, split } from "../ui/layout.ts"; | |
| 13 | import { style } from "../term/style.ts"; | |
| 14 | import { truncate } from "../term/width.ts"; | |
| 15 | import type { Session } from "../session.ts"; | |
| 16 | import { ListState, confirmScreen, drawList, type ListRow } from "./common.ts"; | |
| 17 | import type { PaletteEntry } from "./entries.ts"; | |
| 18 | ||
| 19 | type Signals = FrontRowJson["signals"]; | |
| 20 | ||
| 21 | interface FrontRow { | |
| 22 | repo: string; | |
| 23 | mark: "featured" | "pinned" | "hidden" | ""; | |
| 24 | score: number | undefined; | |
| 25 | signals: Signals | undefined; | |
| 26 | } | |
| 27 | ||
| 28 | const meta = (r: FrontRow): string => | |
| 29 | r.signals === undefined || r.score === undefined | |
| 30 | ? "kept off the page" | |
| 31 | : `score ${r.score} · forks ${r.signals.forks} · ships ${r.signals.ships} · use ${Math.round(r.signals.awakeHours)}h · fresh ${r.signals.shippedAt?.slice(0, 10) ?? "never"}`; | |
| 32 | ||
| 33 | export function frontScreen(f: Session, ctx: Ctx): Screen { | |
| 34 | const state = new ListState(); | |
| 35 | let rows: FrontRow[] = []; | |
| 36 | let pinned: string[] = []; | |
| 37 | let hidden: string[] = []; | |
| 38 | let loaded = false; | |
| 39 | ||
| 40 | /** A site admin who is a person: the only one who changes the page. */ | |
| 41 | const mayEdit = (): boolean => f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 42 | ||
| 43 | const refresh = (): void => { | |
| 44 | // The list is the stranger's: no viewer, whoever is looking. | |
| 45 | const page = frontJson(f.app, undefined); | |
| 46 | const shown: FrontRow[] = []; | |
| 47 | if (page.featured !== null) | |
| 48 | shown.push({ repo: page.featured.repo, mark: "featured", score: page.featured.score, signals: page.featured.signals }); | |
| 49 | for (const a of page.apps) | |
| 50 | shown.push({ repo: a.repo, mark: a.pinned ? "pinned" : "", score: a.score, signals: a.signals }); | |
| 51 | if (mayEdit()) { | |
| 52 | const picks = frontPicksJson(f.app); | |
| 53 | pinned = [...picks.pinned]; | |
| 54 | hidden = [...picks.hidden]; | |
| 55 | for (const name of hidden) { | |
| 56 | const [owner, repoName] = name.split("/"); | |
| 57 | const repo = owner && repoName ? getRepo(f.app.db, owner, repoName) : undefined; | |
| 58 | // A private app is not listed for anyone, hidden or not. | |
| 59 | if (repo !== undefined && canRead(f.app.db, repo, undefined)) | |
| 60 | shown.push({ repo: name, mark: "hidden", score: undefined, signals: undefined }); | |
| 61 | } | |
| 62 | } | |
| 63 | rows = shown; | |
| 64 | loaded = true; | |
| 65 | state.clamp(rows.length); | |
| 66 | ctx.invalidate(); | |
| 67 | }; | |
| 68 | ||
| 69 | const plan = (repo: string, action: "pin" | "hide"): { pinned: string[]; hidden: string[]; line: string } => { | |
| 70 | const isPinned = pinned.includes(repo); | |
| 71 | const isHidden = hidden.includes(repo); | |
| 72 | if (action === "pin") | |
| 73 | return isPinned | |
| 74 | ? { pinned: pinned.filter((p) => p !== repo), hidden, line: `Unpin ${repo}: it ranks by score again.` } | |
| 75 | : { pinned: [repo, ...pinned], hidden: hidden.filter((h) => h !== repo), line: `Pin ${repo} first: it is featured.` }; | |
| 76 | return isHidden | |
| 77 | ? { pinned, hidden: hidden.filter((h) => h !== repo), line: `Show ${repo}: strangers may see it again.` } | |
| 78 | : { pinned: pinned.filter((p) => p !== repo), hidden: [...hidden, repo], line: `Hide ${repo}: strangers no longer see it.` }; | |
| 79 | }; | |
| 80 | ||
| 81 | const ask = (action: "pin" | "hide"): void => { | |
| 82 | if (!mayEdit()) return; | |
| 83 | const row = rows[state.index]; | |
| 84 | if (row === undefined) return; | |
| 85 | const next = plan(row.repo, action); | |
| 86 | // The plan is the action's own dry run, so it says what would be refused. | |
| 87 | const dry = setFront(f.app, f.user, { pinned: next.pinned, hidden: next.hidden }, true); | |
| 88 | if (!dry.ok) { | |
| 89 | ctx.toast(dry.error.kind === "forbidden" ? "Only a site admin sets the front page." : dry.error.message, "danger"); | |
| 90 | return; | |
| 91 | } | |
| 92 | ctx.push( | |
| 93 | confirmScreen(ctx, screen, { | |
| 94 | title: action === "pin" ? "Pin" : "Hide", | |
| 95 | body: [ | |
| 96 | next.line, | |
| 97 | `Pinned: ${dry.value.pinned.join(", ") || "none"}`, | |
| 98 | `Hidden: ${dry.value.hidden.join(", ") || "none"}`, | |
| 99 | ], | |
| 100 | actions: [{ label: "Apply" }, { label: "Cancel" }], | |
| 101 | selected: 1, | |
| 102 | onChoose: (choice) => { | |
| 103 | if (choice !== 0) return; | |
| 104 | const done = setFront(f.app, f.user, { pinned: next.pinned, hidden: next.hidden }); | |
| 105 | ctx.toast(done.ok ? next.line : done.error.kind === "forbidden" ? "Only a site admin sets the front page." : done.error.message, done.ok ? undefined : "danger"); | |
| 106 | refresh(); | |
| 107 | }, | |
| 108 | }), | |
| 109 | ); | |
| 110 | }; | |
| 111 | ||
| 112 | const screen: Screen = { | |
| 113 | title: "Front page", | |
| 114 | get keys() { | |
| 115 | return [ | |
| 116 | binding(["j", "k"], "move cursor"), | |
| 117 | ...(mayEdit() ? [binding(["p"], "pin or unpin"), binding(["h"], "hide or show")] : []), | |
| 118 | ]; | |
| 119 | }, | |
| 120 | load() { | |
| 121 | refresh(); | |
| 122 | return Promise.resolve(); | |
| 123 | }, | |
| 124 | render(area, s) { | |
| 125 | const c = ctx.theme.c; | |
| 126 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 127 | const note = !loaded ? "Loading…" : rows.length === 0 ? "No app is on the front page." : "What a signed-out visitor sees, in order"; | |
| 128 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 129 | const lines: ListRow[] = rows.map((r) => ({ | |
| 130 | title: r.repo, | |
| 131 | secondary: r.mark, | |
| 132 | meta: meta(r), | |
| 133 | })); | |
| 134 | drawList(s, list!, c, state, lines); | |
| 135 | }, | |
| 136 | key(e) { | |
| 137 | if (e.key === "p" && mayEdit()) { | |
| 138 | ask("pin"); | |
| 139 | return true; | |
| 140 | } | |
| 141 | if (e.key === "h" && mayEdit()) { | |
| 142 | ask("hide"); | |
| 143 | return true; | |
| 144 | } | |
| 145 | return state.key(e, rows.length); | |
| 146 | }, | |
| 147 | }; | |
| 148 | return screen; | |
| 149 | } | |
| 150 | ||
| 151 | /** The stranger's front page, ranked with its numbers, as MCP `front_page` has it. */ | |
| 152 | export const entry: PaletteEntry = { | |
| 153 | id: "front", | |
| 154 | title: "Front page", | |
| 155 | hint: "the apps a first visitor sees, ranked; an admin pins or hides", | |
| 156 | offered: () => true, | |
| 157 | run: (session, ctx) => ctx.push(frontScreen(session, ctx)), | |
| 158 | }; |
test/palette-roster.test.ts +4
| @@ -100,6 +100,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 100 | 100 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 101 | 101 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 102 | 102 | ["domains", "Failure domains", "failure domains and their last drill"], |
| 103 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], | |
| 103 | 104 | ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"], |
| 104 | 105 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 105 | 106 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| @@ -143,6 +144,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 143 | 144 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 144 | 145 | ["can", "What I can do", "your role and what it allows here"], |
| 145 | 146 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 147 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], | |
| 146 | 148 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 147 | 149 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 148 | 150 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| @@ -180,6 +182,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 180 | 182 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 181 | 183 | ["model", "Model budget", "what your crew may spend, and add your own subscription"], |
| 182 | 184 | ["can", "What I can do", "your role and what it allows here"], |
| 185 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], | |
| 183 | 186 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 184 | 187 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| 185 | 188 | ["quit", "Quit", "disconnect"], |
| @@ -202,6 +205,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 202 | 205 | ["signin", "Sign in", undefined], |
| 203 | 206 | ["api", "API", "the forge's endpoints; run a GET"], |
| 204 | 207 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 208 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], | |
| 205 | 209 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 206 | 210 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| 207 | 211 | ["quit", "Quit", "disconnect"], |
test/tui-front.test.ts added +230
| @@ -0,0 +1,230 @@ | ||
| 1 | /** | |
| 2 | * The front page in the terminal: the stranger's list in MCP `front_page`'s | |
| 3 | * order with its score parts; a site admin who is a person pins and hides | |
| 4 | * after a plan; anyone else has neither key; a private app is never listed. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 7 | import { mkdirSync, writeFileSync } from "node:fs"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 12 | import { frontJson, forgetFrontScores } from "../src/actions/front.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 15 | import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts"; | |
| 16 | import { recordLook } from "../src/core/looks.ts"; | |
| 17 | import { encodePng } from "../src/design/image/png.ts"; | |
| 18 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 19 | import { tool } from "../src/http/mcp/tools/front_page.ts"; | |
| 20 | import { serve } from "../src/http/server.ts"; | |
| 21 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 22 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 24 | import { promote } from "./helpers/members.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | import { settleApp } from "./helpers/tui.ts"; | |
| 27 | ||
| 28 | quietLogs(); | |
| 29 | ||
| 30 | const HEAD = "a".repeat(40); | |
| 31 | const PNG = Buffer.from(encodePng(1, 1, new Uint8Array([255, 255, 255, 255]))); | |
| 32 | ||
| 33 | describe("the front page in the terminal", () => { | |
| 34 | let dataDir: string; | |
| 35 | let forge: Forge; | |
| 36 | let runtime: AppsRuntime; | |
| 37 | let status: ReturnType<typeof spyOn>; | |
| 38 | ||
| 39 | function boot(username: string | undefined, agent = false) { | |
| 40 | const terminal = headlessTerminal(140, 32); | |
| 41 | const user = | |
| 42 | username === undefined | |
| 43 | ? undefined | |
| 44 | : { ...getUserByName(forge.db, username)!, ...(agent ? { kind: "agent" as const } : {}) }; | |
| 45 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 46 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 47 | app.start(); | |
| 48 | const press = async (...keys: string[]) => { | |
| 49 | for (const k of keys) app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 50 | await settleApp(app); | |
| 51 | }; | |
| 52 | const open = async () => { | |
| 53 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 54 | await settleApp(app); | |
| 55 | for (const ch of "Front page") app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 56 | await settleApp(app); | |
| 57 | await press("enter"); | |
| 58 | }; | |
| 59 | return { text: () => app.screen.toText(), press, open, stop: () => app.stop() }; | |
| 60 | } | |
| 61 | ||
| 62 | const make = async (owner: string, name: string, visibility: "public" | "private") => { | |
| 63 | const made = await createRepo(forge, getUserByName(forge.db, owner)!, { name, visibility, description: name }); | |
| 64 | if (!made.ok) throw new Error(String(made.error)); | |
| 65 | const record = insertApp(forge.db, { repoId: made.value.id, slug: name, access: "public" }); | |
| 66 | if (!record.ok) throw new Error(record.error); | |
| 67 | mkdirSync(join(dataDir, "looks", record.value.id), { recursive: true }); | |
| 68 | writeFileSync(join(dataDir, "looks", record.value.id, "thumb.png"), PNG); | |
| 69 | recordLook(forge.db, { | |
| 70 | appId: record.value.id, | |
| 71 | env: "production", | |
| 72 | commitOid: HEAD, | |
| 73 | kind: "thumb", | |
| 74 | path: `looks/${record.value.id}/thumb.png`, | |
| 75 | }); | |
| 76 | return made.value; | |
| 77 | }; | |
| 78 | ||
| 79 | const mcpOrder = (): string[] => { | |
| 80 | const out = tool.run({ app: forge } as never, getUserByName(forge.db, "alice")!, {}) as never as { value: unknown }; | |
| 81 | const v = out.value as { featured: { repo: string } | null; apps: { repo: string }[] }; | |
| 82 | return [...(v.featured ? [v.featured.repo] : []), ...v.apps.map((a) => a.repo)]; | |
| 83 | }; | |
| 84 | ||
| 85 | beforeAll(async () => { | |
| 86 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-front-")); | |
| 87 | forge = createApp({ dataDir, port: 0 }); | |
| 88 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) { | |
| 89 | const made = await createUser(forge.db, { | |
| 90 | origin: "seed", | |
| 91 | trusted: true, | |
| 92 | username, | |
| 93 | email: `${username}@forge.local`, | |
| 94 | password: "a-good-passphrase", | |
| 95 | adminIfFirst, | |
| 96 | }); | |
| 97 | if (!made.ok) throw new Error(made.error); | |
| 98 | promote(forge.db, made.value); | |
| 99 | } | |
| 100 | forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run(); | |
| 101 | status = spyOn(AppsRuntime.prototype, "status").mockImplementation( | |
| 102 | (_r: AppRecord): EnvStatus[] => [{ env: "production", state: "running", commitOid: HEAD } as EnvStatus], | |
| 103 | ); | |
| 104 | runtime = new AppsRuntime(forge, { | |
| 105 | domain: "front.test", | |
| 106 | scheme: "http", | |
| 107 | publicPort: 1, | |
| 108 | forgeUrl: "http://localhost:1", | |
| 109 | }); | |
| 110 | await make("alice", "one", "public"); | |
| 111 | await make("alice", "two", "public"); | |
| 112 | await make("bob", "three", "public"); | |
| 113 | await make("bob", "secret", "private"); | |
| 114 | forgetFrontScores(forge); | |
| 115 | }); | |
| 116 | ||
| 117 | afterAll(async () => { | |
| 118 | status.mockRestore(); | |
| 119 | await runtime.stop(); | |
| 120 | forge.close(); | |
| 121 | await rm(dataDir, { recursive: true, force: true }); | |
| 122 | }); | |
| 123 | ||
| 124 | test("lists the stranger's apps in MCP front_page's order, never a private one", async () => { | |
| 125 | const order = mcpOrder(); | |
| 126 | expect(order.length).toBe(3); | |
| 127 | expect(order.some((r) => r.endsWith("/secret"))).toBe(false); | |
| 128 | const t = boot("bob"); | |
| 129 | await t.open(); | |
| 130 | const text = t.text(); | |
| 131 | expect(text).toContain("What a signed-out visitor sees"); | |
| 132 | expect(text).toContain("forks 0"); | |
| 133 | expect(text).not.toContain("/secret"); | |
| 134 | const at = order.map((r) => text.indexOf(r)); | |
| 135 | expect(at.every((n) => n >= 0)).toBe(true); | |
| 136 | expect([...at].sort((a, b) => a - b)).toEqual(at); | |
| 137 | t.stop(); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a non-admin has neither key", async () => { | |
| 141 | const stranger = frontJson(forge, undefined); | |
| 142 | const t = boot("bob"); | |
| 143 | await t.open(); | |
| 144 | const before = t.text(); | |
| 145 | await t.press("p"); | |
| 146 | expect(t.text()).toBe(before); | |
| 147 | await t.press("h"); | |
| 148 | expect(t.text()).toBe(before); | |
| 149 | t.stop(); | |
| 150 | // An admin agent is no person: no keys either. | |
| 151 | const a = boot("carol", true); | |
| 152 | await a.open(); | |
| 153 | const seen = a.text(); | |
| 154 | await a.press("h"); | |
| 155 | expect(a.text()).toBe(seen); | |
| 156 | await a.press("p"); | |
| 157 | expect(a.text()).toBe(seen); | |
| 158 | a.stop(); | |
| 159 | expect(frontJson(forge, undefined)).toEqual(stranger); | |
| 160 | }); | |
| 161 | ||
| 162 | test("signed out, the list is the stranger's and p and h do nothing", async () => { | |
| 163 | const stranger = frontJson(forge, undefined); | |
| 164 | const t = boot(undefined); | |
| 165 | await t.open(); | |
| 166 | const before = t.text(); | |
| 167 | for (const r of mcpOrder()) expect(before).toContain(r); | |
| 168 | expect(before).not.toContain("/secret"); | |
| 169 | expect(before).not.toContain("pin or unpin"); | |
| 170 | await t.press("p"); | |
| 171 | expect(t.text()).toBe(before); | |
| 172 | await t.press("h"); | |
| 173 | expect(t.text()).toBe(before); | |
| 174 | t.stop(); | |
| 175 | expect(frontJson(forge, undefined)).toEqual(stranger); | |
| 176 | }); | |
| 177 | ||
| 178 | test("a pin applied in the terminal reaches the web's stranger page", async () => { | |
| 179 | const servers = await serve(forge); | |
| 180 | try { | |
| 181 | const seen = async () => | |
| 182 | ((await (await fetch(`${servers.url}/api/v1/front`)).json()) as { featured: { repo: string } | null }).featured?.repo; | |
| 183 | const order = mcpOrder(); | |
| 184 | const last = order[order.length - 1]!; | |
| 185 | expect(await seen()).not.toBe(last); | |
| 186 | const t = boot("alice"); | |
| 187 | await t.open(); | |
| 188 | await t.press("j", "j", "p", "left", "enter"); | |
| 189 | expect(await seen()).toBe(last); | |
| 190 | const door = await (await fetch(`${servers.url}/`)).text(); | |
| 191 | expect(door).toContain(last.split("/")[1]!); | |
| 192 | // Put it back: unpin. | |
| 193 | await t.press("g", "p", "left", "enter"); | |
| 194 | t.stop(); | |
| 195 | } finally { | |
| 196 | servers.stop(); | |
| 197 | } | |
| 198 | }); | |
| 199 | ||
| 200 | test("an admin pins the last app: the plan shows, and after the confirm it is first", async () => { | |
| 201 | const order = mcpOrder(); | |
| 202 | const last = order[order.length - 1]!; | |
| 203 | const t = boot("alice"); | |
| 204 | await t.open(); | |
| 205 | await t.press("j", "j", "p"); | |
| 206 | expect(t.text()).toContain(`Pin ${last} first`); | |
| 207 | // Cancel is the default: nothing changed yet. | |
| 208 | await t.press("escape"); | |
| 209 | expect(mcpOrder()).toEqual(order); | |
| 210 | await t.press("p", "left", "enter"); | |
| 211 | expect(mcpOrder()[0]).toBe(last); | |
| 212 | t.stop(); | |
| 213 | }); | |
| 214 | ||
| 215 | test("an admin hides an app: it leaves the list, and shows again from the hidden row", async () => { | |
| 216 | const first = mcpOrder()[0]!; | |
| 217 | const t = boot("alice"); | |
| 218 | await t.open(); | |
| 219 | await t.press("h", "left", "enter"); | |
| 220 | expect(mcpOrder()).not.toContain(first); | |
| 221 | expect(t.text()).toContain("hidden"); | |
| 222 | // The hidden row sits at the end; `h` shows it again. | |
| 223 | await t.press("G"); | |
| 224 | const rows = mcpOrder().length; | |
| 225 | for (let i = 0; i < rows; i++) await t.press("j"); | |
| 226 | await t.press("h", "left", "enter"); | |
| 227 | expect(mcpOrder()).toContain(first); | |
| 228 | t.stop(); | |
| 229 | }); | |
| 230 | }); |
new above
offline