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

test/apps.test.ts runs in under 10 s, every test under 1 s, proving what it proves today #10260

Merged opened this

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

Merged

mason/forge:mason/10244-test-apps-test-ts-runs-in-under-10-s-eve → main 2 commits fork

  • Merged. mason/10244-test-apps-test-ts-runs-in-under-10-s-eve was merged into main as e00d108.

Review

Sign in to review this change.
2 files changed +370 −522
test/apps.test.ts +313 −510
@@ -15,7 +15,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1515 import { mkdtemp, rm } from "node:fs/promises";
1616 import { tmpdir } from "node:os";
1717 import { join } from "node:path";
18 import { createApp, type App } from "../src/app.ts";
18 import type { App } from "../src/app.ts";
1919 import { AppsRuntime } from "../src/apps/runtime.ts";
2020 import { seconds, Stopwatch } from "../src/apps/timing.ts";
2121 import { envDataDir, envDir, previewEnv } from "../src/apps/data.ts";
@@ -33,7 +33,7 @@ import {
3333 createRepo,
3434 updateRepoSettings,
3535 } from "../src/actions/repos.ts";
36 import type { AppRecord } from "../src/core/apps.ts";
36 import { listApps, type AppRecord } from "../src/core/apps.ts";
3737 import { getPull, type Pull } from "../src/core/pulls.ts";
3838 import { type Repo } from "../src/core/repos.ts";
3939 import { repoDir } from "../src/git/repos.ts";
@@ -45,14 +45,16 @@ import {
4545 } from "../src/core/runs.ts";
4646 import { runWord, servingHistory } from "../src/web/studio/words.ts";
4747 import { createToken } from "../src/core/tokens.ts";
48 import { createUser, type User } from "../src/core/users.ts";
49 import { serve, type Servers } from "../src/http/server.ts";
48 import type { User } from "../src/core/users.ts";
49 import type { Servers } from "../src/http/server.ts";
5050 import { readRunLog } from "../src/runs/log.ts";
5151 import { grantedCookie } from "./helpers/identity.ts";
5252 import {
5353 APPS_DOMAIN,
54 appsRuntime,
5455 deploySettled,
5556 fetchAsHost,
57 signedInCookie,
5658 startAppsForge,
5759 stopAppsForge,
5860 type AppsForge,
@@ -89,6 +91,7 @@ describe("apps: production, previews, data, gates", () => {
8991 });
9092
9193 test("new app: a repository with a working app, running in production", async () => {
94 // Invariant: a new app is deployed to production and served at its own host, greeting a signed identity.
9295 const made = await newApp(app, alice, {
9396 name: "hello",
9497 description: "A place for notes",
@@ -182,6 +185,7 @@ describe("apps: production, previews, data, gates", () => {
182185 let pull: Pull;
183186
184187 test("a pull gets a preview with a copy of production's data", async () => {
188 // Invariant: an opened pull deploys a preview seeded from production's data, and the preview's writes stay in it.
185189 const committed = await commitToBranch(app, alice, repo, {
186190 branch: "greeting",
187191 base: repo.defaultBranch,
@@ -234,6 +238,7 @@ describe("apps: production, previews, data, gates", () => {
234238 }, 90_000);
235239
236240 test("a push to the pull's branch moves the preview and keeps its data", async () => {
241 // Invariant: a push to the head branch redeploys the preview at the new commit on the data it already had.
237242 const before = listRuns(app.db, repo.id, {
238243 env: `pull-${pull.number}`,
239244 }).length;
@@ -261,6 +266,7 @@ describe("apps: production, previews, data, gates", () => {
261266 }, 90_000);
262267
263268 test("merging the pull removes the preview and redeploys production", async () => {
269 // Invariant: a merge redeploys production at the merge commit, and tears the preview down to a 404.
264270 const merged = await mergePull(
265271 app,
266272 alice,
@@ -280,7 +286,7 @@ describe("apps: production, previews, data, gates", () => {
280286 existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) &&
281287 Date.now() < deadline
282288 )
283 await Bun.sleep(50);
289 await Bun.sleep(10);
284290 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
285291 false,
286292 );
@@ -364,6 +370,7 @@ describe("apps: production, previews, data, gates", () => {
364370 });
365371
366372 test("archiving stops the app, keeps its data, and its URL says it is archived", async () => {
373 // Invariant: archiving stops production without losing its data, answers 503 on the security floor, and unarchiving wakes it.
367374 expect(runtime.envStatus(record, "production")?.state).toBe("running");
368375 const data = envDataDir(dataDir, record.id, "production");
369376 writeFileSync(join(data, "archive-marker"), "kept");
@@ -394,143 +401,275 @@ describe("apps: production, previews, data, gates", () => {
394401 expect(woke.ok).toBe(true);
395402 for (
396403 let i = 0;
397 i < 600 && runtime.envStatus(record, "production")?.state !== "running";
404 i < 6_000 && runtime.envStatus(record, "production")?.state !== "running";
398405 i++
399406 )
400 await Bun.sleep(100);
407 await Bun.sleep(10);
401408 expect(runtime.envStatus(record, "production")?.state).toBe("running");
402409 expect(readFileSync(join(data, "archive-marker"), "utf8")).toBe("kept");
403410 expect((await viaHost(`hello.${DOMAIN}`)).status).toBe(200);
404411 }, 120_000);
405412 });
406413
407 describe("a preview whose copied data the build cannot read starts fresh (issue #36)", () => {
408 let dataDir: string;
409 let app: App;
410 let servers: Servers;
411 let runtime: AppsRuntime;
412 let alice: User;
413 const DOMAIN = "retry.test";
414 // ---- shared by the stories below ----
414415
415 async function settled(
416 where: (run: Run) => boolean,
417 repoId: string,
418 timeoutMs = 30_000,
419 ): Promise<Run> {
420 const deadline = Date.now() + timeoutMs;
421 while (Date.now() < deadline) {
422 const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find(
423 where,
424 );
425 if (
426 run &&
427 (run.state === "ok" ||
428 run.state === "failed" ||
429 run.state === "cancelled")
416 /** A repository with an app, enabled, and its first production deploy settled. */
417 interface Deployed {
418 readonly repo: Repo;
419 readonly record: AppRecord;
420 readonly production: Run;
421 }
422
423 async function deployApp(
424 forge: AppsForge,
425 name: string,
426 files: { path: string; content: string }[],
427 ): Promise<Deployed> {
428 const made = await createRepo(forge.app, forge.alice, {
429 name,
430 visibility: "public",
431 });
432 if (!made.ok) throw new Error(String(made.error));
433 const repo = made.value;
434 const committed = await commitToBranch(forge.app, forge.alice, repo, {
435 message: `The ${name} app`,
436 files,
437 });
438 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
439 const enabled = enableApp(forge.app, forge.alice, repo, {});
440 if (!enabled.ok) throw new Error(String(enabled.error));
441 const production = await deploySettled(
442 forge.app,
443 repo.id,
444 (r) => r.env === "production",
445 );
446 return { repo, record: enabled.value, production };
447 }
448
449 /** A trivial change on the branch `note`, opened as a pull: a preview to deploy. */
450 async function openNote(forge: AppsForge, repo: Repo): Promise<Pull> {
451 const branch = await commitToBranch(forge.app, forge.alice, repo, {
452 branch: "note",
453 base: repo.defaultBranch,
454 message: "A trivial, unrelated change",
455 files: [{ path: "NOTE.md", content: "hi\n" }],
456 });
457 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
458 const opened = await openPull(forge.app, forge.alice, repo, {
459 title: "A note",
460 baseBranch: repo.defaultBranch,
461 headBranch: "note",
462 });
463 if (!opened.ok) throw new Error(String(opened.error));
464 return opened.value;
465 }
466
467 // ---- a restart brings every app's production up before any preview (issue #296) ----
468 // A restart brings production back first, and says where each deploy's
469 // time went: every app's production is reconciled before any preview, and
470 // the log names the wait for a turn, the manifest, the checkout, the data
471 // and the process's own start, each with its seconds.
472 // No package.json, so a checkout is never warm and a preview is never
473 // put back to sleep at boot: every environment deploys again.
474 const BOOT_FORGE_JSON = JSON.stringify({
475 app: { run: "sh run.sh", install: "", health: "/", ready: 10 },
476 });
477 const BOOT_RUN_SH = `exec bun -e "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch:()=>new Response('ok')})"\n`;
478 let boot: AppsForge;
479 /** The runtime serving `boot` now: the restart replaces it. */
480 let bootRuntime: AppsRuntime;
481 const bootRepos: Repo[] = [];
482 /** The deploys the restart made, settled. */
483 let restarted: Run[];
484
485 async function bootSettled(
486 where: (run: Run) => boolean,
487 count: number,
488 ): Promise<Run[]> {
489 const deadline = Date.now() + 60_000;
490 while (Date.now() < deadline) {
491 const runs = bootRepos
492 .flatMap((r) =>
493 listRuns(boot.app.db, r.id, { kind: "deploy", limit: 50 }),
430494 )
431 return run;
432 await Bun.sleep(50);
433 }
434 throw new Error("run did not settle in time");
495 .filter(where);
496 if (
497 runs.length >= count &&
498 runs.every((r) => r.state === "ok" || r.state === "failed")
499 )
500 return runs;
501 await Bun.sleep(10);
435502 }
503 throw new Error("runs did not settle in time");
504 }
505
506 /** Two apps, a preview on the one the boot visits first, all deployed; then the forge's runtime restarted on one permit. */
507 async function restartBoot(): Promise<void> {
508 // Before the restart, each environment only needs deploying once:
509 // four permits, side by side.
510 boot = await startAppsForge({ domain: "boot.test", concurrency: 4 });
511 bootRuntime = boot.runtime;
512 for (const name of ["first", "second"]) {
513 const made = await createRepo(boot.app, boot.alice, {
514 name,
515 visibility: "public",
516 });
517 if (!made.ok) throw new Error(String(made.error));
518 const repo = made.value;
519 bootRepos.push(repo);
520 const committed = await commitToBranch(boot.app, boot.alice, repo, {
521 message: "An app",
522 files: [
523 { path: "forge.json", content: BOOT_FORGE_JSON },
524 { path: "run.sh", content: BOOT_RUN_SH },
525 ],
526 });
527 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
528 const enabled = enableApp(boot.app, boot.alice, repo, {});
529 if (!enabled.ok) throw new Error(String(enabled.error));
530 }
531 // One preview is enough to tell the orders apart, so long as it
532 // belongs to the app the boot visits first: app by app, that
533 // preview would come up between the two productions.
534 const visitedFirst = listApps(boot.app.db)[0]!;
535 await openNote(
536 boot,
537 bootRepos.find((r) => r.id === visitedFirst.repoId)!,
538 );
539 await bootSettled(() => true, 3);
540 // The restart: one permit, so the order the gate hands them out is
541 // the order the environments come up, and that order is asserted.
542 await bootRuntime.stop();
543 bootRuntime = appsRuntime(boot.app, boot.servers, "boot.test", 1);
544 await bootRuntime.start();
545 restarted = await bootSettled((r) => r.reason === "boot", 3);
546 }
547
548 // The slowest fixture in the file and needed by one test only: it is built
549 // from the file's first moment, beside the chain of deploys above, and the
550 // stories below wait for it with their own forges (#10244).
551 let booted: Promise<void> | undefined;
552 beforeAll(() => {
553 booted = restartBoot();
554 booted.catch(() => {}); // awaited, and so reported, by the stories below
555 });
556 afterAll(async () => {
557 await booted?.catch(() => {});
558 await bootRuntime?.stop();
559 if (boot === undefined) return;
560 await boot.runtime.stop();
561 await stopAppsForge(boot);
562 });
563
564 /**
565 * Everything below stands alone: each story on a forge of its own, or on
566 * none, so they run side by side instead of one after another behind the
567 * chain above (#10244). Every forge is built once, all of them at once, and
568 * each story's own invariant sits at the top of its tests.
569 */
570 describe.concurrent("apps: the stories that stand alone, side by side", () => {
571 // ---- a preview whose copied data the build cannot read starts fresh (issue #36) ----
436572
437573 // Never checks in, so `install` has nothing to do: the deploy starts the
438574 // process directly, no `bun install` on the critical path.
439 const FORGE_JSON = JSON.stringify({
575 const RETRY_FORGE_JSON = JSON.stringify({
440576 app: { run: "sh run.sh", install: "", health: "/", ready: 5 },
441577 });
578 // The forge whose previews start again on empty data.
579 let reseed: AppsForge;
580 /** A build that refuses data from a schema it does not know. */
581 let schemaAhead: Deployed;
582 /** A build that never comes up. */
583 let alwaysFails: Deployed;
584
585 // ---- a start failure is said in the app's own terms, not the forge's paths (issue #202) ----
586 let leaky: AppsForge;
587
588 // ---- the App page's terminal line survives an email-scanning edge (issue #254) ----
589 let terminal: AppsForge;
590 let terminalCookie: string;
591
592 // ---- the forge's own restart never reads as this deploy's failure (issue #255) ----
593 let stopping: AppsForge;
442594
443595 beforeAll(async () => {
444 dataDir = await mkdtemp(join(tmpdir(), "forge-retry-"));
445 app = createApp({ dataDir, port: 0 });
446 servers = await serve(app);
447 runtime = new AppsRuntime(app, {
448 domain: DOMAIN,
449 scheme: "http",
450 publicPort: servers.port,
451 forgeUrl: servers.url,
452 concurrency: 2,
453 });
454 await runtime.start();
455 const created = await createUser(app.db, { origin: "seed", trusted: true,
456 username: "alice",
457 email: "[email protected]",
458 password: "a-good-passphrase",
459 adminIfFirst: true,
460 });
461 if (!created.ok) throw new Error(created.error);
462 alice = created.value;
463 }, 30_000);
464
465 afterAll(async () => {
466 await runtime.stop();
467 servers.stop();
468 app.close();
469 await rm(dataDir, { recursive: true, force: true });
470 });
471
472 test("a build too old for a migration on main comes up on the retry", async () => {
473 const made = await createRepo(app, alice, {
474 name: "schema-ahead",
475 visibility: "public",
476 });
477 expect(made.ok).toBe(true);
478 if (!made.ok) return;
479 const repo = made.value;
480 const committed = await commitToBranch(app, alice, repo, {
481 message: "Refuse data from a schema this build does not know",
482 files: [
483 { path: "forge.json", content: FORGE_JSON },
484 {
485 path: "run.sh",
486 content: `if [ -f "$FORGE_APP_DATA/schema-22" ]; then
596 await Promise.all([
597 (async () => {
598 reseed = await startAppsForge({ domain: "retry.test" });
599 [schemaAhead, alwaysFails] = await Promise.all([
600 deployApp(reseed, "schema-ahead", [
601 { path: "forge.json", content: RETRY_FORGE_JSON },
602 {
603 path: "run.sh",
604 content: `if [ -f "$FORGE_APP_DATA/schema-22" ]; then
487605 echo "database is at schema 22, this build only knows 21 - refusing to run" 1>&2
488606 exit 1
489607 fi
490608 exec bun -e "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch:()=>new Response('ok')})"
491609 `,
492 },
493 ],
494 });
495 expect(committed.ok).toBe(true);
496 const enabled = enableApp(app, alice, repo, {});
497 expect(enabled.ok).toBe(true);
498 if (!enabled.ok) return;
499 const record = enabled.value;
610 },
611 ]),
612 deployApp(reseed, "always-fails", [
613 { path: "forge.json", content: RETRY_FORGE_JSON },
614 { path: "run.sh", content: `echo "boom" 1>&2\nexit 1\n` },
615 ]),
616 ]);
617 })(),
618 (async () => {
619 leaky = await startAppsForge({ domain: "leaky.test" });
620 })(),
621 (async () => {
622 // Never started: the page's terminal line is under test, not a
623 // deploy, so `beacon` is never built and nothing waits on it.
624 terminal = await startAppsForge({ concurrency: 1, start: false });
625 terminal.app.config.sshPort = 2222;
626 terminal.app.config.sshHostKey =
627 "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
628 const made = await newApp(terminal.app, terminal.alice, {
629 name: "beacon",
630 description: "a light that stays on",
631 visibility: "public",
632 });
633 if (!made.ok) throw new Error(String(made.error));
634 terminalCookie = await signedInCookie(terminal.servers);
635 })(),
636 (async () => {
637 stopping = await startAppsForge({ domain: "stop-mid-deploy.test" });
638 })(),
639 booted,
640 ]);
641 }, 90_000);
500642
501 const production = await settled((r) => r.env === "production", repo.id);
643 afterAll(async () => {
644 for (const forge of [reseed, leaky, terminal, stopping]) {
645 if (forge === undefined) continue;
646 await forge.runtime.stop();
647 await stopAppsForge(forge);
648 }
649 });
650
651 test("a build too old for a migration on main comes up on the retry", async () => {
652 // Invariant: a preview whose copied data its build refuses starts again on empty data, and serves.
653 const { repo, record, production } = schemaAhead;
502654 expect(production.state).toBe("ok");
503655
504656 // A migration lands on `main` — simulated here the way the issue
505657 // describes it, a schema file production now has that an older build
506658 // does not know how to read.
507659 writeFileSync(
508 join(envDataDir(dataDir, record.id, "production"), "schema-22"),
660 join(envDataDir(reseed.dataDir, record.id, "production"), "schema-22"),
509661 "22",
510662 );
511663
512 const branch = await commitToBranch(app, alice, repo, {
513 branch: "note",
514 base: repo.defaultBranch,
515 message: "A trivial, unrelated change",
516 files: [{ path: "NOTE.md", content: "hi\n" }],
517 });
518 expect(branch.ok).toBe(true);
519 const opened = await openPull(app, alice, repo, {
520 title: "Unrelated change",
521 baseBranch: repo.defaultBranch,
522 headBranch: "note",
523 });
524 expect(opened.ok).toBe(true);
525 if (!opened.ok) return;
526
527 const run = await settled(
528 (r) => r.env === `pull-${opened.value.number}`,
664 const pull = await openNote(reseed, repo);
665 const run = await deploySettled(
666 reseed.app,
529667 repo.id,
668 (r) => r.env === `pull-${pull.number}`,
530669 );
531670 expect(run.state).toBe("ok");
532671
533 const { lines } = readRunLog(dataDir, run.id);
672 const { lines } = readRunLog(reseed.dataDir, run.id);
534673 const text = lines.join("\n");
535674 expect(text).toMatch(/data: (copied|cloned) from production/);
536675 expect(text).toContain("exited with code 1 before answering");
@@ -541,71 +680,37 @@ exec bun -e "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch
541680
542681 // The retry's data directory does not carry the discarded copy's file.
543682 const previewData = envDataDir(
544 dataDir,
683 reseed.dataDir,
545684 record.id,
546 previewEnv(opened.value.number),
685 previewEnv(pull.number),
547686 );
548687 expect(existsSync(join(previewData, "schema-22"))).toBe(false);
549688
550 const page = await fetch(`${servers.url}/`, {
551 headers: { host: `schema-ahead--${opened.value.number}.${DOMAIN}` },
689 const page = await fetch(`${reseed.servers.url}/`, {
690 headers: { host: `schema-ahead--${pull.number}.retry.test` },
552691 });
553692 expect(page.status).toBe(200);
554693 }, 60_000);
555694
556695 test("an app that always exits retries exactly once in a preview, and never in production", async () => {
557 const made = await createRepo(app, alice, {
558 name: "always-fails",
559 visibility: "public",
560 });
561 expect(made.ok).toBe(true);
562 if (!made.ok) return;
563 const repo = made.value;
564 const committed = await commitToBranch(app, alice, repo, {
565 message: "A run that never comes up",
566 files: [
567 { path: "forge.json", content: FORGE_JSON },
568 {
569 path: "run.sh",
570 content: `echo "boom" 1>&2\nexit 1\n`,
571 },
572 ],
573 });
574 expect(committed.ok).toBe(true);
575 const enabled = enableApp(app, alice, repo, {});
576 expect(enabled.ok).toBe(true);
577 if (!enabled.ok) return;
578
696 // Invariant: the empty-data retry happens once, only for a preview's copied data; production is never retried.
697 const { repo, production } = alwaysFails;
579698 // Production: seeded empty, never a copy, so it gets exactly one try.
580 const production = await settled((r) => r.env === "production", repo.id);
581699 expect(production.state).toBe("failed");
582 const prodLog = readRunLog(dataDir, production.id).lines;
700 const prodLog = readRunLog(reseed.dataDir, production.id).lines;
583701 expect(prodLog.filter((l) => l.startsWith("$ sh run.sh")).length).toBe(1);
584702 expect(prodLog.some((l) => l.includes("starting on empty data"))).toBe(
585703 false,
586704 );
587705
588 const branch = await commitToBranch(app, alice, repo, {
589 branch: "note",
590 base: repo.defaultBranch,
591 message: "A trivial change",
592 files: [{ path: "NOTE.md", content: "hi\n" }],
593 });
594 expect(branch.ok).toBe(true);
595 const opened = await openPull(app, alice, repo, {
596 title: "Trivial",
597 baseBranch: repo.defaultBranch,
598 headBranch: "note",
599 });
600 expect(opened.ok).toBe(true);
601 if (!opened.ok) return;
602
603 const run = await settled(
604 (r) => r.env === `pull-${opened.value.number}`,
706 const pull = await openNote(reseed, repo);
707 const run = await deploySettled(
708 reseed.app,
605709 repo.id,
710 (r) => r.env === `pull-${pull.number}`,
606711 );
607712 expect(run.state).toBe("failed");
608 const previewLog = readRunLog(dataDir, run.id).lines;
713 const previewLog = readRunLog(reseed.dataDir, run.id).lines;
609714 // Exactly one retry: the process is started twice, never more.
610715 expect(previewLog.filter((l) => l.startsWith("$ sh run.sh")).length).toBe(
611716 2,
@@ -621,117 +726,34 @@ exec bun -e "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch
621726 expect(run.summary ?? "").toContain("retried on empty data");
622727 expect(run.summary ?? "").toContain("exited with code 1 before answering");
623728 }, 60_000);
624 });
625
626 describe("a start failure is said in the app's own terms, not the forge's paths (issue #202)", () => {
627 let dataDir: string;
628 let app: App;
629 let servers: Servers;
630 let runtime: AppsRuntime;
631 let alice: User;
632 let cookie: string;
633 const DOMAIN = "leaky.test";
634
635 async function settled(
636 where: (run: Run) => boolean,
637 repoId: string,
638 timeoutMs = 30_000,
639 ): Promise<Run> {
640 const deadline = Date.now() + timeoutMs;
641 while (Date.now() < deadline) {
642 const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find(
643 where,
644 );
645 if (
646 run &&
647 (run.state === "ok" ||
648 run.state === "failed" ||
649 run.state === "cancelled")
650 )
651 return run;
652 await Bun.sleep(50);
653 }
654 throw new Error("run did not settle in time");
655 }
656
657 const FORGE_JSON = JSON.stringify({
658 app: { run: "bun run src/server.ts", install: "", health: "/", ready: 3 },
659 });
660
661 beforeAll(async () => {
662 dataDir = await mkdtemp(join(tmpdir(), "forge-leaky-"));
663 app = createApp({ dataDir, port: 0 });
664 servers = await serve(app);
665 runtime = new AppsRuntime(app, {
666 domain: DOMAIN,
667 scheme: "http",
668 publicPort: servers.port,
669 forgeUrl: servers.url,
670 concurrency: 2,
671 });
672 await runtime.start();
673 const created = await createUser(app.db, { origin: "seed", trusted: true,
674 username: "alice",
675 email: "[email protected]",
676 password: "a-good-passphrase",
677 adminIfFirst: true,
678 });
679 if (!created.ok) throw new Error(created.error);
680 alice = created.value;
681 cookie = (
682 (
683 await fetch(`${servers.url}/signin`, {
684 method: "POST",
685 body: new URLSearchParams({
686 username: "alice",
687 password: "a-good-passphrase",
688 }),
689 redirect: "manual",
690 })
691 ).headers.get("set-cookie") ?? ""
692 ).split(";")[0]!;
693 }, 30_000);
694
695 afterAll(async () => {
696 await runtime.stop();
697 servers.stop();
698 app.close();
699 await rm(dataDir, { recursive: true, force: true });
700 });
701729
702730 test("a deploy whose app throws on import names its own file, never the forge's data directory or the app's id", async () => {
703 const made = await createRepo(app, alice, {
704 name: "throws",
705 visibility: "public",
706 });
707 expect(made.ok).toBe(true);
708 if (!made.ok) return;
709 const repo = made.value;
710 const committed = await commitToBranch(app, alice, repo, {
711 message: "An app that throws on import",
712 files: [
713 { path: "forge.json", content: FORGE_JSON },
714 { path: "src/server.ts", content: `throw new Error("boom");\n` },
715 ],
716 });
717 expect(committed.ok).toBe(true);
718 const enabled = enableApp(app, alice, repo, {});
719 expect(enabled.ok).toBe(true);
720 if (!enabled.ok) return;
721 const record = enabled.value;
722
723 const run = await settled((r) => r.env === "production", repo.id);
731 // Invariant: a start failure's error, summary, log and page carry the app's own path, never the forge's (issue #202).
732 const { repo, record, production: run } = await deployApp(leaky, "throws", [
733 {
734 path: "forge.json",
735 content: JSON.stringify({
736 app: {
737 run: "bun run src/server.ts",
738 install: "",
739 health: "/",
740 ready: 3,
741 },
742 }),
743 },
744 { path: "src/server.ts", content: `throw new Error("boom");\n` },
745 ]);
724746 expect(run.state).toBe("failed");
725747
726748 const notForgePaths = (text: string): void => {
727 expect(text).not.toContain(dataDir);
749 expect(text).not.toContain(leaky.dataDir);
728750 expect(text).not.toContain(record.id);
729751 };
730752
731753 // The error the runtime stores — the same one a run's summary and the
732754 // studio's head card both draw from — is the thrown message (issue
733755 // #270), not a stack frame; it never leaks the forge's own paths either.
734 const status = runtime.envStatus(record, "production");
756 const status = leaky.runtime.envStatus(record, "production");
735757 expect(status?.error ?? "").toContain("boom");
736758 notForgePaths(status?.error ?? "");
737759 expect(run.summary ?? "").toContain("boom");
@@ -739,93 +761,35 @@ describe("a start failure is said in the app's own terms, not the forge's paths
739761
740762 // The full log still has the frame, path-stripped down to the app's
741763 // own file rather than the forge's data directory or the app's id.
742 const { lines } = readRunLog(dataDir, run.id);
764 const { lines } = readRunLog(leaky.dataDir, run.id);
743765 const log = lines.join("\n");
744766 expect(log).toContain("src/server.ts");
745767 notForgePaths(log);
746768
747769 // The studio's app page renders the same error in its head, for a
748770 // reader who never asked to see the forge's own filesystem.
749 const page = await fetch(`${servers.url}/${repo.ownerName}/${repo.name}`, {
750 headers: { cookie: `${cookie}; forge_mode=studio` },
751 });
771 const cookie = await signedInCookie(leaky.servers);
772 const page = await fetch(
773 `${leaky.servers.url}/${repo.ownerName}/${repo.name}`,
774 { headers: { cookie: `${cookie}; forge_mode=studio` } },
775 );
752776 expect(page.status).toBe(200);
753777 notForgePaths(await page.text());
754778 }, 60_000);
755 });
756
757
758 /**
759 * Issue #254: the terminal command has a `login@host` in it, which an
760 * email-scanning edge on the forge's own domain rewrites into a `mailto:`
761 * link — the one line a reader must copy character for character, turned
762 * into something that neither shows the login nor copies as text.
763 * `terminalLine` (`src/web/ui.ts`) now wraps it in the `<!--email_off-->`
764 * marker such scanners honour, which leaves the command itself — the thing
765 * a paste must carry — untouched.
766 */
767 describe("the App page's terminal line survives an email-scanning edge (issue #254)", () => {
768 let dataDir: string;
769 let app: App;
770 let servers: Servers;
771 let runtime: AppsRuntime;
772 let base: string;
773 let cookie: string;
774 const DOMAIN = "apps.test";
775
776 beforeAll(async () => {
777 dataDir = await mkdtemp(join(tmpdir(), "forge-app-terminal-edge-"));
778 app = createApp({ dataDir, port: 0 });
779 app.config.sshPort = 2222;
780 app.config.sshHostKey =
781 "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
782 servers = await serve(app);
783 base = servers.url;
784 runtime = new AppsRuntime(app, {
785 domain: DOMAIN,
786 scheme: "http",
787 publicPort: servers.port,
788 forgeUrl: servers.url,
789 concurrency: 1,
790 });
791 await runtime.start();
792 const created = await createUser(app.db, { origin: "seed", trusted: true,
793 username: "priya",
794 email: "[email protected]",
795 password: "a-good-passphrase",
796 adminIfFirst: true,
797 });
798 if (!created.ok) throw new Error(created.error);
799 const made = await newApp(app, created.value, {
800 name: "beacon",
801 description: "a light that stays on",
802 visibility: "public",
803 });
804 if (!made.ok) throw new Error(String(made.error));
805 cookie = (
806 (
807 await fetch(`${base}/signin`, {
808 method: "POST",
809 body: new URLSearchParams({
810 username: "priya",
811 password: "a-good-passphrase",
812 }),
813 redirect: "manual",
814 })
815 ).headers.get("set-cookie") ?? ""
816 ).split(";")[0]!;
817 });
818
819 afterAll(async () => {
820 await runtime.stop();
821 servers.stop();
822 app.close();
823 await rm(dataDir, { recursive: true, force: true });
824 });
825779
780 /**
781 * Issue #254: the terminal command has a `login@host` in it, which an
782 * email-scanning edge on the forge's own domain rewrites into a `mailto:`
783 * link — the one line a reader must copy character for character, turned
784 * into something that neither shows the login nor copies as text.
785 * `terminalLine` (`src/web/ui.ts`) now wraps it in the `<!--email_off-->`
786 * marker such scanners honour, which leaves the command itself — the thing
787 * a paste must carry — untouched.
788 */
826789 test("the command sits between email_off markers, and the visible text is still the exact line", async () => {
827 const res = await fetch(`${base}/priya/beacon/app`, {
828 headers: { cookie },
790 // Invariant: the App page's terminal command is wrapped in email_off markers and is otherwise the exact line.
791 const res = await fetch(`${terminal.servers.url}/alice/beacon/app`, {
792 headers: { cookie: terminalCookie },
829793 });
830794 expect(res.status).toBe(200);
831795 const html = await res.text();
@@ -840,46 +804,10 @@ describe("the App page's terminal line survives an email-scanning edge (issue #2
840804 expect(withoutMarkers).toContain(`<code class="mono break">${command}</code>`);
841805 expect(html).toContain("host key SHA256:abcdefghijklmnopqrstuvwxyz");
842806 });
843 });
844
845 describe("the forge's own restart never reads as this deploy's failure (issue #255)", () => {
846 let dataDir: string;
847 let app: App;
848 let servers: Servers;
849 let runtime: AppsRuntime;
850 let alice: User;
851 const DOMAIN = "stop-mid-deploy.test";
852
853 beforeAll(async () => {
854 dataDir = await mkdtemp(join(tmpdir(), "forge-stop-mid-deploy-"));
855 app = createApp({ dataDir, port: 0 });
856 servers = await serve(app);
857 runtime = new AppsRuntime(app, {
858 domain: DOMAIN,
859 scheme: "http",
860 publicPort: servers.port,
861 forgeUrl: servers.url,
862 concurrency: 2,
863 });
864 await runtime.start();
865 const created = await createUser(app.db, { origin: "seed", trusted: true,
866 username: "alice",
867 email: "[email protected]",
868 password: "a-good-passphrase",
869 adminIfFirst: true,
870 });
871 if (!created.ok) throw new Error(created.error);
872 alice = created.value;
873 }, 30_000);
874
875 afterAll(async () => {
876 await runtime.stop();
877 servers.stop();
878 app.close();
879 await rm(dataDir, { recursive: true, force: true });
880 });
881807
882808 test("an install still in flight when the runtime stops finishes cancelled, not failed", async () => {
809 // Invariant: a deploy the forge's own stop interrupts is cancelled with STOPPED_SUMMARY, worded as a restart, never failed (issue #255).
810 const { app, alice, dataDir, servers, runtime } = stopping;
883811 const made = await createRepo(app, alice, {
884812 name: "slow-install",
885813 visibility: "public",
@@ -932,7 +860,7 @@ describe("the forge's own restart never reads as this deploy's failure (issue #2
932860 runId = inFlight.id;
933861 break;
934862 }
935 await Bun.sleep(20);
863 await Bun.sleep(10);
936864 }
937865 expect(runId).toBeDefined();
938866
@@ -960,18 +888,7 @@ describe("the forge's own restart never reads as this deploy's failure (issue #2
960888
961889 // The studio's own word for it is the quiet one, never the word for a
962890 // broken deploy — and that is what the run's own page renders.
963 const cookie = (
964 (
965 await fetch(`${servers.url}/signin`, {
966 method: "POST",
967 body: new URLSearchParams({
968 username: "alice",
969 password: "a-good-passphrase",
970 }),
971 redirect: "manual",
972 })
973 ).headers.get("set-cookie") ?? ""
974 ).split(";")[0]!;
891 const cookie = await signedInCookie(servers);
975892 const page = await fetch(
976893 `${servers.url}/${repo.ownerName}/${repo.name}/runs/${runId}`,
977894 { headers: { cookie: `${cookie}; forge_mode=studio` } },
@@ -987,10 +904,9 @@ describe("the forge's own restart never reads as this deploy's failure (issue #2
987904 const status = runtime.envStatus(record, "production");
988905 expect(status?.error).toBeUndefined();
989906 }, 30_000);
990 });
991907
992 describe("a start that fails reports the message, not its last line (issue #270)", () => {
993 const manifest: AppManifest = {
908 // ---- a start that fails reports the message, not its last line (issue #270) ----
909 const crashManifest: AppManifest = {
994910 run: "",
995911 install: null,
996912 test: null,
@@ -1012,12 +928,13 @@ describe("a start that fails reports the message, not its last line (issue #270)
1012928 Bun v1.3.14 (Linux x64 baseline)`;
1013929
1014930 test("start: the crash's message wins, the frames and banner do not", async () => {
931 // Invariant: a failed start's error is the crash's message line, not its frames or the runtime's banner.
1015932 const dir = await mkdtemp(join(tmpdir(), "forge-crash-"));
1016933 try {
1017934 const started = await startProcess({
1018935 dir,
1019936 dataDir: dir,
1020 manifest: { ...manifest, run: `printf '%s' '${CRASH}' 1>&2; exit 1` },
937 manifest: { ...crashManifest, run: `printf '%s' '${CRASH}' 1>&2; exit 1` },
1021938 env: "production",
1022939 url: "http://app.test",
1023940 commitOid: "0".repeat(40),
@@ -1104,16 +1021,8 @@ Bun v1.3.14 (Linux x64 baseline)`;
11041021 await rm(dir, { recursive: true, force: true });
11051022 }
11061023 }, 20_000);
1107 });
11081024
1109 // ---- the boot (issue #296) ----
1110 /**
1111 * A restart brings production back first, and says where each deploy's
1112 * time went (issue #296): every app's production is reconciled before any
1113 * preview, and the log names the wait for a turn, the manifest, the
1114 * checkout, the data and the process's own start, each with its seconds.
1115 */
1116 describe("the stopwatch a deploy's log reads from", () => {
1025 // ---- the stopwatch a deploy's log reads from (issue #296) ----
11171026 test("each lap is the time since the one before, in order, on one line", () => {
11181027 let now = 1_000;
11191028 const clock = new Stopwatch(() => now);
@@ -1133,125 +1042,17 @@ describe("the stopwatch a deploy's log reads from", () => {
11331042 expect(seconds(-5)).toBe("0.0s");
11341043 expect(seconds(1_250)).toBe("1.3s");
11351044 });
1136 });
11371045
1138 describe("a restart brings every app's production up before any preview", () => {
1139 let dataDir: string;
1140 let app: App;
1141 let servers: Servers;
1142 let runtime: AppsRuntime;
1143 let alice: User;
1144 const DOMAIN = "boot.test";
1145 const repos: Repo[] = [];
1146 const previews: string[] = [];
1147
1148 // No package.json, so a checkout is never warm and a preview is never
1149 // put back to sleep at boot: every environment deploys again.
1150 const FORGE_JSON = JSON.stringify({
1151 app: { run: "sh run.sh", install: "", health: "/", ready: 10 },
1152 });
1153 const RUN_SH = `exec bun -e "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch:()=>new Response('ok')})"\n`;
1154
1155 const runtimeFor = (): AppsRuntime =>
1156 new AppsRuntime(app, {
1157 domain: DOMAIN,
1158 scheme: "http",
1159 publicPort: servers.port,
1160 forgeUrl: servers.url,
1161 // One permit: the order the gate hands them out is the order the
1162 // environments come up, and that order is the assertion.
1163 concurrency: 1,
1164 });
1165
1166 async function settledAll(
1167 where: (run: Run) => boolean,
1168 count: number,
1169 ): Promise<Run[]> {
1170 const deadline = Date.now() + 60_000;
1171 while (Date.now() < deadline) {
1172 const runs = repos
1173 .flatMap((r) => listRuns(app.db, r.id, { kind: "deploy", limit: 50 }))
1174 .filter(where);
1175 if (
1176 runs.length >= count &&
1177 runs.every((r) => r.state === "ok" || r.state === "failed")
1178 )
1179 return runs;
1180 await Bun.sleep(50);
1181 }
1182 throw new Error("runs did not settle in time");
1183 }
1184
1185 beforeAll(async () => {
1186 dataDir = await mkdtemp(join(tmpdir(), "forge-boot-"));
1187 app = createApp({ dataDir, port: 0 });
1188 servers = await serve(app);
1189 runtime = runtimeFor();
1190 await runtime.start();
1191 const created = await createUser(app.db, { origin: "seed", trusted: true,
1192 username: "alice",
1193 email: "[email protected]",
1194 password: "a-good-passphrase",
1195 adminIfFirst: true,
1196 });
1197 if (!created.ok) throw new Error(created.error);
1198 alice = created.value;
1199 for (const name of ["first", "second"]) {
1200 const made = await createRepo(app, alice, {
1201 name,
1202 visibility: "public",
1203 });
1204 if (!made.ok) throw new Error(String(made.error));
1205 const repo = made.value;
1206 repos.push(repo);
1207 const committed = await commitToBranch(app, alice, repo, {
1208 message: "An app",
1209 files: [
1210 { path: "forge.json", content: FORGE_JSON },
1211 { path: "run.sh", content: RUN_SH },
1212 ],
1213 });
1214 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
1215 const enabled = enableApp(app, alice, repo, {});
1216 if (!enabled.ok) throw new Error(String(enabled.error));
1217 const branch = await commitToBranch(app, alice, repo, {
1218 branch: "note",
1219 base: repo.defaultBranch,
1220 message: "A note",
1221 files: [{ path: "NOTE.md", content: "hi\n" }],
1222 });
1223 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
1224 const opened = await openPull(app, alice, repo, {
1225 title: "A note",
1226 baseBranch: repo.defaultBranch,
1227 headBranch: "note",
1228 });
1229 if (!opened.ok) throw new Error(String(opened.error));
1230 previews.push(`pull-${opened.value.number}`);
1231 }
1232 await settledAll(() => true, 4);
1233 }, 90_000);
1234
1235 afterAll(async () => {
1236 await runtime.stop();
1237 servers.stop();
1238 app.close();
1239 await rm(dataDir, { recursive: true, force: true });
1240 });
1241
1242 test("both productions answer before either preview, and the log says where the time went", async () => {
1243 await runtime.stop();
1244 runtime = runtimeFor();
1245 await runtime.start();
1246 const boot = await settledAll((r) => r.reason === "boot", 4);
1247 expect(boot.every((r) => r.state === "ok")).toBe(true);
1248 const order = [...boot]
1046 test("both productions answer before the preview, and the log says where the time went", async () => {
1047 // Invariant: at boot every app's production is reconciled before any preview, and the log times each stage (issue #296).
1048 expect(restarted.every((r) => r.state === "ok")).toBe(true);
1049 const order = [...restarted]
12491050 .sort((a, b) => Date.parse(a.servingAt!) - Date.parse(b.servingAt!))
12501051 .map((r) => (r.env === "production" ? "production" : "preview"));
1251 expect(order).toEqual(["production", "production", "preview", "preview"]);
1052 expect(order).toEqual(["production", "production", "preview"]);
12521053
1253 const production = boot.find((r) => r.env === "production")!;
1254 const text = readRunLog(dataDir, production.id).lines.join("\n");
1054 const production = restarted.find((r) => r.env === "production")!;
1055 const text = readRunLog(boot.dataDir, production.id).lines.join("\n");
12551056 expect(text).toMatch(
12561057 /timing: starting the process \d+\.\ds after the deploy started — turn \d+\.\ds · manifest \d+\.\ds · checkout \d+\.\ds · data \d+\.\ds · setup \d+\.\ds/,
12571058 );
@@ -1259,10 +1060,9 @@ describe("a restart brings every app's production up before any preview", () =>
12591060 /timing: answered \d+\.\ds after the process was started/,
12601061 );
12611062 }, 90_000);
1262 });
12631063
1264 describe("start: a port lost to another process", () => {
1265 const manifest: AppManifest = {
1064 // ---- start: a port lost to another process ----
1065 const portManifest: AppManifest = {
12661066 run: `bun -e 'Bun.serve({ port: Number(process.env.PORT), hostname: "127.0.0.1", fetch: () => new Response("ok") })'`,
12671067 install: null,
12681068 test: null,
@@ -1289,7 +1089,7 @@ describe("start: a port lost to another process", () => {
12891089 const started = await startProcess({
12901090 dir,
12911091 dataDir: dir,
1292 manifest: { ...manifest, ...over },
1092 manifest: { ...portManifest, ...over },
12931093 env: "production",
12941094 url: "http://app.test",
12951095 commitOid: "0".repeat(40),
@@ -1303,6 +1103,7 @@ describe("start: a port lost to another process", () => {
13031103 };
13041104
13051105 test("an app that finds its port taken is started again on a fresh one", async () => {
1106 // Invariant: a start that loses its port to another process is started again on a fresh port.
13061107 const dir = await mkdtemp(join(tmpdir(), "forge-port-race-"));
13071108 const held = hold();
13081109 const picks: number[] = [];
@@ -1329,6 +1130,7 @@ describe("start: a port lost to another process", () => {
13291130 }, 30_000);
13301131
13311132 test("retries stop after PORT_RETRIES when every port is held", async () => {
1133 // Invariant: a start is tried at most PORT_RETRIES more times, however many ports are taken.
13321134 const dir = await mkdtemp(join(tmpdir(), "forge-port-race-"));
13331135 const holders = Array.from({ length: PORT_RETRIES + 1 }, hold);
13341136 const picks: number[] = [];
@@ -1353,6 +1155,7 @@ describe("start: a port lost to another process", () => {
13531155 }, 60_000);
13541156
13551157 test("an app that exits for another reason is not started again", async () => {
1158 // Invariant: only a lost port earns another start; any other exit fails at once.
13561159 const dir = await mkdtemp(join(tmpdir(), "forge-port-race-"));
13571160 let picks = 0;
13581161 try {
test/helpers/apps-forge.ts +57 −12
@@ -30,31 +30,72 @@ export interface AppsForge {
3030 readonly alice: User;
3131 }
3232
33 export async function startAppsForge(): Promise<AppsForge> {
33 export interface AppsForgeOptions {
34 /** The apps' domain: APPS_DOMAIN unless the test needs its own. */
35 readonly domain?: string;
36 /** How many deploys run at once: 2 unless the test needs another count. */
37 readonly concurrency?: number;
38 /** False for a test of the forge's pages alone: nothing deploys. */
39 readonly start?: boolean;
40 }
41
42 /** alice's passphrase, on every forge `startAppsForge` builds. */
43 export const PASSPHRASE = "a-good-passphrase";
44
45 export async function startAppsForge(
46 options: AppsForgeOptions = {},
47 ): Promise<AppsForge> {
3448 const dataDir = await mkdtemp(join(tmpdir(), "forge-apps-"));
3549 const work = await mkdtemp(join(tmpdir(), "forge-apps-work-"));
3650 const app = createApp({ dataDir, port: 0 });
3751 const servers = await serve(app);
3852 // Every app the runtime boots is handed this key; the tests sign as
3953 // the forge would.
40 const runtime = new AppsRuntime(app, {
41 domain: APPS_DOMAIN,
42 scheme: "http",
43 publicPort: servers.port,
44 forgeUrl: servers.url,
45 concurrency: 2,
46 });
47 await runtime.start();
54 const runtime = appsRuntime(
55 app,
56 servers,
57 options.domain ?? APPS_DOMAIN,
58 options.concurrency ?? 2,
59 );
60 if (options.start !== false) await runtime.start();
4861 const created = await createUser(app.db, { origin: "seed", trusted: true,
4962 username: "alice",
5063 email: "[email protected]",
51 password: "a-good-passphrase",
64 password: PASSPHRASE,
5265 adminIfFirst: true,
5366 });
5467 if (!created.ok) throw new Error(created.error);
5568 return { dataDir, work, app, servers, runtime, alice: created.value };
5669 }
5770
71 /** An apps runtime for this forge, not yet started: a test that restarts one builds the next. */
72 export const appsRuntime = (
73 app: App,
74 servers: Servers,
75 domain: string,
76 concurrency: number,
77 ): AppsRuntime =>
78 new AppsRuntime(app, {
79 domain,
80 scheme: "http",
81 publicPort: servers.port,
82 forgeUrl: servers.url,
83 concurrency,
84 });
85
86 /** The session cookie (`name=value`) of `username` signed in at the forge's own form. */
87 export async function signedInCookie(
88 servers: Servers,
89 username = "alice",
90 ): Promise<string> {
91 const response = await fetch(`${servers.url}/signin`, {
92 method: "POST",
93 body: new URLSearchParams({ username, password: PASSPHRASE }),
94 redirect: "manual",
95 });
96 return (response.headers.get("set-cookie") ?? "").split(";")[0]!;
97 }
98
5899 /**
59100 * Everything but the runtime, which a test may have stopped and replaced:
60101 * the caller stops the one it holds first.
@@ -79,7 +120,11 @@ export const fetchAsHost = (
79120 redirect: "manual",
80121 });
81122
82 /** Wait for the newest deploy on `repoId` matching `where` to finish, and return it. */
123 /**
124 * Wait for the newest deploy on `repoId` matching `where` to finish, and
125 * return it. Polls every 10 ms: a read of a few rows, so a settled run is
126 * seen at once rather than up to a tenth of a second late on every deploy.
127 */
83128 export async function deploySettled(
84129 app: App,
85130 repoId: string,
@@ -98,7 +143,7 @@ export async function deploySettled(
98143 run.state === "cancelled")
99144 )
100145 return run;
101 await Bun.sleep(100);
146 await Bun.sleep(10);
102147 }
103148 throw new Error("run did not settle in time");
104149 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs