Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/e2e-phase-6-sigint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"playground-cli": patch
---

Nightly E2E now exercises the SIGINT cleanup path: a new `nightly-chaos-sigint` cell sends SIGINT to `dot deploy` mid-flight and asserts the process-guard's runAllCleanupAndExit handler exits cleanly within 5s with code 130 (or SIGINT signal).
4 changes: 4 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ jobs:
- cell: nightly-rejections
# source: e2e/cli/deploy.test.ts → describe("dot deploy — rejects --no-contract-build with no artefacts")
pattern: "rejects --no-contract-build"
- cell: nightly-chaos-sigint
# source: e2e/cli/chaos.test.ts → describe("dot deploy — chaos")
pattern: "dot deploy — chaos"
testFile: e2e/cli/chaos.test.ts
steps:
# checkout must run before the local composite action can be loaded.
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ These are things that aren't self-evident from reading the code and have bitten

- **Local launcher:** `tools/e2e-local.sh [smoke|pr|nightly]` — also callable via `pnpm test:e2e:smoke`, `pnpm test:e2e:pr`, `pnpm test:e2e:nightly`.
- **CI workflow:** `.github/workflows/e2e.yml` — runs on PR / push:main / cron 06:00 UTC / workflow_dispatch.
- **CI matrix:** 11 cells across four matrices — `test-no-publish` (parallel: pr-install, pr-preflight, pr-mod, pr-init-session) + `test-publish` (max-parallel: 1: pr-deploy-frontend, pr-deploy-foundry) + `test-nightly-no-publish` (parallel, schedule/dispatch only: nightly-mod-miss, nightly-diagnostic, nightly-rejections) + `test-nightly-publish` (max-parallel: 1, schedule/dispatch only: nightly-deploy-hardhat, nightly-deploy-multi). Each cell runs a subset via `vitest -t "<pattern>"`.
- **CI matrix:** 12 cells across four matrices — `test-no-publish` (parallel: pr-install, pr-preflight, pr-mod, pr-init-session) + `test-publish` (max-parallel: 1: pr-deploy-frontend, pr-deploy-foundry) + `test-nightly-no-publish` (parallel, schedule/dispatch only: nightly-mod-miss, nightly-diagnostic, nightly-rejections, nightly-chaos-sigint) + `test-nightly-publish` (max-parallel: 1, schedule/dispatch only: nightly-deploy-hardhat, nightly-deploy-multi). Each cell runs a subset via `vitest -t "<pattern>"`.
- **Test files:** `e2e/cli/*.test.ts` (vitest, spawned via `bun run src/index.ts`).
- **Reports directory:** `e2e-reports/junit.xml` + `e2e-reports/dot-runs.log` (gitignored).
- **Tag prefix:** `DOT_TAG=e2e-{ci|local}-{trigger}` so Sentry dashboards filter test traffic. The CLI plumbs `DOT_TAG` into the `cli.tag` root-span attribute via `src/telemetry-config.ts`.
Expand Down
123 changes: 123 additions & 0 deletions e2e/cli/chaos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Chaos tests for `dot deploy` — verifies that the process-guard cleanup
* + SIGINT handling work end-to-end.
*
* These run only in the nightly-chaos-sigint cell. PR runs skip them.
*
* Design notes
* ------------
* • We spawn the CLI directly via execa (not the `dot()` helper) so we get a
* child handle we can signal mid-flight.
* • The sentinel `"▸ storage-and-dotns"` appears in headless-mode stdout
* exactly once, from `logHeadlessEvent` in src/commands/deploy/index.ts
* when the storage phase starts. It is specific to the headless code path
* and does not rely on bulletin-deploy banner text (which is intercepted by
* the log parser and never reaches stdout).
* • If the sentinel never fires within 60 s (e.g. the deploy errors during
* preflight), the test still sends SIGINT and asserts clean exit semantics.
* The 5 s wall-clock assertion still exercises the process-guard — just
* against a process that may already be winding down rather than one in
* the middle of a chunk upload.
*/

import { describe, test, expect } from "vitest";
import { execa } from "execa";
import { resolve } from "node:path";
import { SIGNER, E2E_DOMAINS } from "./fixtures/accounts.js";
import { fixturePath } from "./fixtures/templates.js";

const REPO_ROOT = resolve(import.meta.dirname, "../..");
const CLI_ENTRY = resolve(REPO_ROOT, "src/index.ts");

/** How long to wait for the storage-phase sentinel before sending SIGINT anyway. */
const SENTINEL_WAIT_MS = 60_000;

/** Expected maximum elapsed time from SIGINT to process exit. */
const MAX_EXIT_MS = 5_000;

describe("dot deploy — chaos", () => {
test("SIGINT mid-deploy exits with code 130 within 5s", { timeout: 120_000 }, async () => {
const frontendOnly = fixturePath("frontend-only");

const child = execa(
"bun",
[
"run",
CLI_ENTRY,
"deploy",
"--signer",
"dev",
"--domain",
E2E_DOMAINS.chaos,
"--buildDir",
resolve(frontendOnly, "dist"),
"--playground",
"--suri",
SIGNER.suri,
"--dir",
frontendOnly,
],
{
cwd: REPO_ROOT,
env: { ...process.env, DOT_TAG: "e2e-chaos-sigint", DOT_TELEMETRY: "1" },
reject: false,
},
);

// Accumulate output from both streams so the diagnostic tail is
// available if the assertion fails.
let buf = "";
child.stdout?.on("data", (chunk: Buffer) => {
buf += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
buf += chunk.toString();
});

// Wait for the storage phase to start OR for the process to exit early
// (e.g. availability check fails, network error during preflight, etc.)
// OR for the 60 s sentinel budget to expire.
//
// Sentinel: headless logHeadlessEvent writes `▸ storage-and-dotns…\n`
// to stdout when the storage-and-dotns phase begins.
const sentinel = "▸ storage-and-dotns";
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), SENTINEL_WAIT_MS);

const checkBuf = () => {
if (buf.includes(sentinel)) {
clearTimeout(timeout);
resolve();
}
};

child.stdout?.on("data", checkBuf);
child.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});

// Send SIGINT and measure how long the process takes to exit.
const t0 = Date.now();
child.kill("SIGINT");
const result = await child;
const elapsedMs = Date.now() - t0;

// process-guard.ts calls runAllCleanupAndExit(130) on SIGINT.
// execa reflects this as exitCode=130. If the handler doesn't get
// to run (e.g. bun propagates the OS signal before our handler fires),
// execa sets signal="SIGINT" instead. Both outcomes are acceptable:
// what we're asserting is that the process is GONE cleanly within 5 s.
const cleanExit = result.exitCode === 130 || result.signal === "SIGINT";
expect(
cleanExit,
`expected exit code 130 or signal SIGINT, got exitCode=${result.exitCode} signal=${result.signal}\nlast output:\n${buf.slice(-500)}`,
).toBe(true);

expect(
elapsedMs,
`process took ${elapsedMs} ms to exit after SIGINT (limit ${MAX_EXIT_MS} ms)\nlast output:\n${buf.slice(-500)}`,
).toBeLessThan(MAX_EXIT_MS);
});
});
8 changes: 8 additions & 0 deletions e2e/cli/fixtures/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,12 @@ export const E2E_DOMAINS = {
cdm: "e2e-cli-cdm",
hardhat: "e2e-cli-hardhat",
multi: "e2e-cli-multi",
/**
* Used by the nightly-chaos-sigint cell only. The deploy is interrupted by
* SIGINT before it completes, so this domain is never actually registered.
* It is kept separate from `storage` to avoid any race with the happy-path
* storage test in test-publish when both run in a nightly that triggers all
* matrices.
*/
chaos: "e2e-cli-chaos",
} as const;
Loading