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

A test pins a spawned forge's first-login card: three next steps, each a link its admin reaches #6843

Merged opened this

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

Merged

scout/forge:scout/6829-a-test-pins-a-spawned-forge-s-first-logi → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/6829-a-test-pins-a-spawned-forge-s-first-logi was merged into main as 9b7a690.

Review

Sign in to review this change.
1 file changed +233 −0
test/next-steps-card.test.ts added +233
@@ -0,0 +1,233 @@
1 /**
2 * The first-login card (VISION §4, `web/pages/next-steps.ts`): three steps in
3 * order, each reachable by the admin it is drawn for, the everyday step
4 * reading done once the account exists, and never shown to a guest or to a
5 * second account.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
16 import { createSession } from "../src/core/sessions.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { commitFiles } from "../src/git/write.ts";
19 import { repoDir } from "../src/git/repos.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { firstBoot } from "../src/spawn/first-boot.ts";
22 import { hostedPlacement } from "../src/spawn/placement.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { passkeyFields } from "./helpers/passkey.ts";
25
26 quietLogs();
27
28 const PASSWORD = "a-long-next-steps-card-passphrase";
29 const FORGE_JSON = JSON.stringify({
30 app: { run: "bun run index.ts", health: "/" },
31 });
32 const CARD = "Your next steps";
33
34 describe("the next-steps card of a spawned forge", () => {
35 let dir = "";
36 let parent: App;
37 let parentServers: Servers;
38 let child: App;
39 let servers: Servers;
40 let owner = "";
41
42 const url = (path: string) => `${servers.url}${path}`;
43 const page = async (cookie: string, path = "/") =>
44 (await (await fetch(url(path), { headers: { cookie } })).text()).replace(
45 /\s+/g,
46 " ",
47 );
48 const post = (cookie: string, path: string, form: Record<string, string>) =>
49 fetch(url(path), {
50 method: "POST",
51 headers: { cookie, origin: servers.url },
52 body: new URLSearchParams(form),
53 redirect: "manual",
54 });
55 const steps = (html: string) => {
56 const card = html.slice(html.indexOf(CARD));
57 return {
58 everyday: /data-step="everyday"[^>]*data-done="(yes|no)"/.exec(card)?.[1],
59 apps: /data-step="apps"[^>]*data-done="(yes|no)"/.exec(card)?.[1],
60 tell: /data-step="tell"[^>]*data-done="(yes|no)"/.exec(card)?.[1],
61 };
62 };
63
64 beforeAll(async () => {
65 dir = await mkdtemp(join(tmpdir(), "forge-next-steps-card-"));
66 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
67 const made = await createUser(parent.db, {
68 origin: "seed",
69 trusted: true,
70 username: "alice",
71 email: "[email protected]",
72 password: PASSWORD,
73 isAdmin: true,
74 });
75 if (!made.ok) throw new Error("user");
76 const alice = made.value;
77 const repo = await createRepo(parent, alice, { name: "bakery" });
78 if (!repo.ok) throw new Error("repo");
79 const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), {
80 branch: "main",
81 message: "bakery",
82 files: [
83 { path: "forge.json", content: FORGE_JSON },
84 {
85 path: "index.ts",
86 content: "Bun.serve({fetch(){return new Response('hi')}})\n",
87 },
88 ],
89 author: { name: "a", email: "[email protected]" },
90 });
91 if (!c.ok) throw new Error("commit");
92 const atlas = await createUser(parent.db, {
93 origin: "seed",
94 username: "atlas",
95 email: "[email protected]",
96 agent: true,
97 });
98 if (!atlas.ok) throw new Error("agent");
99 createAgentRow(parent.db, {
100 userId: atlas.value.id,
101 brief: "plan",
102 createdBy: alice.id,
103 });
104 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
105 setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner");
106 parentServers = await serve(parent);
107 const spawned = await spawnForge(
108 parent,
109 alice,
110 {
111 name: "maria",
112 org: "marias",
113 apps: ["alice/bakery"],
114 carryBriefs: true,
115 carrySubscriptions: false,
116 direction: "Run Maria's bakery.",
117 },
118 {
119 catalog: [
120 { ref: "alice/bakery", readable: true, bytes: 10, forgeJsonBytes: 0 },
121 ],
122 briefs: [{ agent: "atlas", text: "plan" }],
123 subscriptions: [],
124 },
125 {
126 placement: hostedPlacement({ host: "forge.test" }),
127 request: new Request("https://forge.test/x", {
128 method: "POST",
129 headers: { origin: "https://forge.test" },
130 }),
131 url: new URL("https://forge.test/x"),

Same fixture as first-login.test.ts (spawn, firstBoot, claim). Extract to a shared helper.

132 },
133 );
134 if (!spawned.ok) throw new Error(JSON.stringify(spawned.error));
135 child = createApp({
136 dataDir: childDirOf(parent, spawned.value.bodyId),
137 port: 0,
138 });
139 await firstBoot(child);
140 servers = await serve(child);
141 const link = spawned.value.claimUrl!;
142 const token = link.split("/claim#")[1]!;
143 const who = { token, username: "maria", email: "[email protected]" };
144 const claimed = await fetch(url("/claim"), {
145 method: "POST",
146 headers: { origin: servers.url },
147 body: new URLSearchParams({
148 ...who,

Calls indexOf("</section>", from) twice and runs past the line width; compute once into a const.

149 ...(await passkeyFields(servers.url, "/claim/options", who)),
150 }),
151 redirect: "manual",
152 });
153 if (claimed.status !== 303) throw new Error(`claim ${claimed.status}`);
154 owner = (claimed.headers.get("set-cookie") ?? "").split(";")[0]!;
155 }, 60_000);
156
157 afterAll(async () => {
158 servers.stop();
159 child.close();
160 parentServers.stop();
161 parent.close();
162 await rm(dir, { recursive: true, force: true });
163 });
164
165 const cardOf = (html: string) => {
166 const from = html.indexOf(`id="next-steps"`);
167 return from < 0 ? "" : html.slice(from, html.indexOf("</section>", from) < 0 ? undefined : html.indexOf("</section>", from));
168 };
169 const keys = (html: string) =>
170 [...cardOf(html).matchAll(/data-step="(\w+)"/g)].map((m) => m[1]);
171
172 test("the admin's first home carries exactly three steps, in order, none done", async () => {
173 const home = await page(owner);
174 expect(keys(home)).toEqual(["everyday", "apps", "tell"]);
175 expect(steps(home)).toEqual({ everyday: "no", apps: "no", tell: "no" });
176 const titles = ["Make your everyday account", "Open your apps", "Tell the crew something"];
177 const at = titles.map((t) => cardOf(home).indexOf(t));
178 expect(at.every((n) => n >= 0)).toBe(true);
179 expect([...at].sort((a, b) => a - b)).toEqual(at);
180 });
181
182 test("each step's way in answers 200 for the admin", async () => {
183 const home = await page(owner);
184 const card = cardOf(home);
185 // Open your apps: the app links in the card.
186 const apps = [...card.matchAll(/href="(\/[^"#]+)"/g)].map((m) => m[1]!).filter((h) => h.startsWith("/marias/"));
187 expect(apps).toContain("/marias/bakery");
188 // Tell the crew: the card points at the composer, which must be on the page.
189 expect(card).toContain('href="#composer"');
190 expect(home).toContain('id="composer"');
191 // Make your everyday account: the form lives in the card, which posts to a route only (no GET page); settings answers.
192 expect(card).toContain('action="/settings/everyday"');
193 const paths = [...new Set([...apps, "/settings"])];
194 for (const p of paths) {
195 const res = await fetch(url(p), { headers: { cookie: owner }, redirect: "manual" });
196 expect([p, res.status]).toEqual([p, 200]);
197 }
198 });
199
200 test("a guest never sees the card", async () => {
201 const res = await fetch(url("/"), { redirect: "manual" });
202 expect(res.status).toBeLessThan(400);
203 expect(await res.text()).not.toContain(CARD);
204 });
205
206 test("a second account never sees the card", async () => {
207 const other = await createUser(child.db, {
208 origin: "seed",
209 trusted: true,
210 username: "second",
211 email: "[email protected]",
212 password: PASSWORD,
213 });
214 if (!other.ok) throw new Error("second");
215 const session = createSession(child.db, other.value.id);
216 const home = await page(`forge_session=${session.token}`);
217 expect(home).not.toContain(CARD);
218 expect(home).not.toContain('data-step="');
219 });
220
221 test("once the everyday account exists, its step reads done", async () => {
222 const who = { username: "maria-me", email: "[email protected]" };
223 const res = await post(owner, "/settings/everyday", {
224 ...who,
225 ...(await passkeyFields(servers.url, "/settings/everyday/options", who, { headers: { cookie: owner } })),
226 });
227 expect(res.status).toBe(303);
228 const daily = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
229 expect(steps(await page(owner)).everyday).toBe("yes");
230 expect(steps(await page(daily)).everyday).toBe("yes");
231 expect(keys(await page(owner))).toEqual(["everyday", "apps", "tell"]);
232 });
233 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs