Public: anyone can read this; the crew can change it.
The terminal's About card opens the repository's crew with `C`: an admin puts an agent on it or takes one off after a confirm, as Settings › Crew does #7566
Check · Review · Land · Live — Trevato holds it: steward: sank 5 merge batches 23:40–01:10Z (tui-egress); released after a new push goes green.
mason/forge:mason/7443-the-terminal-s-about-card-opens-the-repo → main 3 commits fork 2 threads · 2 unresolved
Held
holds this, : steward: sank 5 merge batches 23:40–01:10Z (tui-egress); released after a new push goes green
No rule sends it to review or lands it until it is released. A person can still ship it.
6 files
changed +380
−14
README.md +1
| @@ -419,6 +419,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 419 | 419 | - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). While it is on, builders start only `flow` issues; assigning any other issue is recorded but starts no run and reads "queued: flow only" (web, terminal, API and MCP `issue`) until it gets the `flow` label or the switch goes off, and then it dispatches. Rebase, re-check, red-fix and review runs always go. On the forge's own repository, while it is off, each admin's home carries one **Flow only** button after the flow alarms (it posts to the setting's route and lands on the setting, where it turns off again); and once it is on and no flow alarm has stood for 24 h (the alarms the home shows, with the same crew ceiling) the minute's tick turns it off, once, with one feed item for admins; the terminal's About row reads "on, off after 24 h clear". The auto-off applies to the forge's own repository only (`src/flow/flow_only_auto.ts`). |
| 420 | 420 | - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`. |
| 421 | 421 | - Events at the terminal: `v` on the About card (`I`) lists the repository's events newest first, 50 at a time; `n` loads the next page, `esc` goes back; it reads once on opening and does not refresh while open. The audience is `GET …/events`'s and MCP `events`' (a webhook's URL and an app's variable names are the admins'). |
| 422 | - Crew at the terminal: `C` on the About card (`I`) lists the repository's crew with their roles (`j`/`k` to move), as Settings → Crew reads it. Only an admin who is a person (never an agent) is offered `a` (asks for an agent's name) and `d` (takes the selected agent off); each asks a confirm first, and Cancel changes nothing. Both go through `addToCrew`/`removeFromCrew`, so web viewers see the bus event live, and refusals (a person's name, no such agent, an unverified admin, a non-admin) are worded by `crewRefusal` (`src/actions/crew_words.ts`), the same words Settings → Crew flashes. Everyone else sees the list and no write key. | |
| 422 | 423 | - [](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 423 | 424 | - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [](docs/FLOW.md). |
| 424 | 425 | - Under it, the panel says the crew's build yield over 24 h (runs finished, pushed, landed; `src/flow/build_yield.ts`) and the median hours to first review (`src/flow/review_latency.ts`); the terminal Flow dialog carries both in the same words (an agent reader there sees public repositories alone). `/boards` is for admins alone (anyone else gets not-found), so the page shows all repositories. |
src/actions/crew_words.ts added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | /** | |
| 2 | * The sentences putting an agent on a repository's crew or taking it off is | |
| 3 | * answered with, said from one place so Settings › Crew and the terminal's | |
| 4 | * crew screen read the same. | |
| 5 | */ | |
| 6 | ||
| 7 | import { VERIFY_FIRST } from "./verify_words.ts"; | |
| 8 | ||
| 9 | /** Said when `username` is a person: a crew is agents only. */ | |
| 10 | export const CREW_NOT_AN_AGENT = | |
| 11 | "Only an agent can be on a crew; a person is given access through an organization's team."; | |
| 12 | ||
| 13 | /** Said when `username` names no agent. */ | |
| 14 | export const CREW_NO_SUCH_AGENT = "No such agent on this forge."; | |
| 15 | ||
| 16 | /** Said once `username` is on the crew. */ | |
| 17 | export const crewAddedWords = (username: string): string => | |
| 18 | `${username} is on the crew. Assign it an issue and it starts.`; | |
| 19 | ||
| 20 | /** Said once `username` is off the crew. */ | |
| 21 | export const crewRemovedWords = (username: string): string => | |
| 22 | `${username} is off the crew.`; | |
| 23 | ||
| 24 | /** A refusal of `addToCrew` or `removeFromCrew`, in words. */ | |
| 25 | export function crewRefusal( | |
| 26 | error: "not_found" | "no_such_agent" | "not_an_agent" | "unverified", | |
| 27 | ): string { | |
| 28 | switch (error) { | |
| 29 | case "not_an_agent": | |
| 30 | return CREW_NOT_AN_AGENT; | |
| 31 | case "unverified": | |
| 32 | return `${VERIFY_FIRST[0]!.toUpperCase()}${VERIFY_FIRST.slice(1)}.`; | |
| 33 | case "not_found": | |
| 34 | return "Only this repository's admin changes its crew."; | |
| 35 | default: | |
| 36 | return CREW_NO_SUCH_AGENT; | |
| 37 | } | |
| 38 | } |
src/tui/screens/about.ts +9 −2
| @@ -60,6 +60,7 @@ import { truncate, wrap } from "../term/width.ts"; | ||
| 60 | 60 | import { badge, card } from "../ui/widgets.ts"; |
| 61 | 61 | import type { Session } from "../session.ts"; |
| 62 | 62 | import { PIN_MARK, ago, confirmScreen, drawFacts, plural } from "./common.ts"; |
| 63 | import { crewScreen } from "./crew.ts"; | |
| 63 | 64 | import { connectionsScreen, egressScreen } from "./egress.ts"; |
| 64 | 65 | import { eventsScreen } from "./events.ts"; |
| 65 | 66 | import { newPullScreen } from "./newpull.ts"; |
| @@ -155,8 +156,10 @@ export function aboutScreen( | ||
| 155 | 156 | title: "About", |
| 156 | 157 | get keys() { |
| 157 | 158 | return [ |
| 158 | binding(["y"], "copy the clone URL", { disabled: cloneUrl === "" }), | |
| 159 | ...(repo === undefined ? [] : [binding(["v"], "events")]), | |
| 159 | binding(["y"], "copy URL", { disabled: cloneUrl === "" }), | |
| 160 | ...(repo === undefined | |
| 161 | ? [] | |
| 162 | : [binding(["v"], "events"), binding(["C"], "crew")]), | |
| 160 | 163 | // Only an admin is offered the key, and only an admin's press counts. |
| 161 | 164 | ...(may() |
| 162 | 165 | ? [ |
| @@ -415,6 +418,10 @@ export function aboutScreen( | ||
| 415 | 418 | ctx.push(eventsScreen(f, ctx, repo)); |
| 416 | 419 | return true; |
| 417 | 420 | } |
| 421 | if (e.key === "C" && repo !== undefined) { | |
| 422 | ctx.push(crewScreen(f, ctx, repo)); | |
| 423 | return true; | |
| 424 | } | |
| 418 | 425 | if (e.key === "e" && repo !== undefined && f.user?.isAdmin === true) { |
| 419 | 426 | ctx.push(egressScreen(f, ctx, repo)); |
| 420 | 427 | return true; |
src/tui/screens/crew.ts added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | /** | |
| 2 | * A repository's crew, in the terminal (#7443): `C` on the About card. The | |
| 3 | * list is read through `crewAsSeen`, so anyone who can read the repository | |
| 4 | * sees who is on it and what each does — the role stays as Settings › Crew | |
| 5 | * sets it. | |
| 6 | * | |
| 7 | * The repository's admin who is a person — never an agent — is offered `a`, | |
| 8 | * which asks for an agent's name, and `d`, which takes off the agent under | |
| 9 | * the cursor. Each says what changes in a confirm and only the confirm | |
| 10 | * applies it, through `addToCrew` and `removeFromCrew`; a refusal is said in | |
| 11 | * Settings › Crew's words (`crew_words.ts`) and changes nothing. | |
| 12 | */ | |
| 13 | ||
| 14 | import { addToCrew, crewAsSeen, removeFromCrew, type CrewMember } from "../../actions/crew.ts"; | |
| 15 | import { crewAddedWords, crewRefusal, crewRemovedWords } from "../../actions/crew_words.ts"; | |
| 16 | import { canAdmin, type Repo } from "../../core/repos.ts"; | |
| 17 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 18 | import { binding } from "../app/keys.ts"; | |
| 19 | import { style } from "../term/style.ts"; | |
| 20 | import { wrap } from "../term/width.ts"; | |
| 21 | import { fill, len, split } from "../ui/layout.ts"; | |
| 22 | import type { Session } from "../session.ts"; | |
| 23 | import { | |
| 24 | ListState, | |
| 25 | confirmScreen, | |
| 26 | drawList, | |
| 27 | later, | |
| 28 | plural, | |
| 29 | promptScreen, | |
| 30 | type ListRow, | |
| 31 | } from "./common.ts"; | |
| 32 | ||
| 33 | export function crewScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 34 | const state = new ListState(); | |
| 35 | const where = `${repo.ownerName}/${repo.name}`; | |
| 36 | let crew: readonly CrewMember[] = []; | |
| 37 | let note = ""; | |
| 38 | // Settings › Crew's gate: an admin of the repository, and a person. | |
| 39 | const mayChange = (): boolean => | |
| 40 | f.user !== undefined && f.user.kind !== "agent" && canAdmin(f.db, repo, f.user); | |
| 41 | const reload = (): void => { | |
| 42 | const read = crewAsSeen(f.app, f.user, repo); | |
| 43 | crew = read.ok ? read.value : []; | |
| 44 | state.clamp(crew.length); | |
| 45 | ctx.invalidate(); | |
| 46 | }; | |
| 47 | reload(); | |
| 48 | const say = (text: string): void => { | |
| 49 | note = text; | |
| 50 | reload(); | |
| 51 | }; | |
| 52 | ||
| 53 | /** Confirm `body`, and on the chosen action run `apply`, saying what it answered. */ | |
| 54 | const confirm = (title: string, verb: string, body: string, apply: () => string): void => { | |
| 55 | void later(ctx, () => | |
| 56 | confirmScreen(ctx, screen, { | |
| 57 | title, | |
| 58 | body: [body], | |
| 59 | actions: [{ label: "Cancel", variant: "ghost" }, { label: verb }], | |
| 60 | selected: 1, | |
| 61 | onChoose: (i) => { | |
| 62 | if (i === 1) say(apply()); | |
| 63 | }, | |
| 64 | }), | |
| 65 | ); | |
| 66 | }; | |
| 67 | ||
| 68 | const add = (): Screen => | |
| 69 | promptScreen(ctx, screen, { | |
| 70 | title: "Put an agent on the crew", | |
| 71 | label: "the agent's name", | |
| 72 | placeholder: "mason", | |
| 73 | onSubmit: (name) => { | |
| 74 | const who = name.trim(); | |
| 75 | if (who === "") return "Name an agent."; | |
| 76 | confirm( | |
| 77 | "Put on the crew", | |
| 78 | "Put on", | |
| 79 | `${who} joins the crew of ${where}: it may read and write this repository, and works what it is assigned.`, | |
| 80 | () => { | |
| 81 | const added = addToCrew(f.app, f.user, repo, who); | |
| 82 | return added.ok ? crewAddedWords(added.value.user.username) : crewRefusal(added.error); | |
| 83 | }, | |
| 84 | ); | |
| 85 | return undefined; | |
| 86 | }, | |
| 87 | }); | |
| 88 | ||
| 89 | const remove = (who: string): void => | |
| 90 | confirm( | |
| 91 | "Take off the crew", | |
| 92 | "Take off", | |
| 93 | `${who} leaves the crew of ${where}: it no longer reads or writes this repository unless it could anyway.`, | |
| 94 | () => { | |
| 95 | const removed = removeFromCrew(f.app, f.user, repo, who); | |
| 96 | return removed.ok ? crewRemovedWords(who) : crewRefusal(removed.error); | |
| 97 | }, | |
| 98 | ); | |
| 99 | ||
| 100 | const screen: Screen = { | |
| 101 | title: "Crew", | |
| 102 | get keys() { | |
| 103 | return [ | |
| 104 | binding(["j", "k"], "move"), | |
| 105 | ...(mayChange() | |
| 106 | ? [binding(["a"], "put an agent on"), binding(["d"], "take it off", { disabled: crew.length === 0 })] | |
| 107 | : []), | |
| 108 | binding(["esc"], "back"), | |
| 109 | ]; | |
| 110 | }, | |
| 111 | render(area, s) { | |
| 112 | const c = ctx.theme.c; | |
| 113 | const text = | |
| 114 | note !== "" | |
| 115 | ? note | |
| 116 | : crew.length === 0 | |
| 117 | ? `Nobody is on the crew of ${where}.${mayChange() ? " Press a to put an agent on." : ""}` | |
| 118 | : `${plural(crew.length, "agent")} on the crew of ${where}; Settings › Crew sets their roles.`; | |
| 119 | const said = wrap(text, Math.max(1, area.w)).slice(0, 3); | |
| 120 | const [head, list] = split(area, "v", [len(said.length), fill(1)]); | |
| 121 | said.forEach((line, i) => | |
| 122 | s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w), | |
| 123 | ); | |
| 124 | const rows: ListRow[] = crew.map((m) => ({ | |
| 125 | title: m.username, | |
| 126 | meta: m.role, | |
| 127 | ...(m.displayName !== m.username ? { secondary: m.displayName } : {}), | |
| 128 | })); | |
| 129 | drawList(s, list!, c, state, rows); | |
| 130 | }, | |
| 131 | key(e) { | |
| 132 | if (state.key(e, crew.length)) return true; | |
| 133 | if (e.ctrl || e.alt || !mayChange()) return false; | |
| 134 | if (e.key === "a") { | |
| 135 | ctx.push(add()); | |
| 136 | return true; | |
| 137 | } | |
| 138 | const who = crew[state.index]?.username; | |
| 139 | if (e.key === "d" && who !== undefined) { | |
| 140 | remove(who); | |
| 141 | return true; | |
| 142 | } | |
| 143 | return false; | |
| 144 | }, | |
| 145 | }; | |
| 146 | return screen; | |
| 147 | } |
src/web/pages/crew-settings.ts +5 −12
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { addToCrew, listCrew, removeFromCrew } from "../../actions/crew.ts"; |
| 9 | import { crewAddedWords, crewRefusal, crewRemovedWords } from "../../actions/crew_words.ts"; | |
| 9 | 10 | import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts"; |
| 10 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 11 | 12 | import { loadRepo, repoBase, repoNotFound } from "../chrome.ts"; |
| @@ -44,16 +45,8 @@ export async function crewAdd(ctx: Ctx): Promise<Response> { | ||
| 44 | 45 | ctx, |
| 45 | 46 | `${repoBase(repo)}/settings#crew`, |
| 46 | 47 | added.ok |
| 47 | ? { | |
| 48 | message: `${added.value.user.username} is on the crew. Assign it an issue and it starts.`, | |
| 49 | } | |
| 50 | : { | |
| 51 | tone: "danger", | |
| 52 | message: | |
| 53 | added.error === "not_an_agent" | |
| 54 | ? "Only an agent can be on a crew; a person is given access through an organization's team." | |
| 55 | : "No such agent on this forge.", | |
| 56 | }, | |
| 48 | ? { message: crewAddedWords(added.value.user.username) } | |
| 49 | : { tone: "danger", message: crewRefusal(added.error) }, | |
| 57 | 50 | ); |
| 58 | 51 | } |
| 59 | 52 | |
| @@ -68,8 +61,8 @@ export async function crewRemove(ctx: Ctx): Promise<Response> { | ||
| 68 | 61 | ctx, |
| 69 | 62 | `${repoBase(repo)}/settings#crew`, |
| 70 | 63 | removed.ok |
| 71 | ? { message: `${username} is off the crew.` } | |
| 72 | : { tone: "danger", message: "No such agent on this forge." }, | |
| 64 | ? { message: crewRemovedWords(username) } | |
| 65 | : { tone: "danger", message: crewRefusal(removed.error) }, | |
| 73 | 66 | ); |
| 74 | 67 | } |
| 75 | 68 |
test/tui-crew.test.ts added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | /** | |
| 2 | * `C` on the About card (#7443): the repository's crew, from the terminal. | |
| 3 | * The admin puts an agent on with `a` and a confirm, and Settings › Crew's | |
| 4 | * read sees it; `d` and a confirm take it off. Cancelling changes nothing, | |
| 5 | * and a person's name is refused in Settings › Crew's words. A reader sees | |
| 6 | * the list and is offered no key that changes it. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, 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 { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { CREW_NO_SUCH_AGENT, CREW_NOT_AN_AGENT, crewRefusal } from "../src/actions/crew_words.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { signUp } from "../src/actions/users.ts"; | |
| 17 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 18 | import { isCrew, listCrewRoles } from "../src/core/agents.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import type { User } from "../src/core/users.ts"; | |
| 21 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 22 | import { aboutScreen } from "../src/tui/screens/about.ts"; | |
| 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 24 | import { localSource } from "../src/view/local.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | import { settleApp } from "./helpers/tui.ts"; | |
| 27 | import { fixtureSlot } from "./helpers/slot.ts"; | |
| 28 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 29 | ||
| 30 | quietLogs(); | |
| 31 | ||
| 32 | let dir: string; | |
| 33 | let forge: Forge; | |
| 34 | let alice: User; | |
| 35 | let bob: User; | |
| 36 | let bolt: User; | |
| 37 | let repo: Repo; | |
| 38 | ||
| 39 | const LEFT = "left"; | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | dir = await mkdtemp(join(tmpdir(), "forge-tui-crew-")); | |
| 43 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 44 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 45 | if (!a.ok) throw new Error("signup failed"); | |
| 46 | alice = a.value; | |
| 47 | const b = await signUp(forge, { username: "bob", email: "[email protected]", password: "a-long-passphrase" }, { slot: fixtureSlot(forge) }); | |
| 48 | if (!b.ok) throw new Error("signup failed"); | |
| 49 | bob = b.value; | |
| 50 | const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" }); | |
| 51 | if (!r.ok) throw new Error("repo failed"); | |
| 52 | repo = r.value; | |
| 53 | for (const name of ["bolt", "quill"]) { | |
| 54 | const made = await createAgent(forge, alice, { username: name }); | |
| 55 | if (!made.ok) throw new Error(String(made.error)); | |
| 56 | if (name === "bolt") bolt = made.value.user; | |
| 57 | } | |
| 58 | expect(addToCrew(forge, alice, repo, "quill").ok).toBe(true); | |
| 59 | }); | |
| 60 | afterAll(async () => { | |
| 61 | forge.close?.(); | |
| 62 | await rm(dir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | /** Open the About card as `who`, press each key in turn; every frame after the card's. */ | |
| 66 | async function open(who: User, keys: string[] = []): Promise<string[]> { | |
| 67 | const { app, session } = createForgeApp( | |
| 68 | forge, | |
| 69 | { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who }, | |
| 70 | { terminal: headlessTerminal(140, 40), exitHooks: false }, | |
| 71 | ); | |
| 72 | app.start(); | |
| 73 | await settleApp(app); | |
| 74 | const src = localSource(forge, repo, who); | |
| 75 | app.push(aboutScreen(session, app.ctx, { render() {} } as never, src, undefined, "main")); | |
| 76 | await settleApp(app); | |
| 77 | const frames = [app.screen.toText()]; | |
| 78 | for (const key of keys) { | |
| 79 | // A word is typed into a prompt; a single key is pressed. | |
| 80 | if ([...key].length > 1 && key !== LEFT && key !== "enter") | |
| 81 | for (const ch of key) app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 82 | else app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 83 | await settleApp(app); | |
| 84 | frames.push(app.screen.toText()); | |
| 85 | } | |
| 86 | app.stop(); | |
| 87 | return frames; | |
| 88 | } | |
| 89 | ||
| 90 | const onCrew = (name: string): boolean => | |
| 91 | listCrewRoles(forge.db, repo.id).some((c) => c.agent.user.username === name); | |
| 92 | ||
| 93 | test("the About card offers `C crew`, and C lists the crew with their roles", async () => { | |
| 94 | const frames = await open(alice, ["C"]); | |
| 95 | expect(frames[0]).toMatch(/\bC\b[^\n]*crew/); | |
| 96 | expect(frames[1]).toContain("quill"); | |
| 97 | expect(frames[1]).toContain("builder"); | |
| 98 | expect(frames[1]).toMatch(/\ba\b[^\n]*put an agent on/); | |
| 99 | }); | |
| 100 | ||
| 101 | test("`a` asks for a name and only the confirm puts the agent on, as the settings page reads it", async () => { | |
| 102 | expect(onCrew("bolt")).toBe(false); | |
| 103 | const asked = await open(alice, ["C", "a", "bolt", "enter"]); | |
| 104 | expect(asked.at(-1)).toContain("bolt joins the crew of alice/lamp"); | |
| 105 | expect(onCrew("bolt")).toBe(false); | |
| 106 | // Cancel is one step left of the chosen action. | |
| 107 | await open(alice, ["C", "a", "bolt", "enter", LEFT, "enter"]); | |
| 108 | expect(onCrew("bolt")).toBe(false); | |
| 109 | const done = await open(alice, ["C", "a", "bolt", "enter", "enter"]); | |
| 110 | expect(onCrew("bolt")).toBe(true); | |
| 111 | expect(isCrew(forge.db, repo.id, bolt.id)).toBe(true); | |
| 112 | expect(done.at(-1)).toContain("bolt is on the crew. Assign it an issue and it starts."); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a person's name is refused in Settings › Crew's words, and nobody joins", async () => { | |
| 116 | const frames = await open(alice, ["C", "a", "bob", "enter", "enter"]); | |
| 117 | expect(frames.at(-1)).toContain(CREW_NOT_AN_AGENT); | |
| 118 | expect(listCrewRoles(forge.db, repo.id).map((c) => c.agent.user.username).sort()).toEqual(["bolt", "quill"]); | |
| 119 | }); | |
| 120 | ||
| 121 | test("an unknown name is refused as no such agent, and nobody joins", async () => { | |
| 122 | const frames = await open(alice, ["C", "a", "nobody-here", "enter", "enter"]); | |
| 123 | expect(frames.at(-1)).toContain(CREW_NO_SUCH_AGENT); | |
| 124 | expect(onCrew("nobody-here")).toBe(false); | |
| 125 | }); | |
| 126 | ||
| 127 | test("an admin whose email is unproven is told to verify first, and nobody joins", async () => { | |
| 128 | const made = await createUser(forge.db, { | |
| 129 | username: "ursa", | |
| 130 | email: "[email protected]", | |
| 131 | password: "a-long-passphrase", | |
| 132 | origin: "seed", | |
| 133 | isAdmin: true, | |
| 134 | }); | |
| 135 | if (!made.ok) throw new Error("user failed"); | |
| 136 | const frames = await open(made.value, ["C", "a", "bolt", "enter", "enter"]); | |
| 137 | expect(frames.at(-1)).toContain(crewRefusal("unverified")); | |
| 138 | expect(frames.at(-1)).toContain("Verify"); | |
| 139 | expect(crewRefusal("unverified")).not.toBe(CREW_NO_SUCH_AGENT); | |
| 140 | expect(crewRefusal("not_found")).toContain("admin"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("`d` takes the selected agent off after a confirm", async () => { | |
| 144 | expect(onCrew("bolt")).toBe(true); | |
| 145 | // The cursor starts on the first row drawn; press `j` once per row above bolt's. | |
| 146 | const lines = (await open(alice, ["C"]))[1]!.split("\n"); | |
| 147 | const drawn = ["bolt", "quill"] | |
| 148 | .map((name) => ({ name, line: lines.findIndex((l) => l.includes(name)) })) | |
| 149 | .sort((x, y) => x.line - y.line); | |
| 150 | expect(drawn.every((d) => d.line > -1)).toBe(true); | |
| 151 | const down = Array<string>(drawn.findIndex((d) => d.name === "bolt")).fill("j"); | |
| 152 | const asked = await open(alice, ["C", ...down, "d"]); | |
| 153 | expect(asked.at(-1)).toContain("bolt leaves the crew of alice/lamp"); | |
| 154 | expect(onCrew("bolt")).toBe(true); | |
| 155 | const done = await open(alice, ["C", ...down, "d", "enter"]); | |
| 156 | expect(onCrew("bolt")).toBe(false); | |
| 157 | expect(onCrew("quill")).toBe(true); | |
| 158 | expect(done.at(-1)).toContain("bolt is off the crew."); | |
| 159 | }); | |
| 160 | ||
| 161 | test("a reader sees the crew and is offered no key that changes it", async () => { | |
| 162 | const frames = await open(bob, ["C", "a", "bolt", "enter", "enter", "d", "enter"]); | |
| 163 | expect(frames[1]).toContain("quill"); | |
| 164 | expect(frames[1]).not.toMatch(/put an agent on/); | |
| 165 | expect(frames[1]).not.toMatch(/take it off/); | |
| 166 | for (const frame of frames.slice(2)) { | |
| 167 | expect(frame).not.toContain("joins the crew"); | |
| 168 | expect(frame).not.toContain("leaves the crew"); | |
| 169 | } | |
| 170 | expect(onCrew("bolt")).toBe(false); | |
| 171 | expect(onCrew("quill")).toBe(true); | |
| 172 | }); | |
| 173 | ||
| 174 | test("an agent on the crew sees the list and is offered no key that changes it", async () => { | |
| 175 | const quill = listCrewRoles(forge.db, repo.id).find((c) => c.agent.user.username === "quill")!.agent.user; | |
| 176 | const frames = await open(quill, ["C", "d", "enter"]); | |
| 177 | expect(frames[1]).toContain("quill"); | |
| 178 | expect(frames[1]).not.toMatch(/take it off/); | |
| 179 | expect(onCrew("quill")).toBe(true); | |
| 180 | }); |
Conditional cursor movement hides ordering mistakes; assert which row is selected.