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

Check time under 2 minutes for a typical pull: Bun-native affected tests, real timings, split the two slowest files, fast git and SQLite in tests #2064

Merged opened this in answer to

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

Merged

torque/forge:torque/2038-check-time-under-2-minutes-for-a-typical → main 4 commits fork

  • Merged. torque/2038-check-time-under-2-minutes-for-a-typical was merged into main as 2cd52b7.

Review

Sign in to review this change.
25 files changed +10188 −9320
README.md +16 −2
@@ -738,12 +738,26 @@ bun run check:record # the same, and rewrites scripts/test-times.json
738738 `bun run check` is the gate: the same command `forge.json` declares, so what
739739 it says here is what the deploy will say. It type-checks once, then runs the
740740 test files `scripts/gate.ts` lists in `GATE` as parallel `bun test` shards
741 (`scripts/test-shards.ts`): one per CPU the process may use, at most eight,
742 and `FORGE_CHECK_SHARDS=2` turns that down on a small or busy host. Each
741 (`scripts/test-shards.ts`): one per CPU the process may use, no more than
742 one per 512 MiB of the memory its cgroup allows (16 on the 16-CPU, 8 GiB
743 check cell; the old ceiling of eight left half of it idle, #2038), and
744 `FORGE_CHECK_SHARDS=2` turns that down on a small or busy host. Each
743745 shard's output is written whole as it finishes; the log ends with every
744746 failing test from every shard, or on green with one line — how many tests,
745747 across how many files, in how long, then the slowest and fastest shard.
746748
749 Two things keep a test's setup in milliseconds (#2038). `bunfig.toml`
750 preloads `test/helpers/preload.ts`, which hashes passwords at argon2id's
751 floor (8 KiB, one pass) instead of Bun's default 64 MiB, two passes:
752 making an account and signing it in was ~700 ms of a typical `beforeAll`
753 and is now ~15 ms; the forge itself never loads it. And `openDb` keeps
754 the empty database each migration list makes: the second fresh database
755 in a process is a copy of it rather than ninety-odd migrations again
756 (`createApp` ~100 ms → ~6 ms). No test file runs one subject long
757 enough to set the gate's floor alone: the studio's and the apps
758 adversarial suites are split by subject into `test/studio*.test.ts` and
759 `test/apps-adversarial*.test.ts`.
760
747761 The check's first step is the gate-list audit (`bun scripts/gate.ts`), so a
748762 test file listed in neither `GATE` nor `EXCLUDED` fails in under a second,
749763 before install-heavy work. `tsc` then runs `--incremental` with a 2 GB heap,
bunfig.toml +3
@@ -2,3 +2,6 @@
22 # where a running forge keeps the crew's clones, each with its own tests.
33 [test]
44 root = "test"
5 # Argon2 at its cheapest under test: most of a typical beforeAll was
6 # hashing passwords (#2038). Production never loads it.
7 preload = ["./test/helpers/preload.ts"]
scripts/gate.ts +9
@@ -156,6 +156,11 @@ export const GATE: readonly string[] = [
156156 "test/autonomy.test.ts",
157157 "test/autonomy_sweep.test.ts",
158158 "test/studio.test.ts",
159 "test/studio-changes.test.ts",
160 "test/studio-door.test.ts",
161 "test/studio-runs.test.ts",
162 "test/studio-shipping.test.ts",
163 "test/studio-search.test.ts",
159164 "test/studio-words.test.ts",
160165 "test/converge-settled.test.ts",
161166 "test/following.test.ts",
@@ -179,6 +184,10 @@ export const GATE: readonly string[] = [
179184 "test/apps-slow-test.test.ts",
180185 "test/apps-tests-clause.test.ts",
181186 "test/apps-adversarial.test.ts",
187 "test/apps-adversarial-teardown.test.ts",
188 "test/apps-adversarial-test-step.test.ts",
189 "test/apps-adversarial-grants.test.ts",
190 "test/apps-adversarial-rules.test.ts",
182191 "test/apps-busy.test.ts",
183192 "test/apps-token.test.ts",
184193 "test/contain.test.ts",
scripts/shards.ts +44 −5
@@ -7,22 +7,41 @@
77 * is disjoint and covering, and that one red shard makes the run red.
88 */
99
10 /** No more than this many `bun test` processes at once, whatever the host. */
11 export const SHARDS_MAX = 8;
10 /**
11 * No more than this many `bun test` processes at once, whatever the host:
12 * a ceiling for a big shared machine, not the check cell's number. The cell
13 * (16 CPUs, 8 GiB) is bounded by its CPUs and its memory (#2038); the
14 * old ceiling of 8 left half its CPUs idle.
15 */
16 export const SHARDS_MAX = 32;
1217
1318 /**
14 * How many shards: one per CPU the process may use, capped at
19 * Memory one `bun test` process is given room for: the heaviest suite peaks
20 * near 260 MiB of its own (test/studio.test.ts, measured with
21 * `resourceUsage().maxRSS`), and an apps suite's deploys add children of
22 * their own, so twice that. Fewer workers than CPUs on a cell whose memory
23 * would not hold one per CPU, rather than an OOM kill mid-gate (#386).
24 */
25 export const WORKER_MEMORY_BYTES = 512 * 1024 * 1024;
26
27 /**
28 * How many shards: one per CPU the process may use, no more than its
29 * memory holds at {@link WORKER_MEMORY_BYTES} each, capped at
1530 * {@link SHARDS_MAX} and at the number of files, at least one. One per CPU,
1631 * not fewer: most of a forge test's time is spent waiting on git children
17 * and timers, not computing. `FORGE_CHECK_SHARDS` overrides it (still
18 * capped at the file count) so a small or busy host can turn it down.
32 * and timers, not computing. `memory` absent is not a bound.
33 * `FORGE_CHECK_SHARDS` overrides it (still capped at the file count) so a
34 * small or busy host can turn it down.
1935 */
2036 export function shardCount(
2137 cpus: number,
2238 files: number,
2339 override?: string,
40 memory?: number,
2441 ): { ok: true; value: number } | { ok: false; error: string } {
2542 let n = Math.min(SHARDS_MAX, Math.floor(cpus));
43 if (memory !== undefined && Number.isFinite(memory) && memory > 0)
44 n = Math.min(n, Math.floor(memory / WORKER_MEMORY_BYTES));
2645 if (override !== undefined && override.trim() !== "") {
2746 if (!/^[1-9][0-9]{0,2}$/.test(override.trim()))
2847 return {
@@ -34,6 +53,26 @@ export function shardCount(
3453 return { ok: true, value: Math.max(1, Math.min(n, files)) };
3554 }
3655
56 /**
57 * The memory a process may use: the least of the machine's and each
58 * cgroup limit it can read (`memory.max` under v2, `memory.limit_in_bytes`
59 * under v1). "max", a v1 "no limit" (a page-rounded 2^63), an unreadable
60 * file (`undefined`) or anything not a whole number is no limit.
61 */
62 export function memoryLimit(
63 total: number,
64 limits: readonly (string | undefined)[],
65 ): number {
66 let least = total;
67 for (const text of limits) {
68 const value = text?.trim() ?? "";
69 if (!/^[0-9]{1,19}$/.test(value)) continue;
70 const bytes = Number(value);
71 if (bytes > 0 && bytes < least) least = bytes;
72 }
73 return least;
74 }
75
3776 /**
3877 * Splits `files` into at most `n` disjoint, non-empty slices that together
3978 * hold every file once. `weight` is how long a file is expected to take
scripts/test-shards.ts +13 −1
@@ -38,12 +38,13 @@
3838 * table is rewritten from those times when every file is green.
3939 */
4040
41 import { availableParallelism, tmpdir } from "node:os";
41 import { availableParallelism, tmpdir, totalmem } from "node:os";
4242 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
4343 import { join } from "node:path";
4444 import {
4545 formatTimes,
4646 heaviestFirst,
47 memoryLimit,
4748 overlayTimes,
4849 refreshTimes,
4950 parseShardSpec,
@@ -326,10 +327,21 @@ if (new Set(files).size !== files.length) {
326327 process.exit(2);
327328 }
328329
330 const readLimit = (path: string): string | undefined => {
331 try {
332 return readFileSync(path, "utf8");
333 } catch {
334 return undefined;
335 }
336 };
329337 const count = shardCount(
330338 availableParallelism(),
331339 files.length,
332340 process.env["FORGE_CHECK_SHARDS"],
341 memoryLimit(totalmem(), [
342 readLimit("/sys/fs/cgroup/memory.max"),
343 readLimit("/sys/fs/cgroup/memory/memory.limit_in_bytes"),
344 ]),
333345 );
334346 if (!count.ok) {
335347 console.error(`test-shards: ${count.error}`);
scripts/test-times.json +11 −2
@@ -1,5 +1,9 @@
11 {
2 "test/apps-adversarial.test.ts": 145.9,
2 "test/apps-adversarial-grants.test.ts": 28.3,
3 "test/apps-adversarial-rules.test.ts": 29.7,
4 "test/apps-adversarial-teardown.test.ts": 36.4,
5 "test/apps-adversarial-test-step.test.ts": 33.5,
6 "test/apps-adversarial.test.ts": 18,
37 "test/apps-busy.test.ts": 26.4,
48 "test/apps-crew.test.ts": 3.8,
59 "test/apps-identity.test.ts": 3.2,
@@ -45,8 +49,13 @@
4549 "test/spawn.test.ts": 11.1,
4650 "test/standing.test.ts": 2.8,
4751 "test/steer.test.ts": 16.2,
52 "test/studio-changes.test.ts": 31.8,
53 "test/studio-door.test.ts": 34.2,
54 "test/studio-runs.test.ts": 29.3,
55 "test/studio-search.test.ts": 30.7,
56 "test/studio-shipping.test.ts": 35.6,
4857 "test/studio-words.test.ts": 17,
49 "test/studio.test.ts": 196.9,
58 "test/studio.test.ts": 35.3,
5059 "test/usage.test.ts": 17.5,
5160 "test/vitals.test.ts": 7.1,
5261 "test/web-apps.test.ts": 18.7,
src/kernel/db.ts +38 −1
@@ -6,6 +6,7 @@
66 */
77
88 import { Database } from "bun:sqlite";
9 import { existsSync, writeFileSync } from "node:fs";
910 import { migrate } from "./migrate.ts";
1011
1112 /**
@@ -34,12 +35,48 @@ const CACHE_KIB = 64 * 1024;
3435 const MMAP_BYTES = 512 * 1024 * 1024;
3536
3637 export function openDb(path: string, migrations: readonly string[]): Database {
37 const db = new Database(path, { create: true, strict: true });
38 const memory = path === ":memory:";
39 const fresh = memory || !existsSync(path);
40 const image = fresh ? migratedEmpty.get(migrations) : undefined;
41 let db: Database;
42 if (image !== undefined && memory) {
43 db = Database.deserialize(rollbackJournal(image), { strict: true });
44 } else {
45 if (image !== undefined) writeFileSync(path, image, { flag: "wx" });
46 db = new Database(path, { create: true, strict: true });
47 }
3848 applyPragmas(db);
49 // On a copy every migration is already recorded, so this is a no-op —
50 // and a list that grew since the copy was taken still runs what is new.
3951 migrate(db, migrations);
52 if (fresh && image === undefined) migratedEmpty.set(migrations, db.serialize());
4053 return db;
4154 }
4255
56 /**
57 * The empty database each migration list makes, kept by this process after
58 * it makes it once: a fresh database after the first is a copy of it, not
59 * ninety-odd migrations again. A forge opens one fresh database in its life,
60 * so this is the test suite's saving — about 90 ms of every `createApp` — and
61 * production never reads a copy. Keyed by the list itself, so a test's own
62 * list is never confused with the schema. An empty database is the same
63 * bytes however it was made: the migrations that draw randomness only
64 * rewrite rows an empty database does not have.
65 */
66 const migratedEmpty = new WeakMap<readonly string[], Uint8Array>();
67
68 /**
69 * The image with its header saying "rollback journal" (bytes 18 and 19 of a
70 * SQLite file are the read and write versions: 2 is WAL, 1 legacy), which
71 * an in-memory database needs — memory has no write-ahead log to open.
72 */
73 function rollbackJournal(image: Uint8Array): Uint8Array {
74 const copy = image.slice();
75 copy[18] = 1;
76 copy[19] = 1;
77 return copy;
78 }
79
4380 /**
4481 * The pragmas, in one place and applied before anything else touches the
4582 * handle. `journal_mode` and `foreign_keys` are correctness; the rest are
test/apps-adversarial-grants.test.ts added +281
@@ -0,0 +1,281 @@
1 /**
2 * Adversarial coverage for apps (docs/APPS.md), through a live apps
3 * runtime: the private-preview grant and the proxy. Split from apps-
4 * adversarial.test.ts so no one file sets the gate's floor (#2038); each
5 * file of the split boots its own forge and runtime.
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 { createApp, type App } from "../src/app.ts";
13 import { AppsRuntime } from "../src/apps/runtime.ts";
14 import { parseAppHost, signGrant, verifyGrant } from "../src/apps/proxy.ts";
15 import { newApp, updateAppSettings } from "../src/actions/apps.ts";
16 import { listRuns, type Run } from "../src/core/runs.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19
20 const DOMAIN = "apps.test";
21
22 describe("apps adversarial: grants and the proxy", () => {
23 let dataDir: string;
24 let work: string;
25 let app: App;
26 let servers: Servers;
27 let runtime: AppsRuntime;
28 let alice: User;
29
30 const viaHost = (host: string, path = "/", init: RequestInit = {}) =>
31 fetch(`${servers.url}${path}`, {
32 ...init,
33 headers: { ...(init.headers as Record<string, string>), host },
34 redirect: "manual",
35 });
36
37 async function settled(
38 repoId: string,
39 where: (run: Run) => boolean,
40 timeoutMs = 60_000,
41 ): Promise<Run> {
42 const deadline = Date.now() + timeoutMs;
43 while (Date.now() < deadline) {
44 const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find(
45 where,
46 );
47 if (
48 run &&
49 (run.state === "ok" ||
50 run.state === "failed" ||
51 run.state === "cancelled")
52 )
53 return run;
54 await Bun.sleep(50);
55 }
56 throw new Error("run did not settle in time");
57 }
58
59 beforeAll(async () => {
60 dataDir = await mkdtemp(join(tmpdir(), "forge-adv-"));
61 work = await mkdtemp(join(tmpdir(), "forge-adv-work-"));
62 app = createApp({ dataDir, port: 0 });
63 servers = await serve(app);
64 runtime = new AppsRuntime(app, {
65 domain: DOMAIN,
66 scheme: "http",
67 publicPort: servers.port,
68 forgeUrl: servers.url,
69 concurrency: 2,
70 });
71 await runtime.start();
72 const created = await createUser(app.db, { trusted: true,
73 username: "alice",
74 email: "[email protected]",
75 password: "a-good-passphrase",
76 adminIfFirst: true,
77 });
78 if (!created.ok) throw new Error(created.error);
79 alice = created.value;
80 }, 30_000);
81
82 afterAll(async () => {
83 await runtime.stop();
84 servers.stop();
85 app.close();
86 await rm(dataDir, { recursive: true, force: true });
87 await rm(work, { recursive: true, force: true });
88 });
89
90 test("private grant: forged, tampered, expired, wrong-env, and open-redirect are all refused; cookie is HttpOnly/SameSite", async () => {
91 const made = await newApp(app, alice, {
92 name: "grants",
93 visibility: "private",
94 });
95 expect(made.ok).toBe(true);
96 if (!made.ok) return;
97 const repo = made.value.repo;
98 const record = made.value.app;
99 await settled(repo.id, (r) => r.env === "production");
100
101 const target = { app: record, env: "production" as const };
102 const good = signGrant(dataDir, target, 60_000);
103 expect(verifyGrant(dataDir, good, target)).toBe(true);
104
105 // Wrong environment: a grant for production names none for a preview.
106 expect(verifyGrant(dataDir, good, { app: record, env: "pull-1" })).toBe(
107 false,
108 );
109
110 // Expired: signed in the past with a short ttl.
111 const expired = signGrant(dataDir, target, 1_000, Date.now() - 10_000);
112 expect(verifyGrant(dataDir, expired, target)).toBe(false);
113
114 // Tampered signature: the payload is untouched, but the MAC no longer
115 // matches it — this is the actual HMAC check, not just a field mismatch.
116 const parts = good.split(".");
117 const payload = parts.slice(0, -1).join(".");
118 const sig = parts.at(-1)!;
119 const flipped = sig.at(-1) === "A" ? "B" : "A";
120 const tampered = `${payload}.${sig.slice(0, -1)}${flipped}`;
121 expect(verifyGrant(dataDir, tampered, target)).toBe(false);
122
123 // Tampered payload: flip a character in the app id segment — the
124 // signature is now for a different message entirely.
125 const idTampered = `x${payload.slice(1)}.${sig}`;
126 expect(verifyGrant(dataDir, idTampered, target)).toBe(false);
127
128 // Forged: right shape, made-up signature.
129 const forged = `${record.id}.production.${Date.now() + 60_000}.notarealsignature`;
130 expect(verifyGrant(dataDir, forged, target)).toBe(false);
131
132 // Hitting the grant endpoint on a *public* app: it still answers (it
133 // is not access-gated by itself), but the token must still verify
134 // correctly against that public app's own target.
135 const publicMade = await newApp(app, alice, {
136 name: "grantpublic",
137 visibility: "public",
138 });
139 expect(publicMade.ok).toBe(true);
140 if (publicMade.ok) {
141 await settled(publicMade.value.repo.id, (r) => r.env === "production");
142 const pubGrantUrl = `http://grantpublic.${DOMAIN}/__forge/grant?t=${encodeURIComponent(good)}&to=/`;
143 // A production-app grant token does not verify for a different app's
144 // target, so this must be refused (404) even though the destination
145 // app is public.
146 const url = new URL(pubGrantUrl);
147 const crossApp = await viaHost(url.host, `${url.pathname}${url.search}`);
148 expect(crossApp.status).toBe(404);
149 }
150
151 // Open redirect: `to=//evil` and `to=http://evil` must both collapse to `/`.
152 const okToken = signGrant(dataDir, target, 60_000);
153 for (const to of [
154 "//evil.example",
155 "http://evil.example",
156 "https://evil.example/x",
157 ]) {
158 const resp = await viaHost(
159 `grants.${DOMAIN}`,
160 `/__forge/grant?t=${encodeURIComponent(okToken)}&to=${encodeURIComponent(to)}`,
161 );
162 expect(resp.status).toBe(303);
163 expect(resp.headers.get("location")).toBe("/");
164 }
165
166 // The cookie itself: HttpOnly and SameSite=Lax.
167 const grantResp = await viaHost(
168 `grants.${DOMAIN}`,
169 `/__forge/grant?t=${encodeURIComponent(okToken)}&to=/`,
170 );
171 const cookie = grantResp.headers.get("set-cookie") ?? "";
172 expect(cookie.toLowerCase()).toContain("httponly");
173 expect(cookie).toContain("SameSite=Lax");
174
175 // Invariant 9: a private *repository* never distinguishes "you're
176 // anonymous" from "you're signed in but still can't see it" — both
177 // must read `not_found`, or an anonymous prober could tell the two
178 // apart and learn something about a repo they cannot see.
179 const strangerCreated = await createUser(app.db, { trusted: true,
180 username: "stranger",
181 email: "[email protected]",
182 password: "a-good-passphrase",
183 });
184 expect(strangerCreated.ok).toBe(true);
185 if (!strangerCreated.ok) return;
186 const { openEnvUrl } = await import("../src/actions/apps.ts");
187 const anonOnPrivateRepo = openEnvUrl(app, undefined, repo, "production");
188 expect(anonOnPrivateRepo.ok).toBe(false);
189 if (!anonOnPrivateRepo.ok)
190 expect(anonOnPrivateRepo.error).toBe("not_found");
191 const strangerOnPrivateRepo = openEnvUrl(
192 app,
193 strangerCreated.value,
194 repo,
195 "production",
196 );
197 expect(strangerOnPrivateRepo.ok).toBe(false);
198 if (!strangerOnPrivateRepo.ok)
199 expect(strangerOnPrivateRepo.error).toBe("not_found");
200
201 // `unauthorized` is reachable only through the other combination: a
202 // *public* repository (so `canRead` already passes for anyone) whose
203 // app access is explicitly overridden to "private". There, an
204 // anonymous visitor has nothing to be vouched with, and a signed-in
205 // stranger who can read the public code is still not let in: a private
206 // app answers to its writers and crew only (#620).
207 const overrideMade = await newApp(app, alice, {
208 name: "publicwithprivateapp",
209 visibility: "public",
210 });
211 expect(overrideMade.ok).toBe(true);
212 if (!overrideMade.ok) return;
213 await settled(overrideMade.value.repo.id, (r) => r.env === "production");
214 updateAppSettings(app, alice, overrideMade.value.repo, {
215 access: "private",
216 });
217 const anonOnPublicRepo = openEnvUrl(
218 app,
219 undefined,
220 overrideMade.value.repo,
221 "production",
222 );
223 expect(anonOnPublicRepo.ok).toBe(false);
224 if (!anonOnPublicRepo.ok)
225 expect(anonOnPublicRepo.error).toBe("unauthorized");
226 const strangerOnPublicRepo = openEnvUrl(
227 app,
228 strangerCreated.value,
229 overrideMade.value.repo,
230 "production",
231 );
232 expect(strangerOnPublicRepo.ok).toBe(false);
233 if (!strangerOnPublicRepo.ok)
234 expect(strangerOnPublicRepo.error).toBe("not_found");
235 }, 60_000);
236
237 test("the proxy: hop-by-hop headers are stripped, an upstream that dies mid-request yields a clean error, a claimed upgrade is a request", async () => {
238 const made = await newApp(app, alice, {
239 name: "proxytest",
240 visibility: "public",
241 });
242 expect(made.ok).toBe(true);
243 if (!made.ok) return;
244 const repo = made.value.repo;
245 await settled(repo.id, (r) => r.env === "production");
246
247 const page = await viaHost(`proxytest.${DOMAIN}`);
248 expect(page.status).toBe(200);
249 // Hop-by-hop response headers never reach the client from the proxy
250 // path (the app itself doesn't set them, but the proxy must strip them
251 // if it ever did — assert the contract on the response we did get).
252 for (const h of ["connection", "transfer-encoding", "keep-alive"]) {
253 expect(page.headers.has(h)).toBe(false);
254 }
255
256 // The slug is case-insensitive end to end, through the database too —
257 // not just in `parseAppHost`'s own lowercasing.
258 const upperCase = await viaHost(`PROXYTEST.${DOMAIN}`);
259 expect(upperCase.status).toBe(200);
260 expect(await upperCase.text()).toBe(await page.text());
261
262 // A request that says `Upgrade: websocket` without a handshake key is
263 // not a handshake: it is fetched as a request, the hop-by-hop headers
264 // stripped, and the app answers it as one. Real sockets are
265 // test/apps-ws.test.ts.
266 const ws = await viaHost(`proxytest.${DOMAIN}`, "/", {
267 headers: { upgrade: "websocket", connection: "Upgrade" },
268 });
269 expect(ws.status).toBe(200);
270
271 // Stop the live process out from under the proxy (simulating a crash
272 // mid-request) and confirm the *next* request gets the forge's own
273 // "not running" page, never a raw connection-refused/502 from Bun.
274 const { stopEnv } = await import("../src/actions/apps.ts");
275 await stopEnv(app, alice, repo, "production");
276 const afterStop = await viaHost(`proxytest.${DOMAIN}`);
277 expect(afterStop.status).toBe(502);
278 expect(await afterStop.text()).toContain("not running");
279 }, 60_000);
280
281 });
test/apps-adversarial-rules.test.ts added +557
@@ -0,0 +1,557 @@
1 /**
2 * Adversarial coverage for apps (docs/APPS.md), through a live apps
3 * runtime: environment names, preview data, and rules 1 and 4. Split from
4 * apps-adversarial.test.ts so no one file sets the gate's floor (#2038);
5 * each file of the split boots its own forge and runtime.
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 { createApp, type App } from "../src/app.ts";
13 import { AppsRuntime } from "../src/apps/runtime.ts";
14 import { newApp, updateAppSettings } from "../src/actions/apps.ts";
15 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { mergePull, openPull } from "../src/actions/pulls.ts";
17 import {
18 commitToBranch,
19 createRepo,
20 forkRepo,
21 updateRepoSettings,
22 } from "../src/actions/repos.ts";
23 import { getPull } from "../src/core/pulls.ts";
24 import { getRepoById } from "../src/core/repos.ts";
25 import { resolveOid } from "../src/git/merge.ts";
26 import { repoDir } from "../src/git/repos.ts";
27 import {
28 cancelRun,
29 claimRun,
30 createRun,
31 finishRun,
32 listRuns,
33 type Run,
34 } from "../src/core/runs.ts";
35 import { createToken } from "../src/core/tokens.ts";
36 import { createUser, type User } from "../src/core/users.ts";
37 import { serve, type Servers } from "../src/http/server.ts";
38 import { grantedCookie } from "./helpers/identity.ts";
39 import { gitClientEnv } from "./helpers/git-client.ts";
40
41 const DOMAIN = "apps.test";
42
43 const clientEnv = gitClientEnv({
44 GIT_AUTHOR_NAME: "Agent",
45 GIT_AUTHOR_EMAIL: "[email protected]",
46 GIT_COMMITTER_NAME: "Agent",
47 GIT_COMMITTER_EMAIL: "[email protected]",
48 });
49
50 async function sh(args: string[], cwd: string) {
51 const proc = Bun.spawn(args, {
52 cwd,
53 env: clientEnv,
54 stdout: "pipe",
55 stderr: "pipe",
56 });
57 const [out, err, code] = await Promise.all([
58 new Response(proc.stdout).text(),
59 new Response(proc.stderr).text(),
60 proc.exited,
61 ]);
62 return { code, out: out.trim(), err: err.trim() };
63 }
64
65 describe("apps adversarial: env names, preview data, and rules 1/4", () => {
66 let dataDir: string;
67 let work: string;
68 let app: App;
69 let servers: Servers;
70 let runtime: AppsRuntime;
71 let alice: User;
72
73 const viaHost = (host: string, path = "/", init: RequestInit = {}) =>
74 fetch(`${servers.url}${path}`, {
75 ...init,
76 headers: { ...(init.headers as Record<string, string>), host },
77 redirect: "manual",
78 });
79
80 async function settled(
81 repoId: string,
82 where: (run: Run) => boolean,
83 timeoutMs = 60_000,
84 ): Promise<Run> {
85 const deadline = Date.now() + timeoutMs;
86 while (Date.now() < deadline) {
87 const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find(
88 where,
89 );
90 if (
91 run &&
92 (run.state === "ok" ||
93 run.state === "failed" ||
94 run.state === "cancelled")
95 )
96 return run;
97 await Bun.sleep(50);
98 }
99 throw new Error("run did not settle in time");
100 }
101
102 beforeAll(async () => {
103 dataDir = await mkdtemp(join(tmpdir(), "forge-adv-"));
104 work = await mkdtemp(join(tmpdir(), "forge-adv-work-"));
105 app = createApp({ dataDir, port: 0 });
106 servers = await serve(app);
107 runtime = new AppsRuntime(app, {
108 domain: DOMAIN,
109 scheme: "http",
110 publicPort: servers.port,
111 forgeUrl: servers.url,
112 concurrency: 2,
113 });
114 await runtime.start();
115 const created = await createUser(app.db, { trusted: true,
116 username: "alice",
117 email: "[email protected]",
118 password: "a-good-passphrase",
119 adminIfFirst: true,
120 });
121 if (!created.ok) throw new Error(created.error);
122 alice = created.value;
123 }, 30_000);
124
125 afterAll(async () => {
126 await runtime.stop();
127 servers.stop();
128 app.close();
129 await rm(dataDir, { recursive: true, force: true });
130 await rm(work, { recursive: true, force: true });
131 });
132
133 test("the API never lets an env parameter shaped like a path reach the filesystem", async () => {
134 const made = await newApp(app, alice, {
135 name: "envtraversal",
136 visibility: "public",
137 });
138 expect(made.ok).toBe(true);
139 if (!made.ok) return;
140 await settled(made.value.repo.id, (r) => r.env === "production");
141 const adminToken = createToken(
142 app.db,
143 alice.id,
144 "traversal-test",
145 "admin",
146 ).token;
147 const api = (path: string, init: RequestInit = {}) =>
148 fetch(`${servers.url}${path}`, {
149 ...init,
150 headers: { authorization: `Bearer ${adminToken}` },
151 });
152 const owner = made.value.repo.ownerName;
153 const name = made.value.repo.name;
154 // A bare ".." segment is collapsed by `URL`/`fetch` before the request
155 // ever leaves the client (RFC 3986 dot-segment removal), so it can only
156 // be used to probe the *server's* handling when percent-encoded — a raw
157 // ".." never reaches the router at all, which is the stronger property
158 // and not what this test is isolating.
159 for (const hostile of [
160 "..%2F..%2F..%2Fetc",
161 "production%2F..%2F..",
162 "pull--1",
163 ]) {
164 const redeploy = await api(
165 `/api/v1/repos/${owner}/${name}/app/${hostile}/redeploy`,
166 { method: "POST" },
167 );
168 if (redeploy.status !== 422) {
169 throw new Error(
170 `redeploy env=${hostile} returned ${redeploy.status}, expected 422`,
171 );
172 }
173 const body = (await redeploy.json()) as { error: string };
174 expect(body.error).toBe("no_such_env");
175
176 const stop = await api(
177 `/api/v1/repos/${owner}/${name}/app/${hostile}/stop`,
178 { method: "POST" },
179 );
180 if (stop.status !== 422) {
181 throw new Error(
182 `stop env=${hostile} returned ${stop.status}, expected 422`,
183 );
184 }
185
186 const open = await api(
187 `/api/v1/repos/${owner}/${name}/app/${hostile}/open`,
188 );
189 expect(open.status).toBe(422);
190 }
191 }, 60_000);
192
193 test('preview_data: "empty" starts a preview with no copy of production\'s data', async () => {
194 const made = await newApp(app, alice, {
195 name: "emptypolicy",
196 visibility: "public",
197 });
198 expect(made.ok).toBe(true);
199 if (!made.ok) return;
200 const repo = made.value.repo;
201 await settled(repo.id, (r) => r.env === "production");
202 updateAppSettings(app, alice, repo, { previewData: "empty" });
203
204 const form = new FormData();
205 form.set("body", "production-only note");
206 await viaHost(`emptypolicy.${DOMAIN}`, "/notes", {
207 method: "POST",
208 body: form,
209 headers: {
210 cookie: await grantedCookie(app, servers.url, alice, made.value.app),
211 },
212 });
213 expect(await (await viaHost(`emptypolicy.${DOMAIN}`)).text()).toContain(
214 "production-only note",
215 );
216
217 const committed = await commitToBranch(app, alice, repo, {
218 branch: "empty-preview",
219 base: repo.defaultBranch,
220 message: "trigger a preview",
221 files: [{ path: "EMPTY.md", content: "hi\n" }],
222 });
223 expect(committed.ok).toBe(true);
224 const opened = await openPull(app, alice, repo, {
225 title: "Empty preview",
226 baseBranch: repo.defaultBranch,
227 headBranch: "empty-preview",
228 });
229 expect(opened.ok).toBe(true);
230 if (!opened.ok) return;
231 await settled(repo.id, (r) => r.env === `pull-${opened.value.number}`);
232 const previewHtml = await (
233 await viaHost(`emptypolicy--${opened.value.number}.${DOMAIN}`)
234 ).text();
235 expect(previewHtml).not.toContain("production-only note");
236 }, 60_000);
237
238 test("rule 1: the repository takes no push from the crew; the agent's fork takes branches but never its default branch, protected or not, renamed or not", async () => {
239 const madeRepo = await createRepo(app, alice, { name: "rule1-repo" });
240 expect(madeRepo.ok).toBe(true);
241 if (!madeRepo.ok) return;
242 const repo = madeRepo.value;
243 await commitToBranch(app, alice, repo, {
244 message: "seed",
245 files: [{ path: "README.md", content: "hi\n" }],
246 });
247 const madeAgent = await createAgent(app, alice, {
248 username: "rule1bot",
249 model: "",
250 brief: "",
251 });
252 expect(madeAgent.ok).toBe(true);
253 if (!madeAgent.ok) return;
254 const bot = madeAgent.value.user;
255 addToCrew(app, alice, repo, "rule1bot");
256 const token = createToken(app.db, bot.id, "t", "rw");
257 const remote = `${servers.url.replace("://", `://rule1bot:${token.token}@`)}/${repo.ownerName}/${repo.name}.git`;
258 const clone = join(work, "rule1-clone");
259 expect(
260 (await sh(["git", "clone", "--quiet", remote, clone], work)).code,
261 ).toBe(0);
262 await Bun.write(join(clone, "A.md"), "a\n");
263 await sh(["git", "add", "A.md"], clone);
264 await sh(["git", "commit", "-qm", "a"], clone);
265
266 // The repository itself: crew membership is read. Neither the default
267 // branch nor any other takes a push, and the refusal hides nothing it
268 // would not hide from any non-writer.
269 const toDefault = await sh(
270 ["git", "push", "-q", "origin", `HEAD:${repo.defaultBranch}`],
271 clone,
272 );
273 expect(toDefault.code).not.toBe(0);
274 const toOther = await sh(
275 ["git", "push", "-q", "origin", "HEAD:refs/heads/some-branch"],
276 clone,
277 );
278 expect(toOther.code).not.toBe(0);
279 expect(
280 await resolveOid(
281 repoDir(app.reposRoot, repo.ownerName, repo.name),
282 "refs/heads/some-branch",
283 ),
284 ).toBeUndefined();
285
286 // The agent's fork takes its branches…
287 const forked = await forkRepo(app, bot, repo);
288 expect(forked.ok).toBe(true);
289 if (!forked.ok) return;
290 let fork = forked.value;
291 const forkRemote = `${servers.url.replace("://", `://rule1bot:${token.token}@`)}/${fork.ownerName}/${fork.name}.git`;
292 const otherOk = await sh(
293 ["git", "push", "-q", forkRemote, "HEAD:refs/heads/some-branch"],
294 clone,
295 );
296 expect(otherOk.code).toBe(0);
297
298 // …but never its default branch: rule 1 is unconditional, protectDefault
299 // off or on — an agent can't even fast-forward it.
300 const refusedOff = await sh(
301 ["git", "push", "-q", forkRemote, `HEAD:${fork.defaultBranch}`],
302 clone,
303 );
304 expect(refusedOff.code).not.toBe(0);
305 expect(refusedOff.err).toContain("agents propose through pulls");
306 await updateRepoSettings(app, alice, fork, { protectDefault: true });
307 const refusedOn = await sh(
308 ["git", "push", "-q", forkRemote, `HEAD:${fork.defaultBranch}`],
309 clone,
310 );
311 expect(refusedOn.code).not.toBe(0);
312 expect(refusedOn.err).toContain("agents propose through pulls");
313
314 // Rename the fork's default branch, then confirm a push to the NEW name
315 // is still refused (the hook must read the live default branch, not a
316 // cached name).
317 await sh(["git", "checkout", "-q", "-b", "renamed-main"], clone);
318 await sh(["git", "push", "-q", forkRemote, "renamed-main"], clone);
319 const renamed = await updateRepoSettings(app, alice, fork, {
320 defaultBranch: "renamed-main",
321 });
322 expect(renamed.ok).toBe(true);
323 fork = getRepoById(app.db, fork.id)!;
324 expect(fork.defaultBranch).toBe("renamed-main");
325 await Bun.write(join(clone, "B.md"), "b\n");
326 await sh(["git", "add", "B.md"], clone);
327 await sh(["git", "commit", "-qm", "b"], clone);
328 const refusedRenamed = await sh(
329 ["git", "push", "-q", forkRemote, "HEAD:renamed-main"],
330 clone,
331 );
332 expect(refusedRenamed.code).not.toBe(0);
333 expect(refusedRenamed.err).toContain("agents propose through pulls");
334 }, 60_000);
335
336 test("rule 1: an agent's rw token cannot reach settings, app enable, crew, tokens, or agent creation over the API", async () => {
337 const madeRepo = await createRepo(app, alice, { name: "rule1-api-repo" });
338 expect(madeRepo.ok).toBe(true);
339 if (!madeRepo.ok) return;
340 const repo = madeRepo.value;
341 await commitToBranch(app, alice, repo, {
342 message: "seed",
343 files: [{ path: "README.md", content: "hi\n" }],
344 });
345 const madeAgent = await createAgent(app, alice, {
346 username: "rule1apibot",
347 model: "",
348 brief: "",
349 });
350 expect(madeAgent.ok).toBe(true);
351 if (!madeAgent.ok) return;
352 addToCrew(app, alice, repo, "rule1apibot");
353 const bot = madeAgent.value.user;
354 const token = createToken(app.db, bot.id, "t", "rw").token;
355 const api = (path: string, init: RequestInit = {}) =>
356 fetch(`${servers.url}${path}`, {
357 ...init,
358 headers: {
359 ...(init.headers as Record<string, string>),
360 authorization: `Bearer ${token}`,
361 "content-type": "application/json",
362 },
363 });
364
365 const settingsResp = await api(
366 `/api/v1/repos/${repo.ownerName}/${repo.name}`,
367 { method: "PATCH", body: JSON.stringify({ visibility: "public" }) },
368 );
369 expect(settingsResp.status).toBe(403);
370
371 const enableResp = await api(
372 `/api/v1/repos/${repo.ownerName}/${repo.name}/app`,
373 { method: "POST", body: JSON.stringify({}) },
374 );
375 expect(enableResp.status).toBe(403);
376
377 const crewResp = await api(
378 `/api/v1/repos/${repo.ownerName}/${repo.name}/crew/rule1apibot`,
379 { method: "PUT" },
380 );
381 expect(crewResp.status).toBe(403);
382
383 const tokenResp = await api("/api/v1/tokens", {
384 method: "POST",
385 body: JSON.stringify({ adminConfirm: true, name: "x", scope: "admin" }),
386 });
387 // Neither via-session nor via-password: falls through to bad credentials.
388 expect(tokenResp.status).toBe(401);
389
390 const agentResp = await api("/api/v1/agents", {
391 method: "POST",
392 body: JSON.stringify({ username: "another-bot" }),
393 });
394 expect(agentResp.status).toBe(403);
395 }, 30_000);
396
397 test("rule 4: require_checks cannot be satisfied by a stale head, a foreign pull's run, a cancelled run, or a queued one; a push re-locks it", async () => {
398 const madeRepo = await createRepo(app, alice, { name: "rule4-repo" });
399 expect(madeRepo.ok).toBe(true);
400 if (!madeRepo.ok) return;
401 let repo = madeRepo.value;
402 await commitToBranch(app, alice, repo, {
403 message: "seed",
404 files: [{ path: "README.md", content: "hi\n" }],
405 });
406 repo = (await updateRepoSettings(app, alice, repo, { requireChecks: true }))
407 .ok
408 ? getRepoById(app.db, repo.id)!
409 : repo;
410
411 const committed1 = await commitToBranch(app, alice, repo, {
412 branch: "feature",
413 base: repo.defaultBranch,
414 message: "feature commit 1",
415 files: [{ path: "F.md", content: "1\n" }],
416 });
417 expect(committed1.ok).toBe(true);
418 if (!committed1.ok) return;
419 const oid1 = committed1.value.commitOid;
420 const opened = await openPull(app, alice, repo, {
421 title: "Feature",
422 baseBranch: repo.defaultBranch,
423 headBranch: "feature",
424 });
425 expect(opened.ok).toBe(true);
426 if (!opened.ok) return;
427 const pull = opened.value;
428
429 // A second branch, pointing at the exact same commit, opened as a
430 // second pull — so a run tagged with its issue id shares the commit
431 // oid but must never satisfy the first pull's gate.
432 await sh(
433 ["git", "branch", "feature2", oid1],
434 repoDir(app.reposRoot, repo.ownerName, repo.name),
435 );
436 const opened2 = await openPull(app, alice, repo, {
437 title: "Feature 2",
438 baseBranch: repo.defaultBranch,
439 headBranch: "feature2",
440 });
441 expect(opened2.ok).toBe(true);
442 if (!opened2.ok) return;
443
444 const mkRun = (issueId: string, oid: string) =>
445 createRun(app.db, {
446 repoId: repo.id,
447 kind: "deploy",
448 issueId,
449 commitOid: oid,
450 env: "pull-x",
451 reason: "push",
452 });
453
454 // Queued: never satisfies.
455 mkRun(pull.id, oid1);
456 let refused = await mergePull(
457 app,
458 alice,
459 repo,
460 getPull(app.db, repo.id, pull.number)!,
461 "merge",
462 );
463 expect(refused.ok).toBe(false);
464 if (!refused.ok) expect(refused.error).toBe("checks_required");
465
466 // Cancelled: never satisfies.
467 const toCancel = mkRun(pull.id, oid1);
468 cancelRun(app.db, toCancel.id, "test cancel");
469 refused = await mergePull(
470 app,
471 alice,
472 repo,
473 getPull(app.db, repo.id, pull.number)!,
474 "merge",
475 );
476 expect(refused.ok).toBe(false);
477 if (!refused.ok) expect(refused.error).toBe("checks_required");
478
479 // A green run for a DIFFERENT pull at the same commit: never satisfies
480 // pull 1.
481 const foreignRun = mkRun(opened2.value.id, oid1);
482 claimRun(app.db, foreignRun.id);
483 finishRun(app.db, foreignRun.id, { state: "ok", summary: "green" });
484 refused = await mergePull(
485 app,
486 alice,
487 repo,
488 getPull(app.db, repo.id, pull.number)!,
489 "merge",
490 );
491 expect(refused.ok).toBe(false);
492 if (!refused.ok) expect(refused.error).toBe("checks_required");
493
494 // Now a genuinely green run for pull 1 at its actual head: merge works.
495 const goodRun = mkRun(pull.id, oid1);
496 claimRun(app.db, goodRun.id);
497 finishRun(app.db, goodRun.id, { state: "ok", summary: "green" });
498 const merged = await mergePull(
499 app,
500 alice,
501 repo,
502 getPull(app.db, repo.id, pull.number)!,
503 "merge",
504 );
505 expect(merged.ok).toBe(true);
506 }, 60_000);
507
508 test("rule 4: a push after a green check re-locks the pull until the new head is green", async () => {
509 const madeRepo = await createRepo(app, alice, { name: "rule4-relock" });
510 expect(madeRepo.ok).toBe(true);
511 if (!madeRepo.ok) return;
512 let repo = madeRepo.value;
513 await commitToBranch(app, alice, repo, {
514 message: "seed",
515 files: [{ path: "README.md", content: "hi\n" }],
516 });
517 repo = (await updateRepoSettings(app, alice, repo, { requireChecks: true }))
518 .ok
519 ? getRepoById(app.db, repo.id)!
520 : repo;
521 const c1 = await commitToBranch(app, alice, repo, {
522 branch: "relock",
523 base: repo.defaultBranch,
524 message: "c1",
525 files: [{ path: "R.md", content: "1\n" }],
526 });
527 if (!c1.ok) throw new Error("setup failed");
528 const opened = await openPull(app, alice, repo, {
529 title: "Relock",
530 baseBranch: repo.defaultBranch,
531 headBranch: "relock",
532 });
533 if (!opened.ok) throw new Error("setup failed");
534 const pull = opened.value;
535 const green1 = createRun(app.db, {
536 repoId: repo.id,
537 kind: "deploy",
538 issueId: pull.id,
539 commitOid: c1.value.commitOid,
540 env: "pull-x",
541 reason: "push",
542 });
543 claimRun(app.db, green1.id);
544 finishRun(app.db, green1.id, { state: "ok", summary: "green" });
545 expect(
546 (
547 await mergePull(
548 app,
549 alice,
550 repo,
551 getPull(app.db, repo.id, pull.number)!,
552 "merge",
553 )
554 ).ok,
555 ).toBe(true);
556 }, 30_000);
557 });
test/apps-adversarial-teardown.test.ts added +219
@@ -0,0 +1,219 @@
1 /**
2 * Adversarial coverage for apps (docs/APPS.md), through a live apps
3 * runtime: closing a preview or a pull while it is live or mid-deploy.
4 * Split from apps-adversarial.test.ts so no one file sets the gate's floor
5 * (#2038); each file of the split boots its own forge and runtime.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { existsSync } from "node:fs";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { AppsRuntime } from "../src/apps/runtime.ts";
15 import { envDir } from "../src/apps/data.ts";
16 import { newApp } from "../src/actions/apps.ts";
17 import { mergePull, openPull } from "../src/actions/pulls.ts";
18 import { commitToBranch } from "../src/actions/repos.ts";
19 import { getPull } from "../src/core/pulls.ts";
20 import { listRuns, type Run } from "../src/core/runs.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23
24 const DOMAIN = "apps.test";
25
26 describe("apps adversarial: teardown races", () => {
27 let dataDir: string;
28 let work: string;
29 let app: App;
30 let servers: Servers;
31 let runtime: AppsRuntime;
32 let alice: User;
33
34 const viaHost = (host: string, path = "/", init: RequestInit = {}) =>
35 fetch(`${servers.url}${path}`, {
36 ...init,
37 headers: { ...(init.headers as Record<string, string>), host },
38 redirect: "manual",
39 });
40
41 async function settled(
42 repoId: string,
43 where: (run: Run) => boolean,
44 timeoutMs = 60_000,
45 ): Promise<Run> {
46 const deadline = Date.now() + timeoutMs;
47 while (Date.now() < deadline) {
48 const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find(
49 where,
50 );
51 if (
52 run &&
53 (run.state === "ok" ||
54 run.state === "failed" ||
55 run.state === "cancelled")
56 )
57 return run;
58 await Bun.sleep(50);
59 }
60 throw new Error("run did not settle in time");
61 }
62
63 beforeAll(async () => {
64 dataDir = await mkdtemp(join(tmpdir(), "forge-adv-"));
65 work = await mkdtemp(join(tmpdir(), "forge-adv-work-"));
66 app = createApp({ dataDir, port: 0 });
67 servers = await serve(app);
68 runtime = new AppsRuntime(app, {
69 domain: DOMAIN,
70 scheme: "http",
71 publicPort: servers.port,
72 forgeUrl: servers.url,
73 concurrency: 2,
74 });
75 await runtime.start();
76 const created = await createUser(app.db, { trusted: true,
77 username: "alice",
78 email: "[email protected]",
79 password: "a-good-passphrase",
80 adminIfFirst: true,
81 });
82 if (!created.ok) throw new Error(created.error);
83 alice = created.value;
84 }, 30_000);
85
86 afterAll(async () => {
87 await runtime.stop();
88 servers.stop();
89 app.close();
90 await rm(dataDir, { recursive: true, force: true });
91 await rm(work, { recursive: true, force: true });
92 });
93
94 test("closing an already-live preview does not resurrect it a few seconds later", async () => {
95 const made = await newApp(app, alice, {
96 name: "resurrect",
97 visibility: "public",
98 });
99 expect(made.ok).toBe(true);
100 if (!made.ok) return;
101 const repo = made.value.repo;
102 const record = made.value.app;
103 await settled(repo.id, (r) => r.env === "production");
104
105 const committed = await commitToBranch(app, alice, repo, {
106 branch: "preview-branch",
107 base: repo.defaultBranch,
108 message: "preview change",
109 files: [{ path: "PREVIEW.md", content: "hi\n" }],
110 });
111 expect(committed.ok).toBe(true);
112 const opened = await openPull(app, alice, repo, {
113 title: "Preview",
114 baseBranch: repo.defaultBranch,
115 headBranch: "preview-branch",
116 });
117 expect(opened.ok).toBe(true);
118 if (!opened.ok) return;
119 const pull = opened.value;
120
121 // Wait for the preview to actually come up (live, not just queued).
122 await settled(repo.id, (r) => r.env === `pull-${pull.number}`);
123 expect((await viaHost(`resurrect--${pull.number}.${DOMAIN}`)).status).toBe(
124 200,
125 );
126
127 const closed = await mergePull(
128 app,
129 alice,
130 repo,
131 getPull(app.db, repo.id, pull.number)!,
132 "merge",
133 );
134 expect(closed.ok).toBe(true);
135
136 // The teardown itself must remove the directory promptly.
137 const goneDeadline = Date.now() + 10_000;
138 while (
139 existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) &&
140 Date.now() < goneDeadline
141 )
142 await Bun.sleep(50);
143 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
144 false,
145 );
146
147 // The bug: `watch()`'s exit handler mistook the deliberate stop for a
148 // crash and scheduled a restart ~2s later (RESTART_BACKOFF_MS[0]). Wait
149 // well past that window and confirm nothing came back — no directory,
150 // no live process answering the preview's host, no new "restart" run.
151 await Bun.sleep(3_500);
152 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
153 false,
154 );
155 const stillGone = await viaHost(`resurrect--${pull.number}.${DOMAIN}`);
156 expect(stillGone.status).toBe(404);
157 expect(await stillGone.text()).toContain("is gone");
158 const resurrectionRuns = listRuns(app.db, repo.id, {
159 env: `pull-${pull.number}`,
160 }).filter((r) => r.reason === "restart");
161 expect(resurrectionRuns.length).toBe(0);
162 }, 60_000);
163
164 test("closing a pull mid-deploy still leaves no directory and no process", async () => {
165 const made = await newApp(app, alice, {
166 name: "midclose",
167 visibility: "public",
168 });
169 expect(made.ok).toBe(true);
170 if (!made.ok) return;
171 const repo = made.value.repo;
172 const record = made.value.app;
173 await settled(repo.id, (r) => r.env === "production");
174
175 const committed = await commitToBranch(app, alice, repo, {
176 branch: "slow-branch",
177 base: repo.defaultBranch,
178 message: "a change",
179 files: [{ path: "SLOW.md", content: "hi\n" }],
180 });
181 expect(committed.ok).toBe(true);
182 const opened = await openPull(app, alice, repo, {
183 title: "Slow",
184 baseBranch: repo.defaultBranch,
185 headBranch: "slow-branch",
186 });
187 expect(opened.ok).toBe(true);
188 if (!opened.ok) return;
189 const pull = opened.value;
190
191 // Close it immediately — almost certainly while the deploy is still
192 // installing/testing/starting, never having reached `live`.
193 const closedQuickly = await mergePull(
194 app,
195 alice,
196 repo,
197 getPull(app.db, repo.id, pull.number)!,
198 "merge",
199 );
200 expect(closedQuickly.ok).toBe(true);
201
202 const deadline = Date.now() + 15_000;
203 while (
204 existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) &&
205 Date.now() < deadline
206 )
207 await Bun.sleep(50);
208 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
209 false,
210 );
211 await Bun.sleep(3_500);
212 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
213 false,
214 );
215 expect((await viaHost(`midclose--${pull.number}.${DOMAIN}`)).status).toBe(
216 404,
217 );
218 }, 60_000);
219 });
test/apps-adversarial-test-step.test.ts added +230
@@ -0,0 +1,230 @@
1 /**
2 * Adversarial coverage for apps (docs/APPS.md), through a live apps
3 * runtime: what a failing install and a closed pull's test step leave
4 * behind. Split from apps-adversarial.test.ts so no one file sets the
5 * gate's floor (#2038); each file of the split boots its own forge and
6 * runtime.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { existsSync } from "node:fs";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { AppsRuntime } from "../src/apps/runtime.ts";
16 import { newApp } from "../src/actions/apps.ts";
17 import { mergePull, openPull } from "../src/actions/pulls.ts";
18 import { commitToBranch } from "../src/actions/repos.ts";
19 import { getPull } from "../src/core/pulls.ts";
20 import { listRuns, type Run } from "../src/core/runs.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23
24 const DOMAIN = "apps.test";
25
26 describe("apps adversarial: the install and test steps", () => {
27 let dataDir: string;
28 let work: string;
29 let app: App;
30 let servers: Servers;
31 let runtime: AppsRuntime;
32 let alice: User;
33
34 const viaHost = (host: string, path = "/", init: RequestInit = {}) =>
35 fetch(`${servers.url}${path}`, {
36 ...init,
37 headers: { ...(init.headers as Record<string, string>), host },
38 redirect: "manual",
39 });
40
41 async function settled(
42 repoId: string,
43 where: (run: Run) => boolean,
44 timeoutMs = 60_000,
45 ): Promise<Run> {
46 const deadline = Date.now() + timeoutMs;
47 while (Date.now() < deadline) {
48 const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find(
49 where,
50 );
51 if (
52 run &&
53 (run.state === "ok" ||
54 run.state === "failed" ||
55 run.state === "cancelled")
56 )
57 return run;
58 await Bun.sleep(50);
59 }
60 throw new Error("run did not settle in time");
61 }
62
63 beforeAll(async () => {
64 dataDir = await mkdtemp(join(tmpdir(), "forge-adv-"));
65 work = await mkdtemp(join(tmpdir(), "forge-adv-work-"));
66 app = createApp({ dataDir, port: 0 });
67 servers = await serve(app);
68 runtime = new AppsRuntime(app, {
69 domain: DOMAIN,
70 scheme: "http",
71 publicPort: servers.port,
72 forgeUrl: servers.url,
73 concurrency: 2,
74 });
75 await runtime.start();
76 const created = await createUser(app.db, { trusted: true,
77 username: "alice",
78 email: "[email protected]",
79 password: "a-good-passphrase",
80 adminIfFirst: true,
81 });
82 if (!created.ok) throw new Error(created.error);
83 alice = created.value;
84 }, 30_000);
85
86 afterAll(async () => {
87 await runtime.stop();
88 servers.stop();
89 app.close();
90 await rm(dataDir, { recursive: true, force: true });
91 await rm(work, { recursive: true, force: true });
92 });
93
94 test("install failing fails before test or start ever run — nothing from a half-installed tree", async () => {
95 const made = await newApp(app, alice, {
96 name: "badinstall",
97 visibility: "public",
98 });
99 expect(made.ok).toBe(true);
100 if (!made.ok) return;
101 const repo = made.value.repo;
102 await settled(repo.id, (r) => r.env === "production");
103
104 const testMarker = join(work, `install-test-marker-${Date.now()}`);
105 const runMarker = join(work, `install-run-marker-${Date.now()}`);
106 const committed = await commitToBranch(app, alice, repo, {
107 branch: "bad-install",
108 base: repo.defaultBranch,
109 message: "install fails",
110 files: [
111 {
112 path: "forge.json",
113 content: `${JSON.stringify(
114 {
115 app: {
116 install: "exit 1",
117 run: `touch ${runMarker} && bun run src/server.ts`,
118 test: `touch ${testMarker} && bun test`,
119 },
120 },
121 null,
122 2,
123 )}\n`,
124 },
125 ],
126 });
127 expect(committed.ok).toBe(true);
128 if (!committed.ok) return;
129 const opened = await openPull(app, alice, repo, {
130 title: "Bad install",
131 baseBranch: repo.defaultBranch,
132 headBranch: "bad-install",
133 });
134 expect(opened.ok).toBe(true);
135 if (!opened.ok) return;
136
137 const failed = await settled(
138 repo.id,
139 (r) =>
140 r.env === `pull-${opened.value.number}` &&
141 r.commitOid === committed.value.commitOid,
142 );
143 expect(failed.state).toBe("failed");
144 expect(failed.summary).toContain("install");
145 // Neither `test` nor `run` ever executed.
146 expect(existsSync(testMarker)).toBe(false);
147 expect(existsSync(runMarker)).toBe(false);
148 expect(
149 (await viaHost(`badinstall--${opened.value.number}.${DOMAIN}`)).status,
150 ).toBe(502);
151 }, 60_000);
152
153 test("closing a pull whose deploy already answered kills its still-running test step — no orphan `bun test`", async () => {
154 const made = await newApp(app, alice, {
155 name: "slowtest",
156 visibility: "public",
157 });
158 expect(made.ok).toBe(true);
159 if (!made.ok) return;
160 const repo = made.value.repo;
161 await settled(repo.id, (r) => r.env === "production");
162
163 // A merge lands this same forge.json on the default branch and
164 // production redeploys it too, running this same test step to
165 // completion (nothing aborts a deploy that was never in flight when the
166 // pull closed) — a marker keyed by $FORGE_APP_ENV keeps that legitimate
167 // production run from being mistaken for the preview's orphan.
168 const marker = join(work, `slowtest-marker-${Date.now()}`);
169 const committed = await commitToBranch(app, alice, repo, {
170 branch: "slow-test",
171 base: repo.defaultBranch,
172 message: "slow test",
173 files: [
174 {
175 path: "forge.json",
176 content: `${JSON.stringify(
177 {
178 app: {
179 run: "bun run src/server.ts",
180 test: `sleep 5 && touch ${marker}-$FORGE_APP_ENV`,
181 },
182 },
183 null,
184 2,
185 )}\n`,
186 },
187 ],
188 });
189 expect(committed.ok).toBe(true);
190 if (!committed.ok) return;
191 const opened = await openPull(app, alice, repo, {
192 title: "Slow test",
193 baseBranch: repo.defaultBranch,
194 headBranch: "slow-test",
195 });
196 expect(opened.ok).toBe(true);
197 if (!opened.ok) return;
198
199 // Wait for the app to answer — well before the test step's sleep ends.
200 const upDeadline = Date.now() + 10_000;
201 let up = false;
202 while (Date.now() < upDeadline) {
203 if (
204 (await viaHost(`slowtest--${opened.value.number}.${DOMAIN}`))
205 .status === 200
206 ) {
207 up = true;
208 break;
209 }
210 await Bun.sleep(50);
211 }
212 expect(up).toBe(true);
213
214 // Close it while the test step is still sleeping.
215 const closed = await mergePull(
216 app,
217 alice,
218 repo,
219 getPull(app.db, repo.id, opened.value.number)!,
220 "merge",
221 );
222 expect(closed.ok).toBe(true);
223
224 // Wait well past the sleep the test step was doing, and confirm it
225 // never finished: the preview's own marker is not there (production's,
226 // from its own unrelated redeploy of the same merged commit, may be).
227 await Bun.sleep(6_500);
228 expect(existsSync(`${marker}-preview`)).toBe(false);
229 }, 60_000);
230 });
test/apps-adversarial.test.ts +11 −969
@@ -5,6 +5,11 @@
55 * the schema. Where the existing suites (apps.test.ts, apps-api.test.ts,
66 * crew.test.ts) already drive a rule end to end, this file goes for the
77 * edges: stale state, races, forged input, and the boundary of a bound.
8 *
9 * The teardown races, the grants and the proxy, and rules 1 and 4 driven
10 * through a live apps runtime are their own files
11 * (apps-adversarial-{teardown,test-step,grants,rules}.test.ts), one
12 * runtime each, so no one file sets the gate's floor (#2038).
813 */
914
1015 import {
@@ -22,27 +27,15 @@ import { createApp, type App } from "../src/app.ts";
2227 import { AppsRuntime } from "../src/apps/runtime.ts";
2328 import { cloneDir, envDir, isEnvName, type EnvName } from "../src/apps/data.ts";
2429 import { appEnv, childEnv } from "../src/apps/process.ts";
25 import {
26 parseAppHost,
27 resolveAppHost,
28 signGrant,
29 verifyGrant,
30 } from "../src/apps/proxy.ts";
30 import { parseAppHost, resolveAppHost } from "../src/apps/proxy.ts";
3131 import { parseManifest } from "../src/apps/manifest.ts";
32 import { newApp, updateAppSettings } from "../src/actions/apps.ts";
32 import { newApp } from "../src/actions/apps.ts";
3333 import { addToCrew, createAgent } from "../src/actions/crew.ts";
3434 import { addComment, assignIssue, openIssue } from "../src/actions/issues.ts";
35 import { mergePull, openPull } from "../src/actions/pulls.ts";
36 import {
37 commitToBranch,
38 createRepo,
39 forkRepo,
40 updateRepoSettings,
41 } from "../src/actions/repos.ts";
42 import { getPull, type Pull } from "../src/core/pulls.ts";
43 import { getRepoById, type Repo } from "../src/core/repos.ts";
44 import { resolveOid } from "../src/git/merge.ts";
45 import { repoDir } from "../src/git/repos.ts";
35 import { openPull } from "../src/actions/pulls.ts";
36 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
37 import type { Pull } from "../src/core/pulls.ts";
38 import type { Repo } from "../src/core/repos.ts";
4639 import {
4740 activeRunFor,
4841 automaticRunsSince,
@@ -67,14 +60,11 @@ import {
6760 runLogListenerCount,
6861 subscribeRunLog,
6962 } from "../src/runs/log.ts";
70 import { grantedCookie } from "./helpers/identity.ts";
7163 import { gitClientEnv } from "./helpers/git-client.ts";
7264 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
7365
7466 const testBySocket = portInsteadOfSocket() ? test.skip : test;
7567
76 const DOMAIN = "apps.test";
77
7868 const clientEnv = gitClientEnv({
7969 GIT_AUTHOR_NAME: "Agent",
8070 GIT_AUTHOR_EMAIL: "[email protected]",
@@ -1060,954 +1050,6 @@ describe("migration 14 on an existing database", () => {
10601050 // proxy, and the mechanical rules that only show themselves under load.
10611051 // ---------------------------------------------------------------------------
10621052
1063 describe("apps adversarial: teardown races, grants, the proxy, and rule 1/4", () => {
1064 let dataDir: string;
1065 let work: string;
1066 let app: App;
1067 let servers: Servers;
1068 let runtime: AppsRuntime;
1069 let alice: User;
1070
1071 const viaHost = (host: string, path = "/", init: RequestInit = {}) =>
1072 fetch(`${servers.url}${path}`, {
1073 ...init,
1074 headers: { ...(init.headers as Record<string, string>), host },
1075 redirect: "manual",
1076 });
1077
1078 async function settled(
1079 repoId: string,
1080 where: (run: Run) => boolean,
1081 timeoutMs = 60_000,
1082 ): Promise<Run> {
1083 const deadline = Date.now() + timeoutMs;
1084 while (Date.now() < deadline) {
1085 const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find(
1086 where,
1087 );
1088 if (
1089 run &&
1090 (run.state === "ok" ||
1091 run.state === "failed" ||
1092 run.state === "cancelled")
1093 )
1094 return run;
1095 await Bun.sleep(50);
1096 }
1097 throw new Error("run did not settle in time");
1098 }
1099
1100 beforeAll(async () => {
1101 dataDir = await mkdtemp(join(tmpdir(), "forge-adv-"));
1102 work = await mkdtemp(join(tmpdir(), "forge-adv-work-"));
1103 app = createApp({ dataDir, port: 0 });
1104 servers = await serve(app);
1105 runtime = new AppsRuntime(app, {
1106 domain: DOMAIN,
1107 scheme: "http",
1108 publicPort: servers.port,
1109 forgeUrl: servers.url,
1110 concurrency: 2,
1111 });
1112 await runtime.start();
1113 const created = await createUser(app.db, { trusted: true,
1114 username: "alice",
1115 email: "[email protected]",
1116 password: "a-good-passphrase",
1117 adminIfFirst: true,
1118 });
1119 if (!created.ok) throw new Error(created.error);
1120 alice = created.value;
1121 }, 30_000);
1122
1123 afterAll(async () => {
1124 await runtime.stop();
1125 servers.stop();
1126 app.close();
1127 await rm(dataDir, { recursive: true, force: true });
1128 await rm(work, { recursive: true, force: true });
1129 });
1130
1131 test("closing an already-live preview does not resurrect it a few seconds later", async () => {
1132 const made = await newApp(app, alice, {
1133 name: "resurrect",
1134 visibility: "public",
1135 });
1136 expect(made.ok).toBe(true);
1137 if (!made.ok) return;
1138 const repo = made.value.repo;
1139 const record = made.value.app;
1140 await settled(repo.id, (r) => r.env === "production");
1141
1142 const committed = await commitToBranch(app, alice, repo, {
1143 branch: "preview-branch",
1144 base: repo.defaultBranch,
1145 message: "preview change",
1146 files: [{ path: "PREVIEW.md", content: "hi\n" }],
1147 });
1148 expect(committed.ok).toBe(true);
1149 const opened = await openPull(app, alice, repo, {
1150 title: "Preview",
1151 baseBranch: repo.defaultBranch,
1152 headBranch: "preview-branch",
1153 });
1154 expect(opened.ok).toBe(true);
1155 if (!opened.ok) return;
1156 const pull = opened.value;
1157
1158 // Wait for the preview to actually come up (live, not just queued).
1159 await settled(repo.id, (r) => r.env === `pull-${pull.number}`);
1160 expect((await viaHost(`resurrect--${pull.number}.${DOMAIN}`)).status).toBe(
1161 200,
1162 );
1163
1164 const closed = await mergePull(
1165 app,
1166 alice,
1167 repo,
1168 getPull(app.db, repo.id, pull.number)!,
1169 "merge",
1170 );
1171 expect(closed.ok).toBe(true);
1172
1173 // The teardown itself must remove the directory promptly.
1174 const goneDeadline = Date.now() + 10_000;
1175 while (
1176 existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) &&
1177 Date.now() < goneDeadline
1178 )
1179 await Bun.sleep(50);
1180 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
1181 false,
1182 );
1183
1184 // The bug: `watch()`'s exit handler mistook the deliberate stop for a
1185 // crash and scheduled a restart ~2s later (RESTART_BACKOFF_MS[0]). Wait
1186 // well past that window and confirm nothing came back — no directory,
1187 // no live process answering the preview's host, no new "restart" run.
1188 await Bun.sleep(3_500);
1189 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
1190 false,
1191 );
1192 const stillGone = await viaHost(`resurrect--${pull.number}.${DOMAIN}`);
1193 expect(stillGone.status).toBe(404);
1194 expect(await stillGone.text()).toContain("is gone");
1195 const resurrectionRuns = listRuns(app.db, repo.id, {
1196 env: `pull-${pull.number}`,
1197 }).filter((r) => r.reason === "restart");
1198 expect(resurrectionRuns.length).toBe(0);
1199 }, 60_000);
1200
1201 test("closing a pull mid-deploy still leaves no directory and no process", async () => {
1202 const made = await newApp(app, alice, {
1203 name: "midclose",
1204 visibility: "public",
1205 });
1206 expect(made.ok).toBe(true);
1207 if (!made.ok) return;
1208 const repo = made.value.repo;
1209 const record = made.value.app;
1210 await settled(repo.id, (r) => r.env === "production");
1211
1212 const committed = await commitToBranch(app, alice, repo, {
1213 branch: "slow-branch",
1214 base: repo.defaultBranch,
1215 message: "a change",
1216 files: [{ path: "SLOW.md", content: "hi\n" }],
1217 });
1218 expect(committed.ok).toBe(true);
1219 const opened = await openPull(app, alice, repo, {
1220 title: "Slow",
1221 baseBranch: repo.defaultBranch,
1222 headBranch: "slow-branch",
1223 });
1224 expect(opened.ok).toBe(true);
1225 if (!opened.ok) return;
1226 const pull = opened.value;
1227
1228 // Close it immediately — almost certainly while the deploy is still
1229 // installing/testing/starting, never having reached `live`.
1230 const closedQuickly = await mergePull(
1231 app,
1232 alice,
1233 repo,
1234 getPull(app.db, repo.id, pull.number)!,
1235 "merge",
1236 );
1237 expect(closedQuickly.ok).toBe(true);
1238
1239 const deadline = Date.now() + 15_000;
1240 while (
1241 existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) &&
1242 Date.now() < deadline
1243 )
1244 await Bun.sleep(50);
1245 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
1246 false,
1247 );
1248 await Bun.sleep(3_500);
1249 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
1250 false,
1251 );
1252 expect((await viaHost(`midclose--${pull.number}.${DOMAIN}`)).status).toBe(
1253 404,
1254 );
1255 }, 60_000);
1256
1257 test("install failing fails before test or start ever run — nothing from a half-installed tree", async () => {
1258 const made = await newApp(app, alice, {
1259 name: "badinstall",
1260 visibility: "public",
1261 });
1262 expect(made.ok).toBe(true);
1263 if (!made.ok) return;
1264 const repo = made.value.repo;
1265 await settled(repo.id, (r) => r.env === "production");
1266
1267 const testMarker = join(work, `install-test-marker-${Date.now()}`);
1268 const runMarker = join(work, `install-run-marker-${Date.now()}`);
1269 const committed = await commitToBranch(app, alice, repo, {
1270 branch: "bad-install",
1271 base: repo.defaultBranch,
1272 message: "install fails",
1273 files: [
1274 {
1275 path: "forge.json",
1276 content: `${JSON.stringify(
1277 {
1278 app: {
1279 install: "exit 1",
1280 run: `touch ${runMarker} && bun run src/server.ts`,
1281 test: `touch ${testMarker} && bun test`,
1282 },
1283 },
1284 null,
1285 2,
1286 )}\n`,
1287 },
1288 ],
1289 });
1290 expect(committed.ok).toBe(true);
1291 if (!committed.ok) return;
1292 const opened = await openPull(app, alice, repo, {
1293 title: "Bad install",
1294 baseBranch: repo.defaultBranch,
1295 headBranch: "bad-install",
1296 });
1297 expect(opened.ok).toBe(true);
1298 if (!opened.ok) return;
1299
1300 const failed = await settled(
1301 repo.id,
1302 (r) =>
1303 r.env === `pull-${opened.value.number}` &&
1304 r.commitOid === committed.value.commitOid,
1305 );
1306 expect(failed.state).toBe("failed");
1307 expect(failed.summary).toContain("install");
1308 // Neither `test` nor `run` ever executed.
1309 expect(existsSync(testMarker)).toBe(false);
1310 expect(existsSync(runMarker)).toBe(false);
1311 expect(
1312 (await viaHost(`badinstall--${opened.value.number}.${DOMAIN}`)).status,
1313 ).toBe(502);
1314 }, 60_000);
1315
1316 test("closing a pull whose deploy already answered kills its still-running test step — no orphan `bun test`", async () => {
1317 const made = await newApp(app, alice, {
1318 name: "slowtest",
1319 visibility: "public",
1320 });
1321 expect(made.ok).toBe(true);
1322 if (!made.ok) return;
1323 const repo = made.value.repo;
1324 await settled(repo.id, (r) => r.env === "production");
1325
1326 // A merge lands this same forge.json on the default branch and
1327 // production redeploys it too, running this same test step to
1328 // completion (nothing aborts a deploy that was never in flight when the
1329 // pull closed) — a marker keyed by $FORGE_APP_ENV keeps that legitimate
1330 // production run from being mistaken for the preview's orphan.
1331 const marker = join(work, `slowtest-marker-${Date.now()}`);
1332 const committed = await commitToBranch(app, alice, repo, {
1333 branch: "slow-test",
1334 base: repo.defaultBranch,
1335 message: "slow test",
1336 files: [
1337 {
1338 path: "forge.json",
1339 content: `${JSON.stringify(
1340 {
1341 app: {
1342 run: "bun run src/server.ts",
1343 test: `sleep 5 && touch ${marker}-$FORGE_APP_ENV`,
1344 },
1345 },
1346 null,
1347 2,
1348 )}\n`,
1349 },
1350 ],
1351 });
1352 expect(committed.ok).toBe(true);
1353 if (!committed.ok) return;
1354 const opened = await openPull(app, alice, repo, {
1355 title: "Slow test",
1356 baseBranch: repo.defaultBranch,
1357 headBranch: "slow-test",
1358 });
1359 expect(opened.ok).toBe(true);
1360 if (!opened.ok) return;
1361
1362 // Wait for the app to answer — well before the test step's sleep ends.
1363 const upDeadline = Date.now() + 10_000;
1364 let up = false;
1365 while (Date.now() < upDeadline) {
1366 if (
1367 (await viaHost(`slowtest--${opened.value.number}.${DOMAIN}`))
1368 .status === 200
1369 ) {
1370 up = true;
1371 break;
1372 }
1373 await Bun.sleep(50);
1374 }
1375 expect(up).toBe(true);
1376
1377 // Close it while the test step is still sleeping.
1378 const closed = await mergePull(
1379 app,
1380 alice,
1381 repo,
1382 getPull(app.db, repo.id, opened.value.number)!,
1383 "merge",
1384 );
1385 expect(closed.ok).toBe(true);
1386
1387 // Wait well past the sleep the test step was doing, and confirm it
1388 // never finished: the preview's own marker is not there (production's,
1389 // from its own unrelated redeploy of the same merged commit, may be).
1390 await Bun.sleep(6_500);
1391 expect(existsSync(`${marker}-preview`)).toBe(false);
1392 }, 60_000);
1393
1394 test("private grant: forged, tampered, expired, wrong-env, and open-redirect are all refused; cookie is HttpOnly/SameSite", async () => {
1395 const made = await newApp(app, alice, {
1396 name: "grants",
1397 visibility: "private",
1398 });
1399 expect(made.ok).toBe(true);
1400 if (!made.ok) return;
1401 const repo = made.value.repo;
1402 const record = made.value.app;
1403 await settled(repo.id, (r) => r.env === "production");
1404
1405 const target = { app: record, env: "production" as const };
1406 const good = signGrant(dataDir, target, 60_000);
1407 expect(verifyGrant(dataDir, good, target)).toBe(true);
1408
1409 // Wrong environment: a grant for production names none for a preview.
1410 expect(verifyGrant(dataDir, good, { app: record, env: "pull-1" })).toBe(
1411 false,
1412 );
1413
1414 // Expired: signed in the past with a short ttl.
1415 const expired = signGrant(dataDir, target, 1_000, Date.now() - 10_000);
1416 expect(verifyGrant(dataDir, expired, target)).toBe(false);
1417
1418 // Tampered signature: the payload is untouched, but the MAC no longer
1419 // matches it — this is the actual HMAC check, not just a field mismatch.
1420 const parts = good.split(".");
1421 const payload = parts.slice(0, -1).join(".");
1422 const sig = parts.at(-1)!;
1423 const flipped = sig.at(-1) === "A" ? "B" : "A";
1424 const tampered = `${payload}.${sig.slice(0, -1)}${flipped}`;
1425 expect(verifyGrant(dataDir, tampered, target)).toBe(false);
1426
1427 // Tampered payload: flip a character in the app id segment — the
1428 // signature is now for a different message entirely.
1429 const idTampered = `x${payload.slice(1)}.${sig}`;
1430 expect(verifyGrant(dataDir, idTampered, target)).toBe(false);
1431
1432 // Forged: right shape, made-up signature.
1433 const forged = `${record.id}.production.${Date.now() + 60_000}.notarealsignature`;
1434 expect(verifyGrant(dataDir, forged, target)).toBe(false);
1435
1436 // Hitting the grant endpoint on a *public* app: it still answers (it
1437 // is not access-gated by itself), but the token must still verify
1438 // correctly against that public app's own target.
1439 const publicMade = await newApp(app, alice, {
1440 name: "grantpublic",
1441 visibility: "public",
1442 });
1443 expect(publicMade.ok).toBe(true);
1444 if (publicMade.ok) {
1445 await settled(publicMade.value.repo.id, (r) => r.env === "production");
1446 const pubGrantUrl = `http://grantpublic.${DOMAIN}/__forge/grant?t=${encodeURIComponent(good)}&to=/`;
1447 // A production-app grant token does not verify for a different app's
1448 // target, so this must be refused (404) even though the destination
1449 // app is public.
1450 const url = new URL(pubGrantUrl);
1451 const crossApp = await viaHost(url.host, `${url.pathname}${url.search}`);
1452 expect(crossApp.status).toBe(404);
1453 }
1454
1455 // Open redirect: `to=//evil` and `to=http://evil` must both collapse to `/`.
1456 const okToken = signGrant(dataDir, target, 60_000);
1457 for (const to of [
1458 "//evil.example",
1459 "http://evil.example",
1460 "https://evil.example/x",
1461 ]) {
1462 const resp = await viaHost(
1463 `grants.${DOMAIN}`,
1464 `/__forge/grant?t=${encodeURIComponent(okToken)}&to=${encodeURIComponent(to)}`,
1465 );
1466 expect(resp.status).toBe(303);
1467 expect(resp.headers.get("location")).toBe("/");
1468 }
1469
1470 // The cookie itself: HttpOnly and SameSite=Lax.
1471 const grantResp = await viaHost(
1472 `grants.${DOMAIN}`,
1473 `/__forge/grant?t=${encodeURIComponent(okToken)}&to=/`,
1474 );
1475 const cookie = grantResp.headers.get("set-cookie") ?? "";
1476 expect(cookie.toLowerCase()).toContain("httponly");
1477 expect(cookie).toContain("SameSite=Lax");
1478
1479 // Invariant 9: a private *repository* never distinguishes "you're
1480 // anonymous" from "you're signed in but still can't see it" — both
1481 // must read `not_found`, or an anonymous prober could tell the two
1482 // apart and learn something about a repo they cannot see.
1483 const strangerCreated = await createUser(app.db, { trusted: true,
1484 username: "stranger",
1485 email: "[email protected]",
1486 password: "a-good-passphrase",
1487 });
1488 expect(strangerCreated.ok).toBe(true);
1489 if (!strangerCreated.ok) return;
1490 const { openEnvUrl } = await import("../src/actions/apps.ts");
1491 const anonOnPrivateRepo = openEnvUrl(app, undefined, repo, "production");
1492 expect(anonOnPrivateRepo.ok).toBe(false);
1493 if (!anonOnPrivateRepo.ok)
1494 expect(anonOnPrivateRepo.error).toBe("not_found");
1495 const strangerOnPrivateRepo = openEnvUrl(
1496 app,
1497 strangerCreated.value,
1498 repo,
1499 "production",
1500 );
1501 expect(strangerOnPrivateRepo.ok).toBe(false);
1502 if (!strangerOnPrivateRepo.ok)
1503 expect(strangerOnPrivateRepo.error).toBe("not_found");
1504
1505 // `unauthorized` is reachable only through the other combination: a
1506 // *public* repository (so `canRead` already passes for anyone) whose
1507 // app access is explicitly overridden to "private". There, an
1508 // anonymous visitor has nothing to be vouched with, and a signed-in
1509 // stranger who can read the public code is still not let in: a private
1510 // app answers to its writers and crew only (#620).
1511 const overrideMade = await newApp(app, alice, {
1512 name: "publicwithprivateapp",
1513 visibility: "public",
1514 });
1515 expect(overrideMade.ok).toBe(true);
1516 if (!overrideMade.ok) return;
1517 await settled(overrideMade.value.repo.id, (r) => r.env === "production");
1518 updateAppSettings(app, alice, overrideMade.value.repo, {
1519 access: "private",
1520 });
1521 const anonOnPublicRepo = openEnvUrl(
1522 app,
1523 undefined,
1524 overrideMade.value.repo,
1525 "production",
1526 );
1527 expect(anonOnPublicRepo.ok).toBe(false);
1528 if (!anonOnPublicRepo.ok)
1529 expect(anonOnPublicRepo.error).toBe("unauthorized");
1530 const strangerOnPublicRepo = openEnvUrl(
1531 app,
1532 strangerCreated.value,
1533 overrideMade.value.repo,
1534 "production",
1535 );
1536 expect(strangerOnPublicRepo.ok).toBe(false);
1537 if (!strangerOnPublicRepo.ok)
1538 expect(strangerOnPublicRepo.error).toBe("not_found");
1539 }, 60_000);
1540
1541 test("the proxy: hop-by-hop headers are stripped, an upstream that dies mid-request yields a clean error, a claimed upgrade is a request", async () => {
1542 const made = await newApp(app, alice, {
1543 name: "proxytest",
1544 visibility: "public",
1545 });
1546 expect(made.ok).toBe(true);
1547 if (!made.ok) return;
1548 const repo = made.value.repo;
1549 await settled(repo.id, (r) => r.env === "production");
1550
1551 const page = await viaHost(`proxytest.${DOMAIN}`);
1552 expect(page.status).toBe(200);
1553 // Hop-by-hop response headers never reach the client from the proxy
1554 // path (the app itself doesn't set them, but the proxy must strip them
1555 // if it ever did — assert the contract on the response we did get).
1556 for (const h of ["connection", "transfer-encoding", "keep-alive"]) {
1557 expect(page.headers.has(h)).toBe(false);
1558 }
1559
1560 // The slug is case-insensitive end to end, through the database too —
1561 // not just in `parseAppHost`'s own lowercasing.
1562 const upperCase = await viaHost(`PROXYTEST.${DOMAIN}`);
1563 expect(upperCase.status).toBe(200);
1564 expect(await upperCase.text()).toBe(await page.text());
1565
1566 // A request that says `Upgrade: websocket` without a handshake key is
1567 // not a handshake: it is fetched as a request, the hop-by-hop headers
1568 // stripped, and the app answers it as one. Real sockets are
1569 // test/apps-ws.test.ts.
1570 const ws = await viaHost(`proxytest.${DOMAIN}`, "/", {
1571 headers: { upgrade: "websocket", connection: "Upgrade" },
1572 });
1573 expect(ws.status).toBe(200);
1574
1575 // Stop the live process out from under the proxy (simulating a crash
1576 // mid-request) and confirm the *next* request gets the forge's own
1577 // "not running" page, never a raw connection-refused/502 from Bun.
1578 const { stopEnv } = await import("../src/actions/apps.ts");
1579 await stopEnv(app, alice, repo, "production");
1580 const afterStop = await viaHost(`proxytest.${DOMAIN}`);
1581 expect(afterStop.status).toBe(502);
1582 expect(await afterStop.text()).toContain("not running");
1583 }, 60_000);
1584
1585 test("the API never lets an env parameter shaped like a path reach the filesystem", async () => {
1586 const made = await newApp(app, alice, {
1587 name: "envtraversal",
1588 visibility: "public",
1589 });
1590 expect(made.ok).toBe(true);
1591 if (!made.ok) return;
1592 await settled(made.value.repo.id, (r) => r.env === "production");
1593 const adminToken = createToken(
1594 app.db,
1595 alice.id,
1596 "traversal-test",
1597 "admin",
1598 ).token;
1599 const api = (path: string, init: RequestInit = {}) =>
1600 fetch(`${servers.url}${path}`, {
1601 ...init,
1602 headers: { authorization: `Bearer ${adminToken}` },
1603 });
1604 const owner = made.value.repo.ownerName;
1605 const name = made.value.repo.name;
1606 // A bare ".." segment is collapsed by `URL`/`fetch` before the request
1607 // ever leaves the client (RFC 3986 dot-segment removal), so it can only
1608 // be used to probe the *server's* handling when percent-encoded — a raw
1609 // ".." never reaches the router at all, which is the stronger property
1610 // and not what this test is isolating.
1611 for (const hostile of [
1612 "..%2F..%2F..%2Fetc",
1613 "production%2F..%2F..",
1614 "pull--1",
1615 ]) {
1616 const redeploy = await api(
1617 `/api/v1/repos/${owner}/${name}/app/${hostile}/redeploy`,
1618 { method: "POST" },
1619 );
1620 if (redeploy.status !== 422) {
1621 throw new Error(
1622 `redeploy env=${hostile} returned ${redeploy.status}, expected 422`,
1623 );
1624 }
1625 const body = (await redeploy.json()) as { error: string };
1626 expect(body.error).toBe("no_such_env");
1627
1628 const stop = await api(
1629 `/api/v1/repos/${owner}/${name}/app/${hostile}/stop`,
1630 { method: "POST" },
1631 );
1632 if (stop.status !== 422) {
1633 throw new Error(
1634 `stop env=${hostile} returned ${stop.status}, expected 422`,
1635 );
1636 }
1637
1638 const open = await api(
1639 `/api/v1/repos/${owner}/${name}/app/${hostile}/open`,
1640 );
1641 expect(open.status).toBe(422);
1642 }
1643 }, 60_000);
1644
1645 test('preview_data: "empty" starts a preview with no copy of production\'s data', async () => {
1646 const made = await newApp(app, alice, {
1647 name: "emptypolicy",
1648 visibility: "public",
1649 });
1650 expect(made.ok).toBe(true);
1651 if (!made.ok) return;
1652 const repo = made.value.repo;
1653 await settled(repo.id, (r) => r.env === "production");
1654 updateAppSettings(app, alice, repo, { previewData: "empty" });
1655
1656 const form = new FormData();
1657 form.set("body", "production-only note");
1658 await viaHost(`emptypolicy.${DOMAIN}`, "/notes", {
1659 method: "POST",
1660 body: form,
1661 headers: {
1662 cookie: await grantedCookie(app, servers.url, alice, made.value.app),
1663 },
1664 });
1665 expect(await (await viaHost(`emptypolicy.${DOMAIN}`)).text()).toContain(
1666 "production-only note",
1667 );
1668
1669 const committed = await commitToBranch(app, alice, repo, {
1670 branch: "empty-preview",
1671 base: repo.defaultBranch,
1672 message: "trigger a preview",
1673 files: [{ path: "EMPTY.md", content: "hi\n" }],
1674 });
1675 expect(committed.ok).toBe(true);
1676 const opened = await openPull(app, alice, repo, {
1677 title: "Empty preview",
1678 baseBranch: repo.defaultBranch,
1679 headBranch: "empty-preview",
1680 });
1681 expect(opened.ok).toBe(true);
1682 if (!opened.ok) return;
1683 await settled(repo.id, (r) => r.env === `pull-${opened.value.number}`);
1684 const previewHtml = await (
1685 await viaHost(`emptypolicy--${opened.value.number}.${DOMAIN}`)
1686 ).text();
1687 expect(previewHtml).not.toContain("production-only note");
1688 }, 60_000);
1689
1690 test("rule 1: the repository takes no push from the crew; the agent's fork takes branches but never its default branch, protected or not, renamed or not", async () => {
1691 const madeRepo = await createRepo(app, alice, { name: "rule1-repo" });
1692 expect(madeRepo.ok).toBe(true);
1693 if (!madeRepo.ok) return;
1694 const repo = madeRepo.value;
1695 await commitToBranch(app, alice, repo, {
1696 message: "seed",
1697 files: [{ path: "README.md", content: "hi\n" }],
1698 });
1699 const madeAgent = await createAgent(app, alice, {
1700 username: "rule1bot",
1701 model: "",
1702 brief: "",
1703 });
1704 expect(madeAgent.ok).toBe(true);
1705 if (!madeAgent.ok) return;
1706 const bot = madeAgent.value.user;
1707 addToCrew(app, alice, repo, "rule1bot");
1708 const token = createToken(app.db, bot.id, "t", "rw");
1709 const remote = `${servers.url.replace("://", `://rule1bot:${token.token}@`)}/${repo.ownerName}/${repo.name}.git`;
1710 const clone = join(work, "rule1-clone");
1711 expect(
1712 (await sh(["git", "clone", "--quiet", remote, clone], work)).code,
1713 ).toBe(0);
1714 await Bun.write(join(clone, "A.md"), "a\n");
1715 await sh(["git", "add", "A.md"], clone);
1716 await sh(["git", "commit", "-qm", "a"], clone);
1717
1718 // The repository itself: crew membership is read. Neither the default
1719 // branch nor any other takes a push, and the refusal hides nothing it
1720 // would not hide from any non-writer.
1721 const toDefault = await sh(
1722 ["git", "push", "-q", "origin", `HEAD:${repo.defaultBranch}`],
1723 clone,
1724 );
1725 expect(toDefault.code).not.toBe(0);
1726 const toOther = await sh(
1727 ["git", "push", "-q", "origin", "HEAD:refs/heads/some-branch"],
1728 clone,
1729 );
1730 expect(toOther.code).not.toBe(0);
1731 expect(
1732 await resolveOid(
1733 repoDir(app.reposRoot, repo.ownerName, repo.name),
1734 "refs/heads/some-branch",
1735 ),
1736 ).toBeUndefined();
1737
1738 // The agent's fork takes its branches…
1739 const forked = await forkRepo(app, bot, repo);
1740 expect(forked.ok).toBe(true);
1741 if (!forked.ok) return;
1742 let fork = forked.value;
1743 const forkRemote = `${servers.url.replace("://", `://rule1bot:${token.token}@`)}/${fork.ownerName}/${fork.name}.git`;
1744 const otherOk = await sh(
1745 ["git", "push", "-q", forkRemote, "HEAD:refs/heads/some-branch"],
1746 clone,
1747 );
1748 expect(otherOk.code).toBe(0);
1749
1750 // …but never its default branch: rule 1 is unconditional, protectDefault
1751 // off or on — an agent can't even fast-forward it.
1752 const refusedOff = await sh(
1753 ["git", "push", "-q", forkRemote, `HEAD:${fork.defaultBranch}`],
1754 clone,
1755 );
1756 expect(refusedOff.code).not.toBe(0);
1757 expect(refusedOff.err).toContain("agents propose through pulls");
1758 await updateRepoSettings(app, alice, fork, { protectDefault: true });
1759 const refusedOn = await sh(
1760 ["git", "push", "-q", forkRemote, `HEAD:${fork.defaultBranch}`],
1761 clone,
1762 );
1763 expect(refusedOn.code).not.toBe(0);
1764 expect(refusedOn.err).toContain("agents propose through pulls");
1765
1766 // Rename the fork's default branch, then confirm a push to the NEW name
1767 // is still refused (the hook must read the live default branch, not a
1768 // cached name).
1769 await sh(["git", "checkout", "-q", "-b", "renamed-main"], clone);
1770 await sh(["git", "push", "-q", forkRemote, "renamed-main"], clone);
1771 const renamed = await updateRepoSettings(app, alice, fork, {
1772 defaultBranch: "renamed-main",
1773 });
1774 expect(renamed.ok).toBe(true);
1775 fork = getRepoById(app.db, fork.id)!;
1776 expect(fork.defaultBranch).toBe("renamed-main");
1777 await Bun.write(join(clone, "B.md"), "b\n");
1778 await sh(["git", "add", "B.md"], clone);
1779 await sh(["git", "commit", "-qm", "b"], clone);
1780 const refusedRenamed = await sh(
1781 ["git", "push", "-q", forkRemote, "HEAD:renamed-main"],
1782 clone,
1783 );
1784 expect(refusedRenamed.code).not.toBe(0);
1785 expect(refusedRenamed.err).toContain("agents propose through pulls");
1786 }, 60_000);
1787
1788 test("rule 1: an agent's rw token cannot reach settings, app enable, crew, tokens, or agent creation over the API", async () => {
1789 const madeRepo = await createRepo(app, alice, { name: "rule1-api-repo" });
1790 expect(madeRepo.ok).toBe(true);
1791 if (!madeRepo.ok) return;
1792 const repo = madeRepo.value;
1793 await commitToBranch(app, alice, repo, {
1794 message: "seed",
1795 files: [{ path: "README.md", content: "hi\n" }],
1796 });
1797 const madeAgent = await createAgent(app, alice, {
1798 username: "rule1apibot",
1799 model: "",
1800 brief: "",
1801 });
1802 expect(madeAgent.ok).toBe(true);
1803 if (!madeAgent.ok) return;
1804 addToCrew(app, alice, repo, "rule1apibot");
1805 const bot = madeAgent.value.user;
1806 const token = createToken(app.db, bot.id, "t", "rw").token;
1807 const api = (path: string, init: RequestInit = {}) =>
1808 fetch(`${servers.url}${path}`, {
1809 ...init,
1810 headers: {
1811 ...(init.headers as Record<string, string>),
1812 authorization: `Bearer ${token}`,
1813 "content-type": "application/json",
1814 },
1815 });
1816
1817 const settingsResp = await api(
1818 `/api/v1/repos/${repo.ownerName}/${repo.name}`,
1819 { method: "PATCH", body: JSON.stringify({ visibility: "public" }) },
1820 );
1821 expect(settingsResp.status).toBe(403);
1822
1823 const enableResp = await api(
1824 `/api/v1/repos/${repo.ownerName}/${repo.name}/app`,
1825 { method: "POST", body: JSON.stringify({}) },
1826 );
1827 expect(enableResp.status).toBe(403);
1828
1829 const crewResp = await api(
1830 `/api/v1/repos/${repo.ownerName}/${repo.name}/crew/rule1apibot`,
1831 { method: "PUT" },
1832 );
1833 expect(crewResp.status).toBe(403);
1834
1835 const tokenResp = await api("/api/v1/tokens", {
1836 method: "POST",
1837 body: JSON.stringify({ adminConfirm: true, name: "x", scope: "admin" }),
1838 });
1839 // Neither via-session nor via-password: falls through to bad credentials.
1840 expect(tokenResp.status).toBe(401);
1841
1842 const agentResp = await api("/api/v1/agents", {
1843 method: "POST",
1844 body: JSON.stringify({ username: "another-bot" }),
1845 });
1846 expect(agentResp.status).toBe(403);
1847 }, 30_000);
1848
1849 test("rule 4: require_checks cannot be satisfied by a stale head, a foreign pull's run, a cancelled run, or a queued one; a push re-locks it", async () => {
1850 const madeRepo = await createRepo(app, alice, { name: "rule4-repo" });
1851 expect(madeRepo.ok).toBe(true);
1852 if (!madeRepo.ok) return;
1853 let repo = madeRepo.value;
1854 await commitToBranch(app, alice, repo, {
1855 message: "seed",
1856 files: [{ path: "README.md", content: "hi\n" }],
1857 });
1858 repo = (await updateRepoSettings(app, alice, repo, { requireChecks: true }))
1859 .ok
1860 ? getRepoById(app.db, repo.id)!
1861 : repo;
1862
1863 const committed1 = await commitToBranch(app, alice, repo, {
1864 branch: "feature",
1865 base: repo.defaultBranch,
1866 message: "feature commit 1",
1867 files: [{ path: "F.md", content: "1\n" }],
1868 });
1869 expect(committed1.ok).toBe(true);
1870 if (!committed1.ok) return;
1871 const oid1 = committed1.value.commitOid;
1872 const opened = await openPull(app, alice, repo, {
1873 title: "Feature",
1874 baseBranch: repo.defaultBranch,
1875 headBranch: "feature",
1876 });
1877 expect(opened.ok).toBe(true);
1878 if (!opened.ok) return;
1879 const pull = opened.value;
1880
1881 // A second branch, pointing at the exact same commit, opened as a
1882 // second pull — so a run tagged with its issue id shares the commit
1883 // oid but must never satisfy the first pull's gate.
1884 await sh(
1885 ["git", "branch", "feature2", oid1],
1886 repoDir(app.reposRoot, repo.ownerName, repo.name),
1887 );
1888 const opened2 = await openPull(app, alice, repo, {
1889 title: "Feature 2",
1890 baseBranch: repo.defaultBranch,
1891 headBranch: "feature2",
1892 });
1893 expect(opened2.ok).toBe(true);
1894 if (!opened2.ok) return;
1895
1896 const mkRun = (issueId: string, oid: string) =>
1897 createRun(app.db, {
1898 repoId: repo.id,
1899 kind: "deploy",
1900 issueId,
1901 commitOid: oid,
1902 env: "pull-x",
1903 reason: "push",
1904 });
1905
1906 // Queued: never satisfies.
1907 mkRun(pull.id, oid1);
1908 let refused = await mergePull(
1909 app,
1910 alice,
1911 repo,
1912 getPull(app.db, repo.id, pull.number)!,
1913 "merge",
1914 );
1915 expect(refused.ok).toBe(false);
1916 if (!refused.ok) expect(refused.error).toBe("checks_required");
1917
1918 // Cancelled: never satisfies.
1919 const toCancel = mkRun(pull.id, oid1);
1920 cancelRun(app.db, toCancel.id, "test cancel");
1921 refused = await mergePull(
1922 app,
1923 alice,
1924 repo,
1925 getPull(app.db, repo.id, pull.number)!,
1926 "merge",
1927 );
1928 expect(refused.ok).toBe(false);
1929 if (!refused.ok) expect(refused.error).toBe("checks_required");
1930
1931 // A green run for a DIFFERENT pull at the same commit: never satisfies
1932 // pull 1.
1933 const foreignRun = mkRun(opened2.value.id, oid1);
1934 claimRun(app.db, foreignRun.id);
1935 finishRun(app.db, foreignRun.id, { state: "ok", summary: "green" });
1936 refused = await mergePull(
1937 app,
1938 alice,
1939 repo,
1940 getPull(app.db, repo.id, pull.number)!,
1941 "merge",
1942 );
1943 expect(refused.ok).toBe(false);
1944 if (!refused.ok) expect(refused.error).toBe("checks_required");
1945
1946 // Now a genuinely green run for pull 1 at its actual head: merge works.
1947 const goodRun = mkRun(pull.id, oid1);
1948 claimRun(app.db, goodRun.id);
1949 finishRun(app.db, goodRun.id, { state: "ok", summary: "green" });
1950 const merged = await mergePull(
1951 app,
1952 alice,
1953 repo,
1954 getPull(app.db, repo.id, pull.number)!,
1955 "merge",
1956 );
1957 expect(merged.ok).toBe(true);
1958 }, 60_000);
1959
1960 test("rule 4: a push after a green check re-locks the pull until the new head is green", async () => {
1961 const madeRepo = await createRepo(app, alice, { name: "rule4-relock" });
1962 expect(madeRepo.ok).toBe(true);
1963 if (!madeRepo.ok) return;
1964 let repo = madeRepo.value;
1965 await commitToBranch(app, alice, repo, {
1966 message: "seed",
1967 files: [{ path: "README.md", content: "hi\n" }],
1968 });
1969 repo = (await updateRepoSettings(app, alice, repo, { requireChecks: true }))
1970 .ok
1971 ? getRepoById(app.db, repo.id)!
1972 : repo;
1973 const c1 = await commitToBranch(app, alice, repo, {
1974 branch: "relock",
1975 base: repo.defaultBranch,
1976 message: "c1",
1977 files: [{ path: "R.md", content: "1\n" }],
1978 });
1979 if (!c1.ok) throw new Error("setup failed");
1980 const opened = await openPull(app, alice, repo, {
1981 title: "Relock",
1982 baseBranch: repo.defaultBranch,
1983 headBranch: "relock",
1984 });
1985 if (!opened.ok) throw new Error("setup failed");
1986 const pull = opened.value;
1987 const green1 = createRun(app.db, {
1988 repoId: repo.id,
1989 kind: "deploy",
1990 issueId: pull.id,
1991 commitOid: c1.value.commitOid,
1992 env: "pull-x",
1993 reason: "push",
1994 });
1995 claimRun(app.db, green1.id);
1996 finishRun(app.db, green1.id, { state: "ok", summary: "green" });
1997 expect(
1998 (
1999 await mergePull(
2000 app,
2001 alice,
2002 repo,
2003 getPull(app.db, repo.id, pull.number)!,
2004 "merge",
2005 )
2006 ).ok,
2007 ).toBe(true);
2008 }, 30_000);
2009 });
2010
20111053 describe("boot: a forge addressed by IP hands every app a host that works (issue #269)", () => {
20121054 let dataDir: string;
20131055 let work: string;
test/helpers.test.ts +2
@@ -28,6 +28,8 @@ describe("a git client's environment under test", () => {
2828 expect(env["GIT_CONFIG_PARAMETERS"]).toBeUndefined();
2929 expect(env["GIT_CONFIG_GLOBAL"]).toBe("/dev/null");
3030 expect(env["GIT_CONFIG_SYSTEM"]).toBe("/dev/null");
31 expect(env["GIT_CONFIG_NOSYSTEM"]).toBe("1");
32 expect(env["GIT_OPTIONAL_LOCKS"]).toBe("0");
3133 expect(env["GIT_TERMINAL_PROMPT"]).toBe("0");
3234 expect(env["PATH"]).toBe(process.env["PATH"] ?? "/usr/bin:/bin");
3335 // The test's own additions win.
test/helpers/cheap-hash.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * Argon2 at its cheapest, for tests: `test/helpers/preload.ts` installs it
3 * before every test file (`bunfig.toml`).
4 *
5 * A forge hashes a password with `Bun.password.hash(password, "argon2id")`,
6 * Bun's default cost — 64 MiB and two passes, about 150 ms on a quiet
7 * machine and several times that with sixteen files hashing side by side.
8 * The suite makes an account and signs it in about four hundred times, so
9 * that cost was most of a typical `beforeAll` and most of the gate's floor
10 * (#2038). Here the default becomes argon2id's minimum (8 KiB, one pass);
11 * `Bun.password.verify` reads the cost back from the hash, so signing in is
12 * as cheap. Production never loads this file, and a test that names its own
13 * cost (`test/signin-throttle.test.ts`) keeps it.
14 *
15 * `realHash` is the runtime's own, for the test that proves this is only a
16 * test's saving.
17 */
18
19 export const realHash = Bun.password.hash.bind(Bun.password);
20
21 /** argon2id's floor: the cheapest hash a verify still accepts as argon2id. */
22 export const CHEAP = { algorithm: "argon2id", memoryCost: 8, timeCost: 1 } as const;
23
24 type Algorithm = Parameters<typeof Bun.password.hash>[1];
25
26 /** The cost a test hash is made at: the floor, unless the caller named one. */
27 export function cheapened(algorithm: Algorithm): Algorithm {
28 if (algorithm === undefined || algorithm === "argon2id") return CHEAP;
29 return algorithm;
30 }
31
32 /** Every `Bun.password.hash` in this process from now on, at the floor. */
33 export function installCheapHash(): void {
34 Bun.password.hash = (password, algorithm) => realHash(password, cheapened(algorithm));
35 }
test/helpers/git-client.ts +4 −1
@@ -30,7 +30,8 @@ const IDENTITY = {
3030 /**
3131 * A deterministic environment for a git client under test: `PATH` and
3232 * nothing else from the host, no global or system config, no prompt, and a
33 * fixed identity. `extra` is the test's own additions — a clone URL's
33 * fixed identity. No optional locks either: a `git status` in a test never
34 * rewrites the index it only reads (#2038). `extra` is the test's own additions — a clone URL's
3435 * askpass, `GIT_SSH_COMMAND`, a different author — and wins.
3536 */
3637 export function gitClientEnv(
@@ -41,6 +42,8 @@ export function gitClientEnv(
4142 HOME: "/dev/null",
4243 GIT_CONFIG_GLOBAL: "/dev/null",
4344 GIT_CONFIG_SYSTEM: "/dev/null",
45 GIT_CONFIG_NOSYSTEM: "1",
46 GIT_OPTIONAL_LOCKS: "0",
4447 GIT_TERMINAL_PROMPT: "0",
4548 LC_ALL: "C",
4649 ...IDENTITY,
test/helpers/preload.ts added +8
@@ -0,0 +1,8 @@
1 /**
2 * Loaded before every test file (`bunfig.toml` `[test] preload`), never by
3 * the forge. Only what the whole suite must agree on belongs here.
4 */
5
6 import { installCheapHash } from "./cheap-hash.ts";
7
8 installCheapHash();
test/helpers/studio.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * What the studio test files share (#2038 split one file into several):
3 * a picture to drop where a look would sit, and an app runtime that
4 * answers "running" without starting anything.
5 */
6
7 import { spyOn } from "bun:test";
8 import type { App } from "../../src/app.ts";
9 import { AppsRuntime, type EnvState, type EnvStatus } from "../../src/apps/runtime.ts";
10 import type { AppRecord } from "../../src/core/apps.ts";
11 import { looksForEnv } from "../../src/core/looks.ts";
12 import type { Servers } from "../../src/http/server.ts";
13
14 /** One transparent pixel, dropped where a look would sit. */
15 export const PNG = Buffer.from(
16 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
17 "base64",
18 );
19
20 /**
21 * A runtime registered for `app`, never started, answering every app's
22 * production with `stateOf` (running unless told) — the door shows only
23 * what is up or wakes. The returned function takes it away again.
24 */
25 export async function serving(
26 app: App,
27 servers: Servers,
28 stateOf: (record: AppRecord) => EnvState = () => "running",
29 ): Promise<() => Promise<void>> {
30 const runtime = new AppsRuntime(app, {
31 domain: "studio-door.test",
32 scheme: "http",
33 publicPort: servers.port,
34 forgeUrl: servers.url,
35 });
36 const status = spyOn(AppsRuntime.prototype, "status").mockImplementation(
37 (record: AppRecord): EnvStatus[] => [
38 {
39 env: "production",
40 state: stateOf(record),
41 // Serving the commit its newest picture is of.
42 commitOid: looksForEnv(app.db, record.id, "production")[0]?.commitOid ?? "",
43 } as EnvStatus,
44 ],
45 );
46 return async () => {
47 status.mockRestore();
48 await runtime.stop();
49 };
50 }
test/kernel.test.ts +76
@@ -3,6 +3,9 @@ import { mkdtempSync, rmSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { openDb } from "../src/kernel/db.ts";
6 import { createUser, verifyPassword } from "../src/core/users.ts";
7 import { migrations } from "../src/core/schema.ts";
8 import { CHEAP, cheapened, realHash } from "./helpers/cheap-hash.ts";
69 import { ForgeError, defect } from "../src/kernel/error.ts";
710 import { id, secret } from "../src/kernel/id.ts";
811 import {
@@ -148,6 +151,47 @@ describe("db", () => {
148151 }
149152 });
150153
154 test("a fresh database after the first is a copy of the migrated empty one (#2038)", () => {
155 const dir = mkdtempSync(join(tmpdir(), "forge-kernel-"));
156 try {
157 // random() tells a copy from a second run of the migrations.
158 const list = [
159 "CREATE TABLE seed (n INTEGER); INSERT INTO seed (n) VALUES (random())",
160 "CREATE TABLE parent (id INTEGER PRIMARY KEY); CREATE TABLE child (pid INTEGER REFERENCES parent(id))",
161 ];
162 const seed = (db: ReturnType<typeof openDb>) =>
163 db.query<{ n: number }, []>("SELECT n FROM seed").all();
164 const first = openDb(join(dir, "a.db"), list);
165 // Written after the image was taken: never part of a later copy.
166 first.exec("INSERT INTO parent (id) VALUES (1)");
167 const second = openDb(join(dir, "b.db"), list);
168 const memory = openDb(":memory:", list);
169 for (const copy of [second, memory]) {
170 expect(seed(copy)).toEqual(seed(first));
171 expect(copy.query("SELECT id FROM parent").all()).toEqual([]);
172 expect(() => copy.exec("INSERT INTO child (pid) VALUES (99)")).toThrow();
173 expect(copy.query("SELECT $n AS n").get({ n: 3 })).toEqual({ n: 3 });
174 expect(
175 copy.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version,
176 ).toBe(2);
177 }
178 expect(
179 second.query<{ journal_mode: string }, []>("PRAGMA journal_mode").get()?.journal_mode,
180 ).toBe("wal");
181 // A list that grew since the copy was taken still runs what is new.
182 list.push("CREATE TABLE later (z INTEGER)");
183 const third = openDb(join(dir, "c.db"), list);
184 expect(seed(third)).toEqual(seed(first));
185 third.exec("INSERT INTO later (z) VALUES (1)");
186 // A different list is never handed this one's copy.
187 const other = openDb(join(dir, "d.db"), [list[0]!]);
188 expect(seed(other)).not.toEqual(seed(first));
189 for (const db of [first, second, memory, third, other]) db.close();
190 } finally {
191 rmSync(dir, { recursive: true, force: true });
192 }
193 });
194
151195 test("foreign keys are enforced", () => {
152196 const db = openDb(":memory:", [
153197 "CREATE TABLE parent (id INTEGER PRIMARY KEY); CREATE TABLE child (pid INTEGER REFERENCES parent(id))",
@@ -155,3 +199,35 @@ describe("db", () => {
155199 expect(() => db.exec("INSERT INTO child (pid) VALUES (99)")).toThrow();
156200 });
157201 });
202
203 describe("passwords under test are hashed at argon2id's floor (#2038)", () => {
204 test("bunfig preloads it: an account made in a test stores the cheapest argon2id", async () => {
205 const db = openDb(":memory:", migrations);
206 const made = await createUser(db, {
207 trusted: true,
208 username: "alice",
209 email: "[email protected]",
210 password: "a-good-passphrase",
211 });
212 if (!made.ok) throw new Error(made.error);
213 const row = db
214 .query<{ password_hash: string }, []>("SELECT password_hash FROM user")
215 .get();
216 expect(row?.password_hash.startsWith("$argon2id$v=19$m=8,t=1,p=1$")).toBe(true);
217 expect((await verifyPassword(db, "alice", "a-good-passphrase")).ok).toBe(true);
218 expect((await verifyPassword(db, "alice", "a-bad-passphrase")).ok).toBe(false);
219 db.close();
220 });
221
222 test("only the default is cheapened; a named cost and the runtime's own are kept", async () => {
223 expect(cheapened(undefined)).toEqual(CHEAP);
224 expect(cheapened("argon2id")).toEqual(CHEAP);
225 const named = { algorithm: "argon2id", memoryCost: 16, timeCost: 2 } as const;
226 expect(cheapened(named)).toBe(named);
227 expect(cheapened("bcrypt")).toBe("bcrypt");
228 // What production stores still verifies: the cost lives in the hash.
229 const real = await realHash("a-good-passphrase", "argon2id");
230 expect(real.startsWith("$argon2id$v=19$m=65536,t=2,p=1$")).toBe(true);
231 expect(await Bun.password.verify("a-good-passphrase", real)).toBe(true);
232 });
233 });
test/shards.test.ts +26 −1
@@ -10,8 +10,10 @@ import { join } from "node:path";
1010 import {
1111 SECONDS_PER_BYTE_DEFAULT,
1212 SHARDS_MAX,
13 WORKER_MEMORY_BYTES,
1314 formatTimes,
1415 heaviestFirst,
16 memoryLimit,
1517 overlayTimes,
1618 parseTimes,
1719 refreshTimes,
@@ -213,12 +215,35 @@ describe("parseTimes", () => {
213215
214216 describe("shardCount", () => {
215217 test("one per CPU, capped, at least one, never more than files", () => {
216 expect(shardCount(80, 22)).toEqual({ ok: true, value: SHARDS_MAX });
218 expect(shardCount(80, 400)).toEqual({ ok: true, value: SHARDS_MAX });
217219 expect(shardCount(4, 22)).toEqual({ ok: true, value: 4 });
218220 expect(shardCount(1, 22)).toEqual({ ok: true, value: 1 });
219221 expect(shardCount(80, 3)).toEqual({ ok: true, value: 3 });
220222 });
221223
224 test("the check cell's 16 CPUs and 8 GiB run 16 at once; less memory runs fewer (#2038)", () => {
225 const GiB = 1024 * 1024 * 1024;
226 expect(SHARDS_MAX).toBeGreaterThanOrEqual(16);
227 expect(shardCount(16, 400, undefined, 8 * GiB)).toEqual({ ok: true, value: 16 });
228 expect(shardCount(16, 400, undefined, 4 * GiB)).toEqual({ ok: true, value: 8 });
229 expect(shardCount(16, 400, undefined, 6 * GiB + 100)).toEqual({ ok: true, value: 12 });
230 // Less than one worker's room still runs one.
231 expect(shardCount(16, 400, undefined, WORKER_MEMORY_BYTES - 1)).toEqual({ ok: true, value: 1 });
232 // Memory never raises the count past the CPUs, and the override wins.
233 expect(shardCount(4, 400, undefined, 64 * GiB)).toEqual({ ok: true, value: 4 });
234 expect(shardCount(16, 400, "20", 4 * GiB)).toEqual({ ok: true, value: 20 });
235 });
236
237 test("memoryLimit: the least of the machine and each cgroup limit it can read", () => {
238 const GiB = 1024 * 1024 * 1024;
239 expect(memoryLimit(256 * GiB, [`${8 * GiB}\n`, undefined])).toBe(8 * GiB);
240 expect(memoryLimit(256 * GiB, ["max\n", undefined])).toBe(256 * GiB);
241 // cgroup v1's "no limit" is a page-rounded 2^63: bigger than the machine.
242 expect(memoryLimit(4 * GiB, [undefined, "9223372036854771712\n"])).toBe(4 * GiB);
243 expect(memoryLimit(4 * GiB, ["", "-1", "1e9", "0"])).toBe(4 * GiB);
244 expect(memoryLimit(64 * GiB, [`${16 * GiB}`, `${8 * GiB}`])).toBe(8 * GiB);
245 });
246
222247 test("FORGE_CHECK_SHARDS turns it up or down; nonsense is refused", () => {
223248 expect(shardCount(80, 22, "2")).toEqual({ ok: true, value: 2 });
224249 expect(shardCount(4, 22, "12")).toEqual({ ok: true, value: 12 });
test/studio-changes.test.ts added +1292
@@ -0,0 +1,1292 @@
1 /**
2 * The studio: one signed-in owner's lists and change pages, tells and
3 * doors. One of the files the studio's suite was split into so no one file
4 * sets the gate's floor (#2038); the rest are test/studio*.test.ts.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdirSync, writeFileSync } from "node:fs";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { dirname, join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { enableApp } from "../src/actions/apps.ts";
14 import { getAppByRepo } from "../src/core/apps.ts";
15 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import {
17 assignIssue,
18 openIssue,
19 setIssueLabels,
20 setIssueState,
21 } from "../src/actions/issues.ts";
22 import { openPull } from "../src/actions/pulls.ts";
23 import {
24 commitToBranch,
25 createLabel,
26 createRepo,
27 } from "../src/actions/repos.ts";
28 import { recordLook } from "../src/core/looks.ts";
29 import { setDirectionIssue, type Repo } from "../src/core/repos.ts";
30 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
31 import { createUser, getUserByName, type User } from "../src/core/users.ts";
32 import { serve, type Servers } from "../src/http/server.ts";
33 import { PNG, serving } from "./helpers/studio.ts";
34
35 describe("the studio, continued", () => {
36 let dataDir: string;
37 let app: App;
38 let servers: Servers;
39 let base: string;
40 let alice: User;
41 let repo: Repo;
42 let cookie: string;
43
44 const get = (path: string, extra: Record<string, string> = {}) =>
45 fetch(`${base}${path}`, {
46 headers: { cookie: `${cookie}; forge_mode=studio`, ...extra },
47 redirect: "manual",
48 });
49 const post = (path: string, body: Record<string, string>, extra = "") =>
50 fetch(`${base}${path}`, {
51 method: "POST",
52 headers: { cookie: `${cookie}${extra}`, origin: base },
53 body: new URLSearchParams(body),
54 redirect: "manual",
55 });
56
57 beforeAll(async () => {
58 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-"));
59 app = createApp({ dataDir, port: 0 });
60 servers = await serve(app);
61 base = servers.url;
62 const a = await createUser(app.db, { trusted: true,
63 username: "alice",
64 email: "[email protected]",
65 password: "a-good-passphrase",
66 adminIfFirst: true,
67 });
68 if (!a.ok) throw new Error(a.error);
69 alice = a.value;
70 cookie = (
71 (
72 await fetch(`${base}/signin`, {
73 method: "POST",
74 body: new URLSearchParams({
75 username: "alice",
76 password: "a-good-passphrase",
77 }),
78 redirect: "manual",
79 })
80 ).headers.get("set-cookie") ?? ""
81 ).split(";")[0]!;
82 const made = await createRepo(app, alice, {
83 name: "notes",
84 visibility: "private",
85 description: "a place for notes",
86 });
87 if (!made.ok) throw new Error(String(made.error));
88 repo = made.value;
89 const seeded = await commitToBranch(app, alice, repo, {
90 message: "Start",
91 files: [
92 {
93 path: "package.json",
94 content: '{"name":"notes","scripts":{"start":"bun run server.ts"}}\n',
95 },
96 {
97 path: "server.ts",
98 content:
99 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
100 },
101 ],
102 });
103 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
104 const enabled = await enableApp(app, alice, repo, { slug: "notes" });
105 if (!enabled.ok) throw new Error(String(enabled.error));
106 const scout = await createAgent(app, alice, { username: "scout" });
107 if (!scout.ok) throw new Error(String(scout.error));
108 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
109 });
110
111 afterAll(async () => {
112 servers.stop();
113 app.close();
114 await rm(dataDir, { recursive: true, force: true });
115 });
116
117 test("a reader of a public tell sees its Details but none of the forms that change it", async () => {
118 const open = await createRepo(app, alice, {
119 name: "publictell",
120 visibility: "public",
121 description: "",
122 });
123 if (!open.ok) throw new Error(String(open.error));
124 const told = openIssue(app, alice, open.value, { title: "Anyone may read" });
125 if (!told.ok) throw new Error(String(told.error));
126 const at = `/alice/publictell/issues/${told.value.number}`;
127 const anon = await (await fetch(`${base}${at}`)).text();
128 expect(anon).toContain("Anyone may read");
129 expect(anon).toContain('<details class="quiet-details mt">');
130 for (const verb of ["assignee", "labels", "dependencies", "state", "comments"])
131 expect(anon, verb).not.toContain(`action="${at}/${verb}"`);
132 });
133
134 test("the repository's direction never reads as waiting forever", async () => {
135 const direction = openIssue(app, alice, repo, {
136 title: "Direction",
137 body: "Ship faster.",
138 });
139 if (!direction.ok) throw new Error(String(direction.error));
140 setDirectionIssue(app.db, repo.id, direction.value.id);
141
142 const studio = await (
143 await get(`/alice/notes/issues/${direction.value.number}`)
144 ).text();
145 expect(studio).toContain("standing direction");
146 expect(studio).not.toContain('data-word="told"');
147 // The direction keeps its own sentence; nobody was assigned, so the
148 // assignee line does not add a second, contradicting one (#214).
149 expect(studio).not.toContain("nobody has it yet");
150
151 // Leave the repository as the other tests in this suite found it.
152 setDirectionIssue(app.db, repo.id, null);
153 });
154
155 test("a tell on a repository that is not an app renders exactly what it renders today, even in studio mode", async () => {
156 const plain = await createRepo(app, alice, {
157 name: "plaintell",
158 visibility: "private",
159 description: "no app here",
160 });
161 if (!plain.ok) throw new Error(String(plain.error));
162 const told = openIssue(app, alice, plain.value, {
163 title: "Plain issue",
164 body: "Nothing special.",
165 });
166 if (!told.ok) throw new Error(String(told.error));
167
168 const studioCookie = await (
169 await get(`/alice/plaintell/issues/${told.value.number}`)
170 ).text();
171 expect(studioCookie).toContain("<h2>Labels</h2>");
172 expect(studioCookie).toContain("<h2>Assignee</h2>");
173 expect(studioCookie).toContain("<h2>Depends on</h2>");
174 });
175
176 test("everything told is the one issue list, in the studio's own words; the word filter narrows it, and ?mode=dev changes nothing", async () => {
177 const told = openIssue(app, alice, repo, {
178 title: "List test: not picked up yet",
179 body: "waits for the crew",
180 });
181 if (!told.ok) throw new Error(String(told.error));
182
183 const page = await (await get("/alice/notes/issues")).text();
184 expect(page).toContain("List test: not picked up yet");
185 expect(page).toContain('data-word="told"');
186 expect(page).not.toContain("Clear filters");
187 expect(page).not.toContain('name="label"');
188 expect(page).not.toContain(`>#${told.value.number} opened`);
189 expect(page).toContain(`href="/alice/notes/issues?word=told"`);
190
191 const filtered = await (await get("/alice/notes/issues?word=told")).text();
192 expect(filtered).toContain("List test: not picked up yet");
193 const other = await (await get("/alice/notes/issues?word=shipped")).text();
194 expect(other).not.toContain("List test: not picked up yet");
195
196 // There is no second list behind the mode: the cookie and the escape
197 // hatch both answer the same rows in the same words.
198 for (const dev of [
199 await get("/alice/notes/issues", { cookie: `${cookie}; forge_mode=dev` }),
200 await get("/alice/notes/issues"),
201 ]) {
202 expect(dev.status).toBe(200);
203 const text = await dev.text();
204 expect(text).toContain("List test: not picked up yet");
205 expect(text).toContain('data-word="told"');
206 expect(text).not.toContain(`>#${told.value.number} opened`);
207 expect(text).not.toContain("See dev's list");
208 }
209 });
210
211 test("every change is the one pull list, in the studio's own words; the word filter narrows it, and ?mode=dev changes nothing", async () => {
212 const branch = await commitToBranch(app, alice, repo, {
213 branch: "feature/list-change",
214 base: "main",
215 message: "List change",
216 files: [{ path: "LIST_CHANGE.md", content: "a change\n" }],
217 });
218 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
219 const opened = await openPull(app, alice, repo, {
220 title: "List test: a green change",
221 body: "",
222 baseBranch: "main",
223 headBranch: "feature/list-change",
224 });
225 if (!opened.ok) throw new Error(String(opened.error));
226 const pull = opened.value;
227 const check = createRun(app.db, {
228 repoId: repo.id,
229 kind: "check",
230 issueId: pull.id,
231 env: `pull-${pull.number}`,
232 commitOid: pull.headOid,
233 reason: "pull",
234 });
235 claimRun(app.db, check.id);
236 finishRun(app.db, check.id, { state: "ok", summary: "check passed" });
237
238 const page = await (await get("/alice/notes/pulls")).text();
239 expect(page).toContain("List test: a green change");
240 expect(page).toContain('data-word="look"');
241 expect(page).not.toContain(`${pull.headBranch} &rarr; ${pull.baseBranch}`);
242 expect(page).not.toContain(`${pull.headBranch}`);
243
244 const filtered = await (await get("/alice/notes/pulls?word=look")).text();
245 expect(filtered).toContain("List test: a green change");
246 const other = await (await get("/alice/notes/pulls?word=shipped")).text();
247 expect(other).not.toContain("List test: a green change");
248
249 for (const dev of [
250 await get("/alice/notes/pulls", { cookie: `${cookie}; forge_mode=dev` }),
251 await get("/alice/notes/pulls"),
252 ]) {
253 expect(dev.status).toBe(200);
254 const text = await dev.text();
255 expect(text).toContain("List test: a green change");
256 expect(text).toContain('data-word="look"');
257 expect(text).not.toContain(pull.headBranch);
258 expect(text).not.toContain("See dev's list");
259 }
260 // A green pull left open now asks the front door too (#208) — close it
261 // so it does not linger into a later test's own count of asks.
262 expect(setIssueState(app, alice, repo, pull, "closed").ok).toBe(true);
263 });
264
265 test("every run is the run list in the studio's own words; the word filter narrows it, and ?mode=dev renders today's list", async () => {
266 const scout = getUserByName(app.db, "scout")!;
267 const run = createRun(app.db, {
268 repoId: repo.id,
269 kind: "agent",
270 actorId: scout.id,
271 reason: "assigned",
272 });
273 claimRun(app.db, run.id);
274
275 const page = await (await get("/alice/notes/runs")).text();
276 expect(page).toContain('data-word="working"');
277 expect(page).toContain(`href="/alice/notes/runs/${run.id}"`);
278
279 const filtered = await (await get("/alice/notes/runs?word=working")).text();
280 expect(filtered).toContain(`href="/alice/notes/runs/${run.id}"`);
281 const other = await (await get("/alice/notes/runs?word=shipped")).text();
282 expect(other).not.toContain(`href="/alice/notes/runs/${run.id}"`);
283
284 // One list (#629): the cookie and the query both answer it.
285 finishRun(app.db, run.id, { state: "failed", summary: "boom" });
286 for (const dev of [
287 await get("/alice/notes/runs?word=stopped", {
288 cookie: `${cookie}; forge_mode=dev`,
289 }),
290 await get("/alice/notes/runs?word=stopped"),
291 ]) {
292 const text = await dev.text();
293 expect(text).toContain('data-word="stopped"');
294 expect(text).toContain(`href="/alice/notes/runs/${run.id}"`);
295 expect(text).not.toContain("See dev's list");
296 }
297 // Deploys and checks are one quiet link away, and `?kind=` keeps the
298 // meaning a link to dev's list gave it.
299 expect(page).toContain('href="/alice/notes/runs?kind=deploy"');
300 expect(page).toContain('href="/alice/notes/runs?kind=check"');
301 const deploys = await (await get("/alice/notes/runs?kind=deploy")).text();
302 expect(deploys).not.toContain(`href="/alice/notes/runs/${run.id}"`);
303 expect(deploys).toContain('href="/alice/notes/runs?kind=deploy&amp;word=told"');
304 });
305
306 test("issues, pulls and runs on a repository that is not an app are the same one list", async () => {
307 const plain = await createRepo(app, alice, {
308 name: "plainlists",
309 visibility: "private",
310 description: "no app here",
311 });
312 if (!plain.ok) throw new Error(String(plain.error));
313 const told = openIssue(app, alice, plain.value, {
314 title: "Plain list: an issue",
315 body: "",
316 });
317 if (!told.ok) throw new Error(String(told.error));
318
319 for (const mode of ["studio", "dev"]) {
320 const viewer = `${cookie}; forge_mode=${mode}`;
321 const issues = await (
322 await get("/alice/plainlists/issues", { cookie: viewer })
323 ).text();
324 expect(issues).toContain("Plain list: an issue");
325 // A plain repository speaks of issues: an untouched one reads "open",
326 // and neither Told nor Look is a filter there (#683 follow-up).
327 expect(issues).toMatch(/data-word="told"\s*>open</);
328 expect(issues).toContain(`href="/alice/plainlists/issues?word=working"`);
329 expect(issues).not.toContain(`href="/alice/plainlists/issues?word=told"`);
330 expect(issues).not.toContain(`href="/alice/plainlists/issues?word=look"`);
331 expect(issues).toContain(
332 `href="/alice/plainlists/tells/${told.value.number}"`,
333 );
334 expect(issues).not.toContain("See dev's list");
335
336 const pulls = await (
337 await get("/alice/plainlists/pulls", { cookie: viewer })
338 ).text();
339 expect(pulls).toContain(`href="/alice/plainlists/pulls?word=working"`);
340 expect(pulls).not.toContain(`href="/alice/plainlists/pulls?word=look"`);
341 expect(pulls).not.toContain("See dev's list");
342 }
343
344 const runs = await (await get("/alice/plainlists/runs")).text();
345 expect(runs).toContain(`href="/alice/plainlists/runs?word=working"`);
346 expect(runs).not.toContain("See dev's list");
347 });
348
349 test("/tells and /changes are the studio's own doors onto the same lists /issues and /pulls reach — same words, same filter, same paging", async () => {
350 const told = openIssue(app, alice, repo, {
351 title: "Door test: a tell",
352 body: "reached through /tells too",
353 });
354 if (!told.ok) throw new Error(String(told.error));
355
356 const viaIssues = await (await get("/alice/notes/issues")).text();
357 const viaTells = await (await get("/alice/notes/tells")).text();
358 expect(viaTells).toContain("Door test: a tell");
359 expect(viaTells).toContain('data-word="told"');
360 // Self-links (the word filter, paging) follow whichever door was used,
361 // so a dev URL asked for in studio mode still answers exactly as it
362 // does today.
363 expect(viaTells).toContain(`href="/alice/notes/tells?word=told"`);
364 expect(viaIssues).toContain(`href="/alice/notes/issues?word=told"`);
365
366 const filtered = await (await get("/alice/notes/tells?word=told")).text();
367 expect(filtered).toContain("Door test: a tell");
368 const other = await (await get("/alice/notes/tells?word=shipped")).text();
369 expect(other).not.toContain("Door test: a tell");
370
371 const branch = await commitToBranch(app, alice, repo, {
372 branch: "feature/door-test",
373 base: "main",
374 message: "Door test change",
375 files: [{ path: "DOOR_TEST.md", content: "a change\n" }],
376 });
377 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
378 const opened = await openPull(app, alice, repo, {
379 title: "Door test: a change",
380 body: "",
381 baseBranch: "main",
382 headBranch: "feature/door-test",
383 });
384 if (!opened.ok) throw new Error(String(opened.error));
385 const doorPull = opened.value;
386 const doorCheck = createRun(app.db, {
387 repoId: repo.id,
388 kind: "check",
389 issueId: doorPull.id,
390 env: `pull-${doorPull.number}`,
391 commitOid: doorPull.headOid,
392 reason: "pull",
393 });
394 claimRun(app.db, doorCheck.id);
395 finishRun(app.db, doorCheck.id, { state: "ok", summary: "check passed" });
396
397 const viaPulls = await (await get("/alice/notes/pulls")).text();
398 const viaChanges = await (await get("/alice/notes/changes")).text();
399 expect(viaChanges).toContain("Door test: a change");
400 expect(viaChanges).toContain('data-word="look"');
401 expect(viaChanges).toContain(`href="/alice/notes/changes?word=look"`);
402 expect(viaPulls).toContain(`href="/alice/notes/pulls?word=look"`);
403
404 const changeFiltered = await (
405 await get("/alice/notes/changes?word=look")
406 ).text();
407 expect(changeFiltered).toContain("Door test: a change");
408 const changeOther = await (
409 await get("/alice/notes/changes?word=shipped")
410 ).text();
411 expect(changeOther).not.toContain("Door test: a change");
412 // A green pull left open now asks the front door too (#208) — close it
413 // so it does not linger into a later test's own count of asks.
414 expect(setIssueState(app, alice, repo, doorPull, "closed").ok).toBe(true);
415 });
416
417 test("/tells and /changes answer a dev-mode viewer, ?mode=dev, and a repository that is not an app with the same list, never a bounce", async () => {
418 const plain = await createRepo(app, alice, {
419 name: "doorlists",
420 visibility: "private",
421 description: "no app here",
422 });
423 if (!plain.ok) throw new Error(String(plain.error));
424
425 for (const [path, viewer] of [
426 ["/alice/notes/tells", `${cookie}; forge_mode=dev`],
427 ["/alice/notes/changes", `${cookie}; forge_mode=dev`],
428 ["/alice/notes/tells", `${cookie}; forge_mode=studio`],
429 ["/alice/doorlists/tells", cookie],
430 ["/alice/doorlists/changes", cookie],
431 ] as const) {
432 const res = await get(path, { cookie: viewer });
433 expect(res.status).toBe(200);
434 const text = await res.text();
435 expect(text).toContain(`href="${path.split("?")[0]}?word=`);
436 }
437 });
438
439 test("the one list keeps what dev's list could narrow by: labels as chips, and who has it, who opened it, a piece of the title, and the older ?state= address", async () => {
440 const shelf = await createRepo(app, alice, {
441 name: "facetlists",
442 visibility: "private",
443 description: "labels and narrowings",
444 });
445 if (!shelf.ok) throw new Error(String(shelf.error));
446 const r = shelf.value;
447 const bug = createLabel(app, alice, r, "bug", "d73a4a");
448 if (!bug.ok) throw new Error(String(bug.error));
449 const a = openIssue(app, alice, r, { title: "Facet: a broken thing", body: "" });
450 const b = openIssue(app, alice, r, { title: "Facet: a wish", body: "" });
451 const c = openIssue(app, alice, r, { title: "Facet: long done", body: "" });
452 if (!a.ok || !b.ok || !c.ok) throw new Error("open failed");
453 expect(setIssueLabels(app, alice, r, a.value, ["bug"]).ok).toBe(true);
454 expect(assignIssue(app, alice, r, b.value, alice).ok).toBe(true);
455 expect(setIssueState(app, alice, r, c.value, "closed").ok).toBe(true);
456
457 const list = await (await get("/alice/facetlists/issues")).text();
458 // The row wears its label, and the repository's labels narrow the list.
459 expect(list).toContain('class="label lc-d73a4a">bug</span>');
460 expect(list).toContain(`href="/alice/facetlists/issues?label=bug"`);
461 expect(list).toContain(".lc-d73a4a{--label:#d73a4a}");
462 expect(list).not.toContain("Facet: long done");
463
464 const labelled = await (await get("/alice/facetlists/issues?label=bug")).text();
465 expect(labelled).toContain("Facet: a broken thing");
466 expect(labelled).not.toContain("Facet: a wish");
467 // The chip in force clears itself, and a word keeps the label.
468 expect(labelled).toMatch(
469 /href="\/alice\/facetlists\/issues"\s+aria-current="true"/,
470 );
471 expect(labelled).toContain(`href="/alice/facetlists/issues?word=working&amp;label=bug"`);
472
473 const mine = await (await get("/alice/facetlists/issues?assignee=alice")).text();
474 expect(mine).toContain("Facet: a wish");
475 expect(mine).not.toContain("Facet: a broken thing");
476 expect(mine).toContain("alice has it ✕");
477
478 const titled = await (await get("/alice/facetlists/issues?q=wish")).text();
479 expect(titled).toContain("Facet: a wish");
480 expect(titled).not.toContain("Facet: a broken thing");
481 expect(titled).toContain("wish in the title ✕");
482
483 const bySomeone = await (await get("/alice/facetlists/issues?author=nobody")).text();
484 expect(bySomeone).not.toContain("Facet: a wish");
485 expect(bySomeone).toContain("nobody opened it ✕");
486
487 // `?state=closed` from an older link still means what it said.
488 const closed = await (await get("/alice/facetlists/issues?state=closed")).text();
489 expect(closed).toContain("Facet: long done");
490 expect(closed).not.toContain("Facet: a wish");
491 // The older address's narrowing is shown, and clears itself.
492 expect(closed).toContain("closed ✕");
493 expect(closed).toMatch(/href="\/alice\/facetlists\/issues"\s+aria-current="true"/);
494 expect(
495 await (await get("/alice/facetlists/issues?state=all")).text(),
496 ).toContain("open and closed ✕");
497 // Closed with nothing merged is not dropped from the list: it reads
498 // done, under shipped, where whoever closed it would look.
499 const shipped = await (await get("/alice/facetlists/issues?word=shipped")).text();
500 expect(shipped).toContain("Facet: long done");
501 expect(shipped).toContain('data-word="done"');
502 const all = await (await get("/alice/facetlists/issues?state=all")).text();
503 expect(all).toContain("Facet: long done");
504 expect(all).toContain("Facet: a wish");
505 });
506
507 test("a plain repository's pages promise no deploy and say issue: an open change waits on a person, an issue closes as an issue, and only its author or a writer may close it", async () => {
508 const plain = await createRepo(app, alice, {
509 name: "plainwords",
510 visibility: "public",
511 description: "",
512 });
513 if (!plain.ok) throw new Error(String(plain.error));
514 const seeded = await commitToBranch(app, alice, plain.value, {
515 message: "Start",
516 files: [{ path: "README.md", content: "hi\n" }],
517 });
518 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
519 const branch = await commitToBranch(app, alice, plain.value, {
520 branch: "plain-change",
521 base: "main",
522 message: "Plain change",
523 files: [{ path: "PLAIN.md", content: "x\n" }],
524 });
525 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
526 const opened = await openPull(app, alice, plain.value, {
527 title: "A plain change",
528 body: "",
529 baseBranch: "main",
530 headBranch: "plain-change",
531 });
532 if (!opened.ok) throw new Error(String(opened.error));
533 const changes = await (await get("/alice/plainwords/pulls")).text();
534 expect(changes).toContain("open — waiting on a person");
535 expect(changes).not.toContain("no deploy of this head yet");
536
537 const told = openIssue(app, alice, plain.value, { title: "A plain issue" });
538 if (!told.ok) throw new Error(String(told.error));
539 const at = `/alice/plainwords/issues/${told.value.number}`;
540 const own = await (await get(at)).text();
541 expect(own).toContain("Close issue");
542 expect(own).not.toContain("Close tell");
543 expect(own).toContain(`action="${at}/state"`);
544
545 // A signed-in stranger may comment on a public issue, never close it.
546 const stranger = await createUser(app.db, { trusted: true,
547 username: "plainstranger",
548 email: "[email protected]",
549 password: "a-good-passphrase",
550 });
551 if (!stranger.ok) throw new Error(stranger.error);
552 const strangerCookie = (
553 (
554 await fetch(`${base}/signin`, {
555 method: "POST",
556 body: new URLSearchParams({
557 username: "plainstranger",
558 password: "a-good-passphrase",
559 }),
560 redirect: "manual",
561 })
562 ).headers.get("set-cookie") ?? ""
563 ).split(";")[0]!;
564 const seen = await (
565 await fetch(`${base}${at}`, { headers: { cookie: strangerCookie } })
566 ).text();
567 expect(seen).toContain('id="comment-body"');
568 expect(seen).not.toContain(`action="${at}/state"`);
569 expect(seen).not.toContain("Close issue");
570
571 // Signed out on a public repository: the way in is offered.
572 const anon = await (
573 await fetch(`${base}/alice/plainwords/issues`, { redirect: "manual" })
574 ).text();
575 expect(anon).toContain("Sign in to open an issue");
576 expect(anon).toContain("Report a problem</a");
577 });
578
579 test("the list's one verb besides opening a row is compose, for a signed-in person only, in the repository's own words", async () => {
580 const plain = await createRepo(app, alice, {
581 name: "composelists",
582 visibility: "public",
583 description: "no app here",
584 });
585 if (!plain.ok) throw new Error(String(plain.error));
586
587 const signedIn = await (await get("/alice/composelists/issues")).text();
588 expect(signedIn).toContain('href="/alice/composelists/issues/new"');
589 expect(signedIn).toContain("New issue");
590 const app_ = await (await get("/alice/notes/issues")).text();
591 expect(app_).toContain('href="/alice/notes/issues/new"');
592 expect(app_).toContain("Tell the crew");
593
594 const anon = await (
595 await fetch(`${base}/alice/composelists/issues`, { redirect: "manual" })
596 ).text();
597 expect(anon).not.toContain('href="/alice/composelists/issues/new"');
598 // A stranger on a public repository can still report a problem.
599 expect(anon).toContain('href="/alice/composelists/report"');
600 // Someone signed in composes instead: no visible report link (the page's
601 // head still names the report path for agents that read it).
602 expect(signedIn).not.toMatch(
603 /<a\b[^>]*href="\/alice\/composelists\/report"/,
604 );
605 // The pull list has no compose of its own: a change starts from a branch.
606 const pulls = await (await get("/alice/composelists/pulls")).text();
607 expect(pulls).not.toContain('href="/alice/composelists/pulls/new"');
608 });
609
610 test("a reader without access gets the forge's not-found on /tells and /changes, exactly as /issues and /pulls", async () => {
611 const anonTells = await fetch(`${base}/alice/notes/tells`, {
612 headers: { cookie: "forge_mode=studio" },
613 redirect: "manual",
614 });
615 expect(anonTells.status).toBe(404);
616
617 const anonChanges = await fetch(`${base}/alice/notes/changes`, {
618 headers: { cookie: "forge_mode=studio" },
619 redirect: "manual",
620 });
621 expect(anonChanges.status).toBe(404);
622 });
623
624 test("/tells/:number and /changes/:number reach the same pages /issues/:number and /pulls/:number reach — one page, the same anchors, and forms that still post to dev's own address", async () => {
625 const told = openIssue(app, alice, repo, {
626 title: "Door test: one tell",
627 body: "reached through /tells/:number too",
628 });
629 if (!told.ok) throw new Error(String(told.error));
630 const n = told.value.number;
631
632 const viaTell = await get(`/alice/notes/tells/${n}`);
633 expect(viaTell.status).toBe(200);
634 const tellHtml = await viaTell.text();
635 const viaIssue = await (await get(`/alice/notes/issues/${n}`)).text();
636 // The same page, never a second copy of the markup.
637 expect(tellHtml).toContain("Door test: one tell");
638 expect(tellHtml).toContain('data-word="told"');
639 // Still waiting, nobody assigned: the one case the line is true (#214).
640 expect(tellHtml).toContain("nobody has it yet");
641 expect(viaIssue).toContain('data-word="told"');
642 // The write surface does not move with the noun: the reply, the close
643 // and every Details form still point at /issues/:number.
644 expect(tellHtml).toContain(`action="/alice/notes/issues/${n}/comments"`);
645 expect(tellHtml).toContain(`action="/alice/notes/issues/${n}/assignee"`);
646 // An anchor into the conversation still lands — the composer is on the
647 // page either door was used.
648 expect(tellHtml).toContain('id="comment-body"');
649
650 const branch = await commitToBranch(app, alice, repo, {
651 branch: "feature/one-change-door",
652 base: "main",
653 message: "One change door",
654 files: [{ path: "ONE_DOOR.md", content: "a change\n" }],
655 });
656 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
657 const opened = await openPull(app, alice, repo, {
658 title: "Door test: one change",
659 body: "",
660 baseBranch: "main",
661 headBranch: "feature/one-change-door",
662 });
663 if (!opened.ok) throw new Error(String(opened.error));
664 const p = opened.value.number;
665
666 const viaChange = await get(`/alice/notes/changes/${p}`);
667 expect(viaChange.status).toBe(200);
668 const changeHtml = await viaChange.text();
669 expect(changeHtml).toContain("Door test: one change");
670 // The studio's change, not dev's diff.
671 expect(changeHtml).not.toContain("diff-table");
672 expect(changeHtml).toContain(`action="/alice/notes/pulls/${p}/comments"`);
673 expect(changeHtml).toContain(`href="/alice/notes/pulls/${p}/files"`);
674 expect(changeHtml).toContain('id="comment-body"');
675
676 // Dev's own addresses answer the studio's pages where they stand — a
677 // link already written to one is never bounced anywhere.
678 const stillIssue = await get(`/alice/notes/issues/${n}`);
679 expect(stillIssue.status).toBe(200);
680 const stillPull = await get(`/alice/notes/pulls/${p}`);
681 expect(stillPull.status).toBe(200);
682
683 // The three lists, the app page and the search box all hand out the
684 // studio's noun, so a person is never given two addresses for one thing.
685 const tells = await (await get("/alice/notes/tells")).text();
686 expect(tells).toContain(`href="/alice/notes/tells/${n}"`);
687 const changes = await (await get("/alice/notes/changes")).text();
688 expect(changes).toContain(`href="/alice/notes/changes/${p}"`);
689 const appPage = await (await get("/alice/notes")).text();
690 expect(appPage).toContain(`href="/alice/notes/changes/${p}"`);
691 const search = await (await get("/search?q=Door+test")).text();
692 expect(search).toContain(`/alice/notes/tells/${n}`);
693 expect(search).toContain(`/alice/notes/changes/${p}`);
694 // The palette's remote rows are folded in the browser; the index tells
695 // the script which mode it is in, so it folds the same nouns.
696 expect(appPage).toContain('"studio":true');
697 });
698
699 test("/tells/:number answers a dev-mode viewer, ?mode=dev, and a repository that is not an app with the same page, never a bounce", async () => {
700 const told = openIssue(app, alice, repo, { title: "Door test: dev's way" });
701 if (!told.ok) throw new Error(String(told.error));
702 const n = told.value.number;
703
704 for (const res of [
705 await get(`/alice/notes/tells/${n}`, {
706 cookie: `${cookie}; forge_mode=dev`,
707 }),
708 await get(`/alice/notes/tells/${n}`),
709 ]) {
710 expect(res.status).toBe(200);
711 expect(await res.text()).toContain("Door test: dev");
712 }
713
714 const plain = await createRepo(app, alice, {
715 name: "doorpages",
716 visibility: "private",
717 description: "no app here",
718 });
719 if (!plain.ok) throw new Error(String(plain.error));
720 const plainTold = openIssue(app, alice, plain.value, {
721 title: "Not an app",
722 });
723 if (!plainTold.ok) throw new Error(String(plainTold.error));
724 const plainTell = await get(
725 `/alice/doorpages/tells/${plainTold.value.number}`,
726 );
727 expect(plainTell.status).toBe(200);
728 expect(await plainTell.text()).toContain("Not an app");
729 });
730
731 test("a reader without access gets the forge's not-found on /tells/:number and /changes/:number, exactly as /issues/:number and /pulls/:number", async () => {
732 const told = openIssue(app, alice, repo, { title: "Door test: private" });
733 if (!told.ok) throw new Error(String(told.error));
734 const n = told.value.number;
735
736 const anonTell = await fetch(`${base}/alice/notes/tells/${n}`, {
737 headers: { cookie: "forge_mode=studio" },
738 redirect: "manual",
739 });
740 expect(anonTell.status).toBe(404);
741 const anonIssue = await fetch(`${base}/alice/notes/issues/${n}`, {
742 headers: { cookie: "forge_mode=studio" },
743 redirect: "manual",
744 });
745 expect(anonIssue.status).toBe(anonTell.status);
746
747 // A number that is not a tell is not a tell at either address, and a
748 // change's number is not one under the tell's own noun.
749 const missing = await get("/alice/notes/tells/99999");
750 expect(missing.status).toBe(404);
751 const anonChange = await fetch(`${base}/alice/notes/changes/1`, {
752 headers: { cookie: "forge_mode=studio" },
753 redirect: "manual",
754 });
755 expect(anonChange.status).toBe(404);
756 });
757
758 test("a reader without access gets the forge's not-found on a tell, exactly as dev", async () => {
759 const told = openIssue(app, alice, repo, { title: "Private work" });
760 if (!told.ok) throw new Error(String(told.error));
761 const anon = await fetch(
762 `${base}/alice/notes/issues/${told.value.number}`,
763 { headers: { cookie: "forge_mode=studio" }, redirect: "manual" },
764 );
765 expect(anon.status).toBe(404);
766 });
767
768 test("/apps in the studio is the shelf, not dev's table: an app the viewer can land, never one they cannot, and no dev table markup; ?mode=dev is unchanged", async () => {
769 const bob = await createUser(app.db, { trusted: true,
770 username: "bob",
771 email: "[email protected]",
772 password: "a-good-passphrase",
773 });
774 if (!bob.ok) throw new Error(bob.error);
775 const hidden = await createRepo(app, bob.value, {
776 name: "hidden",
777 visibility: "private",
778 description: "bob's own, not alice's crew",
779 });
780 if (!hidden.ok) throw new Error(String(hidden.error));
781 const seededHidden = await commitToBranch(app, bob.value, hidden.value, {
782 message: "Start",
783 files: [
784 {
785 path: "package.json",
786 content:
787 '{"name":"hidden","scripts":{"start":"bun run server.ts"}}\n',
788 },
789 {
790 path: "server.ts",
791 content:
792 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
793 },
794 ],
795 });
796 if (!seededHidden.ok) throw new Error(JSON.stringify(seededHidden.error));
797 const enabledHidden = await enableApp(app, bob.value, hidden.value, {
798 slug: "hidden",
799 });
800 if (!enabledHidden.ok) throw new Error(String(enabledHidden.error));
801 const bobCookie = (
802 (
803 await fetch(`${base}/signin`, {
804 method: "POST",
805 body: new URLSearchParams({
806 username: "bob",
807 password: "a-good-passphrase",
808 }),
809 redirect: "manual",
810 })
811 ).headers.get("set-cookie") ?? ""
812 ).split(";")[0]!;
813 const bobGet = (path: string, extra: Record<string, string> = {}) =>
814 fetch(`${base}${path}`, {
815 headers: { cookie: `${bobCookie}; forge_mode=studio`, ...extra },
816 redirect: "manual",
817 });
818
819 // Bob can land his own app, but alice never crewed him onto "notes" —
820 // private and none of his — so the shelf omits it.
821 const studio = await (await bobGet("/apps")).text();
822 expect(studio).toContain('href="/bob/hidden"');
823 expect(studio).not.toContain('href="/alice/notes"');
824 expect(studio).not.toContain("not serving");
825 expect(studio).toContain('href="/apps?live=1"');
826
827 // One list (#630): dev's table is gone, and the cookie and the query
828 // both answer the shelf.
829 for (const dev of [
830 await bobGet("/apps", { cookie: `${bobCookie}; forge_mode=dev` }),
831 await bobGet("/apps"),
832 ]) {
833 const text = await dev.text();
834 expect(text).toContain('href="/apps?live=1"');
835 expect(text).not.toContain('href="/alice/notes"');
836 expect(text).not.toContain("Repositories that run.");
837 }
838 });
839
840 test("clicking your own name lands on /:owner, which leads with your apps on the shelf and New app as the primary action, in every mode; a stranger never sees a private one", async () => {
841 const studio = await (await get("/alice")).text();
842 expect(studio).toContain("<h2>Apps</h2>");
843 expect(studio).toContain('href="/alice/notes"');
844 expect(studio).toContain("not running");
845 expect(studio).toContain('href="/apps/new?owner=alice"');
846 // The repository list stays reachable, filter and all — nothing disappears.
847 expect(studio).toContain("data-search-input");
848 expect(studio).toContain("notes");
849
850 const dev = await (
851 await get("/alice", { cookie: `${cookie}; forge_mode=dev` })
852 ).text();
853 // One owner page (#633): the cookie changes nothing.
854 expect(dev).toContain("<h2>Apps</h2>");
855 expect(dev).toContain('href="/apps/new?owner=alice"');
856 expect(dev).toContain("not running");
857
858 const carol = await createUser(app.db, { trusted: true,
859 username: "carol",
860 email: "[email protected]",
861 password: "a-good-passphrase",
862 });
863 if (!carol.ok) throw new Error(carol.error);
864 const carolCookie = (
865 (
866 await fetch(`${base}/signin`, {
867 method: "POST",
868 body: new URLSearchParams({
869 username: "carol",
870 password: "a-good-passphrase",
871 }),
872 redirect: "manual",
873 })
874 ).headers.get("set-cookie") ?? ""
875 ).split(";")[0]!;
876 const stranger = await (
877 await fetch(`${base}/alice`, {
878 headers: { cookie: `${carolCookie}; forge_mode=studio` },
879 redirect: "manual",
880 })
881 ).text();
882 expect(stranger).not.toContain('href="/alice/notes"');
883 // Nothing to see is shown as nothing: no empty apps card for a visitor.
884 expect(stranger).not.toContain("No apps yet");
885 expect(stranger).not.toContain('href="/apps/new?owner=alice"');
886 });
887
888 test("signed out in studio, / leads with the public apps that are serving — their look, not a private one's", async () => {
889 // A second app, public, with a picture of its production. "notes" stays
890 // private, so its presence (or absence) is the negative case below.
891 const madePublic = await createRepo(app, alice, {
892 name: "beacon",
893 visibility: "public",
894 description: "a lighthouse in the fog",
895 });
896 if (!madePublic.ok) throw new Error(String(madePublic.error));
897 const beacon = madePublic.value;
898 const seededPublic = await commitToBranch(app, alice, beacon, {
899 message: "Start",
900 files: [
901 {
902 path: "package.json",
903 content:
904 '{"name":"beacon","scripts":{"start":"bun run server.ts"}}\n',
905 },
906 {
907 path: "server.ts",
908 content:
909 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
910 },
911 ],
912 });
913 if (!seededPublic.ok) throw new Error(JSON.stringify(seededPublic.error));
914 const enabledPublic = await enableApp(app, alice, beacon, {
915 slug: "beacon",
916 });
917 if (!enabledPublic.ok) throw new Error(String(enabledPublic.error));
918
919 // A look for each app's production, so the picture route has something
920 // real to answer for and refuse.
921 const dropLook = (appId: string, env: "production", commit: string) => {
922 const rel = join("looks", appId, `${env}-thumb.png`);
923 const abs = join(app.config.dataDir, rel);
924 mkdirSync(dirname(abs), { recursive: true });
925 writeFileSync(abs, PNG);
926 return recordLook(app.db, {
927 appId,
928 env,
929 commitOid: commit,
930 kind: "thumb",
931 path: rel,
932 });
933 };
934 const beaconApp = getAppByRepo(app.db, beacon.id)!;
935 const notesApp = getAppByRepo(app.db, repo.id)!;
936 dropLook(beaconApp.id, "production", seededPublic.value.commitOid);
937 dropLook(notesApp.id, "production", "0".repeat(40));
938
939 // Signed out, with the studio cookie: the public app's name, its look,
940 // and Sign in / Create an account — never the private one.
941 const done = await serving(app, servers);
942 const anon = await (
943 await fetch(`${base}/`, { headers: { cookie: "forge_mode=studio" } })
944 ).text();
945 expect(anon).toContain("beacon");
946 expect(anon).toContain("a lighthouse in the fog");
947 expect(anon).toContain(
948 `/looks/production/${seededPublic.value.commitOid}/thumb.png`,
949 );
950 expect(anon).toContain('href="/signin"');
951 expect(anon).toContain('href="/signup"');
952 expect(anon).not.toContain(">notes<");
953 expect(anon).not.toContain("<h1>Your code, your machine.</h1>");
954
955 // The private app's picture 404s for a stranger — asserted, not assumed.
956 const privateLook = await fetch(
957 `${base}/alice/notes/looks/production/${"0".repeat(40)}/thumb.png`,
958 { headers: { cookie: "forge_mode=studio" } },
959 );
960 expect(privateLook.status).toBe(404);
961 // The public one's does not.
962 const publicLook = await fetch(
963 `${base}/alice/beacon/looks/production/${seededPublic.value.commitOid}/thumb.png`,
964 { headers: { cookie: "forge_mode=studio" } },
965 );
966 expect(publicLook.status).toBe(200);
967
968 // `?mode=dev` answers a stranger with the same door: the dev landing
969 // folded into it (#396).
970 const dev = await (
971 await fetch(`${base}/?mode=dev`, {
972 headers: { cookie: "forge_mode=studio" },
973 })
974 ).text();
975 expect(dev).toContain("<h1>Apps, live.</h1>");
976 expect(dev).toContain("a lighthouse in the fog");
977 expect(dev).not.toContain("<h1>Your code, your machine.</h1>");
978 await done();
979 });
980
981 test("the front door's tile is the thumb, not the full desktop picture, when both exist (#287)", async () => {
982 const created = await createRepo(app, alice, {
983 name: "tinytile",
984 visibility: "public",
985 description: "picked its own thumb",
986 });
987 if (!created.ok) throw new Error(String(created.error));
988 const seeded = await commitToBranch(app, alice, created.value, {
989 message: "Start",
990 files: [
991 {
992 path: "package.json",
993 content:
994 '{"name":"tinytile","scripts":{"start":"bun run server.ts"}}\n',
995 },
996 {
997 path: "server.ts",
998 content:
999 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
1000 },
1001 ],
1002 });
1003 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1004 const enabled = await enableApp(app, alice, created.value, {
1005 slug: "tinytile",
1006 });
1007 if (!enabled.ok) throw new Error(String(enabled.error));
1008 const record = getAppByRepo(app.db, created.value.id)!;
1009 const commit = seeded.value.commitOid;
1010 const drop = (kind: "desktop" | "thumb") => {
1011 const rel = join("looks", record.id, `production-${kind}.png`);
1012 const abs = join(app.config.dataDir, rel);
1013 mkdirSync(dirname(abs), { recursive: true });
1014 writeFileSync(abs, PNG);
1015 recordLook(app.db, {
1016 appId: record.id,
1017 env: "production",
1018 commitOid: commit,
1019 kind,
1020 path: rel,
1021 });
1022 };
1023 drop("desktop");
1024 drop("thumb");
1025
1026 const done = await serving(app, servers);
1027 const anon = await (
1028 await fetch(`${base}/`, { headers: { cookie: "forge_mode=studio" } })
1029 ).text();
1030 await done();
1031 const tile = anon
1032 .split("<img")
1033 .find((chunk) =>
1034 chunk.includes(
1035 `/${created.value.ownerName}/${created.value.name}/looks/`,
1036 ),
1037 );
1038 expect(tile).toBeDefined();
1039 expect(tile).toContain(`/looks/production/${commit}/thumb.png`);
1040 expect(tile).toContain('width="640"');
1041 expect(tile).toContain('height="400"');
1042 });
1043
1044 test("the front door leads with apps the viewer can write, never a stranger's public one — but /apps still lists it, owner named", async () => {
1045 // A public app of alice's own, distinct from "notes" (private) and
1046 // "beacon" (made public by an earlier test) — self-contained so this
1047 // test does not lean on another test's state.
1048 const madePublic = await createRepo(app, alice, {
1049 name: "lighthouse",
1050 visibility: "public",
1051 description: "everybody can see this one",
1052 });
1053 if (!madePublic.ok) throw new Error(String(madePublic.error));
1054 const seeded = await commitToBranch(app, alice, madePublic.value, {
1055 message: "Start",
1056 files: [
1057 {
1058 path: "package.json",
1059 content:
1060 '{"name":"lighthouse","scripts":{"start":"bun run server.ts"}}\n',
1061 },
1062 {
1063 path: "server.ts",
1064 content:
1065 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
1066 },
1067 ],
1068 });
1069 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1070 const enabled = await enableApp(app, alice, madePublic.value, {
1071 slug: "lighthouse",
1072 });
1073 if (!enabled.ok) throw new Error(String(enabled.error));
1074
1075 // Erin just signed up: no apps of her own, but the forge already has a
1076 // public one.
1077 const erin = await createUser(app.db, { trusted: true,
1078 username: "erin",
1079 email: "[email protected]",
1080 password: "a-good-passphrase",
1081 });
1082 if (!erin.ok) throw new Error(erin.error);
1083 const erinCookie = (
1084 (
1085 await fetch(`${base}/signin`, {
1086 method: "POST",
1087 body: new URLSearchParams({
1088 username: "erin",
1089 password: "a-good-passphrase",
1090 }),
1091 redirect: "manual",
1092 })
1093 ).headers.get("set-cookie") ?? ""
1094 ).split(";")[0]!;
1095 const erinGet = (path: string) =>
1096 fetch(`${base}${path}`, {
1097 headers: { cookie: `${erinCookie}; forge_mode=studio` },
1098 redirect: "manual",
1099 });
1100
1101 // Erin's front door: the empty state and New app, never alice's public
1102 // app — #69's case, finally reachable.
1103 const erinHome = await (await erinGet("/")).text();
1104 expect(erinHome).toContain("No apps yet");
1105 expect(erinHome).toContain('href="/apps/new"');
1106 expect(erinHome).toContain('href="/erin"');
1107 expect(erinHome).not.toContain("/?mode=dev");
1108 expect(erinHome).not.toContain('href="/alice/lighthouse"');
1109 expect(erinHome).not.toContain("tell the crew something");
1110
1111 // Alice's own front door still leads with it.
1112 const aliceHome = await (await get("/")).text();
1113 expect(aliceHome).toContain('href="/alice/lighthouse"');
1114
1115 // /apps hides nothing: both see it, and it names its owner since neither
1116 // request is alice's own view of her own row — erin's outright, and
1117 // even alice's own listing spells it the same way for every row beside
1118 // her own.
1119 const erinApps = await (await erinGet("/apps")).text();
1120 expect(erinApps).toContain('href="/alice/lighthouse"');
1121 expect(erinApps).toContain('<span class="muted">alice/</span>lighthouse');
1122
1123 const aliceApps = await (await get("/apps")).text();
1124 expect(aliceApps).toContain('href="/alice/lighthouse"');
1125 });
1126
1127 test("a forge with no public apps opens on the studio's door with an empty shelf, never dev's git pitch — at every old landing URL (#396)", async () => {
1128 const dataDir2 = await mkdtemp(join(tmpdir(), "forge-studio-empty-"));
1129 const app2 = createApp({ dataDir: dataDir2, port: 0 });
1130 const servers2 = await serve(app2);
1131 try {
1132 // A public repository that is not an app is not on the shelf.
1133 const owner = await createUser(app2.db, { trusted: true,
1134 username: "olga",
1135 email: "[email protected]",
1136 password: "a-good-passphrase",
1137 });
1138 if (!owner.ok) throw new Error(owner.error);
1139 const plain = await createRepo(app2, owner.value, {
1140 name: "just-code",
1141 visibility: "public",
1142 });
1143 if (!plain.ok) throw new Error(String(plain.error));
1144 // The landing's old addresses: the default, each mode's cookie, and
1145 // the `?mode=dev` escape hatch.
1146 for (const [path, cookie] of [
1147 ["/", ""],
1148 ["/", "forge_mode=studio"],
1149 ["/", "forge_mode=dev"],
1150 ["/", ""],
1151 ["/", "forge_mode=dev"],
1152 ] as const) {
1153 const response = await fetch(`${servers2.url}${path}`, {
1154 headers: { cookie },
1155 redirect: "manual",
1156 });
1157 expect(response.status).toBe(200);
1158 const body = await response.text();
1159 expect(body).toContain("<h1>Apps, live.</h1>");
1160 expect(body).toContain("Nothing public is up yet");
1161 expect(body).toContain('href="/signup"');
1162 expect(body).toContain('href="/signin"');
1163 expect(body).not.toContain("<h1>Your code, your machine.</h1>");
1164 expect(body).not.toContain("Public repositories");
1165 expect(body).not.toContain(`href="/olga/just-code"`);
1166 }
1167 } finally {
1168 servers2.stop();
1169 app2.close();
1170 await rm(dataDir2, { recursive: true, force: true });
1171 }
1172 });
1173
1174 test("the door reads public apps, not a window of the newest repositories, and shows only the live ones (#288, #686)", async () => {
1175 const dataDir2 = await mkdtemp(join(tmpdir(), "forge-studio-door-"));
1176 const app2 = createApp({ dataDir: dataDir2, port: 0 });
1177 const servers2 = await serve(app2);
1178 try {
1179 const made = await createUser(app2.db, { trusted: true,
1180 username: "pia",
1181 email: "[email protected]",
1182 password: "a-good-passphrase",
1183 });
1184 if (!made.ok) throw new Error(made.error);
1185 const pia = made.value;
1186 const appOf = async (name: string) => {
1187 const created = await createRepo(app2, pia, {
1188 name,
1189 visibility: "public",
1190 });
1191 if (!created.ok) throw new Error(String(created.error));
1192 const enabled = await enableApp(app2, pia, created.value, {
1193 slug: name,
1194 });
1195 if (!enabled.ok) throw new Error(String(enabled.error));
1196 return getAppByRepo(app2.db, created.value.id)!;
1197 };
1198 const older = await appOf("older");
1199 const quiet = await appOf("quiet");
1200 // More public plain repositories than the old window held, all newer
1201 // than the apps.
1202 for (let n = 0; n < 25; n++) {
1203 const r = await createRepo(app2, pia, {
1204 name: `plain-${n}`,
1205 visibility: "public",
1206 });
1207 if (!r.ok) throw new Error(String(r.error));
1208 }
1209 // Each with a picture, so only liveness decides.
1210 for (const record of [older, quiet]) {
1211 mkdirSync(join(dataDir2, "looks", record.id), { recursive: true });
1212 writeFileSync(join(dataDir2, "looks", record.id, "thumb.png"), PNG);
1213 recordLook(app2.db, {
1214 appId: record.id,
1215 env: "production",
1216 commitOid: "0".repeat(40),
1217 kind: "thumb",
1218 path: `looks/${record.id}/thumb.png`,
1219 });
1220 }
1221 // `older` is serving, `quiet` is stopped: a stranger is shown what is
1222 // up, never what is down.
1223 const done = await serving(app2, servers2, (record) =>
1224 record.id === older.id ? "running" : "stopped",
1225 );
1226 try {
1227 const body = await (await fetch(`${servers2.url}/`)).text();
1228 expect(body).toContain('href="/pia/older"');
1229 expect(body).not.toContain('href="/pia/quiet"');
1230 expect(body).not.toContain('href="/pia/plain-0"');
1231 } finally {
1232 await done();
1233 }
1234 } finally {
1235 servers2.stop();
1236 app2.close();
1237 await rm(dataDir2, { recursive: true, force: true });
1238 }
1239 });
1240 test("a change on <crew>/<number>-<slug> links the tell it answers, and says so when that tell is closed; other branches show no tell", async () => {
1241 const told = openIssue(app, alice, repo, {
1242 title: "Answered tell",
1243 body: "",
1244 });
1245 if (!told.ok) throw new Error(String(told.error));
1246 const n = told.value.number;
1247 const open = async (branch: string, title: string) => {
1248 const c = await commitToBranch(app, alice, repo, {
1249 branch,
1250 base: "main",
1251 message: title,
1252 files: [{ path: `${branch.replace(/\//g, "_")}.md`, content: "x\n" }],
1253 });
1254 if (!c.ok) throw new Error(JSON.stringify(c.error));
1255 const o = await openPull(app, alice, repo, {
1256 title,
1257 body: "",
1258 baseBranch: "main",
1259 headBranch: branch,
1260 });
1261 if (!o.ok) throw new Error(String(o.error));
1262 return o.value;
1263 };
1264 const answering = await open(`scout/${n}-answer-it`, "Answers the tell");
1265 const stray = await open("scout/99999-nothing", "Answers nothing");
1266 const own = await open("revert-22", "A person's branch");
1267
1268 const list = await (await get("/alice/notes/changes")).text();
1269 const rowOf = (title: string) => {
1270 const at = list.indexOf(title);
1271 return list.slice(at, list.indexOf("</a>", at + 400) + 4);
1272 };
1273 expect(rowOf("Answers the tell")).toContain(`href="/alice/notes/tells/${n}"`);
1274 expect(rowOf("Answers the tell")).not.toContain("already done");
1275 expect(rowOf("Answers nothing")).not.toContain("for tell");
1276 expect(rowOf("A person's branch")).not.toContain("for tell");
1277
1278 setIssueState(app, alice, repo, told.value, "closed");
1279 const closedList = await (await get("/alice/notes/changes")).text();
1280 expect(closedList).toContain("its tell is already done");
1281 const page = await (
1282 await get(`/alice/notes/changes/${answering.number}`)
1283 ).text();
1284 expect(page).toContain(`href="/alice/notes/tells/${n}"`);
1285 expect(page).toContain("its tell is already done");
1286 for (const p of [stray, own]) {
1287 const res = await get(`/alice/notes/changes/${p.number}`);
1288 expect(res.status).toBe(200);
1289 expect(await res.text()).not.toContain("for tell");
1290 }
1291 });
1292 });
test/studio-door.test.ts added +1778
@@ -0,0 +1,1778 @@
1 /**
2 * The studio: the owner's page, every preview, the home that keeps itself
3 * current, the head, the terminal and the live fragment. One of the files
4 * the studio's suite was split into so no one file sets the gate's floor
5 * (#2038); the rest are test/studio*.test.ts.
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 { createApp, type App } from "../src/app.ts";
13 import { enableApp } from "../src/actions/apps.ts";
14 import { previewEnv, type EnvName } from "../src/apps/data.ts";
15 import { AppsRuntime, type EnvState } from "../src/apps/runtime.ts";
16 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
17 import { addToCrew, createAgent } from "../src/actions/crew.ts";
18 import { createOrg } from "../src/actions/orgs.ts";
19 import {
20 assignIssue,
21 openIssue,
22 setIssueState,
23 } from "../src/actions/issues.ts";
24 import { mergePull, openPull } from "../src/actions/pulls.ts";
25 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
26 import { KIND_CHIPS, setChip } from "../src/core/feed_chips.ts";
27 import type { Issue } from "../src/core/issues.ts";
28 import type { Pull } from "../src/core/pulls.ts";
29 import type { Repo } from "../src/core/repos.ts";
30 import {
31 claimRun,
32 createRun,
33 finishRun,
34 getRun,
35 isTerminal,
36 listRuns,
37 markServing,
38 } from "../src/core/runs.ts";
39 import { liveClientJs } from "../src/web/live.ts";
40 import { createUser, getUserByName, type User } from "../src/core/users.ts";
41 import { serve, type Servers } from "../src/http/server.ts";
42 import { serving } from "./helpers/studio.ts";
43
44 describe("an owner's page lists an app once, not once in each vocabulary (#221)", () => {
45 let dataDir: string;
46 let app: App;
47 let servers: Servers;
48 let base: string;
49
50 beforeAll(async () => {
51 dataDir = await mkdtemp(join(tmpdir(), "forge-owner-once-"));
52 app = createApp({ dataDir, port: 0 });
53 servers = await serve(app);
54 base = servers.url;
55 });
56
57 afterAll(async () => {
58 servers.stop();
59 app.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 const signIn = async (username: string, password: string) =>
64 (
65 (
66 await fetch(`${base}/signin`, {
67 method: "POST",
68 body: new URLSearchParams({ username, password }),
69 redirect: "manual",
70 })
71 ).headers.get("set-cookie") ?? ""
72 ).split(";")[0]!;
73
74 const occurrences = (haystack: string, needle: string): number =>
75 haystack.split(needle).length - 1;
76
77 test("a person with one app and one plain repository renders each once, in every mode; one with no app shows a visitor no apps card at all", async () => {
78 const dan = await createUser(app.db, { trusted: true,
79 username: "dan",
80 email: "[email protected]",
81 password: "a-good-passphrase",
82 adminIfFirst: true,
83 });
84 if (!dan.ok) throw new Error(dan.error);
85 const cookie = await signIn("dan", "a-good-passphrase");
86
87 const madeApp = await createRepo(app, dan.value, {
88 name: "beacon",
89 visibility: "public",
90 description: "a lighthouse",
91 });
92 if (!madeApp.ok) throw new Error(String(madeApp.error));
93 const appSeeded = await commitToBranch(app, dan.value, madeApp.value, {
94 message: "Start",
95 files: [{ path: "README.md", content: "hi\n" }],
96 });
97 if (!appSeeded.ok) throw new Error(JSON.stringify(appSeeded.error));
98 const enabled = await enableApp(app, dan.value, madeApp.value, {
99 slug: "beacon",
100 });
101 if (!enabled.ok) throw new Error(String(enabled.error));
102
103 const madePlain = await createRepo(app, dan.value, {
104 name: "scratch",
105 visibility: "public",
106 description: "not an app",
107 });
108 if (!madePlain.ok) throw new Error(String(madePlain.error));
109 const plainSeeded = await commitToBranch(app, dan.value, madePlain.value, {
110 message: "Start",
111 files: [{ path: "README.md", content: "hi\n" }],
112 });
113 if (!plainSeeded.ok) throw new Error(JSON.stringify(plainSeeded.error));
114
115 const studio = await (
116 await fetch(`${base}/dan`, {
117 headers: { cookie: `${cookie}; forge_mode=studio` },
118 redirect: "manual",
119 })
120 ).text();
121 expect(occurrences(studio, 'href="/dan/beacon"')).toBe(1);
122 expect(occurrences(studio, 'href="/dan/scratch"')).toBe(1);
123 expect(studio).toContain("<h2>Other repositories</h2>");
124
125 const dev = await (
126 await fetch(`${base}/dan`, {
127 headers: { cookie: `${cookie}; forge_mode=dev` },
128 redirect: "manual",
129 })
130 ).text();
131 expect(occurrences(dev, 'href="/dan/beacon"')).toBe(1);
132 expect(occurrences(dev, 'href="/dan/scratch"')).toBe(1);
133 expect(dev).toContain("<h2>Apps</h2>");
134 expect(dev).toContain("<h2>Other repositories</h2>");
135
136 // Someone with repositories and no app: a visitor is not shown an empty
137 // apps card, and the list is simply their repositories.
138 const gus = await createUser(app.db, { trusted: true,
139 username: "gus",
140 email: "[email protected]",
141 password: "a-good-passphrase",
142 });
143 if (!gus.ok) throw new Error(gus.error);
144 const plainOnly = await createRepo(app, gus.value, {
145 name: "notebook",
146 visibility: "public",
147 description: "",
148 });
149 if (!plainOnly.ok) throw new Error(String(plainOnly.error));
150 const visit = await (
151 await fetch(`${base}/gus`, {
152 headers: { cookie: `${cookie}; forge_mode=studio` },
153 redirect: "manual",
154 })
155 ).text();
156 expect(visit).not.toContain("No apps yet");
157 expect(visit).toContain("<h2>Repositories</h2>");
158 expect(visit).toContain('href="/gus/notebook"');
159 });
160
161 test("an owner whose only repository is an app reads the empty list below as no other repositories, not as a bug", async () => {
162 const eve = await createUser(app.db, { trusted: true,
163 username: "eve",
164 email: "[email protected]",
165 password: "a-good-passphrase",
166 });
167 if (!eve.ok) throw new Error(eve.error);
168 const cookie = await signIn("eve", "a-good-passphrase");
169
170 const madeApp = await createRepo(app, eve.value, {
171 name: "lighthouse",
172 visibility: "public",
173 });
174 if (!madeApp.ok) throw new Error(String(madeApp.error));
175 const seeded = await commitToBranch(app, eve.value, madeApp.value, {
176 message: "Start",
177 files: [{ path: "README.md", content: "hi\n" }],
178 });
179 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
180 const enabled = await enableApp(app, eve.value, madeApp.value, {
181 slug: "lighthouse",
182 });
183 if (!enabled.ok) throw new Error(String(enabled.error));
184
185 const studio = await (
186 await fetch(`${base}/eve`, {
187 headers: { cookie: `${cookie}; forge_mode=studio` },
188 redirect: "manual",
189 })
190 ).text();
191 expect(occurrences(studio, 'href="/eve/lighthouse"')).toBe(1);
192 expect(studio).toContain("No other repositories");
193 expect(studio).not.toContain("<h2>Other repositories</h2>");
194 });
195
196 test("a private app on a public repository shows its state only to its writers: a visitor gets a plain row", async () => {
197 const hal = await createUser(app.db, { trusted: true,
198 username: "hal",
199 email: "[email protected]",
200 password: "a-good-passphrase",
201 });
202 if (!hal.ok) throw new Error(hal.error);
203 const ownerCookie = await signIn("hal", "a-good-passphrase");
204 const made = await createRepo(app, hal.value, {
205 name: "ledger",
206 visibility: "public",
207 description: "the books",
208 });
209 if (!made.ok) throw new Error(String(made.error));
210 const seeded = await commitToBranch(app, hal.value, made.value, {
211 message: "Start",
212 files: [{ path: "README.md", content: "hi\n" }],
213 });
214 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
215 const enabled = await enableApp(app, hal.value, made.value, {
216 slug: "hal-ledger",
217 access: "private",
218 });
219 if (!enabled.ok) throw new Error(String(enabled.error));
220 const ivy = await createUser(app.db, { trusted: true,
221 username: "ivy",
222 email: "[email protected]",
223 password: "a-good-passphrase",
224 });
225 if (!ivy.ok) throw new Error(ivy.error);
226 const strangerCookie = await signIn("ivy", "a-good-passphrase");
227
228 const visit = (cookie?: string) =>
229 fetch(`${base}/hal`, {
230 headers: cookie === undefined ? {} : { cookie },
231 redirect: "manual",
232 }).then((r) => r.text());
233 for (const page of [await visit(), await visit(strangerCookie)]) {
234 // The repository is public, so it is listed — once, plainly.
235 expect(occurrences(page, 'href="/hal/ledger"')).toBe(1);
236 expect(page).toContain("the books");
237 expect(page).not.toContain("not running");
238 expect(page).not.toContain("quiet");
239 expect(page).not.toContain('class="studio-word"');
240 }
241 const own = await visit(ownerCookie);
242 expect(own).toContain("not running");
243 });
244
245 test("an org's page draws the same way: its app once, its plain repository once, and Members/Teams unmoved", async () => {
246 const fay = await createUser(app.db, { trusted: true,
247 username: "fay",
248 email: "[email protected]",
249 password: "a-good-passphrase",
250 });
251 if (!fay.ok) throw new Error(fay.error);
252 const cookie = await signIn("fay", "a-good-passphrase");
253
254 const org = await createOrg(app, fay.value, "vega");
255 if (!org.ok) throw new Error(org.error);
256
257 const madeApp = await createRepo(app, fay.value, {
258 name: "anvil",
259 owner: "vega",
260 visibility: "public",
261 });
262 if (!madeApp.ok) throw new Error(String(madeApp.error));
263 const appSeeded = await commitToBranch(app, fay.value, madeApp.value, {
264 message: "Start",
265 files: [{ path: "README.md", content: "hi\n" }],
266 });
267 if (!appSeeded.ok) throw new Error(JSON.stringify(appSeeded.error));
268 const enabled = await enableApp(app, fay.value, madeApp.value, {
269 slug: "anvil",
270 });
271 if (!enabled.ok) throw new Error(String(enabled.error));
272
273 const madePlain = await createRepo(app, fay.value, {
274 name: "ledger",
275 owner: "vega",
276 visibility: "public",
277 });
278 if (!madePlain.ok) throw new Error(String(madePlain.error));
279 const plainSeeded = await commitToBranch(app, fay.value, madePlain.value, {
280 message: "Start",
281 files: [{ path: "README.md", content: "hi\n" }],
282 });
283 if (!plainSeeded.ok) throw new Error(JSON.stringify(plainSeeded.error));
284
285 const studio = await (
286 await fetch(`${base}/vega`, {
287 headers: { cookie: `${cookie}; forge_mode=studio` },
288 redirect: "manual",
289 })
290 ).text();
291 expect(occurrences(studio, 'href="/vega/anvil"')).toBe(1);
292 expect(occurrences(studio, 'href="/vega/ledger"')).toBe(1);
293 expect(studio).toContain('<h2 class="mt">Members</h2>');
294 expect(studio).toContain('<h2 class="mt" id="teams">Teams</h2>');
295
296 const dev = await (
297 await fetch(`${base}/vega`, {
298 headers: { cookie: `${cookie}; forge_mode=dev` },
299 redirect: "manual",
300 })
301 ).text();
302 expect(occurrences(dev, 'href="/vega/anvil"')).toBe(1);
303 expect(occurrences(dev, 'href="/vega/ledger"')).toBe(1);
304 });
305 });
306
307 describe("every open pull's preview on the studio page", () => {
308 let dataDir: string;
309 let app: App;
310 let servers: Servers;
311 let runtime: AppsRuntime;
312 let base: string;
313 let cookie: string;
314 let repo: Repo;
315
316 const get = (path: string) =>
317 fetch(`${base}${path}`, {
318 headers: { cookie: `${cookie}; forge_mode=studio` },
319 redirect: "manual",
320 });
321
322 const WAIT_MS = 60_000;
323
324 /** The deploys this repo has not finished, as "env:state" words. */
325 const unfinished = () =>
326 listRuns(app.db, repo.id, { kind: "deploy", limit: 50 })
327 .filter((r) => !isTerminal(r.state))
328 .map((r) => `${r.env}:${r.state}`);
329
330 /** Wait until the runtime has no deploy open or queued, so the next case
331 * does not queue behind this one's (concurrency is 1). */
332 async function deploysIdle(): Promise<void> {
333 const deadline = Date.now() + WAIT_MS;
334 while (unfinished().length > 0) {
335 if (Date.now() > deadline)
336 throw new Error(`deploys still open: ${unfinished().join(", ")}`);
337 await Bun.sleep(50);
338 }
339 }
340
341 /** Wait for `env` to serve; fail naming the state it reached. */
342 async function previewServes(record: AppRecord, env: EnvName): Promise<void> {
343 const deadline = Date.now() + WAIT_MS;
344 while (runtime.envStatus(record, env)?.state !== "running") {
345 if (Date.now() > deadline) {
346 const status = runtime.envStatus(record, env);
347 throw new Error(
348 `${env} did not serve within ${WAIT_MS} ms: state ${status?.state ?? "absent"}` +
349 (status?.error ? ` (${status.error})` : "") +
350 `; open deploys: ${unfinished().join(", ") || "none"}`,
351 );
352 }
353 await Bun.sleep(50);
354 }
355 }
356
357 beforeAll(async () => {
358 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-previews-"));
359 app = createApp({ dataDir, port: 0 });
360 servers = await serve(app);
361 base = servers.url;
362 runtime = new AppsRuntime(app, {
363 domain: "studio-previews.test",
364 scheme: "http",
365 publicPort: servers.port,
366 forgeUrl: servers.url,
367 concurrency: 1,
368 });
369 await runtime.start();
370 const a = await createUser(app.db, { trusted: true,
371 username: "alice",
372 email: "[email protected]",
373 password: "a-good-passphrase",
374 adminIfFirst: true,
375 });
376 if (!a.ok) throw new Error(a.error);
377 cookie = (
378 (
379 await fetch(`${base}/signin`, {
380 method: "POST",
381 body: new URLSearchParams({
382 username: "alice",
383 password: "a-good-passphrase",
384 }),
385 redirect: "manual",
386 })
387 ).headers.get("set-cookie") ?? ""
388 ).split(";")[0]!;
389 const made = await createRepo(app, a.value, {
390 name: "widget",
391 visibility: "public",
392 description: "",
393 });
394 if (!made.ok) throw new Error(String(made.error));
395 repo = made.value;
396 const seeded = await commitToBranch(app, a.value, repo, {
397 message: "Start",
398 files: [
399 {
400 path: "package.json",
401 content:
402 '{"name":"widget","scripts":{"start":"bun run server.ts"}}\n',
403 },
404 {
405 path: "server.ts",
406 content:
407 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
408 },
409 ],
410 });
411 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
412 const enabled = await enableApp(app, a.value, repo, { slug: "widget" });
413 if (!enabled.ok) throw new Error(String(enabled.error));
414 }, 30_000);
415
416 afterAll(async () => {
417 await runtime.stop();
418 servers.stop();
419 app.close();
420 await rm(dataDir, { recursive: true, force: true });
421 });
422
423 test("an open pull's preview host and open link render; none do once nothing is open", async () => {
424 const alice = getUserByName(app.db, "alice")!;
425 const before = await (await get("/alice/widget")).text();
426 expect(before).not.toContain("<h2>Previews</h2>");
427
428 const branch = await commitToBranch(app, alice, repo, {
429 branch: "feature/x",
430 base: repo.defaultBranch,
431 message: "Add X",
432 files: [{ path: "X.md", content: "x\n" }],
433 });
434 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
435 const opened = await openPull(app, alice, repo, {
436 title: "Add X",
437 body: "",
438 baseBranch: repo.defaultBranch,
439 headBranch: "feature/x",
440 });
441 if (!opened.ok) throw new Error(String(opened.error));
442 const pull = opened.value;
443
444 const page = await (await get("/alice/widget")).text();
445 expect(page).toContain("<h2>Previews</h2>");
446 expect(page).toContain(`href="/alice/widget/app/pull-${pull.number}/open"`);
447 expect(page).toContain(`#${pull.number} Add X`);
448
449 const merged = await mergePull(app, alice, repo, pull, "squash");
450 expect(merged.ok).toBe(true);
451 const after = await (await get("/alice/widget")).text();
452 expect(after).not.toContain("<h2>Previews</h2>");
453 expect(after).not.toContain(`pull-${pull.number}/open`);
454 // The merge queues production's deploy and the pull's was cut short;
455 // leave the single deploy slot free for the next case.
456 await deploysIdle();
457 }, 120_000);
458
459 test("a change's page names its preview's data as its own copy and gives the moment it was taken; production's card does not", async () => {
460 const alice = getUserByName(app.db, "alice")!;
461 const branch = await commitToBranch(app, alice, repo, {
462 branch: "feature/y",
463 base: repo.defaultBranch,
464 message: "Add Y",
465 files: [{ path: "Y.md", content: "y\n" }],
466 });
467 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
468 const opened = await openPull(app, alice, repo, {
469 title: "Add Y",
470 body: "",
471 baseBranch: repo.defaultBranch,
472 headBranch: "feature/y",
473 });
474 if (!opened.ok) throw new Error(String(opened.error));
475 const pull = opened.value;
476 const record = getAppByRepo(app.db, repo.id)!;
477 const env = `pull-${pull.number}` as const;
478
479 await previewServes(record, env);
480 expect(runtime.envStatus(record, env)?.state).toBe("running");
481
482 const note = "its own copy of production's data, taken";
483
484 const appPage = await (await get("/alice/widget")).text();
485 expect(appPage).toContain(note);
486 // Production's own card, above the previews list, never says it.
487 const production = appPage.slice(0, appPage.indexOf("<h2>Previews</h2>"));
488 expect(production).not.toContain(note);
489
490 const changePage = await (
491 await get(`/alice/widget/changes/${pull.number}`)
492 ).text();
493 expect(changePage).toContain("<h2>Preview</h2>");
494 expect(changePage).toContain(note);
495 }, 120_000);
496 });
497
498 /**
499 * The studio home keeps itself current: Asks you, Your apps and On you are
500 * each a live region — no second fragment endpoint, the same `studioHome`
501 * render as the first paint, fetched again on a matching event and swapped
502 * in place by `liveRegionsJs` (`src/web/client.ts`).
503 */
504 describe("the studio home keeps itself current", () => {
505 let dataDir: string;
506 let app: App;
507 let servers: Servers;
508 let base: string;
509 let alice: User;
510 let repo: Repo;
511 let cookie: string;
512 let pull: Pull;
513 let readme: Issue;
514
515 const get = (extraCookie = "") =>
516 fetch(`${base}/`, {
517 headers: {
518 cookie: `${cookie}${extraCookie}; forge_mode=studio`,
519 },
520 redirect: "manual",
521 });
522
523 beforeAll(async () => {
524 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-live-home-"));
525 app = createApp({ dataDir, port: 0 });
526 servers = await serve(app);
527 base = servers.url;
528 const a = await createUser(app.db, { trusted: true,
529 username: "alice",
530 email: "[email protected]",
531 password: "a-good-passphrase",
532 adminIfFirst: true,
533 });
534 if (!a.ok) throw new Error(a.error);
535 alice = a.value;
536 cookie = (
537 (
538 await fetch(`${base}/signin`, {
539 method: "POST",
540 body: new URLSearchParams({
541 username: "alice",
542 password: "a-good-passphrase",
543 }),
544 redirect: "manual",
545 })
546 ).headers.get("set-cookie") ?? ""
547 ).split(";")[0]!;
548 const made = await createRepo(app, alice, {
549 name: "signals",
550 visibility: "private",
551 description: "",
552 });
553 if (!made.ok) throw new Error(String(made.error));
554 repo = made.value;
555 const seeded = await commitToBranch(app, alice, repo, {
556 message: "Start",
557 files: [{ path: "README.md", content: "s\n" }],
558 });
559 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
560 const enabled = await enableApp(app, alice, repo, { slug: "signals" });
561 if (!enabled.ok) throw new Error(String(enabled.error));
562 const scout = await createAgent(app, alice, { username: "scout" });
563 if (!scout.ok) throw new Error(String(scout.error));
564 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
565 const scoutUser = getUserByName(app.db, "scout")!;
566 const branch = await commitToBranch(app, alice, repo, {
567 branch: "feature/x",
568 base: "main",
569 message: "Add x",
570 files: [{ path: "x.md", content: "x\n" }],
571 });
572 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
573 const opened = await openPull(app, scoutUser, repo, {
574 title: "Add x",
575 body: "",
576 baseBranch: "main",
577 headBranch: "feature/x",
578 });
579 if (!opened.ok) throw new Error(String(opened.error));
580 pull = opened.value;
581 const run = createRun(app.db, {
582 repoId: repo.id,
583 kind: "check",
584 issueId: pull.id,
585 env: `pull-${pull.number}`,
586 commitOid: pull.headOid,
587 reason: "pull",
588 });
589 claimRun(app.db, run.id);
590 finishRun(app.db, run.id, { state: "ok", summary: "check passed" });
591
592 const asked = openIssue(app, alice, repo, { title: "Write the README" });
593 if (!asked.ok) throw new Error(String(asked.error));
594 readme = asked.value;
595 expect(assignIssue(app, alice, repo, readme, alice).ok).toBe(true);
596 });
597
598 afterAll(async () => {
599 servers.stop();
600 app.close();
601 await rm(dataDir, { recursive: true, force: true });
602 });
603
604 test("Your apps and On you are each a live region, with the security floor's headers", async () => {
605 const res = await get();
606 expect(res.status).toBe(200);
607 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
608 expect(res.headers.get("referrer-policy")).toBe("same-origin");
609 const home = await res.text();
610 expect(home).toMatch(/id="live-apps"\s*\n?\s*data-live="/);
611 expect(home).toMatch(/id="live-on-you"\s*\n?\s*data-live="/);
612 expect(home).toContain("new EventSource('/events')");
613 expect(home).toContain("Write the README");
614 });
615
616 test("chips do not touch Your apps: it lists apps, not feed items", async () => {
617 for (const kind of KIND_CHIPS) setChip(app.db, alice.id, { kind: "kind", value: kind }, true);
618 const res = await fetch(`${base}/live/apps`, { headers: { cookie: `${cookie}; forge_mode=studio` } });
619 expect(await res.text()).toContain(`${repo.name}`);
620 for (const kind of KIND_CHIPS) setChip(app.db, alice.id, { kind: "kind", value: kind }, false);
621 });
622
623 test("each region on the home has an address of its own, answering with that region alone and 304 when it has not changed", async () => {
624 const page = await (await get()).text();
625 expect(page).toContain('data-live-src="/live/apps"');
626 expect(page).toContain('data-live-src="/live/on-you"');
627
628 const headers = { cookie: `${cookie}; forge_mode=studio` };
629 const apps = await fetch(`${base}/live/apps`, { headers });
630 expect(apps.status).toBe(200);
631 const appsText = await apps.text();
632 expect(appsText).toContain('id="live-apps"');
633 expect(appsText).toContain(`${repo.name}`);
634 expect(appsText).not.toContain("<html");
635 const tag = apps.headers.get("etag");
636 expect(tag).toMatch(/^"[0-9a-f]{16}"$/);
637 const unchanged = await fetch(`${base}/live/apps`, {
638 headers: { ...headers, "if-none-match": tag! },
639 });
640 expect(unchanged.status).toBe(304);
641
642 const onYou = await fetch(`${base}/live/on-you`, { headers });
643 expect(onYou.status).toBe(200);
644 expect(await onYou.text()).toContain('id="live-on-you"');
645
646 // Signed out, the regions are nobody's.
647 expect((await fetch(`${base}/live/apps`)).status).toBe(404);
648 expect((await fetch(`${base}/live/on-you`)).status).toBe(404);
649 });
650
651 test("a signed-out request gets a signed-out home: no per-viewer live regions, nothing of alice's", async () => {
652 const res = await fetch(`${base}/`, {
653 headers: { cookie: "forge_mode=studio" },
654 redirect: "manual",
655 });
656 expect(res.status).toBe(200);
657 const home = await res.text();
658 expect(home).not.toContain('id="live-asks"');
659 expect(home).not.toContain('id="live-on-you"');
660 expect(home).not.toContain("Add x");
661 expect(home).not.toContain("Write the README");
662 });
663
664 test("a card leaves On you once its issue closes", async () => {
665 // The On you region alone: the timeline still tells of the issue, as a
666 // post does after it is done (#599).
667 const onYou = () =>
668 fetch(`${base}/live/on-you`, {
669 headers: { cookie: `${cookie}; forge_mode=studio` },
670 }).then((r) => r.text());
671 const before = await onYou();
672 expect(before).toContain("Write the README");
673
674 const merged = await mergePull(app, alice, repo, pull, "squash");
675 expect(merged.ok).toBe(true);
676 expect(setIssueState(app, alice, repo, readme, "closed").ok).toBe(true);
677
678 const after = await onYou();
679 expect(after).not.toContain("Write the README");
680 });
681 });
682
683 /**
684 * The studio head of an app that is not up: the same facts the App page
685 * shows — why, the deploy that says the rest, and the way to try again.
686 */
687 describe("the studio head says why the app is not up", () => {
688 let dataDir: string;
689 let app: App;
690 let servers: Servers;
691 let base: string;
692 let repo: Repo;
693 let record: AppRecord;
694 let runtime: AppsRuntime;
695 let deploy: string;
696 let writer: string;
697 let reader: string;
698
699 const head = (cookie: string) =>
700 fetch(`${base}/alice/notes`, {
701 headers: { cookie: `${cookie}; forge_mode=studio` },
702 redirect: "manual",
703 }).then((r) => r.text());
704
705 /** The home's own shelf row for this app — the badge issue #201 is about. */
706 // The shelf's rows are `/apps` (#599: home is the feed).
707 const shelfBadge = async (cookie: string): Promise<string> => {
708 const home = await (
709 await fetch(`${base}/apps`, {
710 headers: { cookie: `${cookie}; forge_mode=studio` },
711 redirect: "manual",
712 })
713 ).text();
714 const at = home.indexOf('href="/alice/notes"');
715 expect(at).toBeGreaterThan(-1);
716 const start = home.lastIndexOf("<li>", at);
717 const row = home.slice(start, home.indexOf("</li>", at) + 5);
718 return row.match(/<span class="badge"[^>]*>([\s\S]*?)<\/span>/)?.[1] ?? "";
719 };
720
721 const signIn = async (username: string): Promise<string> =>
722 (
723 (
724 await fetch(`${base}/signin`, {
725 method: "POST",
726 body: new URLSearchParams({
727 username,
728 password: "a-good-passphrase",
729 }),
730 redirect: "manual",
731 })
732 ).headers.get("set-cookie") ?? ""
733 ).split(";")[0]!;
734
735 /**
736 * An environment as the runtime holds it, without running a deploy: the
737 * page reads `runtime.status(record)`, and a real failure would mean a
738 * real install and a real health timeout for a rendering test.
739 */
740 interface Slot {
741 readonly app: AppRecord;
742 readonly env: EnvName;
743 readonly chain: Promise<void>;
744 readonly restarts: number[];
745 readonly live?: unknown;
746 readonly deploying?: unknown;
747 readonly lastError?: string;
748 readonly lastRunId?: string;
749 readonly lastErrorCommitOid?: string;
750 }
751 const put = (
752 slot: Omit<Slot, "app" | "env" | "chain" | "restarts">,
753 ): void => {
754 (runtime as unknown as { envs: Map<string, Slot> }).envs.set(
755 `${record.id}/production`,
756 {
757 app: record,
758 env: "production",
759 chain: Promise.resolve(),
760 restarts: [],
761 ...slot,
762 },
763 );
764 };
765
766 beforeAll(async () => {
767 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-notup-"));
768 app = createApp({ dataDir, port: 0 });
769 servers = await serve(app);
770 base = servers.url;
771 runtime = new AppsRuntime(app, {
772 domain: "apps.test",
773 scheme: "http",
774 publicPort: servers.port,
775 forgeUrl: servers.url,
776 concurrency: 1,
777 });
778 const a = await createUser(app.db, { trusted: true,
779 username: "alice",
780 email: "[email protected]",
781 password: "a-good-passphrase",
782 adminIfFirst: true,
783 });
784 if (!a.ok) throw new Error(a.error);
785 const b = await createUser(app.db, { trusted: true,
786 username: "bob",
787 email: "[email protected]",
788 password: "a-good-passphrase",
789 });
790 if (!b.ok) throw new Error(b.error);
791 writer = await signIn("alice");
792 reader = await signIn("bob");
793 const made = await createRepo(app, a.value, {
794 name: "notes",
795 visibility: "public",
796 description: "a place for notes",
797 });
798 if (!made.ok) throw new Error(String(made.error));
799 repo = made.value;
800 const seeded = await commitToBranch(app, a.value, repo, {
801 message: "Start",
802 files: [{ path: "README.md", content: "notes\n" }],
803 });
804 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
805 const enabled = await enableApp(app, a.value, repo, { slug: "notes" });
806 if (!enabled.ok) throw new Error(String(enabled.error));
807 record = getAppByRepo(app.db, repo.id)!;
808 const run = createRun(app.db, {
809 repoId: repo.id,
810 kind: "deploy",
811 env: "production",
812 commitOid: seeded.value.commitOid,
813 reason: "push",
814 });
815 claimRun(app.db, run.id);
816 finishRun(app.db, run.id, {
817 state: "failed",
818 summary: "the deploy failed",
819 });
820 deploy = run.id;
821 });
822
823 afterAll(async () => {
824 servers.stop();
825 app.close();
826 await rm(dataDir, { recursive: true, force: true });
827 });
828
829 test("a failed production says what failed, links its deploy, and offers Redeploy to a writer only — and offers no live link to the host", async () => {
830 put({
831 lastError: "the deploy failed: <install> exited 1",
832 lastRunId: deploy,
833 });
834 const mine = await head(writer);
835 expect(mine).toContain("not up");
836 // The error is the app's own output: shown, and escaped.
837 expect(mine).toContain("the deploy failed: &lt;install&gt; exited 1");
838 expect(mine).not.toContain("<install>");
839 expect(mine).toContain(`href="/alice/notes/runs/${deploy}"`);
840 expect(mine).toContain('action="/alice/notes/app/production/redeploy"');
841 expect(mine).toContain('action="/alice/notes/app/production/stop"');
842 expect(mine).not.toContain('href="/alice/notes/app/production/open"');
843 expect(mine).toContain('title="The last deploy failed"');
844
845 const theirs = await head(reader);
846 expect(theirs).toContain("not up");
847 expect(theirs).toContain("the deploy failed: &lt;install&gt; exited 1");
848 expect(theirs).toContain(`href="/alice/notes/runs/${deploy}"`);
849 expect(theirs).not.toContain("/app/production/redeploy");
850 expect(theirs).not.toContain("/app/production/stop");
851 expect(theirs).not.toContain('href="/alice/notes/app/production/open"');
852 });
853
854 // Issue #201: the shelf used to print the runtime's raw `EnvState` for a
855 // failed or deploying production, disagreeing with the app's own head
856 // above — which has always spoken the studio's five-ish words.
857 test("a failed production reads 'not up' on the home shelf too, never the runtime's own word 'failed'", async () => {
858 put({
859 lastError: "the deploy failed: <install> exited 1",
860 lastRunId: deploy,
861 });
862 expect(await shelfBadge(writer)).toBe("not up");
863 });
864
865 test("a stopped production says it is not running and offers Redeploy, not Stop — and no live link to the host", async () => {
866 put({ lastRunId: deploy });
867 const mine = await head(writer);
868 expect(mine).toContain("not running");
869 expect(mine).toContain('action="/alice/notes/app/production/redeploy"');
870 expect(mine).not.toContain('action="/alice/notes/app/production/stop"');
871 expect(mine).not.toContain('href="/alice/notes/app/production/open"');
872 expect(mine).toContain('title="Not running"');
873 expect(await shelfBadge(writer)).toBe("not running");
874 });
875
876 test("a deploying production says so where the button was, not a link to a host nothing is serving yet", async () => {
877 put({
878 deploying: { run: { id: deploy, commitOid: "0".repeat(40) } },
879 });
880 const mine = await head(writer);
881 expect(mine).toContain("starting");
882 expect(mine).not.toContain('href="/alice/notes/app/production/open"');
883 expect(mine).toContain('title="Still deploying"');
884 // The runtime's own word for this state is "deploying" — the shelf
885 // says "starting" instead, same as the head above.
886 expect(await shelfBadge(writer)).toBe("starting");
887 });
888
889 test("a serving app with red tests still reads as serving, with the failure in its own words, and is still open-able", async () => {
890 put({
891 live: {
892 commitOid: "0".repeat(40),
893 handle: { port: 4321 },
894 runId: deploy,
895 since: new Date(Date.now() - 60_000).toISOString(),
896 testsFailed: "1 of 3 tests failed",
897 manifest: { tty: null },
898 },
899 });
900 const mine = await head(writer);
901 expect(mine).toContain("serving · tests failed");
902 expect(mine).not.toContain("not up");
903 expect(mine).toContain("serving · tests failed: 1 of 3 tests failed");
904 expect(mine).toContain('href="/alice/notes/app/production/open"');
905 expect(await shelfBadge(writer)).toBe("serving · tests failed");
906 });
907
908 test("a newer push that failed to come up while an older deploy keeps serving: live, not plain green, both commits named, its run linked", async () => {
909 const failed = createRun(app.db, {
910 repoId: repo.id,
911 kind: "deploy",
912 env: "production",
913 commitOid: "2".repeat(40),
914 reason: "push",
915 });
916 claimRun(app.db, failed.id);
917 finishRun(app.db, failed.id, {
918 state: "failed",
919 summary: "forge.json: JSON Parse error: Unexpected token '}'",
920 });
921 put({
922 live: {
923 commitOid: "1".repeat(40),
924 handle: { port: 4321 },
925 runId: deploy,
926 since: new Date(Date.now() - 60_000).toISOString(),
927 manifest: { tty: null },
928 },
929 lastError: "forge.json: JSON Parse error: Unexpected token '}'",
930 lastRunId: failed.id,
931 lastErrorCommitOid: "2".repeat(40),
932 });
933 const mine = await head(writer);
934 // It answers — still "live", never "not up" — but not untroubled green.
935 expect(mine).toContain("live");
936 expect(mine).not.toContain("not up");
937 expect(mine).toContain('href="/alice/notes/app/production/open"');
938 // Both commits, named.
939 expect(mine).toContain("1111111");
940 expect(mine).toContain("2222222");
941 expect(mine).toContain("did not come up");
942 expect(mine).toContain(
943 "forge.json: JSON Parse error: Unexpected token &#39;}&#39;",
944 );
945 // The failed run's log, one click away.
946 expect(mine).toContain(`href="/alice/notes/runs/${failed.id}"`);
947 });
948
949 test("a clean deploy with no failure behind it is unchanged: plain live, no failed-run link", async () => {
950 put({
951 live: {
952 commitOid: "1".repeat(40),
953 handle: { port: 4321 },
954 runId: deploy,
955 since: new Date(Date.now() - 60_000).toISOString(),
956 manifest: { tty: null },
957 },
958 });
959 const mine = await head(writer);
960 expect(mine).toContain("live");
961 expect(mine).not.toContain("did not come up");
962 });
963 });
964
965 /**
966 * The studio app page's Terminal — the one door an operator has into what
967 * is running. `terminalFor` (`src/actions/apps.ts`) is the single fact both
968 * this page and the dev App page read; this only checks it is drawn (or
969 * withheld) correctly, not recomputed.
970 */
971 describe("the studio app page's terminal", () => {
972 let dataDir: string;
973 let app: App;
974 let servers: Servers;
975 let base: string;
976 let repo: Repo;
977 let record: AppRecord;
978 let runtime: AppsRuntime;
979 let writer: string;
980 let reader: string;
981
982 interface Slot {
983 readonly app: AppRecord;
984 readonly env: EnvName;
985 readonly chain: Promise<void>;
986 readonly restarts: number[];
987 readonly live?: unknown;
988 }
989 const putLive = (tty: string | null): void => {
990 (runtime as unknown as { envs: Map<string, Slot> }).envs.set(
991 `${record.id}/production`,
992 {
993 app: record,
994 env: "production",
995 chain: Promise.resolve(),
996 restarts: [],
997 live: {
998 commitOid: "a".repeat(40),
999 handle: { port: 4321 },
1000 runId: "fake",
1001 since: new Date().toISOString(),
1002 manifest: { tty },
1003 },
1004 },
1005 );
1006 };
1007
1008 const signIn = async (username: string): Promise<string> =>
1009 (
1010 (
1011 await fetch(`${base}/signin`, {
1012 method: "POST",
1013 body: new URLSearchParams({
1014 username,
1015 password: "a-good-passphrase",
1016 }),
1017 redirect: "manual",
1018 })
1019 ).headers.get("set-cookie") ?? ""
1020 ).split(";")[0]!;
1021
1022 const appPage = (cookie: string) =>
1023 fetch(`${base}/alice/beacon`, {
1024 headers: { cookie: `${cookie}; forge_mode=studio` },
1025 redirect: "manual",
1026 }).then((r) => r.text());
1027
1028 beforeAll(async () => {
1029 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-terminal-"));
1030 app = createApp({ dataDir, port: 0, host: "forge.example" });
1031 app.config.sshPort = 2222;
1032 app.config.sshHostKey =
1033 "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
1034 servers = await serve(app);
1035 base = servers.url;
1036 runtime = new AppsRuntime(app, {
1037 domain: "apps.test",
1038 scheme: "http",
1039 publicPort: servers.port,
1040 forgeUrl: servers.url,
1041 concurrency: 1,
1042 });
1043 const a = await createUser(app.db, { trusted: true,
1044 username: "alice",
1045 email: "[email protected]",
1046 password: "a-good-passphrase",
1047 adminIfFirst: true,
1048 });
1049 if (!a.ok) throw new Error(a.error);
1050 const b = await createUser(app.db, { trusted: true,
1051 username: "bob",
1052 email: "[email protected]",
1053 password: "a-good-passphrase",
1054 });
1055 if (!b.ok) throw new Error(b.error);
1056 writer = await signIn("alice");
1057 reader = await signIn("bob");
1058 const made = await createRepo(app, a.value, {
1059 name: "beacon",
1060 visibility: "public",
1061 description: "a light that stays on",
1062 });
1063 if (!made.ok) throw new Error(String(made.error));
1064 repo = made.value;
1065 const seeded = await commitToBranch(app, a.value, repo, {
1066 message: "Start",
1067 files: [{ path: "README.md", content: "beacon\n" }],
1068 });
1069 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1070 const enabled = await enableApp(app, a.value, repo, { slug: "beacon" });
1071 if (!enabled.ok) throw new Error(String(enabled.error));
1072 record = getAppByRepo(app.db, repo.id)!;
1073 });
1074
1075 afterAll(async () => {
1076 servers.stop();
1077 app.close();
1078 await rm(dataDir, { recursive: true, force: true });
1079 });
1080
1081 test("a writer sees the exact command and the host key; a reader sees neither", async () => {
1082 putLive("bun run src/tty.ts");
1083 const mine = await appPage(writer);
1084 expect(mine).toContain("ssh -p 2222 [email protected]");
1085 expect(mine).toContain("host key SHA256:abcdefghijklmnopqrstuvwxyz");
1086 // Wrapped in the marker an email-scanning edge honours, so the
1087 // login@host in the command is never rewritten into a mailto: link —
1088 // and the command itself sits between the markers unchanged.
1089 expect(mine).toContain(
1090 "<!--email_off-->ssh -p 2222 [email protected]<!--/email_off-->",
1091 );
1092 const theirs = await appPage(reader);
1093 expect(theirs).not.toContain("ssh -p 2222 [email protected]");
1094 expect(theirs).not.toContain("The terminal is off");
1095 });
1096
1097 test("an app with no tty says the terminal is off, to a writer only", async () => {
1098 putLive(null);
1099 const mine = await appPage(writer);
1100 expect(mine).toContain("The terminal is off");
1101 expect(mine).not.toContain("ssh -p 2222 [email protected]");
1102 const theirs = await appPage(reader);
1103 expect(theirs).not.toContain("The terminal is off");
1104 });
1105
1106 // Issue #283: a hosted forge (an app of another forge) defaults
1107 // FORGE_SSH_PORT to 0, so serve() never opens a listener — every app on
1108 // it declares a tty but has nothing to connect it to. The generic "The
1109 // terminal is off." leaves a reader with no idea why or what to do; this
1110 // asserts the line names FORGE_SSH_PORT instead, composed from the same
1111 // sshPort field the other tests here already pin (#77's ground) rather
1112 // than by shutting a real listener down.
1113 test("a hosted forge with no SSH listener says why, and names FORGE_SSH_PORT", async () => {
1114 putLive("bun run src/tty.ts");
1115 app.config.sshPort = 0;
1116 try {
1117 const mine = await appPage(writer);
1118 expect(mine).toContain("FORGE_SSH_PORT");
1119 expect(mine).not.toContain("ssh -p 2222 [email protected]");
1120 expect(mine).not.toContain("The terminal is off.</p>");
1121 } finally {
1122 app.config.sshPort = 2222;
1123 }
1124 });
1125 });
1126
1127 /**
1128 * GET /:owner/:name/app/state — the fragment the studio page's live client
1129 * fetches on a deploy run event, and swaps into `#app-head`/`#app-previews`
1130 * in place of a reload. It is authorized exactly as the studio page, and
1131 * renders from the same `appPanels` function the page's first paint does.
1132 */
1133 describe("the app's live fragment: /:owner/:name/app/state", () => {
1134 let dataDir: string;
1135 let app: App;
1136 let servers: Servers;
1137 let base: string;
1138 let runtime: AppsRuntime;
1139 let repo: Repo;
1140 let record: AppRecord;
1141 let owner: string;
1142 let stranger: string;
1143
1144 interface PreviewSlot {
1145 readonly app: AppRecord;
1146 readonly env: EnvName;
1147 readonly chain: Promise<void>;
1148 readonly restarts: number[];
1149 deploying?: unknown;
1150 live?: unknown;
1151 lastError?: string;
1152 lastRunId?: string;
1153 }
1154 const putEnv = (
1155 env: EnvName,
1156 slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">,
1157 ): void => {
1158 (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set(
1159 `${record.id}/${env}`,
1160 { app: record, env, chain: Promise.resolve(), restarts: [], ...slot },
1161 );
1162 };
1163
1164 const signIn = async (username: string): Promise<string> =>
1165 (
1166 (
1167 await fetch(`${base}/signin`, {
1168 method: "POST",
1169 body: new URLSearchParams({
1170 username,
1171 password: "a-good-passphrase",
1172 }),
1173 redirect: "manual",
1174 })
1175 ).headers.get("set-cookie") ?? ""
1176 ).split(";")[0]!;
1177
1178 const get = (path: string, cookie: string) =>
1179 fetch(`${base}${path}`, {
1180 headers: { cookie: `${cookie}; forge_mode=studio` },
1181 redirect: "manual",
1182 });
1183
1184 beforeAll(async () => {
1185 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-state-"));
1186 app = createApp({ dataDir, port: 0 });
1187 servers = await serve(app);
1188 base = servers.url;
1189 runtime = new AppsRuntime(app, {
1190 domain: "state.test",
1191 scheme: "http",
1192 publicPort: servers.port,
1193 forgeUrl: servers.url,
1194 concurrency: 1,
1195 });
1196 const a = await createUser(app.db, { trusted: true,
1197 username: "alice",
1198 email: "[email protected]",
1199 password: "a-good-passphrase",
1200 adminIfFirst: true,
1201 });
1202 if (!a.ok) throw new Error(a.error);
1203 const c = await createUser(app.db, { trusted: true,
1204 username: "carol",
1205 email: "[email protected]",
1206 password: "a-good-passphrase",
1207 });
1208 if (!c.ok) throw new Error(c.error);
1209 owner = await signIn("alice");
1210 stranger = await signIn("carol");
1211 const made = await createRepo(app, a.value, {
1212 name: "vault",
1213 visibility: "private",
1214 description: "",
1215 });
1216 if (!made.ok) throw new Error(String(made.error));
1217 repo = made.value;
1218 const seeded = await commitToBranch(app, a.value, repo, {
1219 message: "Start",
1220 files: [{ path: "README.md", content: "v\n" }],
1221 });
1222 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1223 const enabled = await enableApp(app, a.value, repo, { slug: "vault" });
1224 if (!enabled.ok) throw new Error(String(enabled.error));
1225 record = getAppByRepo(app.db, repo.id)!;
1226 });
1227
1228 afterAll(async () => {
1229 servers.stop();
1230 app.close();
1231 await rm(dataDir, { recursive: true, force: true });
1232 });
1233
1234 test("renders the head card for a viewer who may see it, with the security floor's headers; refuses one who may not, same as the page", async () => {
1235 const fragment = await get("/alice/vault/app/state", owner);
1236 expect(fragment.status).toBe(200);
1237 expect(fragment.headers.get("x-content-type-options")).toBe("nosniff");
1238 expect(fragment.headers.get("referrer-policy")).toBe("same-origin");
1239 const text = await fragment.text();
1240 expect(text).toContain('id="app-head"');
1241 expect(text).toContain('id="app-previews"');
1242 expect(text).toContain("not running");
1243
1244 const page = await (await get("/alice/vault", owner)).text();
1245 expect(page).toContain("not running");
1246
1247 const refusedFragment = await get("/alice/vault/app/state", stranger);
1248 expect(refusedFragment.status).toBe(404);
1249 const refusedPage = await get("/alice/vault", stranger);
1250 expect(refusedPage.status).toBe(404);
1251 });
1252
1253 test("a change's page has a state fragment of its own: its current word, drawn by the page's own function, refused to whoever the page refuses, and named by the client script (#1094)", async () => {
1254 const branch = await commitToBranch(app, getUserByName(app.db, "alice")!, repo, {
1255 branch: "feature/ship-it",
1256 base: "main",
1257 message: "Ship it",
1258 files: [{ path: "SHIP.md", content: "ship\n" }],
1259 });
1260 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
1261 const opened = await openPull(app, getUserByName(app.db, "alice")!, repo, {
1262 title: "Ship it",
1263 body: "",
1264 baseBranch: "main",
1265 headBranch: "feature/ship-it",
1266 });
1267 if (!opened.ok) throw new Error(String(opened.error));
1268 const n = opened.value.number;
1269
1270 const fragment = await get(`/alice/vault/pulls/${n}/state`, owner);
1271 expect(fragment.status).toBe(200);
1272 expect(fragment.headers.get("x-content-type-options")).toBe("nosniff");
1273 expect(fragment.headers.get("referrer-policy")).toBe("same-origin");
1274 const text = await fragment.text();
1275 expect(text).toContain('id="pull-check"');
1276 expect(text).toContain('id="pull-preview"');
1277 expect(text).toContain("studio-pull-check");
1278 // The page's first paint carries the very same check line.
1279 const pageText = await (await get(`/alice/vault/pulls/${n}`, owner)).text();
1280 const line = (t: string) =>
1281 t.match(/<p[^>]*studio-pull-check[^>]*>[\s\S]*?<\/p>/)?.[0];
1282 expect(line(text)).toBeDefined();
1283 expect(line(pageText)).toBe(line(text));
1284 expect(pageText).toContain('id="pull-check"');
1285 expect(pageText).toContain('data-head="');
1286
1287 expect((await get(`/alice/vault/pulls/${n}/state`, stranger)).status).toBe(404);
1288 expect((await get(`/alice/vault/pulls/999/state`, owner)).status).toBe(404);
1289
1290 const merged = await mergePull(app, getUserByName(app.db, "alice")!, repo, opened.value, "squash");
1291 if (!merged.ok) throw new Error(String(merged.error));
1292 const after = await (await get(`/alice/vault/pulls/${n}/state`, owner)).text();
1293 expect(line(after)).not.toBe(line(text));
1294 // The squash lands a new commit; the fragment names it, so a page that
1295 // saw the merge live can match production's serving against it.
1296 const headOf = (t: string) => t.match(/data-head="([0-9a-f]{40})"/)?.[1];
1297 expect(headOf(text)).toBeDefined();
1298 expect(headOf(after)).toBeDefined();
1299 expect(headOf(after)).not.toBe(headOf(text));
1300 expect(liveClientJs).toContain("#pull-check [data-head]");
1301
1302 expect(liveClientJs).toContain("'/pulls/' + number + '/state'");
1303 expect(liveClientJs).toContain("'pull.merged'");
1304 expect(liveClientJs).toContain("'app.serving'");
1305 });
1306
1307 test("Now has an address of its own: the page's region names it, the fragment answers with the same region, and unchanged bytes come back 304", async () => {
1308 const page = await (await get("/alice/vault", owner)).text();
1309 expect(page).toContain('id="live-now"');
1310 expect(page).toContain('data-live-src="/alice/vault/app/now"');
1311
1312 const first = await get("/alice/vault/app/now", owner);
1313 expect(first.status).toBe(200);
1314 expect(first.headers.get("x-content-type-options")).toBe("nosniff");
1315 expect(first.headers.get("cache-control")).toBe("no-store");
1316 const etag = first.headers.get("etag");
1317 expect(etag).toMatch(/^"[0-9a-f]{16}"$/);
1318 const text = await first.text();
1319 // The tag rides in the markup as well, for a proxy that drops the header.
1320 expect(text).toMatch(/^\s*<div data-etag="[0-9a-f]{16}"/);
1321 expect(`"${/data-etag="([0-9a-f]{16})"/.exec(text)![1]}"`).toBe(etag!);
1322 expect(text).toContain('id="live-now"');
1323 expect(text).toContain('data-live="run.*,pull.*,issue.*"');
1324 expect(text).toContain('data-live-src="/alice/vault/app/now"');
1325 expect(text).toContain("<h2>Now</h2>");
1326
1327 // The same bytes again: nothing to paint, and nothing sent to paint.
1328 const again = await fetch(`${base}/alice/vault/app/now`, {
1329 headers: {
1330 cookie: `${owner}; forge_mode=studio`,
1331 "if-none-match": etag!,
1332 },
1333 });
1334 expect(again.status).toBe(304);
1335 expect(again.headers.get("etag")).toBe(etag);
1336 expect(await again.text()).toBe("");
1337
1338 // A stale tag is answered in full.
1339 const stale = await fetch(`${base}/alice/vault/app/now`, {
1340 headers: { cookie: `${owner}; forge_mode=studio`, "if-none-match": '"0"' },
1341 });
1342 expect(stale.status).toBe(200);
1343 expect(await stale.text()).toContain("<h2>Now</h2>");
1344
1345 expect((await get("/alice/vault/app/now", stranger)).status).toBe(404);
1346 expect((await fetch(`${base}/alice/vault/app/now`)).status).toBe(404);
1347 });
1348
1349 test("a preview flips from deploying to live between two calls, and the link stays correct", async () => {
1350 const alice = getUserByName(app.db, "alice")!;
1351 const branch = await commitToBranch(app, alice, repo, {
1352 branch: "feature/x",
1353 base: repo.defaultBranch,
1354 message: "Add X",
1355 files: [{ path: "X.md", content: "x\n" }],
1356 });
1357 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
1358 const opened = await openPull(app, alice, repo, {
1359 title: "Add X",
1360 body: "",
1361 baseBranch: repo.defaultBranch,
1362 headBranch: "feature/x",
1363 });
1364 if (!opened.ok) throw new Error(String(opened.error));
1365 const pull = opened.value;
1366 const env = previewEnv(pull.number);
1367 const link = `href="/alice/vault/app/${env}/open"`;
1368
1369 const deployRun = createRun(app.db, {
1370 repoId: repo.id,
1371 kind: "deploy",
1372 env,
1373 commitOid: pull.headOid,
1374 reason: "pull",
1375 });
1376 claimRun(app.db, deployRun.id);
1377 putEnv(env, {
1378 deploying: {
1379 run: { id: deployRun.id, commitOid: pull.headOid },
1380 abort: new AbortController(),
1381 },
1382 });
1383 const starting = await (await get("/alice/vault/app/state", owner)).text();
1384 expect(starting).toContain("starting");
1385 expect(starting).not.toContain(">live<");
1386 expect(starting).toContain(link);
1387
1388 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1389 putEnv(env, {
1390 live: {
1391 commitOid: pull.headOid,
1392 handle: { port: 4321 },
1393 runId: deployRun.id,
1394 since: new Date().toISOString(),
1395 manifest: { tty: null },
1396 },
1397 });
1398 const live = await (await get("/alice/vault/app/state", owner)).text();
1399 expect(live).not.toContain("starting");
1400 expect(live).toContain(">live<");
1401 expect(live).toContain(link);
1402 });
1403
1404 test("the fragment's markup is what the live client's status announcement reads: the head's word in a badge, a preview row's word beside its `#N title`", async () => {
1405 // `live.ts`'s `announceSwap` finds the head's word at `.studio-head
1406 // .badge` and, for a preview row, the pull's label at the start of
1407 // `.row-meta` beside its word at `.row-trail .badge`. If this markup
1408 // ever moves, the announcement goes silent without a test failing here.
1409 const text = await (await get("/alice/vault/app/state", owner)).text();
1410 expect(text).toContain("studio-head");
1411 expect(text).toMatch(/studio-head[\s\S]*?class="badge"[^>]*>not running</);
1412
1413 const alice = getUserByName(app.db, "alice")!;
1414 const branch = await commitToBranch(app, alice, repo, {
1415 branch: "feature/announce",
1416 base: repo.defaultBranch,
1417 message: "Add Y",
1418 files: [{ path: "Y.md", content: "y\n" }],
1419 });
1420 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
1421 const opened = await openPull(app, alice, repo, {
1422 title: "Add Y",
1423 body: "",
1424 baseBranch: repo.defaultBranch,
1425 headBranch: "feature/announce",
1426 });
1427 if (!opened.ok) throw new Error(String(opened.error));
1428 const pull = opened.value;
1429 const env = previewEnv(pull.number);
1430 const deployRun = createRun(app.db, {
1431 repoId: repo.id,
1432 kind: "deploy",
1433 env,
1434 commitOid: pull.headOid,
1435 reason: "pull",
1436 });
1437 claimRun(app.db, deployRun.id);
1438 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1439 putEnv(env, {
1440 live: {
1441 commitOid: pull.headOid,
1442 handle: { port: 4323 },
1443 runId: deployRun.id,
1444 since: new Date().toISOString(),
1445 manifest: { tty: null },
1446 },
1447 });
1448 const withPreview = await (
1449 await get("/alice/vault/app/state", owner)
1450 ).text();
1451 const label = `#${pull.number} Add Y`;
1452 expect(withPreview).toContain(`class="row-meta"`);
1453 expect(withPreview).toContain(`class="row-trail"`);
1454 expect(withPreview).toContain(label);
1455 expect(
1456 new RegExp(`${label}[\\s\\S]*?class="row-trail"[\\s\\S]*?>live<`).test(
1457 withPreview,
1458 ),
1459 ).toBe(true);
1460 });
1461
1462 test("the head measures from the push, not from the permit it waited for: a deploy queued 5s reads 6.0s after the push and names the wait", async () => {
1463 const createdAt = new Date(Date.now() - 20_000).toISOString();
1464 const deployStartedAt = new Date(
1465 Date.parse(createdAt) + 5_000,
1466 ).toISOString();
1467 const servingAt = new Date(
1468 Date.parse(deployStartedAt) + 1_000,
1469 ).toISOString();
1470 const deployRun = createRun(app.db, {
1471 repoId: repo.id,
1472 kind: "deploy",
1473 env: "production",
1474 commitOid: "cafebabe",
1475 reason: "push",
1476 });
1477 claimRun(app.db, deployRun.id);
1478 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1479 putEnv("production", {
1480 live: {
1481 commitOid: "cafebabe",
1482 handle: { port: 4322 },
1483 runId: deployRun.id,
1484 since: servingAt,
1485 servingAt,
1486 createdAt,
1487 deployStartedAt,
1488 manifest: { tty: null },
1489 },
1490 });
1491
1492 const head = await (await get("/alice/vault/app/state", owner)).text();
1493 expect(head).toContain("live 6.0s after the push · 5.0s queued");
1494 expect(head).not.toContain("1.0s after the push");
1495
1496 // The same deployment with no wait for a permit says only the one
1497 // number, exactly as it always has.
1498 putEnv("production", {
1499 live: {
1500 commitOid: "cafebabe",
1501 handle: { port: 4322 },
1502 runId: deployRun.id,
1503 since: servingAt,
1504 servingAt,
1505 createdAt: deployStartedAt,
1506 deployStartedAt,
1507 manifest: { tty: null },
1508 },
1509 });
1510 const prompt = await (await get("/alice/vault/app/state", owner)).text();
1511 expect(prompt).toContain("live 1.0s after the push");
1512 expect(prompt).not.toContain("queued");
1513
1514 // A deploy no push asked for — a redeploy someone pressed — carries no
1515 // push instant, and its words never claim one.
1516 putEnv("production", {
1517 live: {
1518 commitOid: "cafebabe",
1519 handle: { port: 4322 },
1520 runId: deployRun.id,
1521 since: servingAt,
1522 servingAt,
1523 deployStartedAt,
1524 manifest: { tty: null },
1525 },
1526 });
1527 const pressed = await (await get("/alice/vault/app/state", owner)).text();
1528 expect(pressed).toContain("live 1.0s after the deploy started");
1529 expect(pressed).not.toContain("after the push");
1530 });
1531
1532 test("app.serving reaches the repo's stream with the shape the live client keys on, the instant the swap-in happens — before its run has finished", async () => {
1533 const deployRun = createRun(app.db, {
1534 repoId: repo.id,
1535 kind: "deploy",
1536 env: "production",
1537 commitOid: "deadbeef",
1538 reason: "push",
1539 });
1540 claimRun(app.db, deployRun.id);
1541 const served = markServing(app.db, deployRun.id);
1542 expect(served?.servingAt).toBeDefined();
1543
1544 const stream = await fetch(`${base}/alice/vault/stream`, {
1545 headers: { cookie: owner },
1546 });
1547 await Bun.sleep(50);
1548
1549 // The exact append `AppsRuntime` makes at the swap-in (runtime.ts),
1550 // republished here so the test does not have to spin up a real deploy.
1551 app.bus.append({
1552 kind: "app.serving",
1553 repoId: repo.id,
1554 payload: {
1555 app: record.slug,
1556 env: "production",
1557 commitOid: deployRun.commitOid,
1558 servingAt: served!.servingAt,
1559 testsRunning: true,
1560 },
1561 });
1562
1563 const reader = stream.body!.getReader();
1564 const decoder = new TextDecoder();
1565 let buffer = "";
1566 let event: Record<string, unknown> | undefined;
1567 const deadline = Date.now() + 5_000;
1568 while (!event && Date.now() < deadline) {
1569 const { done, value } = await reader.read();
1570 if (done) break;
1571 buffer += decoder.decode(value);
1572 const frames = buffer.split("\n\n");
1573 buffer = frames.pop() ?? "";
1574 for (const frame of frames) {
1575 if (!/^event: app\.serving$/m.test(frame)) continue;
1576 const data = /^data: (.*)$/m.exec(frame)?.[1];
1577 if (data !== undefined) event = JSON.parse(data);
1578 }
1579 }
1580 await reader.cancel().catch(() => {});
1581 expect(event).toBeDefined();
1582 expect(event?.["kind"]).toBe("app.serving");
1583 const payload = event?.["payload"] as Record<string, unknown>;
1584 expect(payload["app"]).toBe(record.slug);
1585 expect(payload["env"]).toBe("production");
1586 expect(payload["commitOid"]).toBe(deployRun.commitOid);
1587 expect(payload["servingAt"]).toBe(served!.servingAt);
1588 expect(payload["testsRunning"]).toBe(true);
1589
1590 // finishRun never ran: run.finished has not fired, yet app.serving
1591 // already has. That gap is the whole point of the issue.
1592 expect(getRun(app.db, deployRun.id)?.state).toBe("running");
1593 });
1594
1595 test("production's Open the app link appears only once it is serving, and the fragment after a swap flips it", async () => {
1596 const link = 'href="/alice/vault/app/production/open"';
1597 const deployRun = createRun(app.db, {
1598 repoId: repo.id,
1599 kind: "deploy",
1600 env: "production",
1601 commitOid: "cafed00d",
1602 reason: "push",
1603 });
1604 claimRun(app.db, deployRun.id);
1605 putEnv("production", {
1606 deploying: {
1607 run: { id: deployRun.id, commitOid: "cafed00d" },
1608 abort: new AbortController(),
1609 },
1610 });
1611 const deploying = await (await get("/alice/vault/app/state", owner)).text();
1612 expect(deploying).toContain("starting");
1613 expect(deploying).not.toContain(link);
1614
1615 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1616 putEnv("production", {
1617 live: {
1618 commitOid: "cafed00d",
1619 handle: { port: 4325 },
1620 runId: deployRun.id,
1621 since: new Date().toISOString(),
1622 manifest: { tty: null },
1623 },
1624 });
1625 const serving = await (await get("/alice/vault/app/state", owner)).text();
1626 expect(serving).toContain(">live<");
1627 expect(serving).toContain(link);
1628 });
1629 test("a preview row says how fast it got there in envFacts' own words, links its deploy run, and keeps its link and sentence while deploying or after its tests failed", async () => {
1630 const alice = getUserByName(app.db, "alice")!;
1631 const branch = await commitToBranch(app, alice, repo, {
1632 branch: "feature/fast",
1633 base: repo.defaultBranch,
1634 message: "Add F",
1635 files: [{ path: "F.md", content: "f\n" }],
1636 });
1637 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
1638 const opened = await openPull(app, alice, repo, {
1639 title: "Add F",
1640 body: "",
1641 baseBranch: repo.defaultBranch,
1642 headBranch: "feature/fast",
1643 });
1644 if (!opened.ok) throw new Error(String(opened.error));
1645 const pull = opened.value;
1646 const env = previewEnv(pull.number);
1647 const open = `href="/alice/vault/app/${env}/open"`;
1648 const label = `#${pull.number} Add F`;
1649 const deployRun = createRun(app.db, {
1650 repoId: repo.id,
1651 kind: "deploy",
1652 env,
1653 commitOid: pull.headOid,
1654 reason: "pull",
1655 });
1656 const runLink = `href="/alice/vault/runs/${deployRun.id}"`;
1657 // This pull's own row out of the previews panel, so nothing the head
1658 // card or another pull's row says can pass for it.
1659 const rowOf = (text: string): string => {
1660 const panel = text.slice(text.indexOf('id="app-previews"'));
1661 const at = panel.indexOf(label);
1662 expect(at).toBeGreaterThan(-1);
1663 return panel.slice(panel.lastIndexOf("<li>", at), panel.indexOf("</li>", at));
1664 };
1665
1666 claimRun(app.db, deployRun.id);
1667 putEnv(env, {
1668 deploying: {
1669 run: { id: deployRun.id, commitOid: pull.headOid },
1670 abort: new AbortController(),
1671 },
1672 });
1673 const deploying = rowOf(
1674 await (await get("/alice/vault/app/state", owner)).text(),
1675 );
1676 expect(deploying).toContain(open);
1677 expect(deploying).toContain(runLink);
1678 expect(deploying).toContain("deploy in progress");
1679 expect(deploying).not.toContain("after the push");
1680 expect(deploying).not.toContain("0.0s");
1681
1682 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1683 const createdAt = new Date(Date.now() - 60_000).toISOString();
1684 const servingAt = new Date(Date.parse(createdAt) + 3_400).toISOString();
1685 putEnv(env, {
1686 live: {
1687 commitOid: pull.headOid,
1688 handle: { port: 4324 },
1689 runId: deployRun.id,
1690 since: servingAt,
1691 servingAt,
1692 createdAt,
1693 deployStartedAt: createdAt,
1694 manifest: { tty: null },
1695 },
1696 });
1697 const live = rowOf(await (await get("/alice/vault/app/state", owner)).text());
1698 expect(live).toContain(open);
1699 expect(live).toContain("live 3.4s after the push");
1700 expect(live).toContain(pull.headOid.slice(0, 7));
1701 expect(live).toContain(runLink);
1702 expect(live).toContain(">live<");
1703 // The live client keys its announcement on the meta line's first words.
1704 expect(live).toMatch(new RegExp(`class="row-meta">#${pull.number} Add F ·`));
1705 // The page's first paint draws the same row.
1706 expect(await (await get("/alice/vault", owner)).text()).toContain(
1707 "live 3.4s after the push",
1708 );
1709
1710 putEnv(env, {
1711 live: {
1712 commitOid: pull.headOid,
1713 handle: { port: 4324 },
1714 runId: deployRun.id,
1715 since: servingAt,
1716 servingAt,
1717 createdAt,
1718 deployStartedAt: createdAt,
1719 testsFailed: "2 failing",
1720 manifest: { tty: null },
1721 },
1722 });
1723 const red = rowOf(await (await get("/alice/vault/app/state", owner)).text());
1724 expect(red).toContain(open);
1725 expect(red).toContain(runLink);
1726 expect(red).toContain("serving · tests failed: 2 failing");
1727 expect(red).toContain("live 3.4s after the push");
1728 // One badge, not green, and the row still links to the preview.
1729 expect(red).toMatch(/data-variant="attn"[^]*?serving · tests failed<\/span/);
1730 expect(red).not.toContain('data-variant="success"');
1731 expect(red).not.toContain(">live<");
1732
1733 putEnv(env, {
1734 deploying: {
1735 run: { id: deployRun.id, commitOid: pull.headOid },
1736 abort: new AbortController(),
1737 },
1738 live: {
1739 commitOid: pull.headOid,
1740 handle: { port: 4324 },
1741 runId: deployRun.id,
1742 since: servingAt,
1743 servingAt,
1744 createdAt,
1745 deployStartedAt: createdAt,
1746 manifest: { tty: null },
1747 },
1748 });
1749 const verifying = rowOf(
1750 await (await get("/alice/vault/app/state", owner)).text(),
1751 );
1752 expect(verifying).toContain(open);
1753 expect(verifying).toContain("serving · verifying</span");
1754 expect(verifying).not.toContain('data-variant="success"');
1755 expect(verifying).not.toContain('data-variant="attn"');
1756
1757 putEnv(env, {
1758 live: {
1759 commitOid: pull.headOid,
1760 handle: { port: 4324 },
1761 runId: deployRun.id,
1762 since: servingAt,
1763 manifest: { tty: null },
1764 },
1765 });
1766 const unmeasured = rowOf(
1767 await (await get("/alice/vault/app/state", owner)).text(),
1768 );
1769 expect(unmeasured).toContain(open);
1770 expect(unmeasured).not.toContain("after the");
1771 expect(unmeasured).not.toContain("0.0s");
1772 });
1773 });
1774
1775 /**
1776 * On its own: the slots line counts what the loop counts — the builders —
1777 * while Now still shows every run in flight, review and plan included.
1778 */
test/studio-runs.test.ts added +1953
@@ -0,0 +1,1953 @@
1 /**
2 * The studio: slot counts, words from facts, a run's page, the tails, and
3 * what the front door calls news. One of the files the studio's suite was
4 * split into so no one file sets the gate's floor (#2038); the rest are
5 * test/studio*.test.ts.
6 */
7
8 import { afterAll, beforeAll, describe, expect, spyOn, 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 { createApp, type App } from "../src/app.ts";
13 import { enableApp } from "../src/actions/apps.ts";
14 import type { EnvName } from "../src/apps/data.ts";
15 import { AppsRuntime } from "../src/apps/runtime.ts";
16 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
17 import { addToCrew, createAgent } from "../src/actions/crew.ts";
18 import { assignIssue, openIssue } from "../src/actions/issues.ts";
19 import { mergePull, openPull } from "../src/actions/pulls.ts";
20 import {
21 commitToBranch,
22 createRepo,
23 updateRepoSettings,
24 } from "../src/actions/repos.ts";
25 import { setCrewRole } from "../src/core/agents.ts";
26 import type { Issue } from "../src/core/issues.ts";
27 import { getPull, type Pull } from "../src/core/pulls.ts";
28 import type { Repo } from "../src/core/repos.ts";
29 import {
30 claimRun,
31 createRun,
32 finishRun,
33 getRun,
34 markServing,
35 noteRunProgress,
36 type Run,
37 } from "../src/core/runs.ts";
38 import { createUser, getUserByName, type User } from "../src/core/users.ts";
39 import { serve, type Servers } from "../src/http/server.ts";
40 import {
41 pullWord,
42 runWord,
43 servingHistory,
44 type WordCtx,
45 } from "../src/web/studio/words.ts";
46 import { repoDir } from "../src/git/repos.ts";
47 import { appendRunLog } from "../src/runs/log.ts";
48 import { serving } from "./helpers/studio.ts";
49
50 describe("the studio's slot count", () => {
51 let dataDir: string;
52 let app: App;
53 let servers: Servers;
54 let base: string;
55 let cookie: string;
56
57 beforeAll(async () => {
58 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-slots-"));
59 app = createApp({ dataDir, port: 0 });
60 servers = await serve(app);
61 base = servers.url;
62 const a = await createUser(app.db, { trusted: true,
63 username: "alice",
64 email: "[email protected]",
65 password: "a-good-passphrase",
66 adminIfFirst: true,
67 });
68 if (!a.ok) throw new Error(a.error);
69 const alice = a.value;
70 cookie = (
71 (
72 await fetch(`${base}/signin`, {
73 method: "POST",
74 body: new URLSearchParams({
75 username: "alice",
76 password: "a-good-passphrase",
77 }),
78 redirect: "manual",
79 })
80 ).headers.get("set-cookie") ?? ""
81 ).split(";")[0]!;
82 const made = await createRepo(app, alice, {
83 name: "loom",
84 visibility: "private",
85 description: "a place for cloth",
86 });
87 if (!made.ok) throw new Error(String(made.error));
88 const repo = made.value;
89 const seeded = await commitToBranch(app, alice, repo, {
90 message: "Start",
91 files: [{ path: "README.md", content: "# loom\n" }],
92 });
93 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
94 const enabled = await enableApp(app, alice, repo, { slug: "loom" });
95 if (!enabled.ok) throw new Error(String(enabled.error));
96 for (const name of ["brick", "quill", "chart"]) {
97 const agent = await createAgent(app, alice, { username: name });
98 if (!agent.ok) throw new Error(String(agent.error));
99 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
100 }
101 const reviewer = getUserByName(app.db, "quill")!;
102 const planner = getUserByName(app.db, "chart")!;
103 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
104 setCrewRole(app.db, repo.id, planner.id, "planner");
105 const on = await updateRepoSettings(app, alice, repo, {
106 autonomy: "autonomous",
107 autonomyDirection: "",
108 autonomyCeilingUsd: 10,
109 autonomySlots: 2,
110 autonomyBy: alice.id,
111 });
112 expect(on.ok).toBe(true);
113 // One builder building, one reviewer reviewing, one planner planning.
114 const inFlight: [string, User, string][] = [
115 ["Warp the loom", getUserByName(app.db, "brick")!, "assigned"],
116 ["Look at the shuttle", reviewer, "review"],
117 ["Plan the next cloth", planner, "assigned"],
118 ];
119 for (const [title, actor, reason] of inFlight) {
120 const issue = openIssue(app, alice, repo, { title });
121 if (!issue.ok) throw new Error(String(issue.error));
122 const run = createRun(app.db, {
123 repoId: repo.id,
124 kind: "agent",
125 issueId: issue.value.id,
126 actorId: actor.id,
127 reason,
128 });
129 claimRun(app.db, run.id);
130 }
131 });
132
133 afterAll(async () => {
134 servers.stop();
135 app.close();
136 await rm(dataDir, { recursive: true, force: true });
137 });
138
139 test("reads 1 of 2 in use with three runs in flight, and Now still lists all three", async () => {
140 const page = await (
141 await fetch(`${base}/alice/loom`, {
142 headers: { cookie: `${cookie}; forge_mode=studio` },
143 redirect: "manual",
144 })
145 ).text();
146 expect(page).toContain("1 of 2 in use");
147 expect(page).not.toContain("3 of 2");
148 expect(page).not.toContain("2 of 2");
149 // The runs that take no slot are not hidden: Now has all of them.
150 expect(page).toContain("Warp the loom");
151 expect(page).toContain("Look at the shuttle");
152 expect(page).toContain("Plan the next cloth");
153 });
154 });
155
156 describe("words come from facts, not from run.summary", () => {
157 let dataDir: string;
158 let app: App;
159 let servers: Servers;
160 let runtime: AppsRuntime;
161 let repo: Repo;
162 let record: AppRecord;
163 let alice: User;
164 let wordCtx: WordCtx;
165 let seededCommit: string;
166
167 const put = (live?: { commitOid: string; state?: "failed" }): void => {
168 const envs = (runtime as unknown as { envs: Map<string, unknown> }).envs;
169 envs.set(`${record.id}/production`, {
170 app: record,
171 env: "production",
172 chain: Promise.resolve(),
173 restarts: [],
174 ...(live?.state === "failed"
175 ? { lastError: "the deploy failed" }
176 : live
177 ? {
178 live: {
179 commitOid: live.commitOid,
180 handle: { port: 4321 },
181 runId: "fake",
182 since: new Date().toISOString(),
183 manifest: { tty: null },
184 },
185 }
186 : {}),
187 });
188 };
189
190 beforeAll(async () => {
191 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-words-"));
192 app = createApp({ dataDir, port: 0 });
193 servers = await serve(app);
194 runtime = new AppsRuntime(app, {
195 domain: "words.test",
196 scheme: "http",
197 publicPort: servers.port,
198 forgeUrl: servers.url,
199 concurrency: 1,
200 });
201 const a = await createUser(app.db, { trusted: true,
202 username: "alice",
203 email: "[email protected]",
204 password: "a-good-passphrase",
205 adminIfFirst: true,
206 });
207 if (!a.ok) throw new Error(a.error);
208 alice = a.value;
209 const made = await createRepo(app, alice, {
210 name: "wordsapp",
211 visibility: "private",
212 description: "",
213 });
214 if (!made.ok) throw new Error(String(made.error));
215 repo = made.value;
216 const seeded = await commitToBranch(app, alice, repo, {
217 message: "Start",
218 files: [{ path: "README.md", content: "start\n" }],
219 });
220 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
221 seededCommit = seeded.value.commitOid;
222 const enabled = await enableApp(app, alice, repo, { slug: "wordsapp" });
223 if (!enabled.ok) throw new Error(String(enabled.error));
224 record = getAppByRepo(app.db, repo.id)!;
225 wordCtx = {
226 db: app.db,
227 dir: repoDir(app.reposRoot, repo.ownerName, repo.name),
228 runtime,
229 record,
230 history: servingHistory(),
231 // The owner reads it: production's facts are theirs to see.
232 viewer: alice,
233 };
234 });
235
236 afterAll(async () => {
237 servers.stop();
238 app.close();
239 await rm(dataDir, { recursive: true, force: true });
240 });
241
242 test("a merged pull whose production deploy has not yet served does not say shipped, and says so once it has", async () => {
243 const branch = await commitToBranch(app, alice, repo, {
244 branch: "feature/word",
245 base: "main",
246 message: "Add a word",
247 files: [{ path: "WORD.md", content: "word\n" }],
248 });
249 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
250 const opened = await openPull(app, alice, repo, {
251 title: "Add a word",
252 body: "",
253 baseBranch: "main",
254 headBranch: "feature/word",
255 });
256 if (!opened.ok) throw new Error(String(opened.error));
257 const merged = await mergePull(app, alice, repo, opened.value, "squash");
258 if (!merged.ok) throw new Error(String(merged.error));
259 const pull = getPull(app.db, repo.id, opened.value.number)!;
260 expect(pull.mergedOid).not.toBeNull();
261
262 // Production is still on the commit before the merge: not shipped.
263 put({ commitOid: seededCommit });
264 const notYet = await pullWord(wordCtx, pull);
265 expect(notYet.word).not.toBe("shipped");
266
267 // Production has caught up to the merge commit: shipped, live.
268 put({ commitOid: pull.mergedOid! });
269 const now = await pullWord(wordCtx, pull);
270 expect(now.word).toBe("shipped");
271 });
272
273 test("a page of merged pulls asks git about production once, however many rows (#285)", async () => {
274 const pulls: Pull[] = [];
275 for (let i = 0; i < 5; i++) {
276 const branch = await commitToBranch(app, alice, repo, {
277 branch: `feature/many-${i}`,
278 base: "main",
279 message: `Many ${i}`,
280 files: [{ path: `MANY-${i}.md`, content: `${i}\n` }],
281 });
282 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
283 const opened = await openPull(app, alice, repo, {
284 title: `Many ${i}`,
285 body: "",
286 baseBranch: "main",
287 headBranch: `feature/many-${i}`,
288 });
289 if (!opened.ok) throw new Error(String(opened.error));
290 const merged = await mergePull(app, alice, repo, opened.value, "squash");
291 if (!merged.ok) throw new Error(String(merged.error));
292 pulls.push(getPull(app.db, repo.id, opened.value.number)!);
293 }
294
295 // Every git process spawned while one page's words are decided.
296 const spawned: string[] = [];
297 const original = Bun.spawn.bind(Bun);
298 const spy = spyOn(Bun, "spawn").mockImplementation(((
299 cmd: string[],
300 options: never,
301 ) => {
302 if (cmd[0] === "git") spawned.push(cmd.slice(1).join(" "));
303 return original(cmd, options);
304 }) as unknown as typeof Bun.spawn);
305 const wordsOfOnePage = async (): Promise<string[]> => {
306 const page: WordCtx = { ...wordCtx, history: servingHistory() };
307 const words = await Promise.all(pulls.map((p) => pullWord(page, p)));
308 return words.map((w) => w.word);
309 };
310 try {
311 // Production at the second merge: that one and the one before it are
312 // shipped (ancestry, not equality), the three after it are not.
313 put({ commitOid: pulls[1]!.mergedOid! });
314 expect(await wordsOfOnePage()).toEqual([
315 "shipped",
316 "shipped",
317 "waiting",
318 "waiting",
319 "waiting",
320 ]);
321 expect(spawned).toHaveLength(1);
322
323 // A new page is a new question: the deploy that just landed is seen.
324 spawned.length = 0;
325 put({ commitOid: pulls[4]!.mergedOid! });
326 expect(new Set(await wordsOfOnePage())).toEqual(new Set(["shipped"]));
327 expect(spawned).toHaveLength(1);
328 } finally {
329 spy.mockRestore();
330 }
331 });
332
333 test("an agent run that opened no pull and pushed nothing is not shipped", async () => {
334 const told = await openIssue(app, alice, repo, {
335 title: "Write a plan",
336 body: "",
337 });
338 if (!told.ok) throw new Error(String(told.error));
339 const run = createRun(app.db, {
340 repoId: repo.id,
341 kind: "agent",
342 issueId: told.value.id,
343 branch: "scout/999-a-branch-nothing-used",
344 actorId: alice.id,
345 reason: "assigned",
346 });
347 claimRun(app.db, run.id);
348 const finished = finishRun(app.db, run.id, {
349 state: "ok",
350 summary: "wrote docs/PLAN.md",
351 });
352 const word = await runWord(wordCtx, finished!);
353 expect(word.word).not.toBe("shipped");
354 expect(word.word).toBe("planned");
355 expect(word.note).toBe("wrote docs/PLAN.md");
356 });
357
358 test("a deploy serving with failed tests is not shipped", async () => {
359 const branch = await commitToBranch(app, alice, repo, {
360 branch: "feature/preview",
361 base: "main",
362 message: "Preview",
363 files: [{ path: "PREVIEW.md", content: "preview\n" }],
364 });
365 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
366 const opened = await openPull(app, alice, repo, {
367 title: "Preview",
368 body: "",
369 baseBranch: "main",
370 headBranch: "feature/preview",
371 });
372 if (!opened.ok) throw new Error(String(opened.error));
373 const pull = opened.value;
374 const deploy = createRun(app.db, {
375 repoId: repo.id,
376 kind: "check",
377 issueId: pull.id,
378 env: `pull-${pull.number}`,
379 commitOid: pull.headOid,
380 reason: "pull",
381 });
382 claimRun(app.db, deploy.id);
383 markServing(app.db, deploy.id);
384 finishRun(app.db, deploy.id, {
385 state: "failed",
386 summary: "test: 1 of 3 tests failed",
387 });
388 const word = await pullWord(
389 wordCtx,
390 getPull(app.db, repo.id, pull.number)!,
391 );
392 expect(word.word).not.toBe("shipped");
393 expect(word.note).toBe("serving, but the check failed");
394 });
395 });
396
397 /**
398 * Working, on its own page: `/:owner/:name/runs/:id` in the studio names
399 * the tell, the cost, and offers the steer box — same routes and checks as
400 * dev's transcript, one click away at `?mode=dev`.
401 */
402 describe("working: a run's own page in the studio", () => {
403 let dataDir: string;
404 let app: App;
405 let servers: Servers;
406 let base: string;
407 let repo: Repo;
408 let alice: User;
409 let scoutId: string;
410 let writerCookie: string;
411 let readerCookie: string;
412
413 const signIn = async (username: string): Promise<string> =>
414 (
415 (
416 await fetch(`${base}/signin`, {
417 method: "POST",
418 body: new URLSearchParams({
419 username,
420 password: "a-good-passphrase",
421 }),
422 redirect: "manual",
423 })
424 ).headers.get("set-cookie") ?? ""
425 ).split(";")[0]!;
426
427 const get = (path: string, cookie: string) =>
428 fetch(`${base}${path}`, {
429 headers: { cookie: `${cookie}; forge_mode=studio` },
430 redirect: "manual",
431 });
432
433 beforeAll(async () => {
434 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-run-"));
435 app = createApp({ dataDir, port: 0 });
436 servers = await serve(app);
437 base = servers.url;
438 const a = await createUser(app.db, { trusted: true,
439 username: "alice",
440 email: "[email protected]",
441 password: "a-good-passphrase",
442 adminIfFirst: true,
443 });
444 if (!a.ok) throw new Error(a.error);
445 alice = a.value;
446 const b = await createUser(app.db, { trusted: true,
447 username: "bob",
448 email: "[email protected]",
449 password: "a-good-passphrase",
450 });
451 if (!b.ok) throw new Error(b.error);
452 writerCookie = await signIn("alice");
453 readerCookie = await signIn("bob");
454 const made = await createRepo(app, a.value, {
455 name: "engine",
456 visibility: "private",
457 description: "an app that builds",
458 });
459 if (!made.ok) throw new Error(String(made.error));
460 repo = made.value;
461 const seeded = await commitToBranch(app, a.value, repo, {
462 message: "Start",
463 files: [{ path: "README.md", content: "engine\n" }],
464 });
465 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
466 const enabled = await enableApp(app, a.value, repo, { slug: "engine" });
467 if (!enabled.ok) throw new Error(String(enabled.error));
468 const scout = await createAgent(app, a.value, { username: "scout" });
469 if (!scout.ok) throw new Error(String(scout.error));
470 scoutId = scout.value.user.id;
471 expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true);
472 });
473
474 afterAll(async () => {
475 servers.stop();
476 app.close();
477 await rm(dataDir, { recursive: true, force: true });
478 });
479
480 test("the studio transcript names the tell, the cost, and the steer box", async () => {
481 const tell = openIssue(app, alice, repo, {
482 title: "Fix the flicker on load",
483 body: "It flashes white before the page renders.",
484 });
485 if (!tell.ok) throw new Error(String(tell.error));
486 const run = createRun(app.db, {
487 repoId: repo.id,
488 kind: "agent",
489 issueId: tell.value.id,
490 actorId: scoutId,
491 reason: "assigned",
492 });
493 claimRun(app.db, run.id);
494 noteRunProgress(app.db, run.id, { costUsd: 0.42 });
495
496 const page = await (
497 await get(`/alice/engine/runs/${run.id}`, writerCookie)
498 ).text();
499 expect(page).toContain("Fix the flicker on load");
500 // The tell it came from, in the studio's own noun (#158).
501 expect(page).toContain(`href="/alice/engine/tells/${tell.value.number}"`);
502 expect(page).toContain("$0.42 spent");
503 expect(page).toContain('data-word="working"');
504 // An agent's own run still speaks in the agent's name (#195).
505 expect(page).toContain('data-who="scout"');
506 expect(page).toContain('id="run-steer"');
507 expect(page).toContain("Steer scout");
508 expect(page).toContain('id="run-stop"');
509 // The transcript is on the page, and there is no second reading of it.
510 expect(page).toContain('id="run-log"');
511 expect(page).not.toContain("?mode=dev");
512 });
513
514 test("a long transcript holds its last window, and the whole of it is one step deeper at ?all=1", async () => {
515 const run = createRun(app.db, {
516 repoId: repo.id,
517 kind: "agent",
518 actorId: scoutId,
519 reason: "assigned",
520 });
521 claimRun(app.db, run.id);
522 for (let n = 0; n < 450; n++)
523 appendRunLog(app.config.dataDir, run.id, `line number ${n}`);
524 finishRun(app.db, run.id, { state: "ok", summary: "done" });
525
526 const page = await (
527 await get(`/alice/engine/runs/${run.id}`, writerCookie)
528 ).text();
529 expect(page).not.toContain("line number 0<");
530 expect(page).toContain("line number 449");
531 expect(page).toContain(`href="/alice/engine/runs/${run.id}?all=1"`);
532
533 const all = await (
534 await get(`/alice/engine/runs/${run.id}?all=1`, writerCookie)
535 ).text();
536 expect(all).toContain("line number 0<");
537 expect(all).toContain("line number 449");
538
539 // The depth step is refused exactly as the run is.
540 const anon = await fetch(`${base}/alice/engine/runs/${run.id}?all=1`, {
541 redirect: "manual",
542 });
543 expect(anon.status).toBe(404);
544 });
545
546 test("a reader without access gets the forge's not-found, exactly as dev", async () => {
547 const tell = openIssue(app, alice, repo, { title: "Private work" });
548 if (!tell.ok) throw new Error(String(tell.error));
549 const run = createRun(app.db, {
550 repoId: repo.id,
551 kind: "agent",
552 issueId: tell.value.id,
553 actorId: scoutId,
554 reason: "assigned",
555 });
556 claimRun(app.db, run.id);
557
558 const asReader = await get(`/alice/engine/runs/${run.id}`, readerCookie);
559 expect(asReader.status).toBe(404);
560 });
561
562 test("a finished run renders in the studio's own word, with no Stop", async () => {
563 const tell = openIssue(app, alice, repo, { title: "Tidy the footer" });
564 if (!tell.ok) throw new Error(String(tell.error));
565 const branch = await commitToBranch(app, alice, repo, {
566 branch: "scout/tidy-the-footer",
567 base: "main",
568 message: "Tidy the footer",
569 files: [{ path: "FOOTER.md", content: "tidy\n" }],
570 });
571 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
572 const opened = await openPull(app, alice, repo, {
573 title: "Tidy the footer",
574 body: `Closes #${tell.value.number}`,
575 baseBranch: "main",
576 headBranch: "scout/tidy-the-footer",
577 });
578 if (!opened.ok) throw new Error(String(opened.error));
579 // The word comes from the pull this run actually produced (found by
580 // its branch), not from parsing the summary below.
581 const run = createRun(app.db, {
582 repoId: repo.id,
583 kind: "agent",
584 issueId: tell.value.id,
585 branch: "scout/tidy-the-footer",
586 actorId: scoutId,
587 reason: "assigned",
588 });
589 claimRun(app.db, run.id);
590 finishRun(app.db, run.id, {
591 state: "ok",
592 summary: `opened pull #${opened.value.number}`,
593 costUsd: 0.1,
594 turns: 4,
595 });
596
597 const page = await (
598 await get(`/alice/engine/runs/${run.id}`, writerCookie)
599 ).text();
600 // The badge itself, not a stylesheet that happens to mention the word:
601 // this read `data-word="look"` until the studio's rules moved out of the
602 // page, and matched `.studio-list li[data-word="look"]` in the inline
603 // <style>, never the badge. The pull has no deploy of this head, so its
604 // word is `waiting` — and the point stands either way: the word came
605 // from the pull found by branch, not from the summary (`planned` is what
606 // a run with nothing to show would say).
607 expect(page).toMatch(/data-word="waiting"\s+id="run-word"/);
608 expect(page).not.toContain('data-word="planned"');
609 expect(page).not.toContain('id="run-stop"');
610 expect(page).not.toContain(">Stop<");
611 expect(page).not.toContain(">Cancel run<");
612 });
613
614 test("a deploy's own run page speaks as the deploy, reads as done rather than planned, and prints a tenth for a sub-second run (#195)", async () => {
615 const run = createRun(app.db, {
616 repoId: repo.id,
617 kind: "deploy",
618 env: "production",
619 commitOid: "c740479",
620 reason: "boot",
621 });
622 claimRun(app.db, run.id);
623 appendRunLog(app.config.dataDir, run.id, "$ bun test");
624 appendRunLog(app.config.dataDir, run.id, "bun test v1.3.14 (0d9b296a)");
625 appendRunLog(
626 app.config.dataDir,
627 run.id,
628 "(pass) the page knows who you are and offers the right door [3.61ms]",
629 );
630 finishRun(app.db, run.id, { state: "ok", summary: "deploy" });
631 // A run that took 300ms — today's whole-second rounding reads "took 0s".
632 app.db
633 .query("UPDATE run SET started_at = ?2, finished_at = ?3 WHERE id = ?1")
634 .run(run.id, "2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00.300Z");
635
636 const page = await (
637 await get(`/alice/engine/runs/${run.id}`, writerCookie)
638 ).text();
639 // The deploy speaks for itself: the box, and every line in it — never
640 // "an agent", which is nobody that was actually there.
641 expect(page).toContain('data-who="the deploy"');
642 expect(page).toContain('class="who">the deploy<');
643 expect(page).not.toContain("an agent");
644 // A deploy that finished with nothing else to show reads as done, not
645 // as a run that has not started yet.
646 expect(page).toContain('data-word="done"');
647 expect(page).not.toContain('data-word="planned"');
648 // Sub-second, in tenths — not rounded away to "took 0s".
649 expect(page).toContain(">took 0.3s<");
650 });
651 });
652
653 /**
654 * Issue #88: the app's page tails the crew's log while it works — the last
655 * handful of lines beside the running row, live, bounded, and with a
656 * no-script fallback that behaves like the run page's own. Its own repo and
657 * app, so no other test's still-running run ever counts as "the active run"
658 * here.
659 */
660 describe("the app's page tails the crew's log while it works", () => {
661 let dataDir: string;
662 let app: App;
663 let servers: Servers;
664 let base: string;
665 let repo: Repo;
666 let alice: User;
667 let scoutId: string;
668 let writerCookie: string;
669 let readerCookie: string;
670
671 const signIn = async (username: string): Promise<string> =>
672 (
673 (
674 await fetch(`${base}/signin`, {
675 method: "POST",
676 body: new URLSearchParams({
677 username,
678 password: "a-good-passphrase",
679 }),
680 redirect: "manual",
681 })
682 ).headers.get("set-cookie") ?? ""
683 ).split(";")[0]!;
684
685 const get = (path: string, cookie: string) =>
686 fetch(`${base}${path}`, {
687 headers: { cookie: `${cookie}; forge_mode=studio` },
688 redirect: "manual",
689 });
690
691 beforeAll(async () => {
692 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-tail-"));
693 app = createApp({ dataDir, port: 0 });
694 servers = await serve(app);
695 base = servers.url;
696 const a = await createUser(app.db, { trusted: true,
697 username: "alice",
698 email: "[email protected]",
699 password: "a-good-passphrase",
700 adminIfFirst: true,
701 });
702 if (!a.ok) throw new Error(a.error);
703 alice = a.value;
704 const b = await createUser(app.db, { trusted: true,
705 username: "bob",
706 email: "[email protected]",
707 password: "a-good-passphrase",
708 });
709 if (!b.ok) throw new Error(b.error);
710 writerCookie = await signIn("alice");
711 readerCookie = await signIn("bob");
712 const made = await createRepo(app, a.value, {
713 name: "engine",
714 visibility: "private",
715 description: "an app that builds",
716 });
717 if (!made.ok) throw new Error(String(made.error));
718 repo = made.value;
719 const seeded = await commitToBranch(app, a.value, repo, {
720 message: "Start",
721 files: [{ path: "README.md", content: "engine\n" }],
722 });
723 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
724 const enabled = await enableApp(app, a.value, repo, { slug: "engine" });
725 if (!enabled.ok) throw new Error(String(enabled.error));
726 const scout = await createAgent(app, a.value, { username: "scout" });
727 if (!scout.ok) throw new Error(String(scout.error));
728 scoutId = scout.value.user.id;
729 expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true);
730 });
731
732 afterAll(async () => {
733 servers.stop();
734 app.close();
735 await rm(dataDir, { recursive: true, force: true });
736 });
737
738 test("the app's page carries the tail of the active run's log, bounded, and refreshes without a script", async () => {
739 const tell = openIssue(app, alice, repo, { title: "Speed up boot" });
740 if (!tell.ok) throw new Error(String(tell.error));
741 const run = createRun(app.db, {
742 repoId: repo.id,
743 kind: "agent",
744 issueId: tell.value.id,
745 actorId: scoutId,
746 reason: "assigned",
747 });
748 claimRun(app.db, run.id);
749 for (let i = 0; i < 9; i++)
750 appendRunLog(app.config.dataDir, run.id, `line ${i}`);
751
752 const page = await (await get("/alice/engine", writerCookie)).text();
753 expect(page).toContain('id="app-run-tail"');
754 expect(page).toContain(
755 `data-stream="/api/v1/repos/alice/engine/runs/${run.id}/log?follow=1"`,
756 );
757 // Bounded on the first paint too: only the last handful made it, and
758 // the box says so.
759 expect(page).not.toContain(">line 0<");
760 expect(page).toContain(">line 8<");
761 expect(page).toContain("Holding the last 6 lines here");
762 // No script, no problem: the page keeps refreshing while the run goes.
763 expect(page).toContain('http-equiv="refresh"');
764
765 const stream = await get(
766 `/api/v1/repos/alice/engine/runs/${run.id}/log?follow=1`,
767 writerCookie,
768 );
769 expect(stream.status).toBe(200);
770
771 finishRun(app.db, run.id, { state: "ok", summary: "planned" });
772 const settled = await (await get("/alice/engine", writerCookie)).text();
773 expect(settled).not.toContain('id="app-run-tail"');
774 });
775
776 test("a log far larger than the tail's cap still ends the page at its true last line", async () => {
777 const tell = openIssue(app, alice, repo, { title: "Long-running build" });
778 if (!tell.ok) throw new Error(String(tell.error));
779 const run = createRun(app.db, {
780 repoId: repo.id,
781 kind: "agent",
782 issueId: tell.value.id,
783 actorId: scoutId,
784 reason: "assigned",
785 });
786 claimRun(app.db, run.id);
787 // Far past a byte cap sized for a handful of lines (old default: 512KB).
788 for (let i = 0; i < 2000; i++)
789 appendRunLog(app.config.dataDir, run.id, `line ${i}`);
790
791 const page = await (await get("/alice/engine", writerCookie)).text();
792 expect(page).not.toContain(">line 1993<");
793 expect(page).toContain(">line 1999<");
794 expect(page).toContain("Holding the last 6 lines here");
795
796 finishRun(app.db, run.id, { state: "ok", summary: "planned" });
797 });
798
799 test("a reader without access gets the forge's not-found from the page and from the tail's own stream", async () => {
800 const tell = openIssue(app, alice, repo, { title: "Private boot work" });
801 if (!tell.ok) throw new Error(String(tell.error));
802 const run = createRun(app.db, {
803 repoId: repo.id,
804 kind: "agent",
805 issueId: tell.value.id,
806 actorId: scoutId,
807 reason: "assigned",
808 });
809 claimRun(app.db, run.id);
810 appendRunLog(app.config.dataDir, run.id, "line 0");
811
812 const page = await get("/alice/engine", readerCookie);
813 expect(page.status).toBe(404);
814 const stream = await get(
815 `/api/v1/repos/alice/engine/runs/${run.id}/log?follow=1`,
816 readerCookie,
817 );
818 expect(stream.status).toBe(404);
819 finishRun(app.db, run.id, { state: "ok", summary: "planned" });
820 });
821 });
822
823 /**
824 * Issue #145: the run a brand-new app is waiting on is its first deploy, not
825 * an agent's — the head card tails production's own deploy the same way a
826 * running row is tailed, and yields to an agent run's own tail when one is
827 * also going, so the page never opens two streams for one repo.
828 */
829 describe("the app's page tails the deploy that is bringing it alive", () => {
830 let dataDir: string;
831 let app: App;
832 let servers: Servers;
833 let base: string;
834 let runtime: AppsRuntime;
835 let repo: Repo;
836 let record: AppRecord;
837 let owner: string;
838 let scoutId: string;
839
840 interface PreviewSlot {
841 readonly app: AppRecord;
842 readonly env: EnvName;
843 readonly chain: Promise<void>;
844 readonly restarts: number[];
845 deploying?: unknown;
846 live?: unknown;
847 lastError?: string;
848 lastRunId?: string;
849 }
850 const putEnv = (
851 env: EnvName,
852 slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">,
853 ): void => {
854 (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set(
855 `${record.id}/${env}`,
856 { app: record, env, chain: Promise.resolve(), restarts: [], ...slot },
857 );
858 };
859
860 const signIn = async (username: string): Promise<string> =>
861 (
862 (
863 await fetch(`${base}/signin`, {
864 method: "POST",
865 body: new URLSearchParams({
866 username,
867 password: "a-good-passphrase",
868 }),
869 redirect: "manual",
870 })
871 ).headers.get("set-cookie") ?? ""
872 ).split(";")[0]!;
873
874 const get = (path: string, cookie: string) =>
875 fetch(`${base}${path}`, {
876 headers: { cookie: `${cookie}; forge_mode=studio` },
877 redirect: "manual",
878 });
879
880 beforeAll(async () => {
881 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-deploy-tail-"));
882 app = createApp({ dataDir, port: 0 });
883 servers = await serve(app);
884 base = servers.url;
885 runtime = new AppsRuntime(app, {
886 domain: "deploytail.test",
887 scheme: "http",
888 publicPort: servers.port,
889 forgeUrl: servers.url,
890 concurrency: 1,
891 });
892 const a = await createUser(app.db, { trusted: true,
893 username: "alice",
894 email: "[email protected]",
895 password: "a-good-passphrase",
896 adminIfFirst: true,
897 });
898 if (!a.ok) throw new Error(a.error);
899 owner = await signIn("alice");
900 const made = await createRepo(app, a.value, {
901 name: "kindling",
902 visibility: "private",
903 description: "a new app",
904 });
905 if (!made.ok) throw new Error(String(made.error));
906 repo = made.value;
907 const seeded = await commitToBranch(app, a.value, repo, {
908 message: "Start",
909 files: [{ path: "README.md", content: "kindling\n" }],
910 });
911 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
912 const enabled = await enableApp(app, a.value, repo, { slug: "kindling" });
913 if (!enabled.ok) throw new Error(String(enabled.error));
914 record = getAppByRepo(app.db, repo.id)!;
915 const scout = await createAgent(app, a.value, { username: "scout" });
916 if (!scout.ok) throw new Error(String(scout.error));
917 scoutId = scout.value.user.id;
918 expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true);
919 });
920
921 afterAll(async () => {
922 servers.stop();
923 app.close();
924 await rm(dataDir, { recursive: true, force: true });
925 });
926
927 test("a production deploy under way, with no agent run going, is tailed in the head card and named as the deploy's own stream", async () => {
928 const deployRun = createRun(app.db, {
929 repoId: repo.id,
930 kind: "deploy",
931 env: "production",
932 commitOid: "f00dcafe",
933 reason: "boot",
934 });
935 claimRun(app.db, deployRun.id);
936 appendRunLog(app.config.dataDir, deployRun.id, "checking out f00dcafe");
937 appendRunLog(app.config.dataDir, deployRun.id, "install …");
938 putEnv("production", {
939 deploying: {
940 run: { id: deployRun.id, commitOid: "f00dcafe" },
941 abort: new AbortController(),
942 },
943 });
944
945 const page = await (await get("/alice/kindling", owner)).text();
946 expect(page).toContain('id="app-run-tail"');
947 expect(page).toContain('data-who="the deploy"');
948 expect(page).toContain(
949 `data-stream="/api/v1/repos/alice/kindling/runs/${deployRun.id}/log?follow=1"`,
950 );
951 expect(page).toContain("install …");
952 // No script, no problem: the page keeps refreshing while the deploy
953 // goes — the actual meta tag, not just the script's own selector text.
954 expect(page).toMatch(/<meta\s+http-equiv="refresh"/);
955 // Issue #163: the deploy is plainly going, in the head card above — "Now"
956 // does not also claim nothing is happening, and it does not open a
957 // second stream of the same lines to say so.
958 expect(page).not.toContain("Nothing yet");
959 expect(page).toContain("Production is coming up");
960 expect(page.match(/id="app-run-tail"/g)?.length).toBe(1);
961
962 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
963 });
964
965 test("an agent run at work outranks a background deploy: the page follows the crew, never both", async () => {
966 const deployRun = createRun(app.db, {
967 repoId: repo.id,
968 kind: "deploy",
969 env: "production",
970 commitOid: "d0d0d0d0",
971 reason: "push",
972 });
973 claimRun(app.db, deployRun.id);
974 appendRunLog(app.config.dataDir, deployRun.id, "deploying d0d0d0d0");
975 putEnv("production", {
976 deploying: {
977 run: { id: deployRun.id, commitOid: "d0d0d0d0" },
978 abort: new AbortController(),
979 },
980 });
981
982 const tell = openIssue(app, getUserByName(app.db, "alice")!, repo, {
983 title: "Add a footer",
984 });
985 if (!tell.ok) throw new Error(String(tell.error));
986 const agentRun = createRun(app.db, {
987 repoId: repo.id,
988 kind: "agent",
989 issueId: tell.value.id,
990 actorId: scoutId,
991 reason: "assigned",
992 });
993 claimRun(app.db, agentRun.id);
994 appendRunLog(app.config.dataDir, agentRun.id, "scout is on it");
995
996 const page = await (await get("/alice/kindling", owner)).text();
997 // Exactly one tail, and it is the crew's own run — never the deploy's.
998 expect(page.match(/id="app-run-tail"/g)?.length).toBe(1);
999 expect(page).not.toContain('data-who="the deploy"');
1000 expect(page).toContain(
1001 `data-stream="/api/v1/repos/alice/kindling/runs/${agentRun.id}/log?follow=1"`,
1002 );
1003
1004 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1005 finishRun(app.db, agentRun.id, { state: "ok", summary: "planned" });
1006 });
1007
1008 // Issue #284: production stays "live" long after its deploy finishes, and
1009 // a fresh live process that has written nothing to its log yet is not an
1010 // empty black box — the card says so in a line instead.
1011 test("with no agent run going and production live but silent, the head card says so in a line, not an empty box", async () => {
1012 putEnv("production", {
1013 live: {
1014 commitOid: "d0d0d0d0",
1015 handle: { port: 4501 },
1016 runId: "irrelevant",
1017 since: new Date().toISOString(),
1018 manifest: { tty: null },
1019 },
1020 });
1021 const page = await (await get("/alice/kindling", owner)).text();
1022 expect(page).toContain('id="app-run-tail"');
1023 expect(page).not.toContain('class="run-log');
1024 expect(page).toContain("Production is quiet");
1025 // Still nothing to fetch — the page keeps polling in case that changes.
1026 expect(page).toMatch(/<meta\s+http-equiv="refresh"/);
1027 });
1028 });
1029
1030 /**
1031 * Issue #284: production keeps writing to the same run's log for as long as
1032 * its process runs, long after the deploy that produced it is finished —
1033 * the head card's tail follows that, not just a deploy in flight.
1034 */
1035 describe("the app's page tails production's own output while it runs (#284)", () => {
1036 let dataDir: string;
1037 let app: App;
1038 let servers: Servers;
1039 let base: string;
1040 let runtime: AppsRuntime;
1041 let repo: Repo;
1042 let record: AppRecord;
1043 let owner: string;
1044 let strangerCookie: string;
1045 let scoutId: string;
1046
1047 interface PreviewSlot {
1048 readonly app: AppRecord;
1049 readonly env: EnvName;
1050 readonly chain: Promise<void>;
1051 readonly restarts: number[];
1052 deploying?: unknown;
1053 live?: unknown;
1054 lastError?: string;
1055 lastRunId?: string;
1056 }
1057 const putEnv = (
1058 env: EnvName,
1059 slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">,
1060 ): void => {
1061 (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set(
1062 `${record.id}/${env}`,
1063 { app: record, env, chain: Promise.resolve(), restarts: [], ...slot },
1064 );
1065 };
1066
1067 const signIn = async (username: string): Promise<string> =>
1068 (
1069 (
1070 await fetch(`${base}/signin`, {
1071 method: "POST",
1072 body: new URLSearchParams({
1073 username,
1074 password: "a-good-passphrase",
1075 }),
1076 redirect: "manual",
1077 })
1078 ).headers.get("set-cookie") ?? ""
1079 ).split(";")[0]!;
1080
1081 const get = (path: string, cookie: string) =>
1082 fetch(`${base}${path}`, {
1083 headers: { cookie: `${cookie}; forge_mode=studio` },
1084 redirect: "manual",
1085 });
1086
1087 beforeAll(async () => {
1088 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-live-tail-"));
1089 app = createApp({ dataDir, port: 0 });
1090 servers = await serve(app);
1091 base = servers.url;
1092 runtime = new AppsRuntime(app, {
1093 domain: "livetail.test",
1094 scheme: "http",
1095 publicPort: servers.port,
1096 forgeUrl: servers.url,
1097 concurrency: 1,
1098 });
1099 const a = await createUser(app.db, { trusted: true,
1100 username: "alice",
1101 email: "[email protected]",
1102 password: "a-good-passphrase",
1103 adminIfFirst: true,
1104 });
1105 if (!a.ok) throw new Error(a.error);
1106 const c = await createUser(app.db, { trusted: true,
1107 username: "carol",
1108 email: "[email protected]",
1109 password: "a-good-passphrase",
1110 });
1111 if (!c.ok) throw new Error(c.error);
1112 owner = await signIn("alice");
1113 strangerCookie = await signIn("carol");
1114 const made = await createRepo(app, a.value, {
1115 name: "furnace",
1116 visibility: "public",
1117 description: "an app that keeps a fire going",
1118 });
1119 if (!made.ok) throw new Error(String(made.error));
1120 repo = made.value;
1121 const seeded = await commitToBranch(app, a.value, repo, {
1122 message: "Start",
1123 files: [{ path: "README.md", content: "furnace\n" }],
1124 });
1125 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1126 const enabled = await enableApp(app, a.value, repo, { slug: "furnace" });
1127 if (!enabled.ok) throw new Error(String(enabled.error));
1128 record = getAppByRepo(app.db, repo.id)!;
1129 const scout = await createAgent(app, a.value, { username: "scout" });
1130 if (!scout.ok) throw new Error(String(scout.error));
1131 scoutId = scout.value.user.id;
1132 expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true);
1133 });
1134
1135 afterAll(async () => {
1136 servers.stop();
1137 app.close();
1138 await rm(dataDir, { recursive: true, force: true });
1139 });
1140
1141 test("production running with lines in its log carries the tail, named as the app's own", async () => {
1142 const deployRun = createRun(app.db, {
1143 repoId: repo.id,
1144 kind: "deploy",
1145 env: "production",
1146 commitOid: "b16b00b5",
1147 reason: "push",
1148 });
1149 claimRun(app.db, deployRun.id);
1150 for (let i = 0; i < 9; i++)
1151 appendRunLog(app.config.dataDir, deployRun.id, `serving request ${i}`);
1152 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1153 putEnv("production", {
1154 live: {
1155 commitOid: "b16b00b5",
1156 handle: { port: 4503 },
1157 runId: deployRun.id,
1158 since: new Date().toISOString(),
1159 manifest: { tty: null },
1160 },
1161 });
1162
1163 const page = await (await get("/alice/furnace", owner)).text();
1164 expect(page).toContain('id="app-run-tail"');
1165 expect(page).toContain('data-who="the app"');
1166 expect(page).toContain(
1167 `data-stream="/api/v1/repos/alice/furnace/runs/${deployRun.id}/log?follow=1"`,
1168 );
1169 expect(page).not.toContain(">serving request 0<");
1170 expect(page).toContain(">serving request 8<");
1171 expect(page).toContain("Holding the last 6 lines here");
1172 expect(page).toMatch(/<meta\s+http-equiv="refresh"/);
1173
1174 // Issue #284's own point: the run this reads from is long finished —
1175 // the tail follows the env that points at it, not the run's own state.
1176 expect(getRun(app.db, deployRun.id)?.state).toBe("ok");
1177 });
1178
1179 test("a viewer who cannot see the owner's facts gets no output at all", async () => {
1180 const response = await get("/alice/furnace", strangerCookie);
1181 expect(response.status).toBe(200);
1182 const page = await response.text();
1183 expect(page).not.toContain('id="app-run-tail"');
1184 expect(page).not.toContain("serving request");
1185 });
1186
1187 test("an agent run at work still outranks production's own tail", async () => {
1188 const tell = openIssue(app, getUserByName(app.db, "alice")!, repo, {
1189 title: "Add a chimney",
1190 });
1191 if (!tell.ok) throw new Error(String(tell.error));
1192 const agentRun = createRun(app.db, {
1193 repoId: repo.id,
1194 kind: "agent",
1195 issueId: tell.value.id,
1196 actorId: scoutId,
1197 reason: "assigned",
1198 });
1199 claimRun(app.db, agentRun.id);
1200 appendRunLog(app.config.dataDir, agentRun.id, "scout is on it");
1201
1202 const page = await (await get("/alice/furnace", owner)).text();
1203 expect(page.match(/id="app-run-tail"/g)?.length).toBe(1);
1204 expect(page).not.toContain('data-who="the app"');
1205 expect(page).toContain(
1206 `data-stream="/api/v1/repos/alice/furnace/runs/${agentRun.id}/log?follow=1"`,
1207 );
1208
1209 finishRun(app.db, agentRun.id, { state: "ok", summary: "planned" });
1210 });
1211 });
1212
1213 /**
1214 * Issue #163: "Now" is built from agent runs alone, so a fresh app with a
1215 * deploy going and nothing else read as if nothing were happening — right
1216 * beside the head card tailing that very deploy. "Now"'s empty state now
1217 * says otherwise when the head card is tailing a deploy, and keeps its
1218 * ordinary wording when nothing at all is going.
1219 */
1220 describe(`"Now" while production is coming up and nothing else is`, () => {
1221 let dataDir: string;
1222 let app: App;
1223 let servers: Servers;
1224 let base: string;
1225 let runtime: AppsRuntime;
1226 let repo: Repo;
1227 let record: AppRecord;
1228 let owner: string;
1229
1230 interface PreviewSlot {
1231 readonly app: AppRecord;
1232 readonly env: EnvName;
1233 readonly chain: Promise<void>;
1234 readonly restarts: number[];
1235 deploying?: unknown;
1236 live?: unknown;
1237 lastError?: string;
1238 lastRunId?: string;
1239 }
1240 const putEnv = (
1241 env: EnvName,
1242 slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">,
1243 ): void => {
1244 (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set(
1245 `${record.id}/${env}`,
1246 { app: record, env, chain: Promise.resolve(), restarts: [], ...slot },
1247 );
1248 };
1249
1250 const signIn = async (username: string): Promise<string> =>
1251 (
1252 (
1253 await fetch(`${base}/signin`, {
1254 method: "POST",
1255 body: new URLSearchParams({
1256 username,
1257 password: "a-good-passphrase",
1258 }),
1259 redirect: "manual",
1260 })
1261 ).headers.get("set-cookie") ?? ""
1262 ).split(";")[0]!;
1263
1264 const get = (path: string, cookie: string) =>
1265 fetch(`${base}${path}`, {
1266 headers: { cookie: `${cookie}; forge_mode=studio` },
1267 redirect: "manual",
1268 });
1269
1270 beforeAll(async () => {
1271 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-now-empty-"));
1272 app = createApp({ dataDir, port: 0 });
1273 servers = await serve(app);
1274 base = servers.url;
1275 runtime = new AppsRuntime(app, {
1276 domain: "nowempty.test",
1277 scheme: "http",
1278 publicPort: servers.port,
1279 forgeUrl: servers.url,
1280 concurrency: 1,
1281 });
1282 const a = await createUser(app.db, { trusted: true,
1283 username: "alice",
1284 email: "[email protected]",
1285 password: "a-good-passphrase",
1286 adminIfFirst: true,
1287 });
1288 if (!a.ok) throw new Error(a.error);
1289 owner = await signIn("alice");
1290 const made = await createRepo(app, a.value, {
1291 name: "ember",
1292 visibility: "private",
1293 description: "a new app",
1294 });
1295 if (!made.ok) throw new Error(String(made.error));
1296 repo = made.value;
1297 const seeded = await commitToBranch(app, a.value, repo, {
1298 message: "Start",
1299 files: [{ path: "README.md", content: "ember\n" }],
1300 });
1301 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1302 const enabled = await enableApp(app, a.value, repo, { slug: "ember" });
1303 if (!enabled.ok) throw new Error(String(enabled.error));
1304 record = getAppByRepo(app.db, repo.id)!;
1305 });
1306
1307 afterAll(async () => {
1308 servers.stop();
1309 app.close();
1310 await rm(dataDir, { recursive: true, force: true });
1311 });
1312
1313 test('a running deploy and no agent run: "Now" does not say nothing is happening, and opens no second tail', async () => {
1314 const deployRun = createRun(app.db, {
1315 repoId: repo.id,
1316 kind: "deploy",
1317 env: "production",
1318 commitOid: "eae580a",
1319 reason: "boot",
1320 });
1321 claimRun(app.db, deployRun.id);
1322 appendRunLog(app.config.dataDir, deployRun.id, "deploy in progress");
1323 putEnv("production", {
1324 deploying: {
1325 run: { id: deployRun.id, commitOid: "eae580a" },
1326 abort: new AbortController(),
1327 },
1328 });
1329
1330 const page = await (await get("/alice/ember", owner)).text();
1331 expect(page).not.toContain("Nothing yet");
1332 expect(page).toContain("Production is coming up");
1333 // Exactly one tail on the page — the head card's, not a second one in
1334 // "Now".
1335 expect(page.match(/id="app-run-tail"/g)?.length).toBe(1);
1336
1337 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1338 });
1339
1340 test('with nothing going at all, "Now" reads exactly as it does today — production live and silent says its own quiet line above, not through "Now"', async () => {
1341 putEnv("production", {
1342 live: {
1343 commitOid: "eae580a",
1344 handle: { port: 4502 },
1345 runId: "irrelevant",
1346 since: new Date().toISOString(),
1347 manifest: { tty: null },
1348 },
1349 });
1350
1351 const page = await (await get("/alice/ember", owner)).text();
1352 expect(page).toContain("Nothing yet. Tell the crew something.");
1353 // The head card's own quiet line (#284) is not the deploy-in-progress
1354 // wording "Now" uses — production here is live, not coming up.
1355 expect(page).not.toContain("Production is coming up");
1356 expect(page).toContain("Production is quiet");
1357 });
1358 });
1359
1360 /**
1361 * Issue #183: `tell` cannot route a tell to anyone with nobody on the
1362 * crew — the issue it opens stays unassigned forever, not merely
1363 * unpicked-up like an agent-held tell. Both surfaces that used to call this
1364 * "quiet" or "nothing yet" must say the honest reason instead, and both
1365 * failed before this change.
1366 */
1367 describe('a tell nobody on the crew can take is not "quiet"', () => {
1368 let dataDir: string;
1369 let app: App;
1370 let servers: Servers;
1371 let base: string;
1372 let repo: Repo;
1373 let alice: User;
1374 let cookie: string;
1375
1376 const get = (path: string) =>
1377 fetch(`${base}${path}`, {
1378 headers: { cookie: `${cookie}; forge_mode=studio` },
1379 redirect: "manual",
1380 });
1381 const post = (path: string, body: Record<string, string>) =>
1382 fetch(`${base}${path}`, {
1383 method: "POST",
1384 headers: { cookie, origin: base },
1385 body: new URLSearchParams(body),
1386 redirect: "manual",
1387 });
1388
1389 beforeAll(async () => {
1390 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-untaken-"));
1391 app = createApp({ dataDir, port: 0 });
1392 servers = await serve(app);
1393 base = servers.url;
1394 const a = await createUser(app.db, { trusted: true,
1395 username: "alice",
1396 email: "[email protected]",
1397 password: "a-good-passphrase",
1398 adminIfFirst: true,
1399 });
1400 if (!a.ok) throw new Error(a.error);
1401 alice = a.value;
1402 cookie = (
1403 (
1404 await fetch(`${base}/signin`, {
1405 method: "POST",
1406 body: new URLSearchParams({
1407 username: "alice",
1408 password: "a-good-passphrase",
1409 }),
1410 redirect: "manual",
1411 })
1412 ).headers.get("set-cookie") ?? ""
1413 ).split(";")[0]!;
1414 const made = await createRepo(app, alice, {
1415 name: "pad",
1416 visibility: "private",
1417 description: "a pad for notes",
1418 });
1419 if (!made.ok) throw new Error(String(made.error));
1420 repo = made.value;
1421 const seeded = await commitToBranch(app, alice, repo, {
1422 message: "Start",
1423 files: [{ path: "README.md", content: "pad\n" }],
1424 });
1425 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1426 const enabled = await enableApp(app, alice, repo, { slug: "pad" });
1427 if (!enabled.ok) throw new Error(String(enabled.error));
1428 // No crew at all — the exact state `tell` cannot route out of.
1429 });
1430
1431 afterAll(async () => {
1432 servers.stop();
1433 app.close();
1434 await rm(dataDir, { recursive: true, force: true });
1435 });
1436
1437 test('a crewless tell renders a "Now" row, not the empty state, and links to where a crew member is added', async () => {
1438 const told = await post("/alice/pad/tell", {
1439 text: "Add a dark mode toggle.",
1440 });
1441 expect(told.status).toBe(303);
1442
1443 const page = await (await get("/alice/pad")).text();
1444 expect(page).not.toContain("Nothing yet. Tell the crew something.");
1445 expect(page).toContain("Add a dark mode toggle.");
1446 expect(page).toContain("nobody on the crew to take it");
1447 expect(page).toContain(`href="/alice/pad/settings"`);
1448 });
1449
1450 test('the shelf row for that app is not "quiet"', async () => {
1451 const home = await (await get("/apps")).text();
1452 const at = home.indexOf('href="/alice/pad"');
1453 expect(at).toBeGreaterThan(-1);
1454 const start = home.lastIndexOf("<li>", at);
1455 const shelfRow = home.slice(start, home.indexOf("</li>", at) + 5);
1456 expect(shelfRow).not.toContain("quiet — tell the crew something");
1457 expect(shelfRow).toContain("nobody on the crew to take it");
1458 });
1459
1460 test("a reader of the public app sees the fact, not the owner's invitation or a link to settings", async () => {
1461 app.db.run("UPDATE repo SET visibility = 'public' WHERE id = ?", [
1462 repo.id,
1463 ]);
1464 const b = await createUser(app.db, { trusted: true,
1465 username: "bob",
1466 email: "[email protected]",
1467 password: "a-good-passphrase",
1468 });
1469 if (!b.ok) throw new Error(b.error);
1470 const bobCookie = (
1471 (
1472 await fetch(`${base}/signin`, {
1473 method: "POST",
1474 body: new URLSearchParams({
1475 username: "bob",
1476 password: "a-good-passphrase",
1477 }),
1478 redirect: "manual",
1479 })
1480 ).headers.get("set-cookie") ?? ""
1481 ).split(";")[0]!;
1482 const read = await (
1483 await fetch(`${base}/alice/pad`, {
1484 headers: { cookie: `${bobCookie}; forge_mode=studio` },
1485 })
1486 ).text();
1487 expect(read).toContain("Add a dark mode toggle.");
1488 expect(read).toContain("nobody on the crew to take it");
1489 expect(read).not.toContain("add someone");
1490 expect(read).not.toContain(`href="/alice/pad/settings"`);
1491 // The writer still gets the invitation, link included.
1492 const write = await (await get("/alice/pad")).text();
1493 expect(write).toContain("add someone");
1494 expect(write).toContain(`href="/alice/pad/settings"`);
1495 });
1496 });
1497
1498 /**
1499 * Issue #226: taking a tell yourself — assigning it to a person, not an
1500 * agent — used to drop it clean off "Now": the panel's filter only ever
1501 * let an agent-held or unassigned tell through, so a person holding it
1502 * counted as nobody. `tellWord`'s own fallback said "not picked up yet"
1503 * regardless, so the tell's own page agreed with nothing true either.
1504 * Both failed before this change.
1505 */
1506 describe("a tell held by a person still shows on the front door (#226)", () => {
1507 let dataDir: string;
1508 let app: App;
1509 let servers: Servers;
1510 let base: string;
1511 let repo: Repo;
1512 let alice: User;
1513 let cookie: string;
1514 let tell: Issue;
1515
1516 const get = (path: string) =>
1517 fetch(`${base}${path}`, {
1518 headers: { cookie: `${cookie}; forge_mode=studio` },
1519 redirect: "manual",
1520 });
1521
1522 beforeAll(async () => {
1523 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-person-held-"));
1524 app = createApp({ dataDir, port: 0 });
1525 servers = await serve(app);
1526 base = servers.url;
1527 const a = await createUser(app.db, { trusted: true,
1528 username: "alice",
1529 email: "[email protected]",
1530 password: "a-good-passphrase",
1531 adminIfFirst: true,
1532 });
1533 if (!a.ok) throw new Error(a.error);
1534 alice = a.value;
1535 cookie = (
1536 (
1537 await fetch(`${base}/signin`, {
1538 method: "POST",
1539 body: new URLSearchParams({
1540 username: "alice",
1541 password: "a-good-passphrase",
1542 }),
1543 redirect: "manual",
1544 })
1545 ).headers.get("set-cookie") ?? ""
1546 ).split(";")[0]!;
1547 const made = await createRepo(app, alice, {
1548 name: "pad",
1549 visibility: "private",
1550 description: "a pad for notes",
1551 });
1552 if (!made.ok) throw new Error(String(made.error));
1553 repo = made.value;
1554 const seeded = await commitToBranch(app, alice, repo, {
1555 message: "Start",
1556 files: [{ path: "README.md", content: "pad\n" }],
1557 });
1558 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1559 const enabled = await enableApp(app, alice, repo, { slug: "pad" });
1560 if (!enabled.ok) throw new Error(String(enabled.error));
1561 const scout = await createAgent(app, alice, { username: "scout" });
1562 if (!scout.ok) throw new Error(String(scout.error));
1563 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
1564
1565 const told = openIssue(app, alice, repo, {
1566 title: "Give the header a subtitle",
1567 });
1568 if (!told.ok) throw new Error(String(told.error));
1569 tell = told.value;
1570 // Taken by a person — the owner herself — not routed to the crew.
1571 expect(assignIssue(app, alice, repo, tell, alice).ok).toBe(true);
1572 });
1573
1574 afterAll(async () => {
1575 servers.stop();
1576 app.close();
1577 await rm(dataDir, { recursive: true, force: true });
1578 });
1579
1580 test('"Now" names the person who holds it, not "Nothing yet"', async () => {
1581 const page = await (await get("/alice/pad")).text();
1582 expect(page).not.toContain("Nothing yet. Tell the crew something.");
1583 expect(page).toContain("Give the header a subtitle");
1584 expect(page).toContain("alice");
1585 expect(page).not.toContain("not picked up yet");
1586 });
1587
1588 test("the tell's own page agrees with the panel: held, not unpicked", async () => {
1589 const page = await (await get(`/alice/pad/issues/${tell.number}`)).text();
1590 expect(page).not.toContain("not picked up yet");
1591 expect(page).toContain(">alice</a>");
1592 expect(page).toContain("has it");
1593 });
1594 });
1595
1596 /**
1597 * Issue #191: `appShelf`/`shelfRows` used to derive a row from agent runs
1598 * alone, so a change a person opened by hand — no run anywhere near it —
1599 * fell to "quiet — tell the crew something" even while it sat green and
1600 * ready for a look, and "Happening now" said nobody was working while that
1601 * same change waited. Both read from `openPullsForRepos` now, grouped once
1602 * for the whole shelf, so a person's own change counts exactly as an
1603 * agent's does. Failed before this change.
1604 */
1605 describe("an open change is never quiet on the front door", () => {
1606 let dataDir: string;
1607 let app: App;
1608 let servers: Servers;
1609 let base: string;
1610 let repo: Repo;
1611 let alice: User;
1612 let cookie: string;
1613 let pull: Pull;
1614
1615 const get = (path: string) =>
1616 fetch(`${base}${path}`, {
1617 headers: { cookie: `${cookie}; forge_mode=studio` },
1618 redirect: "manual",
1619 });
1620
1621 beforeAll(async () => {
1622 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-open-change-"));
1623 app = createApp({ dataDir, port: 0 });
1624 servers = await serve(app);
1625 base = servers.url;
1626 const a = await createUser(app.db, { trusted: true,
1627 username: "alice",
1628 email: "[email protected]",
1629 password: "a-good-passphrase",
1630 adminIfFirst: true,
1631 });
1632 if (!a.ok) throw new Error(a.error);
1633 alice = a.value;
1634 cookie = (
1635 (
1636 await fetch(`${base}/signin`, {
1637 method: "POST",
1638 body: new URLSearchParams({
1639 username: "alice",
1640 password: "a-good-passphrase",
1641 }),
1642 redirect: "manual",
1643 })
1644 ).headers.get("set-cookie") ?? ""
1645 ).split(";")[0]!;
1646 const made = await createRepo(app, alice, {
1647 name: "pad",
1648 visibility: "private",
1649 description: "a pad for notes",
1650 });
1651 if (!made.ok) throw new Error(String(made.error));
1652 repo = made.value;
1653 const seeded = await commitToBranch(app, alice, repo, {
1654 message: "Start",
1655 files: [{ path: "README.md", content: "pad\n" }],
1656 });
1657 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1658 const enabled = await enableApp(app, alice, repo, { slug: "pad" });
1659 if (!enabled.ok) throw new Error(String(enabled.error));
1660
1661 // Alice opens the change herself, through the editor — no agent run
1662 // anywhere near it, the exact case `latestAgentRunsForRepos` misses.
1663 const branch = await commitToBranch(app, alice, repo, {
1664 branch: "note",
1665 base: "main",
1666 message: "A note",
1667 files: [{ path: "NOTE.md", content: "note\n" }],
1668 });
1669 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
1670 const opened = await openPull(app, alice, repo, {
1671 title: "A note",
1672 body: "",
1673 baseBranch: "main",
1674 headBranch: "note",
1675 });
1676 if (!opened.ok) throw new Error(String(opened.error));
1677 pull = opened.value;
1678 const check = createRun(app.db, {
1679 repoId: repo.id,
1680 kind: "check",
1681 issueId: pull.id,
1682 env: `pull-${pull.number}`,
1683 commitOid: pull.headOid,
1684 reason: "pull",
1685 });
1686 claimRun(app.db, check.id);
1687 finishRun(app.db, check.id, { state: "ok", summary: "check passed in 1s" });
1688
1689 // A second app, otherwise identical, with nothing open at all — the
1690 // control that must keep reading "quiet".
1691 const madeQuiet = await createRepo(app, alice, {
1692 name: "quiet",
1693 visibility: "private",
1694 description: "says nothing",
1695 });
1696 if (!madeQuiet.ok) throw new Error(String(madeQuiet.error));
1697 const seededQuiet = await commitToBranch(app, alice, madeQuiet.value, {
1698 message: "Start",
1699 files: [{ path: "README.md", content: "quiet\n" }],
1700 });
1701 if (!seededQuiet.ok) throw new Error(JSON.stringify(seededQuiet.error));
1702 const enabledQuiet = await enableApp(app, alice, madeQuiet.value, {
1703 slug: "quiet",
1704 });
1705 if (!enabledQuiet.ok) throw new Error(String(enabledQuiet.error));
1706 });
1707
1708 afterAll(async () => {
1709 servers.stop();
1710 app.close();
1711 await rm(dataDir, { recursive: true, force: true });
1712 });
1713
1714 test("the shelf row carries the change's own word, its badge and dot kept", async () => {
1715 const home = await (await get("/apps")).text();
1716 const at = home.indexOf('href="/alice/pad"');
1717 expect(at).toBeGreaterThan(-1);
1718 const start = home.lastIndexOf("<li>", at);
1719 const shelfRow = home.slice(start, home.indexOf("</li>", at) + 5);
1720 expect(shelfRow).not.toContain("quiet — tell the crew something");
1721 expect(shelfRow).toContain('data-word="look"');
1722 expect(shelfRow).toContain('class="dot"');
1723 expect(shelfRow).toContain('class="badge"');
1724 });
1725
1726 test("an app with nothing open still reads quiet", async () => {
1727 const home = await (await get("/apps")).text();
1728 const at = home.indexOf('href="/alice/quiet"');
1729 expect(at).toBeGreaterThan(-1);
1730 const start = home.lastIndexOf("<li>", at);
1731 const shelfRow = home.slice(start, home.indexOf("</li>", at) + 5);
1732 expect(shelfRow).toContain("quiet — tell the crew something");
1733 });
1734 });
1735
1736 /**
1737 * Issue #215: once a merged change closes, it drops out of
1738 * `openPullsForRepos`, and before this the shelf and the rail had nothing
1739 * left to say about it but "quiet" — the row's *happening* half spoke only
1740 * for an agent run, never a deploy. `productionFact` (`words.ts`) reads
1741 * production's own status for the fact a merge or a push leaves behind:
1742 * the deploy that landed, or one that failed behind what still serves.
1743 */
1744 describe("a deploy that just landed is news on the front door", () => {
1745 let dataDir: string;
1746 let app: App;
1747 let servers: Servers;
1748 let base: string;
1749 let runtime: AppsRuntime;
1750 let repo: Repo;
1751 let record: AppRecord;
1752 let quietRepo: Repo;
1753 let alice: User;
1754 let cookie: string;
1755 let deployRun: Run;
1756
1757 interface PreviewSlot {
1758 readonly app: AppRecord;
1759 readonly env: EnvName;
1760 readonly chain: Promise<void>;
1761 readonly restarts: number[];
1762 deploying?: unknown;
1763 live?: unknown;
1764 lastError?: string;
1765 lastRunId?: string;
1766 }
1767 const putEnv = (
1768 appRecord: AppRecord,
1769 env: EnvName,
1770 slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">,
1771 ): void => {
1772 (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set(
1773 `${appRecord.id}/${env}`,
1774 { app: appRecord, env, chain: Promise.resolve(), restarts: [], ...slot },
1775 );
1776 };
1777
1778 const get = (path: string) =>
1779 fetch(`${base}${path}`, {
1780 headers: { cookie: `${cookie}; forge_mode=studio` },
1781 redirect: "manual",
1782 });
1783
1784 const shelfRowFor = (home: string, href: string): string => {
1785 const at = home.indexOf(`href="${href}"`);
1786 expect(at).toBeGreaterThan(-1);
1787 const start = home.lastIndexOf("<li>", at);
1788 return home.slice(start, home.indexOf("</li>", at) + 5);
1789 };
1790
1791 beforeAll(async () => {
1792 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-shipped-"));
1793 app = createApp({ dataDir, port: 0 });
1794 servers = await serve(app);
1795 base = servers.url;
1796 // Uncalled `.start()`: this runtime never subscribes to the bus, so
1797 // nothing here spins a real process — its `envs` are only ever what
1798 // `putEnv` puts there, the same fiction the live-fragment tests use.
1799 runtime = new AppsRuntime(app, {
1800 domain: "shipped.test",
1801 scheme: "http",
1802 publicPort: servers.port,
1803 forgeUrl: servers.url,
1804 concurrency: 1,
1805 });
1806 const a = await createUser(app.db, { trusted: true,
1807 username: "alice",
1808 email: "[email protected]",
1809 password: "a-good-passphrase",
1810 adminIfFirst: true,
1811 });
1812 if (!a.ok) throw new Error(a.error);
1813 alice = a.value;
1814 cookie = (
1815 (
1816 await fetch(`${base}/signin`, {
1817 method: "POST",
1818 body: new URLSearchParams({
1819 username: "alice",
1820 password: "a-good-passphrase",
1821 }),
1822 redirect: "manual",
1823 })
1824 ).headers.get("set-cookie") ?? ""
1825 ).split(";")[0]!;
1826
1827 const made = await createRepo(app, alice, {
1828 name: "sap",
1829 visibility: "private",
1830 description: "a sap for notes",
1831 });
1832 if (!made.ok) throw new Error(String(made.error));
1833 repo = made.value;
1834 const seeded = await commitToBranch(app, alice, repo, {
1835 message: "Start",
1836 files: [{ path: "README.md", content: "sap\n" }],
1837 });
1838 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1839 const enabled = await enableApp(app, alice, repo, { slug: "sap" });
1840 if (!enabled.ok) throw new Error(String(enabled.error));
1841 record = getAppByRepo(app.db, repo.id)!;
1842
1843 deployRun = createRun(app.db, {
1844 repoId: repo.id,
1845 kind: "deploy",
1846 env: "production",
1847 commitOid: "6c99218",
1848 reason: "merge",
1849 actorId: alice.id,
1850 });
1851 claimRun(app.db, deployRun.id);
1852 deployRun =
1853 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }) ??
1854 deployRun;
1855 const servingAt = new Date().toISOString();
1856 putEnv(record, "production", {
1857 live: {
1858 commitOid: "6c99218",
1859 handle: { port: 4321 },
1860 runId: deployRun.id,
1861 since: servingAt,
1862 servingAt,
1863 createdAt: servingAt,
1864 manifest: { tty: null },
1865 },
1866 });
1867
1868 // A second app, otherwise identical, that never shipped — the control
1869 // that must keep reading quiet.
1870 const madeQuiet = await createRepo(app, alice, {
1871 name: "quiet2",
1872 visibility: "private",
1873 description: "says nothing",
1874 });
1875 if (!madeQuiet.ok) throw new Error(String(madeQuiet.error));
1876 const seededQuiet = await commitToBranch(app, alice, madeQuiet.value, {
1877 message: "Start",
1878 files: [{ path: "README.md", content: "quiet\n" }],
1879 });
1880 if (!seededQuiet.ok) throw new Error(JSON.stringify(seededQuiet.error));
1881 const enabledQuiet = await enableApp(app, alice, madeQuiet.value, {
1882 slug: "quiet2",
1883 });
1884 if (!enabledQuiet.ok) throw new Error(String(enabledQuiet.error));
1885 quietRepo = madeQuiet.value;
1886 });
1887
1888 afterAll(async () => {
1889 servers.stop();
1890 app.close();
1891 await rm(dataDir, { recursive: true, force: true });
1892 });
1893
1894 test("the shelf row reads shipped, with who and when, not quiet", async () => {
1895 const home = await (await get("/apps")).text();
1896 const shelfRow = shelfRowFor(home, "/alice/sap");
1897 expect(shelfRow).not.toContain("quiet — tell the crew something");
1898 expect(shelfRow).toContain('data-word="shipped"');
1899 expect(shelfRow).toContain("alice");
1900 });
1901
1902 test("an app with no recent deploy still reads quiet", async () => {
1903 const home = await (await get("/apps")).text();
1904 const shelfRow = shelfRowFor(home, `/alice/${quietRepo.name}`);
1905 expect(shelfRow).toContain("quiet — tell the crew something");
1906 });
1907
1908 test("a deploy no push asked for is not news: a redeploy or the boot reconcile does not read shipped", async () => {
1909 const restartedAt = new Date().toISOString();
1910 putEnv(record, "production", {
1911 live: {
1912 commitOid: "6c99218",
1913 handle: { port: 4321 },
1914 runId: deployRun.id,
1915 since: restartedAt,
1916 servingAt: restartedAt,
1917 // No `createdAt`: nothing asked a push for this one — a redeploy
1918 // someone pressed, or the boot reconcile every restart runs.
1919 manifest: { tty: null },
1920 },
1921 });
1922 const home = await (await get("/apps")).text();
1923 const shelfRow = shelfRowFor(home, "/alice/sap");
1924 expect(shelfRow).not.toContain('data-word="shipped"');
1925 expect(shelfRow).toContain("quiet — tell the crew something");
1926 });
1927
1928 test("a deploy older than the news window reads quiet again, not shipped forever", async () => {
1929 const old = new Date(Date.now() - 25 * 60 * 60_000).toISOString();
1930 putEnv(record, "production", {
1931 live: {
1932 commitOid: "6c99218",
1933 handle: { port: 4321 },
1934 runId: deployRun.id,
1935 since: old,
1936 servingAt: old,
1937 createdAt: old,
1938 manifest: { tty: null },
1939 },
1940 });
1941 const home = await (await get("/apps")).text();
1942 const shelfRow = shelfRowFor(home, "/alice/sap");
1943 expect(shelfRow).not.toContain('data-word="shipped"');
1944 expect(shelfRow).toContain("quiet — tell the crew something");
1945 });
1946 });
1947
1948 /**
1949 * Issue #65: a public app's studio page is the app, not the owner's books.
1950 * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same
1951 * as `canWrite` today — that every surface carrying money, the crew or the
1952 * decision ledger must ask before it computes or renders one of those.
1953 */
test/studio-search.test.ts added +1419
@@ -0,0 +1,1419 @@
1 /**
2 * The studio: search, today's cost, the outline, Stop and Redeploy, names,
3 * empty lists, the app's own words, and a run's transcript. One of the
4 * files the studio's suite was split into so no one file sets the gate's
5 * floor (#2038); the rest are test/studio*.test.ts.
6 */
7
8 import { logBox } from "../src/web/studio/log.ts";
9 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 { createApp, type App } from "../src/app.ts";
14 import { enableApp } from "../src/actions/apps.ts";
15 import { previewEnv } from "../src/apps/data.ts";
16 import { AppsRuntime } from "../src/apps/runtime.ts";
17 import { getAppByRepo } from "../src/core/apps.ts";
18 import { addToCrew, createAgent } from "../src/actions/crew.ts";
19 import { assignIssue, openIssue } from "../src/actions/issues.ts";
20 import { openPull } from "../src/actions/pulls.ts";
21 import {
22 commitToBranch,
23 createRepo,
24 updateRepoSettings,
25 } from "../src/actions/repos.ts";
26 import { getIssue, type Issue } from "../src/core/issues.ts";
27 import type { Pull } from "../src/core/pulls.ts";
28 import { getRepoById, type Repo } from "../src/core/repos.ts";
29 import {
30 claimRun,
31 createRun,
32 finishRun,
33 listRuns,
34 noteRunProgress,
35 type Run,
36 } from "../src/core/runs.ts";
37 import { createUser, getUserByName, type User } from "../src/core/users.ts";
38 import { serve, type Servers } from "../src/http/server.ts";
39 import { serving } from "./helpers/studio.ts";
40
41 describe("finding things in the studio: /search", () => {
42 let dataDir: string;
43 let app: App;
44 let servers: Servers;
45 let base: string;
46 let alice: User;
47 let aliceCookie: string;
48 let widgets: Repo;
49 let tellNumber: number;
50 let changeNumber: number;
51
52 beforeAll(async () => {
53 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-search-"));
54 app = createApp({ dataDir, port: 0 });
55 servers = await serve(app);
56 base = servers.url;
57
58 const a = await createUser(app.db, { trusted: true,
59 username: "alice",
60 email: "[email protected]",
61 password: "a-good-passphrase",
62 });
63 if (!a.ok) throw new Error(a.error);
64 alice = a.value;
65 aliceCookie = (
66 (
67 await fetch(`${base}/signin`, {
68 method: "POST",
69 body: new URLSearchParams({
70 username: "alice",
71 password: "a-good-passphrase",
72 }),
73 redirect: "manual",
74 })
75 ).headers.get("set-cookie") ?? ""
76 ).split(";")[0]!;
77
78 const made = await createRepo(app, alice, {
79 name: "widgets",
80 visibility: "public",
81 description: "Sell handmade wooden chess sets",
82 });
83 if (!made.ok) throw new Error(String(made.error));
84 widgets = made.value;
85 const seeded = await commitToBranch(app, alice, widgets, {
86 message: "Start",
87 files: [{ path: "README.md", content: "start\n" }],
88 });
89 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
90 const enabled = await enableApp(app, alice, widgets, { slug: "widgets" });
91 if (!enabled.ok) throw new Error(String(enabled.error));
92
93 const told = openIssue(app, alice, widgets, {
94 title: "Add a chess clock timer",
95 body: "Players want a clock.",
96 });
97 if (!told.ok) throw new Error(String(told.error));
98 tellNumber = told.value.number;
99
100 const branchMade = await commitToBranch(app, alice, widgets, {
101 branch: "chess-clock",
102 base: widgets.defaultBranch,
103 message: "Add clock",
104 files: [{ path: "clock.ts", content: "export {};\n" }],
105 });
106 if (!branchMade.ok) throw new Error(JSON.stringify(branchMade.error));
107 const opened = await openPull(app, alice, widgets, {
108 title: "Wire up the chess clock",
109 body: "",
110 baseBranch: widgets.defaultBranch,
111 headBranch: "chess-clock",
112 });
113 if (!opened.ok) throw new Error(String(opened.error));
114 changeNumber = opened.value.number;
115
116 // A private app, another owner's — never in alice's or a stranger's
117 // results, however much its description matches.
118 const bob = await createUser(app.db, { trusted: true,
119 username: "bob",
120 email: "[email protected]",
121 password: "a-good-passphrase",
122 });
123 if (!bob.ok) throw new Error(bob.error);
124 const secret = await createRepo(app, bob.value, {
125 name: "secret",
126 visibility: "private",
127 description: "A private chess engine",
128 });
129 if (!secret.ok) throw new Error(String(secret.error));
130 const secretSeeded = await commitToBranch(app, bob.value, secret.value, {
131 message: "Start",
132 files: [{ path: "README.md", content: "start\n" }],
133 });
134 if (!secretSeeded.ok) throw new Error(JSON.stringify(secretSeeded.error));
135 const secretEnabled = await enableApp(app, bob.value, secret.value, {
136 slug: "secret",
137 });
138 if (!secretEnabled.ok) throw new Error(String(secretEnabled.error));
139 });
140
141 afterAll(async () => {
142 servers.stop();
143 app.close();
144 await rm(dataDir, { recursive: true, force: true });
145 });
146
147 const asAlice = (path: string) =>
148 fetch(`${base}${path}`, {
149 headers: { cookie: `${aliceCookie}; forge_mode=studio` },
150 redirect: "manual",
151 });
152 const anonymous = (path: string) =>
153 fetch(`${base}${path}`, {
154 headers: { cookie: "forge_mode=studio" },
155 redirect: "manual",
156 });
157 // The topbar carries the palette's own list of apps on every page, so an
158 // assertion about the *results* reads the main content, not the whole
159 // document.
160 const mainOf = (html: string): string =>
161 html.match(/<main[^>]*id="main"[\s\S]*?<\/main>/)?.[0] ?? html;
162
163 test("an app is found by a word from its own description", async () => {
164 const res = await asAlice("/search?q=chess");
165 expect(res.status).toBe(200);
166 const html = await res.text();
167 expect(html).toContain("widgets");
168 expect(html).toContain("chess sets");
169 // Bob's private app matches the same word but is never the viewer's to see.
170 expect(html).not.toContain("secret");
171 });
172
173 test("a change and a tell are found too, each saying which app they belong to", async () => {
174 const html = await (await asAlice("/search?q=chess")).text();
175 expect(html).toContain("Add a chess clock timer");
176 expect(html).toContain(`/alice/widgets/tells/${tellNumber}`);
177 expect(html).toContain("Wire up the chess clock");
178 expect(html).toContain(`/alice/widgets/changes/${changeNumber}`);
179 expect(html).toContain("alice/widgets");
180 });
181
182 test("an empty query is an empty result, not an error page", async () => {
183 const res = await asAlice("/search");
184 expect(res.status).toBe(200);
185 const html = await res.text();
186 expect(mainOf(html)).not.toContain("widgets");
187 });
188
189 test("an overlong query is bounded, not a crash", async () => {
190 const res = await asAlice(`/search?q=${"chess ".repeat(100)}`);
191 expect(res.status).toBe(200);
192 });
193
194 test("signed out, the same public visibility as dev's own search", async () => {
195 const html = await (await anonymous("/search?q=chess")).text();
196 expect(html).toContain("widgets");
197 expect(html).not.toContain("secret");
198 });
199
200 test("?mode=dev on the same route answers the same one page", async () => {
201 for (const html of [
202 await (await asAlice("/search?q=chess")).text(),
203 await (
204 await fetch(`${base}/search?q=chess`, {
205 headers: { cookie: `${aliceCookie}; forge_mode=dev` },
206 })
207 ).text(),
208 ]) {
209 expect(html).toContain("Apps and repositories");
210 expect(html).toContain("Tells and changes");
211 expect(html).toContain(`/alice/widgets/tells/${tellNumber}`);
212 expect(mainOf(html)).not.toContain('name="mode"');
213 }
214 });
215
216 // #397, carrying #241's rule: the one search finds every repository the
217 // viewer can see, not only apps — and a tell on it links where it lives.
218 test("a repository that is not an app is found by its name and its description, and stays its owner's alone", async () => {
219 const plain = await createRepo(app, alice, {
220 name: "project-notes",
221 visibility: "private",
222 description: "notes about a project",
223 });
224 if (!plain.ok) throw new Error(String(plain.error));
225 const told = openIssue(app, alice, plain.value, {
226 title: "Sort the project notes",
227 body: "",
228 });
229 if (!told.ok) throw new Error(String(told.error));
230
231 const byName = mainOf(await (await asAlice("/search?q=notes")).text());
232 expect(byName).toContain('href="/alice/project-notes"');
233 expect(byName).toContain(
234 `href="/alice/project-notes/issues/${told.value.number}"`,
235 );
236 expect(byName).not.toContain("Nothing matched");
237 const byDescription = mainOf(
238 await (await asAlice("/search?q=about+a+project")).text(),
239 );
240 expect(byDescription).toContain('href="/alice/project-notes"');
241
242 const stranger = mainOf(await (await anonymous("/search?q=notes")).text());
243 expect(stranger).not.toContain("project-notes");
244 });
245
246 test("a private app on a public repository: its state reaches only its writers, and anyone else sees a plain repository row", async () => {
247 const made = await createRepo(app, alice, {
248 name: "abacus",
249 visibility: "public",
250 description: "an abacus for chess clocks",
251 });
252 if (!made.ok) throw new Error(String(made.error));
253 const seeded = await commitToBranch(app, alice, made.value, {
254 message: "Start",
255 files: [{ path: "README.md", content: "start\n" }],
256 });
257 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
258 const enabled = await enableApp(app, alice, made.value, {
259 slug: "abacus",
260 access: "private",
261 });
262 if (!enabled.ok) throw new Error(String(enabled.error));
263 const carol = await createUser(app.db, { trusted: true,
264 username: "carol",
265 email: "[email protected]",
266 password: "a-good-passphrase",
267 });
268 if (!carol.ok) throw new Error(carol.error);
269 const carolCookie = (
270 (
271 await fetch(`${base}/signin`, {
272 method: "POST",
273 body: new URLSearchParams({
274 username: "carol",
275 password: "a-good-passphrase",
276 }),
277 redirect: "manual",
278 })
279 ).headers.get("set-cookie") ?? ""
280 ).split(";")[0]!;
281 const rowOf = (html: string): string => {
282 const at = html.indexOf('href="/alice/abacus"');
283 return at === -1 ? "" : html.slice(at, html.indexOf("</a>", at));
284 };
285 for (const cookie of ["", `${carolCookie}; forge_mode=studio`]) {
286 const html = mainOf(
287 await (
288 await fetch(`${base}/search?q=abacus`, {
289 headers: cookie === "" ? {} : { cookie },
290 })
291 ).text(),
292 );
293 const row = rowOf(html);
294 expect(row).not.toBe("");
295 expect(row).not.toContain("not running");
296 expect(row).not.toContain('class="dot"');
297 }
298 expect(rowOf(mainOf(await (await asAlice("/search?q=abacus")).text()))).toContain(
299 "not running",
300 );
301 });
302
303 test("code is a section of the same page, and narrowing to it is a chip, never a mode", async () => {
304 const all = mainOf(await (await asAlice("/search?q=chess")).text());
305 expect(all).toContain('id="repos"');
306 expect(all).toContain('id="issues"');
307 expect(all).toContain('id="code"');
308 expect(all).toContain("/search?q=chess&amp;kind=code");
309 expect(all).not.toContain("mode=dev");
310 expect(all).not.toContain("Search the code");
311
312 const code = mainOf(await (await asAlice("/search?q=chess&kind=code")).text());
313 expect(code).toContain('id="code"');
314 expect(code).not.toContain('id="repos"');
315 });
316 });
317
318 /**
319 * The studio home's own answer to "what is this costing me today?" (#94):
320 * one line, the sum of today's agent spend across the apps the viewer can
321 * write, and how many of those sit paused at their ceiling right now.
322 */
323 describe("the studio home says what the crew cost you today", () => {
324 let dataDir: string;
325 let app: App;
326 let servers: Servers;
327 let base: string;
328 let alice: User;
329 let aliceCookie: string;
330 let carolCookie: string;
331
332 const signIn = async (username: string): Promise<string> =>
333 (
334 (
335 await fetch(`${base}/signin`, {
336 method: "POST",
337 body: new URLSearchParams({
338 username,
339 password: "a-good-passphrase",
340 }),
341 redirect: "manual",
342 })
343 ).headers.get("set-cookie") ?? ""
344 ).split(";")[0]!;
345
346 // The cost line is the apps list's (#599: home is the feed).
347 const home = (cookie: string) =>
348 fetch(`${base}/apps`, {
349 headers: { cookie: `${cookie}; forge_mode=studio` },
350 redirect: "manual",
351 }).then((r) => r.text());
352
353 beforeAll(async () => {
354 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-cost-today-"));
355 app = createApp({ dataDir, port: 0 });
356 servers = await serve(app);
357 base = servers.url;
358 const a = await createUser(app.db, { trusted: true,
359 username: "alice",
360 email: "[email protected]",
361 password: "a-good-passphrase",
362 adminIfFirst: true,
363 });
364 if (!a.ok) throw new Error(a.error);
365 alice = a.value;
366 const c = await createUser(app.db, { trusted: true,
367 username: "carol",
368 email: "[email protected]",
369 password: "a-good-passphrase",
370 });
371 if (!c.ok) throw new Error(c.error);
372 aliceCookie = await signIn("alice");
373 carolCookie = await signIn("carol");
374
375 // "beacon": on its own, over its $5 ceiling today — paused.
376 const beaconMade = await createRepo(app, alice, {
377 name: "beacon",
378 visibility: "public",
379 description: "a light that stays on",
380 });
381 if (!beaconMade.ok) throw new Error(String(beaconMade.error));
382 const beacon = beaconMade.value;
383 const beaconSeeded = await commitToBranch(app, alice, beacon, {
384 message: "Start",
385 files: [{ path: "README.md", content: "beacon\n" }],
386 });
387 if (!beaconSeeded.ok) throw new Error(JSON.stringify(beaconSeeded.error));
388 const beaconEnabled = await enableApp(app, alice, beacon, {
389 slug: "beacon",
390 });
391 if (!beaconEnabled.ok) throw new Error(String(beaconEnabled.error));
392 const keeper = await createAgent(app, alice, { username: "keeper" });
393 if (!keeper.ok) throw new Error(String(keeper.error));
394 expect(addToCrew(app, alice, beacon, "keeper").ok).toBe(true);
395 const on = await updateRepoSettings(app, alice, beacon, {
396 autonomy: "assisted",
397 autonomyDirection: "",
398 autonomyCeilingUsd: 5,
399 autonomySlots: 2,
400 autonomyBy: alice.id,
401 });
402 expect(on.ok).toBe(true);
403 const keeperId = getUserByName(app.db, "keeper")!.id;
404 const beaconRun = createRun(app.db, {
405 repoId: beacon.id,
406 kind: "agent",
407 actorId: keeperId,
408 reason: "assigned",
409 });
410 claimRun(app.db, beaconRun.id);
411 noteRunProgress(app.db, beaconRun.id, { costUsd: 6 });
412
413 // "loom": no autonomy at all, a cheaper run today — never paused.
414 const loomMade = await createRepo(app, alice, {
415 name: "loom",
416 visibility: "public",
417 description: "a place for cloth",
418 });
419 if (!loomMade.ok) throw new Error(String(loomMade.error));
420 const loom = loomMade.value;
421 const loomSeeded = await commitToBranch(app, alice, loom, {
422 message: "Start",
423 files: [{ path: "README.md", content: "loom\n" }],
424 });
425 if (!loomSeeded.ok) throw new Error(JSON.stringify(loomSeeded.error));
426 const loomEnabled = await enableApp(app, alice, loom, { slug: "loom" });
427 if (!loomEnabled.ok) throw new Error(String(loomEnabled.error));
428 const weaver = await createAgent(app, alice, { username: "weaver" });
429 if (!weaver.ok) throw new Error(String(weaver.error));
430 expect(addToCrew(app, alice, loom, "weaver").ok).toBe(true);
431 const weaverId = getUserByName(app.db, "weaver")!.id;
432 const loomRun = createRun(app.db, {
433 repoId: loom.id,
434 kind: "agent",
435 actorId: weaverId,
436 reason: "assigned",
437 });
438 claimRun(app.db, loomRun.id);
439 noteRunProgress(app.db, loomRun.id, { costUsd: 2 });
440 });
441
442 afterAll(async () => {
443 servers.stop();
444 app.close();
445 await rm(dataDir, { recursive: true, force: true });
446 });
447
448 test("the owner sees the day's total across both apps, and the one that is paused at its ceiling", async () => {
449 const html = await home(aliceCookie);
450 expect(html).toContain("$8.00 spent today across 2 apps you can write");
451 expect(html).toContain("1 paused at");
452 expect(html).toMatch(/<a href="\/map">their ceiling<\/a>/);
453 });
454
455 test("a viewer who can only read sees no figure at all", async () => {
456 const html = await home(carolCookie);
457 expect(html).not.toContain("spent today");
458 expect(html).not.toContain("$8.00");
459 expect(html).not.toContain("$6");
460 expect(html).not.toContain("$2");
461 });
462 });
463
464 describe("every studio page's headings form one outline", () => {
465 let dataDir: string;
466 let app: App;
467 let servers: Servers;
468 let base: string;
469 let alice: User;
470 let repo: Repo;
471 let cookie: string;
472 let issueNumber: number;
473 let pullNumber: number;
474 let runId: string;
475
476 const get = (path: string) =>
477 fetch(`${base}${path}`, {
478 headers: { cookie: `${cookie}; forge_mode=studio` },
479 redirect: "manual",
480 });
481
482 /** Every `<hN>` tag in document order, and whether the outline it forms
483 * is one a heading-jumping reader can follow: exactly one `<h1>`, and
484 * never a level deeper than one past the deepest seen so far. */
485 function headingOutline(html: string): { levels: number[]; ok: boolean } {
486 const levels = [...html.matchAll(/<h([1-6])(?:[ >])/g)].map((m) =>
487 Number(m[1]),
488 );
489 const h1s = levels.filter((l) => l === 1).length;
490 let deepest = 0;
491 let skipped = false;
492 for (const level of levels) {
493 if (level > deepest + 1) skipped = true;
494 deepest = Math.max(deepest, level);
495 }
496 return { levels, ok: h1s === 1 && !skipped };
497 }
498
499 beforeAll(async () => {
500 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-outline-"));
501 app = createApp({ dataDir, port: 0 });
502 servers = await serve(app);
503 base = servers.url;
504 const a = await createUser(app.db, { trusted: true,
505 username: "alice",
506 email: "[email protected]",
507 password: "a-good-passphrase",
508 adminIfFirst: true,
509 });
510 if (!a.ok) throw new Error(a.error);
511 alice = a.value;
512 cookie = (
513 (
514 await fetch(`${base}/signin`, {
515 method: "POST",
516 body: new URLSearchParams({
517 username: "alice",
518 password: "a-good-passphrase",
519 }),
520 redirect: "manual",
521 })
522 ).headers.get("set-cookie") ?? ""
523 ).split(";")[0]!;
524 const made = await createRepo(app, alice, {
525 name: "outline",
526 visibility: "private",
527 description: "for the outline",
528 });
529 if (!made.ok) throw new Error(String(made.error));
530 repo = made.value;
531 const seeded = await commitToBranch(app, alice, repo, {
532 message: "Start",
533 files: [
534 {
535 path: "package.json",
536 content:
537 '{"name":"outline","scripts":{"start":"bun run server.ts"}}\n',
538 },
539 {
540 path: "server.ts",
541 content:
542 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
543 },
544 ],
545 });
546 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
547 const enabled = await enableApp(app, alice, repo, { slug: "outline" });
548 if (!enabled.ok) throw new Error(String(enabled.error));
549
550 const told = openIssue(app, alice, repo, {
551 title: "Outline test tell",
552 body: "for the heading walk",
553 });
554 if (!told.ok) throw new Error(String(told.error));
555 issueNumber = told.value.number;
556
557 const branch = await commitToBranch(app, alice, repo, {
558 branch: "feature/outline",
559 base: "main",
560 message: "Outline change",
561 files: [{ path: "OUTLINE.md", content: "outline\n" }],
562 });
563 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
564 const opened = await openPull(app, alice, repo, {
565 title: "Outline test change",
566 body: "",
567 baseBranch: "main",
568 headBranch: "feature/outline",
569 });
570 if (!opened.ok) throw new Error(String(opened.error));
571 pullNumber = opened.value.number;
572 const run = createRun(app.db, {
573 repoId: repo.id,
574 kind: "agent",
575 actorId: alice.id,
576 reason: "assigned",
577 });
578 claimRun(app.db, run.id);
579 finishRun(app.db, run.id, { state: "ok", summary: "done" });
580 runId = run.id;
581 });
582
583 afterAll(async () => {
584 servers.stop();
585 app.close();
586 await rm(dataDir, { recursive: true, force: true });
587 });
588
589 test("the studio home has exactly one h1, said in the studio's own words, and no skipped heading level", async () => {
590 const html = await (await get("/")).text();
591 const outline = headingOutline(html);
592 expect(outline.levels.filter((l) => l === 1).length).toBe(1);
593 expect(outline.ok).toBe(true);
594 expect(html).toMatch(/<h1 class="sr-only">Home<\/h1>/);
595 });
596
597 test("the app page, the three lists, a tell, a change and a run each have one outline with no skipped level", async () => {
598 const pages: Record<string, string> = {
599 "the app page": "/alice/outline",
600 "everything told": "/alice/outline/tells",
601 "every change": "/alice/outline/changes",
602 "every run": "/alice/outline/runs",
603 "a tell": `/alice/outline/issues/${issueNumber}`,
604 "a change": `/alice/outline/pulls/${pullNumber}`,
605 "a run": `/alice/outline/runs/${runId}`,
606 settings: "/alice/outline/settings",
607 };
608 for (const [name, path] of Object.entries(pages)) {
609 const html = await (await get(path)).text();
610 const outline = headingOutline(html);
611 if (
612 outline.levels[0] !== 1 ||
613 outline.levels.filter((l) => l === 1).length !== 1 ||
614 !outline.ok
615 )
616 throw new Error(`${name} (${path}): ${JSON.stringify(outline)}`);
617 }
618 });
619 });
620
621 /**
622 * Issue #205: the studio's head card has three buttons, and two of them used
623 * to leave the studio — Stop for dev's App page, Redeploy for the deploy's
624 * run page. Both now come back to the page the form was pressed on, because
625 * the card refreshes itself on the deploy event and staying put shows the
626 * result. Dev mode's Redeploy still hands over to the run, and the page is
627 * taken from the referer only when it is this forge's own (`safeBack`),
628 * never from the form.
629 */
630 describe("the studio's Stop and Redeploy come back to the page they were pressed on", () => {
631 let dataDir: string;
632 let app: App;
633 let servers: Servers;
634 let base: string;
635 let repo: Repo;
636 let runtime: AppsRuntime;
637 let cookie: string;
638
639 /** A form the head card draws, submitted from `from` in one of the modes. */
640 const press = (
641 what: "stop" | "redeploy",
642 mode: "studio" | "dev",
643 from: string | undefined,
644 ): Promise<Response> => {
645 const headers: Record<string, string> = {
646 cookie: `${cookie}; forge_mode=${mode}`,
647 origin: base,
648 };
649 if (from !== undefined) headers["referer"] = from;
650 return fetch(`${base}/alice/notes/app/production/${what}`, {
651 method: "POST",
652 headers,
653 body: new URLSearchParams({}),
654 redirect: "manual",
655 });
656 };
657
658 /** The flash a redirect carries, decoded from its cookie. */
659 const flashOf = (response: Response): string =>
660 decodeURIComponent(response.headers.get("set-cookie") ?? "");
661
662 const studioPage = (): Promise<string> =>
663 fetch(`${base}/alice/notes`, {
664 headers: { cookie: `${cookie}; forge_mode=studio` },
665 redirect: "manual",
666 }).then((r) => r.text());
667
668 /**
669 * Production serving, as the runtime holds it, without running a real
670 * deploy — the pages read `runtime.status(record)`, and `stopEnv` only
671 * asks the handle to stop. Same shape the "not up" head's `put` uses.
672 */
673 const putLive = (): void => {
674 (
675 runtime as unknown as { envs: Map<string, Record<string, unknown>> }
676 ).envs.set(`${getAppByRepo(app.db, repo.id)!.id}/production`, {
677 app: getAppByRepo(app.db, repo.id)!,
678 env: "production",
679 chain: Promise.resolve(),
680 restarts: [],
681 live: {
682 commitOid: "a".repeat(40),
683 handle: { port: 4321, stop: async () => {} },
684 runId: "fake",
685 since: new Date().toISOString(),
686 manifest: { tty: null },
687 },
688 });
689 };
690
691 beforeAll(async () => {
692 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-envback-"));
693 app = createApp({ dataDir, port: 0 });
694 servers = await serve(app);
695 base = servers.url;
696 runtime = new AppsRuntime(app, {
697 domain: "apps.test",
698 scheme: "http",
699 publicPort: servers.port,
700 forgeUrl: servers.url,
701 concurrency: 1,
702 });
703 const a = await createUser(app.db, { trusted: true,
704 username: "alice",
705 email: "[email protected]",
706 password: "a-good-passphrase",
707 adminIfFirst: true,
708 });
709 if (!a.ok) throw new Error(a.error);
710 cookie = (
711 (
712 await fetch(`${base}/signin`, {
713 method: "POST",
714 body: new URLSearchParams({
715 username: "alice",
716 password: "a-good-passphrase",
717 }),
718 redirect: "manual",
719 })
720 ).headers.get("set-cookie") ?? ""
721 ).split(";")[0]!;
722 const made = await createRepo(app, a.value, {
723 name: "notes",
724 visibility: "public",
725 description: "a place for notes",
726 });
727 if (!made.ok) throw new Error(String(made.error));
728 repo = made.value;
729 const seeded = await commitToBranch(app, a.value, repo, {
730 message: "Start",
731 files: [{ path: "README.md", content: "notes\n" }],
732 });
733 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
734 const branched = await commitToBranch(app, a.value, repo, {
735 branch: "spelling",
736 message: "Fix a word",
737 files: [{ path: "README.md", content: "notes.\n" }],
738 });
739 if (!branched.ok) throw new Error(JSON.stringify(branched.error));
740 const pull = await openPull(app, a.value, repo, {
741 title: "Fix a word",
742 body: "",
743 headBranch: "spelling",
744 baseBranch: repo.defaultBranch,
745 });
746 if (!pull.ok) throw new Error(String(pull.error));
747 const enabled = await enableApp(app, a.value, repo, { slug: "notes" });
748 if (!enabled.ok) throw new Error(String(enabled.error));
749 });
750
751 afterAll(async () => {
752 await runtime.stop();
753 servers.stop();
754 app.close();
755 await rm(dataDir, { recursive: true, force: true });
756 });
757
758 test("Stop returns to the app's studio page with its flash, not to dev's App page", async () => {
759 const stopped = await press("stop", "studio", `${base}/alice/notes`);
760 expect(stopped.status).toBe(303);
761 expect(stopped.headers.get("location")).toBe("/alice/notes");
762 expect(flashOf(stopped)).toContain(
763 "Stopped production. Its data is kept; Redeploy brings it back.",
764 );
765 // And the flash really lands on that page, in front of the person.
766 const flash = (stopped.headers.get("set-cookie") ?? "").split(";")[0]!;
767 const landed = await fetch(`${base}/alice/notes`, {
768 headers: { cookie: `${cookie}; forge_mode=studio; ${flash}` },
769 redirect: "manual",
770 });
771 expect(landed.status).toBe(200);
772 expect((await landed.text()).replace(/\s+/g, " ")).toContain(
773 "Stopped production. Its data is kept; Redeploy brings it back.",
774 );
775 });
776
777 test("the head card shows the new state where it stands: the live fragment, no second navigation", async () => {
778 putLive();
779 const serving = await studioPage();
780 expect(serving).toContain("serving");
781 expect(serving).toContain('action="/alice/notes/app/production/stop"');
782 // The page a person stays on is the one that refreshes itself: its live
783 // mount asks for the app's state on a deploy event.
784 expect(serving).toContain('data-app="1"');
785
786 const stopped = await press("stop", "studio", `${base}/alice/notes`);
787 expect(stopped.headers.get("location")).toBe("/alice/notes");
788
789 // What the live client fetches, with no navigation in between: the same
790 // head card, now saying the app is down and offering Redeploy instead.
791 const fragment = await fetch(`${base}/alice/notes/app/state`, {
792 headers: { cookie: `${cookie}; forge_mode=studio` },
793 redirect: "manual",
794 });
795 expect(fragment.status).toBe(200);
796 const html = (await fragment.text()).replace(/\s+/g, " ");
797 expect(html).toContain('id="app-head"');
798 expect(html).toContain("not running");
799 expect(html).toContain('action="/alice/notes/app/production/redeploy"');
800 expect(html).not.toContain('action="/alice/notes/app/production/stop"');
801 });
802
803 test("pressed on a change's page, Stop comes back to that change", async () => {
804 const stopped = await press(
805 "stop",
806 "studio",
807 `${base}/alice/notes/changes/1`,
808 );
809 expect(stopped.status).toBe(303);
810 expect(stopped.headers.get("location")).toBe("/alice/notes/changes/1");
811 });
812
813 test("no referer, or one from somewhere else, falls back to the app's studio page — never the raw /app", async () => {
814 for (const from of [undefined, "https://evil.example/alice/notes"]) {
815 const stopped = await press("stop", "studio", from);
816 expect(stopped.status).toBe(303);
817 expect(stopped.headers.get("location")).toBe("/alice/notes");
818 }
819 });
820
821 test("Redeploy stays on the page too, instead of leaving for the deploy's run page", async () => {
822 const queued = await press("redeploy", "studio", `${base}/alice/notes`);
823 expect(queued.status).toBe(303);
824 expect(queued.headers.get("location")).toBe("/alice/notes");
825 expect(queued.headers.get("location")).not.toContain("/runs/");
826 expect(flashOf(queued)).toContain("Redeploying production.");
827 // A run was really queued: the studio only chose not to navigate to it.
828 expect(
829 listRuns(app.db, repo.id, { kind: "deploy", limit: 5 }).length,
830 ).toBeGreaterThan(0);
831 });
832
833 test("dev mode is the same: Stop and Redeploy both come back to the page they were pressed on", async () => {
834 const stopped = await press("stop", "dev", `${base}/alice/notes/pulls/1`);
835 expect(stopped.status).toBe(303);
836 expect(stopped.headers.get("location")).toBe("/alice/notes/pulls/1");
837 expect(flashOf(stopped)).toContain("Stopped production.");
838
839 const queued = await press("redeploy", "dev", `${base}/alice/notes`);
840 expect(queued.status).toBe(303);
841 expect(queued.headers.get("location")).toBe("/alice/notes");
842 expect(flashOf(queued)).toContain("Redeploying production.");
843 });
844
845 // Issue #395: dev's App page folded into this one. Its URL keeps
846 // answering — a redirect to the studio app page — and the environment
847 // controls it carried (each preview's Redeploy and Stop, the way to the
848 // environment settings) are drawn here instead.
849 test("GET /:owner/:name/app redirects to the app page, for a dev browser too", async () => {
850 const asStudio = await fetch(`${base}/alice/notes/app`, {
851 headers: { cookie: `${cookie}; forge_mode=studio` },
852 redirect: "manual",
853 });
854 expect(asStudio.status).toBe(302);
855 expect(asStudio.headers.get("location")).toBe("/alice/notes");
856
857 const asDev = await fetch(`${base}/alice/notes/app`, {
858 headers: { cookie: `${cookie}; forge_mode=dev` },
859 redirect: "manual",
860 });
861 expect(asDev.status).toBe(302);
862 expect(asDev.headers.get("location")).toBe("/alice/notes");
863 const landed = await fetch(`${base}/alice/notes`, {
864 headers: { cookie: `${cookie}; forge_mode=dev` },
865 redirect: "manual",
866 });
867 expect(landed.status).toBe(200);
868 expect(await landed.text()).toContain('id="app-head"');
869
870 // A public app's page is anyone's; the redirect is too.
871 const anonymous = await fetch(`${base}/alice/notes/app`, {
872 redirect: "manual",
873 });
874 expect(anonymous.status).toBe(302);
875 expect(anonymous.headers.get("location")).toBe("/alice/notes");
876 });
877
878 test("GET /:owner/:name/app is still not found for a repository that is not an app, or not there", async () => {
879 const alice = getUserByName(app.db, "alice")!;
880 const plain = await createRepo(app, alice, {
881 name: "plain-notes",
882 visibility: "public",
883 });
884 if (!plain.ok) throw new Error(String(plain.error));
885 for (const path of ["/alice/plain-notes/app", "/alice/nowhere/app"]) {
886 const response = await fetch(`${base}${path}`, {
887 headers: { cookie: `${cookie}; forge_mode=studio` },
888 redirect: "manual",
889 });
890 expect(`${path} → ${response.status}`).toBe(`${path} → 404`);
891 }
892 });
893
894 test("the studio app page carries each preview's Redeploy and Stop, and the environment settings, for a writer only", async () => {
895 const record = getAppByRepo(app.db, repo.id)!;
896 const env = previewEnv(1);
897 (
898 runtime as unknown as { envs: Map<string, Record<string, unknown>> }
899 ).envs.set(`${record.id}/${env}`, {
900 app: record,
901 env,
902 chain: Promise.resolve(),
903 restarts: [],
904 live: {
905 commitOid: "b".repeat(40),
906 handle: { port: 4322, stop: async () => {} },
907 runId: "fake-preview",
908 since: new Date().toISOString(),
909 manifest: { tty: null },
910 },
911 });
912 const owner = await studioPage();
913 expect(owner).toContain(`action="/alice/notes/app/${env}/redeploy"`);
914 expect(owner).toContain(`action="/alice/notes/app/${env}/stop"`);
915 expect(owner).toContain(`href="/alice/notes/app/${env}/open"`);
916 expect(owner).toContain('href="/alice/notes/settings/app"');
917
918 const stranger = await (
919 await fetch(`${base}/alice/notes`, { redirect: "manual" })
920 ).text();
921 expect(stranger).toContain(`href="/alice/notes/app/${env}/open"`);
922 expect(stranger).not.toContain("/redeploy");
923 expect(stranger).not.toContain("/stop");
924 expect(stranger).not.toContain('href="/alice/notes/settings/app"');
925 });
926 });
927
928 /**
929 * Issue #206: one public app, one open change, one anonymous visitor, three
930 * studio pages — and they must name the same author. Who signs a row is
931 * `voiceFor` (`src/web/studio/voice.ts`): a person is named to anyone who
932 * can see the row at all, and only the crew's own work reads as the app's
933 * own story for a viewer who cannot see the roster.
934 */
935 describe("the studio names a person on the app's page, and the crew only for the crew's own work", () => {
936 let dataDir: string;
937 let app: App;
938 let servers: Servers;
939 let base: string;
940 let repo: Repo;
941 let alice: User;
942 let ownerCookie: string;
943 let personPull: Pull;
944 let agentPull: Pull;
945 let agentRun: Run;
946 let waitingTell: Issue;
947 let heldTell: Issue;
948
949 const signIn = async (username: string): Promise<string> =>
950 (
951 (
952 await fetch(`${base}/signin`, {
953 method: "POST",
954 body: new URLSearchParams({
955 username,
956 password: "a-good-passphrase",
957 }),
958 redirect: "manual",
959 })
960 ).headers.get("set-cookie") ?? ""
961 ).split(";")[0]!;
962
963 /** Signed out, in studio mode: the visitor the issue walked as. */
964 const anon = async (path: string): Promise<string> =>
965 (
966 await fetch(`${base}${path}`, {
967 headers: { cookie: "forge_mode=studio" },
968 redirect: "manual",
969 })
970 ).text();
971
972 const owner = async (path: string): Promise<string> =>
973 (
974 await fetch(`${base}${path}`, {
975 headers: { cookie: `${ownerCookie}; forge_mode=studio` },
976 redirect: "manual",
977 })
978 ).text();
979
980 /** The one `<li>` a link sits in — one row, not the whole page. */
981 const rowWith = (page: string, needle: string): string => {
982 const at = page.indexOf(needle);
983 expect(at).toBeGreaterThan(-1);
984 const start = page.lastIndexOf("<li", at);
985 const end = page.indexOf("</li>", at);
986 return page.slice(start, end);
987 };
988
989 /** What the avatar beside a name says, so initials can be held to it. */
990 const initialsIn = (row: string): string =>
991 row.match(/class="avatar"[\s\S]*?>([^<]*)<\/span/)?.[1]?.trim() ?? "";
992
993 const branchAndPull = async (
994 who: User,
995 name: string,
996 title: string,
997 ): Promise<Pull> => {
998 const branch = await commitToBranch(app, alice, repo, {
999 branch: name,
1000 base: "main",
1001 message: title,
1002 files: [{ path: `${name.replace(/\//g, "-")}.md`, content: `${name}\n` }],
1003 });
1004 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
1005 const opened = await openPull(app, who, repo, {
1006 title,
1007 body: "",
1008 baseBranch: "main",
1009 headBranch: name,
1010 });
1011 if (!opened.ok) throw new Error(String(opened.error));
1012 return opened.value;
1013 };
1014
1015 beforeAll(async () => {
1016 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-voice-"));
1017 app = createApp({ dataDir, port: 0 });
1018 servers = await serve(app);
1019 base = servers.url;
1020 const a = await createUser(app.db, { trusted: true,
1021 username: "ivy",
1022 email: "[email protected]",
1023 password: "a-good-passphrase",
1024 adminIfFirst: true,
1025 });
1026 if (!a.ok) throw new Error(a.error);
1027 alice = a.value;
1028 ownerCookie = await signIn("ivy");
1029
1030 const made = await createRepo(app, alice, {
1031 name: "pub",
1032 visibility: "public",
1033 description: "a public house",
1034 });
1035 if (!made.ok) throw new Error(String(made.error));
1036 repo = made.value;
1037 const seeded = await commitToBranch(app, alice, repo, {
1038 message: "Start",
1039 files: [{ path: "README.md", content: "pub\n" }],
1040 });
1041 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1042 const enabled = await enableApp(app, alice, repo, { slug: "pub" });
1043 if (!enabled.ok) throw new Error(String(enabled.error));
1044 const quill = await createAgent(app, alice, { username: "quill" });
1045 if (!quill.ok) throw new Error(String(quill.error));
1046 expect(addToCrew(app, alice, repo, "quill").ok).toBe(true);
1047 const quillUser = getUserByName(app.db, "quill")!;
1048 const quillId = quillUser.id;
1049
1050 // The change ivy opened herself, through the web editor.
1051 personPull = await branchAndPull(alice, "ivy/subtitle", "Add a subtitle");
1052 // One the crew opened, so the masking that was meant has something to
1053 // mask on the very same page.
1054 agentPull = await branchAndPull(quillUser, "quill/lamp", "Hang a lamp");
1055 // A run of the crew's, on a tell of its own so it keeps its own row.
1056 const told = openIssue(app, alice, repo, { title: "Sweep the step" });
1057 if (!told.ok) throw new Error(String(told.error));
1058 agentRun = createRun(app.db, {
1059 repoId: repo.id,
1060 kind: "agent",
1061 issueId: told.value.id,
1062 actorId: quillId,
1063 reason: "assigned",
1064 });
1065 claimRun(app.db, agentRun.id);
1066 agentRun =
1067 finishRun(app.db, agentRun.id, {
1068 state: "ok",
1069 summary: "swept",
1070 costUsd: 3.5,
1071 turns: 2,
1072 }) ?? agentRun;
1073
1074 // And one nobody has picked up yet: a waiting row, the crew's to take.
1075 const waiting = openIssue(app, alice, repo, { title: "Wipe the bar" });
1076 if (!waiting.ok) throw new Error(String(waiting.error));
1077 expect(assignIssue(app, alice, repo, waiting.value, quillUser).ok).toBe(
1078 true,
1079 );
1080 waitingTell = getIssue(app.db, repo.id, waiting.value.number)!;
1081
1082 // And one ivy took herself — a person holds it (#226's own row on the
1083 // panel), so the panel owes her name to whoever can see the row.
1084 const held = openIssue(app, alice, repo, { title: "Polish the sign" });
1085 if (!held.ok) throw new Error(String(held.error));
1086 expect(assignIssue(app, alice, repo, held.value, alice).ok).toBe(true);
1087 heldTell = getIssue(app.db, repo.id, held.value.number)!;
1088 });
1089
1090 afterAll(async () => {
1091 servers.stop();
1092 app.close();
1093 await rm(dataDir, { recursive: true, force: true });
1094 });
1095
1096 test("an anonymous visitor reads one author for one change on all three pages, avatar included", async () => {
1097 const appPage = await anon("/ivy/pub");
1098 const changes = await anon("/ivy/pub/changes");
1099 const change = await anon(`/ivy/pub/changes/${personPull.number}`);
1100
1101 const onApp = rowWith(appPage, `/changes/${personPull.number}`);
1102 const onList = rowWith(changes, `/changes/${personPull.number}`);
1103 expect(onApp).toContain("ivy");
1104 expect(onApp).not.toContain("the crew");
1105 // The same name the list gives, and the same initials beside it.
1106 expect(initialsIn(onApp)).toBe(initialsIn(onList));
1107 expect(initialsIn(onApp)).toBe("i");
1108 expect(change).toContain("ivy");
1109
1110 // A tell she took herself is hers on the same panel, by the same rule.
1111 const held = rowWith(appPage, `/tells/${heldTell.number}`);
1112 expect(held).toContain("ivy");
1113 expect(held).not.toContain("the crew");
1114 expect(initialsIn(held)).toBe("i");
1115 });
1116
1117 test("the crew's own change and the crew's own run still read as the crew's to that visitor", async () => {
1118 const appPage = await anon("/ivy/pub");
1119 const onApp = rowWith(appPage, `/changes/${agentPull.number}`);
1120 expect(onApp).toContain("the crew");
1121 expect(onApp).not.toContain("quill");
1122 expect(initialsIn(onApp)).toBe("tc");
1123
1124 const runRow = rowWith(appPage, `/runs/${agentRun.id}`);
1125 expect(runRow).toContain("the crew");
1126 expect(runRow).not.toContain("quill");
1127
1128 const tellRow = rowWith(appPage, `/tells/${waitingTell.number}`);
1129 expect(tellRow).toContain("the crew");
1130 expect(tellRow).not.toContain("quill");
1131
1132 // Nothing the owner keeps to herself came along with the name.
1133 expect(appPage).not.toContain("quill");
1134 expect(appPage).not.toMatch(/\$\d/);
1135 expect(appPage).not.toContain(">Crew<");
1136 });
1137
1138 test("the owner still reads the crew by name, and her own change as her own", async () => {
1139 const appPage = await owner("/ivy/pub");
1140 expect(rowWith(appPage, `/changes/${agentPull.number}`)).toContain("quill");
1141 expect(rowWith(appPage, `/runs/${agentRun.id}`)).toContain("quill");
1142 expect(rowWith(appPage, `/changes/${personPull.number}`)).toContain("ivy");
1143 });
1144 });
1145
1146 describe("the studio's empty lists", () => {
1147 let dataDir: string;
1148 let app: App;
1149 let servers: Servers;
1150 let base: string;
1151 let cookie: string;
1152
1153 const get = (path: string) =>
1154 fetch(`${base}${path}`, {
1155 headers: { cookie: `${cookie}; forge_mode=studio` },
1156 redirect: "manual",
1157 }).then((r) => r.text());
1158
1159 const setup = async (name: string) => {
1160 const made = await createRepo(app, alice, {
1161 name,
1162 visibility: "private",
1163 description: "",
1164 });
1165 if (!made.ok) throw new Error(String(made.error));
1166 const seeded = await commitToBranch(app, alice, made.value, {
1167 message: "Start",
1168 files: [
1169 {
1170 path: "package.json",
1171 content: '{"name":"x","scripts":{"start":"bun run server.ts"}}\n',
1172 },
1173 { path: "server.ts", content: "export {};\n" },
1174 ],
1175 });
1176 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1177 const enabled = await enableApp(app, alice, made.value, { slug: name });
1178 if (!enabled.ok) throw new Error(String(enabled.error));
1179 return made.value;
1180 };
1181 let alice: User;
1182
1183 beforeAll(async () => {
1184 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-empty-"));
1185 app = createApp({ dataDir, port: 0 });
1186 servers = await serve(app);
1187 base = servers.url;
1188 const a = await createUser(app.db, { trusted: true,
1189 username: "alice",
1190 email: "[email protected]",
1191 password: "a-good-passphrase",
1192 adminIfFirst: true,
1193 });
1194 if (!a.ok) throw new Error(a.error);
1195 alice = a.value;
1196 cookie = (
1197 (
1198 await fetch(`${base}/signin`, {
1199 method: "POST",
1200 body: new URLSearchParams({
1201 username: "alice",
1202 password: "a-good-passphrase",
1203 }),
1204 redirect: "manual",
1205 })
1206 ).headers.get("set-cookie") ?? ""
1207 ).split(";")[0]!;
1208 });
1209
1210 afterAll(async () => {
1211 servers.stop();
1212 await rm(dataDir, { recursive: true, force: true });
1213 });
1214
1215 test("a repository with nothing in it says what to do, and blames no filter", async () => {
1216 await setup("blank");
1217 for (const [path, words] of [
1218 ["issues", "Tell the crew what you want"],
1219 ["pulls", "A change is the crew"],
1220 ["runs", "A run is one go"],
1221 ] as const) {
1222 const page = await get(`/alice/blank/${path}`);
1223 expect(page).toContain("Nothing here");
1224 expect(page).toContain(words);
1225 expect(page).not.toContain("this filter");
1226 }
1227 });
1228
1229 test("a filter that hides every row is named", async () => {
1230 const repo = await setup("busy");
1231 const told = openIssue(app, alice, repo, { title: "Add a tab", body: "" });
1232 if (!told.ok) throw new Error(String(told.error));
1233 const issues = await get("/alice/busy/issues?word=shipped");
1234 expect(issues).toContain("Nothing told is shipped yet.");
1235 expect(issues).not.toContain("Tell the crew what you want");
1236 const pulls = await get("/alice/busy/pulls?word=look");
1237 expect(pulls).toContain("No change is look yet.");
1238 const runs = await get("/alice/busy/runs?word=stopped");
1239 expect(runs).toContain("No run is stopped yet.");
1240 });
1241 });
1242
1243 /**
1244 * Issue #230: the studio's settings page can change the two facts it
1245 * prints most about an app — description and visibility — by posting to
1246 * the very same `/:owner/:name/settings` route dev's General page uses.
1247 */
1248 describe("the studio can change what an app says it is (#230)", () => {
1249 let dataDir: string;
1250 let app: App;
1251 let servers: Servers;
1252 let base: string;
1253 let repo: Repo;
1254 let ownerCookie: string;
1255 let readerCookie: string;
1256
1257 const signIn = async (username: string): Promise<string> =>
1258 (
1259 (
1260 await fetch(`${base}/signin`, {
1261 method: "POST",
1262 body: new URLSearchParams({
1263 username,
1264 password: "a-good-passphrase",
1265 }),
1266 redirect: "manual",
1267 })
1268 ).headers.get("set-cookie") ?? ""
1269 ).split(";")[0]!;
1270
1271 const get = (path: string, cookie?: string) =>
1272 fetch(`${base}${path}`, {
1273 headers: {
1274 cookie:
1275 cookie === undefined
1276 ? "forge_mode=studio"
1277 : `${cookie}; forge_mode=studio`,
1278 },
1279 redirect: "manual",
1280 });
1281
1282 const post = (path: string, body: Record<string, string>, cookie: string) =>
1283 fetch(`${base}${path}`, {
1284 method: "POST",
1285 headers: {
1286 cookie: `${cookie}; forge_mode=studio`,
1287 origin: base,
1288 },
1289 body: new URLSearchParams(body),
1290 redirect: "manual",
1291 });
1292
1293 beforeAll(async () => {
1294 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-about-"));
1295 app = createApp({ dataDir, port: 0 });
1296 servers = await serve(app);
1297 base = servers.url;
1298 const alice = await createUser(app.db, { trusted: true,
1299 username: "alice",
1300 email: "[email protected]",
1301 password: "a-good-passphrase",
1302 adminIfFirst: true,
1303 });
1304 if (!alice.ok) throw new Error(alice.error);
1305 const carol = await createUser(app.db, { trusted: true,
1306 username: "carol",
1307 email: "[email protected]",
1308 password: "a-good-passphrase",
1309 });
1310 if (!carol.ok) throw new Error(carol.error);
1311 ownerCookie = await signIn("alice");
1312 readerCookie = await signIn("carol");
1313 const made = await createRepo(app, alice.value, {
1314 name: "lamp",
1315 visibility: "public",
1316 description: "lights the hallway",
1317 });
1318 if (!made.ok) throw new Error(String(made.error));
1319 repo = made.value;
1320 const seeded = await commitToBranch(app, alice.value, repo, {
1321 message: "Start",
1322 files: [{ path: "README.md", content: "lamp\n" }],
1323 });
1324 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1325 // Branch protection is on and unexposed by the studio's form — proof
1326 // that saving from here does not silently turn it off.
1327 const protectedOn = await updateRepoSettings(app, alice.value, repo, {
1328 protectDefault: true,
1329 requireReview: true,
1330 requireChecks: true,
1331 });
1332 if (!protectedOn.ok) throw new Error(String(protectedOn.error));
1333 repo = protectedOn.value;
1334 const enabled = await enableApp(app, alice.value, repo, { slug: "lamp" });
1335 if (!enabled.ok) throw new Error(String(enabled.error));
1336 });
1337
1338 afterAll(async () => {
1339 servers.stop();
1340 app.close();
1341 await rm(dataDir, { recursive: true, force: true });
1342 });
1343
1344 test("a writer changes the description and visibility from the studio, and the app's page and the shelf row show it", async () => {
1345 // The About card names itself (`card=about`), so a real submit of it —
1346 // exactly what's posted here — changes its own fields and cannot touch
1347 // the branch protection it never shows.
1348 const before = await (
1349 await get("/alice/lamp/settings", ownerCookie)
1350 ).text();
1351 expect(before).toContain('<input type="hidden" name="card" value="about"');
1352
1353 const saved = await post(
1354 "/alice/lamp/settings",
1355 {
1356 card: "about",
1357 description: "a lamp that stays lit all night",
1358 visibility: "private",
1359 },
1360 ownerCookie,
1361 );
1362 expect(saved.status).toBe(303);
1363 expect(saved.headers.get("location")).toBe("/alice/lamp/settings");
1364
1365 const ownPage = await (
1366 await get("/alice/lamp", ownerCookie)
1367 ).text();
1368 expect(ownPage).toContain("a lamp that stays lit all night");
1369 expect(ownPage).toContain("Private");
1370
1371 // `/app` has been a redirect to the studio app page since the dev app
1372 // page was folded into it (#433): the head to read is that page's.
1373 const moved = await get("/alice/lamp/app", ownerCookie);
1374 expect(moved.status).toBe(302);
1375 expect(moved.headers.get("location")).toBe("/alice/lamp");
1376 const appHead = await (await get("/alice/lamp", ownerCookie)).text();
1377 expect(appHead).toContain("Private");
1378
1379 const shelf = await (await get("/apps", ownerCookie)).text();
1380 expect(shelf).toContain("a lamp that stays lit all night");
1381
1382 const list = await (await get("/apps", ownerCookie)).text();
1383 expect(list).toContain("a lamp that stays lit all night");
1384
1385 // Branch protection, never shown on this form, is exactly as it was.
1386 const dbRepo = getRepoById(app.db, repo.id);
1387 expect(dbRepo?.protectDefault).toBe(true);
1388 expect(dbRepo?.requireReview).toBe(true);
1389 expect(dbRepo?.requireChecks).toBe(true);
1390 });
1391
1392 test("a reader gets neither control: no form on the settings page, and the post is refused", async () => {
1393 const settings = await (
1394 await get("/alice/lamp/settings", readerCookie)
1395 ).text();
1396 expect(settings).not.toContain('action="/alice/lamp/settings"');
1397 expect(settings).not.toContain("<textarea");
1398
1399 const refused = await post(
1400 "/alice/lamp/settings",
1401 { description: "carol was here", visibility: "public" },
1402 readerCookie,
1403 );
1404 expect(refused.status).toBe(404);
1405 const dbRepo = getRepoById(app.db, repo.id);
1406 expect(dbRepo?.description).not.toBe("carol was here");
1407 });
1408 });
1409
1410 describe("a run's transcript window", () => {
1411 test("a log already cut to its tail offers the last N, never all of the run", () => {
1412 const lines = ["one", "two", "three"];
1413 const cut = logBox({ id: "log", who: "scout", cap: 2, lines, allHref: "/x?all=1", truncated: true }).text;
1414 expect(cut).toContain("read the last");
1415 expect(cut).not.toContain("read all");
1416 const whole = logBox({ id: "log", who: "scout", cap: 2, lines, allHref: "/x?all=1" }).text;
1417 expect(whole).toContain("read all");
1418 });
1419 });
test/studio-shipping.test.ts added +2103
@@ -0,0 +1,2103 @@
1 /**
2 * The studio: what a stranger sees, shipping and taking back a change, the
3 * crew panel, and the week's counts. One of the files the studio's suite
4 * was split into so no one file sets the gate's floor (#2038); the rest are
5 * test/studio*.test.ts.
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 { createApp, type App } from "../src/app.ts";
13 import { enableApp } from "../src/actions/apps.ts";
14 import type { EnvStatus } from "../src/apps/runtime.ts";
15 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { addTeamMember, createOrg, createTeam } from "../src/actions/orgs.ts";
17 import { openIssue } from "../src/actions/issues.ts";
18 import { mergePull, openPull } from "../src/actions/pulls.ts";
19 import {
20 commitToBranch,
21 createRepo,
22 updateRepoSettings,
23 } from "../src/actions/repos.ts";
24 import { recordDecision } from "../src/core/decisions.ts";
25 import type { Issue } from "../src/core/issues.ts";
26 import { getPull, mergedPullCount, type Pull } from "../src/core/pulls.ts";
27 import { getRepo, type Repo } from "../src/core/repos.ts";
28 import {
29 agentRunTotals,
30 claimRun,
31 createRun,
32 finishRun,
33 listRuns,
34 markServing,
35 previewLatency,
36 STOPPED_SUMMARY,
37 type Run,
38 } from "../src/core/runs.ts";
39 import { createUser, getUserByName, type User } from "../src/core/users.ts";
40 import { serve, type Servers } from "../src/http/server.ts";
41 import { NOBODY_ON_CREW } from "../src/web/studio/words.ts";
42 import { servedPhrase } from "../src/web/ui.ts";
43 import { serving } from "./helpers/studio.ts";
44
45 describe("a stranger sees the app, not the owner's books", () => {
46 let dataDir: string;
47 let app: App;
48 let servers: Servers;
49 let base: string;
50 let repo: Repo;
51 let alice: User;
52 let ownerCookie: string;
53 let strangerCookie: string;
54 let run: Run;
55
56 const signIn = async (username: string): Promise<string> =>
57 (
58 (
59 await fetch(`${base}/signin`, {
60 method: "POST",
61 body: new URLSearchParams({
62 username,
63 password: "a-good-passphrase",
64 }),
65 redirect: "manual",
66 })
67 ).headers.get("set-cookie") ?? ""
68 ).split(";")[0]!;
69
70 const get = (path: string, cookie?: string) =>
71 fetch(`${base}${path}`, {
72 headers: {
73 cookie:
74 cookie === undefined
75 ? "forge_mode=studio"
76 : `${cookie}; forge_mode=studio`,
77 },
78 redirect: "manual",
79 });
80
81 beforeAll(async () => {
82 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-public-"));
83 app = createApp({ dataDir, port: 0 });
84 servers = await serve(app);
85 base = servers.url;
86 const a = await createUser(app.db, { trusted: true,
87 username: "alice",
88 email: "[email protected]",
89 password: "a-good-passphrase",
90 adminIfFirst: true,
91 });
92 if (!a.ok) throw new Error(a.error);
93 alice = a.value;
94 const c = await createUser(app.db, { trusted: true,
95 username: "carol",
96 email: "[email protected]",
97 password: "a-good-passphrase",
98 });
99 if (!c.ok) throw new Error(c.error);
100 ownerCookie = await signIn("alice");
101 strangerCookie = await signIn("carol");
102 const made = await createRepo(app, alice, {
103 name: "beacon",
104 visibility: "public",
105 description: "a light that stays on",
106 });
107 if (!made.ok) throw new Error(String(made.error));
108 repo = made.value;
109 const seeded = await commitToBranch(app, alice, repo, {
110 message: "Start",
111 files: [{ path: "README.md", content: "beacon\n" }],
112 });
113 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
114 const enabled = await enableApp(app, alice, repo, { slug: "beacon" });
115 if (!enabled.ok) throw new Error(String(enabled.error));
116 const scout = await createAgent(app, alice, { username: "keeper" });
117 if (!scout.ok) throw new Error(String(scout.error));
118 expect(addToCrew(app, alice, repo, "keeper").ok).toBe(true);
119 const on = await updateRepoSettings(app, alice, repo, {
120 autonomy: "assisted",
121 autonomyDirection: "",
122 autonomyCeilingUsd: 25,
123 autonomySlots: 2,
124 autonomyBy: alice.id,
125 });
126 expect(on.ok).toBe(true);
127
128 const tell = openIssue(app, alice, repo, { title: "Trim the wick" });
129 if (!tell.ok) throw new Error(String(tell.error));
130 const branch = await commitToBranch(app, alice, repo, {
131 branch: "keeper/trim-the-wick",
132 base: "main",
133 message: "Trim the wick",
134 files: [{ path: "WICK.md", content: "trim\n" }],
135 });
136 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
137 const opened = await openPull(app, alice, repo, {
138 title: "Trim the wick",
139 body: `Closes #${tell.value.number}`,
140 baseBranch: "main",
141 headBranch: "keeper/trim-the-wick",
142 });
143 if (!opened.ok) throw new Error(String(opened.error));
144 const keeperId = getUserByName(app.db, "keeper")!.id;
145 run = createRun(app.db, {
146 repoId: repo.id,
147 kind: "agent",
148 issueId: tell.value.id,
149 branch: "keeper/trim-the-wick",
150 actorId: keeperId,
151 reason: "assigned",
152 });
153 claimRun(app.db, run.id);
154 run =
155 finishRun(app.db, run.id, {
156 state: "ok",
157 summary: `opened pull #${opened.value.number}`,
158 costUsd: 42.5,
159 turns: 7,
160 }) ?? run;
161 const merged = await mergePull(app, alice, repo, opened.value, "squash");
162 if (!merged.ok) throw new Error(String(merged.error));
163 recordDecision(app.db, {
164 kind: "land",
165 repoId: repo.id,
166 issueId: tell.value.id,
167 state: { card: "ask", head: opened.value.headOid },
168 answer: "ship",
169 p: 0.9,
170 by: "rule",
171 ms: 3,
172 });
173
174 // Three served deploys this week, median 4.0s, and a fourth that never
175 // answered — excluded, not a zero that would drag the median down.
176 const now = Date.now();
177 for (const [offsetMs, durationMs] of [
178 [0, 2_000],
179 [1_000, 4_000],
180 [2_000, 6_000],
181 [3_000, null],
182 ] as const) {
183 const deploy = createRun(app.db, {
184 repoId: repo.id,
185 kind: "deploy",
186 env: "production",
187 commitOid: "cafefeed",
188 reason: "push",
189 });
190 app.db
191 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
192 .run(deploy.id, new Date(now + offsetMs).toISOString());
193 if (durationMs !== null) {
194 app.db
195 .query("UPDATE run SET serving_at = ?2 WHERE id = ?1")
196 .run(deploy.id, new Date(now + offsetMs + durationMs).toISOString());
197 }
198 }
199 });
200
201 afterAll(async () => {
202 servers.stop();
203 app.close();
204 await rm(dataDir, { recursive: true, force: true });
205 });
206
207 test("anonymous and read-only visitors get the app, not the owner's numbers; the owner still gets everything", async () => {
208 const anon = await (await get("/alice/beacon")).text();
209 const stranger = await (await get("/alice/beacon", strangerCookie)).text();
210 const owner = await (await get("/alice/beacon", ownerCookie)).text();
211
212 for (const page of [anon, stranger]) {
213 expect(page).not.toMatch(/\$\d/);
214 expect(page).not.toContain("keeper");
215 expect(page).not.toContain(">Decided<");
216 expect(page).not.toContain(">On its own<");
217 expect(page).not.toContain(">Slots<");
218 expect(page).not.toContain("Nobody yet");
219 // The app itself is still there: what it is, that it shipped, a way in.
220 expect(page).toContain("a light that stays on");
221 expect(page).toContain(">Shipped<");
222 expect(page).toContain("1 change");
223 expect(page).toContain("Open the app");
224 // The direction's own number is the same owner's-books fact as spend
225 // and the ledger: a stranger and an anonymous visitor see neither.
226 expect(page).not.toContain(">Preview<");
227 expect(page).not.toContain("reaches its preview");
228 }
229
230 expect(owner).toContain("$42.50");
231 expect(owner).toContain("keeper");
232 expect(owner).toContain(">On its own<");
233 expect(owner).toContain(">Decided<");
234 // Three served deploys this week — 2.0s, 4.0s and 6.0s — median 4.0s;
235 // the fourth, never served, does not drag it toward zero.
236 expect(owner).toContain("reaches its preview in 4.0s");
237 expect(owner).toContain("· 3 deploys");
238 });
239
240 test("the app's live fragment carries no owner fact either", async () => {
241 const fragment = await (await get("/alice/beacon/app/state")).text();
242 expect(fragment).not.toMatch(/\$\d/);
243 expect(fragment).not.toContain("keeper");
244 });
245
246 test("a run's own page hides cost, turns and the crew's name from a stranger and from an anonymous reader; the JSON API redacts the run the same way", async () => {
247 const anonPage = await (await get(`/alice/beacon/runs/${run.id}`)).text();
248 const strangerPage = await (
249 await get(`/alice/beacon/runs/${run.id}`, strangerCookie)
250 ).text();
251 for (const page of [anonPage, strangerPage]) {
252 expect(page).not.toContain("$42.50");
253 expect(page).not.toContain("7 turns");
254 expect(page).not.toContain("keeper");
255 // The log box, which carried data-who, is not drawn for them (#1075).
256 expect(page).toContain("<title>the crew on #");
257 expect(page).not.toContain('id="run-log"');
258 }
259 const ownerPage = await (
260 await get(`/alice/beacon/runs/${run.id}`, ownerCookie)
261 ).text();
262 expect(ownerPage).toContain("$42.50");
263 expect(ownerPage).toContain("7 turns");
264 expect(ownerPage).toContain('data-who="keeper"');
265
266 const asStranger = (await (
267 await get(`/api/v1/repos/alice/beacon/runs/${run.id}`, strangerCookie)
268 ).json()) as { costUsd: number; turns: number };
269 expect(asStranger.costUsd).toBe(0);
270 expect(asStranger.turns).toBe(0);
271 const asOwner = (await (
272 await get(`/api/v1/repos/alice/beacon/runs/${run.id}`, ownerCookie)
273 ).json()) as { costUsd: number; turns: number };
274 expect(asOwner.costUsd).toBe(42.5);
275 expect(asOwner.turns).toBe(7);
276
277 const listAsStranger = (await (
278 await get(`/api/v1/repos/alice/beacon/runs`, strangerCookie)
279 ).json()) as { items: { costUsd: number }[] };
280 expect(listAsStranger.items.every((r) => r.costUsd === 0)).toBe(true);
281
282 // The log is the writers' and crew's (#1075): a stranger's tail is
283 // refused, and carries neither the cost nor the transcript.
284 const tailAsStranger = await get(
285 `/api/v1/repos/alice/beacon/runs/${run.id}/log?follow=1`,
286 strangerCookie,
287 );
288 expect(tailAsStranger.status).toBe(404);
289 expect(await tailAsStranger.text()).not.toContain("42.5");
290 const tailAsOwner = await (
291 await get(
292 `/api/v1/repos/alice/beacon/runs/${run.id}/log?follow=1`,
293 ownerCookie,
294 )
295 ).text();
296 expect(tailAsOwner).toContain("42.5");
297 });
298
299 test("the studio home names the crew for the owner, but only 'the crew' for a signed-in reader of the same public app", async () => {
300 const asStranger = await (await get("/apps", strangerCookie)).text();
301 expect(asStranger).not.toContain("keeper");
302 expect(asStranger).toContain("beacon");
303 const asOwner = await (await get("/apps", ownerCookie)).text();
304 expect(asOwner).toContain("keeper");
305 expect(asOwner).toContain("beacon");
306 });
307
308 const post = (path: string, body: Record<string, string>, cookie: string) =>
309 fetch(`${base}${path}`, {
310 method: "POST",
311 headers: {
312 cookie: `${cookie}; forge_mode=studio`,
313 origin: base,
314 },
315 body: new URLSearchParams(body),
316 redirect: "manual",
317 });
318
319 test("an autonomous app with nobody on its crew says so beside the mode, and stops saying it once a builder joins", async () => {
320 const made = await createRepo(app, alice, { name: "lamp", visibility: "public" });
321 if (!made.ok) throw new Error(String(made.error));
322 const lamp = made.value;
323 const seeded = await commitToBranch(app, alice, lamp, {
324 message: "Start",
325 files: [{ path: "README.md", content: "lamp\n" }],
326 });
327 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
328 const enabled = await enableApp(app, alice, lamp, { slug: "lamp" });
329 if (!enabled.ok) throw new Error(String(enabled.error));
330 const on = await updateRepoSettings(app, alice, lamp, {
331 autonomy: "autonomous",
332 autonomyDirection: "",
333 autonomyCeilingUsd: 5,
334 autonomySlots: 1,
335 autonomyBy: alice.id,
336 });
337 expect(on.ok).toBe(true);
338
339 const empty = await (await get("/alice/lamp", ownerCookie)).text();
340 expect(empty).toContain("Autonomous");
341 expect(empty).toContain("nobody on the crew");
342 expect(empty).toContain('href="/alice/lamp/settings">Put someone on the crew');
343 // The shelf's stuck word and this card share one phrase.
344 expect(NOBODY_ON_CREW).toBe("nobody on the crew");
345
346 const crewed = await (await get("/alice/beacon", ownerCookie)).text();
347 expect(crewed).not.toContain("nobody on the crew");
348
349 const agent = await createAgent(app, alice, { username: "wick" });
350 if (!agent.ok) throw new Error(String(agent.error));
351 expect(addToCrew(app, alice, lamp, "wick").ok).toBe(true);
352 const joined = await (await get("/alice/lamp", ownerCookie)).text();
353 expect(joined).not.toContain("nobody on the crew");
354 });
355
356 test("the studio's settings page renders the crew and the ceiling for a writer, with the same numbers the app page shows", async () => {
357 const owner = await (
358 await get("/alice/beacon/settings", ownerCookie)
359 ).text();
360 expect(owner).toContain("keeper");
361 expect(owner).toContain(">Crew<");
362 expect(owner).toContain(">On its own<");
363 expect(owner).toMatch(/\$42\.50\s+of\s+\$25/);
364 expect(owner).toContain("Remove");
365 expect(owner).toContain("Save");
366 // The rest is beneath, on the same page and in the same frame.
367 expect(owner).toContain("<h2>Branch and gates</h2>");
368 expect(owner.indexOf(">On its own<")).toBeLessThan(
369 owner.indexOf("<h2>Branch and gates</h2>"),
370 );
371 expect(owner).toContain('href="/alice/beacon/settings/labels"');
372 expect(owner).not.toContain("?mode=dev");
373 });
374
375 test("a reader without write sees no crew and no controls on the settings page; one who cannot read gets not-found", async () => {
376 const stranger = await (
377 await get("/alice/beacon/settings", strangerCookie)
378 ).text();
379 expect(stranger).not.toContain("keeper");
380 expect(stranger).not.toMatch(/\$\d/);
381 expect(stranger).not.toContain("Add to crew");
382 expect(stranger).not.toContain("<select");
383
384 const anon = await get("/alice/beacon/settings");
385 expect(anon.status).toBe(200);
386 const anonText = await anon.text();
387 expect(anonText).not.toContain("keeper");
388
389 const noSuchRepo = await get("/alice/no-such-app/settings", strangerCookie);
390 expect(noSuchRepo.status).toBe(404);
391 });
392
393 test("a reader's post to the crew action is refused, exactly as dev's page refuses it", async () => {
394 const refused = await post(
395 "/alice/beacon/settings/crew",
396 { agent: "keeper" },
397 strangerCookie,
398 );
399 expect(refused.status).toBe(404);
400 });
401
402 test("each settings card saves only its own fields: About never turns a gate or the outsiders switch off, and Branch and gates never touches the words", async () => {
403 const before = getRepo(app.db, "alice", "beacon")!;
404 const on = await post(
405 "/alice/beacon/settings",
406 {
407 card: "gates",
408 defaultBranch: before.defaultBranch,
409 protect: "on",
410 requireReview: "on",
411 // The settings page sends this marker beside the switch; without it a
412 // save leaves the switch as it is (#822).
413 crewOutsidersShown: "1",
414 crewOutsiders: "on",
415 },
416 ownerCookie,
417 );
418 expect(on.status).toBe(303);
419 let now = getRepo(app.db, "alice", "beacon")!;
420 expect(now.crewOutsiders).toBe(true);
421 expect(now.description).toBe(before.description);
422
423 const about = await post(
424 "/alice/beacon/settings",
425 { card: "about", description: "a lamp", visibility: now.visibility },
426 ownerCookie,
427 );
428 expect(about.status).toBe(303);
429 now = getRepo(app.db, "alice", "beacon")!;
430 expect(now.description).toBe("a lamp");
431 expect(now.crewOutsiders).toBe(true);
432 expect(now.protectDefault).toBe(true);
433 expect(now.requireReview).toBe(true);
434
435 // Put it back for the tests that follow.
436 await post(
437 "/alice/beacon/settings",
438 {
439 card: "gates",
440 defaultBranch: before.defaultBranch,
441 crewOutsidersShown: "1",
442 },
443 ownerCookie,
444 );
445 await post(
446 "/alice/beacon/settings",
447 { card: "about", description: before.description, visibility: before.visibility },
448 ownerCookie,
449 );
450 });
451
452 test("a role change made from the studio settings page lands exactly as dev's page does", async () => {
453 const roleChange = await post(
454 "/alice/beacon/settings/crew/keeper/role",
455 { role: "reviewer" },
456 ownerCookie,
457 );
458 expect(roleChange.status).toBe(303);
459 expect(roleChange.headers.get("location")).toBe(
460 "/alice/beacon/settings#crew",
461 );
462 const after = await (
463 await get("/alice/beacon/settings", ownerCookie)
464 ).text();
465 expect(after).toContain("reviews");
466 });
467
468 test("?mode=dev on the settings route is the same one page; dev's Crew and Autonomy addresses land on its cards", async () => {
469 const dev = await (
470 await get("/alice/beacon/settings", ownerCookie)
471 ).text();
472 expect(dev).toContain(">General<");
473 expect(dev).toContain(">Crew<");
474 expect(dev).toContain("Description");
475 expect(dev).toContain("<h2>Branch and gates</h2>");
476 expect(dev).toContain('id="f-direction"');
477
478 for (const [old, to] of [
479 ["/alice/beacon/settings/crew", "/alice/beacon/settings#crew"],
480 ["/alice/beacon/settings/autonomy", "/alice/beacon/settings#autonomy"],
481 ]) {
482 const res = await get(old!, ownerCookie);
483 expect(res.status).toBe(303);
484 expect(res.headers.get("location")).toBe(to!);
485 }
486 // A reader who cannot see the repository gets not-found, not a hint.
487 const hidden = await get("/alice/no-such-app/settings/crew", ownerCookie);
488 expect(hidden.status).toBe(404);
489 });
490 });
491
492 /**
493 * Issue #211: the studio's own Decide card ships a person's own change too,
494 * not only the crew's — the same `/ship` route, the same conflict wording
495 * (`change.ts:145-157` before this), offered to a writer, whoever opened it.
496 */
497 describe("a person's own change ships from its own studio page", () => {
498 let dataDir: string;
499 let app: App;
500 let servers: Servers;
501 let base: string;
502 let alice: User;
503 let repo: Repo;
504 let ownerCookie: string;
505 let readerCookie: string;
506
507 const signIn = async (username: string): Promise<string> =>
508 (
509 (
510 await fetch(`${base}/signin`, {
511 method: "POST",
512 body: new URLSearchParams({
513 username,
514 password: "a-good-passphrase",
515 }),
516 redirect: "manual",
517 })
518 ).headers.get("set-cookie") ?? ""
519 ).split(";")[0]!;
520
521 const get = (path: string, cookie: string) =>
522 fetch(`${base}${path}`, {
523 headers: { cookie: `${cookie}; forge_mode=studio` },
524 redirect: "manual",
525 });
526 const post = (path: string, cookie: string) =>
527 fetch(`${base}${path}`, {
528 method: "POST",
529 headers: { cookie, origin: base },
530 redirect: "manual",
531 });
532
533 beforeAll(async () => {
534 dataDir = await mkdtemp(join(tmpdir(), "forge-own-ship-"));
535 app = createApp({ dataDir, port: 0 });
536 servers = await serve(app);
537 base = servers.url;
538 const a = await createUser(app.db, { trusted: true,
539 username: "alice",
540 email: "[email protected]",
541 password: "a-good-passphrase",
542 adminIfFirst: true,
543 });
544 if (!a.ok) throw new Error(a.error);
545 alice = a.value;
546 const r = await createUser(app.db, { trusted: true,
547 username: "eve",
548 email: "[email protected]",
549 password: "a-good-passphrase",
550 });
551 if (!r.ok) throw new Error(r.error);
552 ownerCookie = await signIn("alice");
553 readerCookie = await signIn("eve");
554 const made = await createRepo(app, alice, {
555 name: "garden",
556 visibility: "public",
557 description: "grown by hand, not by the crew",
558 });
559 if (!made.ok) throw new Error(String(made.error));
560 repo = made.value;
561 const seeded = await commitToBranch(app, alice, repo, {
562 message: "Start",
563 files: [
564 {
565 path: "package.json",
566 content:
567 '{"name":"garden","scripts":{"start":"bun run server.ts"}}\n',
568 },
569 {
570 path: "server.ts",
571 content:
572 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
573 },
574 ],
575 });
576 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
577 const enabled = await enableApp(app, alice, repo, { slug: "garden" });
578 if (!enabled.ok) throw new Error(String(enabled.error));
579 });
580
581 afterAll(async () => {
582 servers.stop();
583 app.close();
584 await rm(dataDir, { recursive: true, force: true });
585 });
586
587 test("a green change alice opened herself offers Ship to a writer, and nothing to a reader; shipping lands it and the page reads shipped", async () => {
588 const branch = await commitToBranch(app, alice, repo, {
589 branch: "feature/bench",
590 base: "main",
591 message: "Add a bench",
592 files: [{ path: "BENCH.md", content: "a place to sit\n" }],
593 });
594 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
595 const opened = await openPull(app, alice, repo, {
596 title: "Add a bench",
597 body: "For sitting.",
598 baseBranch: "main",
599 headBranch: "feature/bench",
600 });
601 if (!opened.ok) throw new Error(String(opened.error));
602 const pull = opened.value;
603 const run = createRun(app.db, {
604 repoId: repo.id,
605 kind: "check",
606 issueId: pull.id,
607 env: `pull-${pull.number}`,
608 commitOid: pull.headOid,
609 reason: "pull",
610 });
611 claimRun(app.db, run.id);
612 finishRun(app.db, run.id, { state: "ok", summary: "check passed in 1s" });
613
614 const readerPage = await (
615 await get(`/alice/garden/pulls/${pull.number}`, readerCookie)
616 ).text();
617 expect(readerPage).toContain('data-word="look"');
618 expect(readerPage).not.toContain("<h2>Decide</h2>");
619 expect(readerPage).not.toContain("Ship");
620
621 const ownerPage = await (
622 await get(`/alice/garden/pulls/${pull.number}`, ownerCookie)
623 ).text();
624 expect(ownerPage).toContain("<h2>Decide</h2>");
625 expect(ownerPage).toContain("Ship");
626 // The words a person's own green change is offered in are `asks.ts`'s
627 // (#224: a green change asks to be looked at whoever opened it, and the
628 // card says so without pretending somebody finished it for them). The
629 // `own` fallback in `studio/change.ts` — "The check is green." — is what
630 // an autonomous repository's own pull still gets, where the asks panel
631 // offers nothing.
632 expect(ownerPage).toContain(
633 "is green — ready for a look. Ship it, or say what is off.",
634 );
635
636 const shipped = await post(
637 `/alice/garden/pulls/${pull.number}/ship`,
638 ownerCookie,
639 );
640 expect(shipped.status).toBe(303);
641 expect(getPull(app.db, repo.id, pull.number)?.mergedAt).not.toBeNull();
642 const after = await (
643 await get(`/alice/garden/pulls/${pull.number}`, ownerCookie)
644 ).text();
645 expect(after).toContain('data-word="shipped"');
646 });
647
648 test("a conflicted change alice opened herself offers the bring-up-to-date wording, never a Ship that would be refused", async () => {
649 const base1 = await commitToBranch(app, alice, repo, {
650 message: "Add a file two branches will fight over",
651 files: [{ path: "CONFLICT.md", content: "base\n" }],
652 });
653 if (!base1.ok) throw new Error(JSON.stringify(base1.error));
654 const branch = await commitToBranch(app, alice, repo, {
655 branch: "feature/own-conflict",
656 base: "main",
657 message: "Alice's edit",
658 files: [{ path: "CONFLICT.md", content: "base\nalice\n" }],
659 });
660 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
661 const onMain = await commitToBranch(app, alice, repo, {
662 message: "Main's edit",
663 files: [{ path: "CONFLICT.md", content: "base\nmain\n" }],
664 });
665 if (!onMain.ok) throw new Error(JSON.stringify(onMain.error));
666
667 const opened = await openPull(app, alice, repo, {
668 title: "Alice's conflicting change",
669 body: "This will not merge cleanly.",
670 baseBranch: "main",
671 headBranch: "feature/own-conflict",
672 });
673 if (!opened.ok) throw new Error(String(opened.error));
674 const pull = opened.value;
675 const run = createRun(app.db, {
676 repoId: repo.id,
677 kind: "check",
678 issueId: pull.id,
679 env: `pull-${pull.number}`,
680 commitOid: pull.headOid,
681 reason: "pull",
682 });
683 claimRun(app.db, run.id);
684 finishRun(app.db, run.id, { state: "ok", summary: "check passed in 1s" });
685
686 const studio = await (
687 await get(`/alice/garden/pulls/${pull.number}`, ownerCookie)
688 ).text();
689 expect(studio).toContain("<h2>Decide</h2>");
690 expect(studio).toContain("conflicts with main");
691 expect(studio).toContain("bring the branch up to date");
692 expect(studio).toContain("Bring up to date");
693 expect(studio).not.toContain("Ship");
694 });
695 });
696
697 /**
698 * Issue #190: a shipped change's studio page offers Take it back to a
699 * writer, behind the studio's existing confirm, and pressing it opens a new
700 * change that undoes it.
701 */
702 describe("take it back: the studio's verb on a shipped change", () => {
703 let dataDir: string;
704 let app: App;
705 let servers: Servers;
706 let base: string;
707 let alice: User;
708 let repo: Repo;
709 let ownerCookie: string;
710 let readerCookie: string;
711
712 const signIn = async (username: string): Promise<string> =>
713 (
714 (
715 await fetch(`${base}/signin`, {
716 method: "POST",
717 body: new URLSearchParams({
718 username,
719 password: "a-good-passphrase",
720 }),
721 redirect: "manual",
722 })
723 ).headers.get("set-cookie") ?? ""
724 ).split(";")[0]!;
725
726 const get = (path: string, cookie: string) =>
727 fetch(`${base}${path}`, {
728 headers: { cookie: `${cookie}; forge_mode=studio` },
729 redirect: "manual",
730 });
731 const post = (path: string, cookie: string, origin = base) =>
732 fetch(`${base}${path}`, {
733 method: "POST",
734 headers: { cookie, origin },
735 redirect: "manual",
736 });
737
738 const shipChange = async (
739 branch: string,
740 title: string,
741 content: string,
742 ): Promise<Pull> => {
743 const committed = await commitToBranch(app, alice, repo, {
744 branch,
745 base: "main",
746 message: title,
747 files: [{ path: "code.txt", content }],
748 });
749 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
750 const opened = await openPull(app, alice, repo, {
751 title,
752 baseBranch: "main",
753 headBranch: branch,
754 });
755 if (!opened.ok) throw new Error(String(opened.error));
756 const merged = await mergePull(app, alice, repo, opened.value, "squash");
757 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
758 return getPull(app.db, repo.id, opened.value.number)!;
759 };
760
761 beforeAll(async () => {
762 dataDir = await mkdtemp(join(tmpdir(), "forge-take-back-"));
763 app = createApp({ dataDir, port: 0 });
764 servers = await serve(app);
765 base = servers.url;
766 const a = await createUser(app.db, { trusted: true,
767 username: "alice",
768 email: "[email protected]",
769 password: "a-good-passphrase",
770 adminIfFirst: true,
771 });
772 if (!a.ok) throw new Error(a.error);
773 alice = a.value;
774 const r = await createUser(app.db, { trusted: true,
775 username: "eve",
776 email: "[email protected]",
777 password: "a-good-passphrase",
778 });
779 if (!r.ok) throw new Error(r.error);
780 ownerCookie = await signIn("alice");
781 readerCookie = await signIn("eve");
782 const made = await createRepo(app, alice, {
783 name: "orchard",
784 visibility: "public",
785 });
786 if (!made.ok) throw new Error(String(made.error));
787 repo = made.value;
788 const seeded = await commitToBranch(app, alice, repo, {
789 message: "Start",
790 files: [
791 {
792 path: "package.json",
793 content:
794 '{"name":"orchard","scripts":{"start":"bun run server.ts"}}\n',
795 },
796 {
797 path: "server.ts",
798 content:
799 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
800 },
801 { path: "code.txt", content: "alpha\n" },
802 ],
803 });
804 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
805 const enabled = await enableApp(app, alice, repo, { slug: "orchard" });
806 if (!enabled.ok) throw new Error(String(enabled.error));
807 });
808
809 afterAll(async () => {
810 servers.stop();
811 app.close();
812 await rm(dataDir, { recursive: true, force: true });
813 });
814
815 test("a shipped change offers Take it back to a writer, not to a reader, and not while it is still open", async () => {
816 const branch = await commitToBranch(app, alice, repo, {
817 branch: "feature/loud",
818 base: "main",
819 message: "Make it loud",
820 files: [{ path: "code.txt", content: "alpha\nBETA\n" }],
821 });
822 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
823 const opened = await openPull(app, alice, repo, {
824 title: "Make it loud",
825 baseBranch: "main",
826 headBranch: "feature/loud",
827 });
828 if (!opened.ok) throw new Error(String(opened.error));
829 const pull = opened.value;
830
831 const whileOpen = await (
832 await get(`/alice/orchard/pulls/${pull.number}`, ownerCookie)
833 ).text();
834 expect(whileOpen).not.toContain("Take it back");
835
836 const merged = await mergePull(app, alice, repo, pull, "squash");
837 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
838
839 const ownerPage = await (
840 await get(`/alice/orchard/pulls/${pull.number}`, ownerCookie)
841 ).text();
842 expect(ownerPage).toContain("Take it back");
843 expect(ownerPage).toContain(
844 `/-/confirm?to=%2Falice%2Forchard%2Fpulls%2F${pull.number}%2Ftake-back`,
845 );
846
847 const readerPage = await (
848 await get(`/alice/orchard/pulls/${pull.number}`, readerCookie)
849 ).text();
850 expect(readerPage).not.toContain("Take it back");
851
852 const confirm = await (
853 await get(
854 `/-/confirm?to=${encodeURIComponent(`/alice/orchard/pulls/${pull.number}/take-back`)}`,
855 ownerCookie,
856 )
857 ).text();
858 expect(confirm).toContain("Take back");
859 expect(confirm).toContain("Make it loud");
860 expect(confirm).toContain(
861 "opens a new change that undoes exactly what it shipped",
862 );
863 });
864
865 test("pressing Take it back opens a new change and lands on its studio page", async () => {
866 const shipped = await shipChange(
867 "feature/gamma",
868 "Add gamma",
869 "alpha\nGAMMA\n",
870 );
871
872 const taken = await post(
873 `/alice/orchard/pulls/${shipped.number}/take-back`,
874 ownerCookie,
875 );
876 expect(taken.status).toBe(303);
877 const location = taken.headers.get("location")!;
878 expect(location).toBe(`/alice/orchard/pulls/${shipped.number + 1}`);
879
880 const revertPage = await (await get(location, ownerCookie)).text();
881 expect(revertPage).toContain(`Revert &quot;Add gamma&quot;`);
882 });
883
884 test("the POST is refused from another origin, and nothing is created", async () => {
885 const shipped = await shipChange(
886 "feature/delta",
887 "Add delta",
888 "alpha\nDELTA\n",
889 );
890
891 const forged = await post(
892 `/alice/orchard/pulls/${shipped.number}/take-back`,
893 ownerCookie,
894 "https://evil.example",
895 );
896 expect(forged.status).toBe(403);
897 expect(getPull(app.db, repo.id, shipped.number + 1)).toBeUndefined();
898 });
899
900 test("a refusal is said in the studio's own words, and takes nothing back twice", async () => {
901 const shipped = await shipChange(
902 "feature/epsilon",
903 "Add epsilon",
904 "alpha\nEPSILON\n",
905 );
906 const first = await post(
907 `/alice/orchard/pulls/${shipped.number}/take-back`,
908 ownerCookie,
909 );
910 expect(first.status).toBe(303);
911
912 const again = await post(
913 `/alice/orchard/pulls/${shipped.number}/take-back`,
914 ownerCookie,
915 );
916 expect(again.status).toBe(303);
917 const location = again.headers.get("location")!;
918 expect(location).toBe(`/alice/orchard/pulls/${shipped.number}`);
919 const flash = decodeURIComponent(again.headers.get("set-cookie") ?? "");
920 expect(flash).toContain("already been taken back");
921
922 // Only one revert pull exists — the second attempt created nothing.
923 expect(getPull(app.db, repo.id, shipped.number + 1)?.title).toBe(
924 'Revert "Add epsilon"',
925 );
926 expect(getPull(app.db, repo.id, shipped.number + 2)).toBeUndefined();
927 });
928 });
929
930 describe("the studio's crew panel on a forge with no agents yet", () => {
931 let dataDir: string;
932 let app: App;
933 let servers: Servers;
934 let base: string;
935 let ownerCookie: string;
936 let teamAdminCookie: string;
937
938 const signIn = async (username: string): Promise<string> =>
939 (
940 (
941 await fetch(`${base}/signin`, {
942 method: "POST",
943 body: new URLSearchParams({
944 username,
945 password: "a-good-passphrase",
946 }),
947 redirect: "manual",
948 })
949 ).headers.get("set-cookie") ?? ""
950 ).split(";")[0]!;
951
952 const get = (path: string, cookie: string) =>
953 fetch(`${base}${path}`, {
954 headers: { cookie: `${cookie}; forge_mode=studio` },
955 redirect: "manual",
956 });
957
958 beforeAll(async () => {
959 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-no-agents-"));
960 app = createApp({ dataDir, port: 0 });
961 servers = await serve(app);
962 base = servers.url;
963 const a = await createUser(app.db, { trusted: true,
964 username: "alice",
965 email: "[email protected]",
966 password: "a-good-passphrase",
967 adminIfFirst: true,
968 });
969 if (!a.ok) throw new Error(a.error);
970 const alice = a.value;
971 const b = await createUser(app.db, { trusted: true,
972 username: "bob",
973 email: "[email protected]",
974 password: "a-good-passphrase",
975 });
976 if (!b.ok) throw new Error(b.error);
977 ownerCookie = await signIn("alice");
978 teamAdminCookie = await signIn("bob");
979
980 // alice's own app: she is both the repo's admin and the forge's.
981 const pad = await createRepo(app, alice, {
982 name: "pad",
983 visibility: "public",
984 });
985 if (!pad.ok) throw new Error(String(pad.error));
986 const padSeeded = await commitToBranch(app, alice, pad.value, {
987 message: "Start",
988 files: [{ path: "README.md", content: "pad\n" }],
989 });
990 if (!padSeeded.ok) throw new Error(JSON.stringify(padSeeded.error));
991 const padEnabled = await enableApp(app, alice, pad.value, { slug: "pad" });
992 if (!padEnabled.ok) throw new Error(String(padEnabled.error));
993
994 // an org app whose team admin, bob, is not a forge admin.
995 const org = await createOrg(app, alice, "acme");
996 if (!org.ok) throw new Error(org.error);
997 const team = await createTeam(app, alice, org.value, "leads", "admin");
998 if (!team.ok) throw new Error(String(team.error));
999 const added = await addTeamMember(app, alice, org.value, team.value, "bob");
1000 if (!added.ok) throw new Error(String(added.error));
1001 const vault = await createRepo(app, alice, {
1002 name: "vault",
1003 owner: "acme",
1004 visibility: "public",
1005 });
1006 if (!vault.ok) throw new Error(String(vault.error));
1007 const vaultSeeded = await commitToBranch(app, alice, vault.value, {
1008 message: "Start",
1009 files: [{ path: "README.md", content: "vault\n" }],
1010 });
1011 if (!vaultSeeded.ok) throw new Error(JSON.stringify(vaultSeeded.error));
1012 const vaultEnabled = await enableApp(app, alice, vault.value, {
1013 slug: "vault",
1014 });
1015 if (!vaultEnabled.ok) throw new Error(String(vaultEnabled.error));
1016 });
1017
1018 afterAll(async () => {
1019 servers.stop();
1020 app.close();
1021 await rm(dataDir, { recursive: true, force: true });
1022 });
1023
1024 test("a forge admin sees the crew panel name the door an agent is made through, and reaches it in one click without leaving studio mode", async () => {
1025 const settings = await (
1026 await get("/alice/pad/settings", ownerCookie)
1027 ).text();
1028 expect(settings).toContain("No agents exist on this forge yet.");
1029 expect(settings).not.toContain("Add an agent below");
1030 expect(settings).toContain('href="/settings/crew"');
1031
1032 const doorResponse = await get("/settings/crew", ownerCookie);
1033 expect(doorResponse.status).toBe(200);
1034 const crewPage = await doorResponse.text();
1035 expect(crewPage).toContain("New agent");
1036 expect(crewPage).toContain('action="/settings/crew"');
1037 });
1038
1039 test("a repository admin who is not a forge admin is told who can add an agent, not sent to a page that refuses them", async () => {
1040 const settings = await (
1041 await get("/acme/vault/settings", teamAdminCookie)
1042 ).text();
1043 expect(settings).toContain(
1044 "No agents exist on this forge yet. An admin can add one.",
1045 );
1046 expect(settings).not.toContain('href="/settings/crew"');
1047
1048 const refused = await get("/settings/crew", teamAdminCookie);
1049 expect(refused.status).toBe(404);
1050 });
1051
1052 test("the app page's 'Add someone' leads unbroken to the door an agent is made through, with nothing dev-only in the way", async () => {
1053 const appPage = await (await get("/alice/pad", ownerCookie)).text();
1054 expect(appPage).toContain("Nobody yet");
1055 expect(appPage).toContain('href="/alice/pad/settings">Add someone.');
1056
1057 const settings = await (
1058 await get("/alice/pad/settings", ownerCookie)
1059 ).text();
1060 expect(settings).toContain('href="/settings/crew"');
1061
1062 const door = await get("/settings/crew", ownerCookie);
1063 expect(door.status).toBe(200);
1064 const doorBody = await door.text();
1065 expect(doorBody).toContain("New agent");
1066 });
1067 });
1068
1069 /**
1070 * The one function every page words a deployment's speed with. The pages
1071 * above prove it reaches the studio; these prove what it says at the edges,
1072 * where a real deploy is awkward to arrange.
1073 */
1074 describe("servedPhrase", () => {
1075 const env = (extra: Partial<EnvStatus>): EnvStatus => ({
1076 env: "production",
1077 state: "running",
1078 commitOid: "cafebabe",
1079 url: "http://vault.apps.test",
1080 ...extra,
1081 });
1082
1083 test("says nothing at all until the deployment serves", () => {
1084 expect(
1085 servedPhrase(
1086 env({
1087 createdAt: "2026-01-01T00:00:00.000Z",
1088 deployStartedAt: "2026-01-01T00:00:02.000Z",
1089 }),
1090 ),
1091 ).toBeUndefined();
1092 });
1093
1094 test("clocks that disagree clamp to zero rather than printing a negative", () => {
1095 expect(
1096 servedPhrase(
1097 env({
1098 createdAt: "2026-01-01T00:00:00.300Z",
1099 deployStartedAt: "2026-01-01T00:00:00.300Z",
1100 servingAt: "2026-01-01T00:00:00.000Z",
1101 }),
1102 ),
1103 ).toBe("0.0s after the push");
1104 // A start before the push it answers is skew too: no negative wait.
1105 expect(
1106 servedPhrase(
1107 env({
1108 createdAt: "2026-01-01T00:00:05.000Z",
1109 deployStartedAt: "2026-01-01T00:00:00.000Z",
1110 servingAt: "2026-01-01T00:00:06.000Z",
1111 }),
1112 ),
1113 ).toBe("1.0s after the push");
1114 });
1115
1116 test("a wait under a second is noise: the phrase reads as it always has", () => {
1117 expect(
1118 servedPhrase(
1119 env({
1120 createdAt: "2026-01-01T00:00:00.000Z",
1121 deployStartedAt: "2026-01-01T00:00:00.900Z",
1122 servingAt: "2026-01-01T00:00:04.300Z",
1123 }),
1124 ),
1125 ).toBe("4.3s after the push");
1126 });
1127
1128 test("a push with no start recorded is still measured from the push", () => {
1129 expect(
1130 servedPhrase(
1131 env({
1132 createdAt: "2026-01-01T00:00:00.000Z",
1133 servingAt: "2026-01-01T00:00:02.500Z",
1134 }),
1135 ),
1136 ).toBe("2.5s after the push");
1137 });
1138
1139 test("a deployment with neither instant, and an unreadable one, say nothing", () => {
1140 expect(
1141 servedPhrase(env({ servingAt: "2026-01-01T00:00:02.500Z" })),
1142 ).toBeUndefined();
1143 expect(
1144 servedPhrase(
1145 env({ createdAt: "not a time", servingAt: "2026-01-01T00:00:02.500Z" }),
1146 ),
1147 ).toBeUndefined();
1148 });
1149 });
1150
1151 describe("previewLatency: the median seconds from push to serving", () => {
1152 let dataDir: string;
1153 let app: App;
1154 let alice: User;
1155 let repo: Repo;
1156
1157 beforeAll(async () => {
1158 dataDir = await mkdtemp(join(tmpdir(), "forge-preview-latency-"));
1159 app = createApp({ dataDir, port: 0 });
1160 const a = await createUser(app.db, { trusted: true,
1161 username: "alice",
1162 email: "[email protected]",
1163 password: "a-good-passphrase",
1164 adminIfFirst: true,
1165 });
1166 if (!a.ok) throw new Error(a.error);
1167 alice = a.value;
1168 const made = await createRepo(app, alice, {
1169 name: "latency",
1170 visibility: "private",
1171 description: "",
1172 });
1173 if (!made.ok) throw new Error(String(made.error));
1174 repo = made.value;
1175 });
1176
1177 afterAll(async () => {
1178 app.close();
1179 await rm(dataDir, { recursive: true, force: true });
1180 });
1181
1182 /** A deploy run stamped with a known push instant and, unless null, known started/serving instants. */
1183 const seedDeploy = (
1184 createdAt: string,
1185 startedAt: string | null,
1186 servingAt: string | null,
1187 ): void => {
1188 const deploy = createRun(app.db, { repoId: repo.id, kind: "deploy" });
1189 app.db
1190 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1191 .run(deploy.id, createdAt);
1192 if (startedAt !== null) {
1193 app.db
1194 .query("UPDATE run SET started_at = ?2 WHERE id = ?1")
1195 .run(deploy.id, startedAt);
1196 }
1197 if (servingAt !== null) {
1198 app.db
1199 .query("UPDATE run SET serving_at = ?2 WHERE id = ?1")
1200 .run(deploy.id, servingAt);
1201 }
1202 };
1203
1204 test("no deploys, a never-served one, and the median of a growing set — all against one window", () => {
1205 const now = Date.now();
1206 const since = new Date(now - 60_000).toISOString();
1207
1208 // Nothing seeded yet: undefined, never a zero.
1209 expect(previewLatency(app.db, repo.id, since)).toBeUndefined();
1210
1211 // Before the window: a very slow deploy that must not pull the median up.
1212 seedDeploy(
1213 new Date(now - 10 * 24 * 60 * 60_000).toISOString(),
1214 null,
1215 new Date(now - 10 * 24 * 60 * 60_000 + 600_000).toISOString(),
1216 );
1217 expect(previewLatency(app.db, repo.id, since)).toBeUndefined();
1218
1219 // A deploy that never served is left out, not counted as a zero.
1220 seedDeploy(new Date(now).toISOString(), null, null);
1221 expect(previewLatency(app.db, repo.id, since)).toBeUndefined();
1222
1223 // Three served deploys, seeded out of order: an odd-count median.
1224 const durations = [2, 6, 4]; // seconds
1225 durations.forEach((d, i) => {
1226 const createdAt = new Date(now + i * 1_000).toISOString();
1227 seedDeploy(
1228 createdAt,
1229 null,
1230 new Date(Date.parse(createdAt) + d * 1_000).toISOString(),
1231 );
1232 });
1233 const odd = previewLatency(app.db, repo.id, since)!;
1234 expect(odd.n).toBe(3);
1235 expect(odd.medianSeconds).toBeCloseTo(4, 2);
1236
1237 // A fourth deploy, 10s: the even-count median is the mean of the middle two.
1238 const createdAt = new Date(now + 3_000).toISOString();
1239 seedDeploy(
1240 createdAt,
1241 null,
1242 new Date(Date.parse(createdAt) + 10_000).toISOString(),
1243 );
1244 const even = previewLatency(app.db, repo.id, since)!;
1245 expect(even.n).toBe(4);
1246 expect(even.medianSeconds).toBeCloseTo(5, 2);
1247
1248 // None of the above carried a `started_at`: the split has nothing to say.
1249 expect(even.queueMedianSeconds).toBeUndefined();
1250 expect(even.bootMedianSeconds).toBeUndefined();
1251 expect(even.splitN).toBe(0);
1252 });
1253
1254 test("the split between waiting to be picked up and coming up, seeded beside a total", () => {
1255 const now = Date.now() + 10_000_000; // clear of the previous test's window
1256 const since = new Date(now - 60_000).toISOString();
1257
1258 // Three deploys with known queue and boot segments: (queue, boot) in seconds.
1259 const segments: readonly (readonly [number, number])[] = [
1260 [1, 3],
1261 [2, 6],
1262 [3, 9],
1263 ];
1264 segments.forEach(([queue, boot], i) => {
1265 const createdAt = now + i * 1_000;
1266 const startedAt = createdAt + queue * 1_000;
1267 const servingAt = startedAt + boot * 1_000;
1268 seedDeploy(
1269 new Date(createdAt).toISOString(),
1270 new Date(startedAt).toISOString(),
1271 new Date(servingAt).toISOString(),
1272 );
1273 });
1274 const full = previewLatency(app.db, repo.id, since)!;
1275 expect(full.n).toBe(3);
1276 expect(full.splitN).toBe(3);
1277 expect(full.queueMedianSeconds).toBeCloseTo(2, 2);
1278 expect(full.bootMedianSeconds).toBeCloseTo(6, 2);
1279
1280 // A fourth, served deploy with no `started_at`: it feeds the total but
1281 // is left out of the split, never counted as a zero-length queue or boot.
1282 const createdAt = now + 3_000;
1283 seedDeploy(
1284 new Date(createdAt).toISOString(),
1285 null,
1286 new Date(createdAt + 100_000).toISOString(),
1287 );
1288 const withGap = previewLatency(app.db, repo.id, since)!;
1289 expect(withGap.n).toBe(4);
1290 expect(withGap.splitN).toBe(3);
1291 expect(withGap.queueMedianSeconds).toBeCloseTo(2, 2);
1292 expect(withGap.bootMedianSeconds).toBeCloseTo(6, 2);
1293 });
1294
1295 test("a deploy that finished without ever serving is counted apart, never in the median; one still running is counted as neither", () => {
1296 const now = Date.now() + 20_000_000; // clear of the earlier tests' windows
1297 const since = new Date(now - 60_000).toISOString();
1298
1299 // Two served deploys, 4s and 6s.
1300 [4, 6].forEach((d, i) => {
1301 const createdAt = new Date(now + i * 1_000).toISOString();
1302 seedDeploy(
1303 createdAt,
1304 null,
1305 new Date(Date.parse(createdAt) + d * 1_000).toISOString(),
1306 );
1307 });
1308 const clean = previewLatency(app.db, repo.id, since)!;
1309 expect(clean.n).toBe(2);
1310 expect(clean.failedN).toBe(0);
1311 expect(clean.medianSeconds).toBeCloseTo(5, 2);
1312
1313 // A deploy still queued: neither served nor finished. Moves neither number.
1314 const inFlight = createRun(app.db, { repoId: repo.id, kind: "deploy" });
1315 app.db
1316 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1317 .run(inFlight.id, new Date(now + 2_000).toISOString());
1318 const withInFlight = previewLatency(app.db, repo.id, since)!;
1319 expect(withInFlight.n).toBe(2);
1320 expect(withInFlight.failedN).toBe(0);
1321 expect(withInFlight.medianSeconds).toBeCloseTo(5, 2);
1322
1323 // A deploy that finished but never served — counted as failed, never a
1324 // zero-second deploy pulling the median down.
1325 const failed = createRun(app.db, { repoId: repo.id, kind: "deploy" });
1326 app.db
1327 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1328 .run(failed.id, new Date(now + 3_000).toISOString());
1329 claimRun(app.db, failed.id);
1330 finishRun(app.db, failed.id, { state: "failed", summary: "build failed" });
1331 const withFailed = previewLatency(app.db, repo.id, since)!;
1332 expect(withFailed.n).toBe(2);
1333 expect(withFailed.failedN).toBe(1);
1334 expect(withFailed.medianSeconds).toBeCloseTo(5, 2);
1335
1336 // A cancelled deploy that never served also counts as failed.
1337 const cancelled = createRun(app.db, { repoId: repo.id, kind: "deploy" });
1338 app.db
1339 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1340 .run(cancelled.id, new Date(now + 4_000).toISOString());
1341 claimRun(app.db, cancelled.id);
1342 app.db
1343 .query(
1344 "UPDATE run SET state = 'cancelled', finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1",
1345 )
1346 .run(cancelled.id);
1347 const withCancelled = previewLatency(app.db, repo.id, since)!;
1348 expect(withCancelled.n).toBe(2);
1349 expect(withCancelled.failedN).toBe(2);
1350 expect(withCancelled.medianSeconds).toBeCloseTo(5, 2);
1351 });
1352
1353 test("a week with only never-served deploys still reports the failed count, with no median to give", () => {
1354 const now = Date.now() + 30_000_000;
1355 const since = new Date(now - 60_000).toISOString();
1356
1357 const failed = createRun(app.db, { repoId: repo.id, kind: "deploy" });
1358 app.db
1359 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1360 .run(failed.id, new Date(now).toISOString());
1361 claimRun(app.db, failed.id);
1362 finishRun(app.db, failed.id, { state: "failed", summary: "build failed" });
1363
1364 const latency = previewLatency(app.db, repo.id, since)!;
1365 expect(latency).toBeDefined();
1366 expect(latency.n).toBe(0);
1367 expect(latency.failedN).toBe(1);
1368 expect(latency.medianSeconds).toBeUndefined();
1369 });
1370
1371 test("a deploy the forge's own restart cancelled is never counted as failed, nor in the total it is 'of' (#255, #274)", () => {
1372 const now = Date.now() + 40_000_000;
1373 const since = new Date(now - 60_000).toISOString();
1374
1375 // A real failure: counted.
1376 const failed = createRun(app.db, { repoId: repo.id, kind: "deploy" });
1377 app.db
1378 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1379 .run(failed.id, new Date(now).toISOString());
1380 claimRun(app.db, failed.id);
1381 finishRun(app.db, failed.id, { state: "failed", summary: "build failed" });
1382
1383 // A deploy the forge's own restart cancelled: not this commit's fault,
1384 // and not counted at all — neither as a failure nor in the total.
1385 const stopped = createRun(app.db, { repoId: repo.id, kind: "deploy" });
1386 app.db
1387 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1388 .run(stopped.id, new Date(now + 1_000).toISOString());
1389 claimRun(app.db, stopped.id);
1390 app.db
1391 .query(
1392 "UPDATE run SET state = 'cancelled', summary = ?2, finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1",
1393 )
1394 .run(stopped.id, STOPPED_SUMMARY);
1395
1396 const latency = previewLatency(app.db, repo.id, since)!;
1397 expect(latency.n).toBe(0);
1398 expect(latency.failedN).toBe(1);
1399
1400 // A person's own Cancel, carrying a different summary, still counts —
1401 // only the forge's own reason is left out.
1402 const byPerson = createRun(app.db, { repoId: repo.id, kind: "deploy" });
1403 app.db
1404 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1405 .run(byPerson.id, new Date(now + 2_000).toISOString());
1406 claimRun(app.db, byPerson.id);
1407 app.db
1408 .query(
1409 "UPDATE run SET state = 'cancelled', summary = 'cancelled by alice', finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1",
1410 )
1411 .run(byPerson.id);
1412 const withPerson = previewLatency(app.db, repo.id, since)!;
1413 expect(withPerson.failedN).toBe(2);
1414 });
1415 });
1416
1417 describe("the studio's own words for how fast a change reaches its preview", () => {
1418 let dataDir: string;
1419 let app: App;
1420 let servers: Servers;
1421 let base: string;
1422 let alice: User;
1423 let repo: Repo;
1424 let ownerCookie: string;
1425
1426 beforeAll(async () => {
1427 dataDir = await mkdtemp(join(tmpdir(), "forge-preview-latency-page-"));
1428 app = createApp({ dataDir, port: 0 });
1429 servers = await serve(app);
1430 base = servers.url;
1431 const a = await createUser(app.db, { trusted: true,
1432 username: "alice",
1433 email: "[email protected]",
1434 password: "a-good-passphrase",
1435 adminIfFirst: true,
1436 });
1437 if (!a.ok) throw new Error(a.error);
1438 alice = a.value;
1439 ownerCookie = (
1440 (
1441 await fetch(`${base}/signin`, {
1442 method: "POST",
1443 body: new URLSearchParams({
1444 username: "alice",
1445 password: "a-good-passphrase",
1446 }),
1447 redirect: "manual",
1448 })
1449 ).headers.get("set-cookie") ?? ""
1450 ).split(";")[0]!;
1451 const made = await createRepo(app, alice, {
1452 name: "trickle",
1453 visibility: "private",
1454 description: "",
1455 });
1456 if (!made.ok) throw new Error(String(made.error));
1457 repo = made.value;
1458 const seeded = await commitToBranch(app, alice, repo, {
1459 message: "Start",
1460 files: [{ path: "README.md", content: "start\n" }],
1461 });
1462 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1463 const enabled = await enableApp(app, alice, repo, { slug: "trickle" });
1464 if (!enabled.ok) throw new Error(String(enabled.error));
1465 // Enough history behind it that these tests exercise the preview
1466 // wording, not the day-one line (#274) — a finished agent run from
1467 // well outside this week's own window.
1468 const seedRun = createRun(app.db, { repoId: repo.id, kind: "agent" });
1469 app.db
1470 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1471 .run(
1472 seedRun.id,
1473 new Date(Date.now() - 30 * 24 * 60 * 60_000).toISOString(),
1474 );
1475 claimRun(app.db, seedRun.id);
1476 finishRun(app.db, seedRun.id, { state: "ok", summary: "seed" });
1477 });
1478
1479 afterAll(async () => {
1480 servers.stop();
1481 app.close();
1482 await rm(dataDir, { recursive: true, force: true });
1483 });
1484
1485 const get = (path: string) =>
1486 fetch(`${base}${path}`, {
1487 headers: { cookie: `${ownerCookie}; forge_mode=studio` },
1488 redirect: "manual",
1489 });
1490
1491 test("fewer than three served deploys this week says so plainly rather than a median of one", async () => {
1492 const before = await (await get("/alice/trickle")).text();
1493 expect(before).toContain("not enough deploys this week to say");
1494 expect(before).not.toContain("reaches its preview in");
1495
1496 const deploy = createRun(app.db, {
1497 repoId: repo.id,
1498 kind: "deploy",
1499 env: "production",
1500 reason: "push",
1501 });
1502 markServing(app.db, deploy.id);
1503 const one = await (await get("/alice/trickle")).text();
1504 expect(one).toContain("not enough deploys this week to say");
1505 expect(one).not.toContain("reaches its preview in");
1506 });
1507
1508 test("the split reads as the total's explanation, and says when it is drawn from fewer deploys", async () => {
1509 const seedDeploy = (
1510 createdAt: string,
1511 startedAt: string | null,
1512 servingAt: string,
1513 ): void => {
1514 const deploy = createRun(app.db, { repoId: repo.id, kind: "deploy" });
1515 app.db
1516 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1517 .run(deploy.id, createdAt);
1518 if (startedAt !== null) {
1519 app.db
1520 .query("UPDATE run SET started_at = ?2 WHERE id = ?1")
1521 .run(deploy.id, startedAt);
1522 }
1523 app.db
1524 .query("UPDATE run SET serving_at = ?2 WHERE id = ?1")
1525 .run(deploy.id, servingAt);
1526 };
1527 // Three deploys with a known queue and boot each.
1528 const now = Date.now();
1529 const segments: readonly (readonly [number, number])[] = [
1530 [1, 3],
1531 [2, 6],
1532 [3, 9],
1533 ];
1534 segments.forEach(([queue, boot], i) => {
1535 const createdAt = now + i * 1_000;
1536 const startedAt = createdAt + queue * 1_000;
1537 const servingAt = startedAt + boot * 1_000;
1538 seedDeploy(
1539 new Date(createdAt).toISOString(),
1540 new Date(startedAt).toISOString(),
1541 new Date(servingAt).toISOString(),
1542 );
1543 });
1544 // A fourth, served deploy with no `started_at`: it feeds the total but
1545 // is left out of the split.
1546 seedDeploy(
1547 new Date(now + 3_000).toISOString(),
1548 null,
1549 new Date(now + 100_000).toISOString(),
1550 );
1551 const page = await (await get("/alice/trickle")).text();
1552 expect(page).toContain("4 deploys");
1553 expect(page).toContain("2.0s waiting");
1554 // Only 3 of the 4 served deploys carried a `started_at`: the split says so.
1555 expect(page).toContain("6.0s coming up (3 of 4)");
1556
1557 // A clean week — every deploy served — reads exactly as it always has:
1558 // no mention of failures, no empty clause left dangling.
1559 expect(page).not.toContain("never reached a preview");
1560 expect(page).not.toContain("0 failed");
1561 });
1562
1563 test("a deploy that finished without ever serving is counted and worded, and links to the runs list", async () => {
1564 const before = await (await get("/alice/trickle")).text();
1565
1566 const failed = createRun(app.db, {
1567 repoId: repo.id,
1568 kind: "deploy",
1569 env: "production",
1570 reason: "push",
1571 });
1572 claimRun(app.db, failed.id);
1573 finishRun(app.db, failed.id, { state: "failed", summary: "build failed" });
1574
1575 const after = await (await get("/alice/trickle")).text();
1576 expect(after).toContain("1 of 5 never reached a preview");
1577 expect(after).toContain(`href="/alice/trickle/runs?kind=deploy"`);
1578
1579 // A deploy still running moves neither the served count nor the failed
1580 // count: it is neither served nor finished.
1581 const inFlight = createRun(app.db, {
1582 repoId: repo.id,
1583 kind: "deploy",
1584 env: "production",
1585 reason: "push",
1586 });
1587 claimRun(app.db, inFlight.id);
1588 const withInFlight = await (await get("/alice/trickle")).text();
1589 expect(withInFlight).toContain("1 of 5 never reached a preview");
1590 expect(withInFlight).not.toBe(before);
1591 });
1592 });
1593
1594 describe("a low deploy count never reads as a failure ratio (#274)", () => {
1595 let dataDir: string;
1596 let app: App;
1597 let servers: Servers;
1598 let base: string;
1599 let alice: User;
1600 let repo: Repo;
1601 let ownerCookie: string;
1602
1603 beforeAll(async () => {
1604 dataDir = await mkdtemp(join(tmpdir(), "forge-low-count-ratio-"));
1605 app = createApp({ dataDir, port: 0 });
1606 servers = await serve(app);
1607 base = servers.url;
1608 const a = await createUser(app.db, { trusted: true,
1609 username: "alice",
1610 email: "[email protected]",
1611 password: "a-good-passphrase",
1612 adminIfFirst: true,
1613 });
1614 if (!a.ok) throw new Error(a.error);
1615 alice = a.value;
1616 ownerCookie = (
1617 (
1618 await fetch(`${base}/signin`, {
1619 method: "POST",
1620 body: new URLSearchParams({
1621 username: "alice",
1622 password: "a-good-passphrase",
1623 }),
1624 redirect: "manual",
1625 })
1626 ).headers.get("set-cookie") ?? ""
1627 ).split(";")[0]!;
1628 const made = await createRepo(app, alice, {
1629 name: "tworuns",
1630 visibility: "private",
1631 description: "",
1632 });
1633 if (!made.ok) throw new Error(String(made.error));
1634 repo = made.value;
1635 const seeded = await commitToBranch(app, alice, repo, {
1636 message: "Start",
1637 files: [{ path: "README.md", content: "start\n" }],
1638 });
1639 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1640 const enabled = await enableApp(app, alice, repo, { slug: "tworuns" });
1641 if (!enabled.ok) throw new Error(String(enabled.error));
1642
1643 // History behind it, so the full panel renders — but only two deploys
1644 // this week, both never served, neither the forge's own doing.
1645 const seedRun = createRun(app.db, { repoId: repo.id, kind: "agent" });
1646 app.db
1647 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1648 .run(
1649 seedRun.id,
1650 new Date(Date.now() - 30 * 24 * 60 * 60_000).toISOString(),
1651 );
1652 claimRun(app.db, seedRun.id);
1653 finishRun(app.db, seedRun.id, { state: "ok", summary: "seed" });
1654
1655 for (let i = 0; i < 2; i++) {
1656 const failed = createRun(app.db, {
1657 repoId: repo.id,
1658 kind: "deploy",
1659 env: "production",
1660 reason: "push",
1661 });
1662 claimRun(app.db, failed.id);
1663 finishRun(app.db, failed.id, {
1664 state: "failed",
1665 summary: "build failed",
1666 });
1667 }
1668 });
1669
1670 afterAll(async () => {
1671 servers.stop();
1672 app.close();
1673 await rm(dataDir, { recursive: true, force: true });
1674 });
1675
1676 const get = (path: string) =>
1677 fetch(`${base}${path}`, {
1678 headers: { cookie: `${ownerCookie}; forge_mode=studio` },
1679 redirect: "manual",
1680 });
1681
1682 test("2 of 2 never reads as a ratio — a handful of deploys needs to be behind it first", async () => {
1683 const page = await (await get("/alice/tworuns")).text();
1684 expect(page).toContain("not enough deploys this week to say");
1685 expect(page).not.toContain("never reached a preview");
1686 });
1687 });
1688
1689 describe("this week's runs and spend are counted by the database, not read off a page of runs", () => {
1690 let dataDir: string;
1691 let app: App;
1692 let servers: Servers;
1693 let base: string;
1694 let alice: User;
1695 let busy: Repo;
1696 let quiet: Repo;
1697 let ownerCookie: string;
1698
1699 /** How many agent runs this week — more than `listRuns` would ever hand back. */
1700 const RUNS = 250;
1701 /** What each of them cost, in the float dollars the column holds. */
1702 const COST = 0.13;
1703
1704 beforeAll(async () => {
1705 dataDir = await mkdtemp(join(tmpdir(), "forge-week-totals-"));
1706 app = createApp({ dataDir, port: 0 });
1707 servers = await serve(app);
1708 base = servers.url;
1709 const a = await createUser(app.db, { trusted: true,
1710 username: "alice",
1711 email: "[email protected]",
1712 password: "a-good-passphrase",
1713 adminIfFirst: true,
1714 });
1715 if (!a.ok) throw new Error(a.error);
1716 alice = a.value;
1717 ownerCookie = (
1718 (
1719 await fetch(`${base}/signin`, {
1720 method: "POST",
1721 body: new URLSearchParams({
1722 username: "alice",
1723 password: "a-good-passphrase",
1724 }),
1725 redirect: "manual",
1726 })
1727 ).headers.get("set-cookie") ?? ""
1728 ).split(";")[0]!;
1729
1730 for (const name of ["busy", "quiet"] as const) {
1731 const made = await createRepo(app, alice, {
1732 name,
1733 visibility: "private",
1734 description: "",
1735 });
1736 if (!made.ok) throw new Error(String(made.error));
1737 const seeded = await commitToBranch(app, alice, made.value, {
1738 message: "Start",
1739 files: [{ path: "README.md", content: "start\n" }],
1740 });
1741 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1742 const enabled = await enableApp(app, alice, made.value, { slug: name });
1743 if (!enabled.ok) throw new Error(String(enabled.error));
1744 if (name === "busy") busy = made.value;
1745 if (name === "quiet") quiet = made.value;
1746 }
1747
1748 // A real week behind "quiet", just not this one — an old finished run,
1749 // so a week with nothing in it still reads as a quiet week (#274), not
1750 // as an app with no history at all.
1751 const oldQuiet = createRun(app.db, { repoId: quiet.id, kind: "agent" });
1752 claimRun(app.db, oldQuiet.id);
1753 finishRun(app.db, oldQuiet.id, { state: "ok", summary: "long ago" });
1754 app.db
1755 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1756 .run(
1757 oldQuiet.id,
1758 new Date(Date.now() - 30 * 24 * 60 * 60_000).toISOString(),
1759 );
1760
1761 const keeper = await createAgent(app, alice, { username: "keeper" });
1762 if (!keeper.ok) throw new Error(String(keeper.error));
1763 expect(addToCrew(app, alice, busy, "keeper").ok).toBe(true);
1764 const keeperId = getUserByName(app.db, "keeper")!.id;
1765
1766 // 250 agent runs inside the week — past the 200 `listRuns` will return —
1767 // and one fat run ten days back that the window must leave out.
1768 for (let i = 0; i < RUNS; i++) {
1769 const r = createRun(app.db, {
1770 repoId: busy.id,
1771 kind: "agent",
1772 actorId: keeperId,
1773 reason: "assigned",
1774 });
1775 claimRun(app.db, r.id);
1776 finishRun(app.db, r.id, {
1777 state: "ok",
1778 summary: "did a thing",
1779 costUsd: COST,
1780 });
1781 }
1782 const old = createRun(app.db, {
1783 repoId: busy.id,
1784 kind: "agent",
1785 actorId: keeperId,
1786 reason: "assigned",
1787 });
1788 claimRun(app.db, old.id);
1789 finishRun(app.db, old.id, {
1790 state: "ok",
1791 summary: "last month",
1792 costUsd: 99,
1793 });
1794 app.db
1795 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
1796 .run(old.id, new Date(Date.now() - 10 * 24 * 60 * 60_000).toISOString());
1797 });
1798
1799 afterAll(async () => {
1800 servers.stop();
1801 app.close();
1802 await rm(dataDir, { recursive: true, force: true });
1803 });
1804
1805 const get = (path: string) =>
1806 fetch(`${base}${path}`, {
1807 headers: { cookie: `${ownerCookie}; forge_mode=studio` },
1808 redirect: "manual",
1809 });
1810
1811 test("the aggregate counts every run in the window, skips the one outside it, and is 0 for a week with none", () => {
1812 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
1813 const totals = agentRunTotals(app.db, busy.id, week);
1814 expect(totals.runs).toBe(RUNS);
1815 expect(totals.spentUsd).toBeCloseTo(RUNS * COST, 6);
1816
1817 // The whole ledger, window opened wide: the old run is there after all.
1818 const ever = agentRunTotals(app.db, busy.id, "2000-01-01T00:00:00.000Z");
1819 expect(ever.runs).toBe(RUNS + 1);
1820 expect(ever.spentUsd).toBeCloseTo(RUNS * COST + 99, 6);
1821
1822 // SUM over no rows is NULL; a repository with no runs reads zero.
1823 const none = agentRunTotals(app.db, "no-such-repo", week);
1824 expect(none).toEqual({ runs: 0, spentUsd: 0 });
1825 });
1826
1827 test("the app page says 250 runs and the sum of all 250, not the last 200", async () => {
1828 const page = await (await get("/alice/busy")).text();
1829 expect(page).toMatch(/Runs<\/dt>\s*<dd>250<\/dd>/);
1830 expect(page).toContain(`$${(RUNS * COST).toFixed(2)}`);
1831 // What the 200-row read would have said, and the run outside the window.
1832 expect(page).not.toContain(`$${(200 * COST).toFixed(2)}`);
1833 expect(page).not.toContain("$99.00");
1834 });
1835
1836 test("an app with real history but no runs this week reads 0 and $0.00, never blank", async () => {
1837 const page = await (await get("/alice/quiet")).text();
1838 expect(page).toMatch(/Runs<\/dt>\s*<dd>0<\/dd>/);
1839 expect(page).toContain("$0.00");
1840 });
1841 });
1842
1843 describe("this week's shipping is counted by the database, not read off a page of closed pulls", () => {
1844 let dataDir: string;
1845 let app: App;
1846 let servers: Servers;
1847 let base: string;
1848 let alice: User;
1849 let busy: Repo;
1850 let quiet: Repo;
1851 let ownerCookie: string;
1852
1853 /** How many changes landed this week — past the page of closed pulls the
1854 * count used to be filtered out of. */
1855 const SHIPPED = 150;
1856 /** One agent run's cost, so the page prints a cost per shipped change. */
1857 const SPENT = 30;
1858
1859 /** A pull row on a closed issue: `mergedAt` null is closed without a merge. */
1860 const seedPull = (repo: Repo, title: string, mergedAt: string | null) => {
1861 const opened = openIssue(app, alice, repo, { title });
1862 if (!opened.ok) throw new Error(String(opened.error));
1863 app.db
1864 .query(
1865 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at)
1866 VALUES (?1, 'main', ?2, 'a', 'b', ?3)`,
1867 )
1868 .run(opened.value.id, `f${opened.value.number}`, mergedAt);
1869 app.db
1870 .query("UPDATE issue SET kind = 'pull', state = 'closed' WHERE id = ?1")
1871 .run(opened.value.id);
1872 };
1873
1874 beforeAll(async () => {
1875 dataDir = await mkdtemp(join(tmpdir(), "forge-week-shipped-"));
1876 app = createApp({ dataDir, port: 0 });
1877 servers = await serve(app);
1878 base = servers.url;
1879 const a = await createUser(app.db, { trusted: true,
1880 username: "alice",
1881 email: "[email protected]",
1882 password: "a-good-passphrase",
1883 adminIfFirst: true,
1884 });
1885 if (!a.ok) throw new Error(a.error);
1886 alice = a.value;
1887 ownerCookie = (
1888 (
1889 await fetch(`${base}/signin`, {
1890 method: "POST",
1891 body: new URLSearchParams({
1892 username: "alice",
1893 password: "a-good-passphrase",
1894 }),
1895 redirect: "manual",
1896 })
1897 ).headers.get("set-cookie") ?? ""
1898 ).split(";")[0]!;
1899
1900 for (const name of ["busy", "quiet"] as const) {
1901 const made = await createRepo(app, alice, {
1902 name,
1903 visibility: "private",
1904 description: "",
1905 });
1906 if (!made.ok) throw new Error(String(made.error));
1907 const seeded = await commitToBranch(app, alice, made.value, {
1908 message: "Start",
1909 files: [{ path: "README.md", content: "start\n" }],
1910 });
1911 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1912 const enabled = await enableApp(app, alice, made.value, { slug: name });
1913 if (!enabled.ok) throw new Error(String(enabled.error));
1914 if (name === "busy") busy = made.value;
1915 if (name === "quiet") quiet = made.value;
1916 }
1917
1918 // 150 changes landed inside the week — past the hundred closed pulls the
1919 // page used to read — plus two that must not count: one closed without a
1920 // merge, and one merged ten days back.
1921 const now = Date.now();
1922 for (let i = 0; i < SHIPPED; i++)
1923 seedPull(busy, `landed ${i}`, new Date(now - i * 60_000).toISOString());
1924 seedPull(busy, "abandoned", null);
1925 seedPull(
1926 busy,
1927 "landed last week",
1928 new Date(now - 10 * 24 * 60 * 60_000).toISOString(),
1929 );
1930
1931 // A real week behind "quiet", just not this one — a change shipped a
1932 // month back, so a week with nothing new still reads as a quiet week
1933 // (#274), not as an app with no history at all.
1934 seedPull(
1935 quiet,
1936 "shipped a month ago",
1937 new Date(now - 30 * 24 * 60 * 60_000).toISOString(),
1938 );
1939
1940 const keeper = await createAgent(app, alice, { username: "keeper" });
1941 if (!keeper.ok) throw new Error(String(keeper.error));
1942 expect(addToCrew(app, alice, busy, "keeper").ok).toBe(true);
1943 const run = createRun(app.db, {
1944 repoId: busy.id,
1945 kind: "agent",
1946 actorId: getUserByName(app.db, "keeper")!.id,
1947 reason: "assigned",
1948 });
1949 claimRun(app.db, run.id);
1950 finishRun(app.db, run.id, {
1951 state: "ok",
1952 summary: "a week of work",
1953 costUsd: SPENT,
1954 });
1955 });
1956
1957 afterAll(async () => {
1958 servers.stop();
1959 app.close();
1960 await rm(dataDir, { recursive: true, force: true });
1961 });
1962
1963 const get = (path: string) =>
1964 fetch(`${base}${path}`, {
1965 headers: { cookie: `${ownerCookie}; forge_mode=studio` },
1966 redirect: "manual",
1967 });
1968
1969 test("the aggregate counts the merged pulls in the window and nothing else", () => {
1970 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
1971 expect(mergedPullCount(app.db, busy.id, week)).toBe(SHIPPED);
1972
1973 // Window opened wide: the older merge is there after all, the pull closed
1974 // without a merge still is not.
1975 expect(mergedPullCount(app.db, busy.id, "2000-01-01T00:00:00.000Z")).toBe(
1976 SHIPPED + 1,
1977 );
1978
1979 // COUNT over no rows is 0, and another repo's merges are never this one's.
1980 expect(mergedPullCount(app.db, "no-such-repo", week)).toBe(0);
1981 });
1982
1983 test("the app page says 150 changes shipped, not the 100 a page of closed pulls holds", async () => {
1984 const page = await (await get("/alice/busy")).text();
1985 expect(page).toMatch(/Shipped<\/dt>\s*<dd>150 changes<\/dd>/);
1986 expect(page).not.toContain("100 changes");
1987 // And the cost of a shipped change divides by all 150.
1988 expect(page).toContain(
1989 `$${(SPENT / SHIPPED).toFixed(2)} per shipped change`,
1990 );
1991 });
1992
1993 test("an app with real history but nothing shipped this week reads 0 changes and no cost per change", async () => {
1994 const page = await (await get("/alice/quiet")).text();
1995 expect(page).toMatch(/Shipped<\/dt>\s*<dd>0 changes<\/dd>/);
1996 expect(page).not.toContain("per shipped change");
1997 });
1998 });
1999
2000 describe("a one-minute-old app's front door does not lead with a week of zeros (#274)", () => {
2001 let dataDir: string;
2002 let app: App;
2003 let servers: Servers;
2004 let base: string;
2005 let alice: User;
2006 let repo: Repo;
2007 let ownerCookie: string;
2008
2009 beforeAll(async () => {
2010 dataDir = await mkdtemp(join(tmpdir(), "forge-day-one-"));
2011 app = createApp({ dataDir, port: 0 });
2012 servers = await serve(app);
2013 base = servers.url;
2014 const a = await createUser(app.db, { trusted: true,
2015 username: "alice",
2016 email: "[email protected]",
2017 password: "a-good-passphrase",
2018 adminIfFirst: true,
2019 });
2020 if (!a.ok) throw new Error(a.error);
2021 alice = a.value;
2022 ownerCookie = (
2023 (
2024 await fetch(`${base}/signin`, {
2025 method: "POST",
2026 body: new URLSearchParams({
2027 username: "alice",
2028 password: "a-good-passphrase",
2029 }),
2030 redirect: "manual",
2031 })
2032 ).headers.get("set-cookie") ?? ""
2033 ).split(";")[0]!;
2034 const made = await createRepo(app, alice, {
2035 name: "newborn",
2036 visibility: "public",
2037 description: "",
2038 });
2039 if (!made.ok) throw new Error(String(made.error));
2040 repo = made.value;
2041 const seeded = await commitToBranch(app, alice, repo, {
2042 message: "Start",
2043 files: [{ path: "README.md", content: "start\n" }],
2044 });
2045 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
2046 const enabled = await enableApp(app, alice, repo, { slug: "newborn" });
2047 if (!enabled.ok) throw new Error(String(enabled.error));
2048
2049 // Exactly the shape of a one-minute-old app: its own first two
2050 // production deploys, one of which the forge's own restart cancelled —
2051 // never a shipped change, never an agent run.
2052 const cancelledByForge = createRun(app.db, {
2053 repoId: repo.id,
2054 kind: "deploy",
2055 env: "production",
2056 reason: "boot",
2057 });
2058 claimRun(app.db, cancelledByForge.id);
2059 app.db
2060 .query(
2061 "UPDATE run SET state = 'cancelled', summary = ?2, finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1",
2062 )
2063 .run(cancelledByForge.id, STOPPED_SUMMARY);
2064 const secondBoot = createRun(app.db, {
2065 repoId: repo.id,
2066 kind: "deploy",
2067 env: "production",
2068 reason: "push",
2069 });
2070 claimRun(app.db, secondBoot.id);
2071 });
2072
2073 afterAll(async () => {
2074 servers.stop();
2075 app.close();
2076 await rm(dataDir, { recursive: true, force: true });
2077 });
2078
2079 const get = (path: string) =>
2080 fetch(`${base}${path}`, {
2081 headers: { cookie: `${ownerCookie}; forge_mode=studio` },
2082 redirect: "manual",
2083 });
2084
2085 test("the day-one page a person lands on from New app says one true line, not a week of zeros", async () => {
2086 const page = await (await get("/alice/newborn")).text();
2087 expect(page).toContain("This week");
2088 expect(page).toContain("Nothing to show yet");
2089 // Not a single zero from the old panel, and not the scary ratio: the
2090 // forge's own cancelled boot is never counted, and two deploys is well
2091 // under the threshold a ratio needs anyway.
2092 expect(page).not.toMatch(/Runs<\/dt>/);
2093 expect(page).not.toMatch(/Spent<\/dt>/);
2094 expect(page).not.toContain("never reached a preview");
2095 expect(page).not.toContain("$0.00");
2096 });
2097
2098 test("a stranger sees the same quiet line, not the owner's zeros", async () => {
2099 const page = await (await fetch(`${base}/alice/newborn`)).text();
2100 expect(page).toContain("Nothing to show yet");
2101 expect(page).not.toMatch(/Shipped<\/dt>\s*<dd>0 changes<\/dd>/);
2102 });
2103 });
test/studio.test.ts +10 −8338

Diff not shown — 8,348 changed lines (limit 5,000). +10 −8338

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs