diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs index a59ccbeb0a3..5006f3abe4f 100644 --- a/scripts/run-vitest.mjs +++ b/scripts/run-vitest.mjs @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import { createRequire } from "node:module"; import path from "node:path"; import { spawnPnpmRunner } from "./pnpm-runner.mjs"; @@ -49,6 +50,21 @@ export function shouldSuppressVitestStderrLine(line) { return SUPPRESSED_VITEST_STDERR_PATTERNS.some((pattern) => line.includes(pattern)); } +export function resolveDirectNodeVitestArgs(pnpmArgs) { + return pnpmArgs[0] === "exec" && pnpmArgs[1] === "node" ? pnpmArgs.slice(2) : null; +} + +function spawnVitestProcess({ pnpmArgs, spawnParams }) { + const directNodeArgs = resolveDirectNodeVitestArgs(pnpmArgs); + if (directNodeArgs) { + return spawn(process.execPath, directNodeArgs, spawnParams); + } + return spawnPnpmRunner({ + pnpmArgs, + ...spawnParams, + }); +} + export function installVitestNoOutputWatchdog(params) { const timeoutMs = params.timeoutMs; if (!timeoutMs || timeoutMs <= 0) { @@ -165,9 +181,9 @@ export function forwardVitestOutput(stream, target, shouldSuppressLine = () => f } export function spawnWatchedVitestProcess({ pnpmArgs, spawnParams, env, label }) { - const child = spawnPnpmRunner({ + const child = spawnVitestProcess({ pnpmArgs, - ...spawnParams, + spawnParams, }); const teardownChildCleanup = installVitestProcessGroupCleanup({ child }); const teardownNoOutputWatchdog = installVitestNoOutputWatchdog({ diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index ec825455e3e..87a1a525ab2 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -10,6 +10,8 @@ import { spawnWatchedVitestProcess, } from "./run-vitest.mjs"; import { + applyDefaultMultiSpecVitestCachePaths, + applyDefaultVitestNoOutputTimeout, applyParallelVitestCachePaths, buildFullSuiteVitestRunPlans, createVitestRunSpecs, @@ -324,7 +326,7 @@ async function main() { const { targetArgs } = parseTestProjectsArgs(args, process.cwd()); const changedTargetArgs = targetArgs.length === 0 ? resolveChangedTargetArgs(args, process.cwd()) : null; - const runSpecs = + const rawRunSpecs = targetArgs.length === 0 && changedTargetArgs === null ? buildFullSuiteVitestRunPlans(args, process.cwd()).map((plan) => ({ config: plan.config, @@ -348,6 +350,10 @@ async function main() { baseEnv: process.env, cwd: process.cwd(), }); + const runSpecs = applyDefaultMultiSpecVitestCachePaths( + applyDefaultVitestNoOutputTimeout(rawRunSpecs, { env: process.env }), + { cwd: process.cwd(), env: process.env }, + ); if (runSpecs.length === 0) { console.error("[test] no changed test targets; skipping Vitest."); diff --git a/scripts/test-projects.test-support.d.mts b/scripts/test-projects.test-support.d.mts index e81d36d50a9..1ee2ba14847 100644 --- a/scripts/test-projects.test-support.d.mts +++ b/scripts/test-projects.test-support.d.mts @@ -14,6 +14,8 @@ export type VitestRunSpec = { watchMode: boolean; }; +export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS: string; + export function parseTestProjectsArgs( args: string[], cwd?: string, @@ -51,6 +53,21 @@ export function createVitestRunSpecs( }, ): VitestRunSpec[]; +export function applyDefaultVitestNoOutputTimeout( + specs: VitestRunSpec[], + params?: { + env?: Record; + }, +): VitestRunSpec[]; + +export function applyDefaultMultiSpecVitestCachePaths( + specs: VitestRunSpec[], + params?: { + cwd?: string; + env?: Record; + }, +): VitestRunSpec[]; + export function writeVitestIncludeFile(filePath: string, includePatterns: string[]): void; export function buildVitestArgs(args: string[], cwd?: string): string[]; diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index f684f0c405d..507c065c04e 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -217,6 +217,8 @@ const GENERATED_CHANGED_TEST_TARGETS = new Set([ "src/canvas-host/a2ui/.bundle.hash", "src/canvas-host/a2ui/a2ui.bundle.js", ]); +const VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS"; +export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS = "180000"; const VITEST_CONFIG_TARGET_KIND_BY_PATH = new Map( Object.entries(VITEST_CONFIG_BY_KIND).map(([kind, config]) => [config, kind]), ); @@ -1028,6 +1030,32 @@ export function applyParallelVitestCachePaths(specs, params = {}) { }); } +export function applyDefaultMultiSpecVitestCachePaths(specs, params = {}) { + if (specs.length <= 1 || specs.some((spec) => spec.watchMode)) { + return specs; + } + return applyParallelVitestCachePaths(specs, params); +} + +export function applyDefaultVitestNoOutputTimeout(specs, params = {}) { + const baseEnv = params.env ?? process.env; + if (Object.hasOwn(baseEnv, VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY)) { + return specs; + } + return specs.map((spec) => { + if (spec.watchMode || Object.hasOwn(spec.env ?? {}, VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY)) { + return spec; + } + return { + ...spec, + env: { + ...spec.env, + [VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY]: DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS, + }, + }; + }); +} + export function createVitestRunSpecs(args, params = {}) { const cwd = params.cwd ?? process.cwd(); const baseEnv = params.baseEnv ?? process.env; diff --git a/test/scripts/run-vitest.test.ts b/test/scripts/run-vitest.test.ts index 34fb425232b..028f34dbfee 100644 --- a/test/scripts/run-vitest.test.ts +++ b/test/scripts/run-vitest.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; import { installVitestNoOutputWatchdog, + resolveDirectNodeVitestArgs, resolveVitestNodeArgs, resolveVitestNoOutputTimeoutMs, resolveVitestSpawnParams, @@ -13,6 +14,18 @@ describe("scripts/run-vitest", () => { expect(resolveVitestNodeArgs({ PATH: "/usr/bin" })).toEqual(["--no-maglev"]); }); + it("detects pnpm exec node wrappers that can be spawned directly", () => { + expect( + resolveDirectNodeVitestArgs([ + "exec", + "node", + "--no-maglev", + "node_modules/vitest/vitest.mjs", + ]), + ).toEqual(["--no-maglev", "node_modules/vitest/vitest.mjs"]); + expect(resolveDirectNodeVitestArgs(["exec", "vitest", "run"])).toBeNull(); + }); + it("allows opting back into Maglev explicitly", () => { expect( resolveVitestNodeArgs({ diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index dfec1ea370f..6d23bfd4777 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1,6 +1,9 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS, + applyDefaultMultiSpecVitestCachePaths, + applyDefaultVitestNoOutputTimeout, applyParallelVitestCachePaths, buildFullSuiteVitestRunPlans, buildVitestRunPlans, @@ -807,3 +810,127 @@ describe("scripts/test-projects parallel cache paths", () => { expect(spec?.env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH).toBeUndefined(); }); }); + +describe("scripts/test-projects Vitest stall watchdog", () => { + it("adds a default no-output timeout to non-watch specs", () => { + const [spec] = applyDefaultVitestNoOutputTimeout( + [ + { + config: "test/vitest/vitest.extension-feishu.config.ts", + env: { PATH: "/usr/bin" }, + includeFilePath: null, + includePatterns: null, + pnpmArgs: [], + watchMode: false, + }, + ], + { env: { PATH: "/usr/bin" } }, + ); + + expect(spec?.env.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS).toBe( + DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS, + ); + }); + + it("keeps explicit watchdog settings and watch mode untouched", () => { + const specs = applyDefaultVitestNoOutputTimeout( + [ + { + config: "test/vitest/vitest.extension-feishu.config.ts", + env: { PATH: "/usr/bin" }, + includeFilePath: null, + includePatterns: null, + pnpmArgs: [], + watchMode: true, + }, + { + config: "test/vitest/vitest.extension-memory.config.ts", + env: { OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "0", PATH: "/usr/bin" }, + includeFilePath: null, + includePatterns: null, + pnpmArgs: [], + watchMode: false, + }, + ], + { env: { PATH: "/usr/bin" } }, + ); + + expect(specs[0]?.env.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS).toBeUndefined(); + expect(specs[1]?.env.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS).toBe("0"); + }); +}); + +describe("scripts/test-projects Vitest cache isolation", () => { + it("assigns isolated fs-module caches to multi-spec non-watch runs", () => { + const specs = applyDefaultMultiSpecVitestCachePaths( + [ + { + config: "test/vitest/vitest.unit-fast.config.ts", + env: {}, + includeFilePath: null, + includePatterns: null, + pnpmArgs: [], + watchMode: false, + }, + { + config: "test/vitest/vitest.extension-memory.config.ts", + env: {}, + includeFilePath: null, + includePatterns: null, + pnpmArgs: [], + watchMode: false, + }, + ], + { cwd: "/repo", env: {} }, + ); + + expect(specs.map((spec) => spec.env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH)).toEqual([ + path.join( + "/repo", + "node_modules", + ".experimental-vitest-cache", + "0-test-vitest-vitest.unit-fast.config.ts", + ), + path.join( + "/repo", + "node_modules", + ".experimental-vitest-cache", + "1-test-vitest-vitest.extension-memory.config.ts", + ), + ]); + }); + + it("keeps single-spec and watch runs on the default cache", () => { + const single = [ + { + config: "test/vitest/vitest.unit-fast.config.ts", + env: {}, + includeFilePath: null, + includePatterns: null, + pnpmArgs: [], + watchMode: false, + }, + ]; + expect(applyDefaultMultiSpecVitestCachePaths(single, { cwd: "/repo", env: {} })).toBe(single); + + const watch = [ + { + config: "vitest.config.ts", + env: {}, + includeFilePath: null, + includePatterns: null, + pnpmArgs: [], + watchMode: true, + }, + { + config: "test/vitest/vitest.unit-fast.config.ts", + env: {}, + includeFilePath: null, + includePatterns: null, + pnpmArgs: [], + watchMode: false, + }, + ]; + expect(applyDefaultMultiSpecVitestCachePaths(watch, { cwd: "/repo", env: {} })).toBe(watch); + }); +});