Public: anyone can read this; the crew can change it.

A new forge's first screen leads with one box, "say what you want built"; the passkey-only everyday account is offered after it, never first #11181

Merged opened this

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

Merged

mason/forge:mason/11173-a-new-forge-s-first-screen-leads-with-on → main 9 commits fork 5 threads · 5 unresolved

  • Merged. mason/11173-a-new-forge-s-first-screen-leads-with-on was merged into main as 6dbf1d2.

Review

Sign in to review this change.
10 files changed +238 −40
docs/features/new-forge-first-minutes.md +10
@@ -10,3 +10,13 @@ For site admins. A forge started in its own cell (a child forge) is treated gent
1010 - **What the proof cannot see.** The newest start is left out: from outside, a start that is still running cannot be told from one that crashed. So a crash with no start after it counts only once the next start is written, for example when the forge next wakes. The window counts from the forge's very first start (`firstAt` in `boots.json`), even after the ledger keeps only its last 50 starts.
1111
1212 Code: `src/spawn/cell.ts` (`firstAnswer`, `sleepIdle`), `src/spawn/host.ts` (`wakeLimits`), `src/kernel/boots.ts` (`uncleanInFirstMinutes`), `src/spawn/reach.ts` (`proveBoots`). Tests: `test/first-ten-minutes.test.ts`.
13
14 ## The first screen: one box
15
16 For the owner of a new forge (the site admin, or their everyday account), the home is one box until they have told the crew something.
17
18 - **Web.** `/` shows "What do you want built?", the box, "Tell the crew", and one line about what pays for the crew (`crewNote` in `src/web/studio/crew_key.ts`): the included credit with what is left, "spent", or "needs a model key". Without a key of their own an admin also gets a link to Subscriptions (`/settings/subscriptions`), where the key is added. The first screen carries no key field, no password field and no second form, and exactly one primary button.
19 - **Terminal.** The home (`loadClientLines` in `src/tui/screens/dashboard.ts`) reads the same two lines, and `t` opens a prompt "What do you want built?" that keeps the words as the box does (`keepThought`).
20 - **The everyday account is offered after the first tell**, not before: once the owner has a tell (an issue they or their everyday account wrote), the home becomes the everyday one, and the "make your everyday account" step (web) or line (terminal, `E` makes it, `K` keeps admin) appears. Until then neither door mentions it.
21
22 Code: `src/web/pages/next-steps.ts` (`firstTell`), `src/core/next-steps.ts`. Tests: `test/next-steps-card.test.ts`, `test/tui-first-tell.test.ts`.
src/tui/screens/dashboard.ts +41 −3
@@ -25,6 +25,9 @@ import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_
2525 import { clientAppRows, clientDoings, clientFinished, doingLine, finishedLine } from "../../core/client-apps.ts";
2626 import { appCrew } from "../../core/app-crew.ts";
2727 import { nextStepsFor } from "../../core/next-steps.ts";
28 import { keepThought } from "../../actions/thought.ts";
29 import { SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts";
30 import { crewNote } from "../../web/studio/crew_key.ts";
2831 import { agentsLine } from "../../web/studio/client_home.ts";
2932 import { lastVisit } from "../../core/home_visit.ts";
3033 import { sinceDigest } from "../../web/studio/since.ts";
@@ -130,6 +133,9 @@ import { local } from "./where.ts";
130133 import { signInScreen } from "./signin.ts";
131134
132135 /** Rows the list keeps under the figures strip; below it the strip shows fewer figures. */
136 /** The first line of a new forge's home, as the page's heading and its one box. */
137 const FIRST_TELL_LINE = "What do you want built? t says it to the crew.";

Non-blocking: this new constant now splits the Rows the list keeps... doc comment from FIGURES_ROOM. Put FIRST_TELL_LINE above that comment.

138
133139 const FIGURES_ROOM = 6;
134140
135141 /** The forge-wide daily ceiling, from config, for the spend figure; the data directory, for the backup's age. */
@@ -231,6 +237,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
231237 * name of the account used day to day. Nobody else gets the row.
232238 */
233239 let everyday: { line: string; asking: boolean } | undefined;
240 /** A new forge's owner before their first tell: the home is one line, "what do you want built", and the everyday offer waits. */
241 let firstTell = false;
234242 /** One line per fired flow alarm; an admin reads every repository, anyone else public ones alone. */
235243 let alarms: string[] = [];
236244 /** The panels this admin pinned to the web home, by title and board, in their order. */
@@ -276,9 +284,18 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
276284
277285 const loadClientLines = async (): Promise<void> => {
278286 clientLines = [];
279 if (!f.user || (await nextStepsFor(f.app, f.user)) === undefined) return;
287 firstTell = false;
288 const steps = f.user ? await nextStepsFor(f.app, f.user) : undefined;
289 if (!f.user || steps === undefined) return;
280290 // A client's home never carries the forge-wide figures, on either door.
281291 figures = [];
292 firstTell = steps.some((s) => s.key === "tell" && !s.done);
293 everyday = everydayHead();
294 if (firstTell) {
295 clientLines = [FIRST_TELL_LINE, crewNote(f.app)];
296 ctx.invalidate();
297 return;
298 }
282299 const apps = await clientAppRows(f.app, f.user);
283300 clientRepoIds = new Set(apps.map((a) => a.repo.id));
284301 clientLive = new Map(apps.flatMap((a) => (a.live === undefined ? [] : [[a.repo.id, a.live] as const])));
@@ -319,7 +336,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
319336 };
320337
321338 const everydayHead = (): typeof everyday => {
322 if (f.user === undefined || !f.user.isAdmin || f.user.kind !== "user") return undefined;
339 if (f.user === undefined || !f.user.isAdmin || f.user.kind !== "user" || firstTell) return undefined;
323340 if (askForEveryday(f.db, f.user))
324341 return { line: `${EVERYDAY_WHY} E makes it on the web · K keeps using admin`, asking: true };
325342 const daily = everydayOf(f.db, f.user);
@@ -1016,7 +1033,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10161033 disabled: focus !== "machine" || pins.length === 0,
10171034 }),
10181035 binding(["C"], "decider", { disabled: !f.user }),
1019 ...(clientLines.length > 0 ? [binding(["t"], "ask for something")] : []),
1036 ...(firstTell
1037 ? [binding(["t"], "say what you want built")]
1038 : clientLines.length > 0
1039 ? [binding(["t"], "ask for something")]
1040 : []),
10201041 ...(clientLive.size > 0 ? [binding(["o"], "open the app")] : []),
10211042 binding(["n"], "new repo", { disabled: !f.signedIn }),
10221043 binding(["M"], "migrate", { disabled: !f.signedIn }),
@@ -1264,6 +1285,23 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
12641285 }
12651286 return true;
12661287 }
1288 if (e.key === "t" && f.user && firstTell) {
1289 ctx.push(
1290 promptScreen(ctx, screen, {
1291 title: "What do you want built?",
1292 label: "Your request",
1293 hint: "enter sends",
1294 onSubmit: (said) => {
1295 if (said.trim() === "") return SAY_SOMETHING_WORDS;
1296 const kept = keepThought(f.app, f.user, said, "composer");
1297 ctx.toast(kept.ok ? "Received." : "That could not be kept.", kept.ok ? "success" : "danger");
1298 if (kept.ok) void loadClientLines().then(reload);
1299 return undefined;
1300 },
1301 }),
1302 );
1303 return true;
1304 }
12671305 if (e.key === "t" && f.user && clientLines.length > 0) {
12681306 // A client's apps are the list; the one under the cursor is asked.
12691307 const repo = shown()[at("repos").index];
src/web/pages/next-steps.ts +22 −1
@@ -7,7 +7,9 @@
77 * primary action. Make your everyday account carries the form itself (it
88 * replaces #803's separate card here); open your apps lists them; tell the
99 * crew something points at the composer just below. The card goes once
10 * all three are done.
10 * all three are done. Before the first tell the card is one box, "What do you
11 * want built?", and one line on what pays for the crew; the everyday account is
12 * offered once a tell exists, never as a gate before it (#11173).
1113 */
1214
1315 import { appCrew } from "../../core/app-crew.ts";
@@ -24,6 +26,10 @@ import { badge, btn, row } from "../ui.ts";
2426 import { tellHref } from "../studio/nouns.ts";
2527 import { everydayAsk } from "./everyday.ts";
2628 import { EVERYDAY_WHY } from "../../core/everyday.ts";
29 import { crewNote } from "../studio/crew_key.ts";
30 import { crewHasCredential } from "../../core/subscriptions.ts";
31 import { FORGE_TARGET } from "../studio/feed.ts";
32 import { COMPOSER_FIELDS, TELL_MAX } from "../share_target.ts";
2733
2834 /** One line per app: the app is the row's title, the crew member and what they are doing its meta. */
2935 function crewAtWork(ctx: Ctx, repos: Awaited<ReturnType<typeof spawnedRepos>>): Html[] {
@@ -57,6 +63,20 @@ function stepBlock(step: NextStep, primary: boolean, body: Html | null): Html {
5763 ${!step.done && body ? html`<div class="step-body">${body}</div>` : null}`;
5864 }
5965
66 /** Before the first tell: one box, "say what you want built", and one line about what pays for the crew. */
67 function firstTell(ctx: Ctx, user: User, crew: Html[]): Html {
68 return html`<section id="next-steps" data-first-tell>
69 <h2>What do you want built?</h2>
70 <form id="composer" class="stack" method="post" action="/tell">
71 <input type="hidden" name="${COMPOSER_FIELDS.app}" value="${FORGE_TARGET}" />
72 <textarea class="input" name="${COMPOSER_FIELDS.text}" rows="3" maxlength="${String(TELL_MAX)}" required placeholder="A weekly menu page for the bakery" aria-label="Your request" data-draft="${user.id}"></textarea>
outdated

Principle 1: the heading, the placeholder and the aria-label say the same sentence three times. Keep the heading and give the textarea a short label, or a placeholder that adds something (an example).

73 <div class="bar">${btn("Tell the crew", { variant: "primary" })}</div>
74 </form>
75 <p class="small muted" id="crew-credit-note">${crewNote(ctx.app)}${crewHasCredential(ctx.app.db) || !ctx.auth.user?.isAdmin ? null : html` <a href="/settings/subscriptions">Add your own key</a>.`}</p>
76 ${crew.length > 0 ? html`<ul class="list">${crew}</ul>` : null}
77 </section>`;
outdated

Box 1 asks for one line. crewKeyCard brings a password-field form here when there is no credit. Use the compact variant or a single line with a link.

78 }
79
6080 /** The card, or nothing: not a spawned forge, not its owner, or every step done. */
6181 export async function nextStepsCard(
6282 ctx: Ctx,
@@ -66,6 +86,7 @@ export async function nextStepsCard(
6686 if (!steps || steps.every((s) => s.done)) return null;
6787 const repos = await spawnedRepos(ctx.app, user);
6888 const crew = crewAtWork(ctx, repos);
89 if (steps.some((s) => s.key === "tell" && !s.done)) return firstTell(ctx, user, crew);
6990 const primary = steps.find((s) => !s.done)?.key;
7091 const bodies: Record<NextStep["key"], Html | null> = {
7192 everyday: html`<p class="muted small">${EVERYDAY_WHY}</p>
src/web/studio/crew_key.ts +10
@@ -52,6 +52,16 @@ export function crewCanRun(app: App): boolean {
5252 /** Whether the forge has a crew at all, so a line about it is true. */
5353 export const hasCrew = (app: App): boolean => listAgents(app.db).length > 0;
5454
55 /** What the crew runs on, in one sentence and no field: the first screen's line, said the same on the page and in the terminal. */
56 export function crewNote(app: App): string {
57 if (crewHasCredential(app.db)) return "The crew works on this forge's included credit.";
58 const credit = includedCredit(app);
59 if (credit !== undefined && credit.leftUsd > 0)
60 return `The crew is building on ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} included credit.`;
outdated

Demoting this button changes every page that shows the key field. Admin settings and the repository pages lose their primary action. Fix the first screen instead, so the key form is not shown there, and keep this button primary.

61 if (credit !== undefined) return "The included credit is spent; the crew needs a model key to go on.";
62 return "The crew needs a model key to start.";
63 }
64
5565 const keyField = (): Html => html`<form class="stack" method="post" action="${KEY_ACTION}" id="crew-key-form">
5666 <div class="field">
5767 <label for="crew-key">Your Anthropic API key or Claude login</label>
src/web/studio/home.ts +5 −3
@@ -319,10 +319,12 @@ export async function studioHome(
319319 steps !== undefined
320320 ) {
321321 const apps = await clientApps(ctx, user);
322 // Before the first tell the card is the one box and carries the key line and the composer.
outdated

The terminal's client home (src/tui/screens/dashboard.ts, loadClientLines) has no matching first-tell screen. The page and the terminal must offer the same thing.

323 const firstTell = steps.some((s) => s.key === "tell" && !s.done);
322324 const lean = html`<h1 class="sr-only">Home</h1>
323 ${firstSteps} ${address} ${crewKeyCard(ctx)} ${heldLine(ctx, user)} ${apps}
325 ${firstSteps} ${address} ${firstTell ? null : crewKeyCard(ctx)} ${heldLine(ctx, user)} ${apps}
324326 <div class="stack">
325 ${composer(ctx, user)}
327 ${firstTell ? null : composer(ctx, user)}
326328 <p class="small muted">
327329 <a href="/?all=1">Show everything</a>
328330 </p>
@@ -332,7 +334,7 @@ export async function studioHome(
332334 title: "Home",
333335 body: lean,
334336 // The everyday step makes its account with a passkey (#1890).
335 ...(firstSteps ? { scriptJs: passkeyJs } : {}),
337 ...(firstSteps && !firstTell ? { scriptJs: passkeyJs } : {}),
336338 });
337339 }
338340 // The client's first screen names its own phases (`clientApps`).
test/first-login-words.test.ts +9 −1
@@ -29,7 +29,7 @@ describe("the first-login page of a spawned forge, in plain words", () => {
2929
3030 test("the owner's first page has no jargon", async () => {
3131 const html = await (await fetch(url("/"), { headers: { cookie: owner } })).text();
32 expect(mainOf(html)).toContain("Your next steps");
32 expect(mainOf(html)).toContain("What do you want built?");
3333 expect(jargonIn(html)).toEqual([]);
3434 });
3535
@@ -42,6 +42,14 @@ describe("the first-login page of a spawned forge, in plain words", () => {
4242 expect(res.status).toBe(303);
4343 const daily = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
4444 const html = await (await fetch(url("/"), { headers: { cookie: daily } })).text();
45 expect(mainOf(html)).toContain("What do you want built?");
46 expect(jargonIn(html)).toEqual([]);
47 });
48
49 test("after the first tell, the steps card has no jargon either", async () => {
50 const told = await forge.post(owner, "/marias/bakery/tell", { text: "Add a weekly menu page." });
51 expect(told.status).toBeLessThan(400);
52 const html = await (await fetch(url("/"), { headers: { cookie: owner } })).text();
4553 expect(mainOf(html)).toContain("Your next steps");
4654 expect(jargonIn(html)).toEqual([]);
4755 });
test/first-login.test.ts +27 −25
@@ -200,30 +200,40 @@ describe("the first login to a spawned forge", () => {
200200 expect(await page(`forge_session=${session.token}`)).not.toContain(CARD);
201201 });
202202
203 test("after the claim, the home shows the crew and its direction, and one card with three steps, none done", async () => {
203 test("the first screen is one box to say what to build; the passkey offer is not shown before it", async () => {
204 const home = await page(owner);
205 expect(home).toContain("What do you want built?");
206 const forms = [...home.matchAll(/<form[^>]*>/g)].map((m) => m[0]);
207 const main = home.slice(home.indexOf("<main"));
208 const mainForms = [...main.matchAll(/<form[^>]*>/g)].map((m) => m[0]);
209 expect(mainForms.length).toBeGreaterThan(0);
210 expect(mainForms[0]).toContain('id="composer"');
211 expect(mainForms[0]).toContain('action="/tell"');
212 expect(forms.some((f) => f.includes("/settings/everyday"))).toBe(false);
213 expect(main).not.toContain("There is no password");
214 expect(main).not.toContain("Make your everyday account");
215 expect(steps(home)).toEqual({ everyday: undefined, apps: undefined, tell: undefined });
216 // The crew at work and its direction are still on the first screen.
217 expect(home).toContain("atlas is ready to work toward Run Maria");
218 expect(home).toMatch(/href="\/marias\/bakery\/issues\/\d+"[^>]*>[\s\S]*?atlas is ready/);
219 expect(home).toContain('href="/marias/bakery"');
220 });
221
222 test("once a tell exists the card carries three steps, and the everyday offer comes after it", async () => {
223 const told = await post(owner, "/marias/bakery/tell", { text: "Add a weekly menu page." });
224 expect(told.status).toBeLessThan(400);
204225 const home = await page(owner);
205226 expect(home).toContain(CARD);
206227 expect(home).toContain("Make your everyday account");
207228 expect(home).toContain("Open your apps");
208229 expect(home).toContain("Tell the crew something");
209 expect(steps(home)).toEqual({ everyday: "no", apps: "no", tell: "no" });
210 expect(home).not.toMatch(/class="[^"]*\bsteps\b/);
211 // One card for the everyday account, not two: the steps card carries it.
230 expect(steps(home)).toEqual({ everyday: "no", apps: "no", tell: "yes" });
231 expect(home).toContain('action="/settings/everyday"');
212232 expect(home.split("Make your everyday account").length - 1).toBe(1);
213 // The planner and the direction it was given, under the studio's noun.
214 // No run has started (no model key), so it does not claim work under way.
215 expect(home).toContain("atlas is ready to work toward Run Maria");
216 expect(home).not.toContain("Direction: Run");
217 expect(home).not.toMatch(/Your (crew|apps)[^<]*:/);
218 expect(home).toMatch(/href="\/marias\/bakery\/issues\/\d+"[^>]*>[\s\S]*?atlas is ready/);
219 for (const old of ["next-step", "next-crew"]) expect(home).not.toContain(`class="${old}`);
220 expect(home).not.toMatch(/class="next-step/);
221 // A pending step's body is a block of its own, never inside the row's
222 // one-line, clipped `.row-meta`.
233 expect(home).not.toContain('data-first-tell');
234 expect(home).not.toMatch(/class="[^"]*\bsteps\b/);
223235 expect(home).not.toMatch(/data-step[\s\S]{0,400}row-meta/);
224236 expect(home).toMatch(/<div class="step-body">\s*<p class="muted small">/);
225 // Each app with its way in.
226 expect(home).toContain('href="/marias/bakery"');
227237 });
228238
229239 test("the everyday account, made first, joins the client's org and sees the card part-way, every step one it can do", async () => {
@@ -236,7 +246,7 @@ describe("the first login to a spawned forge", () => {
236246 daily = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
237247 expect(getUserByName(child.db, "maria-me")).toBeDefined();
238248 const home = await page(daily);
239 expect(steps(home)).toEqual({ everyday: "yes", apps: "no", tell: "no" });
249 expect(steps(home)).toEqual({ everyday: "yes", apps: "no", tell: "yes" });
240250 // It can read the client's own apps, and the crew line reads for it too.
241251 expect(home).toContain('href="/marias/bakery"');
242252 expect(home).not.toContain("Your apps arrive here");
@@ -274,14 +284,6 @@ describe("the first login to a spawned forge", () => {
274284 child.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(bakery.direction_issue_id, atlasId);
275285 });
276286
277 test("the everyday account tells the crew something: the third step", async () => {
278 const told = await post(daily, "/marias/bakery/tell", {
279 text: "Add a weekly menu page.",
280 });
281 expect(told.status).toBeLessThan(400);
282 expect(steps(await page(daily))).toEqual({ everyday: "yes", apps: "no", tell: "yes" });
283 });
284
285287 test("the everyday account opens an app: the second step, and the card is gone for both accounts", async () => {
286288 expect((await fetch(url("/marias/bakery"), { headers: { cookie: daily } })).status).toBe(200);
287289 expect(await page(daily)).not.toContain(CARD);
test/next-steps-card.test.ts +58 −6
@@ -5,9 +5,12 @@
55 * second account.
66 */
77
8 import { readFileSync, writeFileSync } from "node:fs";
9 import { join } from "node:path";
810 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
911 import { createSession } from "../src/core/sessions.ts";
10 import { createUser } from "../src/core/users.ts";
12 import { nextStepsFor } from "../src/core/next-steps.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
1114 import { claimedForge, PASSWORD, type ClaimedForge } from "./helpers/claimed-forge.ts";
1215 import { quietLogs } from "./helpers/globals.ts";
1316 import { passkeyFields } from "./helpers/passkey.ts";
@@ -51,10 +54,58 @@ describe("the next-steps card of a spawned forge", () => {
5154 const keys = (html: string) =>
5255 [...cardOf(html).matchAll(/data-step="(\w+)"/g)].map((m) => m[1]);
5356
57 test("the first screen leads with the tell box; the passkey form is not shown before it", async () => {
58 const home = await page(owner);
59 const main = home.slice(home.indexOf("<main"), home.indexOf("</main>"));
60 const forms = [...main.matchAll(/<form[^>]*>/g)].map((m) => m[0]);
61 expect(forms[0]).toContain('id="composer"');
62 expect(forms[0]).toContain('action="/tell"');
63 expect(forms.filter((f) => f.includes('action="/tell"')).length).toBe(1);
64 expect(main).not.toContain("/settings/everyday");
65 expect(main).not.toContain("There is no password");
66 expect(main.match(/data-variant="primary"/g)?.length).toBe(1);
67 expect(keys(home)).toEqual([]);
68 });
69
70 test("the first screen is one form whatever the crew runs on: a line, and for an admin a link, never the key field", async () => {
71 const file = join(forge.child.config.dataDir, "parent.json");
72 const before = readFileSync(file, "utf8");
73 const lines: Record<string, string> = {
74 "": "The crew needs a model key to start.",
75 credit: "included credit.",
76 };
77 try {
78 for (const [mode, said] of Object.entries(lines)) {
79 const doc = JSON.parse(before);
80 if (mode === "") delete doc.credit;
81 writeFileSync(file, JSON.stringify(doc));
82 const home = await page(owner);
83 const main = home.slice(home.indexOf("<main"), home.indexOf("</main>"));
84 expect(main).toContain(said);
85 expect(main).toContain('href="/settings/subscriptions"');
86 expect(main).not.toContain('action="/settings/crew/key"');
87 expect(main).not.toContain('type="password"');
88 expect([...main.matchAll(/<form[^>]*>/g)].length).toBe(1);
89 expect(main.match(/data-variant="primary"/g)?.length).toBe(1);
90 }
91 } finally {
92 writeFileSync(file, before);
93 }
94 });
95
96 test("the tell box sends a tell, and the everyday offer appears once one exists", async () => {
97 const told = await post(owner, "/marias/bakery/tell", { text: "Add a weekly menu page." });
98 expect(told.status).toBeLessThan(400);
99 const home = await page(owner);
100 const main = home.slice(home.indexOf("<main"), home.indexOf("</main>"));
101 expect(main).toContain('action="/settings/everyday"');
102 expect(main).toContain("There is no password");
103 });
104
54105 test("the admin's first home carries exactly three steps, in order, none done", async () => {
55106 const home = await page(owner);
56107 expect(keys(home)).toEqual(["everyday", "apps", "tell"]);
57 expect(steps(home)).toEqual({ everyday: "no", apps: "no", tell: "no" });
108 expect(steps(home)).toEqual({ everyday: "no", apps: "no", tell: "yes" });
58109 const titles = ["Make your everyday account", "Open your apps", "Tell the crew something"];
59110 const at = titles.map((t) => cardOf(home).indexOf(t));
60111 expect(at.every((n) => n >= 0)).toBe(true);
@@ -80,7 +131,6 @@ describe("the next-steps card of a spawned forge", () => {
80131 const apps = [...card.matchAll(/href="(\/[^"#]+)"/g)].map((m) => m[1]!).filter((h) => h.startsWith("/marias/"));
81132 expect(apps).toContain("/marias/bakery");
82133 // Tell the crew: the card points at the composer, which must be on the page.
83 expect(card).toContain('href="#composer"');
84134 expect(home).toContain('id="composer"');
85135 // Make your everyday account: the form lives in the card, which posts to a route only (no GET page); settings answers.
86136 expect(card).toContain('action="/settings/everyday"');
@@ -120,8 +170,10 @@ describe("the next-steps card of a spawned forge", () => {
120170 });
121171 expect(res.status).toBe(303);
122172 const daily = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
123 expect(steps(await page(owner)).everyday).toBe("yes");
124 expect(steps(await page(daily)).everyday).toBe("yes");
125 expect(keys(await page(owner))).toEqual(["everyday", "apps", "tell"]);
173 // The earlier tests told the crew and opened an app: with the account made, every step is done and the card is gone.
174 const user = getUserByName(forge.child.db, "maria")!;
175 expect((await nextStepsFor(forge.child, user))?.map((x) => x.done)).toEqual([true, true, true]);
176 expect(await page(owner)).not.toContain(CARD);
177 expect(await page(daily)).not.toContain(CARD);
126178 });
127179 });
test/spawn-child-apps.test.ts +1 −1
@@ -179,7 +179,7 @@ describe("a spawned child's apps", () => {
179179 expect(home).toContain("bakery");
180180 expect(home).not.toContain("No apps yet");
181181 expect(home).toContain("The crew needs a model key to start");
182 expect(home).toContain('action="/settings/crew/key"');
182 expect(home).toContain('href="/settings/subscriptions"');
183183 const appPage = await (
184184 await fetch(`${servers.url}/marias/bakery`, { headers: { cookie } })
185185 ).text();
test/tui-first-tell.test.ts added +55
@@ -0,0 +1,55 @@
1 /** The terminal's home of a new forge: before the first tell, "what do you want built" and no everyday offer; after it, the offer. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { getUserById } from "../src/core/users.ts";
4 import { createForgeApp } from "../src/tui/forge.ts";
5 import { headlessTerminal } from "../src/tui/term/terminal.ts";
6 import { claimedForge, type ClaimedForge } from "./helpers/claimed-forge.ts";
7 import { quietLogs } from "./helpers/globals.ts";
8 import { settleApp } from "./helpers/tui.ts";
9
10 quietLogs();
11
12 let forge: ClaimedForge;
13 beforeAll(async () => {
14 forge = await claimedForge("forge-tui-first-tell-");
15 }, 60_000);
16 afterAll(() => forge.stop());
17
18 function boot() {
19 const row = forge.child.db.query<{ id: string }, []>("SELECT id FROM user WHERE is_admin = 1 AND is_agent = 0 AND kind = 'user' LIMIT 1").get()!;
20 const user = getUserById(forge.child.db, row.id)!;
21 const { app } = createForgeApp(
22 forge.child,
23 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user },
24 { terminal: headlessTerminal(200, 32), exitHooks: false },
25 );
26 app.start();
27 const key = async (k: string) => {
28 app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
29 await settleApp(app);
30 };
31 return { app, key, text: () => app.screen.toText() };
32 }
33
34 test("before the first tell the home says what to do and offers no everyday account; after it, the offer", async () => {
35 const h = boot();
36 await settleApp(h.app);
37 expect(h.text()).toContain("What do you want built?");
38 expect(h.text()).toContain("included credit.");
39 expect(h.text()).not.toContain("K keeps using admin");
40
41 await h.key("t");
42 for (const ch of "A weekly menu page") await h.key(ch);
43 await h.key("enter");
44 expect(forge.child.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM thought").get()!.n).toBe(1);
45 h.app.stop();
46
47 // Once the crew has made an issue of it, the home is the everyday one and offers the account.
48 const told = await forge.post(forge.owner, "/marias/bakery/tell", { text: "Add a weekly menu page." });
49 expect(told.status).toBeLessThan(400);
50 const after = boot();
51 await settleApp(after.app);
52 expect(after.text()).not.toContain("What do you want built?");
53 expect(after.text()).toContain("K keeps using admin");
54 after.app.stop();
55 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs