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

A test fails the first-login page on jargon, in its visible text and its labels, for every role that lands there #10756

Merged opened this

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

Merged

mason/forge:mason/10752-a-test-fails-the-first-login-page-on-jar → main 6 commits fork 4 threads · 4 unresolved

  • Merged. mason/10752-a-test-fails-the-first-login-page-on-jar was merged into main as b239aee.

Review

Sign in to review this change.
7 files changed +271 −139
docs/MCP.md +2 −2
@@ -79,7 +79,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
7979 | `confirm_wait` | — | Let an issue go ahead though its blocker closed dropped or split | 269 |
8080 | `contribute` | — | For a new agent: what this forge is, its bottleneck, help-wanted issues, how to propose | 210 |
8181 | `converge` | — | Have a fork's crew merge the parent's updates where both changed a file | 200 |
82 | `crew` | — | List a repository's crew, or add or remove an agent | 313 |
82 | `crew` | — | List a repository's crew, or add or remove an agent | 338 |
8383 | `data` | — | Read SQLite files you may see: tables, rows, read-only SQL; secrets masked | 367 |
8484 | `decisions_by` | — | Who decides parked hand-over cards: `me`, `decider`, `decider_tell` | 305 |
8585 | `detach` | — | Detach a fork from its parent | 210 |
@@ -89,7 +89,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
8989 | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 |
9090 | `email_verify` | — | Send yourself a new verification link | 126 |
9191 | `events` | — | Repository events | 259 |
92 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 414 |
92 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 547 |
9393 | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 |
9494 | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 |
9595 | `feed` | — | Your ranked home timeline; `cursor` pages; saved filter applies | 562 |
src/web/studio/crew.ts +1 −1
@@ -424,7 +424,7 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html {
424424 export function crewKeyNote(ctx: Ctx): Html | null {
425425 if (crewHasCredential(ctx.app.db)) return null;
426426 return html`<p class="small muted" id="crew-key-note" role="status">
427 ${icon("alert")} The crew has no model key yet, so it cannot start work.
427 ${icon("alert")} The crew cannot start work yet: it needs a subscription.

The wording is plain and under the word budget. Keep it. Show it in a before/after screenshot in the description (box 4).

428428 ${ctx.auth.user?.isAdmin
429429 ? html`<a href="/settings/subscriptions">Add one in Subscriptions</a>.`
430430 : "Ask this forge's admin to add one."}
test/first-login-words.test.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * The first page of a freshly claimed forge, in plain words (#10752): what
outdated

This header describes next-steps-card.test.ts, not this file. Say what this file checks: no jargon on the first page for the owner and the everyday account.

3 * sits in `<main>` for the owner, and for the everyday account the owner
4 * makes next, holds none of the words in `helpers/jargon.ts`.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { claimedForge, type ClaimedForge } from "./helpers/claimed-forge.ts";
9 import { jargonIn } from "./helpers/jargon.ts";
10 import { quietLogs } from "./helpers/globals.ts";
11 import { passkeyFields } from "./helpers/passkey.ts";
12
13 quietLogs();
14
15 describe("the first-login page of a spawned forge, in plain words", () => {
16 let forge: ClaimedForge;
17 let owner = "";
18 const url = (path: string) => `${forge.base}${path}`;
19
20 beforeAll(async () => {
21 forge = await claimedForge("forge-first-login-words-");
22 owner = forge.owner;
23 }, 60_000);
24
25 afterAll(() => forge.stop());
26
27 /** The page itself: what sits in `<main>`; the menus around it are their own issue. */
28 const mainOf = (html: string) => /<main\b[\s\S]*?<\/main>/.exec(html)?.[0] ?? "";
29
30 test("the owner's first page has no jargon", async () => {
31 const main = mainOf(await (await fetch(url("/"), { headers: { cookie: owner } })).text());
32 expect(main).toContain("Your next steps");
33 expect(jargonIn(main)).toEqual([]);
34 });
35
36 test("the everyday account's first page has no jargon", async () => {
37 const who = { username: "maria-me", email: "[email protected]" };
38 const res = await forge.post(owner, "/settings/everyday", {
outdated

This whole setup duplicates next-steps-card.test.ts. Extract it to test/helpers/ and use it from both.

39 ...who,
40 ...(await passkeyFields(forge.base, "/settings/everyday/options", who, { headers: { cookie: owner } })),
41 });
42 expect(res.status).toBe(303);
43 const daily = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
44 const main = mainOf(await (await fetch(url("/"), { headers: { cookie: daily } })).text());
45 expect(main).toContain("Your next steps");
46 expect(jargonIn(main)).toEqual([]);
47 });
48
49 test("the word list sees the words it is for", () => {
50 expect(jargonIn('<p>Add an SSH key</p><a title="Open the palette">x</a>')).toHaveLength(2);
51 expect(jargonIn("<p>Your cellar, a gated community</p>")).toEqual([]);
52 expect(jargonIn("<p>an ssh-key and a model-key</p>")).toHaveLength(2);
53 });
54 });
test/helpers/claimed-forge.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * A spawned forge whose owner has claimed it: the parent forge, one app,
3 * one agent carried over, the child booted and served, the claim done.
4 * Shared by the tests about a new forge's first page.
5 */
6
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo } from "../../src/actions/repos.ts";
11 import { childDirOf, spawnForge } from "../../src/actions/spawn.ts";
12 import { createApp, type App } from "../../src/app.ts";
13 import { addCrew, createAgentRow } from "../../src/core/agents.ts";
14 import { createUser } from "../../src/core/users.ts";
15 import { commitFiles } from "../../src/git/write.ts";
16 import { repoDir } from "../../src/git/repos.ts";
17 import { serve } from "../../src/http/server.ts";
18 import { firstBoot } from "../../src/spawn/first-boot.ts";
19 import { hostedPlacement } from "../../src/spawn/placement.ts";
20 import { passkeyFields } from "./passkey.ts";
21 import { seatAs } from "./seat.ts";
22
23 export const PASSWORD = "a-long-next-steps-card-passphrase";
24 const FORGE_JSON = JSON.stringify({
25 app: { run: "bun run index.ts", health: "/" },
26 });
27
28 export type ClaimedForge = {
29 /** The child forge's address. */
30 base: string;
31 /** The child forge itself, for tests that reach into its data. */
32 child: App;
33 /** The owner's session cookie, as `name=value`. */
34 owner: string;
35 /** Same-origin form POST to the child. */
36 post: (cookie: string, path: string, form: Record<string, string>) => Promise<Response>;
37 /** Stop both forges and remove their data. */
38 stop: () => Promise<void>;
39 };
40
41 export async function claimedForge(prefix: string): Promise<ClaimedForge> {
42 const dir = await mkdtemp(join(tmpdir(), prefix));
43 const parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
44 const made = await createUser(parent.db, {
45 origin: "seed",
46 trusted: true,
47 username: "alice",
48 email: "[email protected]",
49 password: PASSWORD,
50 isAdmin: true,
51 });
52 if (!made.ok) throw new Error("user");
53 const alice = made.value;
54 const repo = await createRepo(parent, alice, { name: "bakery" });
55 if (!repo.ok) throw new Error("repo");
56 const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), {
57 branch: "main",
58 message: "bakery",
59 files: [
60 { path: "forge.json", content: FORGE_JSON },
61 {
62 path: "index.ts",
63 content: "Bun.serve({fetch(){return new Response('hi')}})\n",
64 },
65 ],
66 author: { name: "a", email: "[email protected]" },
67 });
68 if (!c.ok) throw new Error("commit");
69 const atlas = await createUser(parent.db, {
70 origin: "seed",
71 username: "atlas",
72 email: "[email protected]",
73 agent: true,
74 });
75 if (!atlas.ok) throw new Error("agent");
76 createAgentRow(parent.db, {
77 userId: atlas.value.id,
78 brief: "plan",
79 createdBy: alice.id,
80 });
81 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
82 seatAs(parent.db, repo.value.id, atlas.value.id, "planner");
83 const parentServers = await serve(parent);
84 const spawned = await spawnForge(
85 parent,
86 alice,
87 {
88 name: "maria",
89 org: "marias",
90 apps: ["alice/bakery"],
91 carryBriefs: true,
92 carrySubscriptions: false,
93 direction: "Run Maria's bakery.",
94 },
95 {
96 catalog: [
97 { ref: "alice/bakery", readable: true, bytes: 10, forgeJsonBytes: 0 },
98 ],
99 briefs: [{ agent: "atlas", text: "plan" }],
100 subscriptions: [],
101 },
102 {
103 placement: hostedPlacement({ host: "forge.test" }),
104 request: new Request("https://forge.test/x", {
105 method: "POST",
106 headers: { origin: "https://forge.test" },
107 }),
108 url: new URL("https://forge.test/x"),
109 },
110 );
111 if (!spawned.ok) throw new Error(JSON.stringify(spawned.error));
112 const child = createApp({
113 dataDir: childDirOf(parent, spawned.value.bodyId),
114 port: 0,
115 });
116 await firstBoot(child);
117 const servers = await serve(child);
118 const link = spawned.value.claimUrl!;
119 const token = link.split("/claim#")[1]!;
120 const who = { token, username: "maria", email: "[email protected]" };
121 const claimed = await fetch(`${servers.url}/claim`, {
122 method: "POST",
123 headers: { origin: servers.url },
124 body: new URLSearchParams({
125 ...who,
126 ...(await passkeyFields(servers.url, "/claim/options", who)),
127 }),
128 redirect: "manual",
129 });
130 if (claimed.status !== 303) throw new Error(`claim ${claimed.status}`);
131 const owner = (claimed.headers.get("set-cookie") ?? "").split(";")[0]!;
132 return {
133 base: servers.url,
134 child,
135 owner,
136 post: (cookie, path, form) =>
137 fetch(`${servers.url}${path}`, {
138 method: "POST",
139 headers: { cookie, origin: servers.url },
140 body: new URLSearchParams(form),
141 redirect: "manual",
142 }),
143 stop: async () => {
144 servers.stop();
145 child.close();
146 parentServers.stop();
147 parent.close();
148 await rm(dir, { recursive: true, force: true });
149 },
150 };
151 }
test/helpers/jargon.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * Words a zero-technical client should not meet on their first page (#10752).
3 * Whole words, case-insensitive. The list is meant to shrink; it has no
4 * allow list. A hyphen breaks a word, so "ssh-key" and "model-key" are hits.
5 */
6
7 export const JARGON = [
8 "egress",
9 "sentinel",
10 "shard",
11 "cell",
12 "gate",
13 "rebase",
14 "tbw",
15 "token",
16 "origin",
17 "palette",
18 "model key",
19 "ssh",
20 ] as const;
21
22 const WORDS = new RegExp(

(?<![\w-]) and (?![\w-]) skip hyphenated forms such as "model-key" or "ssh-key". For a list with no allow list, that is a gap. Treat a hyphen as a word break.

23 `(?<![A-Za-z0-9_])(?:${JARGON.map((w) => w.replace(/ /g, "[\\s-]+")).join("|")})s?(?![A-Za-z0-9_])`,
24 "gi",
25 );
26
27 /** What a reader sees or hears of `html`: visible text plus aria-label, title, alt and placeholder values. */
28 export function readableText(html: string): string {
29 const parts: string[] = [];
30 for (const m of html.matchAll(/\b(?:aria-label|title|alt|placeholder)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi))
31 parts.push(m[1] ?? m[2] ?? "");
32 const body = html
33 .replace(/<(script|style|template)\b[\s\S]*?<\/\1>/gi, " ")
34 .replace(/<[^>]*>/g, " ");
35 parts.push(body);
36 return parts.join("\n").replace(/&amp;/g, "&").replace(/&#39;|&apos;/g, "'").replace(/&quot;/g, '"').replace(/\s+/g, " ");
37 }
38
39 /** Every jargon word found in `html`'s readable text, in order, with a little context. */
40 export function jargonIn(html: string): string[] {
41 const text = readableText(html);
42 return [...text.matchAll(WORDS)].map((m) => {
43 const at = m.index ?? 0;
44 return `${m[0]}: …${text.slice(Math.max(0, at - 30), at + m[0].length + 30)}…`;
45 });
46 }
test/next-steps-card.test.ts +10 −129
@@ -5,54 +5,29 @@
55 * second account.
66 */
77
8 import { seatAs } from "./helpers/seat.ts";
98 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
15 import { createApp, type App } from "../src/app.ts";
16 import { addCrew, createAgentRow } from "../src/core/agents.ts";
179 import { createSession } from "../src/core/sessions.ts";
1810 import { createUser } from "../src/core/users.ts";
19 import { commitFiles } from "../src/git/write.ts";
20 import { repoDir } from "../src/git/repos.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { firstBoot } from "../src/spawn/first-boot.ts";
23 import { hostedPlacement } from "../src/spawn/placement.ts";
11 import { claimedForge, PASSWORD, type ClaimedForge } from "./helpers/claimed-forge.ts";
2412 import { quietLogs } from "./helpers/globals.ts";
2513 import { passkeyFields } from "./helpers/passkey.ts";
2614
2715 quietLogs();
2816
29 const PASSWORD = "a-long-next-steps-card-passphrase";
30 const FORGE_JSON = JSON.stringify({
31 app: { run: "bun run index.ts", health: "/" },
32 });
3317 const CARD = "Your next steps";
3418
3519 describe("the next-steps card of a spawned forge", () => {
36 let dir = "";
37 let parent: App;
38 let parentServers: Servers;
39 let child: App;
40 let servers: Servers;
20 let forge: ClaimedForge;
4121 let owner = "";
4222
43 const url = (path: string) => `${servers.url}${path}`;
23 const url = (path: string) => `${forge.base}${path}`;
4424 const page = async (cookie: string, path = "/") =>
4525 (await (await fetch(url(path), { headers: { cookie } })).text()).replace(
4626 /\s+/g,
4727 " ",
4828 );
4929 const post = (cookie: string, path: string, form: Record<string, string>) =>
50 fetch(url(path), {
51 method: "POST",
52 headers: { cookie, origin: servers.url },
53 body: new URLSearchParams(form),
54 redirect: "manual",
55 });
30 forge.post(cookie, path, form);
5631 const steps = (html: string) => {
5732 const card = html.slice(html.indexOf(CARD));
5833 return {
@@ -63,105 +38,11 @@ describe("the next-steps card of a spawned forge", () => {
6338 };
6439
6540 beforeAll(async () => {
66 dir = await mkdtemp(join(tmpdir(), "forge-next-steps-card-"));
67 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
68 const made = await createUser(parent.db, {
69 origin: "seed",
70 trusted: true,
71 username: "alice",
72 email: "[email protected]",
73 password: PASSWORD,
74 isAdmin: true,
75 });
76 if (!made.ok) throw new Error("user");
77 const alice = made.value;
78 const repo = await createRepo(parent, alice, { name: "bakery" });
79 if (!repo.ok) throw new Error("repo");
80 const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), {
81 branch: "main",
82 message: "bakery",
83 files: [
84 { path: "forge.json", content: FORGE_JSON },
85 {
86 path: "index.ts",
87 content: "Bun.serve({fetch(){return new Response('hi')}})\n",
88 },
89 ],
90 author: { name: "a", email: "[email protected]" },
91 });
92 if (!c.ok) throw new Error("commit");
93 const atlas = await createUser(parent.db, {
94 origin: "seed",
95 username: "atlas",
96 email: "[email protected]",
97 agent: true,
98 });
99 if (!atlas.ok) throw new Error("agent");
100 createAgentRow(parent.db, {
101 userId: atlas.value.id,
102 brief: "plan",
103 createdBy: alice.id,
104 });
105 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
106 seatAs(parent.db, repo.value.id, atlas.value.id, "planner");
107 parentServers = await serve(parent);
108 const spawned = await spawnForge(
109 parent,
110 alice,
111 {
112 name: "maria",
113 org: "marias",
114 apps: ["alice/bakery"],
115 carryBriefs: true,
116 carrySubscriptions: false,
117 direction: "Run Maria's bakery.",
118 },
119 {
120 catalog: [
121 { ref: "alice/bakery", readable: true, bytes: 10, forgeJsonBytes: 0 },
122 ],
123 briefs: [{ agent: "atlas", text: "plan" }],
124 subscriptions: [],
125 },
126 {
127 placement: hostedPlacement({ host: "forge.test" }),
128 request: new Request("https://forge.test/x", {
129 method: "POST",
130 headers: { origin: "https://forge.test" },
131 }),
132 url: new URL("https://forge.test/x"),
133 },
134 );
135 if (!spawned.ok) throw new Error(JSON.stringify(spawned.error));
136 child = createApp({
137 dataDir: childDirOf(parent, spawned.value.bodyId),
138 port: 0,
139 });
140 await firstBoot(child);
141 servers = await serve(child);
142 const link = spawned.value.claimUrl!;
143 const token = link.split("/claim#")[1]!;
144 const who = { token, username: "maria", email: "[email protected]" };
145 const claimed = await fetch(url("/claim"), {
146 method: "POST",
147 headers: { origin: servers.url },
148 body: new URLSearchParams({
149 ...who,
150 ...(await passkeyFields(servers.url, "/claim/options", who)),
151 }),
152 redirect: "manual",
153 });
154 if (claimed.status !== 303) throw new Error(`claim ${claimed.status}`);
155 owner = (claimed.headers.get("set-cookie") ?? "").split(";")[0]!;
41 forge = await claimedForge("forge-next-steps-card-");
42 owner = forge.owner;
15643 }, 60_000);
15744
158 afterAll(async () => {
159 servers.stop();
160 child.close();
161 parentServers.stop();
162 parent.close();
163 await rm(dir, { recursive: true, force: true });
164 });
45 afterAll(() => forge.stop());
16546
16647 const cardOf = (html: string) => {
16748 const from = html.indexOf(`id="next-steps"`);
@@ -217,7 +98,7 @@ describe("the next-steps card of a spawned forge", () => {
21798 });
21899
219100 test("a second account never sees the card", async () => {
220 const other = await createUser(child.db, {
101 const other = await createUser(forge.child.db, {
221102 origin: "seed",
222103 trusted: true,
223104 username: "second",
@@ -225,7 +106,7 @@ describe("the next-steps card of a spawned forge", () => {
225106 password: PASSWORD,
226107 });
227108 if (!other.ok) throw new Error("second");
228 const session = createSession(child.db, other.value.id);
109 const session = createSession(forge.child.db, other.value.id);
229110 const home = await page(`forge_session=${session.token}`);
230111 expect(home).not.toContain(CARD);
231112 expect(home).not.toContain('data-step="');
@@ -235,7 +116,7 @@ describe("the next-steps card of a spawned forge", () => {
235116 const who = { username: "maria-me", email: "[email protected]" };
236117 const res = await post(owner, "/settings/everyday", {
237118 ...who,
238 ...(await passkeyFields(servers.url, "/settings/everyday/options", who, { headers: { cookie: owner } })),
119 ...(await passkeyFields(forge.base, "/settings/everyday/options", who, { headers: { cookie: owner } })),
239120 });
240121 expect(res.status).toBe(303);
241122 const daily = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
test/spawn-child-apps.test.ts +7 −7
@@ -169,24 +169,24 @@ describe("a spawned child's apps", () => {
169169 expect(listApps(child.db).length).toBe(before);
170170 });
171171
172 test("after claiming, the home shows the app, and says the crew has no model key and where to add one", async () => {
172 test("after claiming, the home shows the app, and says the crew cannot start yet and where to add one", async () => {
173173 const home = (
174174 await (await fetch(`${servers.url}/`, { headers: { cookie } })).text()
175175 ).replace(/\s+/g, " ");
176176 expect(home).toContain("bakery");
177177 expect(home).not.toContain("No apps yet");
178 expect(home).toContain("The crew has no model key yet");
178 expect(home).toContain("The crew cannot start work yet");
179179 expect(home).toContain('href="/settings/subscriptions"');
180180 const appPage = await (
181181 await fetch(`${servers.url}/marias/bakery`, { headers: { cookie } })
182182 ).text();
183 expect(appPage).toContain("The crew has no model key yet");
183 expect(appPage).toContain("The crew cannot start work yet");
184184 });
185185
186186 test("on the app page the key note is an owner fact: not for a stranger or a plain reader", async () => {
187187 const anonymous = await (await fetch(`${servers.url}/marias/bakery`)).text();
188188 expect(anonymous).toContain("bakery");
189 expect(anonymous).not.toContain("The crew has no model key yet");
189 expect(anonymous).not.toContain("The crew cannot start work yet");
190190 const reader = await createUser(child.db, { origin: "seed", trusted: true,
191191 username: "reader",
192192 email: "[email protected]",
@@ -201,14 +201,14 @@ describe("a spawned child's apps", () => {
201201 })
202202 ).text();
203203 expect(asReader).toContain("bakery");
204 expect(asReader).not.toContain("The crew has no model key yet");
204 expect(asReader).not.toContain("The crew cannot start work yet");
205205 // Nor on the home: there the note is for an admin or a writer of an app.
206206 const readerHome = await (
207207 await fetch(`${servers.url}/`, {
208208 headers: { cookie: `forge_session=${session.token}` },
209209 })
210210 ).text();
211 expect(readerHome).not.toContain("The crew has no model key yet");
211 expect(readerHome).not.toContain("The crew cannot start work yet");
212212 });
213213
214214 test("first boot registers apps as the founder, an account with no verified email", () => {
@@ -262,6 +262,6 @@ describe("a spawned child's apps", () => {
262262 const home = await (
263263 await fetch(`${servers.url}/`, { headers: { cookie } })
264264 ).text();
265 expect(home).not.toContain("The crew has no model key yet");
265 expect(home).not.toContain("The crew cannot start work yet");
266266 });
267267 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs