Public: anyone can read this; the crew can change it.
The client tells the crew about an app from its page, in their own words, and the crew's answer shows under the box #1991
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1989-the-client-tells-the-crew-about-an-app-f → main 4 commits fork 5 threads · 5 unresolved
README.md +7
| @@ -505,6 +505,13 @@ The terminal's palette has the row too, on a repository screen, for the | ||
| 505 | 505 | same people: it takes one line, tells with no `from`, and answers in the |
| 506 | 506 | same words. |
| 507 | 507 | |
| 508 | **The crew's answers.** On an app's page the owner's own tells show under the tell | |
| 509 | box, newest first, each with what the crew said back (its last comment, else its | |
| 510 | last finished run's summary, else that it is working or waiting). The region | |
| 511 | updates live from `GET /:owner/:name/app/answers` (owner only; 404 for anyone | |
| 512 | else). In the terminal, `a` in the repository screen opens *Your tells* with the | |
| 513 | same rows behind the same gate. | |
| 514 | ||
| 508 | 515 | An **agent** is an account of kind `agent`: no password, no session, and it |
| 509 | 516 | acts only through a token the forge mints for a run and destroys after it. |
| 510 | 517 | An admin creates one (**Settings → Crew**, or `POST /api/v1/agents`) with a |
src/actions/tell.ts +6 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | 10 | import { listCrewRoles } from "../core/agents.ts"; |
| 11 | import type { Issue, TitleError } from "../core/issues.ts"; | |
| 11 | import { MAX_BODY, type Issue, type TitleError } from "../core/issues.ts"; | |
| 12 | 12 | import type { Repo } from "../core/repos.ts"; |
| 13 | 13 | import { listRuns } from "../core/runs.ts"; |
| 14 | 14 | import type { User } from "../core/users.ts"; |
| @@ -95,6 +95,8 @@ export async function tell( | ||
| 95 | 95 | if (!requireVerified(app, participant.value).ok) return err("unverified"); |
| 96 | 96 | const text = said.trim(); |
| 97 | 97 | if (text === "") return err("empty"); |
| 98 | // One long line becomes only a title, so the body cap never sees it. | |
| 99 | if (text.length > MAX_BODY) return err("body_too_long"); | |
| 98 | 100 | if (from !== null && parseFrom(from) !== from) return err("bad_from"); |
| 99 | 101 | const firstLine = text.split(/\n/)[0]!.trim(); |
| 100 | 102 | const title = |
| @@ -150,6 +152,9 @@ export async function tell( | ||
| 150 | 152 | /** What every door says when a tell is refused for an unverified address. */ |
| 151 | 153 | export const UNVERIFIED_WORDS = "verify your email first"; |
| 152 | 154 | |
| 155 | /** What both doors say when `tell` refuses a text over `MAX_BODY`. */ | |
| 156 | export const TOO_LONG_WORDS = "That is too long to tell in one go."; | |
| 157 | ||
| 153 | 158 | /** What happened, in the sentence both doors say. */ |
| 154 | 159 | export function toldWords(told: Told): string { |
| 155 | 160 | if (told.routed === undefined) |
src/http/server.ts +2
| @@ -242,6 +242,7 @@ import { | ||
| 242 | 242 | liveOnYouFragment, |
| 243 | 243 | studioDoor, |
| 244 | 244 | } from "../web/studio/home.ts"; |
| 245 | import { appAnswersFragment } from "../web/studio/answers.ts"; | |
| 245 | 246 | import { appNowFragment } from "../web/studio/now.ts"; |
| 246 | 247 | import { appStateFragment } from "../web/studio/previews.ts"; |
| 247 | 248 | import { changesList, tellsList } from "../web/studio/lists.ts"; |
| @@ -1083,6 +1084,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1083 | 1084 | router.add("GET", "/:owner/:name/app/state", appStateFragment); |
| 1084 | 1085 | // "Now" on the same page, alone: the region's own address. |
| 1085 | 1086 | router.add("GET", "/:owner/:name/app/now", appNowFragment); |
| 1087 | router.add("GET", "/:owner/:name/app/answers", appAnswersFragment); | |
| 1086 | 1088 | router.add("GET", "/:owner/:name/app/:env/open", appOpen); |
| 1087 | 1089 | // Where a private app sends a person who arrived by its link. |
| 1088 | 1090 | router.add("GET", "/apps/open", appsOpen); |
src/tui/screens/answers.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** | |
| 2 | * Your tells on an app, and what the crew said back — the page's own rows | |
| 3 | * (`answersFor`), behind the page's own gate (`canSeeAnswers`): the owner of | |
| 4 | * an app sees their tells; anyone else gets an empty screen, never a name. | |
| 5 | * Read-only; `enter` opens the tell on the web, `r` reads again. | |
| 6 | */ | |
| 7 | ||
| 8 | import { answersFor, canSeeAnswers } from "../../web/studio/answers.ts"; | |
| 9 | import type { Repo } from "../../core/repos.ts"; | |
| 10 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 11 | import { binding } from "../app/keys.ts"; | |
| 12 | import type { Session } from "../session.ts"; | |
| 13 | import { fill, len, split } from "../ui/layout.ts"; | |
| 14 | import { emptyState } from "../ui/widgets.ts"; | |
| 15 | import { style } from "../term/style.ts"; | |
| 16 | import { truncate } from "../term/width.ts"; | |
| 17 | import { ListState, drawList, hitList, metaOf, openOnWeb, relTime, type ListRow } from "./common.ts"; | |
| 18 | ||
| 19 | /** One line of plain text: a tell or an answer may carry escapes the terminal must not obey. */ | |
| 20 | const plain = (text: string): string => | |
| 21 | // eslint-disable-next-line no-control-regex | |
| 22 | text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim(); | |
| 23 | ||
| 24 | export interface AnswerLine extends ListRow { | |
| 25 | readonly path: string; | |
| 26 | } | |
| 27 | ||
| 28 | /** The rows for `repo`; none unless the viewer may see the answers. */ | |
| 29 | export function answerLines(f: Session, repo: Repo): AnswerLine[] { | |
| 30 | if (!canSeeAnswers(f.db, repo, f.user)) return []; | |
| 31 | return answersFor(f.db, repo, f.user).map(({ issue, said, working }) => ({ | |
| 32 | title: plain(issue.title), | |
| 33 | secondary: | |
| 34 | said === undefined | |
| 35 | ? working | |
| 36 | ? "The crew is working on it." | |
| 37 | : "Waiting for the crew." | |
| 38 | : `${plain(said.who)}: ${plain(said.text)}`, | |
| 39 | meta: metaOf(said === undefined ? (working ? "working" : "waiting") : "answered", relTime(said?.at ?? issue.createdAt)), | |
| 40 | path: `/${repo.ownerName}/${repo.name}/issues/${issue.number}`, | |
| 41 | })); | |
| 42 | } | |
| 43 | ||
| 44 | export function answersScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 45 | const state = new ListState(); | |
| 46 | let lines: AnswerLine[] = []; | |
| 47 | const read = (): void => { | |
| 48 | lines = answerLines(f, repo); | |
| 49 | state.clamp(lines.length); | |
| 50 | }; | |
| 51 | const open = (index: number): void => { | |
| 52 | const line = lines[index]; | |
| 53 | if (line !== undefined) openOnWeb(f, ctx, line.path, "That tell"); | |
| 54 | }; | |
| 55 | return { | |
| 56 | title: "Your tells", | |
| 57 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["r"], "refresh")], | |
| 58 | load: async () => read(), | |
| 59 | render(area, s) { | |
| 60 | const c = ctx.theme.c; | |
| 61 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 62 | s.text( | |
| 63 | head!.x, | |
| 64 | head!.y, | |
| 65 | truncate(`${repo.ownerName}/${repo.name} · what the crew said back`, head!.w), | |
| 66 | style(c.mutedFg, c.bg), | |
| 67 | head!.x + head!.w, | |
| 68 | ); | |
| 69 | if (lines.length === 0) { | |
| 70 | emptyState(s, body!, c, { | |
| 71 | title: "No tells yet", | |
| 72 | body: "What you tell the crew about this app shows up here, with its answer.", | |
| 73 | }); | |
| 74 | return; | |
| 75 | } | |
| 76 | drawList(s, body!, c, state, lines); | |
| 77 | hitList(ctx, body!, state, lines.length, { open }); | |
| 78 | }, | |
| 79 | key(e) { | |
| 80 | if (state.key(e, lines.length)) return true; | |
| 81 | if (e.key === "enter") { | |
| 82 | open(state.index); | |
| 83 | return true; | |
| 84 | } | |
| 85 | if (e.key === "r") { | |
| 86 | read(); | |
| 87 | ctx.invalidate(); | |
| 88 | return true; | |
| 89 | } | |
| 90 | return false; | |
| 91 | }, | |
| 92 | }; | |
| 93 | } |
src/tui/screens/tell.ts +4 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * screen has no page path of its own. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { tell, toldWords, UNVERIFIED_WORDS, type Told, type TellError } from "../../actions/tell.ts"; | |
| 9 | import { tell, toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told, type TellError } from "../../actions/tell.ts"; | |
| 10 | 10 | import type { Repo } from "../../core/repos.ts"; |
| 11 | 11 | import { DIRECTION_NOTE, mayDirectCrew } from "../../crew/direct.ts"; |
| 12 | 12 | import type { Result } from "../../kernel/result.ts"; |
| @@ -25,7 +25,9 @@ export function tellAnswer(told: Result<Told, TellError>): { text: string; tone: | ||
| 25 | 25 | ? DIRECTION_NOTE |
| 26 | 26 | : told.error === "unverified" |
| 27 | 27 | ? UNVERIFIED_WORDS |
| 28 | : "That could not be told.", | |
| 28 | : told.error === "body_too_long" | |
| 29 | ? TOO_LONG_WORDS | |
| 30 | : "That could not be told.", | |
| 29 | 31 | }; |
| 30 | 32 | } |
| 31 | 33 |
src/tui/screens/workspace.ts +9
| @@ -91,6 +91,8 @@ import { | ||
| 91 | 91 | proposingStatus, |
| 92 | 92 | type OriginTarget, |
| 93 | 93 | } from "./contribute.ts"; |
| 94 | import { canSeeAnswers } from "../../web/studio/answers.ts"; | |
| 95 | import { answersScreen } from "./answers.ts"; | |
| 94 | 96 | import { commitsScreen } from "./commits.ts"; |
| 95 | 97 | import { deploysScreen } from "./deploys.ts"; |
| 96 | 98 | import { |
| @@ -1290,6 +1292,9 @@ export function workspaceScreen( | ||
| 1290 | 1292 | binding(["I"], "about this repository"), |
| 1291 | 1293 | binding(["V"], "waves"), |
| 1292 | 1294 | ...(repo === undefined ? [] : [binding(["d"], "production deploys")]), |
| 1295 | ...(repo !== undefined && canSeeAnswers(f.db, repo, f.user) | |
| 1296 | ? [binding(["a"], "your tells and the crew's answers")] | |
| 1297 | : []), | |
| 1293 | 1298 | ...(repo !== undefined && admin() |
| 1294 | 1299 | ? [binding(["R"], `decisions by ${(getRepoById(f.db, repo.id) ?? repo).decisionsBy} (cycle)`)] |
| 1295 | 1300 | : []), |
| @@ -1616,6 +1621,10 @@ export function workspaceScreen( | ||
| 1616 | 1621 | ctx.push(deploysScreen(f, ctx, repo)); |
| 1617 | 1622 | return true; |
| 1618 | 1623 | } |
| 1624 | if (plain && e.key === "a" && repo !== undefined && canSeeAnswers(f.db, repo, f.user)) { | |
Nothing tests the |
||
| 1625 | ctx.push(answersScreen(f, ctx, repo)); | |
| 1626 | return true; | |
| 1627 | } | |
| 1619 | 1628 | if (plain && e.key === "I") { |
| 1620 | 1629 | ctx.push(aboutScreen(f, ctx, screen, src, facts, ref)); |
| 1621 | 1630 | return true; |
src/web/studio/actions.ts +6 −1
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | import { mayView } from "../../core/sealed.ts"; |
| 12 | 12 | import { holdPull, releasePull } from "../../actions/holds.ts"; |
| 13 | 13 | import { addComment } from "../../actions/issues.ts"; |
| 14 | import { parseFrom, tell } from "../../actions/tell.ts"; | |
| 14 | import { parseFrom, tell, TOO_LONG_WORDS } from "../../actions/tell.ts"; | |
| 15 | 15 | import { keepThought } from "../../actions/thought.ts"; |
| 16 | 16 | import { DIRECTION_NOTE } from "../../crew/direct.ts"; |
| 17 | 17 | import { mergePull } from "../../actions/pulls.ts"; |
| @@ -59,6 +59,11 @@ async function tellFrom( | ||
| 59 | 59 | message: "Say something first.", |
| 60 | 60 | }); |
| 61 | 61 | const told = await tell(ctx.app, ctx.auth.user, repo, text, 1, from); |
| 62 | if (!told.ok && told.error === "body_too_long") | |
The page words are good. The terminal's Export this sentence, for example |
||
| 63 | return flashOrJson(ctx, back, { | |
| 64 | tone: "danger", | |
| 65 | message: TOO_LONG_WORDS, | |
| 66 | }); | |
| 62 | 67 | if (!told.ok) |
| 63 | 68 | return flashOrJson(ctx, back, { |
| 64 | 69 | tone: "danger", |
src/web/studio/answers.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * The crew's answers, under the tell box: one row per tell the viewer said | |
| 3 | * on this app, newest first — the tell in their words, and what the crew | |
| 4 | * said back (its last comment on it, else its last finished run's summary, | |
| 5 | * else that it is working or waiting). One live region (`#live-answers`) | |
| 6 | * with an address of its own (`GET /:owner/:name/app/answers`), so an | |
| 7 | * answer that lands appears without a reload. Everything shown is the | |
| 8 | * viewer's own tell on an app they own; every string is escaped by `html`. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 13 | import { listComments, listIssues, type Issue } from "../../core/issues.ts"; | |
| 14 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 15 | import { listRuns } from "../../core/runs.ts"; | |
| 16 | import { getUsersByIds, type User } from "../../core/users.ts"; | |
| 17 | import type { Ctx } from "../../http/context.ts"; | |
| 18 | import { loadRepo, repoBase } from "../chrome.ts"; | |
| 19 | import { html, type Html } from "../html.ts"; | |
| 20 | import { fragment } from "../layout.ts"; | |
| 21 | import { time } from "../ui.ts"; | |
| 22 | import { tellHref } from "./nouns.ts"; | |
| 23 | ||
| 24 | /** The events that can change an answer. */ | |
| 25 | export const ANSWERS_LIVE = "run.*,issue.*"; | |
| 26 | ||
| 27 | /** How many tells the region shows, and the longest answer it quotes. */ | |
| 28 | const SHOWN = 8; | |
| 29 | const ANSWER_MAX = 600; | |
| 30 | ||
| 31 | export interface Answer { | |
| 32 | readonly issue: Issue; | |
| 33 | /** Who answered and what they said; undefined while nobody has. */ | |
| 34 | readonly said: { readonly who: string; readonly text: string; readonly at: string } | undefined; | |
| 35 | readonly working: boolean; | |
| 36 | } | |
| 37 | ||
| 38 | /** The viewer's own tells on `repo`, newest first, each with the crew's answer. */ | |
outdated
This only needs |
||
| 39 | export function answersFor(db: Database, repo: Repo, user: User): readonly Answer[] { | |
| 40 | const issues = listIssues(db, repo.id, { | |
|
||
| 41 | state: "all", | |
| 42 | author: user.username, | |
| 43 | limit: SHOWN, | |
| 44 | }).filter((i) => i.id !== repo.directionIssueId); | |
| 45 | return issues.map((issue) => { | |
| 46 | const comments = listComments(db, issue.id, { kind: "comment" }); | |
| 47 | const speakers = getUsersByIds(db, [...new Set(comments.map((c) => c.authorId))]); | |
| 48 | const crewComment = comments | |
| 49 | .filter((c) => speakers.get(c.authorId)?.kind === "agent") | |
| 50 | .at(-1); | |
| 51 | const runs = listRuns(db, repo.id, { kind: "agent", issueId: issue.id, limit: 5 }); | |
| 52 | const working = runs.some((r) => r.state === "queued" || r.state === "running"); | |
| 53 | const finished = runs.find( | |
| 54 | (r) => (r.state === "ok" || r.state === "failed") && r.summary !== "", | |
| 55 | ); | |
| 56 | const actors = finished?.actorId | |
| 57 | ? getUsersByIds(db, [finished.actorId]).get(finished.actorId) | |
| 58 | : undefined; | |
| 59 | const said = crewComment | |
| 60 | ? { who: crewComment.authorName, text: crewComment.body, at: crewComment.createdAt } | |
| 61 | : finished | |
| 62 | ? { | |
| 63 | who: actors?.username ?? "crew", | |
| 64 | text: finished.summary, | |
| 65 | at: finished.finishedAt ?? finished.createdAt, | |
| 66 | } | |
| 67 | : undefined; | |
| 68 | return { issue, said, working }; | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | /** Whether `user` may read the answers on `repo`: its owner, on an app. Both doors ask this. */ | |
| 73 | export function canSeeAnswers(db: Database, repo: Repo, user: User | undefined): user is User { | |
| 74 | return user !== undefined && getAppByRepo(db, repo.id) !== undefined && canSeeOwnerFacts(db, repo, user); | |
| 75 | } | |
| 76 | ||
| 77 | /** The region itself; empty (but present, so it can fill) with no tells yet. */ | |
| 78 | export function answersRegion(ctx: Ctx, repo: Repo, user: User): Html { | |
| 79 | const base = repoBase(repo); | |
| 80 | const rows = answersFor(ctx.app.db, repo, user).map(({ issue, said, working }) => { | |
| 81 | const text = | |
| 82 | said === undefined | |
| 83 | ? null | |
| 84 | : said.text.length > ANSWER_MAX | |
| 85 | ? `${said.text.slice(0, ANSWER_MAX - 1).trimEnd()}…` | |
| 86 | : said.text; | |
| 87 | return html`<li class="answer" data-tell="${issue.number}"> | |
| 88 | <a href="${tellHref(base, issue.number)}" class="answer-tell">${issue.title}</a> | |
| 89 | ${said === undefined | |
| 90 | ? html`<p class="muted small answer-wait">${working ? "The crew is working on it." : "Waiting for the crew."}</p>` | |
| 91 | : html`<p class="answer-said"><b>${said.who}</b> ${time(said.at)}<br />${text}</p>`} | |
| 92 | </li>`; | |
| 93 | }); | |
| 94 | return html`<div | |
| 95 | id="live-answers" | |
| 96 | data-live="${ANSWERS_LIVE}" | |
| 97 | data-live-repo="${repo.ownerName}/${repo.name}" | |
| 98 | data-live-src="${base}/app/answers" | |
| 99 | > | |
| 100 | ${rows.length === 0 | |
| 101 | ? null | |
| 102 | : html`<div class="card"><div class="card-head"><h2>The crew's answers</h2></div> | |
| 103 | <div class="card-body"><ul class="list answers">${rows}</ul></div></div>`} | |
| 104 | </div>`; | |
| 105 | } | |
| 106 | ||
| 107 | // GET /:owner/:name/app/answers — the region alone, for the page that shows it. | |
| 108 | export function appAnswersFragment(ctx: Ctx): Response { | |
| 109 | const repo = loadRepo(ctx); | |
| 110 | const user = ctx.auth.user; | |
| 111 | if (!repo || !canSeeAnswers(ctx.app.db, repo, user)) | |
| 112 | return fragment(html``, { status: 404 }); | |
| 113 | return fragment(answersRegion(ctx, repo, user), { request: ctx.request }); | |
| 114 | } |
src/web/studio/app.ts +4
| @@ -15,6 +15,7 @@ import { egressNote } from "./egress.ts"; | ||
| 15 | 15 | import { audit } from "../../contract/audit.ts"; |
| 16 | 16 | import { latestProbes } from "../../contract/probes.ts"; |
| 17 | 17 | import { appsRuntime, envsFor } from "../../apps/runtime.ts"; |
| 18 | import { MAX_BODY } from "../../core/issues.ts"; | |
| 18 | 19 | import { listCrewRoles } from "../../core/agents.ts"; |
| 19 | 20 | import type { AppRecord } from "../../core/apps.ts"; |
| 20 | 21 | import { decisionStats, type DecisionStat } from "../../core/decisions.ts"; |
| @@ -47,6 +48,7 @@ import { | ||
| 47 | 48 | userTag, |
| 48 | 49 | } from "../ui.ts"; |
| 49 | 50 | import { changesHref, tellsHref } from "./nouns.ts"; |
| 51 | import { answersRegion } from "./answers.ts"; | |
| 50 | 52 | import { nowCard, nowRegion } from "./now.ts"; |
| 51 | 53 | import { repoAsks } from "./asks.ts"; |
| 52 | 54 | import { appPanels } from "./previews.ts"; |
| @@ -300,6 +302,7 @@ export async function studioApp( | ||
| 300 | 302 | name="text" |
| 301 | 303 | rows="3" |
| 302 | 304 | required |
| 305 | maxlength="${MAX_BODY}" | |
| 303 | 306 | placeholder="${builders.length === 0 |
| 304 | 307 | ? "Add a crew member under Settings → Crew, then tell them what you want." |
| 305 | 308 | : "What should change? One thing at a time works best."}" |
| @@ -476,6 +479,7 @@ export async function studioApp( | ||
| 476 | 479 | <div class="dash"> |
| 477 | 480 | <div class="dash-main stack"> |
| 478 | 481 | ${tell} |
| 482 | ${ownerFacts && ctx.auth.user ? answersRegion(ctx, repo, ctx.auth.user) : null} | |
| 479 | 483 | <div id="app-previews">${previews}</div> |
| 480 | 484 | ${nowRegion(repo, now)} |
| 481 | 485 | </div> |
test/app-answers.test.ts added +174
| @@ -0,0 +1,174 @@ | ||
| 1 | /** | |
| 2 | * The client tells the crew from the app's page and reads the answer under | |
| 3 | * the box (#1989): the tell goes in through the same-origin form, a stand-in | |
| 4 | * run answers, and the answer is on the page and in its live fragment. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { enableApp } from "../src/actions/apps.ts"; | |
| 12 | import { TOO_LONG_WORDS } from "../src/actions/tell.ts"; | |
| 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { addComment, listIssues } from "../src/core/issues.ts"; | |
| 17 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 19 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 22 | import { answerLines, answersScreen } from "../src/tui/screens/answers.ts"; | |
| 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | import { settleApp } from "./helpers/tui.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | describe("the crew's answer under the tell box", () => { | |
| 30 | let dir = ""; | |
| 31 | let app: App; | |
| 32 | let servers: Servers; | |
| 33 | let repo: Repo; | |
| 34 | let alice = ""; | |
| 35 | let bob = ""; | |
| 36 | ||
| 37 | const signIn = async (username: string): Promise<string> => | |
| 38 | ( | |
| 39 | ( | |
| 40 | await fetch(`${servers.url}/signin`, { | |
| 41 | method: "POST", | |
| 42 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 43 | redirect: "manual", | |
| 44 | }) | |
| 45 | ).headers.get("set-cookie") ?? "" | |
| 46 | ).split(";")[0]!; | |
| 47 | const get = (cookie: string, path: string) => | |
| 48 | fetch(`${servers.url}${path}`, { headers: { cookie }, redirect: "manual" }); | |
| 49 | const tell = (cookie: string, text: string, origin = servers.url) => | |
| 50 | fetch(`${servers.url}/alice/notes/tell`, { | |
| 51 | method: "POST", | |
| 52 | headers: { cookie, origin }, | |
| 53 | body: new URLSearchParams({ text }), | |
| 54 | redirect: "manual", | |
| 55 | }); | |
| 56 | ||
| 57 | beforeAll(async () => { | |
| 58 | dir = await mkdtemp(join(tmpdir(), "forge-answers-")); | |
| 59 | app = createApp({ dataDir: dir, port: 0 }); | |
| 60 | servers = await serve(app); | |
| 61 | for (const name of ["alice", "bob"]) { | |
| 62 | const made = await createUser(app.db, { | |
| 63 | trusted: true, | |
| 64 | username: name, | |
| 65 | email: `${name}@forge.local`, | |
| 66 | password: "a-good-passphrase", | |
| 67 | adminIfFirst: true, | |
| 68 | }); | |
| 69 | if (!made.ok) throw new Error(made.error); | |
| 70 | } | |
| 71 | const owner = getUserByName(app.db, "alice")!; | |
| 72 | const made = await createRepo(app, owner, { name: "notes", visibility: "public" }); | |
| 73 | if (!made.ok) throw new Error(String(made.error)); | |
| 74 | repo = made.value; | |
| 75 | const seeded = await commitToBranch(app, owner, repo, { | |
| 76 | message: "Start", | |
| 77 | files: [ | |
| 78 | { path: "package.json", content: '{"name":"notes","scripts":{"start":"bun run server.ts"}}\n' }, | |
| 79 | { path: "server.ts", content: "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n" }, | |
| 80 | ], | |
| 81 | }); | |
| 82 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 83 | const enabled = await enableApp(app, owner, repo, { slug: "notes" }); | |
| 84 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 85 | const scout = await createAgent(app, owner, { username: "scout" }); | |
| 86 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 87 | expect(addToCrew(app, owner, repo, "scout").ok).toBe(true); | |
| 88 | alice = await signIn("alice"); | |
| 89 | bob = await signIn("bob"); | |
| 90 | }); | |
| 91 | ||
| 92 | afterAll(async () => { | |
| 93 | servers.stop(); | |
| 94 | app.close(); | |
| 95 | await rm(dir, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a tell goes in, a stand-in run answers, and the answer is on the page and in the fragment, newest first", async () => { | |
| 99 | expect((await tell(alice, "Make the <b>header</b> bigger.")).status).toBe(303); | |
| 100 | const first = listIssues(app.db, repo.id, { limit: 5 })[0]!; | |
| 101 | // Told, and nobody has answered yet. | |
| 102 | expect(await (await get(alice, "/alice/notes")).text()).toContain("Waiting for the crew."); | |
| 103 | ||
| 104 | // The stand-in run: queued for the builder, then claimed and finished | |
| 105 | // with a summary, as a builder's would be. | |
| 106 | const scout = getUserByName(app.db, "scout")!; | |
| 107 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: first.id, actorId: scout.id, reason: "assigned" }); | |
| 108 | expect(await (await get(alice, "/alice/notes/app/answers")).text()).toContain("The crew is working on it."); | |
| 109 | claimRun(app.db, run.id); | |
| 110 | finishRun(app.db, run.id, { state: "ok", summary: "Made the header bigger <script>x</script>." }); | |
| 111 | ||
| 112 | await tell(alice, "Add a footer."); | |
| 113 | const second = listIssues(app.db, repo.id, { limit: 5 })[0]!; | |
| 114 | expect(second.number).toBeGreaterThan(first.number); | |
| 115 | addComment(app.db, { issueId: second.id, authorId: scout.id, body: "Footer added on a branch." }); | |
| 116 | ||
| 117 | const page = await (await get(alice, "/alice/notes")).text(); | |
| 118 | expect(page).toContain('id="live-answers"'); | |
| 119 | expect(page).toContain("Made the header bigger <script>x</script>."); | |
| 120 | expect(page).not.toContain("<script>x</script>"); | |
| 121 | expect(page).toContain("Make the <b>header</b> bigger."); | |
| 122 | expect(page).toContain("Footer added on a branch."); | |
| 123 | ||
| 124 | const frag = await (await get(alice, "/alice/notes/app/answers")).text(); | |
| 125 | expect(frag).toContain("Footer added on a branch."); | |
| 126 | expect(frag.indexOf("Add a footer.")).toBeLessThan(frag.indexOf("Make the <b>")); | |
| 127 | }); | |
| 128 | ||
| 129 | test("only the client's own app: a stranger has no box, no answers, and cannot tell", async () => { | |
| 130 | const page = await (await get(bob, "/alice/notes")).text(); | |
| 131 | expect(page).not.toContain('id="tell"'); | |
| 132 | expect(page).not.toContain("Footer added on a branch."); | |
| 133 | expect((await get(bob, "/alice/notes/app/answers")).status).toBe(404); | |
| 134 | const before = listIssues(app.db, repo.id, { state: "all", limit: 50 }).length; | |
| 135 | await tell(bob, "I am not the owner."); | |
| 136 | expect(listIssues(app.db, repo.id, { state: "all", limit: 50 }).length).toBe(before); | |
| 137 | }); | |
| 138 | ||
| 139 | test("a cross-origin tell and an over-long one open nothing", async () => { | |
| 140 | const before = listIssues(app.db, repo.id, { state: "all", limit: 50 }).length; | |
| 141 | expect((await tell(alice, "from elsewhere", "https://evil.test")).status).toBeGreaterThanOrEqual(400); | |
| 142 | const long = await tell(alice, "x".repeat(70_000)); | |
| 143 | const flash = long.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "); | |
| 144 | const shown = await (await fetch(new URL(long.headers.get("location")!, servers.url), { headers: { cookie: `${alice}; ${flash}` } })).text(); | |
| 145 | expect(shown).toContain(TOO_LONG_WORDS); | |
| 146 | expect(listIssues(app.db, repo.id, { state: "all", limit: 50 }).length).toBe(before); | |
| 147 | }); | |
| 148 | ||
| 149 | test("the terminal shows the same answers to the owner, and nothing to a stranger", async () => { | |
| 150 | const screen = async (who: string): Promise<{ text: string; count: number }> => { | |
| 151 | const user = getUserByName(app.db, who)!; | |
| 152 | const { app: tui, session } = createForgeApp( | |
| 153 | app, | |
| 154 | { sshUsername: who, remoteAddress: "127.0.0.1", user }, | |
| 155 | { terminal: headlessTerminal(120, 30), exitHooks: false }, | |
| 156 | ); | |
| 157 | tui.start(); | |
| 158 | await settleApp(tui); | |
| 159 | tui.push(answersScreen(session, tui.ctx, repo)); | |
| 160 | await settleApp(tui); | |
| 161 | const text = tui.screen.toText(); | |
| 162 | const count = answerLines(session, repo).length; | |
| 163 | tui.stop(); | |
| 164 | return { text, count }; | |
| 165 | }; | |
| 166 | const mine = await screen("alice"); | |
| 167 | expect(mine.count).toBeGreaterThan(0); | |
| 168 | expect(mine.text).toContain("Add a footer."); | |
| 169 | expect(mine.text).toContain("Footer added on a branch."); | |
| 170 | const theirs = await screen("bob"); | |
| 171 | expect(theirs.count).toBe(0); | |
| 172 | expect(theirs.text).not.toContain("Add a footer."); | |
| 173 | }); | |
| 174 | }); |
test/tui-tell.test.ts +6 −2
| @@ -9,10 +9,10 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createRepo } from "../src/actions/repos.ts"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | import { listIssues } from "../src/core/issues.ts"; | |
| 12 | import { listIssues, MAX_BODY } from "../src/core/issues.ts"; | |
| 13 | 13 | import { createUser, type User } from "../src/core/users.ts"; |
| 14 | 14 | import { tellAnswer } from "../src/tui/screens/tell.ts"; |
| 15 | import { toldWords, UNVERIFIED_WORDS, type Told } from "../src/actions/tell.ts"; | |
| 15 | import { tell, toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told } from "../src/actions/tell.ts"; | |
| 16 | 16 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 17 | 17 | import { repoScreen } from "../src/tui/screens/repo.ts"; |
| 18 | 18 | import { local } from "../src/tui/screens/where.ts"; |
| @@ -110,4 +110,8 @@ describe("tell the crew in the terminal", () => { | ||
| 110 | 110 | test("an unverified address gets the shared words", () => { |
| 111 | 111 | expect(tellAnswer({ ok: false, error: "unverified" }).text).toBe(UNVERIFIED_WORDS); |
| 112 | 112 | }); |
| 113 | ||
| 114 | test("an over-long line gets the page's words", async () => { | |
| 115 | expect(tellAnswer(await tell(app, alice, repo, "x".repeat(MAX_BODY + 1))).text).toBe(TOO_LONG_WORDS); | |
| 116 | }); | |
| 113 | 117 | }); |
Right cap. But the terminal's
tellAnswer(src/tui/screens/tell.ts, on this branch since the merge) does not know this error and says "That could not be told." Give it the page's words through one shared constant, and add a test.