Public: anyone can read this; the crew can change it.
test/studio-door.test.ts runs under 10 s #3573
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3566-test-studio-door-test-ts-runs-under-10-s → main 1 commit fork
2 files
changed +193
−410
scripts/test-times.json +1 −1
| @@ -53,7 +53,7 @@ | ||
| 53 | 53 | "test/standing.test.ts": 2.8, |
| 54 | 54 | "test/steer.test.ts": 16.2, |
| 55 | 55 | "test/studio-changes.test.ts": 31.8, |
| 56 | "test/studio-door.test.ts": 34.2, | |
| 56 | "test/studio-door.test.ts": 2.1, | |
| 57 | 57 | "test/studio-runs.test.ts": 29.3, |
| 58 | 58 | "test/studio-search.test.ts": 30.7, |
| 59 | 59 | "test/studio-shipping.test.ts": 35.6, |
test/studio-door.test.ts +192 −409
| @@ -3,11 +3,15 @@ | ||
| 3 | 3 | * current, the head, the terminal and the live fragment. One of the files |
| 4 | 4 | * the studio's suite was split into so no one file sets the gate's floor |
| 5 | 5 | * (#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. | |
| 6 | 11 | */ |
| 7 | 12 | |
| 8 | 13 | import { |
| 9 | 14 | afterAll, |
| 10 | afterEach, | |
| 11 | 15 | beforeAll, |
| 12 | 16 | beforeEach, |
| 13 | 17 | describe, |
| @@ -20,7 +24,7 @@ import { join } from "node:path"; | ||
| 20 | 24 | import { createApp, type App } from "../src/app.ts"; |
| 21 | 25 | import { enableApp } from "../src/actions/apps.ts"; |
| 22 | 26 | 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"; | |
| 24 | 28 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 25 | 29 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 26 | 30 | import { createOrg } from "../src/actions/orgs.ts"; |
| @@ -40,46 +44,115 @@ import { | ||
| 40 | 44 | createRun, |
| 41 | 45 | finishRun, |
| 42 | 46 | getRun, |
| 43 | isTerminal, | |
| 44 | listRuns, | |
| 45 | 47 | markServing, |
| 46 | 48 | } from "../src/core/runs.ts"; |
| 47 | 49 | import { liveClientJs } from "../src/web/live.ts"; |
| 48 | 50 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 49 | 51 | import { serve, type Servers } from "../src/http/server.ts"; |
| 50 | import { serving } from "./helpers/studio.ts"; | |
| 51 | 52 | import { promote } from "./helpers/members.ts"; |
| 52 | 53 | |
| 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; | |
| 58 | 59 | |
| 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, | |
| 64 | 72 | }); |
| 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 | }); | |
| 65 | 79 | |
| 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, | |
| 70 | 98 | }); |
| 99 | if (!made.ok) throw new Error(made.error); | |
| 100 | promote(app.db, made.value); | |
| 101 | return made.value; | |
| 102 | } | |
| 71 | 103 | |
| 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 | ( | |
| 73 | 107 | ( |
| 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]!; | |
| 82 | 115 | |
| 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)", () => { | |
| 83 | 156 | const occurrences = (haystack: string, needle: string): number => |
| 84 | 157 | haystack.split(needle).length - 1; |
| 85 | 158 | |
| @@ -105,8 +178,9 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)" | ||
| 105 | 178 | files: [{ path: "README.md", content: "hi\n" }], |
| 106 | 179 | }); |
| 107 | 180 | if (!appSeeded.ok) throw new Error(JSON.stringify(appSeeded.error)); |
| 181 | // Not "beacon": alice's app in the terminal's block holds that slug. | |
| 108 | 182 | const enabled = await enableApp(app, dan.value, madeApp.value, { |
| 109 | slug: "beacon", | |
| 183 | slug: "dan-beacon", | |
| 110 | 184 | }); |
| 111 | 185 | if (!enabled.ok) throw new Error(String(enabled.error)); |
| 112 | 186 | |
| @@ -320,13 +394,9 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)" | ||
| 320 | 394 | }); |
| 321 | 395 | |
| 322 | 396 | 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; | |
| 328 | 397 | let cookie: string; |
| 329 | 398 | let repo: Repo; |
| 399 | let record: AppRecord; | |
| 330 | 400 | |
| 331 | 401 | const get = (path: string) => |
| 332 | 402 | fetch(`${base}${path}`, { |
| @@ -334,97 +404,26 @@ describe("every open pull's preview on the studio page", () => { | ||
| 334 | 404 | redirect: "manual", |
| 335 | 405 | }); |
| 336 | 406 | |
| 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, { | |
| 397 | 413 | name: "widget", |
| 398 | 414 | visibility: "public", |
| 399 | 415 | description: "", |
| 400 | 416 | }); |
| 401 | 417 | if (!made.ok) throw new Error(String(made.error)); |
| 402 | 418 | repo = made.value; |
| 403 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 419 | const seeded = await commitToBranch(app, alice, repo, { | |
| 404 | 420 | 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" }], | |
| 417 | 422 | }); |
| 418 | 423 | 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" }); | |
| 420 | 425 | 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)!; | |
| 428 | 427 | }); |
| 429 | 428 | |
| 430 | 429 | 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", () => { | ||
| 458 | 457 | const after = await (await get("/alice/widget")).text(); |
| 459 | 458 | expect(after).not.toContain("<h2>Previews</h2>"); |
| 460 | 459 | expect(after).not.toContain(`pull-${pull.number}/open`); |
| 461 | }, 120_000); | |
| 460 | }); | |
| 462 | 461 | |
| 463 | 462 | 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 () => { |
| 464 | 463 | const alice = getUserByName(app.db, "alice")!; |
| @@ -477,11 +476,21 @@ describe("every open pull's preview on the studio page", () => { | ||
| 477 | 476 | }); |
| 478 | 477 | if (!opened.ok) throw new Error(String(opened.error)); |
| 479 | 478 | 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); | |
| 482 | 480 | |
| 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)); | |
| 485 | 494 | |
| 486 | 495 | const note = "its own copy of production's data, taken"; |
| 487 | 496 | |
| @@ -496,7 +505,7 @@ describe("every open pull's preview on the studio page", () => { | ||
| 496 | 505 | ).text(); |
| 497 | 506 | expect(changePage).toContain("<h2>Preview</h2>"); |
| 498 | 507 | expect(changePage).toContain(note); |
| 499 | }, 120_000); | |
| 508 | }); | |
| 500 | 509 | }); |
| 501 | 510 | |
| 502 | 511 | /** |
| @@ -506,10 +515,6 @@ describe("every open pull's preview on the studio page", () => { | ||
| 506 | 515 | * in place by `liveRegionsJs` (`src/web/client.ts`). |
| 507 | 516 | */ |
| 508 | 517 | describe("the studio home keeps itself current", () => { |
| 509 | let dataDir: string; | |
| 510 | let app: App; | |
| 511 | let servers: Servers; | |
| 512 | let base: string; | |
| 513 | 518 | let alice: User; |
| 514 | 519 | let repo: Repo; |
| 515 | 520 | let cookie: string; |
| @@ -525,31 +530,8 @@ describe("the studio home keeps itself current", () => { | ||
| 525 | 530 | }); |
| 526 | 531 | |
| 527 | 532 | 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"); | |
| 553 | 535 | const made = await createRepo(app, alice, { |
| 554 | 536 | name: "signals", |
| 555 | 537 | visibility: "private", |
| @@ -600,12 +582,6 @@ describe("the studio home keeps itself current", () => { | ||
| 600 | 582 | expect(assignIssue(app, alice, repo, readme, alice).ok).toBe(true); |
| 601 | 583 | }); |
| 602 | 584 | |
| 603 | afterAll(async () => { | |
| 604 | servers.stop(); | |
| 605 | app.close(); | |
| 606 | await rm(dataDir, { recursive: true, force: true }); | |
| 607 | }); | |
| 608 | ||
| 609 | 585 | test("Your apps is a live region and the home has no On you, with the security floor's headers", async () => { |
| 610 | 586 | const res = await get(); |
| 611 | 587 | expect(res.status).toBe(200); |
| @@ -689,13 +665,8 @@ describe("the studio home keeps itself current", () => { | ||
| 689 | 665 | * shows — why, the deploy that says the rest, and the way to try again. |
| 690 | 666 | */ |
| 691 | 667 | 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; | |
| 696 | 668 | let repo: Repo; |
| 697 | 669 | let record: AppRecord; |
| 698 | let runtime: AppsRuntime; | |
| 699 | 670 | let deploy: string; |
| 700 | 671 | let writer: string; |
| 701 | 672 | let reader: string; |
| @@ -722,93 +693,27 @@ describe("the studio head says why the app is not up", () => { | ||
| 722 | 693 | return row.match(/<span class="badge"[^>]*>([\s\S]*?)<\/span>/)?.[1] ?? ""; |
| 723 | 694 | }; |
| 724 | 695 | |
| 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); | |
| 769 | 699 | |
| 770 | 700 | 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")!; | |
| 797 | 702 | writer = await signIn("alice"); |
| 798 | 703 | reader = await signIn("bob"); |
| 799 | const made = await createRepo(app, a.value, { | |
| 704 | const made = await createRepo(app, alice, { | |
| 800 | 705 | name: "notes", |
| 801 | 706 | visibility: "public", |
| 802 | 707 | description: "a place for notes", |
| 803 | 708 | }); |
| 804 | 709 | if (!made.ok) throw new Error(String(made.error)); |
| 805 | 710 | repo = made.value; |
| 806 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 711 | const seeded = await commitToBranch(app, alice, repo, { | |
| 807 | 712 | message: "Start", |
| 808 | 713 | files: [{ path: "README.md", content: "notes\n" }], |
| 809 | 714 | }); |
| 810 | 715 | 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" }); | |
| 812 | 717 | if (!enabled.ok) throw new Error(String(enabled.error)); |
| 813 | 718 | record = getAppByRepo(app.db, repo.id)!; |
| 814 | 719 | const run = createRun(app.db, { |
| @@ -826,12 +731,6 @@ describe("the studio head says why the app is not up", () => { | ||
| 826 | 731 | deploy = run.id; |
| 827 | 732 | }); |
| 828 | 733 | |
| 829 | afterAll(async () => { | |
| 830 | servers.stop(); | |
| 831 | app.close(); | |
| 832 | await rm(dataDir, { recursive: true, force: true }); | |
| 833 | }); | |
| 834 | ||
| 835 | 734 | 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 () => { |
| 836 | 735 | put({ |
| 837 | 736 | lastError: "the deploy failed: <install> exited 1", |
| @@ -975,55 +874,23 @@ describe("the studio head says why the app is not up", () => { | ||
| 975 | 874 | * withheld) correctly, not recomputed. |
| 976 | 875 | */ |
| 977 | 876 | describe("the studio app page's terminal", () => { |
| 978 | let dataDir: string; | |
| 979 | let app: App; | |
| 980 | let servers: Servers; | |
| 981 | let base: string; | |
| 982 | 877 | let repo: Repo; |
| 983 | 878 | let record: AppRecord; |
| 984 | let runtime: AppsRuntime; | |
| 985 | 879 | let writer: string; |
| 986 | 880 | 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">; | |
| 987 | 883 | |
| 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 }, | |
| 1010 | 892 | }, |
| 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 | }); | |
| 1027 | 894 | |
| 1028 | 895 | const appPage = (cookie: string) => |
| 1029 | 896 | fetch(`${base}/alice/beacon`, { |
| @@ -1032,58 +899,35 @@ describe("the studio app page's terminal", () => { | ||
| 1032 | 899 | }).then((r) => r.text()); |
| 1033 | 900 | |
| 1034 | 901 | beforeAll(async () => { |
| 1035 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-terminal-")); | |
| 1036 | app = createApp({ dataDir, port: 0, host: "forge.example" }); | |
| 902 | before = { ...app.config }; | |
| 1037 | 903 | app.config.sshPort = 2222; |
| 1038 | 904 | app.config.sshHostKey = |
| 1039 | 905 | "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")!; | |
| 1064 | 907 | writer = await signIn("alice"); |
| 1065 | 908 | reader = await signIn("bob"); |
| 1066 | const made = await createRepo(app, a.value, { | |
| 909 | const made = await createRepo(app, alice, { | |
| 1067 | 910 | name: "beacon", |
| 1068 | 911 | visibility: "public", |
| 1069 | 912 | description: "a light that stays on", |
| 1070 | 913 | }); |
| 1071 | 914 | if (!made.ok) throw new Error(String(made.error)); |
| 1072 | 915 | repo = made.value; |
| 1073 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 916 | const seeded = await commitToBranch(app, alice, repo, { | |
| 1074 | 917 | message: "Start", |
| 1075 | 918 | files: [{ path: "README.md", content: "beacon\n" }], |
| 1076 | 919 | }); |
| 1077 | 920 | 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" }); | |
| 1079 | 922 | if (!enabled.ok) throw new Error(String(enabled.error)); |
| 1080 | 923 | record = getAppByRepo(app.db, repo.id)!; |
| 1081 | 924 | }); |
| 1082 | 925 | |
| 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; | |
| 1087 | 931 | }); |
| 1088 | 932 | |
| 1089 | 933 | 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", () => { | ||
| 1139 | 983 | * renders from the same `appPanels` function the page's first paint does. |
| 1140 | 984 | */ |
| 1141 | 985 | 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; | |
| 1147 | 986 | let repo: Repo; |
| 1148 | 987 | let record: AppRecord; |
| 1149 | 988 | let owner: string; |
| 1150 | 989 | let stranger: string; |
| 1151 | 990 | |
| 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 | ||
| 1186 | 991 | const get = (path: string, cookie: string) => |
| 1187 | 992 | fetch(`${base}${path}`, { |
| 1188 | 993 | headers: { cookie: `${cookie}; forge_mode=studio` }, |
| 1189 | 994 | redirect: "manual", |
| 1190 | 995 | }); |
| 1191 | 996 | |
| 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")!; | |
| 1221 | 999 | owner = await signIn("alice"); |
| 1222 | 1000 | stranger = await signIn("carol"); |
| 1223 | const made = await createRepo(app, a.value, { | |
| 1001 | const made = await createRepo(app, alice, { | |
| 1224 | 1002 | name: "vault", |
| 1225 | 1003 | visibility: "private", |
| 1226 | 1004 | description: "", |
| 1227 | 1005 | }); |
| 1228 | 1006 | if (!made.ok) throw new Error(String(made.error)); |
| 1229 | 1007 | repo = made.value; |
| 1230 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 1008 | const seeded = await commitToBranch(app, alice, repo, { | |
| 1231 | 1009 | message: "Start", |
| 1232 | 1010 | files: [{ path: "README.md", content: "v\n" }], |
| 1233 | 1011 | }); |
| 1234 | 1012 | 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" }); | |
| 1236 | 1014 | if (!enabled.ok) throw new Error(String(enabled.error)); |
| 1237 | 1015 | record = getAppByRepo(app.db, repo.id)!; |
| 1238 | 1016 | }); |
| 1239 | 1017 | |
| 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)); | |
| 1245 | 1022 | |
| 1246 | 1023 | 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 () => { |
| 1247 | 1024 | const fragment = await get("/alice/vault/app/state", owner); |
| @@ -1386,7 +1163,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1386 | 1163 | reason: "pull", |
| 1387 | 1164 | }); |
| 1388 | 1165 | claimRun(app.db, deployRun.id); |
| 1389 | putEnv(env, { | |
| 1166 | putEnv(record, env, { | |
| 1390 | 1167 | deploying: { |
| 1391 | 1168 | run: { id: deployRun.id, commitOid: pull.headOid }, |
| 1392 | 1169 | abort: new AbortController(), |
| @@ -1398,7 +1175,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1398 | 1175 | expect(starting).toContain(link); |
| 1399 | 1176 | |
| 1400 | 1177 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); |
| 1401 | putEnv(env, { | |
| 1178 | putEnv(record, env, { | |
| 1402 | 1179 | live: { |
| 1403 | 1180 | commitOid: pull.headOid, |
| 1404 | 1181 | handle: { port: 4321 }, |
| @@ -1448,7 +1225,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1448 | 1225 | }); |
| 1449 | 1226 | claimRun(app.db, deployRun.id); |
| 1450 | 1227 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); |
| 1451 | putEnv(env, { | |
| 1228 | putEnv(record, env, { | |
| 1452 | 1229 | live: { |
| 1453 | 1230 | commitOid: pull.headOid, |
| 1454 | 1231 | handle: { port: 4323 }, |
| @@ -1488,7 +1265,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1488 | 1265 | }); |
| 1489 | 1266 | claimRun(app.db, deployRun.id); |
| 1490 | 1267 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); |
| 1491 | putEnv("production", { | |
| 1268 | putEnv(record, "production", { | |
| 1492 | 1269 | live: { |
| 1493 | 1270 | commitOid: "cafebabe", |
| 1494 | 1271 | handle: { port: 4322 }, |
| @@ -1507,7 +1284,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1507 | 1284 | |
| 1508 | 1285 | // The same deployment with no wait for a permit says only the one |
| 1509 | 1286 | // number, exactly as it always has. |
| 1510 | putEnv("production", { | |
| 1287 | putEnv(record, "production", { | |
| 1511 | 1288 | live: { |
| 1512 | 1289 | commitOid: "cafebabe", |
| 1513 | 1290 | handle: { port: 4322 }, |
| @@ -1525,7 +1302,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1525 | 1302 | |
| 1526 | 1303 | // A deploy no push asked for — a redeploy someone pressed — carries no |
| 1527 | 1304 | // push instant, and its words never claim one. |
| 1528 | putEnv("production", { | |
| 1305 | putEnv(record, "production", { | |
| 1529 | 1306 | live: { |
| 1530 | 1307 | commitOid: "cafebabe", |
| 1531 | 1308 | handle: { port: 4322 }, |
| @@ -1556,7 +1333,16 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1556 | 1333 | const stream = await fetch(`${base}/alice/vault/stream`, { |
| 1557 | 1334 | headers: { cookie: owner }, |
| 1558 | 1335 | }); |
| 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 | } | |
| 1560 | 1346 | |
| 1561 | 1347 | // The exact append `AppsRuntime` makes at the swap-in (runtime.ts), |
| 1562 | 1348 | // 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", () => { | ||
| 1572 | 1358 | }, |
| 1573 | 1359 | }); |
| 1574 | 1360 | |
| 1575 | const reader = stream.body!.getReader(); | |
| 1576 | const decoder = new TextDecoder(); | |
| 1577 | let buffer = ""; | |
| 1578 | 1361 | let event: Record<string, unknown> | undefined; |
| 1579 | 1362 | const deadline = Date.now() + 5_000; |
| 1580 | 1363 | while (!event && Date.now() < deadline) { |
| @@ -1614,7 +1397,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1614 | 1397 | reason: "push", |
| 1615 | 1398 | }); |
| 1616 | 1399 | claimRun(app.db, deployRun.id); |
| 1617 | putEnv("production", { | |
| 1400 | putEnv(record, "production", { | |
| 1618 | 1401 | deploying: { |
| 1619 | 1402 | run: { id: deployRun.id, commitOid: "cafed00d" }, |
| 1620 | 1403 | abort: new AbortController(), |
| @@ -1625,7 +1408,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1625 | 1408 | expect(deploying).not.toContain(link); |
| 1626 | 1409 | |
| 1627 | 1410 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); |
| 1628 | putEnv("production", { | |
| 1411 | putEnv(record, "production", { | |
| 1629 | 1412 | live: { |
| 1630 | 1413 | commitOid: "cafed00d", |
| 1631 | 1414 | handle: { port: 4325 }, |
| @@ -1676,7 +1459,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1676 | 1459 | }; |
| 1677 | 1460 | |
| 1678 | 1461 | claimRun(app.db, deployRun.id); |
| 1679 | putEnv(env, { | |
| 1462 | putEnv(record, env, { | |
| 1680 | 1463 | deploying: { |
| 1681 | 1464 | run: { id: deployRun.id, commitOid: pull.headOid }, |
| 1682 | 1465 | abort: new AbortController(), |
| @@ -1694,7 +1477,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1694 | 1477 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); |
| 1695 | 1478 | const createdAt = new Date(Date.now() - 60_000).toISOString(); |
| 1696 | 1479 | const servingAt = new Date(Date.parse(createdAt) + 3_400).toISOString(); |
| 1697 | putEnv(env, { | |
| 1480 | putEnv(record, env, { | |
| 1698 | 1481 | live: { |
| 1699 | 1482 | commitOid: pull.headOid, |
| 1700 | 1483 | handle: { port: 4324 }, |
| @@ -1719,7 +1502,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1719 | 1502 | "live 3.4s after the push", |
| 1720 | 1503 | ); |
| 1721 | 1504 | |
| 1722 | putEnv(env, { | |
| 1505 | putEnv(record, env, { | |
| 1723 | 1506 | live: { |
| 1724 | 1507 | commitOid: pull.headOid, |
| 1725 | 1508 | handle: { port: 4324 }, |
| @@ -1742,7 +1525,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1742 | 1525 | expect(red).not.toContain('data-variant="success"'); |
| 1743 | 1526 | expect(red).not.toContain(">live<"); |
| 1744 | 1527 | |
| 1745 | putEnv(env, { | |
| 1528 | putEnv(record, env, { | |
| 1746 | 1529 | deploying: { |
| 1747 | 1530 | run: { id: deployRun.id, commitOid: pull.headOid }, |
| 1748 | 1531 | abort: new AbortController(), |
| @@ -1766,7 +1549,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1766 | 1549 | expect(verifying).not.toContain('data-variant="success"'); |
| 1767 | 1550 | expect(verifying).not.toContain('data-variant="attn"'); |
| 1768 | 1551 | |
| 1769 | putEnv(env, { | |
| 1552 | putEnv(record, env, { | |
| 1770 | 1553 | live: { |
| 1771 | 1554 | commitOid: pull.headOid, |
| 1772 | 1555 | handle: { port: 4324 }, |
new above
offline