Public: anyone can read this; the crew can change it.
Serve a deployment as soon as it answers; run the app's tests beside it, not in front of it #19
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11-serve-a-deployment-as-soon-as-it-answers → main 1 commit fork
6 files
changed +355
−57
docs/APPS.md +16 −6
| @@ -69,7 +69,13 @@ around it. None is advisory. | ||
| 69 | 69 | 4. **A deployment is a check.** Every deploy is a run; the run's state is |
| 70 | 70 | what the pull shows. With `require_checks` on, `mergePull` refuses |
| 71 | 71 | `checks_required` until the head's deploy run is `ok`, read live inside |
| 72 | the merge — never from a status a caller computed earlier. | |
| 72 | the merge — never from a status a caller computed earlier. Install still | |
| 73 | comes first, but `test` and `start` run side by side: the environment is | |
| 74 | swapped in and serving the moment the app answers, and the run stays | |
| 75 | `running` — checking, not blocking — until `test` finishes and decides | |
| 76 | `ok` or `failed`. A deployment that answers is what was asked for; the | |
| 77 | suite going green is confirmed a few seconds later, not stood in front | |
| 78 | of the door. | |
| 73 | 79 | 5. **A private app answers only to someone the forge vouches for.** An app's |
| 74 | 80 | `access` is `public` or `private` (defaulting to the repository's |
| 75 | 81 | visibility), and it is set apart from the repository's own: the code can |
| @@ -186,9 +192,10 @@ verdict is a run of kind `check`, kept with its log beside the pull, in | ||
| 186 | 192 | the same place a preview's deploy reports and read by the same code: the |
| 187 | 193 | inbox badge, the pull's status, the crew. A failing check on an agent's |
| 188 | 194 | pull goes back to the agent with the log's tail, as a failed preview does, |
| 189 | bounded the same way. An app's own `test` still runs inside its deploy; | |
| 190 | `check` is for a repository that is not an app, or an app whose check is | |
| 191 | more than its test. `install` defaults to `bun install` when the package | |
| 195 | bounded the same way. An app's own `test` still runs inside its deploy, | |
| 196 | concurrently with `start` rather than before it; `check` is for a | |
| 197 | repository that is not an app, or an app whose check is more than its | |
| 198 | test. `install` defaults to `bun install` when the package | |
| 192 | 199 | declares dependencies; `timeout` is seconds, twenty minutes by default. |
| 193 | 200 | A `check` that is present and wrong fails with the reason, so a commit is |
| 194 | 201 | never silently unchecked. A check under way when the forge restarts is |
| @@ -360,8 +367,11 @@ many landed, pulls a person merged, spend, and the mean cost, time and | ||
| 360 | 367 | turns of a run. |
| 361 | 368 | |
| 362 | 369 | On a pull, the runs for the current head are its checks. A deploy run that |
| 363 | fails — install, build, test, or the app never answering — fails the check | |
| 364 | and the preview shows the log where it stopped. A push makes new runs; | |
| 370 | fails — install, build, or the app never answering — fails the check before | |
| 371 | anything serves; the preview shows the log where it stopped. A deploy whose | |
| 372 | app answers but whose `test` then fails is different: the preview keeps | |
| 373 | serving that commit, but the run still fails the check, and the page says | |
| 374 | in words that it is serving code whose tests failed. A push makes new runs; | |
| 365 | 375 | older ones are history, not status. |
| 366 | 376 | |
| 367 | 377 | ## The crew |
src/apps/process.ts +46 −18
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | * One deployment's process: install, test, start, and wait until it | |
| 3 | * answers. Everything the app is handed is listed here and nothing else | |
| 4 | * reaches it — the forge's own environment carries secrets, and a child | |
| 5 | * inherits exactly what `childEnv` names (rule 8 in docs/APPS.md). | |
| 2 | * One deployment's process: install, then start the app and run its tests | |
| 3 | * side by side, and wait until the app answers. Everything the app is | |
| 4 | * handed is listed here and nothing else reaches it — the forge's own | |
| 5 | * environment carries secrets, and a child inherits exactly what `childEnv` | |
| 6 | * names (rule 8 in docs/APPS.md). | |
| 6 | 7 | * |
| 7 | 8 | * Commands run through `sh -c` with an `exec` in front, so the shell |
| 8 | 9 | * replaces itself with the app and a signal to the pid is a signal to the |
| @@ -48,6 +49,17 @@ export interface StartError { | ||
| 48 | 49 | readonly message: string; |
| 49 | 50 | } |
| 50 | 51 | |
| 52 | export interface StartedProcess { | |
| 53 | readonly handle: ProcessHandle; | |
| 54 | /** | |
| 55 | * The `test` step's outcome. Already running by the time this resolves — | |
| 56 | * it started alongside `start`, not before it — so a caller that wants a | |
| 57 | * fast preview can swap the handle in now and await this afterwards. | |
| 58 | * `ok()` immediately when the manifest has no `test`. | |
| 59 | */ | |
| 60 | readonly tested: Promise<Result<void, string>>; | |
| 61 | } | |
| 62 | ||
| 51 | 63 | const STEP_TIMEOUT_MS = 10 * 60_000; |
| 52 | 64 | const STOP_GRACE_MS = 5_000; |
| 53 | 65 | const HEALTH_INTERVAL_MS = 250; |
| @@ -177,13 +189,15 @@ export async function runStep( | ||
| 177 | 189 | } |
| 178 | 190 | |
| 179 | 191 | /** |
| 180 | * Install, test, start, and wait for the app to answer on its port. | |
| 181 | * Returns the running process; on failure the process (if it started) is | |
| 182 | * already gone. | |
| 192 | * Install, then start the app and run its tests side by side, and wait for | |
| 193 | * the app to answer on its port. Returns as soon as it does — `tested` | |
| 194 | * keeps running and is the caller's to await — so a preview is never held | |
| 195 | * behind its own test suite. On failure the process (if it started) and the | |
| 196 | * test step (if one was running) are both already gone. | |
| 183 | 197 | */ |
| 184 | 198 | export async function startProcess( |
| 185 | 199 | input: StartInput, |
| 186 | ): Promise<Result<ProcessHandle, StartError>> { | |
| 200 | ): Promise<Result<StartedProcess, StartError>> { | |
| 187 | 201 | const { manifest, log } = input; |
| 188 | 202 | const stepEnv = childEnv({ |
| 189 | 203 | CI: "1", |
| @@ -200,18 +214,28 @@ export async function startProcess( | ||
| 200 | 214 | if (!installed.ok) |
| 201 | 215 | return err({ stage: "install", message: installed.error }); |
| 202 | 216 | } |
| 203 | if (manifest.test !== null) { | |
| 204 | const tested = await runStep("test", manifest.test, { | |
| 205 | cwd: input.dir, | |
| 206 | env: stepEnv, | |
| 207 | log, | |
| 208 | signal: input.signal, | |
| 209 | }); | |
| 210 | if (!tested.ok) return err({ stage: "test", message: tested.error }); | |
| 211 | } | |
| 212 | 217 | if (input.signal.aborted) |
| 213 | 218 | return err({ stage: "start", message: "cancelled" }); |
| 214 | 219 | |
| 220 | // `test` starts here, alongside the app below, and is aborted on its own | |
| 221 | // signal — a start failure must not leave it running past its run, and | |
| 222 | // neither may the caller's abort after this function has already returned. | |
| 223 | const testAbort = new AbortController(); | |
| 224 | if (input.signal.aborted) testAbort.abort(); | |
| 225 | else | |
| 226 | input.signal.addEventListener("abort", () => testAbort.abort(), { | |
| 227 | once: true, | |
| 228 | }); | |
| 229 | const tested: Promise<Result<void, string>> = | |
| 230 | manifest.test === null | |
| 231 | ? Promise.resolve(ok()) | |
| 232 | : runStep("test", manifest.test, { | |
| 233 | cwd: input.dir, | |
| 234 | env: stepEnv, | |
| 235 | log, | |
| 236 | signal: testAbort.signal, | |
| 237 | }); | |
| 238 | ||
| 215 | 239 | const port = freePort(); |
| 216 | 240 | log(`$ ${manifest.run} (PORT=${port})`); |
| 217 | 241 | const proc = Bun.spawn(["/bin/sh", "-c", `exec ${manifest.run}`], { |
| @@ -267,6 +291,10 @@ export async function startProcess( | ||
| 267 | 291 | }); |
| 268 | 292 | if (!ready.ok) { |
| 269 | 293 | await handle.stop(); |
| 294 | // The app never came up: the test step running beside it has nothing | |
| 295 | // left to verify, and must not outlive this failed run. | |
| 296 | testAbort.abort(); | |
| 297 | await tested.catch(() => {}); | |
| 270 | 298 | const tail = |
| 271 | 299 | recent.length > 0 |
| 272 | 300 | ? ` — last output: ${recent.slice(-3).join(" | ")}` |
| @@ -277,7 +305,7 @@ export async function startProcess( | ||
| 277 | 305 | }); |
| 278 | 306 | } |
| 279 | 307 | log(`ready: ${input.url}`); |
| 280 | return ok(handle); | |
| 308 | return ok({ handle, tested }); | |
| 281 | 309 | } |
| 282 | 310 | |
| 283 | 311 | /** |
src/apps/runtime.ts +75 −27
| @@ -37,6 +37,7 @@ import { Gate } from "../git/env.ts"; | ||
| 37 | 37 | import { resolveOid } from "../git/merge.ts"; |
| 38 | 38 | import { repoDir } from "../git/repos.ts"; |
| 39 | 39 | import { createLog } from "../kernel/log.ts"; |
| 40 | import type { Result } from "../kernel/result.ts"; | |
| 40 | 41 | import { holdLease, STALE_AFTER_MS } from "../runs/lease.ts"; |
| 41 | 42 | import { appendRunLog } from "../runs/log.ts"; |
| 42 | 43 | import { exportTree, readFileAt } from "./checkout.ts"; |
| @@ -83,6 +84,13 @@ export interface EnvStatus { | ||
| 83 | 84 | readonly runId?: string | undefined; |
| 84 | 85 | readonly since?: string | undefined; |
| 85 | 86 | readonly error?: string | undefined; |
| 87 | /** | |
| 88 | * A running environment whose deploy is still checking itself: its tests | |
| 89 | * are still running, or ran and failed. Never set while `state` is | |
| 90 | * anything but `running` — a failed test never stops a deployment that | |
| 91 | * already answers. | |
| 92 | */ | |
| 93 | readonly note?: string | undefined; | |
| 86 | 94 | } |
| 87 | 95 | |
| 88 | 96 | interface Live { |
| @@ -93,6 +101,8 @@ interface Live { | ||
| 93 | 101 | readonly since: string; |
| 94 | 102 | /** How this checkout runs — its `tty` is what an ssh session opens. */ |
| 95 | 103 | readonly manifest: AppManifest; |
| 104 | /** Unset while tests are still running or there are none; the message once they fail. */ | |
| 105 | readonly testsFailed?: string | undefined; | |
| 96 | 106 | } |
| 97 | 107 | |
| 98 | 108 | /** A running environment, as a terminal session needs it. */ |
| @@ -199,7 +209,14 @@ export class AppsRuntime { | ||
| 199 | 209 | |
| 200 | 210 | private describe(state: Environment): EnvStatus { |
| 201 | 211 | const url = this.urlFor(state.app, state.env); |
| 202 | if (state.deploying !== undefined) { | |
| 212 | // Once a deploy's own commit has answered and swapped in, it is | |
| 213 | // "running" from here — even though its run stays open, checking its | |
| 214 | // tests, until they finish. Only a build still short of that point (no | |
| 215 | // live process yet, or the live one is an older commit) is "deploying". | |
| 216 | const swapped = | |
| 217 | state.deploying !== undefined && | |
| 218 | state.live?.commitOid === state.deploying.run.commitOid; | |
| 219 | if (state.deploying !== undefined && !swapped) { | |
| 203 | 220 | return { |
| 204 | 221 | env: state.env, |
| 205 | 222 | state: "deploying", |
| @@ -217,8 +234,12 @@ export class AppsRuntime { | ||
| 217 | 234 | commitOid: state.live.commitOid, |
| 218 | 235 | url, |
| 219 | 236 | port: state.live.handle.port, |
| 220 | runId: state.live.runId, | |
| 237 | runId: swapped ? state.deploying!.run.id : state.live.runId, | |
| 221 | 238 | since: state.live.since, |
| 239 | ...(swapped ? { note: "serving — still verifying its tests" } : {}), | |
| 240 | ...(state.live.testsFailed === undefined | |
| 241 | ? {} | |
| 242 | : { error: `serving — its tests failed: ${state.live.testsFailed}` }), | |
| 222 | 243 | }; |
| 223 | 244 | } |
| 224 | 245 | return { |
| @@ -559,29 +580,9 @@ export class AppsRuntime { | ||
| 559 | 580 | error: `deploy failed: ${error instanceof Error ? error.message : String(error)}`, |
| 560 | 581 | })); |
| 561 | 582 | |
| 562 | state.deploying = undefined; | |
| 563 | lease(); | |
| 564 | if (outcome.ok) { | |
| 565 | const previous = state.live; | |
| 566 | state.live = outcome.value; | |
| 567 | state.lastError = undefined; | |
| 568 | if (previous !== undefined) { | |
| 569 | write( | |
| 570 | `stopping the previous deployment (${previous.commitOid.slice(0, 7)})`, | |
| 571 | ); | |
| 572 | await previous.handle.stop(); | |
| 573 | if (previous.dir !== outcome.value.dir) | |
| 574 | rmSync(previous.dir, { recursive: true, force: true }); | |
| 575 | } | |
| 576 | this.watch(state, repo, outcome.value); | |
| 577 | const url = this.urlFor(state.app, state.env); | |
| 578 | const done = finishRun(this.app.db, run.id, { | |
| 579 | state: "ok", | |
| 580 | summary: `running at ${url}`, | |
| 581 | }); | |
| 582 | write(`done: ${url}`); | |
| 583 | if (done) announceRun(this.app, done); | |
| 584 | } else { | |
| 583 | if (!outcome.ok) { | |
| 584 | state.deploying = undefined; | |
| 585 | lease(); | |
| 585 | 586 | state.lastError = outcome.error; |
| 586 | 587 | const done = finishRun(this.app.db, run.id, { |
| 587 | 588 | state: "failed", |
| @@ -593,7 +594,50 @@ export class AppsRuntime { | ||
| 593 | 594 | `the previous deployment (${state.live.commitOid.slice(0, 7)}) keeps serving`, |
| 594 | 595 | ); |
| 595 | 596 | if (done) announceRun(this.app, done); |
| 597 | return; | |
| 596 | 598 | } |
| 599 | ||
| 600 | // It answers: swap it in now, before its tests are done. `state.deploying` | |
| 601 | // stays set — the run is still open, and a stop or a teardown must still | |
| 602 | // be able to reach in and kill the test step — until they finish below. | |
| 603 | const previous = state.live; | |
| 604 | state.live = outcome.value; | |
| 605 | state.lastError = undefined; | |
| 606 | if (previous !== undefined) { | |
| 607 | write( | |
| 608 | `stopping the previous deployment (${previous.commitOid.slice(0, 7)})`, | |
| 609 | ); | |
| 610 | await previous.handle.stop(); | |
| 611 | if (previous.dir !== outcome.value.dir) | |
| 612 | rmSync(previous.dir, { recursive: true, force: true }); | |
| 613 | } | |
| 614 | this.watch(state, repo, outcome.value); | |
| 615 | const url = this.urlFor(state.app, state.env); | |
| 616 | write(`serving ${url}`); | |
| 617 | ||
| 618 | const tested = await outcome.tested; | |
| 619 | state.deploying = undefined; | |
| 620 | lease(); | |
| 621 | if (tested.ok) { | |
| 622 | const done = finishRun(this.app.db, run.id, { | |
| 623 | state: "ok", | |
| 624 | summary: `running at ${url}`, | |
| 625 | }); | |
| 626 | write(`done: ${url}`); | |
| 627 | if (done) announceRun(this.app, done); | |
| 628 | return; | |
| 629 | } | |
| 630 | // The tests failed, but the deployment that answered is what was asked | |
| 631 | // for: it keeps serving, and the run — the check a pull shows — fails. | |
| 632 | if (state.live === outcome.value) | |
| 633 | state.live = { ...outcome.value, testsFailed: tested.error }; | |
| 634 | const done = finishRun(this.app.db, run.id, { | |
| 635 | state: "failed", | |
| 636 | summary: `test: ${tested.error}`, | |
| 637 | }); | |
| 638 | write(`failed: test: ${tested.error}`); | |
| 639 | write(`serving ${url} anyway — its tests failed`); | |
| 640 | if (done) announceRun(this.app, done); | |
| 597 | 641 | } |
| 598 | 642 | |
| 599 | 643 | private async bringUp( |
| @@ -602,7 +646,10 @@ export class AppsRuntime { | ||
| 602 | 646 | run: Run, |
| 603 | 647 | write: (line: string) => void, |
| 604 | 648 | signal: AbortSignal, |
| 605 | ): Promise<{ ok: true; value: Live } | { ok: false; error: string }> { | |
| 649 | ): Promise< | |
| 650 | | { ok: true; value: Live; tested: Promise<Result<void, string>> } | |
| 651 | | { ok: false; error: string } | |
| 652 | > { | |
| 606 | 653 | const bare = repoDir(this.app.reposRoot, repo.ownerName, repo.name); |
| 607 | 654 | const [forgeJson, packageJson] = await Promise.all([ |
| 608 | 655 | readFileAt(bare, run.commitOid, "forge.json"), |
| @@ -668,12 +715,13 @@ export class AppsRuntime { | ||
| 668 | 715 | ok: true, |
| 669 | 716 | value: { |
| 670 | 717 | commitOid: run.commitOid, |
| 671 | handle: started.value, | |
| 718 | handle: started.value.handle, | |
| 672 | 719 | dir, |
| 673 | 720 | runId: run.id, |
| 674 | 721 | since: new Date().toISOString(), |
| 675 | 722 | manifest: manifest.value, |
| 676 | 723 | }, |
| 724 | tested: started.value.tested, | |
| 677 | 725 | }; |
| 678 | 726 | } |
| 679 | 727 |
src/web/pages/apps.ts +11 −3
| @@ -459,7 +459,9 @@ const envFacts = (base: string, env: EnvStatus): Html => | ||
| 459 | 459 | html`<a href="${base}/runs/${id}" |
| 460 | 460 | >${env.state === "deploying" |
| 461 | 461 | ? "deploy in progress" |
| 462 | : "last deploy"}</a | |
| 462 | : env.note !== undefined | |
| 463 | ? "verifying tests" | |
| 464 | : "last deploy"}</a | |
| 463 | 465 | >`, |
| 464 | 466 | )} |
| 465 | 467 | </div>`; |
| @@ -472,10 +474,13 @@ function envCard(base: string, env: EnvStatus, writer: boolean): Html { | ||
| 472 | 474 | <p class="env-facts mt"> |
| 473 | 475 | <span class="mono truncate" title="${env.url}">${env.url}</span> |
| 474 | 476 | </p> |
| 477 | ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)} | |
| 475 | 478 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}`, |
| 476 | 479 | { |
| 477 | 480 | extra: "env-card", |
| 478 | ...(env.state === "failed" ? { tone: "danger" as const } : {}), | |
| 481 | ...(env.state === "failed" || env.error !== undefined | |
| 482 | ? { tone: "danger" as const } | |
| 483 | : {}), | |
| 479 | 484 | head: html`${icon(number === undefined ? "rocket" : "pull")} |
| 480 | 485 | <h2> |
| 481 | 486 | ${number === undefined |
| @@ -686,6 +691,7 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 686 | 691 | } |
| 687 | 692 | return card( |
| 688 | 693 | html`${envFacts(base, env)} |
| 694 | ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)} | |
| 689 | 695 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)} |
| 690 | 696 | <div class="bar mt"> |
| 691 | 697 | ${btn("Open preview", { |
| @@ -698,7 +704,9 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 698 | 704 | { |
| 699 | 705 | head, |
| 700 | 706 | extra: "env-card", |
| 701 | ...(env.state === "failed" ? { tone: "danger" as const } : {}), | |
| 707 | ...(env.state === "failed" || env.error !== undefined | |
| 708 | ? { tone: "danger" as const } | |
| 709 | : {}), | |
| 702 | 710 | }, |
| 703 | 711 | ); |
| 704 | 712 | } |
test/apps-adversarial.test.ts +131
| @@ -1075,6 +1075,137 @@ describe("apps adversarial: teardown races, grants, the proxy, and rule 1/4", () | ||
| 1075 | 1075 | ); |
| 1076 | 1076 | }, 60_000); |
| 1077 | 1077 | |
| 1078 | test("install failing fails before test or start ever run — nothing from a half-installed tree", async () => { | |
| 1079 | const made = await newApp(app, alice, { | |
| 1080 | name: "badinstall", | |
| 1081 | visibility: "public", | |
| 1082 | }); | |
| 1083 | expect(made.ok).toBe(true); | |
| 1084 | if (!made.ok) return; | |
| 1085 | const repo = made.value.repo; | |
| 1086 | await settled(repo.id, (r) => r.env === "production"); | |
| 1087 | ||
| 1088 | const testMarker = join(work, `install-test-marker-${Date.now()}`); | |
| 1089 | const runMarker = join(work, `install-run-marker-${Date.now()}`); | |
| 1090 | const committed = await commitToBranch(app, alice, repo, { | |
| 1091 | branch: "bad-install", | |
| 1092 | base: repo.defaultBranch, | |
| 1093 | message: "install fails", | |
| 1094 | files: [ | |
| 1095 | { | |
| 1096 | path: "forge.json", | |
| 1097 | content: `${JSON.stringify( | |
| 1098 | { | |
| 1099 | app: { | |
| 1100 | install: "exit 1", | |
| 1101 | run: `touch ${runMarker} && bun run src/server.ts`, | |
| 1102 | test: `touch ${testMarker} && bun test`, | |
| 1103 | }, | |
| 1104 | }, | |
| 1105 | null, | |
| 1106 | 2, | |
| 1107 | )}\n`, | |
| 1108 | }, | |
| 1109 | ], | |
| 1110 | }); | |
| 1111 | expect(committed.ok).toBe(true); | |
| 1112 | if (!committed.ok) return; | |
| 1113 | const opened = await openPull(app, alice, repo, { | |
| 1114 | title: "Bad install", | |
| 1115 | baseBranch: repo.defaultBranch, | |
| 1116 | headBranch: "bad-install", | |
| 1117 | }); | |
| 1118 | expect(opened.ok).toBe(true); | |
| 1119 | if (!opened.ok) return; | |
| 1120 | ||
| 1121 | const failed = await settled( | |
| 1122 | repo.id, | |
| 1123 | (r) => | |
| 1124 | r.env === `pull-${opened.value.number}` && | |
| 1125 | r.commitOid === committed.value.commitOid, | |
| 1126 | ); | |
| 1127 | expect(failed.state).toBe("failed"); | |
| 1128 | expect(failed.summary).toContain("install"); | |
| 1129 | // Neither `test` nor `run` ever executed. | |
| 1130 | expect(existsSync(testMarker)).toBe(false); | |
| 1131 | expect(existsSync(runMarker)).toBe(false); | |
| 1132 | expect( | |
| 1133 | (await viaHost(`badinstall--${opened.value.number}.${DOMAIN}`)).status, | |
| 1134 | ).toBe(502); | |
| 1135 | }, 60_000); | |
| 1136 | ||
| 1137 | test("closing a pull whose deploy already answered kills its still-running test step — no orphan `bun test`", async () => { | |
| 1138 | const made = await newApp(app, alice, { | |
| 1139 | name: "slowtest", | |
| 1140 | visibility: "public", | |
| 1141 | }); | |
| 1142 | expect(made.ok).toBe(true); | |
| 1143 | if (!made.ok) return; | |
| 1144 | const repo = made.value.repo; | |
| 1145 | await settled(repo.id, (r) => r.env === "production"); | |
| 1146 | ||
| 1147 | const marker = join(work, `slowtest-marker-${Date.now()}`); | |
| 1148 | const committed = await commitToBranch(app, alice, repo, { | |
| 1149 | branch: "slow-test", | |
| 1150 | base: repo.defaultBranch, | |
| 1151 | message: "slow test", | |
| 1152 | files: [ | |
| 1153 | { | |
| 1154 | path: "forge.json", | |
| 1155 | content: `${JSON.stringify( | |
| 1156 | { | |
| 1157 | app: { | |
| 1158 | run: "bun run src/server.ts", | |
| 1159 | test: `sleep 5 && touch ${marker}`, | |
| 1160 | }, | |
| 1161 | }, | |
| 1162 | null, | |
| 1163 | 2, | |
| 1164 | )}\n`, | |
| 1165 | }, | |
| 1166 | ], | |
| 1167 | }); | |
| 1168 | expect(committed.ok).toBe(true); | |
| 1169 | if (!committed.ok) return; | |
| 1170 | const opened = await openPull(app, alice, repo, { | |
| 1171 | title: "Slow test", | |
| 1172 | baseBranch: repo.defaultBranch, | |
| 1173 | headBranch: "slow-test", | |
| 1174 | }); | |
| 1175 | expect(opened.ok).toBe(true); | |
| 1176 | if (!opened.ok) return; | |
| 1177 | ||
| 1178 | // Wait for the app to answer — well before the test step's sleep ends. | |
| 1179 | const upDeadline = Date.now() + 10_000; | |
| 1180 | let up = false; | |
| 1181 | while (Date.now() < upDeadline) { | |
| 1182 | if ( | |
| 1183 | (await viaHost(`slowtest--${opened.value.number}.${DOMAIN}`)) | |
| 1184 | .status === 200 | |
| 1185 | ) { | |
| 1186 | up = true; | |
| 1187 | break; | |
| 1188 | } | |
| 1189 | await Bun.sleep(50); | |
| 1190 | } | |
| 1191 | expect(up).toBe(true); | |
| 1192 | ||
| 1193 | // Close it while the test step is still sleeping. | |
| 1194 | const closed = await mergePull( | |
| 1195 | app, | |
| 1196 | alice, | |
| 1197 | repo, | |
| 1198 | getPull(app.db, repo.id, opened.value.number)!, | |
| 1199 | "merge", | |
| 1200 | ); | |
| 1201 | expect(closed.ok).toBe(true); | |
| 1202 | ||
| 1203 | // Wait well past the sleep the test step was doing, and confirm it | |
| 1204 | // never finished: the marker it would have written is not there. | |
| 1205 | await Bun.sleep(6_500); | |
| 1206 | expect(existsSync(marker)).toBe(false); | |
| 1207 | }, 60_000); | |
| 1208 | ||
| 1078 | 1209 | test("private grant: forged, tampered, expired, wrong-env, and open-redirect are all refused; cookie is HttpOnly/SameSite", async () => { |
| 1079 | 1210 | const made = await newApp(app, alice, { |
| 1080 | 1211 | name: "grants", |
test/apps.test.ts +76 −3
| @@ -391,10 +391,17 @@ describe("apps: production, previews, data, gates, crew", () => { | ||
| 391 | 391 | const refused = await mergePull(app, alice, repo, opened.value, "merge"); |
| 392 | 392 | expect(refused.ok).toBe(false); |
| 393 | 393 | if (!refused.ok) expect(refused.error).toBe("checks_required"); |
| 394 | // The preview shows why instead of a gateway error. | |
| 394 | // The app itself never broke, only its tests: the preview answers with | |
| 395 | // its own page rather than a gateway error, because a deployment that | |
| 396 | // answers is what was asked for. | |
| 395 | 397 | const page = await viaHost(`hello--${opened.value.number}.${DOMAIN}`); |
| 396 | expect(page.status).toBe(502); | |
| 397 | expect(await page.text()).toContain("test"); | |
| 398 | expect(page.status).toBe(200); | |
| 399 | expect(await page.text()).toContain( | |
| 400 | `preview of pull #${opened.value.number}`, | |
| 401 | ); | |
| 402 | const status = runtime.envStatus(record, `pull-${opened.value.number}`); | |
| 403 | expect(status?.state).toBe("running"); | |
| 404 | expect(status?.error).toContain("tests failed"); | |
| 398 | 405 | |
| 399 | 406 | const fixed = await commitToBranch(app, alice, repo, { |
| 400 | 407 | branch: "broken", |
| @@ -425,6 +432,72 @@ describe("apps: production, previews, data, gates, crew", () => { | ||
| 425 | 432 | expect(merged.ok).toBe(true); |
| 426 | 433 | }, 120_000); |
| 427 | 434 | |
| 435 | test("a deploy whose test step sleeps: the preview answers before it finishes, and the run stays open until it does", async () => { | |
| 436 | const committed = await commitToBranch(app, alice, repo, { | |
| 437 | branch: "slow-test", | |
| 438 | base: repo.defaultBranch, | |
| 439 | message: "Slow test", | |
| 440 | files: [ | |
| 441 | { | |
| 442 | path: "forge.json", | |
| 443 | content: `${JSON.stringify( | |
| 444 | { | |
| 445 | app: { | |
| 446 | run: "bun run src/server.ts", | |
| 447 | test: "sleep 3 && bun test", | |
| 448 | tty: "bun run src/tty.ts", | |
| 449 | }, | |
| 450 | }, | |
| 451 | null, | |
| 452 | 2, | |
| 453 | )}\n`, | |
| 454 | }, | |
| 455 | ], | |
| 456 | }); | |
| 457 | expect(committed.ok).toBe(true); | |
| 458 | if (!committed.ok) return; | |
| 459 | const opened = await openPull(app, alice, repo, { | |
| 460 | title: "Slow test", | |
| 461 | baseBranch: repo.defaultBranch, | |
| 462 | headBranch: "slow-test", | |
| 463 | }); | |
| 464 | expect(opened.ok).toBe(true); | |
| 465 | if (!opened.ok) return; | |
| 466 | ||
| 467 | const start = Date.now(); | |
| 468 | const deadline = start + 15_000; | |
| 469 | let answered = false; | |
| 470 | while (Date.now() < deadline) { | |
| 471 | if ( | |
| 472 | (await viaHost(`hello--${opened.value.number}.${DOMAIN}`)).status === | |
| 473 | 200 | |
| 474 | ) { | |
| 475 | answered = true; | |
| 476 | break; | |
| 477 | } | |
| 478 | await Bun.sleep(50); | |
| 479 | } | |
| 480 | expect(answered).toBe(true); | |
| 481 | // It answered well inside the test step's own sleep. | |
| 482 | expect(Date.now() - start).toBeLessThan(3_000); | |
| 483 | ||
| 484 | const status = runtime.envStatus(record, `pull-${opened.value.number}`); | |
| 485 | expect(status?.state).toBe("running"); | |
| 486 | expect(status?.note).toContain("verifying its tests"); | |
| 487 | ||
| 488 | const run = listRuns(app.db, repo.id, { | |
| 489 | env: `pull-${opened.value.number}`, | |
| 490 | }).find((r) => r.commitOid === committed.value.commitOid); | |
| 491 | expect(run?.state).toBe("running"); | |
| 492 | ||
| 493 | const done = await settled( | |
| 494 | (r) => | |
| 495 | r.env === `pull-${opened.value.number}` && | |
| 496 | r.commitOid === committed.value.commitOid, | |
| 497 | ); | |
| 498 | expect(done.state).toBe("ok"); | |
| 499 | }, 30_000); | |
| 500 | ||
| 428 | 501 | test("the crew: an agent reads and writes its repo, never the default branch, never a merge", async () => { |
| 429 | 502 | const made = await createAgent(app, alice, { |
| 430 | 503 | username: "bot", |
new above
offline