From da003ba44963089e6b13a36d31f7b884faba84ef Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 22 Jul 2026 09:45:11 +0800 Subject: [PATCH 01/16] test(deploy): real-build integration + gated live e2e for the Cloudflare backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing unit tests drive `runCloudflareDeploy` end to end but stub the `vite build`, wrangler, and Cloudflare, asserting against hand-written emitted configs. Nothing verified the guard's core premise — that a REAL `@cloudflare/vite-plugin` + wrangler build emits exactly the shape `verifyGeneratedDeployConfig` reconstructs, adding nothing that would false-positive a legitimate deploy. Add two layers of coverage. Part A — `test/integration/cloudflare-deploy-build.test.ts` (CI-runnable, no Cloudflare account). Runs a real `vite build` of the kitchen-sink playground (D1 + KV + R2 + AI + sandbox/containers + cron + queue) with an overlaid root `wrangler.jsonc` carrying real-shaped ids, reconstructs `expected` with the same exported builders `runCloudflareDeploy` uses, and asserts the real emitted `dist/ssr/wrangler.json` passes the real guard unmodified (the FP-safety lock — a future plugin/wrangler field addition breaks this instead of blocking a real deploy). Negative cases start from the same real emission, apply one minimal tamper (injected container, swapped D1 id, injected EXTRA_DB / cron / services / routes), and assert rejection. Part B — `test/e2e/cloudflare-deploy-live.test.ts` (gated). A real `void deploy --backend cloudflare --provision` of the spa playground, verified against a live 200 and torn down, wrapped in `describe.skipIf(!VOID_E2E_CF_TOKEN)` so it is inert without credentials. The `test/e2e/**` glob is wired into the void integration test project so it is collected and skipped cleanly in CI rather than left undiscovered; the live run is a manual operator step with their own token. `parseRootWranglerConfig` is exported so the integration test reconstructs the pre-build root config the same way the orchestrator does. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- packages/void/src/cli/deploy.ts | 2 +- packages/void/test/e2e/README.md | 31 +++ .../test/e2e/cloudflare-deploy-live.test.ts | 156 +++++++++++ .../cloudflare-deploy-build.test.ts | 255 ++++++++++++++++++ vite.config.ts | 3 + 5 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 packages/void/test/e2e/README.md create mode 100644 packages/void/test/e2e/cloudflare-deploy-live.test.ts create mode 100644 packages/void/test/integration/cloudflare-deploy-build.test.ts diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 16bcdadda..5b9bfba84 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -2094,7 +2094,7 @@ function isCleanWranglerExit(result: DeployWranglerRunResult): boolean { * unreadable — callers then treat every field as unset (safe: a genuinely broken * config fails later in wrangler with a clear error). */ -function parseRootWranglerConfig(configPath: string): Record { +export function parseRootWranglerConfig(configPath: string): Record { if (!existsSync(configPath)) { return {}; } diff --git a/packages/void/test/e2e/README.md b/packages/void/test/e2e/README.md new file mode 100644 index 000000000..03f00c257 --- /dev/null +++ b/packages/void/test/e2e/README.md @@ -0,0 +1,31 @@ +# `packages/void/test/e2e` + +Gated live-account end-to-end tests. These run REAL Cloudflare operations against a REAL account, +so they are **skipped by default** and never run in CI. + +## `cloudflare-deploy-live.test.ts` + +Runs a real `void deploy --backend cloudflare --provision` of `playground/spa`, verifies the live +worker answers HTTP 200, then deletes the worker and every provisioned resource. + +The suite is skipped unless BOTH env vars are set: + +| Env var | Purpose | +| ------------------------ | -------------------------------------------------------------------------- | +| `VOID_E2E_CF_TOKEN` | Cloudflare API token that can create + delete Workers Scripts, D1, and KV. | +| `VOID_E2E_CF_ACCOUNT_ID` | Cloudflare account id to deploy into. | + +With no env vars the suite SKIPS cleanly (0 run, N skipped) — this is the CI behavior. + +Run it manually with your own throwaway credentials: + +```sh +# Ensure the CLI is built first (dist/cli/cli.mjs): +vp run build:core + +VOID_E2E_CF_TOKEN=... VOID_E2E_CF_ACCOUNT_ID=... vp test run packages/void/test/e2e +``` + +The worker name is fixed (`void-e2e-cf-deploy`), so a crashed run leaves deterministically named +resources (`void-e2e-cf-deploy`, `void-e2e-cf-deploy-db`, `void-e2e-cf-deploy-kv`) that a rerun — or +`wrangler delete` — can clean up. Teardown runs even when the deploy or an assertion fails. diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts new file mode 100644 index 000000000..107e679ae --- /dev/null +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -0,0 +1,156 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'pathe'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; + +// --------------------------------------------------------------------------------------------- +// Gated live-account e2e for `void deploy --backend cloudflare --provision`. +// +// INERT in normal CI. The whole suite is skipped unless BOTH env vars are present: +// - VOID_E2E_CF_TOKEN a Cloudflare API token with permissions to create + delete Workers +// Scripts, D1, and KV in the target account. +// - VOID_E2E_CF_ACCOUNT_ID the Cloudflare account id to deploy into. +// +// When they are set, this runs a REAL provision + deploy of the `playground/spa` app (D1 + KV + +// cron) under a FIXED, throwaway worker name — so reruns are idempotent and every provisioned +// resource is cleanable by a stable name — asserts the live worker answers HTTP 200, then DELETES +// the worker + the provisioned D1 database + KV namespace in `afterAll` (which runs even on +// failure) and restores the overlaid root config. +// +// The live path is a MANUAL operator step: this repo's CI has no token, so the suite only ever +// SKIPS here. Do not commit a token; export the two vars locally to exercise the real path: +// VOID_E2E_CF_TOKEN=... VOID_E2E_CF_ACCOUNT_ID=... vp test run packages/void/test/e2e +// --------------------------------------------------------------------------------------------- + +const CF_TOKEN = process.env.VOID_E2E_CF_TOKEN ?? ''; +const CF_ACCOUNT_ID = process.env.VOID_E2E_CF_ACCOUNT_ID ?? ''; +const E2E_ENABLED = CF_TOKEN.length > 0 && CF_ACCOUNT_ID.length > 0; + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PACKAGE_ROOT = join(ROOT, 'packages', 'void'); +const CLI_ENTRY = join(PACKAGE_ROOT, 'dist', 'cli', 'cli.mjs'); +const WRANGLER_BIN = join(ROOT, 'node_modules', '.bin', 'wrangler'); + +// Deploy target: the SPA playground (real, buildable Void app exercising D1 + KV + a cron). The +// deploy CLI runs its own build → provision → migrate → deploy pipeline against this dir. +const APP = join(ROOT, 'playground', 'spa'); +const APP_WRANGLER = join(APP, 'wrangler.jsonc'); + +// A FIXED throwaway worker name (no Date.now()/random, so a crashed run leaves a deterministically +// named worker + resources the next run — or an operator — can delete). The provisioner derives the +// resource names from it (`suffixedResourceName`): `-db` (D1) and `-kv` (KV). +const WORKER_NAME = 'void-e2e-cf-deploy'; +const D1_NAME = `${WORKER_NAME}-db`; +const KV_TITLE = `${WORKER_NAME}-kv`; + +// The overlaid root config: only the throwaway worker name + a compat date. `--provision` writes the +// resolved `account_id` and the real binding ids into it during the deploy; teardown restores it. +const OVERLAY_WRANGLER = { name: WORKER_NAME, compatibility_date: '2026-02-24' }; + +let wranglerSnapshotTaken = false; +let wranglerOriginal: string | undefined; +let deployOutput = ''; +let deployStatus: number | null = null; + +/** The credentialed env every real Cloudflare call (deploy + teardown) shares. */ +function cloudEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + CLOUDFLARE_API_TOKEN: CF_TOKEN, + CLOUDFLARE_ACCOUNT_ID: CF_ACCOUNT_ID, + }; +} + +/** Best-effort `wrangler` invocation for teardown — never throws, so afterAll always continues. */ +function wrangler(args: Array): { status: number; output: string } { + const res = spawnSync(WRANGLER_BIN, args, { + cwd: APP, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + env: cloudEnv(), + }); + return { status: res.status ?? 1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; +} + +/** Extract the deployed workers.dev URL the CLI prints on a successful deploy. */ +function extractWorkerUrl(output: string): string | undefined { + return output.match(/https:\/\/[^\s]*\.workers\.dev[^\s]*/)?.[0]; +} + +/** Poll the live worker until it answers (edge propagation can lag the deploy by a few seconds). */ +async function fetchWithRetry(url: string, attempts = 10): Promise { + let lastError: unknown; + for (let i = 0; i < attempts; i++) { + try { + const res = await fetch(url); + if (res.status < 500) { + return res; + } + lastError = new Error(`status ${res.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + throw lastError ?? new Error(`worker at ${url} never answered`); +} + +describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (live account)', () => { + beforeAll(() => { + // Snapshot BEFORE any filesystem work so an early failure never deletes a developer-local file. + wranglerOriginal = existsSync(APP_WRANGLER) ? readFileSync(APP_WRANGLER, 'utf-8') : undefined; + wranglerSnapshotTaken = true; + writeFileSync(APP_WRANGLER, `${JSON.stringify(OVERLAY_WRANGLER, null, 2)}\n`); + + const res = spawnSync('node', [CLI_ENTRY, 'deploy', '--backend', 'cloudflare', '--provision'], { + cwd: APP, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + env: cloudEnv(), + }); + deployStatus = res.status; + deployOutput = `${res.stdout ?? ''}${res.stderr ?? ''}`; + }, 600_000); + + afterAll(() => { + // Tear DOWN every provisioned resource + the worker, even if the deploy or an assertion failed. + // Each call is best-effort and independent so one failure never blocks the others. + if (E2E_ENABLED) { + wrangler(['delete', '--name', WORKER_NAME, '--force']); + wrangler(['d1', 'delete', D1_NAME, '--skip-confirmation']); + // KV delete needs the namespace id — resolve it from the account's namespace list by title. + const list = wrangler(['kv', 'namespace', 'list']); + try { + const namespaces = JSON.parse(list.output) as Array<{ id: string; title: string }>; + // wrangler titles a namespace `-` — match the derived KV title as a suffix. + const ns = namespaces.find((n) => n.title === KV_TITLE || n.title.endsWith(KV_TITLE)); + if (ns) { + wrangler(['kv', 'namespace', 'delete', '--namespace-id', ns.id]); + } + } catch { + // A non-JSON list (auth failure / API change) leaves the namespace for manual cleanup. + } + } + // Restore the app's original root config (or remove the overlay if it had none). + if (wranglerSnapshotTaken) { + if (wranglerOriginal === undefined) { + rmSync(APP_WRANGLER, { force: true }); + } else { + writeFileSync(APP_WRANGLER, wranglerOriginal); + } + } + }, 300_000); + + it('provisions resources and deploys the worker', () => { + expect(deployStatus, deployOutput).toBe(0); + // The orchestrator names each provisioned resource + prints the deployed URL. + expect(deployOutput).toMatch(/https:\/\/[^\s]*\.workers\.dev/); + }); + + it('serves a live 200 from the deployed worker', async () => { + const base = extractWorkerUrl(deployOutput); + expect(base, deployOutput).toBeDefined(); + const res = await fetchWithRetry(`${base!.replace(/\/$/, '')}/api/hello`); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/void/test/integration/cloudflare-deploy-build.test.ts b/packages/void/test/integration/cloudflare-deploy-build.test.ts new file mode 100644 index 000000000..e472fcb85 --- /dev/null +++ b/packages/void/test/integration/cloudflare-deploy-build.test.ts @@ -0,0 +1,255 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import spawn from 'cross-spawn'; +import { join } from 'pathe'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { + computeDesiredCloudflareResources, + parseRootWranglerConfig, + reconstructExpectedContainers, + snapshotBindingIdentities, + snapshotPassthroughIdentities, + snapshotPassthroughRawFields, + verifyGeneratedDeployConfig, +} from '../../src/cli/deploy.ts'; +import { readConfig } from '../../src/config.ts'; + +// Real-build FP-safety lock for `void deploy --backend cloudflare`. The 600+ unit tests drive +// `runCloudflareDeploy` with a STUBBED `vite build` and HAND-WRITTEN emitted configs, so nothing +// there proves the guard's core premise: that a REAL `@cloudflare/vite-plugin` + wrangler build +// emits exactly the shape `verifyGeneratedDeployConfig` reconstructs, adding nothing that would +// false-positive a legitimate deploy. This test runs a real `vite build` of the richest playground +// (kitchen-sink: D1 + KV + R2 + AI + sandbox/containers + cron + queue), reconstructs `expected` +// with the SAME exported builders `runCloudflareDeploy` uses (steps 1 + 5/6), and asserts the real +// emission PASSES the real guard unmodified. A future plugin/wrangler upgrade that adds a field +// would break this lock instead of surfacing as a production deploy-blocking false positive. +// +// Negative cases start from the SAME real emitted artifact, apply one minimal tamper in memory, +// write it back to the path the guard reads, and assert it is REJECTED — real-shape teeth. + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PLAYGROUND = 'playground/kitchen-sink'; +const PG = join(ROOT, PLAYGROUND); +const CONFIG_PATH = join(PG, 'wrangler.jsonc'); +const EMITTED_PATH = join(PG, 'dist', 'ssr', 'wrangler.json'); + +// Real-SHAPED ids the guard treats as genuine (`isRealD1Id` UUID / `isRealKvId` 32-hex). Because +// `void deploy --provision` writes real ids into the root config before the build, and the build +// merges the root config into the emitted artifact, an overlaid root `wrangler.jsonc` carrying +// these ids yields an emitted artifact whose id-bearing bindings match the pinned snapshot — the +// exact clean-deploy condition the guard authorizes. +const ACCOUNT_ID = '0123456789abcdef0123456789abcdef'; +const WORKER_NAME = 'void-kitchen-sink'; +const REAL_D1_ID = '52b3bbc7-c473-4b26-9486-1345f5de61c4'; +const REAL_KV_ID = '7c07b54c9f2a4d1e8b3f6a0c153aa11a'; +const OTHER_D1_ID = '11111111-2222-3333-4444-555555555555'; +const R2_BUCKET_NAME = 'void-kitchen-sink-storage'; + +// The overlaid root config. kitchen-sink ships NO root wrangler config (it relies on void.json + +// inference), so the test writes one declaring `account_id`, `name`, and real-shaped ids for every +// id-bearing binding the source uses, then removes it on teardown. +const OVERLAY_WRANGLER = { + name: WORKER_NAME, + account_id: ACCOUNT_ID, + compatibility_date: '2026-02-24', + d1_databases: [{ binding: 'DB', database_name: 'void-kitchen-sink-db', database_id: REAL_D1_ID }], + kv_namespaces: [{ binding: 'KV', id: REAL_KV_ID }], + r2_buckets: [{ binding: 'STORAGE', bucket_name: R2_BUCKET_NAME }], +}; + +// The `expected` payload `runCloudflareDeploy` (steps 5/6) threads into the guard, rebuilt here from +// the same exported builders. +type ExpectedPayload = Parameters[1]; + +let buildStatus: number | null = null; +let buildOutput = ''; +// The pristine real emission, parsed once and reused (deep-cloned) across every tamper case. +let pristineEmitted: Record = {}; +let expectedPayload: ExpectedPayload; + +// Snapshot guard: only touch/restore `wrangler.jsonc` if we actually reached the snapshot step, so +// an early beforeAll failure never deletes a developer-local file the hook never saw. +let wranglerSnapshotTaken = false; +let wranglerOriginal: string | undefined; + +/** A fresh deep clone of the pristine emitted config, so a tamper never mutates the shared copy. */ +function cloneEmitted(): Record { + return JSON.parse(JSON.stringify(pristineEmitted)) as Record; +} + +/** + * Write a tampered emitted config to the exact path the guard reads (via the CF plugin's + * `.wrangler/deploy/config.json` redirect), run the REAL guard, then restore the pristine emission — + * even if the assertion throws. No rebuild: the tamper models a compromised build editing the one + * artifact `wrangler deploy` uploads. + */ +function guardOnTamper(mutate: (config: Record) => void): { + ok: boolean; + reason?: string; +} { + const tampered = cloneEmitted(); + mutate(tampered); + writeFileSync(EMITTED_PATH, JSON.stringify(tampered)); + try { + return verifyGeneratedDeployConfig(PG, expectedPayload); + } finally { + writeFileSync(EMITTED_PATH, JSON.stringify(pristineEmitted)); + } +} + +describe('playground/kitchen-sink --backend cloudflare real-build deploy guard', () => { + beforeAll(async () => { + // Snapshot BEFORE any other filesystem work. + wranglerOriginal = existsSync(CONFIG_PATH) ? readFileSync(CONFIG_PATH, 'utf-8') : undefined; + wranglerSnapshotTaken = true; + + writeFileSync(CONFIG_PATH, `${JSON.stringify(OVERLAY_WRANGLER, null, 2)}\n`); + // Fresh build inputs: drop the previous dist + the stale CF-plugin deploy pointer so the guard + // trusts only a pointer this build wrote. + rmSync(join(PG, 'dist'), { recursive: true, force: true }); + rmSync(join(PG, '.wrangler', 'deploy', 'config.json'), { force: true }); + + const result = spawn.sync('vp', ['run', '--filter', `./${PLAYGROUND}`, 'build'], { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + buildStatus = result.status; + buildOutput = `${result.stdout ?? ''}${result.stderr ?? ''}`; + expect(buildStatus, buildOutput).toBe(0); + + pristineEmitted = JSON.parse(readFileSync(EMITTED_PATH, 'utf-8')) as Record; + + // Reconstruct `expected` EXACTLY as runCloudflareDeploy does (steps 1 + 5/6): the desired set + + // signals from `computeDesiredCloudflareResources`, and the four snapshots + container set from + // the pre-build root config. + const voidConfig = readConfig(PG); + const { desired, needsAI, needsSandbox, expectedCrons } = + await computeDesiredCloudflareResources(PG, voidConfig, CONFIG_PATH); + const rootConfig = parseRootWranglerConfig(CONFIG_PATH); + expectedPayload = { + accountId: ACCOUNT_ID, + workerName: WORKER_NAME, + desired, + bindingIdentitySnapshot: snapshotBindingIdentities(rootConfig), + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + needsAI, + expectedContainers: reconstructExpectedContainers(voidConfig, PG, rootConfig, needsSandbox), + expectedCrons, + }; + }); + + afterAll(() => { + if (!wranglerSnapshotTaken) { + return; + } + if (wranglerOriginal === undefined) { + rmSync(CONFIG_PATH, { force: true }); + } else { + writeFileSync(CONFIG_PATH, wranglerOriginal); + } + }); + + // --- Proof this is a REAL build (not a hand-written facsimile) --------------------------------- + + it('emits the CF-plugin deploy pointer the guard follows', () => { + const pointer = JSON.parse( + readFileSync(join(PG, '.wrangler', 'deploy', 'config.json'), 'utf-8'), + ) as { configPath?: string }; + expect(pointer.configPath).toBe('../../dist/ssr/wrangler.json'); + }); + + it('merges the pinned account, worker name, and real binding ids into the emitted artifact', () => { + expect(pristineEmitted.account_id).toBe(ACCOUNT_ID); + expect(pristineEmitted.name).toBe(WORKER_NAME); + expect(pristineEmitted.d1_databases).toContainEqual( + expect.objectContaining({ binding: 'DB', database_id: REAL_D1_ID }), + ); + expect(pristineEmitted.kv_namespaces).toContainEqual( + expect.objectContaining({ binding: 'KV', id: REAL_KV_ID }), + ); + expect(pristineEmitted.r2_buckets).toContainEqual( + expect.objectContaining({ binding: 'STORAGE', bucket_name: R2_BUCKET_NAME }), + ); + }); + + it('emits the sandbox container, injected AI binding, cron trigger, and queue bindings', () => { + expect(pristineEmitted.containers).toContainEqual( + expect.objectContaining({ name: 'void-sandbox', class_name: 'Sandbox' }), + ); + expect(pristineEmitted.ai).toEqual({ binding: 'AI' }); + expect(pristineEmitted.triggers).toEqual({ crons: ['* * * * *'] }); + const queues = pristineEmitted.queues as { + producers: Array<{ queue: string }>; + consumers: Array<{ queue: string }>; + }; + expect(queues.producers).toContainEqual(expect.objectContaining({ queue: 'demo-queue' })); + expect(queues.consumers).toContainEqual(expect.objectContaining({ queue: 'demo-queue' })); + }); + + it('reconstructs a container set byte-identical to the emitted sandbox container', () => { + // The highest-value FP-safety lock: `reconstructExpectedContainers` (a pure pre-build function) + // must produce EXACTLY what the CF plugin emitted, or every sandbox deploy would false-positive. + expect(expectedPayload.expectedContainers).toEqual(pristineEmitted.containers); + }); + + // --- Positive: the real emission passes the real guard unmodified (FP-lock) ------------------- + + it('the real emitted config passes verifyGeneratedDeployConfig unmodified', () => { + expect(verifyGeneratedDeployConfig(PG, expectedPayload)).toEqual({ ok: true }); + }); + + // --- Negative: the SAME real emission, minimally tampered, is rejected (teeth) ----------------- + + it('rejects an injected container application', () => { + const result = guardOnTamper((config) => { + (config.containers as Array).push({ class_name: 'Evil', image: '/attacker' }); + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/container application .*injected/i); + }); + + it('rejects a swapped D1 database_id', () => { + const result = guardOnTamper((config) => { + (config.d1_databases as Array<{ database_id: string }>)[0].database_id = OTHER_D1_ID; + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/identity|tampered|flattened/i); + }); + + it('rejects an injected id-bearing binding (EXTRA_DB)', () => { + const result = guardOnTamper((config) => { + (config.d1_databases as Array).push({ + binding: 'EXTRA_DB', + database_name: 'attacker-db', + database_id: OTHER_D1_ID, + }); + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/not present before the build|injected/i); + }); + + it('rejects an injected cron schedule', () => { + const result = guardOnTamper((config) => { + (config.triggers as { crons: Array }).crons.push('0 0 * * *'); + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/cron trigger schedule .*injected/i); + }); + + it('rejects an injected services binding', () => { + const result = guardOnTamper((config) => { + config.services = [{ binding: 'EVIL_SVC', service: 'attacker-worker' }]; + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/services binding .*injected/i); + }); + + it('rejects an injected routes entry', () => { + const result = guardOnTamper((config) => { + config.routes = [{ pattern: 'evil.example.com/*', zone_name: 'example.com' }]; + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/routes value does not match/i); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 7df4cf9bd..c1094bfbd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -327,6 +327,9 @@ export default defineConfig({ hookTimeout, include: [ 'packages/void/test/integration/**/*.test.ts', + // Gated live-account e2e (test/e2e/**). Collected here so it is skipped cleanly in CI + // (no token) rather than silently undiscovered; runs only when its env vars are set. + 'packages/void/test/e2e/**/*.test.ts', 'packages/md/test/integration/**/*.test.ts', ], exclude: metaframeworks, From 8fcfa2c724997c2b213fe7aebeb5aa155bff4aa7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 22 Jul 2026 13:00:08 +0800 Subject: [PATCH 02/16] test(deploy): match the KV title exactly, fail e2e teardown on leaks, and cover ISR_CACHE in the FP-lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three test-quality fixes to the new Cloudflare-backend coverage. Live e2e teardown — match the KV namespace by EXACT title before deleting. Matching the derived title as an `endsWith` suffix could select and delete an unrelated namespace that merely shares the suffix; the provisioner creates and matches the namespace by its exact title, so the teardown does too. Live e2e teardown — surface cleanup failures. Each delete was best-effort with its exit status discarded, so a transient API error or missing delete permission left a worker or database behind while the run still reported green. Collect the nonzero exits and re-throw after all cleanup + config restore, so a leak fails loud with the resources to remove manually. Real-build integration — cover ISR_CACHE in the FP-lock. kitchen-sink enables ISR (pages/revalidate.server.ts), so computeDesiredCloudflareResources adds a second KV binding, ISR_CACHE, beyond the app's own KV. The overlay pinned a real id only for KV, so the guard's clean-pass never exercised the two-KV shape a provisioned ISR deploy builds. Pin a distinct real-shaped ISR_CACHE id; the real build now emits both namespaces and the FP-lock covers them. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .../test/e2e/cloudflare-deploy-live.test.ts | 48 ++++++++++++++----- .../cloudflare-deploy-build.test.ts | 15 +++++- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 107e679ae..6ccfaa4bc 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -114,24 +114,43 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (liv afterAll(() => { // Tear DOWN every provisioned resource + the worker, even if the deploy or an assertion failed. - // Each call is best-effort and independent so one failure never blocks the others. + // Each delete is best-effort and independent so one failure never blocks the others — but a + // nonzero exit (transient API error / missing delete permission) is COLLECTED and re-thrown + // after cleanup + config restore, so a run that leaves a worker or database behind fails LOUD + // instead of reporting green with leaked resources. + const failures: Array = []; + const attemptDelete = (label: string, args: Array): void => { + const res = wrangler(args); + if (res.status !== 0) { + failures.push(`${label} (exit ${res.status})\n${res.output.trim()}`); + } + }; if (E2E_ENABLED) { - wrangler(['delete', '--name', WORKER_NAME, '--force']); - wrangler(['d1', 'delete', D1_NAME, '--skip-confirmation']); - // KV delete needs the namespace id — resolve it from the account's namespace list by title. + attemptDelete(`worker ${WORKER_NAME}`, ['delete', '--name', WORKER_NAME, '--force']); + attemptDelete(`d1 ${D1_NAME}`, ['d1', 'delete', D1_NAME, '--skip-confirmation']); + // KV delete needs the namespace id — resolve it from the account's namespace list by EXACT + // title. The provisioner creates AND matches the namespace by this exact title (the + // provision-time existence check rejects any non-exact title), so an `endsWith` here could + // select and delete an unrelated namespace that merely shares the suffix. const list = wrangler(['kv', 'namespace', 'list']); - try { - const namespaces = JSON.parse(list.output) as Array<{ id: string; title: string }>; - // wrangler titles a namespace `-` — match the derived KV title as a suffix. - const ns = namespaces.find((n) => n.title === KV_TITLE || n.title.endsWith(KV_TITLE)); - if (ns) { - wrangler(['kv', 'namespace', 'delete', '--namespace-id', ns.id]); + if (list.status !== 0) { + failures.push(`kv namespace list (exit ${list.status})\n${list.output.trim()}`); + } else { + try { + const namespaces = JSON.parse(list.output) as Array<{ id: string; title: string }>; + const ns = namespaces.find((n) => n.title === KV_TITLE); + if (ns) { + attemptDelete(`kv ${KV_TITLE}`, ['kv', 'namespace', 'delete', '--namespace-id', ns.id]); + } + } catch { + failures.push( + `kv namespace list returned non-JSON output — delete the ${KV_TITLE} namespace manually`, + ); } - } catch { - // A non-JSON list (auth failure / API change) leaves the namespace for manual cleanup. } } - // Restore the app's original root config (or remove the overlay if it had none). + // Restore the app's original root config (or remove the overlay if it had none) BEFORE surfacing + // any teardown failure, so the working tree is always left clean. if (wranglerSnapshotTaken) { if (wranglerOriginal === undefined) { rmSync(APP_WRANGLER, { force: true }); @@ -139,6 +158,9 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (liv writeFileSync(APP_WRANGLER, wranglerOriginal); } } + if (failures.length > 0) { + throw new Error(`live e2e teardown failed — manual cleanup needed:\n${failures.join('\n')}`); + } }, 300_000); it('provisions resources and deploys the worker', () => { diff --git a/packages/void/test/integration/cloudflare-deploy-build.test.ts b/packages/void/test/integration/cloudflare-deploy-build.test.ts index e472fcb85..68f91bd5c 100644 --- a/packages/void/test/integration/cloudflare-deploy-build.test.ts +++ b/packages/void/test/integration/cloudflare-deploy-build.test.ts @@ -41,6 +41,7 @@ const ACCOUNT_ID = '0123456789abcdef0123456789abcdef'; const WORKER_NAME = 'void-kitchen-sink'; const REAL_D1_ID = '52b3bbc7-c473-4b26-9486-1345f5de61c4'; const REAL_KV_ID = '7c07b54c9f2a4d1e8b3f6a0c153aa11a'; +const REAL_ISR_CACHE_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; const OTHER_D1_ID = '11111111-2222-3333-4444-555555555555'; const R2_BUCKET_NAME = 'void-kitchen-sink-storage'; @@ -52,7 +53,14 @@ const OVERLAY_WRANGLER = { account_id: ACCOUNT_ID, compatibility_date: '2026-02-24', d1_databases: [{ binding: 'DB', database_name: 'void-kitchen-sink-db', database_id: REAL_D1_ID }], - kv_namespaces: [{ binding: 'KV', id: REAL_KV_ID }], + // kitchen-sink enables ISR (`pages/revalidate.server.ts`), so `computeDesiredCloudflareResources` + // adds a SECOND KV binding — `ISR_CACHE` — beyond the app's own `KV`. Pin a distinct real-shaped id + // for it too, so the emitted artifact carries both KV namespaces and the FP-lock covers the two-KV + // shape a real provisioned ISR deploy builds (not just the single app KV). + kv_namespaces: [ + { binding: 'KV', id: REAL_KV_ID }, + { binding: 'ISR_CACHE', id: REAL_ISR_CACHE_ID }, + ], r2_buckets: [{ binding: 'STORAGE', bucket_name: R2_BUCKET_NAME }], }; @@ -168,6 +176,11 @@ describe('playground/kitchen-sink --backend cloudflare real-build deploy guard', expect(pristineEmitted.kv_namespaces).toContainEqual( expect.objectContaining({ binding: 'KV', id: REAL_KV_ID }), ); + // The SECOND KV binding an ISR app requires — the guard's clean-pass below therefore exercises + // the two-KV shape a real provisioned ISR deploy builds, not just the single app KV. + expect(pristineEmitted.kv_namespaces).toContainEqual( + expect.objectContaining({ binding: 'ISR_CACHE', id: REAL_ISR_CACHE_ID }), + ); expect(pristineEmitted.r2_buckets).toContainEqual( expect.objectContaining({ binding: 'STORAGE', bucket_name: R2_BUCKET_NAME }), ); From 40b91d1a320b0e614ec2169dbe8c10f45cf0ffce Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 22 Jul 2026 13:12:17 +0800 Subject: [PATCH 03/16] test(deploy): poll the live worker until 200 so a propagation-window 404 does not flake The live-e2e worker poll returned on any status below 500, so a freshly-deployed route that transiently 404s during edge propagation was taken as the final answer and the "serves a live 200" assertion flaked. Retry on ANY non-200 (the test's actual intent is a live 200), returning the last response on exhaustion so the assertion still reports the real final status, and throwing only when every attempt was a network error. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .../test/e2e/cloudflare-deploy-live.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 6ccfaa4bc..0dc71ea3a 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -77,21 +77,33 @@ function extractWorkerUrl(output: string): string | undefined { return output.match(/https:\/\/[^\s]*\.workers\.dev[^\s]*/)?.[0]; } -/** Poll the live worker until it answers (edge propagation can lag the deploy by a few seconds). */ +/** + * Poll the live worker until it serves a 200 (edge propagation can lag the deploy by a few seconds, + * and a freshly-deployed route can transiently 404 or 5xx before it is live). Retries on ANY + * non-200 — not just 5xx — so a propagation-window 404 is not mistaken for the final answer. On + * exhaustion returns the LAST response so the caller's status assertion reports the real final + * status; throws only when every attempt was a network error (no response at all). + */ async function fetchWithRetry(url: string, attempts = 10): Promise { + let lastResponse: Response | undefined; let lastError: unknown; for (let i = 0; i < attempts; i++) { try { const res = await fetch(url); - if (res.status < 500) { + if (res.status === 200) { return res; } - lastError = new Error(`status ${res.status}`); + lastResponse = res; + lastError = undefined; } catch (error) { lastError = error; + lastResponse = undefined; } await new Promise((resolve) => setTimeout(resolve, 3000)); } + if (lastResponse) { + return lastResponse; + } throw lastError ?? new Error(`worker at ${url} never answered`); } From 84497289421dea96fe77fd6959d8bc1ffd915030 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 22 Jul 2026 13:28:29 +0800 Subject: [PATCH 04/16] test(deploy): gate the live e2e on exclusive resource ownership and fix the wrangler bin path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-e2e teardown deleted the fixed-name worker, D1 database, and KV namespace whenever credentials were set — regardless of whether this run created them. Since `--provision` adopts an exact-name D1/KV (it would migrate pre-existing data) and teardown then deletes by that name, a name collision with a real resource could destroy data the test does not own. Add a pre-deploy ownership gate: before provisioning, check the account for a worker / D1 / KV already using the throwaway names and refuse to run on any collision (fail closed if a name cannot be confirmed free). Only after proving exclusive ownership is `ownershipVerified` set, and teardown deletes solely when it is — so a gate failure never deletes the colliding resource, and a partial deploy's own resources are still cleaned up. Also fix `WRANGLER_BIN`, which pointed at a nonexistent repo-root `node_modules/.bin/wrangler`; the real bin is under the package root. The gated suite never ran in CI, so this latent break was invisible until now. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .../test/e2e/cloudflare-deploy-live.test.ts | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 0dc71ea3a..9d2ba0144 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -29,7 +29,7 @@ const E2E_ENABLED = CF_TOKEN.length > 0 && CF_ACCOUNT_ID.length > 0; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PACKAGE_ROOT = join(ROOT, 'packages', 'void'); const CLI_ENTRY = join(PACKAGE_ROOT, 'dist', 'cli', 'cli.mjs'); -const WRANGLER_BIN = join(ROOT, 'node_modules', '.bin', 'wrangler'); +const WRANGLER_BIN = join(PACKAGE_ROOT, 'node_modules', '.bin', 'wrangler'); // Deploy target: the SPA playground (real, buildable Void app exercising D1 + KV + a cron). The // deploy CLI runs its own build → provision → migrate → deploy pipeline against this dir. @@ -51,6 +51,10 @@ let wranglerSnapshotTaken = false; let wranglerOriginal: string | undefined; let deployOutput = ''; let deployStatus: number | null = null; +// Set true ONLY once the pre-deploy ownership gate proves none of the fixed-name resources already +// existed in the account. Teardown deletes ONLY when this is true, so a collision (gate throws) can +// never delete a pre-existing resource the test does not own. +let ownershipVerified = false; /** The credentialed env every real Cloudflare call (deploy + teardown) shares. */ function cloudEnv(): NodeJS.ProcessEnv { @@ -109,6 +113,48 @@ async function fetchWithRetry(url: string, attempts = 10): Promise { describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (live account)', () => { beforeAll(() => { + // Ownership gate — refuse to run if any fixed-name resource ALREADY exists in the target account. + // `--provision` ADOPTS an exact-name D1/KV (it would migrate pre-existing data), and teardown + // deletes by these exact names, so a collision with a real resource risks destroying data this + // test does not own. Only after proving the names are exclusively ours do we set + // `ownershipVerified` — the sole condition under which teardown deletes anything. If we cannot + // confirm a name is free (a list call fails), fail CLOSED rather than risk it. + const collisions: Array = []; + if (wrangler(['deployments', 'list', '--name', WORKER_NAME]).status === 0) { + collisions.push(`worker ${WORKER_NAME}`); + } + if (wrangler(['d1', 'info', D1_NAME]).status === 0) { + collisions.push(`d1 database ${D1_NAME}`); + } + const kvList = wrangler(['kv', 'namespace', 'list']); + if (kvList.status !== 0) { + collisions.push( + `kv namespace ${KV_TITLE} (could not list namespaces to rule out a collision)`, + ); + } else { + try { + const namespaces = JSON.parse(kvList.output) as Array<{ title: string }>; + if (namespaces.some((n) => n.title === KV_TITLE)) { + collisions.push(`kv namespace ${KV_TITLE}`); + } + } catch { + collisions.push( + `kv namespace ${KV_TITLE} (non-JSON namespace list — cannot rule out a collision)`, + ); + } + } + if (collisions.length > 0) { + throw new Error( + 'live e2e refuses to run — these throwaway-named resources already exist in the target ' + + 'account, so provisioning would adopt/migrate them and teardown would delete them ' + + `(risking data this test does not own):\n ${collisions.join('\n ')}\n` + + 'Remove them (or change WORKER_NAME) and rerun.', + ); + } + // Exclusive ownership proven: any resource with these names after this point was created by THIS + // run, so teardown may safely delete them. + ownershipVerified = true; + // Snapshot BEFORE any filesystem work so an early failure never deletes a developer-local file. wranglerOriginal = existsSync(APP_WRANGLER) ? readFileSync(APP_WRANGLER, 'utf-8') : undefined; wranglerSnapshotTaken = true; @@ -137,7 +183,9 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (liv failures.push(`${label} (exit ${res.status})\n${res.output.trim()}`); } }; - if (E2E_ENABLED) { + // Delete ONLY when the ownership gate proved these names were free before the deploy, so a + // collision that aborted beforeAll never deletes a pre-existing resource the test does not own. + if (E2E_ENABLED && ownershipVerified) { attemptDelete(`worker ${WORKER_NAME}`, ['delete', '--name', WORKER_NAME, '--force']); attemptDelete(`d1 ${D1_NAME}`, ['d1', 'delete', D1_NAME, '--skip-confirmation']); // KV delete needs the namespace id — resolve it from the account's namespace list by EXACT From d90fce6e4179b6ce69ed988737d1bd7fc32b1158 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 22 Jul 2026 13:59:38 +0800 Subject: [PATCH 05/16] fix(deploy): fail closed on ambiguous ownership lookups in the live e2e gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-run ownership gate proved the fixed-name worker/D1/KV are free before letting teardown delete by those names. The worker (`deployments list`) and D1 (`d1 info`) checks treated any nonzero exit as "free", but a genuinely-absent resource ALSO exits nonzero — so a transient API error or a missing read scope was indistinguishable from a confirmed not-found and could still arm teardown. Resolve the worker against the CF API (unambiguous 200/404; anything else is unknown → collision) and the D1 database against `d1 list --json` (a successful list that omits the name proves free; a list failure or non-JSON body is a collision). KV already listed-and-failed-closed; all three are now symmetric. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .../test/e2e/cloudflare-deploy-live.test.ts | 65 +++++++++++++++++-- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 9d2ba0144..80119ed91 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -76,6 +76,31 @@ function wrangler(args: Array): { status: number; output: string } { return { status: res.status ?? 1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; } +/** + * Definitively resolve whether the fixed-name worker already exists, for the ownership gate. The CF + * API returns an unambiguous 200 (exists) / 404 (absent) for a script, unlike a wrangler exit code + * (a genuinely-absent worker ALSO exits nonzero). Any other outcome — a non-200/404 status or a + * network error — is `'unknown'`, which the gate treats as a collision and fails closed on, so an + * ownership lookup that merely errored can never be mistaken for "the name is free". + */ +async function workerExistence(): Promise<'exists' | 'absent' | 'unknown'> { + try { + const res = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/workers/scripts/${WORKER_NAME}`, + { headers: { Authorization: `Bearer ${CF_TOKEN}` } }, + ); + if (res.status === 200) { + return 'exists'; + } + if (res.status === 404) { + return 'absent'; + } + return 'unknown'; + } catch { + return 'unknown'; + } +} + /** Extract the deployed workers.dev URL the CLI prints on a successful deploy. */ function extractWorkerUrl(output: string): string | undefined { return output.match(/https:\/\/[^\s]*\.workers\.dev[^\s]*/)?.[0]; @@ -112,20 +137,48 @@ async function fetchWithRetry(url: string, attempts = 10): Promise { } describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (live account)', () => { - beforeAll(() => { + beforeAll(async () => { // Ownership gate — refuse to run if any fixed-name resource ALREADY exists in the target account. // `--provision` ADOPTS an exact-name D1/KV (it would migrate pre-existing data), and teardown // deletes by these exact names, so a collision with a real resource risks destroying data this // test does not own. Only after proving the names are exclusively ours do we set - // `ownershipVerified` — the sole condition under which teardown deletes anything. If we cannot - // confirm a name is free (a list call fails), fail CLOSED rather than risk it. + // `ownershipVerified` — the sole condition under which teardown deletes anything. + // + // Every check fails CLOSED: a lookup that neither confirms "free" nor confirms "taken" (a + // transient API error, a missing read scope, a non-JSON body) is recorded as a collision, so an + // ambiguous result can never be mistaken for a free name and let teardown delete a resource this + // test did not create. const collisions: Array = []; - if (wrangler(['deployments', 'list', '--name', WORKER_NAME]).status === 0) { + + // Worker — a genuinely-absent worker ALSO makes `wrangler` exit nonzero, so an exit code cannot + // tell "free" from "errored". Resolve it against the CF API instead, which returns an unambiguous + // 200/404; anything else is `'unknown'` → treated as a collision (fail closed). + const worker = await workerExistence(); + if (worker === 'exists') { collisions.push(`worker ${WORKER_NAME}`); + } else if (worker === 'unknown') { + collisions.push(`worker ${WORKER_NAME} (could not confirm it is free — lookup errored)`); } - if (wrangler(['d1', 'info', D1_NAME]).status === 0) { - collisions.push(`d1 database ${D1_NAME}`); + + // D1 — `d1 info ` exits nonzero BOTH when the database is absent and when the call errors, + // so it cannot fail closed. List every database instead: a successful JSON list that omits the + // name proves it is free; a list failure or non-JSON body is ambiguous → collision. + const d1List = wrangler(['d1', 'list', '--json']); + if (d1List.status !== 0) { + collisions.push(`d1 database ${D1_NAME} (could not list databases to rule out a collision)`); + } else { + try { + const databases = JSON.parse(d1List.output) as Array<{ name: string }>; + if (databases.some((d) => d.name === D1_NAME)) { + collisions.push(`d1 database ${D1_NAME}`); + } + } catch { + collisions.push( + `d1 database ${D1_NAME} (non-JSON database list — cannot rule out a collision)`, + ); + } } + const kvList = wrangler(['kv', 'namespace', 'list']); if (kvList.status !== 0) { collisions.push( From 71d740ba985469b722eb518df42327e8d3384c59 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 22 Jul 2026 17:40:47 +0800 Subject: [PATCH 06/16] test(deploy): make the live Cloudflare e2e run the real deploy pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gated live e2e drove `void deploy --backend cloudflare --provision`, but `--provision` fails closed in non-interactive shells by design (a spawned CLI has no TTY, and CI cannot commit back the ids a create would mint) — so from `spawnSync` the suite could never reach a real deploy. Restructure it to play the provisioner's role directly: pre-create the id-bearing D1 + KV with `wrangler … create`, write their real ids (plus migrations_dir + account_id) into the app's root config, then run the CI-safe `void deploy --backend cloudflare` (no --provision). This exercises the real build → tamper-guard → remote-migrations → `wrangler deploy` path end to end and asserts a live 200. Also: - Teardown is async + existence-aware: a resource confirmed absent (the deploy never created it) is skipped, present-and-undeletable fails loud, and a lookup that cannot confirm removal fails closed — so a real leak is never silently swallowed. - `workerExistence()` retries a few times: a definitive 200/404 returns immediately, only a persistently non-definitive lookup yields 'unknown' (still fail-closed), so a lone transient API blip no longer refuses a run. - `cloudEnv()` scrubs wrangler transport-redirect overrides (CLOUDFLARE_API_BASE_URL / CF_API_BASE_URL / WRANGLER_API_ENVIRONMENT / CLOUDFLARE_COMPLIANCE_REGION) so the spawned wrangler calls and the direct existence fetch resolve the same public api base. - Document the operator prerequisites (token scopes + a registered workers.dev subdomain) in the e2e README. playground/spa carried a hand-written db/migrations/001_create_users.sql with no drizzle meta, which trips the deploy's schema-drift gate. Add the coherent meta (journal + snapshot) under the existing 001 tag so the app deploys cleanly, keeping 001_create_users.sql byte-identical (immutable history — a renamed migration would re-run CREATE TABLE against an already-migrated D1). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- packages/void/test/e2e/README.md | 29 ++- .../test/e2e/cloudflare-deploy-live.test.ts | 224 ++++++++++++++---- .../spa/db/migrations/meta/001_snapshot.json | 57 +++++ .../spa/db/migrations/meta/_journal.json | 13 + 4 files changed, 277 insertions(+), 46 deletions(-) create mode 100644 playground/spa/db/migrations/meta/001_snapshot.json create mode 100644 playground/spa/db/migrations/meta/_journal.json diff --git a/packages/void/test/e2e/README.md b/packages/void/test/e2e/README.md index 03f00c257..c2f5cea9f 100644 --- a/packages/void/test/e2e/README.md +++ b/packages/void/test/e2e/README.md @@ -5,8 +5,17 @@ so they are **skipped by default** and never run in CI. ## `cloudflare-deploy-live.test.ts` -Runs a real `void deploy --backend cloudflare --provision` of `playground/spa`, verifies the live -worker answers HTTP 200, then deletes the worker and every provisioned resource. +Deploys `playground/spa` (D1 + KV + cron) to the target account via `void deploy --backend cloudflare`, +verifies the live worker answers HTTP 200, then deletes the worker and every resource it created. + +It does **not** drive `--provision`: that path fails closed in non-interactive shells by design (a +spawned CLI has no TTY, and CI cannot commit back the ids a create would mint). So the test plays the +provisioner's role itself — it creates the id-bearing D1 + KV directly with `wrangler … create`, +writes their real ids into the app's root config exactly as `--provision` would, then runs the +CI-safe `void deploy --backend cloudflare` (no `--provision`). That exercises the real +build → tamper-guard → remote-migrations → `wrangler deploy` pipeline end to end. + +### Prerequisites The suite is skipped unless BOTH env vars are set: @@ -15,9 +24,19 @@ The suite is skipped unless BOTH env vars are set: | `VOID_E2E_CF_TOKEN` | Cloudflare API token that can create + delete Workers Scripts, D1, and KV. | | `VOID_E2E_CF_ACCOUNT_ID` | Cloudflare account id to deploy into. | +The token needs, on that account, **Workers Scripts: Edit**, **D1: Edit**, and **Workers KV Storage: +Edit** (Edit implies read, covering the ownership/teardown list calls). Cron triggers ride along with +the worker deploy — no extra permission. + +The account must also have a **workers.dev subdomain** already registered (with its wildcard TLS cert +provisioned). Without one, `wrangler deploy` uploads the worker but cannot publish a public URL, and +the HTTP-200 assertion has nothing to hit. Opening the Workers & Pages dashboard once registers a +subdomain automatically; a freshly-registered subdomain takes a few minutes for its cert to go live, +during which the first run may see a TLS handshake failure — rerun once the cert is ready. + With no env vars the suite SKIPS cleanly (0 run, N skipped) — this is the CI behavior. -Run it manually with your own throwaway credentials: +### Running it ```sh # Ensure the CLI is built first (dist/cli/cli.mjs): @@ -28,4 +47,6 @@ VOID_E2E_CF_TOKEN=... VOID_E2E_CF_ACCOUNT_ID=... vp test run packages/void/test/ The worker name is fixed (`void-e2e-cf-deploy`), so a crashed run leaves deterministically named resources (`void-e2e-cf-deploy`, `void-e2e-cf-deploy-db`, `void-e2e-cf-deploy-kv`) that a rerun — or -`wrangler delete` — can clean up. Teardown runs even when the deploy or an assertion fails. +`wrangler delete` — can clean up. A pre-run ownership gate refuses to start if any of those names +already exist (failing closed on any lookup it cannot confirm), so the test never adopts or deletes a +resource it does not own. Teardown runs even when the deploy or an assertion fails. diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 80119ed91..0a713f5f3 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -4,18 +4,24 @@ import { join } from 'pathe'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; // --------------------------------------------------------------------------------------------- -// Gated live-account e2e for `void deploy --backend cloudflare --provision`. +// Gated live-account e2e for the self-host `void deploy --backend cloudflare` path. // // INERT in normal CI. The whole suite is skipped unless BOTH env vars are present: // - VOID_E2E_CF_TOKEN a Cloudflare API token with permissions to create + delete Workers // Scripts, D1, and KV in the target account. // - VOID_E2E_CF_ACCOUNT_ID the Cloudflare account id to deploy into. // -// When they are set, this runs a REAL provision + deploy of the `playground/spa` app (D1 + KV + -// cron) under a FIXED, throwaway worker name — so reruns are idempotent and every provisioned -// resource is cleanable by a stable name — asserts the live worker answers HTTP 200, then DELETES -// the worker + the provisioned D1 database + KV namespace in `afterAll` (which runs even on -// failure) and restores the overlaid root config. +// When they are set, this deploys the `playground/spa` app (D1 + KV + cron) to the target account +// under a FIXED, throwaway worker name — so reruns are idempotent and every resource is cleanable +// by a stable name — then asserts the live worker answers HTTP 200 and tears everything down. +// +// Why it does NOT drive `--provision`: `void deploy --provision` fails closed in non-interactive +// shells by design (a spawned CLI has no TTY, and CI cannot commit back the ids a create would +// mint). A subprocess can never satisfy that gate, so the test plays the role the provisioner +// would: it creates the id-bearing D1 + KV directly (`wrangler d1/kv … create`), writes their real +// ids into the root config exactly as `--provision` would, then runs the CI-safe +// `void deploy --backend cloudflare` (no `--provision`) — exercising the real build → tamper-guard +// → remote-migrations → `wrangler deploy` pipeline end to end against a live account. // // The live path is a MANUAL operator step: this repo's CI has no token, so the suite only ever // SKIPS here. Do not commit a token; export the two vars locally to exercise the real path: @@ -37,15 +43,22 @@ const APP = join(ROOT, 'playground', 'spa'); const APP_WRANGLER = join(APP, 'wrangler.jsonc'); // A FIXED throwaway worker name (no Date.now()/random, so a crashed run leaves a deterministically -// named worker + resources the next run — or an operator — can delete). The provisioner derives the -// resource names from it (`suffixedResourceName`): `-db` (D1) and `-kv` (KV). +// named worker + resources the next run — or an operator — can delete). The resource names mirror +// what the provisioner derives (`suffixedResourceName`): `-db` (D1) and `-kv` (KV). const WORKER_NAME = 'void-e2e-cf-deploy'; const D1_NAME = `${WORKER_NAME}-db`; const KV_TITLE = `${WORKER_NAME}-kv`; -// The overlaid root config: only the throwaway worker name + a compat date. `--provision` writes the -// resolved `account_id` and the real binding ids into it during the deploy; teardown restores it. -const OVERLAY_WRANGLER = { name: WORKER_NAME, compatibility_date: '2026-02-24' }; +// Binding names the SPA app resolves for its D1 + KV (Void's defaults: `void/db` → DB, `env.KV` → +// KV). The pre-created resources must be written into the root config under THESE bindings so the +// deploy's desired-set / drift-check sees them as satisfied. +const D1_BINDING = 'DB'; +const KV_BINDING = 'KV'; +// Where Void keeps D1 migrations — the deploy's no-provision path REQUIRES `migrations_dir` on the +// D1 entry (else it hard-errors), and `wrangler d1 migrations apply --remote` reads it. Matches what +// the provisioner stamps. +const MIGRATIONS_DIR = './db/migrations'; +const COMPAT_DATE = '2026-02-24'; let wranglerSnapshotTaken = false; let wranglerOriginal: string | undefined; @@ -58,11 +71,22 @@ let ownershipVerified = false; /** The credentialed env every real Cloudflare call (deploy + teardown) shares. */ function cloudEnv(): NodeJS.ProcessEnv { - return { + const env: NodeJS.ProcessEnv = { ...process.env, CLOUDFLARE_API_TOKEN: CF_TOKEN, CLOUDFLARE_ACCOUNT_ID: CF_ACCOUNT_ID, }; + // This suite only supports the PUBLIC Cloudflare API + a `*.workers.dev` URL: `workerExistence()` + // fetches `api.cloudflare.com` directly and the 200 assertion hits `*.workers.dev`. Scrub every + // wrangler transport-redirect override inherited from the operator's shell so the spawned wrangler + // calls AND the direct fetch resolve the SAME api base. Otherwise a worker deployed to a + // staging/FedRAMP/custom base would 404 on the public api, and teardown's existence check would + // treat it as absent and skip the delete — leaking the worker on the alternate base. + delete env.CLOUDFLARE_API_BASE_URL; + delete env.CF_API_BASE_URL; + delete env.WRANGLER_API_ENVIRONMENT; + delete env.CLOUDFLARE_COMPLIANCE_REGION; + return env; } /** Best-effort `wrangler` invocation for teardown — never throws, so afterAll always continues. */ @@ -79,26 +103,95 @@ function wrangler(args: Array): { status: number; output: string } { /** * Definitively resolve whether the fixed-name worker already exists, for the ownership gate. The CF * API returns an unambiguous 200 (exists) / 404 (absent) for a script, unlike a wrangler exit code - * (a genuinely-absent worker ALSO exits nonzero). Any other outcome — a non-200/404 status or a - * network error — is `'unknown'`, which the gate treats as a collision and fails closed on, so an - * ownership lookup that merely errored can never be mistaken for "the name is free". + * (a genuinely-absent worker ALSO exits nonzero). A DEFINITIVE 200/404 is returned immediately; any + * other outcome — a non-200/404 status or a network error — is retried a few times so a single + * transient CF API blip (a 5xx, a 429, a dropped connection) does not needlessly fail the gate. Only + * when EVERY attempt is non-definitive does it return `'unknown'`, which the gate treats as a + * collision and fails closed on — so an ownership lookup that merely errored can never be mistaken + * for "the name is free", while a lone hiccup no longer blocks an otherwise-clean run. */ async function workerExistence(): Promise<'exists' | 'absent' | 'unknown'> { - try { - const res = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/workers/scripts/${WORKER_NAME}`, - { headers: { Authorization: `Bearer ${CF_TOKEN}` } }, - ); - if (res.status === 200) { - return 'exists'; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/workers/scripts/${WORKER_NAME}`, + { headers: { Authorization: `Bearer ${CF_TOKEN}` } }, + ); + if (res.status === 200) { + return 'exists'; + } + if (res.status === 404) { + return 'absent'; + } + } catch { + // Network error — transient; fall through to retry. } - if (res.status === 404) { - return 'absent'; + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 2000)); } - return 'unknown'; + } + return 'unknown'; +} + +/** + * Create the throwaway D1 database directly and return its real id — the id-bearing half of what + * `--provision` would do, but runnable non-interactively. `d1 create` has no JSON flag, so the id is + * resolved from `d1 list --json` by EXACT name. Throws on any failure so `beforeAll` surfaces a + * clear cause (teardown still deletes whatever got created). + */ +function createD1Database(): string { + const created = wrangler(['d1', 'create', D1_NAME]); + if (created.status !== 0) { + throw new Error( + `live e2e setup: "wrangler d1 create ${D1_NAME}" failed:\n${created.output.trim()}`, + ); + } + const list = wrangler(['d1', 'list', '--json']); + if (list.status !== 0) { + throw new Error(`live e2e setup: "wrangler d1 list" failed:\n${list.output.trim()}`); + } + let databases: Array<{ uuid: string; name: string }>; + try { + databases = JSON.parse(list.output) as Array<{ uuid: string; name: string }>; } catch { - return 'unknown'; + throw new Error(`live e2e setup: "wrangler d1 list" returned non-JSON:\n${list.output.trim()}`); + } + const db = databases.find((d) => d.name === D1_NAME); + if (!db?.uuid) { + throw new Error(`live e2e setup: D1 ${D1_NAME} not found in the account after create`); } + return db.uuid; +} + +/** + * Create the throwaway KV namespace directly and return its real id. `kv namespace create ` + * makes a namespace whose title is EXACTLY the argument (no worker prefix); the id is resolved from + * `kv namespace list` by EXACT title. Throws on any failure. + */ +function createKvNamespace(): string { + const created = wrangler(['kv', 'namespace', 'create', KV_TITLE]); + if (created.status !== 0) { + throw new Error( + `live e2e setup: "wrangler kv namespace create ${KV_TITLE}" failed:\n${created.output.trim()}`, + ); + } + const list = wrangler(['kv', 'namespace', 'list']); + if (list.status !== 0) { + throw new Error(`live e2e setup: "wrangler kv namespace list" failed:\n${list.output.trim()}`); + } + let namespaces: Array<{ id: string; title: string }>; + try { + namespaces = JSON.parse(list.output) as Array<{ id: string; title: string }>; + } catch { + throw new Error( + `live e2e setup: "wrangler kv namespace list" returned non-JSON:\n${list.output.trim()}`, + ); + } + const ns = namespaces.find((n) => n.title === KV_TITLE); + if (!ns?.id) { + throw new Error(`live e2e setup: KV ${KV_TITLE} not found in the account after create`); + } + return ns.id; } /** Extract the deployed workers.dev URL the CLI prints on a successful deploy. */ @@ -136,7 +229,7 @@ async function fetchWithRetry(url: string, attempts = 10): Promise<Response> { throw lastError ?? new Error(`worker at ${url} never answered`); } -describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (live account)', () => { +describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare (live account)', () => { beforeAll(async () => { // Ownership gate — refuse to run if any fixed-name resource ALREADY exists in the target account. // `--provision` ADOPTS an exact-name D1/KV (it would migrate pre-existing data), and teardown @@ -211,9 +304,33 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (liv // Snapshot BEFORE any filesystem work so an early failure never deletes a developer-local file. wranglerOriginal = existsSync(APP_WRANGLER) ? readFileSync(APP_WRANGLER, 'utf-8') : undefined; wranglerSnapshotTaken = true; - writeFileSync(APP_WRANGLER, `${JSON.stringify(OVERLAY_WRANGLER, null, 2)}\n`); - const res = spawnSync('node', [CLI_ENTRY, 'deploy', '--backend', 'cloudflare', '--provision'], { + // Play the provisioner's role: create the id-bearing D1 + KV directly (non-interactive), then + // write their REAL ids into the root config exactly as `--provision` would — including the D1 + // `migrations_dir` the no-provision deploy requires. `account_id` is pinned so the deploy's + // whoami / migrate / deploy all target the intended account. + const databaseId = createD1Database(); + const kvId = createKvNamespace(); + const overlay = { + name: WORKER_NAME, + compatibility_date: COMPAT_DATE, + account_id: CF_ACCOUNT_ID, + d1_databases: [ + { + binding: D1_BINDING, + database_name: D1_NAME, + database_id: databaseId, + migrations_dir: MIGRATIONS_DIR, + }, + ], + kv_namespaces: [{ binding: KV_BINDING, id: kvId }], + }; + writeFileSync(APP_WRANGLER, `${JSON.stringify(overlay, null, 2)}\n`); + + // The CI-safe path: no `--provision`. The root config now covers every desired id-bearing + // resource, so the drift-check passes; this exercises build → tamper-guard → remote migrations → + // `wrangler deploy` against the live account. + const res = spawnSync('node', [CLI_ENTRY, 'deploy', '--backend', 'cloudflare'], { cwd: APP, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024, @@ -223,12 +340,14 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (liv deployOutput = `${res.stdout ?? ''}${res.stderr ?? ''}`; }, 600_000); - afterAll(() => { - // Tear DOWN every provisioned resource + the worker, even if the deploy or an assertion failed. - // Each delete is best-effort and independent so one failure never blocks the others — but a - // nonzero exit (transient API error / missing delete permission) is COLLECTED and re-thrown - // after cleanup + config restore, so a run that leaves a worker or database behind fails LOUD - // instead of reporting green with leaked resources. + afterAll(async () => { + // Tear DOWN the worker + the D1/KV created in `beforeAll`, even if the deploy or an assertion + // failed. Each delete is best-effort and independent so one failure never blocks the others. + // A delete is only required to succeed for a resource that STILL EXISTS: a resource confirmed + // ABSENT (e.g. the deploy failed before the worker was created) is already gone — no leak, no + // failure. A nonzero delete of a resource that IS present — or a lookup that cannot confirm the + // resource is gone — is COLLECTED and re-thrown after cleanup + config restore, so a genuine + // leak (or an ambiguous teardown) fails LOUD instead of reporting green. const failures: Array<string> = []; const attemptDelete = (label: string, args: Array<string>): void => { const res = wrangler(args); @@ -239,12 +358,33 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (liv // Delete ONLY when the ownership gate proved these names were free before the deploy, so a // collision that aborted beforeAll never deletes a pre-existing resource the test does not own. if (E2E_ENABLED && ownershipVerified) { - attemptDelete(`worker ${WORKER_NAME}`, ['delete', '--name', WORKER_NAME, '--force']); - attemptDelete(`d1 ${D1_NAME}`, ['d1', 'delete', D1_NAME, '--skip-confirmation']); + // Worker — exists only if the deploy succeeded. Skip when the CF API confirms it is ABSENT + // (avoids a spurious "does not exist" failure on a deploy that never got that far); fail closed + // when existence cannot be confirmed ('unknown') by attempting the delete anyway. + const worker = await workerExistence(); + if (worker !== 'absent') { + attemptDelete(`worker ${WORKER_NAME}`, ['delete', '--name', WORKER_NAME, '--force']); + } + // D1 — created in beforeAll; delete when the account still lists it by EXACT name. A list + // failure cannot confirm the database is gone, so it is recorded (fail closed). + const d1List = wrangler(['d1', 'list', '--json']); + if (d1List.status !== 0) { + failures.push(`d1 list (exit ${d1List.status})\n${d1List.output.trim()}`); + } else { + try { + const databases = JSON.parse(d1List.output) as Array<{ name: string }>; + if (databases.some((d) => d.name === D1_NAME)) { + attemptDelete(`d1 ${D1_NAME}`, ['d1', 'delete', D1_NAME, '--skip-confirmation']); + } + } catch { + failures.push( + `d1 list returned non-JSON output — delete the ${D1_NAME} database manually`, + ); + } + } // KV delete needs the namespace id — resolve it from the account's namespace list by EXACT - // title. The provisioner creates AND matches the namespace by this exact title (the - // provision-time existence check rejects any non-exact title), so an `endsWith` here could - // select and delete an unrelated namespace that merely shares the suffix. + // title. Matching by exact title (not `endsWith`) avoids selecting an unrelated namespace that + // merely shares the suffix. A namespace absent from the list is already gone (skip). const list = wrangler(['kv', 'namespace', 'list']); if (list.status !== 0) { failures.push(`kv namespace list (exit ${list.status})\n${list.output.trim()}`); @@ -276,9 +416,9 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare --provision (liv } }, 300_000); - it('provisions resources and deploys the worker', () => { + it('deploys the worker to the live account', () => { expect(deployStatus, deployOutput).toBe(0); - // The orchestrator names each provisioned resource + prints the deployed URL. + // A successful `wrangler deploy` prints the worker's workers.dev URL. expect(deployOutput).toMatch(/https:\/\/[^\s]*\.workers\.dev/); }); diff --git a/playground/spa/db/migrations/meta/001_snapshot.json b/playground/spa/db/migrations/meta/001_snapshot.json new file mode 100644 index 000000000..b6124cd1e --- /dev/null +++ b/playground/spa/db/migrations/meta/001_snapshot.json @@ -0,0 +1,57 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ccc9547b-6781-44a2-8880-37effa1cce86", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/playground/spa/db/migrations/meta/_journal.json b/playground/spa/db/migrations/meta/_journal.json new file mode 100644 index 000000000..fac8777c1 --- /dev/null +++ b/playground/spa/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1784708395851, + "tag": "001_create_users", + "breakpoints": true + } + ] +} From 1cb7b09472be1e8ec451142096e2705ff766f362 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Wed, 22 Jul 2026 17:50:17 +0800 Subject: [PATCH 07/16] test(deploy): snapshot the FP-lock's expected config before the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real-build integration guard test reconstructed `expected` (desired set + identity snapshots + container set) AFTER running the build, but `runCloudflareDeploy` snapshots the root config BEFORE the build — that pre-build snapshot is the entire basis of the tamper guard. If build-time code rewrote the root wrangler config, the test would read the modified inputs and bless them, so the FP-safety lock could pass an artifact production would reject. Move the reconstruction ahead of the build spawn so the test's snapshot timing matches the orchestrator's; the suite still passes 11/11. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .../cloudflare-deploy-build.test.ts | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/void/test/integration/cloudflare-deploy-build.test.ts b/packages/void/test/integration/cloudflare-deploy-build.test.ts index 68f91bd5c..2a017945e 100644 --- a/packages/void/test/integration/cloudflare-deploy-build.test.ts +++ b/packages/void/test/integration/cloudflare-deploy-build.test.ts @@ -116,20 +116,12 @@ describe('playground/kitchen-sink --backend cloudflare real-build deploy guard', rmSync(join(PG, 'dist'), { recursive: true, force: true }); rmSync(join(PG, '.wrangler', 'deploy', 'config.json'), { force: true }); - const result = spawn.sync('vp', ['run', '--filter', `./${PLAYGROUND}`, 'build'], { - cwd: ROOT, - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, - }); - buildStatus = result.status; - buildOutput = `${result.stdout ?? ''}${result.stderr ?? ''}`; - expect(buildStatus, buildOutput).toBe(0); - - pristineEmitted = JSON.parse(readFileSync(EMITTED_PATH, 'utf-8')) as Record<string, unknown>; - // Reconstruct `expected` EXACTLY as runCloudflareDeploy does (steps 1 + 5/6): the desired set + // signals from `computeDesiredCloudflareResources`, and the four snapshots + container set from - // the pre-build root config. + // the root config — captured BEFORE the build, matching the orchestrator's pre-build snapshot. + // Doing this AFTER the build would let build-time code that rewrote the root config launder a + // tampered binding set into `expected`, so the FP-safety lock could pass an artifact that + // production (which snapshots pre-build) would reject. const voidConfig = readConfig(PG); const { desired, needsAI, needsSandbox, expectedCrons } = await computeDesiredCloudflareResources(PG, voidConfig, CONFIG_PATH); @@ -145,6 +137,17 @@ describe('playground/kitchen-sink --backend cloudflare real-build deploy guard', expectedContainers: reconstructExpectedContainers(voidConfig, PG, rootConfig, needsSandbox), expectedCrons, }; + + const result = spawn.sync('vp', ['run', '--filter', `./${PLAYGROUND}`, 'build'], { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + buildStatus = result.status; + buildOutput = `${result.stdout ?? ''}${result.stderr ?? ''}`; + expect(buildStatus, buildOutput).toBe(0); + + pristineEmitted = JSON.parse(readFileSync(EMITTED_PATH, 'utf-8')) as Record<string, unknown>; }); afterAll(() => { From f8ca9c51f3ef33ae7c75c6010437ab66775ab323 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Wed, 22 Jul 2026 18:51:28 +0800 Subject: [PATCH 08/16] test(deploy): spawn wrangler via cross-spawn for Windows portability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live e2e ran wrangler through Node's native `spawnSync` against pnpm's extensionless `.bin/wrangler` POSIX shim, which Windows cannot execute (the runnable shim there is `wrangler.cmd`) — so every ownership / create / teardown command would fail with a spawn error before reaching Cloudflare. Switch to `cross-spawn` (which resolves the `.cmd` shim on Windows), matching the pattern the sibling integration tests already use. Drop-in on POSIX; the live suite still passes 2/2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- packages/void/test/e2e/cloudflare-deploy-live.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 0a713f5f3..daa08d16c 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -1,5 +1,5 @@ -import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import spawn from 'cross-spawn'; import { join } from 'pathe'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; @@ -91,7 +91,7 @@ function cloudEnv(): NodeJS.ProcessEnv { /** Best-effort `wrangler` invocation for teardown — never throws, so afterAll always continues. */ function wrangler(args: Array<string>): { status: number; output: string } { - const res = spawnSync(WRANGLER_BIN, args, { + const res = spawn.sync(WRANGLER_BIN, args, { cwd: APP, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, @@ -330,7 +330,7 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare (live account)', // The CI-safe path: no `--provision`. The root config now covers every desired id-bearing // resource, so the drift-check passes; this exercises build → tamper-guard → remote migrations → // `wrangler deploy` against the live account. - const res = spawnSync('node', [CLI_ENTRY, 'deploy', '--backend', 'cloudflare'], { + const res = spawn.sync('node', [CLI_ENTRY, 'deploy', '--backend', 'cloudflare'], { cwd: APP, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024, From f159f9685c3e2e5d1de8c5c7fdc36ffb2fc6b1e8 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Wed, 22 Jul 2026 22:12:38 +0800 Subject: [PATCH 09/16] test(deploy): run the live Cloudflare e2e on every CI Wire VOID_E2E_CF_TOKEN / VOID_E2E_CF_ACCOUNT_ID (GitHub repo secrets) into the CI test job so the gated live deploy e2e runs on every push/PR instead of only ever skipping; fork PRs get empty secrets and skip, and win32 skips the live path. Each run deploys under a UNIQUE per-run worker/D1/KV name (VOID_E2E_RUN_ID = run-id + attempt) and tears it down, so overlapping runs never collide and every run gets a fresh environment. Getting the credentials to the test required threading them past three env filters: - vitest collects the test file before the runner injects the step env, so the enable gate + credentials + unique name are read at EXECUTION time (functions + ctx.skip), never at module top-level. - vp run passes only a task's declared env vars to it, so test:all declares the three vars in untrackedEnv (untracked = not folded into the cache fingerprint). - vitest filters the worker env too, mirrored via test.env on the core-markdown project. The live-worker 200 check polls ~2 min because a brand-new *.workers.dev hostname needs its route to propagate (slower from a CI runner), and reports the URL plus whether the workers.dev route is enabled on a persistent non-200. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .github/workflows/ci.yml | 13 ++ .../test/e2e/cloudflare-deploy-live.test.ts | 150 ++++++++++++++---- vite.config.ts | 18 +++ 3 files changed, 148 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e2f8cb0e..59e5a0538 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,19 @@ jobs: runs-on: ${{ matrix.os }} permissions: contents: read + env: + # The gated live Cloudflare deploy e2e (packages/void/test/e2e/cloudflare-deploy-live.test.ts) + # activates only when BOTH vars are non-empty. Same-repo pushes/PRs receive the secrets; fork + # PRs receive empty strings, so the suite SKIPS there. Each run deploys a UNIQUELY-named worker + # (VOID_E2E_RUN_ID) and tears it down, so overlapping runs never collide on the same worker/D1/KV. + # NOTE: only `github.*` (and `secrets.*`) are valid in a job-level `env` map. `strategy.job-index` + # is NOT — referencing it silently voids the ENTIRE map (every var becomes empty, no error), which + # is why an earlier `run-id + attempt + strategy.job-index` value made the token empty and the e2e + # skip. run-id + attempt is already unique per deploying run: windows skips the live path, so only + # the single ubuntu leg ever deploys. + VOID_E2E_CF_TOKEN: ${{ secrets.VOID_E2E_CF_TOKEN }} + VOID_E2E_CF_ACCOUNT_ID: ${{ secrets.VOID_E2E_CF_ACCOUNT_ID }} + VOID_E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} strategy: fail-fast: false matrix: diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index daa08d16c..2fbe6a44c 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -6,14 +6,16 @@ import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; // --------------------------------------------------------------------------------------------- // Gated live-account e2e for the self-host `void deploy --backend cloudflare` path. // -// INERT in normal CI. The whole suite is skipped unless BOTH env vars are present: +// Runs whenever BOTH env vars are present, and cleanly SKIPS (per test, at runtime) otherwise: // - VOID_E2E_CF_TOKEN a Cloudflare API token with permissions to create + delete Workers // Scripts, D1, and KV in the target account. // - VOID_E2E_CF_ACCOUNT_ID the Cloudflare account id to deploy into. // // When they are set, this deploys the `playground/spa` app (D1 + KV + cron) to the target account -// under a FIXED, throwaway worker name — so reruns are idempotent and every resource is cleanable -// by a stable name — then asserts the live worker answers HTTP 200 and tears everything down. +// under a UNIQUE per-run worker name (derived from `VOID_E2E_RUN_ID` in CI, a timestamp locally) so +// that overlapping runs — two pushes in flight, a rerun of the same commit — never collide on the +// same worker or D1/KV. Each run owns a fresh, exclusively-named set that teardown deletes, then +// asserts the live worker answers HTTP 200. // // Why it does NOT drive `--provision`: `void deploy --provision` fails closed in non-interactive // shells by design (a spawned CLI has no TTY, and CI cannot commit back the ids a create would @@ -23,14 +25,33 @@ import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; // `void deploy --backend cloudflare` (no `--provision`) — exercising the real build → tamper-guard // → remote-migrations → `wrangler deploy` pipeline end to end against a live account. // -// The live path is a MANUAL operator step: this repo's CI has no token, so the suite only ever -// SKIPS here. Do not commit a token; export the two vars locally to exercise the real path: +// In CI the two vars come from repo secrets (same-repo pushes/PRs); fork PRs get no secrets and skip. +// Do not commit a token; to exercise the real path locally, export the two vars: // VOID_E2E_CF_TOKEN=... VOID_E2E_CF_ACCOUNT_ID=... vp test run packages/void/test/e2e // --------------------------------------------------------------------------------------------- -const CF_TOKEN = process.env.VOID_E2E_CF_TOKEN ?? ''; -const CF_ACCOUNT_ID = process.env.VOID_E2E_CF_ACCOUNT_ID ?? ''; -const E2E_ENABLED = CF_TOKEN.length > 0 && CF_ACCOUNT_ID.length > 0; +// Read the credentials + the enable gate at EXECUTION time, never at module top-level. Vitest imports +// (collects) this file BEFORE the CI runner injects the job's step env into the worker's process.env, +// so a top-level `process.env.VOID_E2E_CF_TOKEN` read sees an empty string and `describe.skipIf` would +// skip the suite even when the credentials are present — while an identical read inside a hook/test +// (which run AFTER injection) sees the real value. So these are functions, called from the hooks/tests. +function cfToken(): string { + return process.env.VOID_E2E_CF_TOKEN ?? ''; +} +function cfAccountId(): string { + return process.env.VOID_E2E_CF_ACCOUNT_ID ?? ''; +} +// Windows is intentionally excluded from the LIVE deploy: invoking the wrangler bin against a real +// account on win32 is unverified, and this gate runs on EVERY CI — a first-time, unproven windows +// live deploy must not be what turns `main` red. The real-build integration test +// (test/integration/cloudflare-deploy-build.test.ts) already exercises the build path on windows; +// enabling the live path there is a follow-up gated on a verified windows run. Fork PRs also skip +// cleanly: they receive no secrets, so the token is empty. +function e2eEnabled(): boolean { + return cfToken().length > 0 && cfAccountId().length > 0 && process.platform !== 'win32'; +} +const NO_CREDS_NOTE = + 'VOID_E2E_CF_TOKEN + VOID_E2E_CF_ACCOUNT_ID not set (or win32) — live deploy skipped'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PACKAGE_ROOT = join(ROOT, 'packages', 'void'); @@ -42,12 +63,39 @@ const WRANGLER_BIN = join(PACKAGE_ROOT, 'node_modules', '.bin', 'wrangler'); const APP = join(ROOT, 'playground', 'spa'); const APP_WRANGLER = join(APP, 'wrangler.jsonc'); -// A FIXED throwaway worker name (no Date.now()/random, so a crashed run leaves a deterministically -// named worker + resources the next run — or an operator — can delete). The resource names mirror -// what the provisioner derives (`suffixedResourceName`): `<name>-db` (D1) and `<name>-kv` (KV). -const WORKER_NAME = 'void-e2e-cf-deploy'; -const D1_NAME = `${WORKER_NAME}-db`; -const KV_TITLE = `${WORKER_NAME}-kv`; +// A UNIQUE per-run worker name so running this on EVERY CI is safe: two runs that overlap (two pushes +// in flight, a rerun of the same commit) must never deploy the same-named worker or create the same +// D1/KV, or they would clobber each other and teardown would race one against the other. The suffix is +// `VOID_E2E_RUN_ID` (CI sets it to run-id + attempt — unique per deploying run; windows skips the live +// path so only the ubuntu leg deploys) or, locally, a timestamp + random. The shared +// `void-e2e-cf-deploy-` prefix keeps every resource greppable for manual cleanup of an orphan from a +// hard-killed run. Derived names mirror the provisioner (`suffixedResourceName`): `<name>-db` (D1), +// `<name>-kv` (KV). +function uniqueRunSuffix(): string { + const raw = + process.env.VOID_E2E_RUN_ID ?? + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + // Cloudflare worker/D1/KV names are lowercase `[a-z0-9-]` and must not start/end with `-`. + // Sanitize, then cap the suffix so the worker name — and its `-db`/`-kv` tails — stay well under + // the 63-char limit. + const cleaned = raw + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .slice(0, 28) + .replace(/^-+|-+$/g, ''); + return cleaned || 'local'; +} +// Computed in `beforeAll` (execution time), not here, so `VOID_E2E_RUN_ID` is read after the runner +// injects it — the same collection-vs-execution timing as the credentials above. A module-load read +// would always miss the CI run id and fall back to the timestamp. +let WORKER_NAME = ''; +let D1_NAME = ''; +let KV_TITLE = ''; +function computeResourceNames(): void { + WORKER_NAME = `void-e2e-cf-deploy-${uniqueRunSuffix()}`; + D1_NAME = `${WORKER_NAME}-db`; + KV_TITLE = `${WORKER_NAME}-kv`; +} // Binding names the SPA app resolves for its D1 + KV (Void's defaults: `void/db` → DB, `env.KV` → // KV). The pre-created resources must be written into the root config under THESE bindings so the @@ -73,8 +121,8 @@ let ownershipVerified = false; function cloudEnv(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, - CLOUDFLARE_API_TOKEN: CF_TOKEN, - CLOUDFLARE_ACCOUNT_ID: CF_ACCOUNT_ID, + CLOUDFLARE_API_TOKEN: cfToken(), + CLOUDFLARE_ACCOUNT_ID: cfAccountId(), }; // This suite only supports the PUBLIC Cloudflare API + a `*.workers.dev` URL: `workerExistence()` // fetches `api.cloudflare.com` directly and the 200 assertion hits `*.workers.dev`. Scrub every @@ -114,8 +162,8 @@ async function workerExistence(): Promise<'exists' | 'absent' | 'unknown'> { for (let attempt = 0; attempt < 3; attempt++) { try { const res = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/workers/scripts/${WORKER_NAME}`, - { headers: { Authorization: `Bearer ${CF_TOKEN}` } }, + `https://api.cloudflare.com/client/v4/accounts/${cfAccountId()}/workers/scripts/${WORKER_NAME}`, + { headers: { Authorization: `Bearer ${cfToken()}` } }, ); if (res.status === 200) { return 'exists'; @@ -200,13 +248,15 @@ function extractWorkerUrl(output: string): string | undefined { } /** - * Poll the live worker until it serves a 200 (edge propagation can lag the deploy by a few seconds, - * and a freshly-deployed route can transiently 404 or 5xx before it is live). Retries on ANY + * Poll the live worker until it serves a 200. A BRAND-NEW `*.workers.dev` hostname (every run uses a + * unique worker name) needs the route to propagate globally before the first request succeeds, and + * that window is materially longer from a CI runner's network path than from a dev machine — a run + * observed a solid 30s of 404s before the route went live. So poll generously (~2 min). Retries on ANY * non-200 — not just 5xx — so a propagation-window 404 is not mistaken for the final answer. On - * exhaustion returns the LAST response so the caller's status assertion reports the real final - * status; throws only when every attempt was a network error (no response at all). + * exhaustion returns the LAST response so the caller's status assertion reports the real final status; + * throws only when every attempt was a network error (no response at all). */ -async function fetchWithRetry(url: string, attempts = 10): Promise<Response> { +async function fetchWithRetry(url: string, attempts = 40, delayMs = 3000): Promise<Response> { let lastResponse: Response | undefined; let lastError: unknown; for (let i = 0; i < attempts; i++) { @@ -221,7 +271,9 @@ async function fetchWithRetry(url: string, attempts = 10): Promise<Response> { lastError = error; lastResponse = undefined; } - await new Promise((resolve) => setTimeout(resolve, 3000)); + if (i < attempts - 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } } if (lastResponse) { return lastResponse; @@ -229,9 +281,34 @@ async function fetchWithRetry(url: string, attempts = 10): Promise<Response> { throw lastError ?? new Error(`worker at ${url} never answered`); } -describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare (live account)', () => { +/** + * Whether the deployed worker has its `*.workers.dev` route enabled, for diagnosing a persistent 404. + * A propagation-window 404 has the route enabled (just not live yet); a route that is DISABLED 404s + * forever. Best-effort — returns a short human string, never throws. + */ +async function workersDevEnabled(): Promise<string> { + try { + const res = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${cfAccountId()}/workers/scripts/${WORKER_NAME}/subdomain`, + { headers: { Authorization: `Bearer ${cfToken()}` } }, + ); + const body = (await res.json().catch(() => ({}))) as { result?: unknown }; + return `workers.dev subdomain endpoint ${res.status}: ${JSON.stringify(body.result ?? body)}`; + } catch (error) { + return `workers.dev subdomain check errored: ${String(error)}`; + } +} + +describe('void deploy --backend cloudflare (live account)', () => { beforeAll(async () => { - // Ownership gate — refuse to run if any fixed-name resource ALREADY exists in the target account. + // Gate at execution time (see the credential note): when the token/account are absent the suite is + // a no-op — the tests below skip too. Compute the unique resource names now, from the injected env. + if (!e2eEnabled()) { + return; + } + computeResourceNames(); + + // Ownership gate — refuse to run if any of this run's resources ALREADY exist in the target account. // `--provision` ADOPTS an exact-name D1/KV (it would migrate pre-existing data), and teardown // deletes by these exact names, so a collision with a real resource risks destroying data this // test does not own. Only after proving the names are exclusively ours do we set @@ -314,7 +391,7 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare (live account)', const overlay = { name: WORKER_NAME, compatibility_date: COMPAT_DATE, - account_id: CF_ACCOUNT_ID, + account_id: cfAccountId(), d1_databases: [ { binding: D1_BINDING, @@ -357,7 +434,7 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare (live account)', }; // Delete ONLY when the ownership gate proved these names were free before the deploy, so a // collision that aborted beforeAll never deletes a pre-existing resource the test does not own. - if (E2E_ENABLED && ownershipVerified) { + if (e2eEnabled() && ownershipVerified) { // Worker — exists only if the deploy succeeded. Skip when the CF API confirms it is ABSENT // (avoids a spurious "does not exist" failure on a deploy that never got that far); fail closed // when existence cannot be confirmed ('unknown') by attempting the delete anyway. @@ -416,16 +493,23 @@ describe.skipIf(!E2E_ENABLED)('void deploy --backend cloudflare (live account)', } }, 300_000); - it('deploys the worker to the live account', () => { + it('deploys the worker to the live account', (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); expect(deployStatus, deployOutput).toBe(0); // A successful `wrangler deploy` prints the worker's workers.dev URL. expect(deployOutput).toMatch(/https:\/\/[^\s]*\.workers\.dev/); }); - it('serves a live 200 from the deployed worker', async () => { + it('serves a live 200 from the deployed worker', async (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); const base = extractWorkerUrl(deployOutput); expect(base, deployOutput).toBeDefined(); - const res = await fetchWithRetry(`${base!.replace(/\/$/, '')}/api/hello`); - expect(res.status).toBe(200); - }); + const url = `${base!.replace(/\/$/, '')}/api/hello`; + const res = await fetchWithRetry(url); + // On a persistent non-200, report the URL + whether the workers.dev route is even enabled, to + // tell a slow-propagation 404 (route enabled) from a route that never went live (disabled). + const diagnostic = + res.status === 200 ? '' : ` — GET ${url} → ${res.status}; ${await workersDevEnabled()}`; + expect(res.status, `worker ${WORKER_NAME}${diagnostic}`).toBe(200); + }, 200_000); }); diff --git a/vite.config.ts b/vite.config.ts index c1094bfbd..08426ff5d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,17 @@ import { defineConfig } from 'vite-plus'; const maxWorkers = process.env.CI ? 2 : Math.max(2, Math.floor(availableParallelism() * 0.75)); +// Vitest forwards a FILTERED env to test workers — in CI only VOID_E2E_CF_TOKEN survives, while +// VOID_E2E_CF_ACCOUNT_ID and VOID_E2E_RUN_ID are dropped. The gated live Cloudflare deploy e2e +// (packages/void/test/e2e/cloudflare-deploy-live.test.ts) needs all three, so forward them into the +// core-markdown workers explicitly from this main config process (which has the runner's step env). +// Absent/unset → empty strings → the suite skips itself as designed. +const e2eWorkerEnv = { + VOID_E2E_CF_TOKEN: process.env.VOID_E2E_CF_TOKEN ?? '', + VOID_E2E_CF_ACCOUNT_ID: process.env.VOID_E2E_CF_ACCOUNT_ID ?? '', + VOID_E2E_RUN_ID: process.env.VOID_E2E_RUN_ID ?? '', +}; + // Windows CI runners are 2-3x slower for builds and server startup. const ciTimeoutScale = process.platform === 'win32' ? 3 : 1; const testTimeout = 120_000 * ciTimeoutScale; @@ -27,6 +38,11 @@ export default defineConfig({ }, 'test:all': { command: 'vp check && vp run build:all && vp run check && vp test run', + // `vp run` passes ONLY the env vars a task declares to that task — not the full environment. + // Without this, the gated live Cloudflare e2e's credentials never reach `vp test run`, so it + // skips even when the CI job injects them. `untrackedEnv` forwards them without folding them + // into the task's cache fingerprint (credentials must not bust the cache). + untrackedEnv: ['VOID_E2E_CF_TOKEN', 'VOID_E2E_CF_ACCOUNT_ID', 'VOID_E2E_RUN_ID'], }, }, }, @@ -325,6 +341,8 @@ export default defineConfig({ sequence: { groupOrder: 3 }, testTimeout, hookTimeout, + // Forward the live-e2e credentials past vitest's worker env filter (see e2eWorkerEnv). + env: e2eWorkerEnv, include: [ 'packages/void/test/integration/**/*.test.ts', // Gated live-account e2e (test/e2e/**). Collected here so it is skipped cleanly in CI From 21aa08668b1cfe05eb561ad0e98a6134990d9ddd Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Wed, 22 Jul 2026 23:44:03 +0800 Subject: [PATCH 10/16] test(deploy): gate the live e2e behind a Cloudflare environment and harden wrangler parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the live Cloudflare deploy e2e into a dedicated `live-e2e` CI job bound to the `cloudflare-e2e` GitHub Environment (deployment branch policy: main only), running only on a direct push to main. The account token is an environment secret reachable only from that ref-gated job, so a pull_request run — even one that edits the workflow — cannot read it; the main `test` job carries no creds and the live suite self-skips there. - Parse wrangler `--json` list output from stdout alone (the helper still exposes the combined stdout+stderr for error text), so a status-0 stderr notice can no longer break the ownership gate or teardown via JSON.parse. - Treat an empty VOID_E2E_RUN_ID as absent (`||`, not `??`) so a local run falls back to a unique timestamp+random suffix instead of collapsing every run onto `-local`; CI's run id is unaffected. - Match the deployed workers.dev host with a multi-label-safe regex and assert its worker label equals this run's name, catching a clobbered wrangler.jsonc overlay. - Document the environment-gated CI flow, unique per-run naming, and the local single-run constraint in the e2e README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .github/workflows/ci.yml | 47 ++++++++++++++----- packages/void/test/e2e/README.md | 42 ++++++++++++++--- .../test/e2e/cloudflare-deploy-live.test.ts | 44 ++++++++++++----- 3 files changed, 103 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59e5a0538..0e1cac1c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,19 +20,9 @@ jobs: runs-on: ${{ matrix.os }} permissions: contents: read - env: - # The gated live Cloudflare deploy e2e (packages/void/test/e2e/cloudflare-deploy-live.test.ts) - # activates only when BOTH vars are non-empty. Same-repo pushes/PRs receive the secrets; fork - # PRs receive empty strings, so the suite SKIPS there. Each run deploys a UNIQUELY-named worker - # (VOID_E2E_RUN_ID) and tears it down, so overlapping runs never collide on the same worker/D1/KV. - # NOTE: only `github.*` (and `secrets.*`) are valid in a job-level `env` map. `strategy.job-index` - # is NOT — referencing it silently voids the ENTIRE map (every var becomes empty, no error), which - # is why an earlier `run-id + attempt + strategy.job-index` value made the token empty and the e2e - # skip. run-id + attempt is already unique per deploying run: windows skips the live path, so only - # the single ubuntu leg ever deploys. - VOID_E2E_CF_TOKEN: ${{ secrets.VOID_E2E_CF_TOKEN }} - VOID_E2E_CF_ACCOUNT_ID: ${{ secrets.VOID_E2E_CF_ACCOUNT_ID }} - VOID_E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + # No Cloudflare creds in this job. The credentialed live deploy e2e runs in the separate + # `live-e2e` job (below), whose secrets are bound to the `cloudflare-e2e` environment. Here the + # live suite (part of `vp run test:all`) sees empty creds and self-skips on every event. strategy: fail-fast: false matrix: @@ -56,11 +46,42 @@ jobs: - run: vp run test:all + # Credentialed live Cloudflare deploy e2e, ISOLATED from `test` so the account token exists ONLY in + # a job bound to the `cloudflare-e2e` GitHub Environment. That environment's deployment branch policy + # allows `main` ONLY and the ref is enforced server-side, so a `pull_request` run (ref + # refs/pull/<n>/merge) can never reach the secret — even a PR that edits this file — and a modified + # workflow cannot exfiltrate it. The `if` also keeps the job off everything except a direct push to + # the `main` branch: tag pushes and `workflow_call` (release) carry a non-main ref, so they are + # skipped — which both avoids a branch-policy failure and matches where the secret is allowed. It + # deploys a UNIQUELY-named worker (VOID_E2E_RUN_ID) to a throwaway account and tears it down. + live-e2e: + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + environment: cloudflare-e2e + env: + VOID_E2E_CF_TOKEN: ${{ secrets.VOID_E2E_CF_TOKEN }} + VOID_E2E_CF_ACCOUNT_ID: ${{ secrets.VOID_E2E_CF_ACCOUNT_ID }} + VOID_E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 + with: + node-version-file: .node-version + cache: true + # build:core produces dist/cli/cli.mjs + the built `void` plugin the deploy uses to build the app. + - run: vp run build:core + - run: vp test run packages/void/test/e2e/cloudflare-deploy-live.test.ts + done: runs-on: ubuntu-latest if: always() needs: - test + - live-e2e steps: - run: exit 1 if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} diff --git a/packages/void/test/e2e/README.md b/packages/void/test/e2e/README.md index c2f5cea9f..8b0ce3e61 100644 --- a/packages/void/test/e2e/README.md +++ b/packages/void/test/e2e/README.md @@ -1,7 +1,8 @@ # `packages/void/test/e2e` -Gated live-account end-to-end tests. These run REAL Cloudflare operations against a REAL account, -so they are **skipped by default** and never run in CI. +Gated live-account end-to-end tests. These run REAL Cloudflare operations against a REAL account, so +they **skip unless credentials are present**. Locally that means exporting the env vars below; in CI +they run on **pushes to `main`** (see [In CI](#in-ci)). ## `cloudflare-deploy-live.test.ts` @@ -34,7 +35,7 @@ the HTTP-200 assertion has nothing to hit. Opening the Workers & Pages dashboard subdomain automatically; a freshly-registered subdomain takes a few minutes for its cert to go live, during which the first run may see a TLS handshake failure — rerun once the cert is ready. -With no env vars the suite SKIPS cleanly (0 run, N skipped) — this is the CI behavior. +With no env vars the suite SKIPS cleanly (0 run, N skipped). ### Running it @@ -45,8 +46,37 @@ vp run build:core VOID_E2E_CF_TOKEN=... VOID_E2E_CF_ACCOUNT_ID=... vp test run packages/void/test/e2e ``` -The worker name is fixed (`void-e2e-cf-deploy`), so a crashed run leaves deterministically named -resources (`void-e2e-cf-deploy`, `void-e2e-cf-deploy-db`, `void-e2e-cf-deploy-kv`) that a rerun — or -`wrangler delete` — can clean up. A pre-run ownership gate refuses to start if any of those names +### In CI + +`VOID_E2E_CF_TOKEN` and `VOID_E2E_CF_ACCOUNT_ID` are **environment secrets** on the `cloudflare-e2e` +GitHub Environment (not repo secrets), and a dedicated `live-e2e` job in `.github/workflows/ci.yml` +consumes them — the main `test` job carries no creds at all. Two layers keep the account token off +untrusted code: + +- The `live-e2e` job runs only on a direct push to `main` (`github.event_name == 'push' && github.ref +== 'refs/heads/main'`), so `pull_request` runs, tag pushes, and `workflow_call` (release) skip it. +- The `cloudflare-e2e` environment's deployment branch policy allows `main` **only**, enforced by + GitHub server-side on the run's ref. A `pull_request` run's ref is `refs/pull/<n>/merge`, so it can + never reach the secret **even if a PR edits this workflow** — the expression-level gate alone would + be bypassable that way; the environment ref-check is the real boundary. + +So the credentialed live deploy runs on every merge to `main`; everywhere else the creds are empty and +the suite SKIPS (the `test` job still imports the file and skips it). Each `live-e2e` run also gets +`VOID_E2E_RUN_ID = <run-id>-<attempt>` for the unique per-run resource names below. + +### Resource naming & cleanup + +Each run derives a UNIQUE resource name — `void-e2e-cf-deploy-<suffix>` — so overlapping runs never +collide on remote resources. The suffix is `VOID_E2E_RUN_ID` (CI: `<run-id>-<attempt>`) or, locally, a +timestamp + random. Derived names mirror the provisioner: `<name>-db` (D1), `<name>-kv` (KV). The +shared `void-e2e-cf-deploy-` prefix keeps every resource greppable, so an orphan from a hard-killed run +is easy to find and `wrangler delete`. A pre-run ownership gate refuses to start if the run's names already exist (failing closed on any lookup it cannot confirm), so the test never adopts or deletes a resource it does not own. Teardown runs even when the deploy or an assertion fails. + +> **Local concurrency:** unique names isolate _remote_ resources, but a run also snapshots and +> overwrites the shared `playground/spa/wrangler.jsonc` and builds in place. Do **not** run two live +> e2e invocations against the same checkout at once — they would race that file. CI is unaffected (each +> runner is an isolated checkout, and only the single `main`-push `live-e2e` job deploys). The deploy +> test also asserts the returned host's worker label equals this run's `WORKER_NAME`, so a clobbered +> overlay fails loudly instead of validating another run's worker. diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 2fbe6a44c..0aa9eec38 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -72,8 +72,14 @@ const APP_WRANGLER = join(APP, 'wrangler.jsonc'); // hard-killed run. Derived names mirror the provisioner (`suffixedResourceName`): `<name>-db` (D1), // `<name>-kv` (KV). function uniqueRunSuffix(): string { + // `||`, not `??`: `VOID_E2E_RUN_ID` arrives as an EMPTY STRING (not undefined) when unset, because + // `vite.config.ts` forwards `VOID_E2E_RUN_ID: process.env.VOID_E2E_RUN_ID ?? ''` into the worker + // via `test.env`. `??` would treat that `''` as present and collapse every local run onto the + // `-local` suffix (below), so overlapping local runs would clobber one shared worker/D1/KV. `||` + // lets the empty value fall through to a unique timestamp + random. CI sets a non-empty run id, so + // its value is unaffected. const raw = - process.env.VOID_E2E_RUN_ID ?? + process.env.VOID_E2E_RUN_ID || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; // Cloudflare worker/D1/KV names are lowercase `[a-z0-9-]` and must not start/end with `-`. // Sanitize, then cap the suffix so the worker name — and its `-db`/`-kv` tails — stay well under @@ -138,14 +144,20 @@ function cloudEnv(): NodeJS.ProcessEnv { } /** Best-effort `wrangler` invocation for teardown — never throws, so afterAll always continues. */ -function wrangler(args: Array<string>): { status: number; output: string } { +function wrangler(args: Array<string>): { status: number; stdout: string; output: string } { const res = spawn.sync(WRANGLER_BIN, args, { cwd: APP, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, env: cloudEnv(), }); - return { status: res.status ?? 1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; + const stdout = res.stdout ?? ''; + const stderr = res.stderr ?? ''; + // `output` (stdout+stderr) is for human-facing error text only. JSON parsing must read `stdout` + // ALONE: wrangler can print notices/warnings to stderr on a status-0 list (update notices, a + // "Delegating to locally-installed wrangler" line, a Node deprecation warning), and any non-JSON + // trailing the payload would make JSON.parse throw — breaking the ownership gate / teardown. + return { status: res.status ?? 1, stdout, output: `${stdout}${stderr}` }; } /** @@ -200,7 +212,7 @@ function createD1Database(): string { } let databases: Array<{ uuid: string; name: string }>; try { - databases = JSON.parse(list.output) as Array<{ uuid: string; name: string }>; + databases = JSON.parse(list.stdout) as Array<{ uuid: string; name: string }>; } catch { throw new Error(`live e2e setup: "wrangler d1 list" returned non-JSON:\n${list.output.trim()}`); } @@ -229,7 +241,7 @@ function createKvNamespace(): string { } let namespaces: Array<{ id: string; title: string }>; try { - namespaces = JSON.parse(list.output) as Array<{ id: string; title: string }>; + namespaces = JSON.parse(list.stdout) as Array<{ id: string; title: string }>; } catch { throw new Error( `live e2e setup: "wrangler kv namespace list" returned non-JSON:\n${list.output.trim()}`, @@ -244,7 +256,11 @@ function createKvNamespace(): string { /** Extract the deployed workers.dev URL the CLI prints on a successful deploy. */ function extractWorkerUrl(output: string): string | undefined { - return output.match(/https:\/\/[^\s]*\.workers\.dev[^\s]*/)?.[0]; + // `[a-z0-9.-]+` spans the MULTI-label host (`<worker>.<account-subdomain>.workers.dev`); the + // optional `(?:\/[^\s]*)?` consumes only a real URL path, so a trailing sentence period (were the + // URL ever printed in prose) is not captured. A bare `[a-z0-9-]+` before `.workers.dev` would NOT + // match the two-label host and would return undefined — do not "simplify" it to that. + return output.match(/https:\/\/[a-z0-9.-]+\.workers\.dev(?:\/[^\s]*)?/)?.[0]; } /** @@ -338,7 +354,7 @@ describe('void deploy --backend cloudflare (live account)', () => { collisions.push(`d1 database ${D1_NAME} (could not list databases to rule out a collision)`); } else { try { - const databases = JSON.parse(d1List.output) as Array<{ name: string }>; + const databases = JSON.parse(d1List.stdout) as Array<{ name: string }>; if (databases.some((d) => d.name === D1_NAME)) { collisions.push(`d1 database ${D1_NAME}`); } @@ -356,7 +372,7 @@ describe('void deploy --backend cloudflare (live account)', () => { ); } else { try { - const namespaces = JSON.parse(kvList.output) as Array<{ title: string }>; + const namespaces = JSON.parse(kvList.stdout) as Array<{ title: string }>; if (namespaces.some((n) => n.title === KV_TITLE)) { collisions.push(`kv namespace ${KV_TITLE}`); } @@ -449,7 +465,7 @@ describe('void deploy --backend cloudflare (live account)', () => { failures.push(`d1 list (exit ${d1List.status})\n${d1List.output.trim()}`); } else { try { - const databases = JSON.parse(d1List.output) as Array<{ name: string }>; + const databases = JSON.parse(d1List.stdout) as Array<{ name: string }>; if (databases.some((d) => d.name === D1_NAME)) { attemptDelete(`d1 ${D1_NAME}`, ['d1', 'delete', D1_NAME, '--skip-confirmation']); } @@ -467,7 +483,7 @@ describe('void deploy --backend cloudflare (live account)', () => { failures.push(`kv namespace list (exit ${list.status})\n${list.output.trim()}`); } else { try { - const namespaces = JSON.parse(list.output) as Array<{ id: string; title: string }>; + const namespaces = JSON.parse(list.stdout) as Array<{ id: string; title: string }>; const ns = namespaces.find((n) => n.title === KV_TITLE); if (ns) { attemptDelete(`kv ${KV_TITLE}`, ['kv', 'namespace', 'delete', '--namespace-id', ns.id]); @@ -497,7 +513,13 @@ describe('void deploy --backend cloudflare (live account)', () => { ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); expect(deployStatus, deployOutput).toBe(0); // A successful `wrangler deploy` prints the worker's workers.dev URL. - expect(deployOutput).toMatch(/https:\/\/[^\s]*\.workers\.dev/); + const url = extractWorkerUrl(deployOutput); + expect(url, deployOutput).toBeDefined(); + // The host's FIRST label must be THIS run's worker name. If a concurrent local run had clobbered + // the shared `wrangler.jsonc` overlay, the deploy would have targeted a different worker — this + // catches that cross-run contamination rather than silently validating someone else's worker. + const workerLabel = new URL(url!).hostname.split('.')[0]; + expect(workerLabel, `deployed host for ${url}`).toBe(WORKER_NAME); }); it('serves a live 200 from the deployed worker', async (ctx) => { From 7464e8d8e6d35ff1ede687619d9e6a766647c50a Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Wed, 22 Jul 2026 23:57:37 +0800 Subject: [PATCH 11/16] test(deploy): fail the live-e2e job closed when Cloudflare creds are absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedicated live-e2e job self-skipped when a secret was empty (unset, deleted, or a not-yet-rotated token), so it exited 0 and `done` stayed green — silently removing the live-deploy gate. Add a preflight step that fails the job when either credential is empty (the `test` job's uncredentialed run still self-skips), and scope the job to the canonical repo so forks don't run it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .github/workflows/ci.yml | 16 +++++++++++++--- packages/void/test/e2e/README.md | 7 +++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e1cac1c7..e4a2fd414 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,10 +52,13 @@ jobs: # refs/pull/<n>/merge) can never reach the secret — even a PR that edits this file — and a modified # workflow cannot exfiltrate it. The `if` also keeps the job off everything except a direct push to # the `main` branch: tag pushes and `workflow_call` (release) carry a non-main ref, so they are - # skipped — which both avoids a branch-policy failure and matches where the secret is allowed. It - # deploys a UNIQUELY-named worker (VOID_E2E_RUN_ID) to a throwaway account and tears it down. + # skipped — which both avoids a branch-policy failure and matches where the secret is allowed, and + # it is scoped to the canonical repo so forks don't run it. It deploys a UNIQUELY-named worker + # (VOID_E2E_RUN_ID) to a throwaway account and tears it down. A preflight step fails the job when a + # credential is empty (unset / deleted / not-yet-rotated token), so a missing secret can never turn + # the live-deploy gate into a silent green no-op (the `test` job's uncredentialed run still skips). live-e2e: - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'voidzero-dev/void' }} runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read @@ -72,6 +75,13 @@ jobs: with: node-version-file: .node-version cache: true + # Fail closed BEFORE the build: a missing/empty credential must fail this job, not self-skip. The + # `${VAR:?}` expansion exits nonzero when the secret is unset OR empty (GitHub resolves an unset + # secret to ''). + - name: Require live Cloudflare credentials + run: | + : "${VOID_E2E_CF_TOKEN:?empty — set it as a cloudflare-e2e environment secret}" + : "${VOID_E2E_CF_ACCOUNT_ID:?empty — set it as a cloudflare-e2e environment secret}" # build:core produces dist/cli/cli.mjs + the built `void` plugin the deploy uses to build the app. - run: vp run build:core - run: vp test run packages/void/test/e2e/cloudflare-deploy-live.test.ts diff --git a/packages/void/test/e2e/README.md b/packages/void/test/e2e/README.md index 8b0ce3e61..eada0c4a0 100644 --- a/packages/void/test/e2e/README.md +++ b/packages/void/test/e2e/README.md @@ -61,8 +61,11 @@ untrusted code: be bypassable that way; the environment ref-check is the real boundary. So the credentialed live deploy runs on every merge to `main`; everywhere else the creds are empty and -the suite SKIPS (the `test` job still imports the file and skips it). Each `live-e2e` run also gets -`VOID_E2E_RUN_ID = <run-id>-<attempt>` for the unique per-run resource names below. +the suite SKIPS (the `test` job still imports the file and skips it). The `live-e2e` job itself is +fail-closed — a preflight step errors if either credential is empty (a missing, deleted, or +not-yet-rotated secret), so a misconfigured gate is loud instead of a silent green skip. Each +`live-e2e` run also gets `VOID_E2E_RUN_ID = <run-id>-<attempt>` for the unique per-run resource names +below. ### Resource naming & cleanup From 01b3b58921baeb70286f8a74864c80af14101911 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Thu, 23 Jul 2026 10:53:41 +0800 Subject: [PATCH 12/16] test(deploy): cover D1, KV, cron, and re-deploy idempotency in the live e2e Extend the live Cloudflare deploy e2e beyond "serves a 200" to exercise the deploy's actual integrations against the deployed worker: - D1 round-trip: POST /api/users then GET, proving remote migrations applied and the DB binding resolves (the static /api/hello check passes even if `d1 migrations apply --remote` silently failed). - KV round-trip: PUT then GET a new /api/kv/:key route, proving the KV binding resolves the real namespace. The route 404s on an absent key so the poll absorbs KV read-after-write propagation. - Cron registration: assert the deployed worker's schedules include the app's cron via the CF API, with a retry so a transient API blip can't red a healthy deploy. - Re-deploy idempotency: a second `void deploy --backend cloudflare` succeeds and the worker still serves, covering the drift-check + already-applied remote migration + in-place worker update. Generalize fetchWithRetry to forward a request init (for the KV PUT); add a small cfWorkerApi helper for the schedules read. Add the /api/kv/:key route and document it in the spa README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .../test/e2e/cloudflare-deploy-live.test.ts | 130 +++++++++++++++++- playground/spa/README.md | 3 + playground/spa/routes/api/kv/[key].ts | 21 +++ 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 playground/spa/routes/api/kv/[key].ts diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 0aa9eec38..80c9c40d9 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -270,14 +270,20 @@ function extractWorkerUrl(output: string): string | undefined { * observed a solid 30s of 404s before the route went live. So poll generously (~2 min). Retries on ANY * non-200 — not just 5xx — so a propagation-window 404 is not mistaken for the final answer. On * exhaustion returns the LAST response so the caller's status assertion reports the real final status; - * throws only when every attempt was a network error (no response at all). + * throws only when every attempt was a network error (no response at all). `init` is forwarded to + * `fetch` for non-GET requests (e.g. a KV `PUT`) and defaults to a GET. */ -async function fetchWithRetry(url: string, attempts = 40, delayMs = 3000): Promise<Response> { +async function fetchWithRetry( + url: string, + init: RequestInit = {}, + attempts = 40, + delayMs = 3000, +): Promise<Response> { let lastResponse: Response | undefined; let lastError: unknown; for (let i = 0; i < attempts; i++) { try { - const res = await fetch(url); + const res = await fetch(url, init); if (res.status === 200) { return res; } @@ -315,6 +321,14 @@ async function workersDevEnabled(): Promise<string> { } } +/** Authenticated GET against the Cloudflare API for THIS deployed worker (Bearer `cfToken()`). */ +async function cfWorkerApi(path: string): Promise<Response> { + return fetch( + `https://api.cloudflare.com/client/v4/accounts/${cfAccountId()}/workers/scripts/${WORKER_NAME}${path}`, + { headers: { Authorization: `Bearer ${cfToken()}` } }, + ); +} + describe('void deploy --backend cloudflare (live account)', () => { beforeAll(async () => { // Gate at execution time (see the credential note): when the token/account are absent the suite is @@ -534,4 +548,114 @@ describe('void deploy --backend cloudflare (live account)', () => { res.status === 200 ? '' : ` — GET ${url} → ${res.status}; ${await workersDevEnabled()}`; expect(res.status, `worker ${WORKER_NAME}${diagnostic}`).toBe(200); }, 200_000); + + it('persists data through the live D1 binding', async (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); + const base = extractWorkerUrl(deployOutput); + expect(base, deployOutput).toBeDefined(); + const root = base!.replace(/\/$/, ''); + // A 500 here means the remote D1 migration never applied (no `users` table) or the DB binding did + // not resolve — the exact deploy failure the static /api/hello check cannot catch. A unique email + // keeps the read-back assertion precise regardless of any pre-existing rows. This (and the KV/ + // idempotency cases) run AFTER `serves a live 200` has already blocked until the hostname routes + // globally, so their shorter retry budgets only need to cover worker cold-start, not propagation. + const email = `e2e-${uniqueRunSuffix()}@example.com`; + const created = await fetchWithRetry( + `${root}/api/users`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'E2E User', email }), + }, + 15, + 2000, + ); + const createdBody = await created.text(); + expect(created.status, createdBody).toBe(200); + expect(JSON.parse(createdBody)).toEqual({ created: true }); + + const list = await fetchWithRetry(`${root}/api/users`, {}, 15, 2000); + const listBody = await list.text(); + expect(list.status, listBody).toBe(200); + const rows = JSON.parse(listBody) as Array<{ email: string }>; + expect( + rows.some((u) => u.email === email), + `posted user ${email} not found in ${listBody}`, + ).toBe(true); + }, 120_000); + + it('round-trips a value through the live KV binding', async (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); + const base = extractWorkerUrl(deployOutput); + expect(base, deployOutput).toBeDefined(); + const root = base!.replace(/\/$/, ''); + // Proves the `KV` binding resolves to the real namespace end to end: write via the deployed worker, + // then read it back. (The namespace id was pinned into wrangler.jsonc by beforeAll.) + const key = `probe-${uniqueRunSuffix()}`; + const value = `kv-${Date.now().toString(36)}`; + const put = await fetchWithRetry( + `${root}/api/kv/${key}`, + { method: 'PUT', body: value }, + 15, + 2000, + ); + expect(put.status, `PUT /api/kv/${key}`).toBe(200); + const got = await fetchWithRetry(`${root}/api/kv/${key}`, {}, 15, 2000); + const gotBody = await got.text(); + expect(got.status, gotBody).toBe(200); + expect(gotBody).toBe(value); + }, 120_000); + + it('registers the cron trigger on the deployed worker', async (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); + // The deploy emits `triggers.crons` and `wrangler deploy` PUTs them to the worker's schedules. We + // assert the schedule is REGISTERED via the CF API — fast + deterministic — instead of waiting for + // the hourly job to actually fire. Retry so a single transient CF-API blip (a 429/5xx or a network + // throw) or a brief registration-visibility lag on the shared CI runner does not red an + // otherwise-healthy deploy; a genuinely-unregistered cron still fails loud after the retries. + let crons: Array<string | undefined> = []; + let diag = 'no response'; + for (let attempt = 0; attempt < 8; attempt++) { + try { + const res = await cfWorkerApi('/schedules'); + const body = (await res.json().catch(() => ({}))) as { + result?: { schedules?: Array<{ cron?: string }> }; + }; + diag = `status ${res.status}: ${JSON.stringify(body)}`; + if (res.status === 200) { + crons = (body.result?.schedules ?? []).map((s) => s.cron); + if (crons.includes('0 * * * *')) { + break; + } + } + } catch (error) { + diag = `fetch threw: ${String(error)}`; + } + if (attempt < 7) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + expect(crons, `registered schedules — ${diag}`).toContain('0 * * * *'); + }, 60_000); + + it('re-deploys idempotently to the same worker', async (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); + // A SECOND `void deploy` against the already-created worker/D1/KV must succeed cleanly: the + // drift-check still passes (the overlay covers every id), the remote migration is already applied + // (a no-op), and the worker is updated in place. The overlay wrangler.jsonc is still on disk + // (afterAll restores it only after every test), so this reuses it. Runs LAST for that reason. + const res = spawn.sync('node', [CLI_ENTRY, 'deploy', '--backend', 'cloudflare'], { + cwd: APP, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + env: cloudEnv(), + }); + const output = `${res.stdout ?? ''}${res.stderr ?? ''}`; + expect(res.status, output).toBe(0); + // Same worker name → same host; it still serves after the in-place update. + const base = extractWorkerUrl(output) ?? extractWorkerUrl(deployOutput); + expect(base, output).toBeDefined(); + const served = await fetchWithRetry(`${base!.replace(/\/$/, '')}/api/hello`, {}, 15, 2000); + expect(served.status).toBe(200); + }, 600_000); }); diff --git a/playground/spa/README.md b/playground/spa/README.md index 5ab9fff55..fb8fc231e 100644 --- a/playground/spa/README.md +++ b/playground/spa/README.md @@ -13,6 +13,8 @@ routes/api/ users/ index.ts # GET list + POST create (valibot validation) [id].ts # GET single user by param + kv/ + [key].ts # PUT store + GET read a value in KV (:key param) middleware/ 01.logger.ts # Logs method + path for every request migrations/ @@ -28,6 +30,7 @@ crons/ - **Input validation** — `defineHandler.withValidator()` with valibot schemas for body and params - **Typed fetch client** — `fetch` from `void/client` with full type inference from route definitions - **Typed DB queries** — Drizzle ORM via `db` from `void/db` with schema defined in `db/schema.ts` +- **KV storage** — `c.env.KV` read/write in a route, plus the cron writing a heartbeat key - **Middleware** — `defineMiddleware` for cross-cutting concerns (request logging) - **Cron jobs** — `defineScheduled` with cron expression syntax (`"0 * * * *"`) - **SQL migrations** — auto-applied on dev server start diff --git a/playground/spa/routes/api/kv/[key].ts b/playground/spa/routes/api/kv/[key].ts new file mode 100644 index 000000000..468d65925 --- /dev/null +++ b/playground/spa/routes/api/kv/[key].ts @@ -0,0 +1,21 @@ +import * as v from 'valibot'; +import { defineHandler } from 'void'; + +// PUT /api/kv/:key — store the raw request body in KV under an e2e-namespaced key. +export const PUT = defineHandler.withValidator({ + params: v.object({ key: v.string() }), +})(async (c, { params }) => { + await c.env.KV.put(`e2e:${params.key}`, await c.req.text()); + return { ok: true }; +}); + +// GET /api/kv/:key — read the value back (404 when the key is absent). +export const GET = defineHandler.withValidator({ + params: v.object({ key: v.string() }), +})(async (c, { params }) => { + const value = await c.env.KV.get(`e2e:${params.key}`); + if (value === null) { + return new Response('Not found', { status: 404 }); + } + return c.text(value); +}); From dee7f6e96ad97c8ad3a37a4034849e8f7bff8701 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Thu, 23 Jul 2026 11:16:12 +0800 Subject: [PATCH 13/16] =?UTF-8?q?test(deploy):=20harden=20the=20live=20e2e?= =?UTF-8?q?=20=E2=80=94=20bounded=20deploys,=20wider=20KV=20poll,=20strong?= =?UTF-8?q?er=20idempotency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bound both deploy subprocesses with a spawn.sync timeout below the enclosing test timeout: a hung CLI/wrangler would otherwise block the worker's event loop (so vitest's own timer cannot fire) and keep afterAll from running, leaking the worker/D1/KV. A timeout now yields a null status that fails loud and lets teardown reclaim the resources. - Widen the KV read poll to 80s (past KV's ~60s eventual-consistency / negative-cache window) so a first-read miss that raced the write's central visibility converges instead of false-failing CI. - Add a per-request AbortSignal timeout to the polling fetches and the schedules read, so one stalled socket aborts and the retry loop continues rather than eating the whole budget. - Strengthen the idempotency case: read the URL from the second deploy's own output (no fallback to the first deploy), assert the host's worker label equals WORKER_NAME, and re-check a D1-backed route so a redeploy that uploaded nothing or dropped a binding fails instead of passing on a stale /api/hello. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .../test/e2e/cloudflare-deploy-live.test.ts | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 80c9c40d9..4b07fc73b 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -57,6 +57,14 @@ const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PACKAGE_ROOT = join(ROOT, 'packages', 'void'); const CLI_ENTRY = join(PACKAGE_ROOT, 'dist', 'cli', 'cli.mjs'); const WRANGLER_BIN = join(PACKAGE_ROOT, 'node_modules', '.bin', 'wrangler'); +// Bound each deploy subprocess BELOW the enclosing vitest timeout. `spawn.sync` blocks the test +// worker's event loop, so a hung CLI/wrangler would keep vitest's own timer from firing AND keep +// `afterAll` from running — leaking the worker/D1/KV. On timeout, spawnSync returns a null status, +// which fails the status assertion loudly and lets teardown reclaim the resources. +const DEPLOY_TIMEOUT_MS = 480_000; +// Per-request abort deadline for the polling fetches, so a single stalled request aborts and the +// retry loop moves on instead of consuming the whole per-test budget on one hung socket. +const REQUEST_TIMEOUT_MS = 20_000; // Deploy target: the SPA playground (real, buildable Void app exercising D1 + KV + a cron). The // deploy CLI runs its own build → provision → migrate → deploy pipeline against this dir. @@ -283,7 +291,11 @@ async function fetchWithRetry( let lastError: unknown; for (let i = 0; i < attempts; i++) { try { - const res = await fetch(url, init); + const res = await fetch(url, { + ...init, + // Per-request deadline so a hung socket aborts and we retry, rather than blocking the poll. + signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); if (res.status === 200) { return res; } @@ -325,7 +337,11 @@ async function workersDevEnabled(): Promise<string> { async function cfWorkerApi(path: string): Promise<Response> { return fetch( `https://api.cloudflare.com/client/v4/accounts/${cfAccountId()}/workers/scripts/${WORKER_NAME}${path}`, - { headers: { Authorization: `Bearer ${cfToken()}` } }, + { + headers: { Authorization: `Bearer ${cfToken()}` }, + // Bound the request so one stalled schedules read aborts and the caller's retry loop continues. + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, ); } @@ -442,9 +458,10 @@ describe('void deploy --backend cloudflare (live account)', () => { encoding: 'utf8', maxBuffer: 20 * 1024 * 1024, env: cloudEnv(), + timeout: DEPLOY_TIMEOUT_MS, }); deployStatus = res.status; - deployOutput = `${res.stdout ?? ''}${res.stderr ?? ''}`; + deployOutput = `${res.stdout ?? ''}${res.stderr ?? ''}${res.error ? `\n${res.error}` : ''}`; }, 600_000); afterAll(async () => { @@ -600,11 +617,14 @@ describe('void deploy --backend cloudflare (live account)', () => { 2000, ); expect(put.status, `PUT /api/kv/${key}`).toBe(200); - const got = await fetchWithRetry(`${root}/api/kv/${key}`, {}, 15, 2000); + // KV is eventually consistent and a first-read MISS can be negatively cached at the edge for up to + // ~60s, so poll past that window (40 × 2s = 80s). A healthy binding whose first GET raced the + // write's central visibility then converges rather than false-failing CI. + const got = await fetchWithRetry(`${root}/api/kv/${key}`, {}, 40, 2000); const gotBody = await got.text(); expect(got.status, gotBody).toBe(200); expect(gotBody).toBe(value); - }, 120_000); + }, 150_000); it('registers the cron trigger on the deployed worker', async (ctx) => { ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); @@ -649,13 +669,22 @@ describe('void deploy --backend cloudflare (live account)', () => { encoding: 'utf8', maxBuffer: 20 * 1024 * 1024, env: cloudEnv(), + timeout: DEPLOY_TIMEOUT_MS, }); - const output = `${res.stdout ?? ''}${res.stderr ?? ''}`; + const output = `${res.stdout ?? ''}${res.stderr ?? ''}${res.error ? `\n${res.error}` : ''}`; expect(res.status, output).toBe(0); - // Same worker name → same host; it still serves after the in-place update. - const base = extractWorkerUrl(output) ?? extractWorkerUrl(deployOutput); + // Prove THIS deploy re-targeted the same worker: read the URL from ITS OWN output (no fallback to + // the first deploy, which would mask a second deploy that uploaded nothing) and require the host's + // worker label to equal WORKER_NAME. + const base = extractWorkerUrl(output); expect(base, output).toBeDefined(); - const served = await fetchWithRetry(`${base!.replace(/\/$/, '')}/api/hello`, {}, 15, 2000); + expect(new URL(base!).hostname.split('.')[0], output).toBe(WORKER_NAME); + const root = base!.replace(/\/$/, ''); + // The re-deployed worker still serves AND its bindings survived the update: /api/hello (static) is + // 200, and /api/users (a D1-backed route) is 200 — a dropped or broken DB binding would 500. + const served = await fetchWithRetry(`${root}/api/hello`, {}, 15, 2000); expect(served.status).toBe(200); + const dbRoute = await fetchWithRetry(`${root}/api/users`, {}, 15, 2000); + expect(dbRoute.status, await dbRoute.text()).toBe(200); }, 600_000); }); From 4319634086d856fd5a4f3b8d37da028ebedb656f Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Thu, 23 Jul 2026 11:42:19 +0800 Subject: [PATCH 14/16] test(deploy): bound the wrangler helper and CF-API existence checks in the live e2e Two remaining unbounded calls could freeze teardown and leak resources: - The wrangler() helper used spawn.sync with no timeout, so a stalled wrangler/CF call during setup or teardown would block the worker's event loop (keeping vitest's hook timer from firing) and strand already-created resources. Bound it with a finite timeout; a timeout yields a non-zero status that every caller already treats as a failed call (fail-closed gate / recorded teardown failure) and surfaces the error in the output. - workerExistence() and workersDevEnabled() fetched the CF API with no abort deadline, so an accepted-but-unanswered request could hang the ownership gate or teardown existence check. Add the same per-request AbortSignal deadline the other API helpers already use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .../test/e2e/cloudflare-deploy-live.test.ts | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 4b07fc73b..14f6a0ce3 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -62,6 +62,10 @@ const WRANGLER_BIN = join(PACKAGE_ROOT, 'node_modules', '.bin', 'wrangler'); // `afterAll` from running — leaking the worker/D1/KV. On timeout, spawnSync returns a null status, // which fails the status assertion loudly and lets teardown reclaim the resources. const DEPLOY_TIMEOUT_MS = 480_000; +// The same event-loop-block reasoning applies to every `wrangler` call (setup create/list, the +// ownership gate, teardown list/delete): an unbounded `spawn.sync` on a stalled wrangler/CF call would +// freeze vitest's hook timer and strand already-created resources. Quick ops, so a much shorter bound. +const WRANGLER_TIMEOUT_MS = 120_000; // Per-request abort deadline for the polling fetches, so a single stalled request aborts and the // retry loop moves on instead of consuming the whole per-test budget on one hung socket. const REQUEST_TIMEOUT_MS = 20_000; @@ -158,6 +162,10 @@ function wrangler(args: Array<string>): { status: number; stdout: string; output encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, env: cloudEnv(), + // Bound each call so a stalled wrangler/CF request can't block the event loop and freeze teardown + // (see WRANGLER_TIMEOUT_MS). On timeout, status is null → `?? 1` makes it a non-zero (failed) call, + // which every caller already handles: the ownership gate fails closed, teardown records a failure. + timeout: WRANGLER_TIMEOUT_MS, }); const stdout = res.stdout ?? ''; const stderr = res.stderr ?? ''; @@ -165,7 +173,11 @@ function wrangler(args: Array<string>): { status: number; stdout: string; output // ALONE: wrangler can print notices/warnings to stderr on a status-0 list (update notices, a // "Delegating to locally-installed wrangler" line, a Node deprecation warning), and any non-JSON // trailing the payload would make JSON.parse throw — breaking the ownership gate / teardown. - return { status: res.status ?? 1, stdout, output: `${stdout}${stderr}` }; + return { + status: res.status ?? 1, + stdout, + output: `${stdout}${stderr}${res.error ? `\n${res.error}` : ''}`, + }; } /** @@ -183,7 +195,12 @@ async function workerExistence(): Promise<'exists' | 'absent' | 'unknown'> { try { const res = await fetch( `https://api.cloudflare.com/client/v4/accounts/${cfAccountId()}/workers/scripts/${WORKER_NAME}`, - { headers: { Authorization: `Bearer ${cfToken()}` } }, + { + headers: { Authorization: `Bearer ${cfToken()}` }, + // Abort a stalled connection so the retry loop (and teardown that depends on it) stays + // bounded — an accepted-but-never-answered request would otherwise hang the whole check. + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, ); if (res.status === 200) { return 'exists'; @@ -324,7 +341,10 @@ async function workersDevEnabled(): Promise<string> { try { const res = await fetch( `https://api.cloudflare.com/client/v4/accounts/${cfAccountId()}/workers/scripts/${WORKER_NAME}/subdomain`, - { headers: { Authorization: `Bearer ${cfToken()}` } }, + { + headers: { Authorization: `Bearer ${cfToken()}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, ); const body = (await res.json().catch(() => ({}))) as { result?: unknown }; return `workers.dev subdomain endpoint ${res.status}: ${JSON.stringify(body.result ?? body)}`; From 1c90652bfc6e6f8cba6b097863f6bb26d1cc43cf Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Thu, 23 Jul 2026 12:54:22 +0800 Subject: [PATCH 15/16] test(deploy): bound the whole fetch-retry loop by the enclosing test's signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchWithRetry only bounded each request (AbortSignal.timeout), not the loop: a worker that accepts connections but never answers could keep the 40-attempt loop running ~15 minutes while the enclosing test times out at 200s, and vitest cannot cancel the still-running promise — so requests and timers leaked into later tests and teardown. Thread the test's ctx.signal (which vitest aborts at the test timeout) into fetchWithRetry: break the loop when it fires, combine it with the per-request deadline via AbortSignal.any, and make the inter-attempt delay abortable. The loop now stops with its test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .../test/e2e/cloudflare-deploy-live.test.ts | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index 14f6a0ce3..e6c72ff1f 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -288,6 +288,26 @@ function extractWorkerUrl(output: string): string | undefined { return output.match(/https:\/\/[a-z0-9.-]+\.workers\.dev(?:\/[^\s]*)?/)?.[0]; } +/** A `setTimeout` delay that also resolves immediately when `signal` aborts, so a pending retry wait + * never outlives a timed-out test. */ +function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); +} + /** * Poll the live worker until it serves a 200. A BRAND-NEW `*.workers.dev` hostname (every run uses a * unique worker name) needs the route to propagate globally before the first request succeeds, and @@ -303,15 +323,28 @@ async function fetchWithRetry( init: RequestInit = {}, attempts = 40, delayMs = 3000, + signal?: AbortSignal, ): Promise<Response> { let lastResponse: Response | undefined; let lastError: unknown; for (let i = 0; i < attempts; i++) { + // Overall deadline: callers pass the test's `ctx.signal`, which vitest aborts at the test timeout. + // Without it, a worker that accepts connections but never answers could keep this loop running + // (e.g. 40 × ~22s ≈ 15 min) long after vitest already failed the test, leaking requests and timers + // into later tests and teardown. Stop as soon as the enclosing test is done. + if (signal?.aborted) { + break; + } try { const res = await fetch(url, { ...init, - // Per-request deadline so a hung socket aborts and we retry, rather than blocking the poll. - signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + // Per-request deadline (fail a single stalled socket fast) AND the overall test signal, so + // neither one request nor the whole loop can outlive its budget. + signal: + init.signal ?? + (signal + ? AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]) + : AbortSignal.timeout(REQUEST_TIMEOUT_MS)), }); if (res.status === 200) { return res; @@ -323,7 +356,7 @@ async function fetchWithRetry( lastResponse = undefined; } if (i < attempts - 1) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); + await abortableDelay(delayMs, signal); } } if (lastResponse) { @@ -578,7 +611,7 @@ describe('void deploy --backend cloudflare (live account)', () => { const base = extractWorkerUrl(deployOutput); expect(base, deployOutput).toBeDefined(); const url = `${base!.replace(/\/$/, '')}/api/hello`; - const res = await fetchWithRetry(url); + const res = await fetchWithRetry(url, {}, 40, 3000, ctx.signal); // On a persistent non-200, report the URL + whether the workers.dev route is even enabled, to // tell a slow-propagation 404 (route enabled) from a route that never went live (disabled). const diagnostic = @@ -606,12 +639,13 @@ describe('void deploy --backend cloudflare (live account)', () => { }, 15, 2000, + ctx.signal, ); const createdBody = await created.text(); expect(created.status, createdBody).toBe(200); expect(JSON.parse(createdBody)).toEqual({ created: true }); - const list = await fetchWithRetry(`${root}/api/users`, {}, 15, 2000); + const list = await fetchWithRetry(`${root}/api/users`, {}, 15, 2000, ctx.signal); const listBody = await list.text(); expect(list.status, listBody).toBe(200); const rows = JSON.parse(listBody) as Array<{ email: string }>; @@ -635,12 +669,13 @@ describe('void deploy --backend cloudflare (live account)', () => { { method: 'PUT', body: value }, 15, 2000, + ctx.signal, ); expect(put.status, `PUT /api/kv/${key}`).toBe(200); // KV is eventually consistent and a first-read MISS can be negatively cached at the edge for up to // ~60s, so poll past that window (40 × 2s = 80s). A healthy binding whose first GET raced the // write's central visibility then converges rather than false-failing CI. - const got = await fetchWithRetry(`${root}/api/kv/${key}`, {}, 40, 2000); + const got = await fetchWithRetry(`${root}/api/kv/${key}`, {}, 40, 2000, ctx.signal); const gotBody = await got.text(); expect(got.status, gotBody).toBe(200); expect(gotBody).toBe(value); @@ -702,9 +737,9 @@ describe('void deploy --backend cloudflare (live account)', () => { const root = base!.replace(/\/$/, ''); // The re-deployed worker still serves AND its bindings survived the update: /api/hello (static) is // 200, and /api/users (a D1-backed route) is 200 — a dropped or broken DB binding would 500. - const served = await fetchWithRetry(`${root}/api/hello`, {}, 15, 2000); + const served = await fetchWithRetry(`${root}/api/hello`, {}, 15, 2000, ctx.signal); expect(served.status).toBe(200); - const dbRoute = await fetchWithRetry(`${root}/api/users`, {}, 15, 2000); + const dbRoute = await fetchWithRetry(`${root}/api/users`, {}, 15, 2000, ctx.signal); expect(dbRoute.status, await dbRoute.text()).toBe(200); }, 600_000); }); From f17334d261d37ea4a32436adcaf5c4aae701f0a6 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Thu, 23 Jul 2026 14:49:27 +0800 Subject: [PATCH 16/16] test(deploy): bound the cron poll and aggregate wrangler work to each deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two loop/aggregate operations in the live Cloudflare e2e were bounded only per-call, so a stalled Cloudflare service could overrun the enclosing test or hook while spawn.sync blocked vitest's timer. - Cron poll: thread the test signal through cfWorkerApi (AbortSignal.any with the per-request timeout) and wait with abortableDelay, breaking on ctx.signal.aborted — so 8x20s of polling can't outlive the 60s test, matching fetchWithRetry. - Wrangler calls: each hook now stamps a wranglerDeadline (setup 90s, teardown 200s) and wrangler() shrinks each call's timeout to the time remaining, skipping as a failed call once spent — so the 5-6 sequential spawn.sync calls can't collectively overrun the hook (setup 90s+480s deploy < 600s beforeAll; teardown 200s+~64s probe < 300s afterAll). An exhausted budget records the un-deleted resource as a loud failure instead of hanging. Verified: typecheck clean; live e2e 6/6 pass, account left clean (0 workers, 0 D1, 0 KV). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .../test/e2e/cloudflare-deploy-live.test.ts | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts index e6c72ff1f..8da4cca2f 100644 --- a/packages/void/test/e2e/cloudflare-deploy-live.test.ts +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -69,6 +69,17 @@ const WRANGLER_TIMEOUT_MS = 120_000; // Per-request abort deadline for the polling fetches, so a single stalled request aborts and the // retry loop moves on instead of consuming the whole per-test budget on one hung socket. const REQUEST_TIMEOUT_MS = 20_000; +// `WRANGLER_TIMEOUT_MS` bounds ONE stalled call, but each hook fires SEVERAL wrangler calls back to +// back with no `await` between them (setup: 6 create/list; teardown: up to 5 list/delete), so +// `spawn.sync` blocks the event loop across the whole run and vitest's hook timer can never fire to +// interrupt it — the per-call bound alone lets the AGGREGATE (5–6 × 120s) blow past the hook budget, +// and an external cancel mid-teardown strands D1/KV. These budgets cap the SUM of wrangler wall-clock +// per hook: each hook stamps `wranglerDeadline` at its start and `wrangler()` shrinks each call's +// timeout to the time left, skipping (as a failed call) once spent. Sized under the hook timeouts — +// setup: 90s + DEPLOY_TIMEOUT_MS (a separate spawn) = 570s < 600s beforeAll; teardown: 200s + the +// ~64s async worker probe = 264s < 300s afterAll. +const SETUP_WRANGLER_BUDGET_MS = 90_000; +const TEARDOWN_WRANGLER_BUDGET_MS = 200_000; // Deploy target: the SPA playground (real, buildable Void app exercising D1 + KV + a cron). The // deploy CLI runs its own build → provision → migrate → deploy pipeline against this dir. @@ -155,8 +166,25 @@ function cloudEnv(): NodeJS.ProcessEnv { return env; } +// Absolute wall-clock deadline (epoch ms) for the CURRENT hook's aggregate wrangler work; each hook +// stamps it at its start (see SETUP_/TEARDOWN_WRANGLER_BUDGET_MS). Infinity outside a hook so a stray +// call is still bounded by WRANGLER_TIMEOUT_MS alone. +let wranglerDeadline = Number.POSITIVE_INFINITY; + /** Best-effort `wrangler` invocation for teardown — never throws, so afterAll always continues. */ function wrangler(args: Array<string>): { status: number; stdout: string; output: string } { + // Shrink this call's timeout to the time left in the hook's aggregate budget, so a run of stalled + // calls can't collectively overrun the hook (spawn.sync blocks the loop, so vitest can't interrupt). + // Once the budget is spent, skip the call as a failed one rather than launching a doomed spawn — the + // ownership gate fails closed and teardown records the leak loudly instead of silently hanging. + const remaining = wranglerDeadline - Date.now(); + if (remaining <= 0) { + return { + status: 1, + stdout: '', + output: `wrangler ${args.join(' ')} skipped — hook wrangler-time budget exhausted`, + }; + } const res = spawn.sync(WRANGLER_BIN, args, { cwd: APP, encoding: 'utf8', @@ -165,7 +193,7 @@ function wrangler(args: Array<string>): { status: number; stdout: string; output // Bound each call so a stalled wrangler/CF request can't block the event loop and freeze teardown // (see WRANGLER_TIMEOUT_MS). On timeout, status is null → `?? 1` makes it a non-zero (failed) call, // which every caller already handles: the ownership gate fails closed, teardown records a failure. - timeout: WRANGLER_TIMEOUT_MS, + timeout: Math.min(WRANGLER_TIMEOUT_MS, remaining), }); const stdout = res.stdout ?? ''; const stderr = res.stderr ?? ''; @@ -387,13 +415,16 @@ async function workersDevEnabled(): Promise<string> { } /** Authenticated GET against the Cloudflare API for THIS deployed worker (Bearer `cfToken()`). */ -async function cfWorkerApi(path: string): Promise<Response> { +async function cfWorkerApi(path: string, signal?: AbortSignal): Promise<Response> { return fetch( `https://api.cloudflare.com/client/v4/accounts/${cfAccountId()}/workers/scripts/${WORKER_NAME}${path}`, { headers: { Authorization: `Bearer ${cfToken()}` }, - // Bound the request so one stalled schedules read aborts and the caller's retry loop continues. - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + // Bound the request so one stalled schedules read aborts and the caller's retry loop continues; + // when the caller passes the test signal, also abort the moment Vitest times the test out. + signal: signal + ? AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]) + : AbortSignal.timeout(REQUEST_TIMEOUT_MS), }, ); } @@ -405,6 +436,10 @@ describe('void deploy --backend cloudflare (live account)', () => { if (!e2eEnabled()) { return; } + // Cap the SUM of this hook's wrangler wall-clock (the ownership-gate lists + the create/list pairs) + // so it plus the separate deploy spawn stays under the 600s beforeAll budget. The deploy is NOT a + // wrangler() call, so it keeps its own DEPLOY_TIMEOUT_MS. + wranglerDeadline = Date.now() + SETUP_WRANGLER_BUDGET_MS; computeResourceNames(); // Ownership gate — refuse to run if any of this run's resources ALREADY exist in the target account. @@ -535,6 +570,9 @@ describe('void deploy --backend cloudflare (live account)', () => { // Delete ONLY when the ownership gate proved these names were free before the deploy, so a // collision that aborted beforeAll never deletes a pre-existing resource the test does not own. if (e2eEnabled() && ownershipVerified) { + // Cap the SUM of teardown's wrangler wall-clock (the worker probe is async + separately bounded) + // so the whole cleanup stays under the 300s afterAll budget even if every list/delete stalls. + wranglerDeadline = Date.now() + TEARDOWN_WRANGLER_BUDGET_MS; // Worker — exists only if the deploy succeeded. Skip when the CF API confirms it is ABSENT // (avoids a spurious "does not exist" failure on a deploy that never got that far); fail closed // when existence cannot be confirmed ('unknown') by attempting the delete anyway. @@ -691,8 +729,13 @@ describe('void deploy --backend cloudflare (live account)', () => { let crons: Array<string | undefined> = []; let diag = 'no response'; for (let attempt = 0; attempt < 8; attempt++) { + // Stop as soon as Vitest times the test out: like `fetchWithRetry`, thread `ctx.signal` into the + // request AND the inter-attempt delay so 8 × 20s of polling can't outlive the 60s test budget. + if (ctx.signal.aborted) { + break; + } try { - const res = await cfWorkerApi('/schedules'); + const res = await cfWorkerApi('/schedules', ctx.signal); const body = (await res.json().catch(() => ({}))) as { result?: { schedules?: Array<{ cron?: string }> }; }; @@ -707,7 +750,7 @@ describe('void deploy --backend cloudflare (live account)', () => { diag = `fetch threw: ${String(error)}`; } if (attempt < 7) { - await new Promise((resolve) => setTimeout(resolve, 2000)); + await abortableDelay(2000, ctx.signal); } } expect(crons, `registered schedules — ${diag}`).toContain('0 * * * *');