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

test/studio-door.test.ts runs under 10 s #3573

Merged opened this

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

Merged

mason/forge:mason/3566-test-studio-door-test-ts-runs-under-10-s → main 1 commit fork

  • Merged. mason/3566-test-studio-door-test-ts-runs-under-10-s was merged into main as 5a17fb0.

Review

Sign in to review this change.
2 files changed +193 −410
scripts/test-times.json +1 −1
@@ -53,7 +53,7 @@
5353 "test/standing.test.ts": 2.8,
5454 "test/steer.test.ts": 16.2,
5555 "test/studio-changes.test.ts": 31.8,
56 "test/studio-door.test.ts": 34.2,
56 "test/studio-door.test.ts": 2.1,
5757 "test/studio-runs.test.ts": 29.3,
5858 "test/studio-search.test.ts": 30.7,
5959 "test/studio-shipping.test.ts": 35.6,
test/studio-door.test.ts +192 −409
@@ -3,11 +3,15 @@
33 * current, the head, the terminal and the live fragment. One of the files
44 * the studio's suite was split into so no one file sets the gate's floor
55 * (#2038); the rest are test/studio*.test.ts.
6 *
7 * One forge for the whole file (#3566): each block below owns its own
8 * people and repositories on it, and one runtime — registered, never
9 * started — holds the environments a block puts where a deploy would
10 * have, so nothing here waits on an install, a health check or a clock.
611 */
712
813 import {
914 afterAll,
10 afterEach,
1115 beforeAll,
1216 beforeEach,
1317 describe,
@@ -20,7 +24,7 @@ import { join } from "node:path";
2024 import { createApp, type App } from "../src/app.ts";
2125 import { enableApp } from "../src/actions/apps.ts";
2226 import { previewEnv, type EnvName } from "../src/apps/data.ts";
23 import { AppsRuntime, type EnvState } from "../src/apps/runtime.ts";
27 import { AppsRuntime } from "../src/apps/runtime.ts";
2428 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
2529 import { addToCrew, createAgent } from "../src/actions/crew.ts";
2630 import { createOrg } from "../src/actions/orgs.ts";
@@ -40,46 +44,115 @@ import {
4044 createRun,
4145 finishRun,
4246 getRun,
43 isTerminal,
44 listRuns,
4547 markServing,
4648 } from "../src/core/runs.ts";
4749 import { liveClientJs } from "../src/web/live.ts";
4850 import { createUser, getUserByName, type User } from "../src/core/users.ts";
4951 import { serve, type Servers } from "../src/http/server.ts";
50 import { serving } from "./helpers/studio.ts";
5152 import { promote } from "./helpers/members.ts";
5253
53 describe("an owner's page lists an app once, not once in each vocabulary (#221)", () => {
54 let dataDir: string;
55 let app: App;
56 let servers: Servers;
57 let base: string;
54 let dataDir: string;
55 let app: App;
56 let servers: Servers;
57 let base: string;
58 let runtime: AppsRuntime;
5859
59 beforeAll(async () => {
60 dataDir = await mkdtemp(join(tmpdir(), "forge-owner-once-"));
61 app = createApp({ dataDir, port: 0 });
62 servers = await serve(app);
63 base = servers.url;
60 beforeAll(async () => {
61 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-door-"));
62 // The terminal's block reads its command off this host.
63 app = createApp({ dataDir, port: 0, host: "forge.example" });
64 servers = await serve(app);
65 base = servers.url;
66 runtime = new AppsRuntime(app, {
67 domain: "apps.test",
68 scheme: "http",
69 publicPort: servers.port,
70 forgeUrl: servers.url,
71 concurrency: 1,
6472 });
73 // The forge's first account, and so its admin; bob and carol are readers
74 // and strangers to alice's apps.
75 await person("alice", true);
76 await person("bob");
77 await person("carol");
78 });
6579
66 afterAll(async () => {
67 servers.stop();
68 app.close();
69 await rm(dataDir, { recursive: true, force: true });
80 afterAll(async () => {
81 // The slots stand in for deploys; there is no process behind them to stop.
82 slots().clear();
83 await runtime.stop();
84 servers.stop();
85 app.close();
86 await rm(dataDir, { recursive: true, force: true });
87 });
88
89 const PASSWORD = "a-good-passphrase";
90
91 /** A person on the shared forge, promoted past the newcomer's limits. */
92 async function person(username: string, adminIfFirst = false): Promise<User> {
93 const made = await createUser(app.db, { origin: "seed", trusted: true,
94 username,
95 email: `${username}@forge.local`,
96 password: PASSWORD,
97 adminIfFirst,
7098 });
99 if (!made.ok) throw new Error(made.error);
100 promote(app.db, made.value);
101 return made.value;
102 }
71103
72 const signIn = async (username: string, password: string) =>
104 /** The session cookie `username` signs in with. */
105 const signIn = async (username: string, password = PASSWORD): Promise<string> =>
106 (
73107 (
74 (
75 await fetch(`${base}/signin`, {
76 method: "POST",
77 body: new URLSearchParams({ username, password }),
78 redirect: "manual",
79 })
80 ).headers.get("set-cookie") ?? ""
81 ).split(";")[0]!;
108 await fetch(`${base}/signin`, {
109 method: "POST",
110 body: new URLSearchParams({ username, password }),
111 redirect: "manual",
112 })
113 ).headers.get("set-cookie") ?? ""
114 ).split(";")[0]!;
82115
116 /**
117 * An environment as the runtime holds it, without running a deploy: the
118 * pages read `runtime.status(record)`, and a real one would mean a real
119 * install and a real health check for a rendering test.
120 */
121 interface Slot {
122 readonly app: AppRecord;
123 readonly env: EnvName;
124 readonly chain: Promise<void>;
125 readonly restarts: number[];
126 readonly deploying?: unknown;
127 readonly live?: unknown;
128 readonly lastError?: string;
129 readonly lastRunId?: string;
130 readonly lastErrorCommitOid?: string;
131 }
132 const slots = (): Map<string, Slot> =>
133 (runtime as unknown as { envs: Map<string, Slot> }).envs;
134
135 const putEnv = (
136 record: AppRecord,
137 env: EnvName,
138 slot: Omit<Slot, "app" | "env" | "chain" | "restarts">,
139 ): void => {
140 slots().set(`${record.id}/${env}`, {
141 app: record,
142 env,
143 chain: Promise.resolve(),
144 restarts: [],
145 ...slot,
146 });
147 };
148
149 /** Take every environment of `record` away, as if none had ever deployed. */
150 const clearEnvs = (record: AppRecord): void => {
151 for (const key of [...slots().keys()])
152 if (key.startsWith(`${record.id}/`)) slots().delete(key);
153 };
154
155 describe("an owner's page lists an app once, not once in each vocabulary (#221)", () => {
83156 const occurrences = (haystack: string, needle: string): number =>
84157 haystack.split(needle).length - 1;
85158
@@ -105,8 +178,9 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)"
105178 files: [{ path: "README.md", content: "hi\n" }],
106179 });
107180 if (!appSeeded.ok) throw new Error(JSON.stringify(appSeeded.error));
181 // Not "beacon": alice's app in the terminal's block holds that slug.
108182 const enabled = await enableApp(app, dan.value, madeApp.value, {
109 slug: "beacon",
183 slug: "dan-beacon",
110184 });
111185 if (!enabled.ok) throw new Error(String(enabled.error));
112186
@@ -320,13 +394,9 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)"
320394 });
321395
322396 describe("every open pull's preview on the studio page", () => {
323 let dataDir: string;
324 let app: App;
325 let servers: Servers;
326 let runtime: AppsRuntime;
327 let base: string;
328397 let cookie: string;
329398 let repo: Repo;
399 let record: AppRecord;
330400
331401 const get = (path: string) =>
332402 fetch(`${base}${path}`, {
@@ -334,97 +404,26 @@ describe("every open pull's preview on the studio page", () => {
334404 redirect: "manual",
335405 });
336406
337 const WAIT_MS = 60_000;
338
339 /** The deploys this repo has not finished, as "env:state" words. */
340 const unfinished = () =>
341 listRuns(app.db, repo.id, { kind: "deploy", limit: 50 })
342 .filter((r) => !isTerminal(r.state))
343 .map((r) => `${r.env}:${r.state}`);
344
345 /** Wait for `env` to serve; fail naming the state it reached. */
346 async function previewServes(record: AppRecord, env: EnvName): Promise<void> {
347 const deadline = Date.now() + WAIT_MS;
348 while (runtime.envStatus(record, env)?.state !== "running") {
349 if (Date.now() > deadline) {
350 const status = runtime.envStatus(record, env);
351 throw new Error(
352 `${env} did not serve within ${WAIT_MS} ms: state ${status?.state ?? "absent"}` +
353 (status?.error ? ` (${status.error})` : "") +
354 `; open deploys: ${unfinished().join(", ") || "none"}`,
355 );
356 }
357 await Bun.sleep(50);
358 }
359 }
360
361 // A fresh app for each test: one test's open pull would otherwise be the
362 // next's preview, and its deploys queue ahead of the next's (concurrency 1).
363 beforeEach(async () => {
364 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-previews-"));
365 app = createApp({ dataDir, port: 0 });
366 servers = await serve(app);
367 base = servers.url;
368 runtime = new AppsRuntime(app, {
369 domain: "studio-previews.test",
370 scheme: "http",
371 publicPort: servers.port,
372 forgeUrl: servers.url,
373 concurrency: 1,
374 });
375 await runtime.start();
376 const a = await createUser(app.db, { origin: "seed", trusted: true,
377 username: "alice",
378 email: "[email protected]",
379 password: "a-good-passphrase",
380 adminIfFirst: true,
381 });
382 if (!a.ok) throw new Error(a.error);
383 promote(app.db, a.value);
384 cookie = (
385 (
386 await fetch(`${base}/signin`, {
387 method: "POST",
388 body: new URLSearchParams({
389 username: "alice",
390 password: "a-good-passphrase",
391 }),
392 redirect: "manual",
393 })
394 ).headers.get("set-cookie") ?? ""
395 ).split(";")[0]!;
396 const made = await createRepo(app, a.value, {
407 // The tests take turns on one repository: the first leaves no pull open,
408 // which is the second's start.
409 beforeAll(async () => {
410 const alice = getUserByName(app.db, "alice")!;
411 cookie = await signIn("alice");
412 const made = await createRepo(app, alice, {
397413 name: "widget",
398414 visibility: "public",
399415 description: "",
400416 });
401417 if (!made.ok) throw new Error(String(made.error));
402418 repo = made.value;
403 const seeded = await commitToBranch(app, a.value, repo, {
419 const seeded = await commitToBranch(app, alice, repo, {
404420 message: "Start",
405 files: [
406 {
407 path: "package.json",
408 content:
409 '{"name":"widget","scripts":{"start":"bun run server.ts"}}\n',
410 },
411 {
412 path: "server.ts",
413 content:
414 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
415 },
416 ],
421 files: [{ path: "README.md", content: "w\n" }],
417422 });
418423 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
419 const enabled = await enableApp(app, a.value, repo, { slug: "widget" });
424 const enabled = await enableApp(app, alice, repo, { slug: "widget" });
420425 if (!enabled.ok) throw new Error(String(enabled.error));
421 }, 30_000);
422
423 afterEach(async () => {
424 await runtime.stop();
425 servers.stop();
426 app.close();
427 await rm(dataDir, { recursive: true, force: true });
426 record = getAppByRepo(app.db, repo.id)!;
428427 });
429428
430429 test("an open pull's preview host and open link render; none do once nothing is open", async () => {
@@ -458,7 +457,7 @@ describe("every open pull's preview on the studio page", () => {
458457 const after = await (await get("/alice/widget")).text();
459458 expect(after).not.toContain("<h2>Previews</h2>");
460459 expect(after).not.toContain(`pull-${pull.number}/open`);
461 }, 120_000);
460 });
462461
463462 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 () => {
464463 const alice = getUserByName(app.db, "alice")!;
@@ -477,11 +476,21 @@ describe("every open pull's preview on the studio page", () => {
477476 });
478477 if (!opened.ok) throw new Error(String(opened.error));
479478 const pull = opened.value;
480 const record = getAppByRepo(app.db, repo.id)!;
481 const env = `pull-${pull.number}` as const;
479 const env = previewEnv(pull.number);
482480
483 await previewServes(record, env);
484 expect(runtime.envStatus(record, env)?.state).toBe("running");
481 // Production and the preview both serving, as a deploy leaves them;
482 // test/apps.test.ts proves a real preview gets its copy of the data.
483 const serving = (commitOid: string) => ({
484 live: {
485 commitOid,
486 handle: { port: 4321 },
487 runId: "fake",
488 since: new Date().toISOString(),
489 manifest: { tty: null },
490 },
491 });
492 putEnv(record, "production", serving("0".repeat(40)));
493 putEnv(record, env, serving(pull.headOid));
485494
486495 const note = "its own copy of production's data, taken";
487496
@@ -496,7 +505,7 @@ describe("every open pull's preview on the studio page", () => {
496505 ).text();
497506 expect(changePage).toContain("<h2>Preview</h2>");
498507 expect(changePage).toContain(note);
499 }, 120_000);
508 });
500509 });
501510
502511 /**
@@ -506,10 +515,6 @@ describe("every open pull's preview on the studio page", () => {
506515 * in place by `liveRegionsJs` (`src/web/client.ts`).
507516 */
508517 describe("the studio home keeps itself current", () => {
509 let dataDir: string;
510 let app: App;
511 let servers: Servers;
512 let base: string;
513518 let alice: User;
514519 let repo: Repo;
515520 let cookie: string;
@@ -525,31 +530,8 @@ describe("the studio home keeps itself current", () => {
525530 });
526531
527532 beforeAll(async () => {
528 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-live-home-"));
529 app = createApp({ dataDir, port: 0 });
530 servers = await serve(app);
531 base = servers.url;
532 const a = await createUser(app.db, { origin: "seed", trusted: true,
533 username: "alice",
534 email: "[email protected]",
535 password: "a-good-passphrase",
536 adminIfFirst: true,
537 });
538 if (!a.ok) throw new Error(a.error);
539 promote(app.db, a.value);
540 alice = a.value;
541 cookie = (
542 (
543 await fetch(`${base}/signin`, {
544 method: "POST",
545 body: new URLSearchParams({
546 username: "alice",
547 password: "a-good-passphrase",
548 }),
549 redirect: "manual",
550 })
551 ).headers.get("set-cookie") ?? ""
552 ).split(";")[0]!;
533 alice = getUserByName(app.db, "alice")!;
534 cookie = await signIn("alice");
553535 const made = await createRepo(app, alice, {
554536 name: "signals",
555537 visibility: "private",
@@ -600,12 +582,6 @@ describe("the studio home keeps itself current", () => {
600582 expect(assignIssue(app, alice, repo, readme, alice).ok).toBe(true);
601583 });
602584
603 afterAll(async () => {
604 servers.stop();
605 app.close();
606 await rm(dataDir, { recursive: true, force: true });
607 });
608
609585 test("Your apps is a live region and the home has no On you, with the security floor's headers", async () => {
610586 const res = await get();
611587 expect(res.status).toBe(200);
@@ -689,13 +665,8 @@ describe("the studio home keeps itself current", () => {
689665 * shows — why, the deploy that says the rest, and the way to try again.
690666 */
691667 describe("the studio head says why the app is not up", () => {
692 let dataDir: string;
693 let app: App;
694 let servers: Servers;
695 let base: string;
696668 let repo: Repo;
697669 let record: AppRecord;
698 let runtime: AppsRuntime;
699670 let deploy: string;
700671 let writer: string;
701672 let reader: string;
@@ -722,93 +693,27 @@ describe("the studio head says why the app is not up", () => {
722693 return row.match(/<span class="badge"[^>]*>([\s\S]*?)<\/span>/)?.[1] ?? "";
723694 };
724695
725 const signIn = async (username: string): Promise<string> =>
726 (
727 (
728 await fetch(`${base}/signin`, {
729 method: "POST",
730 body: new URLSearchParams({
731 username,
732 password: "a-good-passphrase",
733 }),
734 redirect: "manual",
735 })
736 ).headers.get("set-cookie") ?? ""
737 ).split(";")[0]!;
738
739 /**
740 * An environment as the runtime holds it, without running a deploy: the
741 * page reads `runtime.status(record)`, and a real failure would mean a
742 * real install and a real health timeout for a rendering test.
743 */
744 interface Slot {
745 readonly app: AppRecord;
746 readonly env: EnvName;
747 readonly chain: Promise<void>;
748 readonly restarts: number[];
749 readonly live?: unknown;
750 readonly deploying?: unknown;
751 readonly lastError?: string;
752 readonly lastRunId?: string;
753 readonly lastErrorCommitOid?: string;
754 }
755 const put = (
756 slot: Omit<Slot, "app" | "env" | "chain" | "restarts">,
757 ): void => {
758 (runtime as unknown as { envs: Map<string, Slot> }).envs.set(
759 `${record.id}/production`,
760 {
761 app: record,
762 env: "production",
763 chain: Promise.resolve(),
764 restarts: [],
765 ...slot,
766 },
767 );
768 };
696 /** Production as the runtime holds it, in place of a real deploy. */
697 const put = (slot: Omit<Slot, "app" | "env" | "chain" | "restarts">): void =>
698 putEnv(record, "production", slot);
769699
770700 beforeAll(async () => {
771 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-notup-"));
772 app = createApp({ dataDir, port: 0 });
773 servers = await serve(app);
774 base = servers.url;
775 runtime = new AppsRuntime(app, {
776 domain: "apps.test",
777 scheme: "http",
778 publicPort: servers.port,
779 forgeUrl: servers.url,
780 concurrency: 1,
781 });
782 const a = await createUser(app.db, { origin: "seed", trusted: true,
783 username: "alice",
784 email: "[email protected]",
785 password: "a-good-passphrase",
786 adminIfFirst: true,
787 });
788 if (!a.ok) throw new Error(a.error);
789 promote(app.db, a.value);
790 const b = await createUser(app.db, { origin: "seed", trusted: true,
791 username: "bob",
792 email: "[email protected]",
793 password: "a-good-passphrase",
794 });
795 if (!b.ok) throw new Error(b.error);
796 promote(app.db, b.value);
701 const alice = getUserByName(app.db, "alice")!;
797702 writer = await signIn("alice");
798703 reader = await signIn("bob");
799 const made = await createRepo(app, a.value, {
704 const made = await createRepo(app, alice, {
800705 name: "notes",
801706 visibility: "public",
802707 description: "a place for notes",
803708 });
804709 if (!made.ok) throw new Error(String(made.error));
805710 repo = made.value;
806 const seeded = await commitToBranch(app, a.value, repo, {
711 const seeded = await commitToBranch(app, alice, repo, {
807712 message: "Start",
808713 files: [{ path: "README.md", content: "notes\n" }],
809714 });
810715 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
811 const enabled = await enableApp(app, a.value, repo, { slug: "notes" });
716 const enabled = await enableApp(app, alice, repo, { slug: "notes" });
812717 if (!enabled.ok) throw new Error(String(enabled.error));
813718 record = getAppByRepo(app.db, repo.id)!;
814719 const run = createRun(app.db, {
@@ -826,12 +731,6 @@ describe("the studio head says why the app is not up", () => {
826731 deploy = run.id;
827732 });
828733
829 afterAll(async () => {
830 servers.stop();
831 app.close();
832 await rm(dataDir, { recursive: true, force: true });
833 });
834
835734 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 () => {
836735 put({
837736 lastError: "the deploy failed: <install> exited 1",
@@ -975,55 +874,23 @@ describe("the studio head says why the app is not up", () => {
975874 * withheld) correctly, not recomputed.
976875 */
977876 describe("the studio app page's terminal", () => {
978 let dataDir: string;
979 let app: App;
980 let servers: Servers;
981 let base: string;
982877 let repo: Repo;
983878 let record: AppRecord;
984 let runtime: AppsRuntime;
985879 let writer: string;
986880 let reader: string;
881 // What the forge had before this block gave it a listener and a key.
882 let before: Pick<App["config"], "sshPort" | "sshHostKey">;
987883
988 interface Slot {
989 readonly app: AppRecord;
990 readonly env: EnvName;
991 readonly chain: Promise<void>;
992 readonly restarts: number[];
993 readonly live?: unknown;
994 }
995 const putLive = (tty: string | null): void => {
996 (runtime as unknown as { envs: Map<string, Slot> }).envs.set(
997 `${record.id}/production`,
998 {
999 app: record,
1000 env: "production",
1001 chain: Promise.resolve(),
1002 restarts: [],
1003 live: {
1004 commitOid: "a".repeat(40),
1005 handle: { port: 4321 },
1006 runId: "fake",
1007 since: new Date().toISOString(),
1008 manifest: { tty },
1009 },
884 const putLive = (tty: string | null): void =>
885 putEnv(record, "production", {
886 live: {
887 commitOid: "a".repeat(40),
888 handle: { port: 4321 },
889 runId: "fake",
890 since: new Date().toISOString(),
891 manifest: { tty },
1010892 },
1011 );
1012 };
1013
1014 const signIn = async (username: string): Promise<string> =>
1015 (
1016 (
1017 await fetch(`${base}/signin`, {
1018 method: "POST",
1019 body: new URLSearchParams({
1020 username,
1021 password: "a-good-passphrase",
1022 }),
1023 redirect: "manual",
1024 })
1025 ).headers.get("set-cookie") ?? ""
1026 ).split(";")[0]!;
893 });
1027894
1028895 const appPage = (cookie: string) =>
1029896 fetch(`${base}/alice/beacon`, {
@@ -1032,58 +899,35 @@ describe("the studio app page's terminal", () => {
1032899 }).then((r) => r.text());
1033900
1034901 beforeAll(async () => {
1035 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-terminal-"));
1036 app = createApp({ dataDir, port: 0, host: "forge.example" });
902 before = { ...app.config };
1037903 app.config.sshPort = 2222;
1038904 app.config.sshHostKey =
1039905 "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
1040 servers = await serve(app);
1041 base = servers.url;
1042 runtime = new AppsRuntime(app, {
1043 domain: "apps.test",
1044 scheme: "http",
1045 publicPort: servers.port,
1046 forgeUrl: servers.url,
1047 concurrency: 1,
1048 });
1049 const a = await createUser(app.db, { origin: "seed", trusted: true,
1050 username: "alice",
1051 email: "[email protected]",
1052 password: "a-good-passphrase",
1053 adminIfFirst: true,
1054 });
1055 if (!a.ok) throw new Error(a.error);
1056 promote(app.db, a.value);
1057 const b = await createUser(app.db, { origin: "seed", trusted: true,
1058 username: "bob",
1059 email: "[email protected]",
1060 password: "a-good-passphrase",
1061 });
1062 if (!b.ok) throw new Error(b.error);
1063 promote(app.db, b.value);
906 const alice = getUserByName(app.db, "alice")!;
1064907 writer = await signIn("alice");
1065908 reader = await signIn("bob");
1066 const made = await createRepo(app, a.value, {
909 const made = await createRepo(app, alice, {
1067910 name: "beacon",
1068911 visibility: "public",
1069912 description: "a light that stays on",
1070913 });
1071914 if (!made.ok) throw new Error(String(made.error));
1072915 repo = made.value;
1073 const seeded = await commitToBranch(app, a.value, repo, {
916 const seeded = await commitToBranch(app, alice, repo, {
1074917 message: "Start",
1075918 files: [{ path: "README.md", content: "beacon\n" }],
1076919 });
1077920 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1078 const enabled = await enableApp(app, a.value, repo, { slug: "beacon" });
921 const enabled = await enableApp(app, alice, repo, { slug: "beacon" });
1079922 if (!enabled.ok) throw new Error(String(enabled.error));
1080923 record = getAppByRepo(app.db, repo.id)!;
1081924 });
1082925
1083 afterAll(async () => {
1084 servers.stop();
1085 app.close();
1086 await rm(dataDir, { recursive: true, force: true });
926 afterAll(() => {
927 if (before.sshPort === undefined) delete app.config.sshPort;
928 else app.config.sshPort = before.sshPort;
929 if (before.sshHostKey === undefined) delete app.config.sshHostKey;
930 else app.config.sshHostKey = before.sshHostKey;
1087931 });
1088932
1089933 test("a writer sees the exact command and the host key; a reader sees neither", async () => {
@@ -1139,109 +983,42 @@ describe("the studio app page's terminal", () => {
1139983 * renders from the same `appPanels` function the page's first paint does.
1140984 */
1141985 describe("the app's live fragment: /:owner/:name/app/state", () => {
1142 let dataDir: string;
1143 let app: App;
1144 let servers: Servers;
1145 let base: string;
1146 let runtime: AppsRuntime;
1147986 let repo: Repo;
1148987 let record: AppRecord;
1149988 let owner: string;
1150989 let stranger: string;
1151990
1152 interface PreviewSlot {
1153 readonly app: AppRecord;
1154 readonly env: EnvName;
1155 readonly chain: Promise<void>;
1156 readonly restarts: number[];
1157 deploying?: unknown;
1158 live?: unknown;
1159 lastError?: string;
1160 lastRunId?: string;
1161 }
1162 const putEnv = (
1163 env: EnvName,
1164 slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">,
1165 ): void => {
1166 (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set(
1167 `${record.id}/${env}`,
1168 { app: record, env, chain: Promise.resolve(), restarts: [], ...slot },
1169 );
1170 };
1171
1172 const signIn = async (username: string): Promise<string> =>
1173 (
1174 (
1175 await fetch(`${base}/signin`, {
1176 method: "POST",
1177 body: new URLSearchParams({
1178 username,
1179 password: "a-good-passphrase",
1180 }),
1181 redirect: "manual",
1182 })
1183 ).headers.get("set-cookie") ?? ""
1184 ).split(";")[0]!;
1185
1186991 const get = (path: string, cookie: string) =>
1187992 fetch(`${base}${path}`, {
1188993 headers: { cookie: `${cookie}; forge_mode=studio` },
1189994 redirect: "manual",
1190995 });
1191996
1192 // A fresh app for each test: one test's pull or preview slot would
1193 // otherwise show up in the next's head card and previews.
1194 beforeEach(async () => {
1195 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-state-"));
1196 app = createApp({ dataDir, port: 0 });
1197 servers = await serve(app);
1198 base = servers.url;
1199 runtime = new AppsRuntime(app, {
1200 domain: "state.test",
1201 scheme: "http",
1202 publicPort: servers.port,
1203 forgeUrl: servers.url,
1204 concurrency: 1,
1205 });
1206 const a = await createUser(app.db, { origin: "seed", trusted: true,
1207 username: "alice",
1208 email: "[email protected]",
1209 password: "a-good-passphrase",
1210 adminIfFirst: true,
1211 });
1212 if (!a.ok) throw new Error(a.error);
1213 promote(app.db, a.value);
1214 const c = await createUser(app.db, { origin: "seed", trusted: true,
1215 username: "carol",
1216 email: "[email protected]",
1217 password: "a-good-passphrase",
1218 });
1219 if (!c.ok) throw new Error(c.error);
1220 promote(app.db, c.value);
997 beforeAll(async () => {
998 const alice = getUserByName(app.db, "alice")!;
1221999 owner = await signIn("alice");
12221000 stranger = await signIn("carol");
1223 const made = await createRepo(app, a.value, {
1001 const made = await createRepo(app, alice, {
12241002 name: "vault",
12251003 visibility: "private",
12261004 description: "",
12271005 });
12281006 if (!made.ok) throw new Error(String(made.error));
12291007 repo = made.value;
1230 const seeded = await commitToBranch(app, a.value, repo, {
1008 const seeded = await commitToBranch(app, alice, repo, {
12311009 message: "Start",
12321010 files: [{ path: "README.md", content: "v\n" }],
12331011 });
12341012 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1235 const enabled = await enableApp(app, a.value, repo, { slug: "vault" });
1013 const enabled = await enableApp(app, alice, repo, { slug: "vault" });
12361014 if (!enabled.ok) throw new Error(String(enabled.error));
12371015 record = getAppByRepo(app.db, repo.id)!;
12381016 });
12391017
1240 afterEach(async () => {
1241 servers.stop();
1242 app.close();
1243 await rm(dataDir, { recursive: true, force: true });
1244 });
1018 // Every test starts from an app nothing has deployed: one test's preview
1019 // or production slot would otherwise show in the next's head card. Each
1020 // opens a pull of its own, and reads only that pull's row.
1021 beforeEach(() => clearEnvs(record));
12451022
12461023 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 () => {
12471024 const fragment = await get("/alice/vault/app/state", owner);
@@ -1386,7 +1163,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
13861163 reason: "pull",
13871164 });
13881165 claimRun(app.db, deployRun.id);
1389 putEnv(env, {
1166 putEnv(record, env, {
13901167 deploying: {
13911168 run: { id: deployRun.id, commitOid: pull.headOid },
13921169 abort: new AbortController(),
@@ -1398,7 +1175,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
13981175 expect(starting).toContain(link);
13991176
14001177 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1401 putEnv(env, {
1178 putEnv(record, env, {
14021179 live: {
14031180 commitOid: pull.headOid,
14041181 handle: { port: 4321 },
@@ -1448,7 +1225,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
14481225 });
14491226 claimRun(app.db, deployRun.id);
14501227 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1451 putEnv(env, {
1228 putEnv(record, env, {
14521229 live: {
14531230 commitOid: pull.headOid,
14541231 handle: { port: 4323 },
@@ -1488,7 +1265,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
14881265 });
14891266 claimRun(app.db, deployRun.id);
14901267 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1491 putEnv("production", {
1268 putEnv(record, "production", {
14921269 live: {
14931270 commitOid: "cafebabe",
14941271 handle: { port: 4322 },
@@ -1507,7 +1284,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
15071284
15081285 // The same deployment with no wait for a permit says only the one
15091286 // number, exactly as it always has.
1510 putEnv("production", {
1287 putEnv(record, "production", {
15111288 live: {
15121289 commitOid: "cafebabe",
15131290 handle: { port: 4322 },
@@ -1525,7 +1302,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
15251302
15261303 // A deploy no push asked for — a redeploy someone pressed — carries no
15271304 // push instant, and its words never claim one.
1528 putEnv("production", {
1305 putEnv(record, "production", {
15291306 live: {
15301307 commitOid: "cafebabe",
15311308 handle: { port: 4322 },
@@ -1556,7 +1333,16 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
15561333 const stream = await fetch(`${base}/alice/vault/stream`, {
15571334 headers: { cookie: owner },
15581335 });
1559 await Bun.sleep(50);
1336 const reader = stream.body!.getReader();
1337 const decoder = new TextDecoder();
1338 let buffer = "";
1339 // The stream says it is connected as it subscribes: from here on, an
1340 // append reaches it — no sleep stands in for that.
1341 while (!buffer.includes(": connected\n\n")) {
1342 const { done, value } = await reader.read();
1343 if (done) throw new Error("the stream closed before it connected");
1344 buffer += decoder.decode(value);
1345 }
15601346
15611347 // The exact append `AppsRuntime` makes at the swap-in (runtime.ts),
15621348 // republished here so the test does not have to spin up a real deploy.
@@ -1572,9 +1358,6 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
15721358 },
15731359 });
15741360
1575 const reader = stream.body!.getReader();
1576 const decoder = new TextDecoder();
1577 let buffer = "";
15781361 let event: Record<string, unknown> | undefined;
15791362 const deadline = Date.now() + 5_000;
15801363 while (!event && Date.now() < deadline) {
@@ -1614,7 +1397,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
16141397 reason: "push",
16151398 });
16161399 claimRun(app.db, deployRun.id);
1617 putEnv("production", {
1400 putEnv(record, "production", {
16181401 deploying: {
16191402 run: { id: deployRun.id, commitOid: "cafed00d" },
16201403 abort: new AbortController(),
@@ -1625,7 +1408,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
16251408 expect(deploying).not.toContain(link);
16261409
16271410 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1628 putEnv("production", {
1411 putEnv(record, "production", {
16291412 live: {
16301413 commitOid: "cafed00d",
16311414 handle: { port: 4325 },
@@ -1676,7 +1459,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
16761459 };
16771460
16781461 claimRun(app.db, deployRun.id);
1679 putEnv(env, {
1462 putEnv(record, env, {
16801463 deploying: {
16811464 run: { id: deployRun.id, commitOid: pull.headOid },
16821465 abort: new AbortController(),
@@ -1694,7 +1477,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
16941477 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
16951478 const createdAt = new Date(Date.now() - 60_000).toISOString();
16961479 const servingAt = new Date(Date.parse(createdAt) + 3_400).toISOString();
1697 putEnv(env, {
1480 putEnv(record, env, {
16981481 live: {
16991482 commitOid: pull.headOid,
17001483 handle: { port: 4324 },
@@ -1719,7 +1502,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
17191502 "live 3.4s after the push",
17201503 );
17211504
1722 putEnv(env, {
1505 putEnv(record, env, {
17231506 live: {
17241507 commitOid: pull.headOid,
17251508 handle: { port: 4324 },
@@ -1742,7 +1525,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
17421525 expect(red).not.toContain('data-variant="success"');
17431526 expect(red).not.toContain(">live<");
17441527
1745 putEnv(env, {
1528 putEnv(record, env, {
17461529 deploying: {
17471530 run: { id: deployRun.id, commitOid: pull.headOid },
17481531 abort: new AbortController(),
@@ -1766,7 +1549,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
17661549 expect(verifying).not.toContain('data-variant="success"');
17671550 expect(verifying).not.toContain('data-variant="attn"');
17681551
1769 putEnv(env, {
1552 putEnv(record, env, {
17701553 live: {
17711554 commitOid: pull.headOid,
17721555 handle: { port: 4324 },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs