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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11173-a-new-forge-s-first-screen-leads-with-on → main 9 commits fork 5 threads · 5 unresolved
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 | ||
| 10 | 10 | - **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. |
| 11 | 11 | |
| 12 | 12 | 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_ | ||
| 25 | 25 | import { clientAppRows, clientDoings, clientFinished, doingLine, finishedLine } from "../../core/client-apps.ts"; |
| 26 | 26 | import { appCrew } from "../../core/app-crew.ts"; |
| 27 | 27 | 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"; | |
| 28 | 31 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| 29 | 32 | import { lastVisit } from "../../core/home_visit.ts"; |
| 30 | 33 | import { sinceDigest } from "../../web/studio/since.ts"; |
| @@ -130,6 +133,9 @@ import { local } from "./where.ts"; | ||
| 130 | 133 | import { signInScreen } from "./signin.ts"; |
| 131 | 134 | |
| 132 | 135 | /** 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."; | |
| 138 | ||
| 133 | 139 | const FIGURES_ROOM = 6; |
| 134 | 140 | |
| 135 | 141 | /** 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 { | ||
| 231 | 237 | * name of the account used day to day. Nobody else gets the row. |
| 232 | 238 | */ |
| 233 | 239 | 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; | |
| 234 | 242 | /** One line per fired flow alarm; an admin reads every repository, anyone else public ones alone. */ |
| 235 | 243 | let alarms: string[] = []; |
| 236 | 244 | /** 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 { | ||
| 276 | 284 | |
| 277 | 285 | const loadClientLines = async (): Promise<void> => { |
| 278 | 286 | 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; | |
| 280 | 290 | // A client's home never carries the forge-wide figures, on either door. |
| 281 | 291 | 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 | } | |
| 282 | 299 | const apps = await clientAppRows(f.app, f.user); |
| 283 | 300 | clientRepoIds = new Set(apps.map((a) => a.repo.id)); |
| 284 | 301 | 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 { | ||
| 319 | 336 | }; |
| 320 | 337 | |
| 321 | 338 | 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; | |
| 323 | 340 | if (askForEveryday(f.db, f.user)) |
| 324 | 341 | return { line: `${EVERYDAY_WHY} E makes it on the web · K keeps using admin`, asking: true }; |
| 325 | 342 | const daily = everydayOf(f.db, f.user); |
| @@ -1016,7 +1033,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1016 | 1033 | disabled: focus !== "machine" || pins.length === 0, |
| 1017 | 1034 | }), |
| 1018 | 1035 | 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 | : []), | |
| 1020 | 1041 | ...(clientLive.size > 0 ? [binding(["o"], "open the app")] : []), |
| 1021 | 1042 | binding(["n"], "new repo", { disabled: !f.signedIn }), |
| 1022 | 1043 | binding(["M"], "migrate", { disabled: !f.signedIn }), |
| @@ -1264,6 +1285,23 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1264 | 1285 | } |
| 1265 | 1286 | return true; |
| 1266 | 1287 | } |
| 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 | } | |
| 1267 | 1305 | if (e.key === "t" && f.user && clientLines.length > 0) { |
| 1268 | 1306 | // A client's apps are the list; the one under the cursor is asked. |
| 1269 | 1307 | const repo = shown()[at("repos").index]; |
src/web/pages/next-steps.ts +22 −1
| @@ -7,7 +7,9 @@ | ||
| 7 | 7 | * primary action. Make your everyday account carries the form itself (it |
| 8 | 8 | * replaces #803's separate card here); open your apps lists them; tell the |
| 9 | 9 | * 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). | |
| 11 | 13 | */ |
| 12 | 14 | |
| 13 | 15 | import { appCrew } from "../../core/app-crew.ts"; |
| @@ -24,6 +26,10 @@ import { badge, btn, row } from "../ui.ts"; | ||
| 24 | 26 | import { tellHref } from "../studio/nouns.ts"; |
| 25 | 27 | import { everydayAsk } from "./everyday.ts"; |
| 26 | 28 | 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"; | |
| 27 | 33 | |
| 28 | 34 | /** One line per app: the app is the row's title, the crew member and what they are doing its meta. */ |
| 29 | 35 | function crewAtWork(ctx: Ctx, repos: Awaited<ReturnType<typeof spawnedRepos>>): Html[] { |
| @@ -57,6 +63,20 @@ function stepBlock(step: NextStep, primary: boolean, body: Html | null): Html { | ||
| 57 | 63 | ${!step.done && body ? html`<div class="step-body">${body}</div>` : null}`; |
| 58 | 64 | } |
| 59 | 65 | |
| 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. |
||
| 78 | } | |
| 79 | ||
| 60 | 80 | /** The card, or nothing: not a spawned forge, not its owner, or every step done. */ |
| 61 | 81 | export async function nextStepsCard( |
| 62 | 82 | ctx: Ctx, |
| @@ -66,6 +86,7 @@ export async function nextStepsCard( | ||
| 66 | 86 | if (!steps || steps.every((s) => s.done)) return null; |
| 67 | 87 | const repos = await spawnedRepos(ctx.app, user); |
| 68 | 88 | const crew = crewAtWork(ctx, repos); |
| 89 | if (steps.some((s) => s.key === "tell" && !s.done)) return firstTell(ctx, user, crew); | |
| 69 | 90 | const primary = steps.find((s) => !s.done)?.key; |
| 70 | 91 | const bodies: Record<NextStep["key"], Html | null> = { |
| 71 | 92 | 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 { | ||
| 52 | 52 | /** Whether the forge has a crew at all, so a line about it is true. */ |
| 53 | 53 | export const hasCrew = (app: App): boolean => listAgents(app.db).length > 0; |
| 54 | 54 | |
| 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 | ||
| 55 | 65 | const keyField = (): Html => html`<form class="stack" method="post" action="${KEY_ACTION}" id="crew-key-form"> |
| 56 | 66 | <div class="field"> |
| 57 | 67 | <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( | ||
| 319 | 319 | steps !== undefined |
| 320 | 320 | ) { |
| 321 | 321 | 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 ( |
||
| 323 | const firstTell = steps.some((s) => s.key === "tell" && !s.done); | |
| 322 | 324 | 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} | |
| 324 | 326 | <div class="stack"> |
| 325 | ${composer(ctx, user)} | |
| 327 | ${firstTell ? null : composer(ctx, user)} | |
| 326 | 328 | <p class="small muted"> |
| 327 | 329 | <a href="/?all=1">Show everything</a> |
| 328 | 330 | </p> |
| @@ -332,7 +334,7 @@ export async function studioHome( | ||
| 332 | 334 | title: "Home", |
| 333 | 335 | body: lean, |
| 334 | 336 | // The everyday step makes its account with a passkey (#1890). |
| 335 | ...(firstSteps ? { scriptJs: passkeyJs } : {}), | |
| 337 | ...(firstSteps && !firstTell ? { scriptJs: passkeyJs } : {}), | |
| 336 | 338 | }); |
| 337 | 339 | } |
| 338 | 340 | // 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", () => { | ||
| 29 | 29 | |
| 30 | 30 | test("the owner's first page has no jargon", async () => { |
| 31 | 31 | 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?"); | |
| 33 | 33 | expect(jargonIn(html)).toEqual([]); |
| 34 | 34 | }); |
| 35 | 35 | |
| @@ -42,6 +42,14 @@ describe("the first-login page of a spawned forge, in plain words", () => { | ||
| 42 | 42 | expect(res.status).toBe(303); |
| 43 | 43 | const daily = (res.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 44 | 44 | 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(); | |
| 45 | 53 | expect(mainOf(html)).toContain("Your next steps"); |
| 46 | 54 | expect(jargonIn(html)).toEqual([]); |
| 47 | 55 | }); |
test/first-login.test.ts +27 −25
| @@ -200,30 +200,40 @@ describe("the first login to a spawned forge", () => { | ||
| 200 | 200 | expect(await page(`forge_session=${session.token}`)).not.toContain(CARD); |
| 201 | 201 | }); |
| 202 | 202 | |
| 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); | |
| 204 | 225 | const home = await page(owner); |
| 205 | 226 | expect(home).toContain(CARD); |
| 206 | 227 | expect(home).toContain("Make your everyday account"); |
| 207 | 228 | expect(home).toContain("Open your apps"); |
| 208 | 229 | 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"'); | |
| 212 | 232 | 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/); | |
| 223 | 235 | expect(home).not.toMatch(/data-step[\s\S]{0,400}row-meta/); |
| 224 | 236 | 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"'); | |
| 227 | 237 | }); |
| 228 | 238 | |
| 229 | 239 | 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", () => { | ||
| 236 | 246 | daily = (res.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 237 | 247 | expect(getUserByName(child.db, "maria-me")).toBeDefined(); |
| 238 | 248 | 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" }); | |
| 240 | 250 | // It can read the client's own apps, and the crew line reads for it too. |
| 241 | 251 | expect(home).toContain('href="/marias/bakery"'); |
| 242 | 252 | expect(home).not.toContain("Your apps arrive here"); |
| @@ -274,14 +284,6 @@ describe("the first login to a spawned forge", () => { | ||
| 274 | 284 | child.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(bakery.direction_issue_id, atlasId); |
| 275 | 285 | }); |
| 276 | 286 | |
| 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 | ||
| 285 | 287 | test("the everyday account opens an app: the second step, and the card is gone for both accounts", async () => { |
| 286 | 288 | expect((await fetch(url("/marias/bakery"), { headers: { cookie: daily } })).status).toBe(200); |
| 287 | 289 | expect(await page(daily)).not.toContain(CARD); |
test/next-steps-card.test.ts +58 −6
| @@ -5,9 +5,12 @@ | ||
| 5 | 5 | * second account. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 8 | 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 11 | 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"; | |
| 11 | 14 | import { claimedForge, PASSWORD, type ClaimedForge } from "./helpers/claimed-forge.ts"; |
| 12 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 13 | 16 | import { passkeyFields } from "./helpers/passkey.ts"; |
| @@ -51,10 +54,58 @@ describe("the next-steps card of a spawned forge", () => { | ||
| 51 | 54 | const keys = (html: string) => |
| 52 | 55 | [...cardOf(html).matchAll(/data-step="(\w+)"/g)].map((m) => m[1]); |
| 53 | 56 | |
| 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 | ||
| 54 | 105 | test("the admin's first home carries exactly three steps, in order, none done", async () => { |
| 55 | 106 | const home = await page(owner); |
| 56 | 107 | 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" }); | |
| 58 | 109 | const titles = ["Make your everyday account", "Open your apps", "Tell the crew something"]; |
| 59 | 110 | const at = titles.map((t) => cardOf(home).indexOf(t)); |
| 60 | 111 | expect(at.every((n) => n >= 0)).toBe(true); |
| @@ -80,7 +131,6 @@ describe("the next-steps card of a spawned forge", () => { | ||
| 80 | 131 | const apps = [...card.matchAll(/href="(\/[^"#]+)"/g)].map((m) => m[1]!).filter((h) => h.startsWith("/marias/")); |
| 81 | 132 | expect(apps).toContain("/marias/bakery"); |
| 82 | 133 | // Tell the crew: the card points at the composer, which must be on the page. |
| 83 | expect(card).toContain('href="#composer"'); | |
| 84 | 134 | expect(home).toContain('id="composer"'); |
| 85 | 135 | // Make your everyday account: the form lives in the card, which posts to a route only (no GET page); settings answers. |
| 86 | 136 | expect(card).toContain('action="/settings/everyday"'); |
| @@ -120,8 +170,10 @@ describe("the next-steps card of a spawned forge", () => { | ||
| 120 | 170 | }); |
| 121 | 171 | expect(res.status).toBe(303); |
| 122 | 172 | 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); | |
| 126 | 178 | }); |
| 127 | 179 | }); |
test/spawn-child-apps.test.ts +1 −1
| @@ -179,7 +179,7 @@ describe("a spawned child's apps", () => { | ||
| 179 | 179 | expect(home).toContain("bakery"); |
| 180 | 180 | expect(home).not.toContain("No apps yet"); |
| 181 | 181 | 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"'); | |
| 183 | 183 | const appPage = await ( |
| 184 | 184 | await fetch(`${servers.url}/marias/bakery`, { headers: { cookie } }) |
| 185 | 185 | ).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 | }); |
Non-blocking: this new constant now splits the
Rows the list keeps...doc comment from FIGURES_ROOM. Put FIRST_TELL_LINE above that comment.