import childProcess from "node:child_process"; import events from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; import util from "node:util"; import { removeDir } from "@cloudflare/workers-utils"; import { stripAnsi } from "miniflare"; import treeKill from "tree-kill"; import dedent from "ts-dedent"; import { test as baseTest, inject, vi } from "vitest"; const debuglog = util.debuglog("vitest-pool-workers:test"); export const vitestConfig = ( cfOptions: Record = {}, testOptions: Record = {} ) => dedent /* javascript */ ` import { cloudflareTest } from "@cloudflare/vitest-pool-workers" import { BaseSequencer } from "vitest/node"; class DeterministicSequencer extends BaseSequencer { sort(files) { return [...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId)); } } export default { plugins: [ cloudflareTest({ miniflare: { compatibilityDate: "2025-12-02", compatibilityFlags: ["nodejs_compat"], }, ...${JSON.stringify(cfOptions)} }) ], test: { sequence: { sequencer: DeterministicSequencer }, testTimeout: 90_000, ...${JSON.stringify(testOptions)} } }; `; export function waitFor(callback: Parameters>[0]) { // The default timeout of `vi.waitFor()` is only 1s, which is a little // short for some of these tests, especially on Windows. return vi.waitFor(callback, { timeout: 10_000, interval: 500 }); } async function seed(root: string, files: Record) { for (const [name, contents] of Object.entries(files)) { const filePath = path.resolve(root, name); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, contents); } } export interface Process { readonly stdout: string; readonly stderr: string; readonly exitCode: Promise; } function wrap(proc: childProcess.ChildProcess): Process { let stdout = ""; let stderr = ""; proc.stdout?.setEncoding("utf8"); proc.stderr?.setEncoding("utf8"); proc.stdout?.on("data", (chunk) => { if (debuglog.enabled) { process.stdout.write(chunk); } stdout += chunk; }); proc.stderr?.on("data", (chunk) => { if (debuglog.enabled) { process.stderr.write(chunk); } stderr += chunk; }); const closePromise = events.once(proc, "close"); return { get stdout() { return stripAnsi(stdout); }, get stderr() { return stripAnsi(stderr); }, get exitCode() { return closePromise.then( ([exitCode, signal]) => exitCode ?? signal ?? -1 ); }, }; } // eslint-disable-next-line jest/expect-expect, jest/no-disabled-tests -- base test fixture definition, not an actual test export const test = baseTest.extend<{ tmpPath: string; seed: (files: Record) => Promise; vitestRun: (options?: { flags?: string[]; maxBuffer?: number; }) => Promise; vitestDev: (options?: { flags?: string[]; maxBuffer?: number }) => Process; }>({ // Fixture for creating a temporary directory // eslint-disable-next-line no-empty-pattern -- Vitest requires the 1st argument to use object destructuring async tmpPath({}, use) { const tmpPoolInstallationPath = inject("tmpPoolInstallationPath"); const tmpPathBase = path.join(tmpPoolInstallationPath, "test-"); const tmpPath = await fs.mkdtemp(tmpPathBase); await use(tmpPath); await removeDir(tmpPath); }, // Fixture for seeding data in the temporary directory async seed({ tmpPath }, use) { await use((files) => seed(tmpPath, files)); }, // Fixture for a starting single-shot `vitest run` process async vitestRun({ tmpPath }, use) { const tmpPoolInstallationPath = inject("tmpPoolInstallationPath"); await use(async ({ flags = [], maxBuffer } = {}) => { // eslint-disable-next-line workers-sdk/no-unsafe-command-execution -- test helper const proc = childProcess.exec( `pnpm exec vitest run --root="${tmpPath}" ` + flags.join(" "), { cwd: tmpPoolInstallationPath, env: getNoCIEnv(), maxBuffer, } ); const wrapped = wrap(proc); await wrapped.exitCode; return wrapped; }); }, // Fixture for a starting long-running `vitest dev` process async vitestDev({ tmpPath }, use) { const tmpPoolInstallationPath = inject("tmpPoolInstallationPath"); // In case this process stops unexpectedly, kill all child processes const processes = new Set(); const killAllProcesses = () => { for (const proc of processes) { if (proc.pid) { proc.stdout?.destroy(); proc.stderr?.destroy(); proc.stdin?.destroy(); treeKill(proc.pid, "SIGKILL"); } } }; process.on("exit", killAllProcesses); await use(({ flags = [], maxBuffer } = {}) => { // eslint-disable-next-line workers-sdk/no-unsafe-command-execution -- test helper const proc = childProcess.exec( `pnpm exec vitest dev --root="${tmpPath}" ` + flags.join(" "), { cwd: tmpPoolInstallationPath, env: getNoCIEnv(), maxBuffer, } ); processes.add(proc); proc.on("exit", () => { if (proc) { processes.delete(proc); } }); return wrap(proc); }); killAllProcesses(); process.off("exit", killAllProcesses); }, }); /** * Get a copy of the process.env that will not be interpreted by Vitest as running in CI. * * This is important for the snapshot update tests to execute correctly. * Vitest uses the `std-env` library's `isCI()` call to determine this. * Since we currently use GitHub Actions to run our CI jobs, this is what we are turning off here. * If we change CI provider then we should update this. */ function getNoCIEnv(): typeof process.env { const env = { ...process.env }; env.CI = undefined; env.GITHUB_ACTIONS = undefined; // Suppress Node.js deprecation warnings in spawned processes to prevent // them from appearing in stderr (which breaks tests that assert stderr is empty) env.NODE_OPTIONS = [env.NODE_OPTIONS, "--no-deprecation"] .filter(Boolean) .join(" "); return env; }