Public: anyone can read this; the crew can change it.
The studio tells a visitor that a person's change was made by "the crew" #245
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/206-the-studio-tells-a-visitor-that-a-person → main 5 commits fork
8 files
changed +359
−39
docs/STUDIO.md +11
| @@ -127,6 +127,17 @@ coming up — but by "the crew" rather than an agent's own name, and never | ||
| 127 | 127 | with a run's cost, turns or steer box. A private repository is unaffected: |
| 128 | 128 | it already answers only to people who can read it at all. |
| 129 | 129 | |
| 130 | **Whose name a row carries** is one rule, in one place — `voiceFor` | |
| 131 | (`src/web/studio/voice.ts`), which the app's page, a run's own page and the | |
| 132 | board all sign their rows with. An agent's name is *not* a secret: a | |
| 133 | change's own page and `/changes` name the agent that opened it to anyone | |
| 134 | who can read the repository. "The crew" is a voice, not a redaction, and it | |
| 135 | is chosen by **what the actor is** first: a person is named to every viewer | |
| 136 | who can see the row at all, so one visitor reads one name for one change on | |
| 137 | all three pages, avatar included; only an agent's own work — a run, a | |
| 138 | change it opened, a tell it holds — reads as the crew's for a viewer who | |
| 139 | cannot see the roster. Nothing here decides whether a row is visible. | |
| 140 | ||
| 130 | 141 | **The terminal** is the same operator's door, so it sits beside the same |
| 131 | 142 | facts: a writer sees the exact `ssh …` command production answers to, |
| 132 | 143 | copyable, with the forge's host key fingerprint beside it, or one line |
src/web/pages/board.ts +16 −8
| @@ -30,6 +30,7 @@ import { boardJs, liveRegionsJs } from "../client.ts"; | ||
| 30 | 30 | import { html, when, type Html } from "../html.ts"; |
| 31 | 31 | import { page } from "../layout.ts"; |
| 32 | 32 | import { avatar, icon, time } from "../ui.ts"; |
| 33 | import { voiceFor } from "../studio/voice.ts"; | |
| 33 | 34 | import { lookUrl, thumbLook } from "./looks.ts"; |
| 34 | 35 | |
| 35 | 36 | // ---- words ---- |
| @@ -210,13 +211,17 @@ function working(ctx: Ctx, scope: Scope): Html { | ||
| 210 | 211 | ); |
| 211 | 212 | const rows = runs.map((run) => { |
| 212 | 213 | const repo = scope.byId.get(run.repoId)!; |
| 213 | // Who is on the crew is an owner's fact — a repo this viewer can only | |
| 214 | // read reads here as the app's own story, never by the agent's name. | |
| 215 | const actorName = | |
| 216 | run.actorId === null | |
| 217 | ? "crew" | |
| 218 | : (actors.get(run.actorId)?.username ?? "crew"); | |
| 219 | const who = scope.ownerFacts.has(repo.id) ? actorName : "the crew"; | |
| 214 | // The same rule the app's page signs its rows by, `voiceFor` | |
| 215 | // (`studio/voice.ts`): the crew's own work reads as the app's own story | |
| 216 | // for a viewer who cannot see the roster; a person is always named. | |
| 217 | const actor = run.actorId === null ? undefined : actors.get(run.actorId); | |
| 218 | const who = voiceFor( | |
| 219 | { | |
| 220 | name: actor?.username, | |
| 221 | isAgent: actor === undefined || actor.kind === "agent", | |
| 222 | }, | |
| 223 | scope.ownerFacts.has(repo.id), | |
| 224 | ); | |
| 220 | 225 | const issue = run.issueId === null ? undefined : about.get(run.issueId); |
| 221 | 226 | const word = stateWord(run); |
| 222 | 227 | return html`<li> |
| @@ -284,7 +289,10 @@ function landed(ctx: Ctx, scope: Scope): Html { | ||
| 284 | 289 | const rows = pulls.map((pull: Pull) => { |
| 285 | 290 | const repo = scope.byId.get(pull.repoId)!; |
| 286 | 291 | const picture = pictureOf(repo); |
| 287 | const author = scope.ownerFacts.has(repo.id) ? pull.authorName : "the crew"; | |
| 292 | const author = voiceFor( | |
| 293 | { name: pull.authorName, isAgent: pull.authorIsAgent }, | |
| 294 | scope.ownerFacts.has(repo.id), | |
| 295 | ); | |
| 288 | 296 | return html`<li> |
| 289 | 297 | <a |
| 290 | 298 | class="board-card board-landed" |
src/web/studio/app.ts +30 −15
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | listRuns, |
| 24 | 24 | previewLatency, |
| 25 | 25 | type PreviewLatency, |
| 26 | type Run, | |
| 26 | 27 | } from "../../core/runs.ts"; |
| 27 | 28 | import { getUsersByIds } from "../../core/users.ts"; |
| 28 | 29 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| @@ -56,6 +57,7 @@ import { LOG_TAIL_CAP, logBox } from "./log.ts"; | ||
| 56 | 57 | import { appPanels } from "./previews.ts"; |
| 57 | 58 | import { stopForm } from "./run.ts"; |
| 58 | 59 | import { tellWord } from "./tell.ts"; |
| 60 | import { voiceFor, type Actor } from "./voice.ts"; | |
| 59 | 61 | import { pullWord, runWord, wordBadge, type WordCtx } from "./words.ts"; |
| 60 | 62 | |
| 61 | 63 | /** |
| @@ -290,10 +292,17 @@ export async function studioApp( | ||
| 290 | 292 | (r, idx, all) => all.findIndex((x) => x.issueId === r.issueId) === idx, |
| 291 | 293 | ) |
| 292 | 294 | .slice(0, 6); |
| 293 | // Who is on the crew is an owner's fact same as its roster card — a | |
| 294 | // viewer who cannot write here reads "Now" as the app's own story, never | |
| 295 | // by the agent's name. | |
| 296 | const nameFor = (name: string): string => (ownerFacts ? name : "the crew"); | |
| 295 | // Who signs a "Now" row is the one rule for it, `voiceFor` | |
| 296 | // (`studio/voice.ts`): a person is named to anyone who can see the row — | |
| 297 | // the same name `/changes` and the change's own page give — and only the | |
| 298 | // crew's own work reads as the app's own story for a viewer who cannot | |
| 299 | // see the roster. The avatar takes the same string, so its initials can | |
| 300 | // never disagree with the name beside them. | |
| 301 | const runActor = (r: Run): Actor => ({ | |
| 302 | name: r.actorId === null ? undefined : actors.get(r.actorId)?.username, | |
| 303 | // A run with nobody on it is the crew's own, never a person's. | |
| 304 | isAgent: r.actorId === null || actors.get(r.actorId)?.kind === "agent", | |
| 305 | }); | |
| 297 | 306 | |
| 298 | 307 | // The one run actually going, if any: its row gets a tail of its own |
| 299 | 308 | // log — the last handful of lines, streamed live — beside it. Only one, |
| @@ -304,11 +313,7 @@ export async function studioApp( | ||
| 304 | 313 | const activeTail = activeRun |
| 305 | 314 | ? logBox({ |
| 306 | 315 | id: "app-run-tail", |
| 307 | who: nameFor( | |
| 308 | activeRun.actorId === null | |
| 309 | ? "crew" | |
| 310 | : (actors.get(activeRun.actorId)?.username ?? "crew"), | |
| 311 | ), | |
| 316 | who: voiceFor(runActor(activeRun), ownerFacts), | |
| 312 | 317 | cap: LOG_TAIL_CAP, |
| 313 | 318 | lines: readRunLogTail(ctx.app.config.dataDir, activeRun.id, LOG_TAIL_CAP) |
| 314 | 319 | .lines, |
| @@ -320,9 +325,7 @@ export async function studioApp( | ||
| 320 | 325 | const runRows: Html[] = []; |
| 321 | 326 | for (const r of rowsNow) { |
| 322 | 327 | const { word, note } = await runWord(wordCtx, r); |
| 323 | const who = nameFor( | |
| 324 | r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew"), | |
| 325 | ); | |
| 328 | const who = voiceFor(runActor(r), ownerFacts); | |
| 326 | 329 | const about = r.issueId === null ? undefined : issues.get(r.issueId); |
| 327 | 330 | const stop = stopForm(ctx, repo, r); |
| 328 | 331 | runRows.push( |
| @@ -351,7 +354,10 @@ export async function studioApp( | ||
| 351 | 354 | ...(await Promise.all( |
| 352 | 355 | openPulls.map(async (p) => { |
| 353 | 356 | const { word, note } = await pullWord(wordCtx, p); |
| 354 | const who = nameFor(p.authorName); | |
| 357 | const who = voiceFor( | |
| 358 | { name: p.authorName, isAgent: p.authorIsAgent }, | |
| 359 | ownerFacts, | |
| 360 | ); | |
| 355 | 361 | return row({ |
| 356 | 362 | href: changeHref(base, p.number), |
| 357 | 363 | nav: true, |
| @@ -364,7 +370,10 @@ export async function studioApp( | ||
| 364 | 370 | )), |
| 365 | 371 | ...runRows, |
| 366 | 372 | ...waiting.map((i) => { |
| 367 | const who = nameFor(i.assigneeName ?? "crew"); | |
| 373 | const who = voiceFor( | |
| 374 | { name: i.assigneeName ?? undefined, isAgent: i.assigneeIsAgent }, | |
| 375 | ownerFacts, | |
| 376 | ); | |
| 368 | 377 | return row({ |
| 369 | 378 | href: tellHref(base, i.number), |
| 370 | 379 | nav: true, |
| @@ -378,6 +387,8 @@ export async function studioApp( | ||
| 378 | 387 | // Held by a person: `tellWord` — the one place a tell's word is |
| 379 | 388 | // decided (`studio/tell.ts`) — says so honestly, the same word and |
| 380 | 389 | // note the tell's own page and "Everything told" already show for it. |
| 390 | // `personHeld` has already established the holder is a person, so | |
| 391 | // `voiceFor` names them to every viewer who can see the row. | |
| 381 | 392 | ...(await Promise.all( |
| 382 | 393 | held.map(async (i) => { |
| 383 | 394 | const { word, note } = |
| @@ -385,7 +396,11 @@ export async function studioApp( | ||
| 385 | 396 | word: "told" as const, |
| 386 | 397 | note: "held", |
| 387 | 398 | }; |
| 388 | const who = nameFor(i.assigneeName ?? "someone"); | |
| 399 | const who = voiceFor( | |
| 400 | { name: i.assigneeName ?? undefined, isAgent: false }, | |
| 401 | ownerFacts, | |
| 402 | "someone", | |
| 403 | ); | |
| 389 | 404 | return row({ |
| 390 | 405 | href: tellHref(base, i.number), |
| 391 | 406 | nav: true, |
src/web/studio/log.ts +8 −7
| @@ -9,23 +9,24 @@ | ||
| 9 | 9 | import type { RunKind } from "../../core/runs.ts"; |
| 10 | 10 | import { logLine } from "../pages/runs.ts"; |
| 11 | 11 | import { html, when, type Html } from "../html.ts"; |
| 12 | import { unnamedRun, voiceFor, type Actor } from "./voice.ts"; | |
| 12 | 13 | |
| 13 | 14 | /** |
| 14 | 15 | * Who speaks a run's own transcript: a deploy or a check speaks for itself |
| 15 | * — never "an agent", which is a name for nobody — and only an agent's run | |
| 16 | * is named for the agent on it, or "the crew" for a reader who cannot see | |
| 17 | * who that is. The one place this is decided, so a run's own page and the | |
| 18 | * tail beside a running row (`studio/previews.ts`) never choose two voices | |
| 19 | * for the same lines. | |
| 16 | * — never "an agent", which is a name for nobody — and anything else is | |
| 17 | * named by the one rule for it, `voiceFor` (`studio/voice.ts`), which says | |
| 18 | * who is named and why. The one place a *run* applies it, so a run's own | |
| 19 | * page and the tail beside a running row (`studio/previews.ts`) never | |
| 20 | * choose two voices for the same lines. | |
| 20 | 21 | */ |
| 21 | 22 | export function runVoice( |
| 22 | 23 | kind: RunKind, |
| 23 | 24 | writer: boolean, |
| 24 | actorName: string | undefined, | |
| 25 | actor: Actor, | |
| 25 | 26 | ): string { |
| 26 | 27 | if (kind === "deploy") return "the deploy"; |
| 27 | 28 | if (kind === "check") return "the check"; |
| 28 | return writer ? (actorName ?? "an agent") : "the crew"; | |
| 29 | return voiceFor(actor, writer, unnamedRun(writer)); | |
| 29 | 30 | } |
| 30 | 31 | |
| 31 | 32 | /** At most this many of a run's own lines held in a full transcript at |
src/web/studio/previews.ts +1 −1
| @@ -176,7 +176,7 @@ function deployTail( | ||
| 176 | 176 | if (agentBusy) return undefined; |
| 177 | 177 | return logBox({ |
| 178 | 178 | id: "app-run-tail", |
| 179 | who: runVoice("deploy", true, undefined), | |
| 179 | who: runVoice("deploy", true, { name: undefined, isAgent: true }), | |
| 180 | 180 | cap: LOG_TAIL_CAP, |
| 181 | 181 | lines: readRunLogTail(ctx.app.config.dataDir, production.runId, LOG_TAIL_CAP) |
| 182 | 182 | .lines, |
src/web/studio/run.ts +12 −8
| @@ -30,6 +30,7 @@ import { previewCard } from "../pages/apps.ts"; | ||
| 30 | 30 | import { costText, durationText, runTitle } from "../pages/runs.ts"; |
| 31 | 31 | import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts"; |
| 32 | 32 | import { changeHref, tellHref } from "./nouns.ts"; |
| 33 | import { unnamedRun, voiceFor, type Actor } from "./voice.ts"; | |
| 33 | 34 | import { runWord, wordBadge, type WordCtx } from "./words.ts"; |
| 34 | 35 | |
| 35 | 36 | /** |
| @@ -77,14 +78,17 @@ export async function studioRun( | ||
| 77 | 78 | run.issueId === null ? undefined : getIssueById(db, run.issueId); |
| 78 | 79 | // A deploy or a check speaks for itself (`runVoice`, shared with the tail |
| 79 | 80 | // `studio/previews.ts` opens for the same lines while production comes |
| 80 | // up) — only an agent run is named for who is on it, and who is on the | |
| 81 | // crew is an owner's fact, same as the app's own page: a viewer who | |
| 82 | // cannot write here reads the transcript as the crew's own work, never by | |
| 83 | // the agent's name (title, every line, the live stream's `data-who`, the | |
| 84 | // running pulse). The steer box below is writer-only already, so it only | |
| 85 | // ever renders with the real name. | |
| 86 | const who = runVoice(run.kind, writer, actor?.username); | |
| 87 | const title = runTitle(run, writer ? actor?.username : "the crew", issue); | |
| 81 | // up); anything else is named by the one rule for it — `voiceFor` | |
| 82 | // (`studio/voice.ts`) says who is named and why. The title and the | |
| 83 | // transcript take the same name, so the page never carries two. The | |
| 84 | // steer box below is writer-only already, so it only ever renders with | |
| 85 | // the real name. | |
| 86 | const by: Actor = { | |
| 87 | name: actor?.username, | |
| 88 | isAgent: actor?.kind === "agent", | |
| 89 | }; | |
| 90 | const who = runVoice(run.kind, writer, by); | |
| 91 | const title = runTitle(run, voiceFor(by, writer, unnamedRun(writer)), issue); | |
| 88 | 92 | const wordCtx: WordCtx = { |
| 89 | 93 | db, |
| 90 | 94 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), |
src/web/studio/voice.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** | |
| 2 | * Whose name a studio row carries — the one place that decides it, so the | |
| 3 | * app's page, a run's own page and the front door never sign the same piece | |
| 4 | * of work to two different people. | |
| 5 | * | |
| 6 | * The decision, once: **an agent's name is not a secret.** A change's own | |
| 7 | * page and `/changes` already name the agent that opened it to anyone who | |
| 8 | * can read the repository, and nothing here could take that back. What | |
| 9 | * "the crew" stands in for is narrower — who is on the crew is part of the | |
| 10 | * roster, the ceiling and the ledger, the owner's own book-keeping | |
| 11 | * (`docs/STUDIO.md`, _The owner's facts and the public's_) — and it is a | |
| 12 | * matter of voice, not of secrecy: a visitor reads a *run* of the crew's | |
| 13 | * as the app's own story rather than as an unfamiliar name's. | |
| 14 | * | |
| 15 | * So masking asks two questions and needs both, and the first is about the | |
| 16 | * actor rather than the viewer: | |
| 17 | * | |
| 18 | * 1. Is the actor an agent? A person is named to every viewer that can see | |
| 19 | * the row at all — their authorship is theirs, and the app page must | |
| 20 | * say what `/changes` and the change's own page say. | |
| 21 | * 2. Can this viewer see the owner's facts? A writer reads the real name. | |
| 22 | * | |
| 23 | * Nothing here decides *whether* a row is visible: a viewer who cannot see | |
| 24 | * a change still cannot see it. This only names the one they can already | |
| 25 | * see. | |
| 26 | */ | |
| 27 | ||
| 28 | /** The name a run, a change or a tell is signed with when no name is shown. */ | |
| 29 | export const CREW = "the crew"; | |
| 30 | ||
| 31 | /** Who did the thing: a name, and whether that name is a crew member. */ | |
| 32 | export interface Actor { | |
| 33 | /** The username, or undefined when the row names nobody at all. */ | |
| 34 | readonly name: string | undefined; | |
| 35 | /** Whether that actor is an agent on the crew rather than a person. */ | |
| 36 | readonly isAgent: boolean; | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * The name to print beside a piece of work, and to draw the avatar from, so | |
| 41 | * the initials always match the name beside them. | |
| 42 | * | |
| 43 | * `unnamed` is what a row that names nobody reads as — "the crew" on the | |
| 44 | * app's page and the front door, `unnamedRun` on a run's own. | |
| 45 | */ | |
| 46 | export function voiceFor( | |
| 47 | actor: Actor, | |
| 48 | writer: boolean, | |
| 49 | unnamed: string = CREW, | |
| 50 | ): string { | |
| 51 | if (actor.name === undefined) return unnamed; | |
| 52 | if (!actor.isAgent) return actor.name; | |
| 53 | return writer ? actor.name : CREW; | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * What a run with no actor on it reads as: "an agent" is a name for nobody, | |
| 58 | * honest enough for a writer who can see the roster anyway and would | |
| 59 | * otherwise read the run as the whole crew's, and "the crew" for everyone | |
| 60 | * else — the voice they read every other run of the crew's in. | |
| 61 | */ | |
| 62 | export const unnamedRun = (writer: boolean): string => | |
| 63 | writer ? "an agent" : CREW; |
test/studio.test.ts +218
| @@ -6970,3 +6970,221 @@ describe("the front door asks about a person's own change too (#208)", () => { | ||
| 6970 | 6970 | expect(setIssueState(app, alice, repo, b, "closed").ok).toBe(true); |
| 6971 | 6971 | }); |
| 6972 | 6972 | }); |
| 6973 | ||
| 6974 | /** | |
| 6975 | * Issue #206: one public app, one open change, one anonymous visitor, three | |
| 6976 | * studio pages — and they must name the same author. Who signs a row is | |
| 6977 | * `voiceFor` (`src/web/studio/voice.ts`): a person is named to anyone who | |
| 6978 | * can see the row at all, and only the crew's own work reads as the app's | |
| 6979 | * own story for a viewer who cannot see the roster. | |
| 6980 | */ | |
| 6981 | describe("the studio names a person on the app's page, and the crew only for the crew's own work", () => { | |
| 6982 | let dataDir: string; | |
| 6983 | let app: App; | |
| 6984 | let servers: Servers; | |
| 6985 | let base: string; | |
| 6986 | let repo: Repo; | |
| 6987 | let alice: User; | |
| 6988 | let ownerCookie: string; | |
| 6989 | let personPull: Pull; | |
| 6990 | let agentPull: Pull; | |
| 6991 | let agentRun: Run; | |
| 6992 | let waitingTell: Issue; | |
| 6993 | let heldTell: Issue; | |
| 6994 | ||
| 6995 | const signIn = async (username: string): Promise<string> => | |
| 6996 | ( | |
| 6997 | ( | |
| 6998 | await fetch(`${base}/signin`, { | |
| 6999 | method: "POST", | |
| 7000 | body: new URLSearchParams({ | |
| 7001 | username, | |
| 7002 | password: "a-good-passphrase", | |
| 7003 | }), | |
| 7004 | redirect: "manual", | |
| 7005 | }) | |
| 7006 | ).headers.get("set-cookie") ?? "" | |
| 7007 | ).split(";")[0]!; | |
| 7008 | ||
| 7009 | /** Signed out, in studio mode: the visitor the issue walked as. */ | |
| 7010 | const anon = async (path: string): Promise<string> => | |
| 7011 | ( | |
| 7012 | await fetch(`${base}${path}`, { | |
| 7013 | headers: { cookie: "forge_mode=studio" }, | |
| 7014 | redirect: "manual", | |
| 7015 | }) | |
| 7016 | ).text(); | |
| 7017 | ||
| 7018 | const owner = async (path: string): Promise<string> => | |
| 7019 | ( | |
| 7020 | await fetch(`${base}${path}`, { | |
| 7021 | headers: { cookie: `${ownerCookie}; forge_mode=studio` }, | |
| 7022 | redirect: "manual", | |
| 7023 | }) | |
| 7024 | ).text(); | |
| 7025 | ||
| 7026 | /** The one `<li>` a link sits in — one row, not the whole page. */ | |
| 7027 | const rowWith = (page: string, needle: string): string => { | |
| 7028 | const at = page.indexOf(needle); | |
| 7029 | expect(at).toBeGreaterThan(-1); | |
| 7030 | const start = page.lastIndexOf("<li", at); | |
| 7031 | const end = page.indexOf("</li>", at); | |
| 7032 | return page.slice(start, end); | |
| 7033 | }; | |
| 7034 | ||
| 7035 | /** What the avatar beside a name says, so initials can be held to it. */ | |
| 7036 | const initialsIn = (row: string): string => | |
| 7037 | row.match(/class="avatar"[\s\S]*?>([^<]*)<\/span/)?.[1]?.trim() ?? ""; | |
| 7038 | ||
| 7039 | const branchAndPull = async ( | |
| 7040 | who: User, | |
| 7041 | name: string, | |
| 7042 | title: string, | |
| 7043 | ): Promise<Pull> => { | |
| 7044 | const branch = await commitToBranch(app, alice, repo, { | |
| 7045 | branch: name, | |
| 7046 | base: "main", | |
| 7047 | message: title, | |
| 7048 | files: [{ path: `${name.replace(/\//g, "-")}.md`, content: `${name}\n` }], | |
| 7049 | }); | |
| 7050 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 7051 | const opened = await openPull(app, who, repo, { | |
| 7052 | title, | |
| 7053 | body: "", | |
| 7054 | baseBranch: "main", | |
| 7055 | headBranch: name, | |
| 7056 | }); | |
| 7057 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 7058 | return opened.value; | |
| 7059 | }; | |
| 7060 | ||
| 7061 | beforeAll(async () => { | |
| 7062 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-voice-")); | |
| 7063 | app = createApp({ dataDir, port: 0 }); | |
| 7064 | servers = await serve(app); | |
| 7065 | base = servers.url; | |
| 7066 | const a = await createUser(app.db, { | |
| 7067 | username: "ivy", | |
| 7068 | email: "[email protected]", | |
| 7069 | password: "a-good-passphrase", | |
| 7070 | adminIfFirst: true, | |
| 7071 | }); | |
| 7072 | if (!a.ok) throw new Error(a.error); | |
| 7073 | alice = a.value; | |
| 7074 | ownerCookie = await signIn("ivy"); | |
| 7075 | ||
| 7076 | const made = await createRepo(app, alice, { | |
| 7077 | name: "pub", | |
| 7078 | visibility: "public", | |
| 7079 | description: "a public house", | |
| 7080 | }); | |
| 7081 | if (!made.ok) throw new Error(String(made.error)); | |
| 7082 | repo = made.value; | |
| 7083 | const seeded = await commitToBranch(app, alice, repo, { | |
| 7084 | message: "Start", | |
| 7085 | files: [{ path: "README.md", content: "pub\n" }], | |
| 7086 | }); | |
| 7087 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 7088 | const enabled = await enableApp(app, alice, repo, { slug: "pub" }); | |
| 7089 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 7090 | const quill = await createAgent(app, alice, { username: "quill" }); | |
| 7091 | if (!quill.ok) throw new Error(String(quill.error)); | |
| 7092 | expect(addToCrew(app, alice, repo, "quill").ok).toBe(true); | |
| 7093 | const quillUser = getUserByName(app.db, "quill")!; | |
| 7094 | const quillId = quillUser.id; | |
| 7095 | ||
| 7096 | // The change ivy opened herself, through the web editor. | |
| 7097 | personPull = await branchAndPull(alice, "ivy/subtitle", "Add a subtitle"); | |
| 7098 | // One the crew opened, so the masking that was meant has something to | |
| 7099 | // mask on the very same page. | |
| 7100 | agentPull = await branchAndPull(quillUser, "quill/lamp", "Hang a lamp"); | |
| 7101 | // A run of the crew's, on a tell of its own so it keeps its own row. | |
| 7102 | const told = openIssue(app, alice, repo, { title: "Sweep the step" }); | |
| 7103 | if (!told.ok) throw new Error(String(told.error)); | |
| 7104 | agentRun = createRun(app.db, { | |
| 7105 | repoId: repo.id, | |
| 7106 | kind: "agent", | |
| 7107 | issueId: told.value.id, | |
| 7108 | actorId: quillId, | |
| 7109 | reason: "assigned", | |
| 7110 | }); | |
| 7111 | claimRun(app.db, agentRun.id); | |
| 7112 | agentRun = | |
| 7113 | finishRun(app.db, agentRun.id, { | |
| 7114 | state: "ok", | |
| 7115 | summary: "swept", | |
| 7116 | costUsd: 3.5, | |
| 7117 | turns: 2, | |
| 7118 | }) ?? agentRun; | |
| 7119 | ||
| 7120 | // And one nobody has picked up yet: a waiting row, the crew's to take. | |
| 7121 | const waiting = openIssue(app, alice, repo, { title: "Wipe the bar" }); | |
| 7122 | if (!waiting.ok) throw new Error(String(waiting.error)); | |
| 7123 | expect(assignIssue(app, alice, repo, waiting.value, quillUser).ok).toBe( | |
| 7124 | true, | |
| 7125 | ); | |
| 7126 | waitingTell = getIssue(app.db, repo.id, waiting.value.number)!; | |
| 7127 | ||
| 7128 | // And one ivy took herself — a person holds it (#226's own row on the | |
| 7129 | // panel), so the panel owes her name to whoever can see the row. | |
| 7130 | const held = openIssue(app, alice, repo, { title: "Polish the sign" }); | |
| 7131 | if (!held.ok) throw new Error(String(held.error)); | |
| 7132 | expect(assignIssue(app, alice, repo, held.value, alice).ok).toBe(true); | |
| 7133 | heldTell = getIssue(app.db, repo.id, held.value.number)!; | |
| 7134 | }); | |
| 7135 | ||
| 7136 | afterAll(async () => { | |
| 7137 | servers.stop(); | |
| 7138 | app.close(); | |
| 7139 | await rm(dataDir, { recursive: true, force: true }); | |
| 7140 | }); | |
| 7141 | ||
| 7142 | test("an anonymous visitor reads one author for one change on all three pages, avatar included", async () => { | |
| 7143 | const appPage = await anon("/ivy/pub"); | |
| 7144 | const changes = await anon("/ivy/pub/changes"); | |
| 7145 | const change = await anon(`/ivy/pub/changes/${personPull.number}`); | |
| 7146 | ||
| 7147 | const onApp = rowWith(appPage, `/changes/${personPull.number}`); | |
| 7148 | const onList = rowWith(changes, `/changes/${personPull.number}`); | |
| 7149 | expect(onApp).toContain("ivy"); | |
| 7150 | expect(onApp).not.toContain("the crew"); | |
| 7151 | // The same name the list gives, and the same initials beside it. | |
| 7152 | expect(initialsIn(onApp)).toBe(initialsIn(onList)); | |
| 7153 | expect(initialsIn(onApp)).toBe("i"); | |
| 7154 | expect(change).toContain("ivy"); | |
| 7155 | ||
| 7156 | // A tell she took herself is hers on the same panel, by the same rule. | |
| 7157 | const held = rowWith(appPage, `/tells/${heldTell.number}`); | |
| 7158 | expect(held).toContain("ivy"); | |
| 7159 | expect(held).not.toContain("the crew"); | |
| 7160 | expect(initialsIn(held)).toBe("i"); | |
| 7161 | }); | |
| 7162 | ||
| 7163 | test("the crew's own change and the crew's own run still read as the crew's to that visitor", async () => { | |
| 7164 | const appPage = await anon("/ivy/pub"); | |
| 7165 | const onApp = rowWith(appPage, `/changes/${agentPull.number}`); | |
| 7166 | expect(onApp).toContain("the crew"); | |
| 7167 | expect(onApp).not.toContain("quill"); | |
| 7168 | expect(initialsIn(onApp)).toBe("tc"); | |
| 7169 | ||
| 7170 | const runRow = rowWith(appPage, `/runs/${agentRun.id}`); | |
| 7171 | expect(runRow).toContain("the crew"); | |
| 7172 | expect(runRow).not.toContain("quill"); | |
| 7173 | ||
| 7174 | const tellRow = rowWith(appPage, `/tells/${waitingTell.number}`); | |
| 7175 | expect(tellRow).toContain("the crew"); | |
| 7176 | expect(tellRow).not.toContain("quill"); | |
| 7177 | ||
| 7178 | // Nothing the owner keeps to herself came along with the name. | |
| 7179 | expect(appPage).not.toContain("quill"); | |
| 7180 | expect(appPage).not.toMatch(/\$\d/); | |
| 7181 | expect(appPage).not.toContain(">Crew<"); | |
| 7182 | }); | |
| 7183 | ||
| 7184 | test("the owner still reads the crew by name, and her own change as her own", async () => { | |
| 7185 | const appPage = await owner("/ivy/pub"); | |
| 7186 | expect(rowWith(appPage, `/changes/${agentPull.number}`)).toContain("quill"); | |
| 7187 | expect(rowWith(appPage, `/runs/${agentRun.id}`)).toContain("quill"); | |
| 7188 | expect(rowWith(appPage, `/changes/${personPull.number}`)).toContain("ivy"); | |
| 7189 | }); | |
| 7190 | }); |
new above
offline