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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/2038-check-time-under-2-minutes-for-a-typical → main 4 commits fork
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 | ||
| 738 | 738 | `bun run check` is the gate: the same command `forge.json` declares, so what |
| 739 | 739 | it says here is what the deploy will say. It type-checks once, then runs the |
| 740 | 740 | 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 | |
| 743 | 745 | shard's output is written whole as it finishes; the log ends with every |
| 744 | 746 | failing test from every shard, or on green with one line — how many tests, |
| 745 | 747 | across how many files, in how long, then the slowest and fastest shard. |
| 746 | 748 | |
| 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 | ||
| 747 | 761 | The check's first step is the gate-list audit (`bun scripts/gate.ts`), so a |
| 748 | 762 | test file listed in neither `GATE` nor `EXCLUDED` fails in under a second, |
| 749 | 763 | before install-heavy work. `tsc` then runs `--incremental` with a 2 GB heap, |
bunfig.toml +3
| @@ -2,3 +2,6 @@ | ||
| 2 | 2 | # where a running forge keeps the crew's clones, each with its own tests. |
| 3 | 3 | [test] |
| 4 | 4 | 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[] = [ | ||
| 156 | 156 | "test/autonomy.test.ts", |
| 157 | 157 | "test/autonomy_sweep.test.ts", |
| 158 | 158 | "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", | |
| 159 | 164 | "test/studio-words.test.ts", |
| 160 | 165 | "test/converge-settled.test.ts", |
| 161 | 166 | "test/following.test.ts", |
| @@ -179,6 +184,10 @@ export const GATE: readonly string[] = [ | ||
| 179 | 184 | "test/apps-slow-test.test.ts", |
| 180 | 185 | "test/apps-tests-clause.test.ts", |
| 181 | 186 | "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", | |
| 182 | 191 | "test/apps-busy.test.ts", |
| 183 | 192 | "test/apps-token.test.ts", |
| 184 | 193 | "test/contain.test.ts", |
scripts/shards.ts +44 −5
| @@ -7,22 +7,41 @@ | ||
| 7 | 7 | * is disjoint and covering, and that one red shard makes the run red. |
| 8 | 8 | */ |
| 9 | 9 | |
| 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; | |
| 12 | 17 | |
| 13 | 18 | /** |
| 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 | |
| 15 | 30 | * {@link SHARDS_MAX} and at the number of files, at least one. One per CPU, |
| 16 | 31 | * 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. | |
| 19 | 35 | */ |
| 20 | 36 | export function shardCount( |
| 21 | 37 | cpus: number, |
| 22 | 38 | files: number, |
| 23 | 39 | override?: string, |
| 40 | memory?: number, | |
| 24 | 41 | ): { ok: true; value: number } | { ok: false; error: string } { |
| 25 | 42 | 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)); | |
| 26 | 45 | if (override !== undefined && override.trim() !== "") { |
| 27 | 46 | if (!/^[1-9][0-9]{0,2}$/.test(override.trim())) |
| 28 | 47 | return { |
| @@ -34,6 +53,26 @@ export function shardCount( | ||
| 34 | 53 | return { ok: true, value: Math.max(1, Math.min(n, files)) }; |
| 35 | 54 | } |
| 36 | 55 | |
| 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 | ||
| 37 | 76 | /** |
| 38 | 77 | * Splits `files` into at most `n` disjoint, non-empty slices that together |
| 39 | 78 | * hold every file once. `weight` is how long a file is expected to take |
scripts/test-shards.ts +13 −1
| @@ -38,12 +38,13 @@ | ||
| 38 | 38 | * table is rewritten from those times when every file is green. |
| 39 | 39 | */ |
| 40 | 40 | |
| 41 | import { availableParallelism, tmpdir } from "node:os"; | |
| 41 | import { availableParallelism, tmpdir, totalmem } from "node:os"; | |
| 42 | 42 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; |
| 43 | 43 | import { join } from "node:path"; |
| 44 | 44 | import { |
| 45 | 45 | formatTimes, |
| 46 | 46 | heaviestFirst, |
| 47 | memoryLimit, | |
| 47 | 48 | overlayTimes, |
| 48 | 49 | refreshTimes, |
| 49 | 50 | parseShardSpec, |
| @@ -326,10 +327,21 @@ if (new Set(files).size !== files.length) { | ||
| 326 | 327 | process.exit(2); |
| 327 | 328 | } |
| 328 | 329 | |
| 330 | const readLimit = (path: string): string | undefined => { | |
| 331 | try { | |
| 332 | return readFileSync(path, "utf8"); | |
| 333 | } catch { | |
| 334 | return undefined; | |
| 335 | } | |
| 336 | }; | |
| 329 | 337 | const count = shardCount( |
| 330 | 338 | availableParallelism(), |
| 331 | 339 | files.length, |
| 332 | 340 | 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 | ]), | |
| 333 | 345 | ); |
| 334 | 346 | if (!count.ok) { |
| 335 | 347 | console.error(`test-shards: ${count.error}`); |
scripts/test-times.json +11 −2
| @@ -1,5 +1,9 @@ | ||
| 1 | 1 | { |
| 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, | |
| 3 | 7 | "test/apps-busy.test.ts": 26.4, |
| 4 | 8 | "test/apps-crew.test.ts": 3.8, |
| 5 | 9 | "test/apps-identity.test.ts": 3.2, |
| @@ -45,8 +49,13 @@ | ||
| 45 | 49 | "test/spawn.test.ts": 11.1, |
| 46 | 50 | "test/standing.test.ts": 2.8, |
| 47 | 51 | "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, | |
| 48 | 57 | "test/studio-words.test.ts": 17, |
| 49 | "test/studio.test.ts": 196.9, | |
| 58 | "test/studio.test.ts": 35.3, | |
| 50 | 59 | "test/usage.test.ts": 17.5, |
| 51 | 60 | "test/vitals.test.ts": 7.1, |
| 52 | 61 | "test/web-apps.test.ts": 18.7, |
src/kernel/db.ts +38 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { Database } from "bun:sqlite"; |
| 9 | import { existsSync, writeFileSync } from "node:fs"; | |
| 9 | 10 | import { migrate } from "./migrate.ts"; |
| 10 | 11 | |
| 11 | 12 | /** |
| @@ -34,12 +35,48 @@ const CACHE_KIB = 64 * 1024; | ||
| 34 | 35 | const MMAP_BYTES = 512 * 1024 * 1024; |
| 35 | 36 | |
| 36 | 37 | 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 | } | |
| 38 | 48 | 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. | |
| 39 | 51 | migrate(db, migrations); |
| 52 | if (fresh && image === undefined) migratedEmpty.set(migrations, db.serialize()); | |
| 40 | 53 | return db; |
| 41 | 54 | } |
| 42 | 55 | |
| 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 | ||
| 43 | 80 | /** |
| 44 | 81 | * The pragmas, in one place and applied before anything else touches the |
| 45 | 82 | * 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 @@ | ||
| 5 | 5 | * the schema. Where the existing suites (apps.test.ts, apps-api.test.ts, |
| 6 | 6 | * crew.test.ts) already drive a rule end to end, this file goes for the |
| 7 | 7 | * 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). | |
| 8 | 13 | */ |
| 9 | 14 | |
| 10 | 15 | import { |
| @@ -22,27 +27,15 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 22 | 27 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 23 | 28 | import { cloneDir, envDir, isEnvName, type EnvName } from "../src/apps/data.ts"; |
| 24 | 29 | 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"; | |
| 31 | 31 | import { parseManifest } from "../src/apps/manifest.ts"; |
| 32 | import { newApp, updateAppSettings } from "../src/actions/apps.ts"; | |
| 32 | import { newApp } from "../src/actions/apps.ts"; | |
| 33 | 33 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 34 | 34 | 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"; | |
| 46 | 39 | import { |
| 47 | 40 | activeRunFor, |
| 48 | 41 | automaticRunsSince, |
| @@ -67,14 +60,11 @@ import { | ||
| 67 | 60 | runLogListenerCount, |
| 68 | 61 | subscribeRunLog, |
| 69 | 62 | } from "../src/runs/log.ts"; |
| 70 | import { grantedCookie } from "./helpers/identity.ts"; | |
| 71 | 63 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 72 | 64 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; |
| 73 | 65 | |
| 74 | 66 | const testBySocket = portInsteadOfSocket() ? test.skip : test; |
| 75 | 67 | |
| 76 | const DOMAIN = "apps.test"; | |
| 77 | ||
| 78 | 68 | const clientEnv = gitClientEnv({ |
| 79 | 69 | GIT_AUTHOR_NAME: "Agent", |
| 80 | 70 | GIT_AUTHOR_EMAIL: "[email protected]", |
| @@ -1060,954 +1050,6 @@ describe("migration 14 on an existing database", () => { | ||
| 1060 | 1050 | // proxy, and the mechanical rules that only show themselves under load. |
| 1061 | 1051 | // --------------------------------------------------------------------------- |
| 1062 | 1052 | |
| 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 | ||
| 2011 | 1053 | describe("boot: a forge addressed by IP hands every app a host that works (issue #269)", () => { |
| 2012 | 1054 | let dataDir: string; |
| 2013 | 1055 | let work: string; |
test/helpers.test.ts +2
| @@ -28,6 +28,8 @@ describe("a git client's environment under test", () => { | ||
| 28 | 28 | expect(env["GIT_CONFIG_PARAMETERS"]).toBeUndefined(); |
| 29 | 29 | expect(env["GIT_CONFIG_GLOBAL"]).toBe("/dev/null"); |
| 30 | 30 | expect(env["GIT_CONFIG_SYSTEM"]).toBe("/dev/null"); |
| 31 | expect(env["GIT_CONFIG_NOSYSTEM"]).toBe("1"); | |
| 32 | expect(env["GIT_OPTIONAL_LOCKS"]).toBe("0"); | |
| 31 | 33 | expect(env["GIT_TERMINAL_PROMPT"]).toBe("0"); |
| 32 | 34 | expect(env["PATH"]).toBe(process.env["PATH"] ?? "/usr/bin:/bin"); |
| 33 | 35 | // 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 = { | ||
| 30 | 30 | /** |
| 31 | 31 | * A deterministic environment for a git client under test: `PATH` and |
| 32 | 32 | * 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 | |
| 34 | 35 | * askpass, `GIT_SSH_COMMAND`, a different author — and wins. |
| 35 | 36 | */ |
| 36 | 37 | export function gitClientEnv( |
| @@ -41,6 +42,8 @@ export function gitClientEnv( | ||
| 41 | 42 | HOME: "/dev/null", |
| 42 | 43 | GIT_CONFIG_GLOBAL: "/dev/null", |
| 43 | 44 | GIT_CONFIG_SYSTEM: "/dev/null", |
| 45 | GIT_CONFIG_NOSYSTEM: "1", | |
| 46 | GIT_OPTIONAL_LOCKS: "0", | |
| 44 | 47 | GIT_TERMINAL_PROMPT: "0", |
| 45 | 48 | LC_ALL: "C", |
| 46 | 49 | ...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"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | 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"; | |
| 6 | 9 | import { ForgeError, defect } from "../src/kernel/error.ts"; |
| 7 | 10 | import { id, secret } from "../src/kernel/id.ts"; |
| 8 | 11 | import { |
| @@ -148,6 +151,47 @@ describe("db", () => { | ||
| 148 | 151 | } |
| 149 | 152 | }); |
| 150 | 153 | |
| 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 | ||
| 151 | 195 | test("foreign keys are enforced", () => { |
| 152 | 196 | const db = openDb(":memory:", [ |
| 153 | 197 | "CREATE TABLE parent (id INTEGER PRIMARY KEY); CREATE TABLE child (pid INTEGER REFERENCES parent(id))", |
| @@ -155,3 +199,35 @@ describe("db", () => { | ||
| 155 | 199 | expect(() => db.exec("INSERT INTO child (pid) VALUES (99)")).toThrow(); |
| 156 | 200 | }); |
| 157 | 201 | }); |
| 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"; | ||
| 10 | 10 | import { |
| 11 | 11 | SECONDS_PER_BYTE_DEFAULT, |
| 12 | 12 | SHARDS_MAX, |
| 13 | WORKER_MEMORY_BYTES, | |
| 13 | 14 | formatTimes, |
| 14 | 15 | heaviestFirst, |
| 16 | memoryLimit, | |
| 15 | 17 | overlayTimes, |
| 16 | 18 | parseTimes, |
| 17 | 19 | refreshTimes, |
| @@ -213,12 +215,35 @@ describe("parseTimes", () => { | ||
| 213 | 215 | |
| 214 | 216 | describe("shardCount", () => { |
| 215 | 217 | 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 }); | |
| 217 | 219 | expect(shardCount(4, 22)).toEqual({ ok: true, value: 4 }); |
| 218 | 220 | expect(shardCount(1, 22)).toEqual({ ok: true, value: 1 }); |
| 219 | 221 | expect(shardCount(80, 3)).toEqual({ ok: true, value: 3 }); |
| 220 | 222 | }); |
| 221 | 223 | |
| 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 | ||
| 222 | 247 | test("FORGE_CHECK_SHARDS turns it up or down; nonsense is refused", () => { |
| 223 | 248 | expect(shardCount(80, 22, "2")).toEqual({ ok: true, value: 2 }); |
| 224 | 249 | 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} → ${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&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&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: <install> 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: <install> 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 '}'", | |
| 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&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 "Add gamma"`); | |
| 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
new above
offline