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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1989-the-client-tells-the-crew-about-an-app-f → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/1989-the-client-tells-the-crew-about-an-app-f was merged into main as 495adda.

Review

Sign in to review this change.
11 files changed +425 −6
README.md +7
@@ -505,6 +505,13 @@ The terminal's palette has the row too, on a repository screen, for the
505505 same people: it takes one line, tells with no `from`, and answers in the
506506 same words.
507507
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
508515 An **agent** is an account of kind `agent`: no password, no session, and it
509516 acts only through a token the forge mints for a run and destroys after it.
510517 An admin creates one (**Settings → Crew**, or `POST /api/v1/agents`) with a
src/actions/tell.ts +6 −1
@@ -8,7 +8,7 @@
88
99 import type { App } from "../app.ts";
1010 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";
1212 import type { Repo } from "../core/repos.ts";
1313 import { listRuns } from "../core/runs.ts";
1414 import type { User } from "../core/users.ts";
@@ -95,6 +95,8 @@ export async function tell(
9595 if (!requireVerified(app, participant.value).ok) return err("unverified");
9696 const text = said.trim();
9797 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");

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.

98100 if (from !== null && parseFrom(from) !== from) return err("bad_from");
99101 const firstLine = text.split(/\n/)[0]!.trim();
100102 const title =
@@ -150,6 +152,9 @@ export async function tell(
150152 /** What every door says when a tell is refused for an unverified address. */
151153 export const UNVERIFIED_WORDS = "verify your email first";
152154
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
153158 /** What happened, in the sentence both doors say. */
154159 export function toldWords(told: Told): string {
155160 if (told.routed === undefined)
src/http/server.ts +2
@@ -242,6 +242,7 @@ import {
242242 liveOnYouFragment,
243243 studioDoor,
244244 } from "../web/studio/home.ts";
245 import { appAnswersFragment } from "../web/studio/answers.ts";
245246 import { appNowFragment } from "../web/studio/now.ts";
246247 import { appStateFragment } from "../web/studio/previews.ts";
247248 import { changesList, tellsList } from "../web/studio/lists.ts";
@@ -1083,6 +1084,7 @@ export function buildRouter(): Router<Handler> {
10831084 router.add("GET", "/:owner/:name/app/state", appStateFragment);
10841085 // "Now" on the same page, alone: the region's own address.
10851086 router.add("GET", "/:owner/:name/app/now", appNowFragment);
1087 router.add("GET", "/:owner/:name/app/answers", appAnswersFragment);
10861088 router.add("GET", "/:owner/:name/app/:env/open", appOpen);
10871089 // Where a private app sends a person who arrived by its link.
10881090 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 @@
66 * screen has no page path of its own.
77 */
88
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";
1010 import type { Repo } from "../../core/repos.ts";
1111 import { DIRECTION_NOTE, mayDirectCrew } from "../../crew/direct.ts";
1212 import type { Result } from "../../kernel/result.ts";
@@ -25,7 +25,9 @@ export function tellAnswer(told: Result<Told, TellError>): { text: string; tone:
2525 ? DIRECTION_NOTE
2626 : told.error === "unverified"
2727 ? UNVERIFIED_WORDS
28 : "That could not be told.",
28 : told.error === "body_too_long"
29 ? TOO_LONG_WORDS
30 : "That could not be told.",
2931 };
3032 }
3133
src/tui/screens/workspace.ts +9
@@ -91,6 +91,8 @@ import {
9191 proposingStatus,
9292 type OriginTarget,
9393 } from "./contribute.ts";
94 import { canSeeAnswers } from "../../web/studio/answers.ts";
95 import { answersScreen } from "./answers.ts";
9496 import { commitsScreen } from "./commits.ts";
9597 import { deploysScreen } from "./deploys.ts";
9698 import {
@@ -1290,6 +1292,9 @@ export function workspaceScreen(
12901292 binding(["I"], "about this repository"),
12911293 binding(["V"], "waves"),
12921294 ...(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 : []),
12931298 ...(repo !== undefined && admin()
12941299 ? [binding(["R"], `decisions by ${(getRepoById(f.db, repo.id) ?? repo).decisionsBy} (cycle)`)]
12951300 : []),
@@ -1616,6 +1621,10 @@ export function workspaceScreen(
16161621 ctx.push(deploysScreen(f, ctx, repo));
16171622 return true;
16181623 }
1624 if (plain && e.key === "a" && repo !== undefined && canSeeAnswers(f.db, repo, f.user)) {

Nothing tests the a key. The test pushes answersScreen directly, so a bad key gate here, or a showing on a repository that is not an app, would go unnoticed. The screen still applies canSeeAnswers itself, so this is not a leak. A follow-up test that presses a on the workspace as owner and as stranger would pin it down.

1625 ctx.push(answersScreen(f, ctx, repo));
1626 return true;
1627 }
16191628 if (plain && e.key === "I") {
16201629 ctx.push(aboutScreen(f, ctx, screen, src, facts, ref));
16211630 return true;
src/web/studio/actions.ts +6 −1
@@ -11,7 +11,7 @@
1111 import { mayView } from "../../core/sealed.ts";
1212 import { holdPull, releasePull } from "../../actions/holds.ts";
1313 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";
1515 import { keepThought } from "../../actions/thought.ts";
1616 import { DIRECTION_NOTE } from "../../crew/direct.ts";
1717 import { mergePull } from "../../actions/pulls.ts";
@@ -59,6 +59,11 @@ async function tellFrom(
5959 message: "Say something first.",
6060 });
6161 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 tellAnswer in src/tui/screens/tell.ts doesn't know body_too_long yet, so a too-long tell typed there reads "That could not be told." Use these same words in both places.

Export this sentence, for example TOO_LONG_WORDS next to UNVERIFIED_WORDS in actions/tell.ts, so the terminal's tellAnswer says the same thing.

63 return flashOrJson(ctx, back, {
64 tone: "danger",
65 message: TOO_LONG_WORDS,
66 });
6267 if (!told.ok)
6368 return flashOrJson(ctx, back, {
6469 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 db. Take db: Database (and pass user) so src/tui can reuse the same query and the terminal shows the same answers as the page.

39 export function answersFor(db: Database, repo: Repo, user: User): readonly Answer[] {
40 const issues = listIssues(db, repo.id, {

listIssues without sealedBy drops the viewer's own sealed tells, so a tell the owner sealed never shows its answer here. Passing sealedBy: user.id would include them, and they are the author's own. Minor.

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";
1515 import { audit } from "../../contract/audit.ts";
1616 import { latestProbes } from "../../contract/probes.ts";
1717 import { appsRuntime, envsFor } from "../../apps/runtime.ts";
18 import { MAX_BODY } from "../../core/issues.ts";
1819 import { listCrewRoles } from "../../core/agents.ts";
1920 import type { AppRecord } from "../../core/apps.ts";
2021 import { decisionStats, type DecisionStat } from "../../core/decisions.ts";
@@ -47,6 +48,7 @@ import {
4748 userTag,
4849 } from "../ui.ts";
4950 import { changesHref, tellsHref } from "./nouns.ts";
51 import { answersRegion } from "./answers.ts";
5052 import { nowCard, nowRegion } from "./now.ts";
5153 import { repoAsks } from "./asks.ts";
5254 import { appPanels } from "./previews.ts";
@@ -300,6 +302,7 @@ export async function studioApp(
300302 name="text"
301303 rows="3"
302304 required
305 maxlength="${MAX_BODY}"
303306 placeholder="${builders.length === 0
304307 ? "Add a crew member under Settings → Crew, then tell them what you want."
305308 : "What should change? One thing at a time works best."}"
@@ -476,6 +479,7 @@ export async function studioApp(
476479 <div class="dash">
477480 <div class="dash-main stack">
478481 ${tell}
482 ${ownerFacts && ctx.auth.user ? answersRegion(ctx, repo, ctx.auth.user) : null}
479483 <div id="app-previews">${previews}</div>
480484 ${nowRegion(repo, now)}
481485 </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 &lt;script&gt;x&lt;/script&gt;.");
120 expect(page).not.toContain("<script>x</script>");
121 expect(page).toContain("Make the &lt;b&gt;header&lt;/b&gt; 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 &lt;b&gt;"));
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";
99 import { join } from "node:path";
1010 import { createRepo } from "../src/actions/repos.ts";
1111 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";
1313 import { createUser, type User } from "../src/core/users.ts";
1414 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";
1616 import { createForgeApp } from "../src/tui/forge.ts";
1717 import { repoScreen } from "../src/tui/screens/repo.ts";
1818 import { local } from "../src/tui/screens/where.ts";
@@ -110,4 +110,8 @@ describe("tell the crew in the terminal", () => {
110110 test("an unverified address gets the shared words", () => {
111111 expect(tellAnswer({ ok: false, error: "unverified" }).text).toBe(UNVERIFIED_WORDS);
112112 });
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 });
113117 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs