diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..3bac9010f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# Enforce LF line endings for every text file on all platforms. +# +# Without this, the Windows CI runner (git's default `core.autocrlf=true`) checks out +# text files with CRLF, and oxfmt — which `vp check` / `vp fmt` run and which enforces +# LF — then flags every file as mis-formatted (the `test (windows-latest)` job). Every +# committed blob is already LF, so this only changes checkout behavior on Windows; it +# triggers no renormalization of existing files. +* text=auto eol=lf + +# Binary assets — never apply any line-ending conversion. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.avif binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.eot binary +*.pdf binary +*.wasm binary +*.mp4 binary +*.zip binary +*.gz binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4a2fd414..658cdd6df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,34 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false + # Defender's real-time scan is synchronous on every file write, and this suite writes a lot of + # them (dependency install, ~26 playground builds, per-worker temp dirs). Excluding the paths + # we generate is purely a speed measure: it never changes what runs. + # + # Skipped for fork pull requests, the only case where the checkout is code no maintainer has + # pushed. Not because scanning is a boundary for this job — it has no secrets, a read-only + # token, an ephemeral runner, and already executes the PR's own build and test code by design + # — but because leaving the scan on for untrusted checkouts costs us nothing: Windows runs on + # a PR only when a maintainer applies the `test: windows` label, so this is rare, while main + # pushes and maintainer branches (the everyday path) keep the speedup. + # + # Warns instead of failing, so a runner image that refuses the call still runs the suite. + - name: Exclude build paths from Windows Defender + if: >- + ${{ contains(matrix.os, 'windows') + && (github.event_name == 'push' + || github.event.pull_request.head.repo.full_name == github.repository) }} + shell: pwsh + run: | + foreach ($path in @('${{ github.workspace }}', '${{ runner.temp }}')) { + try { + Add-MpPreference -ExclusionPath $path -ErrorAction Stop + Write-Host "Defender exclusion added: $path" + } catch { + Write-Host "::warning::could not add Defender exclusion for ${path}: $_" + } + } + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version-file: .node-version diff --git a/.gitignore b/.gitignore index a90ec7a4c..166f8ceea 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ playground-local .worktrees .svelte-kit/ docs/superpowers/ + +# Test temp dirs (unique per test; a Windows run may leave one behind when the OS has not +# yet unmapped a terminated child process image — see provision.test.ts teardown). +**/.tmp-* diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 23d02518b..fd577f151 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -17,7 +17,7 @@ import { import { createRequire } from 'node:module'; import { types as utilTypes } from 'node:util'; import { parse, type ParseError } from 'jsonc-parser'; -import { basename, dirname, join, resolve } from 'pathe'; +import { basename, dirname, join, normalize, resolve } from 'pathe'; import lockfile from 'proper-lockfile'; import { voidLog, voidWarn } from '../log.ts'; import { scanWranglerDotenvKeys } from '../shared/dotenv.ts'; @@ -1424,6 +1424,32 @@ export function createDefaultRunWrangler( killProcessGroup(-child.pid, signal); // negative pid → the whole group return 'signaled'; } + if (isWindows && typeof child.pid === 'number') { + // Windows has NO process groups, so the signal target is the child ITSELF. + // Consult Node's own bookkeeping FIRST: once the exit has been observed there is + // nothing left to signal. This ORDERING is also what makes the pid-based call below + // free of any pid-reuse hazard. Windows destroys a pid only when the process OBJECT + // is destroyed, which requires the process to have exited AND every handle to it to + // be closed; libuv closes its handle (`CloseHandle(handle->process_handle)` in + // `uv__process_proc_exit`, win/process.c) in the SAME straight-line frame that then + // invokes `exit_cb` — Node's `onexit`, whose first statements set + // `exitCode`/`signalCode`. No JavaScript runs in between, so a pid observed here + // with a null exit code is still pinned by libuv's open handle and cannot have been + // reassigned. (`child.kill()` is no safer in that instant: libuv sets + // UV_HANDLE_ESRCH on the very next line and `uv_process_kill` then short-circuits to + // ESRCH, which `ChildProcess.kill` reports as the same ambiguous `false`.) + if (child.exitCode !== null || child.signalCode !== null) { + return 'gone'; + } + // Routed through the SAME seam POSIX uses, because `process.kill` THROWS ESRCH + // (classified "already gone" below), whereas `child.kill()` collapses ESRCH and + // EPERM into one silent `false`. Node sets `exitCode`/`signalCode` asynchronously + // in its `exit` handler, so a child that died a moment ago still reads as + // "running" here — and the ambiguous `false` would then be reported as a FAILED + // delivery, quarantining the lock over a child that had simply already exited. + killProcessGroup(child.pid, signal); + return 'signaled'; + } if (child.kill(signal)) { return 'signaled'; } @@ -1447,11 +1473,31 @@ export function createDefaultRunWrangler( if (groupGone) { return true; } - if (isWindows || typeof child.pid !== 'number') { - // No process-group concept — `close` is the only (and sufficient) confirmation. + if (typeof child.pid !== 'number') { groupGone = true; return true; } + if (isWindows) { + // No process GROUP to probe, but the child ITSELF is still confirmable: libuv + // implements `kill(pid, 0)` on Windows as a real liveness check + // (GetExitCodeProcess + WaitForSingleObject). Descendants are covered separately — + // libuv assigns every non-`detached` child to a global Job Object with + // KILL_ON_JOB_CLOSE, so they die with this process. Prefer Node's own bookkeeping + // (immune to pid reuse) and fall back to the signal-0 probe while the exit has not + // been observed yet — so confirmation stays EARNED rather than assumed. + if (child.exitCode !== null || child.signalCode !== null) { + groupGone = true; + return true; + } + try { + process.kill(child.pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + groupGone = true; + } + } + return groupGone; + } try { process.kill(-child.pid, 0); } catch (error) { @@ -2259,7 +2305,11 @@ function assertQuarantineClearedOrClear(lockTarget: string, onBeforeClear?: () = typeof recorded?.quarantinedAt === 'string' ? recorded.quarantinedAt : undefined; // Prefer the recorded GROUP (negative-pid probe); fall back to the plain pid where no // group was recorded (Windows). No probe target at all → cannot verify → fail closed. - const probeTarget = pgid !== undefined ? -pgid : pid; + // A NEGATIVE target is POSIX-ONLY: on win32 `process.kill(-n, 0)` resolves to + // OpenProcess((DWORD)-n), which fails with ERROR_INVALID_PARAMETER and surfaces as ESRCH — + // i.e. it would report ANY recorded owner "confirmed dead" and clear a LIVE quarantine, + // inverting this gate from fail-closed to fail-open. Mirrors the watchdog's guard above. + const probeTarget = pgid !== undefined && process.platform !== 'win32' ? -pgid : (pid ?? pgid); let confirmedDead = false; if (probeTarget !== undefined) { try { @@ -2354,6 +2404,20 @@ function loadProperLockfileInternals(): ProperLockfileInternals | undefined { } } +/** + * `realpathSync` is `node:fs`, so it returns a NATIVE path — backslashes on Windows — + * while every other path in this module comes from `pathe` and is POSIX-normalized, + * including the not-yet-created branch below (whose `join()` already normalizes). Without + * this, `canonicalizeConfigPath` hands back TWO different spellings of the same file + * depending only on whether it happens to exist yet, defeating the "ONE identity" contract + * the function exists to provide — and splitting the lock identity, the wrangler `-c` argv, + * and the paths printed in error messages. No-op off Windows, where `\` is a legal filename + * character that must survive byte-for-byte. + */ +function toPosixConfigPath(nativePath: string): string { + return process.platform === 'win32' ? normalize(nativePath) : nativePath; +} + /** * Canonicalize an already-absolute config path via `realpath` so the lock identity * and every read/write/`-c` key on the REAL file, not a lexical symlink alias. When the @@ -2366,7 +2430,7 @@ function loadProperLockfileInternals(): ProperLockfileInternals | undefined { */ function canonicalizeConfigPath(absoluteConfigPath: string, depth = 0): string { try { - return realpathSync(absoluteConfigPath); + return toPosixConfigPath(realpathSync(absoluteConfigPath)); } catch (error) { // ONLY ENOENT (a path component does not exist yet) is the benign "not created yet" case // we handle below. Any OTHER realpath error (EACCES, ELOOP for a real symlink cycle, diff --git a/packages/void/test/integration/cloudflare-deploy-build.test.ts b/packages/void/test/integration/cloudflare-deploy-build.test.ts index 2a017945e..d334ff98e 100644 --- a/packages/void/test/integration/cloudflare-deploy-build.test.ts +++ b/packages/void/test/integration/cloudflare-deploy-build.test.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import spawn from 'cross-spawn'; -import { join } from 'pathe'; +import { join, normalize } from 'pathe'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { computeDesiredCloudflareResources, @@ -167,7 +167,11 @@ describe('playground/kitchen-sink --backend cloudflare real-build deploy guard', const pointer = JSON.parse( readFileSync(join(PG, '.wrangler', 'deploy', 'config.json'), 'utf-8'), ) as { configPath?: string }; - expect(pointer.configPath).toBe('../../dist/ssr/wrangler.json'); + // The CF plugin writes this with node:path.relative, so the separator is PLATFORM-NATIVE + // (`..\..\dist\ssr\wrangler.json` on Windows). The guard that consumes it resolves through + // pathe, which normalizes backslashes, so compare the normalized form rather than pinning + // a POSIX literal that only holds on one platform. + expect(normalize(pointer.configPath ?? '')).toBe('../../dist/ssr/wrangler.json'); }); it('merges the pinned account, worker name, and real binding ids into the emitted artifact', () => { diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index b3dd56f5b..b0578b2db 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; import { log } from '@clack/prompts'; import { dirname, join } from 'pathe'; import { afterEach, describe, expect, it } from 'vite-plus/test'; @@ -103,9 +104,18 @@ function emittedVars(vars: Record = {}): EmittedWorkerVarsResul return { ok: true, vars }; } -/** Absolute src paths for runtime imports inside spawned-child env.ts fixtures. */ -const srcEnvPublic = join(import.meta.dirname, '..', '..', 'src', 'runtime', 'env-public.ts'); -const srcEnvHelpers = join(import.meta.dirname, '..', '..', 'src', 'runtime', 'env-helpers.ts'); +/** + * `file://` URLs for the runtime imports inside spawned-child env.ts fixtures. These are ESM + * SPECIFIERS, not filesystem paths: an absolute Windows path (`D:/…`, as `pathe` spells it) + * parses as a URL whose protocol is `d:`, so Node rejects it with + * ERR_UNSUPPORTED_ESM_URL_SCHEME and the child's env.ts never evaluates — the probe then + * reports `schema-error` for reasons that have nothing to do with the schema. + * `pathToFileURL` is the same form `loadUserEnvFile` uses for real user env files. + */ +const srcFileUrl = (...segments: Array): string => + pathToFileURL(join(import.meta.dirname, '..', '..', 'src', ...segments)).href; +const srcEnvPublic = srcFileUrl('runtime', 'env-public.ts'); +const srcEnvHelpers = srcFileUrl('runtime', 'env-helpers.ts'); /** * Wrangler's FULL credential env-var set, canonical names + deprecated `CF_*` aliases. diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index f58cfea16..4c2945385 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdir, mkdirSync, + mkdtempSync, readdirSync, readFileSync, realpath, @@ -5705,7 +5706,9 @@ describe('acquireProvisionLock — quarantine sidecar gate', () => { const recorded = JSON.parse(readFileSync(sidecar, 'utf-8')) as Record; expect(recorded).toMatchObject({ pid: child.pid, - pgid: child.pid, + // Windows has no process groups, so `writeQuarantineSidecar` records `null` there by + // design and the gate falls back to probing the plain pid. + pgid: process.platform === 'win32' ? null : child.pid, reason: 'test quarantine (child unconfirmed)', }); expect(typeof recorded.quarantinedAt).toBe('string'); @@ -6442,6 +6445,16 @@ describe('acquireProvisionLock — recovery decisions are serialized + generatio }); }); +/** + * Windows cannot DELIVER a signal to another process. libuv maps SIGTERM/SIGINT/SIGKILL to + * `TerminateProcess`, which destroys the target immediately WITHOUT running any handler in it, + * and a synthetic Ctrl-C would need `GenerateConsoleCtrlEvent` (unreachable from Node). The + * cases carrying this note drive a real child `node` parent with `kill('SIGTERM')` and then + * assert on that parent's signal-teardown handler — work that only happens under POSIX signal + * semantics. + */ +const WINDOWS_NO_SIGNALS = 'signal delivery to another process is POSIX-only (see note above)'; + describe('createDefaultRunWrangler — signal-path kill failure quarantines the lock (child-process)', () => { const SIGNAL_KILL_TMP = join(import.meta.dirname, '.tmp-signal-kill'); beforeEach(() => { @@ -6465,7 +6478,8 @@ describe('createDefaultRunWrangler — signal-path kill failure quarantines the return false; } - it('SIGTERM + an injected kill FAILURE: the parent dies with mutex + sidecar RETAINED; the gate blocks until the child dies', async () => { + it('SIGTERM + an injected kill FAILURE: the parent dies with mutex + sidecar RETAINED; the gate blocks until the child dies', async (ctx) => { + ctx.skip(process.platform === 'win32', WINDOWS_NO_SIGNALS); // Pre-fix, the signal-forwarding path re-raised IMMEDIATELY on a non-ESRCH kill // failure — the parent died, proper-lockfile's exit hook removed the still-tracked // mutex, and the live wrangler child kept creating resources with NO lock on disk. @@ -6626,7 +6640,8 @@ describe('createDefaultRunWrangler — ONE signal coordinator: repeated signals expect(result.signal).toBeNull(); }, 15_000); - it('one SIGTERM (+ a repeat mid-teardown) is coalesced: the parent re-raises only after the group is dead', async () => { + it('one SIGTERM (+ a repeat mid-teardown) is coalesced: the parent re-raises only after the group is dead', async (ctx) => { + ctx.skip(process.platform === 'win32', WINDOWS_NO_SIGNALS); // Previously, runners installed per-runner listeners that detached themselves at // signal entry — a REPEATED signal during the bounded teardown took Node's default // action, killing the parent with the child alive, every tracked mutex swept, and @@ -6703,7 +6718,8 @@ describe('createDefaultRunWrangler — ONE signal coordinator: repeated signals } }, 20_000); - it('a spawn attempted AFTER teardown began is rejected fast (no child spawned, teardown unaffected)', async () => { + it('a spawn attempted AFTER teardown began is rejected fast (no child spawned, teardown unaffected)', async (ctx) => { + ctx.skip(process.platform === 'win32', WINDOWS_NO_SIGNALS); const pidFile = join(COORDINATOR_TMP, 'c.pid'); const childScript = join(COORDINATOR_TMP, 'slow-exit-child2.cjs'); writeFileSync( @@ -6771,7 +6787,8 @@ describe('createDefaultRunWrangler — ONE signal coordinator: repeated signals } }, 20_000); - it('the coordinator-WIDE deadline bounds an unconfirmable teardown (quarantine + re-raise, never a wedge)', async () => { + it('the coordinator-WIDE deadline bounds an unconfirmable teardown (quarantine + re-raise, never a wedge)', async (ctx) => { + ctx.skip(process.platform === 'win32', WINDOWS_NO_SIGNALS); // The runner's own confirmation window is 60s here and the child never dies (the // injected group-killer claims success but kills nothing) — pre-fix `pending` // stayed nonzero for the full 60s and the fatal signal was not re-raised. The @@ -7156,13 +7173,69 @@ describe('provisionCloudflareResources — Hyperdrive lister deadline', () => { }); describe('createDefaultRunWrangler — confirmed child termination + kill classification', () => { - const KILL_CLASSIFY_TMP = join(import.meta.dirname, '.tmp-kill-classify'); + /** + * A UNIQUE directory per test. Every child spawned in this block runs with its `cwd` set to + * this directory, and a fixed shared path lets a crashed or concurrent run leave a stale + * `*.pid` file behind whose pid the OS may since have reassigned — so nothing may be inferred + * from, and certainly nothing killed by, a pid found on disk. Per-test isolation also stops + * one case's stranded child from blocking every later case's cleanup. + */ + let killClassifyTmp = ''; + /** + * Windows refuses to remove a directory that is a live process's CWD, and `waitForDead` only + * proves the exit CODE is set — the rundown that releases the CWD handle finishes slightly + * later. `maxRetries` is Node's documented remedy for exactly that: it retries + * EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM on a linear backoff, and is honored only when + * `recursive` is set. Inert on POSIX, which unlinks a directory regardless of open handles. + * + * This deliberately KILLS NOTHING. A pid read from a file is not proof that the process now + * holding that pid is the one that wrote it, so signalling it could terminate an unrelated + * process. + * + * A removal that still fails is then classified, not blanket-tolerated: a recorded pid that is + * STILL LIVE means a child was genuinely stranded — a real leak, failed loud. Otherwise the + * only things left are the dead child's own script and pid file, which Windows keeps mapped + * until process rundown completes; that can outlast any practical retry budget and is an OS + * artifact rather than a defect in the code under test, whose assertions have already passed. + */ + const rmTmp = (): void => { + if (killClassifyTmp === '') { + return; + } + try { + rmSync(killClassifyTmp, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); + return; + } catch (error) { + const left = existsSync(killClassifyTmp) ? readdirSync(killClassifyTmp) : []; + const live = left + .filter((entry) => entry.endsWith('.pid')) + .map((entry) => Number(readFileSync(join(killClassifyTmp, entry), 'utf-8').trim())) + .filter((pid) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }); + const detail = + `${(error as Error).message}\n` + + ` entries remaining: ${left.length > 0 ? left.join(', ') : '(none)'}\n` + + ` recorded pids still resolving to a live process: ${live.length > 0 ? live.join(', ') : 'none'}`; + if (live.length > 0) { + throw new Error(`could not remove ${killClassifyTmp}: ${detail}`); + } + // Nothing of ours is running. Warn so it stays visible, rather than reddening a green + // behavioural test over temp-file hygiene; the directory is unique per test and ignored by + // git, so a leftover cannot affect any other case. + console.warn(`warn: leaving ${killClassifyTmp} behind — ${detail}`); + } + }; beforeEach(() => { - rmSync(KILL_CLASSIFY_TMP, { recursive: true, force: true }); - mkdirSync(KILL_CLASSIFY_TMP, { recursive: true }); + killClassifyTmp = mkdtempSync(join(import.meta.dirname, '.tmp-kill-classify-')); }); afterEach(() => { - rmSync(KILL_CLASSIFY_TMP, { recursive: true, force: true }); + rmTmp(); }); async function waitForDead(pid: number, timeoutMs: number): Promise { @@ -7186,14 +7259,14 @@ describe('createDefaultRunWrangler — confirmed child termination + kill classi }); it('a REAL kill failure (EPERM) on timeout QUARANTINES: the runner REJECTS, never settle-and-release', async () => { - const pidFile = join(KILL_CLASSIFY_TMP, 'cli.pid'); - const hungCli = join(KILL_CLASSIFY_TMP, 'hung.cjs'); + const pidFile = join(killClassifyTmp, 'cli.pid'); + const hungCli = join(killClassifyTmp, 'hung.cjs'); writeFileSync( hungCli, `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n` + `setInterval(() => {}, 1 << 30);\n`, ); - const run = createDefaultRunWrangler(KILL_CLASSIFY_TMP, { + const run = createDefaultRunWrangler(killClassifyTmp, { launcher: [process.execPath, hungCli], timeoutMs: 300, // Simulate a kill that FAILS for a non-ESRCH reason (the child is still alive). @@ -7228,14 +7301,14 @@ describe('createDefaultRunWrangler — confirmed child termination + kill classi }, 15_000); it('an unconfirmable termination (grace expiry, child still alive) QUARANTINES too', async () => { - const pidFile = join(KILL_CLASSIFY_TMP, 'cli3.pid'); - const hungCli = join(KILL_CLASSIFY_TMP, 'hung3.cjs'); + const pidFile = join(killClassifyTmp, 'cli3.pid'); + const hungCli = join(killClassifyTmp, 'hung3.cjs'); writeFileSync( hungCli, `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n` + `setInterval(() => {}, 1 << 30);\n`, ); - const run = createDefaultRunWrangler(KILL_CLASSIFY_TMP, { + const run = createDefaultRunWrangler(killClassifyTmp, { launcher: [process.execPath, hungCli], timeoutMs: 250, confirmKillGraceMs: 500, @@ -7263,14 +7336,14 @@ describe('createDefaultRunWrangler — confirmed child termination + kill classi }, 15_000); it('an ESRCH kill (already gone) is treated as SUCCESS — settles as the timeout hard-failure, no kill-error text', async () => { - const pidFile = join(KILL_CLASSIFY_TMP, 'cli2.pid'); - const hungCli = join(KILL_CLASSIFY_TMP, 'hung2.cjs'); + const pidFile = join(killClassifyTmp, 'cli2.pid'); + const hungCli = join(killClassifyTmp, 'hung2.cjs'); writeFileSync( hungCli, `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n` + `setInterval(() => {}, 1 << 30);\n`, ); - const run = createDefaultRunWrangler(KILL_CLASSIFY_TMP, { + const run = createDefaultRunWrangler(killClassifyTmp, { launcher: [process.execPath, hungCli], timeoutMs: 300, // Kill FOR REAL, then report ESRCH (the child exited in the race window) → treated as gone. @@ -7293,12 +7366,12 @@ describe('createDefaultRunWrangler — confirmed child termination + kill classi // Drive a FULL provision whose wrangler runner is the REAL default runner spawning a hung // CLI. The `d1 list` times out → confirmed-termination hard failure → provision rejects → // the whole-provision lock is released. The child must be dead and the mutex gone. - const cfgDir = join(KILL_CLASSIFY_TMP, 'app'); + const cfgDir = join(killClassifyTmp, 'app'); mkdirSync(cfgDir, { recursive: true }); const cfgPath = join(cfgDir, 'wrangler.jsonc'); writeFileSync(cfgPath, JSON.stringify({ name: 'app', account_id: 'acct' })); - const pidFile = join(KILL_CLASSIFY_TMP, 'prov-cli.pid'); - const hungCli = join(KILL_CLASSIFY_TMP, 'prov-hung.cjs'); + const pidFile = join(killClassifyTmp, 'prov-cli.pid'); + const hungCli = join(killClassifyTmp, 'prov-hung.cjs'); writeFileSync( hungCli, `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n` + diff --git a/vite.config.ts b/vite.config.ts index 08426ff5d..651d6f5c6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,26 @@ const ciTimeoutScale = process.platform === 'win32' ? 3 : 1; const testTimeout = 120_000 * ciTimeoutScale; const hookTimeout = 120_000 * ciTimeoutScale; +// Integration files that share a playground directory with another file in the same project. A +// parallel run lets one file's build wipe state (.void/, dist/) that another is actively using — +// e.g. kitchen-sink-build.test.ts deletes .void/, destroying the miniflare persistence that +// kitchen-sink.test.ts's dev server depends on. These run serially in their own project so the +// rest of the integration suite does not have to. +const sharedPlayground = [ + // playground/kitchen-sink + 'packages/void/test/integration/kitchen-sink.test.ts', + 'packages/void/test/integration/kitchen-sink-build.test.ts', + 'packages/void/test/integration/cloudflare-deploy-build.test.ts', + // playground/named-layouts + 'packages/void/test/integration/named-layouts.test.ts', + 'packages/void/test/integration/full-deploy-manifest.test.ts', + // playground/spa — both rewrite its wrangler.jsonc. The live suite only runs when its + // credentials are set, which is never the case in the `test` job, but is the case for a local + // `test:all` run — the scenario the credential forwarding below exists to support. + 'packages/void/test/integration/spa-middleware-auth.test.ts', + 'packages/void/test/e2e/cloudflare-deploy-live.test.ts', +]; + const metaframeworks = [ 'packages/void/test/integration/analog.test.ts', 'packages/void/test/integration/astro.test.ts', @@ -341,7 +361,9 @@ export default defineConfig({ sequence: { groupOrder: 3 }, testTimeout, hookTimeout, - // Forward the live-e2e credentials past vitest's worker env filter (see e2eWorkerEnv). + // Kept alongside the e2e glob below so a newly added e2e file gets its credentials + // forwarded past vitest's worker env filter here too (see e2eWorkerEnv). The one + // existing e2e file drives playground/spa, so it is in `shared-playground` instead. env: e2eWorkerEnv, include: [ 'packages/void/test/integration/**/*.test.ts', @@ -350,12 +372,26 @@ export default defineConfig({ 'packages/void/test/e2e/**/*.test.ts', 'packages/md/test/integration/**/*.test.ts', ], - exclude: metaframeworks, + exclude: [...metaframeworks, ...sharedPlayground], maxWorkers, - // kitchen-sink.test.ts (dev server) and kitchen-sink-build.test.ts - // (build) both use playground/kitchen-sink/. The build test deletes - // .void/ before running, which destroys miniflare persistence that - // the dev server depends on. Serialize to prevent the conflict. + }, + }, + { + test: { + // The only integration files that share a playground directory. Serialized among + // themselves so one file's build cannot delete state another is mid-way through using. + // This needs its own group order: `fileParallelism: false` resolves to `maxWorkers: 1`, + // and vitest rejects projects that share a group order but disagree on `maxWorkers`. + name: 'shared-playground', + sequence: { groupOrder: 4 }, + include: sharedPlayground, + // The live-e2e suite lives here, so this project — not core-markdown — is the one that + // has to forward its credentials past vitest's worker env filter (see e2eWorkerEnv). + // Without this the suite reads empty credentials and self-skips, quietly turning the + // live deployment gate into a green no-op. + env: e2eWorkerEnv, + testTimeout, + hookTimeout, fileParallelism: false, }, },