Public: anyone can read this; the crew can change it.
The terminal's deploys screen lets a writer see who a private app names with `m`, and add or remove one after a plan, as MCP `app_members` does #7434
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7418-the-terminal-s-deploys-screen-lets-a-wri → main 4 commits fork 7 threads · 7 unresolved
docs/APPS.md +6 −1
| @@ -180,7 +180,12 @@ Under the box, four starter sentences are plain links (no script): | ||
| 180 | 180 | gate; a guest sees neither. A writer presses `p` there to probe the running app's |
| 181 | 181 | doors as nobody, a user and an admin, read-only — one line per door with |
| 182 | 182 | its status and `ok` or what was wrong, `r` to knock again; the result is |
| 183 | kept and the mismatch issue opened or closed as for MCP `probe`. | |
| 183 | kept and the mismatch issue opened or closed as for MCP `probe`. On a | |
| 184 | private app, `m` lists the people it names; a writer who is a person | |
| 185 | presses `a` to let someone in by username or `d` to let the selected one | |
| 186 | out, each shown as a plan and applied after a confirm, as MCP | |
| 187 | `app_members` does. An agent is not offered `m`; the list is its writers' | |
| 188 | to choose. | |
| 184 | 189 | - A **private** app answers only to someone the forge has vouched for |
| 185 | 190 | (`canRead` on the repository, then a signed grant); a public one to anyone. |
| 186 | 191 | - **Every app has a terminal.** `ssh -p 2222 <name>@<forge>` runs the app's |
src/tui/screens/appmembers.ts added +177
| @@ -0,0 +1,177 @@ | ||
| 1 | /** | |
| 2 | * The people a private app names, in the terminal (#7418): `m` on the | |
| 3 | * deploys screen. The list is read through `readAppMembers`, the settings | |
| 4 | * page's and MCP `app_members`'s action, so it is gated the same way: a | |
| 5 | * person who can write the repository, never an agent — and the deploys | |
| 6 | * screen offers `m` to that person alone. | |
outdated
This says an agent is 'offered no key', but deploys.ts offers it |
||
| 7 | * | |
| 8 | * `a` asks for a username and `d` takes out the person under the cursor. | |
| 9 | * Each is planned first (`setAppMembersThrough` with `dryRun`), the plan — | |
| 10 | * who is let in, who is let out — is shown in a confirm, and only the | |
| 11 | * confirm applies it — to the list as it stands then, so a change another | |
| 12 | * writer made meanwhile is kept. An unknown username is refused in the action's words | |
| 13 | * and the prompt stays open. | |
| 14 | */ | |
| 15 | ||
| 16 | import { | |
| 17 | readAppMembers, | |
| 18 | setAppMembersThrough, | |
| 19 | type AppMembersError, | |
| 20 | } from "../../actions/app_members.ts"; | |
| 21 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; | |
| 22 | import type { Repo } from "../../core/repos.ts"; | |
| 23 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 24 | import { binding } from "../app/keys.ts"; | |
| 25 | import { style } from "../term/style.ts"; | |
| 26 | import { wrap } from "../term/width.ts"; | |
| 27 | import { fill, len, split } from "../ui/layout.ts"; | |
| 28 | import type { Session } from "../session.ts"; | |
| 29 | import { | |
| 30 | ListState, | |
| 31 | confirmScreen, | |
| 32 | drawList, | |
| 33 | later, | |
| 34 | promptScreen, | |
| 35 | type ListRow, | |
| 36 | } from "./common.ts"; | |
| 37 | ||
| 38 | /** A refusal in MCP `app_members`'s words. */ | |
| 39 | export function appMembersRefusal(e: AppMembersError): string { | |
| 40 | return e.kind === "invalid" | |
| 41 | ? e.message | |
| 42 | : e.kind === "forbidden" || e.kind === "unauthorized" | |
| 43 | ? SETTING_WORDS.appMembersForbidden | |
| 44 | : "no such app or repository (owner/name)"; | |
| 45 | } | |
| 46 | ||
| 47 | /** `Let in: carol` / `Let out: dave`, one line each side that moves. */ | |
| 48 | export function planLines(plan: { added: readonly string[]; removed: readonly string[] }): string[] { | |
| 49 | return [ | |
| 50 | ...(plan.added.length > 0 ? [`Let in: ${plan.added.join(", ")}`] : []), | |
| 51 | ...(plan.removed.length > 0 ? [`Let out: ${plan.removed.join(", ")}`] : []), | |
| 52 | ]; | |
| 53 | } | |
| 54 | ||
| 55 | export function appMembersScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 56 | const state = new ListState(); | |
| 57 | let members: readonly string[] = []; | |
| 58 | // Whether the action reads for this viewer; it decides, not the screen. | |
| 59 | let canChange = false; | |
| 60 | let refusal = ""; | |
| 61 | let note = ""; | |
| 62 | const reload = (): void => { | |
| 63 | const read = readAppMembers(f.app, f.user, repo); | |
| 64 | canChange = read.ok; | |
| 65 | members = read.ok ? read.value.members : []; | |
| 66 | refusal = read.ok ? "" : appMembersRefusal(read.error); | |
| 67 | state.clamp(members.length); | |
| 68 | ctx.invalidate(); | |
| 69 | }; | |
| 70 | reload(); | |
| 71 | const say = (text: string): void => { | |
| 72 | note = text; | |
| 73 | reload(); | |
| 74 | }; | |
| 75 | ||
| 76 | /** | |
| 77 | * The list as it stands now with one person's change made — never the | |
| 78 | * screen's snapshot, so a change another writer made meanwhile (the page, | |
| 79 | * the API, MCP) is kept; `undefined` when it cannot be read. | |
| 80 | */ | |
| 81 | const current = (): readonly string[] | undefined => { | |
| 82 | const read = readAppMembers(f.app, f.user, repo); | |
| 83 | return read.ok ? read.value.members : undefined; | |
| 84 | }; | |
| 85 | type Change = (now: readonly string[]) => readonly string[]; | |
| 86 | const letIn = (who: string): Change => (now) => [...now, who]; | |
| 87 | const letOut = (who: string): Change => (now) => now.filter((m) => m !== who); | |
| 88 | ||
| 89 | /** Plan `change`, or the refusal; on a plan, confirm and apply it to the list as it is then. */ | |
| 90 | const propose = (title: string, verb: string, change: Change): string | undefined => { | |
Next: the already-named case (empty plan) has no test. Add one: |
||
| 91 | const plan = setAppMembersThrough(f.app, f.user, repo, { members: change(current() ?? members), dryRun: true }); | |
| 92 | if (!plan.ok) return appMembersRefusal(plan.error); | |
| 93 | const lines = planLines(plan.value); | |
outdated
High: this writes |
||
| 94 | if (lines.length === 0) { | |
| 95 | say("Nobody is let in or out: the list already says so."); | |
| 96 | return undefined; | |
| 97 | } | |
| 98 | void later(ctx, () => | |
| 99 | confirmScreen(ctx, screen, { | |
| 100 | title, | |
| 101 | body: [...lines, `${repo.ownerName}/${repo.name} and its previews.`], | |
| 102 | actions: [{ label: "Cancel", variant: "ghost" }, { label: verb }], | |
| 103 | selected: 1, | |
| 104 | onChoose: (i) => { | |
| 105 | if (i !== 1) return; | |
| 106 | // Read again: the confirm may have stood open while the list moved. | |
| 107 | const now = readAppMembers(f.app, f.user, repo); | |
| 108 | if (!now.ok) return say(appMembersRefusal(now.error)); | |
| 109 | const set = setAppMembersThrough(f.app, f.user, repo, { members: change(now.value.members), dryRun: false }); | |
| 110 | say(set.ok ? `${planLines(set.value).join(" · ") || "Nobody moved"}.` : appMembersRefusal(set.error)); | |
| 111 | }, | |
| 112 | }), | |
| 113 | ); | |
| 114 | return undefined; | |
outdated
Same snapshot problem as line 93: build the change as a delta (who is let out), not as |
||
| 115 | }; | |
| 116 | ||
| 117 | const add = (): Screen => | |
| 118 | promptScreen(ctx, screen, { | |
| 119 | title: "Let someone in", | |
| 120 | label: "their username", | |
| 121 | placeholder: "carol", | |
| 122 | onSubmit: (name) => { | |
| 123 | const who = name.trim(); | |
| 124 | if (who === "") return "Name a person."; | |
| 125 | return propose("Let in", "Let in", letIn(who)); | |
| 126 | }, | |
| 127 | }); | |
| 128 | ||
| 129 | const remove = (who: string): void => { | |
| 130 | const refused = propose("Let out", "Let out", letOut(who)); | |
| 131 | if (refused !== undefined) say(refused); | |
| 132 | }; | |
| 133 | ||
| 134 | const screen: Screen = { | |
| 135 | title: "Members", | |
| 136 | get keys() { | |
| 137 | return [ | |
| 138 | binding(["j", "k"], "move"), | |
| 139 | ...(canChange ? [binding(["a"], "let someone in"), binding(["d"], "let them out")] : []), | |
| 140 | binding(["esc"], "back"), | |
| 141 | ]; | |
| 142 | }, | |
| 143 | render(area, s) { | |
| 144 | const c = ctx.theme.c; | |
| 145 | const text = | |
| 146 | note !== "" | |
| 147 | ? note | |
| 148 | : refusal !== "" | |
| 149 | ? refusal | |
| 150 | : members.length === 0 | |
| 151 | ? `${repo.ownerName}/${repo.name} names nobody; its writers and crew enter. Press a to let someone in.` | |
| 152 | : `${repo.ownerName}/${repo.name} names ${members.length}: they enter the app and its previews, nothing else.`; | |
| 153 | const said = wrap(text, Math.max(1, area.w)).slice(0, 3); | |
| 154 | const [head, list] = split(area, "v", [len(said.length), fill(1)]); | |
| 155 | said.forEach((line, i) => | |
| 156 | s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w), | |
| 157 | ); | |
| 158 | const rows: ListRow[] = members.map((m) => ({ title: m })); | |
| 159 | drawList(s, list!, c, state, rows); | |
| 160 | }, | |
| 161 | key(e) { | |
| 162 | if (state.key(e, members.length)) return true; | |
| 163 | if (e.ctrl || e.alt || !canChange) return false; | |
| 164 | if (e.key === "a") { | |
| 165 | ctx.push(add()); | |
| 166 | return true; | |
| 167 | } | |
| 168 | const who = members[state.index]; | |
| 169 | if (e.key === "d" && who !== undefined) { | |
| 170 | remove(who); | |
| 171 | return true; | |
| 172 | } | |
| 173 | return false; | |
| 174 | }, | |
| 175 | }; | |
| 176 | return screen; | |
| 177 | } |
src/tui/screens/deploys.ts +18
| @@ -20,6 +20,11 @@ | ||
| 20 | 20 | * `probeApp` against the running production app, every door tried as |
| 21 | 21 | * nobody, a user and an admin, read-only, one line per door. |
| 22 | 22 | * |
| 23 | * A private app's writer who is a person — never an agent — gets `m`: the | |
| 24 | * people the app names, with `a` and `d` to let one in or out after a plan | |
| 25 | * and a confirm, as MCP `app_members` does (`appmembers.ts`). A public app | |
| 26 | * names nobody and is not offered `m`. | |
| 27 | * | |
| 23 | 28 | * The app's admin sees its own domains under the head, each with its state |
| 24 | 29 | * in the settings page's words (`domain_words.ts`), and gets `n`: a domains |
| 25 | 30 | * screen where `+` adds one, `c` checks the selected one's DNS proof now and |
| @@ -83,6 +88,7 @@ import { | ||
| 83 | 88 | type ListRow, |
| 84 | 89 | } from "./common.ts"; |
| 85 | 90 | import { RunList, openRunLog, runRows } from "./runlist.ts"; |
| 91 | import { appMembersScreen } from "./appmembers.ts"; | |
| 86 | 92 | |
| 87 | 93 | const LIMIT = 50; |
| 88 | 94 | |
| @@ -132,6 +138,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 132 | 138 | let side: string[] = []; |
| 133 | 139 | let pulls: AppPullLine[] = []; |
| 134 | 140 | let production: Production = { state: "", redeploy: false, stop: false }; |
| 141 | let members = false; | |
| 135 | 142 | /** The app's domains, or undefined for anyone `listAppDomains` refuses. */ |
| 136 | 143 | let domains: AppDomainView[] | undefined; |
| 137 | 144 | |
| @@ -149,6 +156,12 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 149 | 156 | side = appSideLines(f.app, f.user, repo); |
| 150 | 157 | pulls = appPullLines(f.app, f.user, repo); |
| 151 | 158 | production = readProduction(f, repo); |
| 159 | // The action's gate: a person who can write, never an agent. | |
| 160 | members = | |
| 161 | getAppByRepo(f.db, repo.id)?.access === "private" && | |
| 162 | f.user !== undefined && | |
| 163 | f.user.kind !== "agent" && | |
| 164 | canWrite(f.db, repo, f.user); | |
| 152 | 165 | const listed = listAppDomains(f.app, f.user, repo); |
| 153 | 166 | domains = listed.ok ? listed.value : undefined; |
| 154 | 167 | loaded = true; |
| @@ -204,6 +217,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 204 | 217 | ...(production.redeploy ? [binding(["D"], "redeploy production")] : []), |
| 205 | 218 | ...(production.stop ? [binding(["S"], "stop production")] : []), |
| 206 | 219 | ...(mayProbe ? [binding(["p"], "probe the running app's doors")] : []), |
| 220 | ...(members ? [binding(["m"], "members")] : []), | |
| 207 | 221 | ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []), |
| 208 | 222 | ]; |
| 209 | 223 | }, |
| @@ -296,6 +310,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 296 | 310 | ctx.push(probeScreen(f, ctx, repo)); |
| 297 | 311 | return true; |
| 298 | 312 | } |
| 313 | if (e.key === "m" && members) { | |
| 314 | ctx.push(appMembersScreen(f, ctx, repo)); | |
| 315 | return true; | |
| 316 | } | |
| 299 | 317 | if (e.key === "n" && domains !== undefined) { |
| 300 | 318 | ctx.push(domainsScreen(f, ctx, repo, read)); |
| 301 | 319 | return true; |
test/tui-appmembers.test.ts added +205
| @@ -0,0 +1,205 @@ | ||
| 1 | /** | |
| 2 | * The deploys screen's `m` (#7418): the people a private app names, from the | |
| 3 | * terminal. A writer lets carol in with `a`, sees the plan, confirms, and | |
| 4 | * carol may enter the app; `d` and a confirm let her out, and she may not. | |
| 5 | * Cancelling a plan changes nothing. An unknown username is refused in MCP | |
| 6 | * `app_members`'s words. A confirm applies its own change to the list as it | |
| 7 | * stands then, so a change another writer made meanwhile is kept. An agent | |
| 8 | * on the crew is not offered `m`. A public app has no `m`. | |
| 9 | */ | |
| 10 | ||
| 11 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { setAppMembersThrough } from "../src/actions/app_members.ts"; | |
| 16 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 17 | import { createRepo } from "../src/actions/repos.ts"; | |
| 18 | import { createApp, type App } from "../src/app.ts"; | |
| 19 | import { createApp as createAppRecord, type AppRecord } from "../src/core/apps.ts"; | |
| 20 | import { canEnterApp, type Repo } from "../src/core/repos.ts"; | |
| 21 | import { createUser, type User } from "../src/core/users.ts"; | |
| 22 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 23 | import { planLines } from "../src/tui/screens/appmembers.ts"; | |
| 24 | import { deploysScreen } from "../src/tui/screens/deploys.ts"; | |
| 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | import { settleApp } from "./helpers/tui.ts"; | |
| 28 | ||
| 29 | quietLogs(); | |
| 30 | ||
| 31 | let dir: string; | |
| 32 | let app: App; | |
| 33 | let alice: User; | |
| 34 | let carol: User; | |
| 35 | let dave: User; | |
| 36 | let bot: User; | |
| 37 | let shop: Repo; | |
| 38 | let open: Repo; | |
| 39 | let record: AppRecord; | |
| 40 | ||
| 41 | const LEFT = "\x1b[D"; | |
| 42 | ||
| 43 | beforeAll(async () => { | |
| 44 | dir = await mkdtemp(join(tmpdir(), "forge-tui-appmembers-")); | |
| 45 | app = createApp({ dataDir: dir, port: 0 }); | |
| 46 | const mk = async (username: string, first = false): Promise<User> => { | |
| 47 | const r = await createUser(app.db, { | |
| 48 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 49 | password: "a-good-passphrase", adminIfFirst: first, | |
| 50 | }); | |
| 51 | if (!r.ok) throw new Error(r.error); | |
| 52 | return r.value; | |
| 53 | }; | |
| 54 | alice = await mk("alice", true); | |
| 55 | carol = await mk("carol"); | |
| 56 | dave = await mk("dave"); | |
| 57 | const agent = await createAgent(app, alice, { username: "bot" }); | |
| 58 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 59 | bot = agent.value.user; | |
| 60 | const repoOf = async (name: string): Promise<Repo> => { | |
| 61 | const r = await createRepo(app, alice, { name, visibility: "public" }); | |
| 62 | if (!r.ok) throw new Error("repo failed"); | |
| 63 | return r.value; | |
| 64 | }; | |
| 65 | shop = await repoOf("shop"); | |
| 66 | open = await repoOf("open"); | |
| 67 | const made = createAppRecord(app.db, { repoId: shop.id, slug: "shop", access: "private" }); | |
| 68 | if (!made.ok) throw new Error(made.error); | |
| 69 | record = made.value; | |
| 70 | if (!createAppRecord(app.db, { repoId: open.id, slug: "open", access: "public" }).ok) throw new Error("app"); | |
| 71 | expect(addToCrew(app, alice, shop, "bot").ok).toBe(true); | |
| 72 | }); | |
| 73 | ||
| 74 | afterAll(async () => { | |
| 75 | app.db.close(); | |
| 76 | await rm(dir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | /** The deploys screen of `of` as `who`, fed `keys` one chunk at a time; every frame. */ | |
| 80 | async function drive(who: User, of: Repo, keys: readonly (string | (() => void))[]): Promise<string[]> { | |
| 81 | const terminal = headlessTerminal(160, 40); | |
| 82 | const { app: tui, session } = createForgeApp( | |
| 83 | app, | |
| 84 | { sshUsername: who.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: who }, | |
| 85 | { terminal, exitHooks: false }, | |
| 86 | ); | |
| 87 | tui.start(); | |
| 88 | await settleApp(tui); | |
| 89 | tui.push(deploysScreen(session, tui.ctx, of)); | |
| 90 | await settleApp(tui); | |
| 91 | const frames = [tui.screen.toText()]; | |
| 92 | for (const k of keys) { | |
| 93 | // A function is someone else acting between two keys. | |
| 94 | if (typeof k === "function") k(); | |
| 95 | else terminal.feed(k); | |
| 96 | await settleApp(tui); | |
| 97 | frames.push(tui.screen.toText()); | |
| 98 | } | |
| 99 | tui.stop(); | |
| 100 | return frames; | |
| 101 | } | |
| 102 | ||
| 103 | const enters = (u: User): boolean => canEnterApp(app.db, shop, record, u); | |
| 104 | ||
| 105 | test("a private app's writer is offered `m members` on the deploys screen", async () => { | |
| 106 | const [first] = await drive(alice, shop, []); | |
| 107 | expect(first).toContain("m members"); | |
| 108 | }); | |
| 109 | ||
| 110 | test("`a` plans letting carol in, and only the confirm lets her in", async () => { | |
| 111 | expect(enters(carol)).toBe(false); | |
| 112 | const frames = await drive(alice, shop, ["m", "a", "carol", "\r"]); | |
| 113 | // The plan, shown before anything changed. | |
| 114 | expect(frames.at(-1)).toContain("Let in: carol"); | |
| 115 | expect(enters(carol)).toBe(false); | |
| 116 | // Cancel is one step left of the chosen action. | |
| 117 | await drive(alice, shop, ["m", "a", "carol", "\r", LEFT, "\r"]); | |
| 118 | expect(enters(carol)).toBe(false); | |
| 119 | const done = await drive(alice, shop, ["m", "a", "carol", "\r", "\r"]); | |
| 120 | expect(enters(carol)).toBe(true); | |
| 121 | expect(done.at(-1)).toContain("Let in: carol."); | |
| 122 | expect(done.at(-1)).toContain("carol"); | |
| 123 | }); | |
| 124 | ||
| 125 | test("`d` plans letting the selected person out, and the confirm does", async () => { | |
| 126 | expect(enters(carol)).toBe(true); | |
| 127 | const frames = await drive(alice, shop, ["m", "d"]); | |
| 128 | expect(frames.at(-1)).toContain("Let out: carol"); | |
| 129 | expect(enters(carol)).toBe(true); | |
| 130 | const done = await drive(alice, shop, ["m", "d", "\r"]); | |
| 131 | expect(enters(carol)).toBe(false); | |
| 132 | expect(done.at(-1)).toContain("Let out: carol."); | |
| 133 | }); | |
| 134 | ||
| 135 | test("an unknown username is refused in MCP `app_members`'s words, and nobody is let in", async () => { | |
| 136 | const frames = await drive(alice, shop, ["m", "a", "nobody-here", "\r"]); | |
| 137 | const refused = setAppMembersThrough(app, alice, shop, { members: ["nobody-here"] }); | |
| 138 | expect(refused.ok).toBe(false); | |
| 139 | const words = !refused.ok && refused.error.kind === "invalid" ? refused.error.message : ""; | |
| 140 | expect(words).toBe("nobody-here is not a person's account here"); | |
| 141 | expect(frames.at(-1)).toContain(words); | |
| 142 | expect(frames.at(-1)).not.toContain("Let in:"); | |
| 143 | // The prompt stays open, holding what was typed, to be corrected. | |
| 144 | expect(frames.at(-1)).toContain("Let someone in"); | |
| 145 | expect(frames.at(-1)).toContain("their username"); | |
| 146 | }); | |
| 147 | ||
| 148 | test("letting in someone already named says so and opens no confirm", async () => { | |
| 149 | expect(setAppMembersThrough(app, alice, shop, { members: ["carol"], dryRun: false }).ok).toBe(true); | |
| 150 | const frames = await drive(alice, shop, ["m", "a", "carol", "\r"]); | |
| 151 | expect(frames.at(-1)).toContain("Nobody is let in or out: the list already says so."); | |
outdated
Next: the |
||
| 152 | expect(frames.at(-1)).not.toContain("Let in:"); | |
| 153 | expect(frames.at(-1)).not.toContain("Cancel"); | |
| 154 | expect(enters(carol)).toBe(true); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a confirm applies only its own change: a person another writer named meanwhile stays named", async () => { | |
| 158 | expect(setAppMembersThrough(app, alice, shop, { members: [], dryRun: false }).ok).toBe(true); | |
| 159 | // Alice plans carol in; while the confirm stands open, dave is named on the page. | |
| 160 | const nameDave = (): void => { | |
| 161 | expect(setAppMembersThrough(app, alice, shop, { members: ["dave"], dryRun: false }).ok).toBe(true); | |
| 162 | }; | |
| 163 | await drive(alice, shop, ["m", "a", "carol", "\r", nameDave, "\r"]); | |
| 164 | expect(enters(carol)).toBe(true); | |
| 165 | expect(enters(dave)).toBe(true); | |
| 166 | // Alice plans carol out; meanwhile dave is let out elsewhere, and the | |
| 167 | // confirm does not name him again from the screen's old list. | |
| 168 | const dropDave = (): void => { | |
| 169 | expect(setAppMembersThrough(app, alice, shop, { members: ["carol"], dryRun: false }).ok).toBe(true); | |
| 170 | }; | |
| 171 | const frames = await drive(alice, shop, ["m", "d", dropDave, "\r"]); | |
| 172 | // The row `d` took was carol's, whatever order the list is in: the plan names her alone. | |
| 173 | expect(frames[2]).toContain("Let out: carol"); | |
| 174 | expect(frames[2]).not.toContain("Let out: carol, dave"); | |
| 175 | expect(frames[2]).not.toContain("Let out: dave"); | |
| 176 | expect(enters(carol)).toBe(false); | |
| 177 | expect(enters(dave)).toBe(false); | |
| 178 | }); | |
| 179 | ||
| 180 | test("an agent on the crew is not offered `m`, and pressing it opens nothing", async () => { | |
| 181 | expect(setAppMembersThrough(app, alice, shop, { members: ["carol"], dryRun: false }).ok).toBe(true); | |
| 182 | const frames = await drive(bot, shop, ["m", "a", "d"]); | |
| 183 | expect(frames[0]).toContain("production deploys"); | |
| 184 | expect(frames[0]).not.toContain("m members"); | |
| 185 | for (const frame of frames.slice(1)) { | |
| 186 | expect(frame).toContain("production deploys"); | |
| 187 | expect(frame).not.toContain("Members"); | |
| 188 | expect(frame).not.toContain("Let in:"); | |
| 189 | expect(frame).not.toContain("Let out:"); | |
| 190 | } | |
| 191 | expect(enters(carol)).toBe(true); | |
| 192 | }); | |
| 193 | ||
| 194 | test("a public app is not offered `m`, and pressing it opens nothing", async () => { | |
| 195 | const frames = await drive(alice, open, ["m"]); | |
| 196 | expect(frames[0]).not.toContain("m members"); | |
| 197 | expect(frames.at(-1)).not.toContain("names nobody"); | |
| 198 | expect(frames.at(-1)).toContain("production deploys"); | |
| 199 | }); | |
| 200 | ||
| 201 | test("the plan says each side that moves, and only those", () => { | |
| 202 | expect(planLines({ added: ["carol"], removed: [] })).toEqual(["Let in: carol"]); | |
| 203 | expect(planLines({ added: [], removed: ["dave", "erin"] })).toEqual(["Let out: dave, erin"]); | |
| 204 | expect(planLines({ added: [], removed: [] })).toEqual([]); | |
| 205 | }); |
Update this once agents are no longer offered
m, for example: 'An agent is not offeredm; the list is its writers' to choose.'