Skip to content

Commit db426dc

Browse files
committed
test: preserve wrapper behavior for targeted runs
1 parent d0337a1 commit db426dc

File tree

2 files changed

+221
-49
lines changed

2 files changed

+221
-49
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
133133
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
134134
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
135+
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
135136
- Do not set test workers above 16; tried already.
136137
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
137138
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.

scripts/test-parallel.mjs

Lines changed: 220 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,50 @@ const shardIndexOverride = (() => {
205205
const parsed = Number.parseInt(process.env.OPENCLAW_TEST_SHARD_INDEX ?? "", 10);
206206
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
207207
})();
208+
const OPTION_TAKES_VALUE = new Set([
209+
"-t",
210+
"-c",
211+
"-r",
212+
"--testNamePattern",
213+
"--config",
214+
"--root",
215+
"--dir",
216+
"--reporter",
217+
"--outputFile",
218+
"--pool",
219+
"--execArgv",
220+
"--vmMemoryLimit",
221+
"--maxWorkers",
222+
"--environment",
223+
"--shard",
224+
"--changed",
225+
"--sequence",
226+
"--inspect",
227+
"--inspectBrk",
228+
"--testTimeout",
229+
"--hookTimeout",
230+
"--bail",
231+
"--retry",
232+
"--diff",
233+
"--exclude",
234+
"--project",
235+
"--slowTestThreshold",
236+
"--teardownTimeout",
237+
"--attachmentsDir",
238+
"--mode",
239+
"--api",
240+
"--browser",
241+
"--maxConcurrency",
242+
"--mergeReports",
243+
"--configLoader",
244+
"--experimental",
245+
]);
246+
const SINGLE_RUN_ONLY_FLAGS = new Set([
247+
"--coverage",
248+
"--reporter",
249+
"--outputFile",
250+
"--mergeReports",
251+
]);
208252

209253
if (shardIndexOverride !== null && shardCount <= 1) {
210254
console.error(
@@ -229,6 +273,142 @@ const silentArgs =
229273
const rawPassthroughArgs = process.argv.slice(2);
230274
const passthroughArgs =
231275
rawPassthroughArgs[0] === "--" ? rawPassthroughArgs.slice(1) : rawPassthroughArgs;
276+
const parsePassthroughArgs = (args) => {
277+
const fileFilters = [];
278+
const optionArgs = [];
279+
let consumeNextAsOptionValue = false;
280+
281+
for (const arg of args) {
282+
if (consumeNextAsOptionValue) {
283+
optionArgs.push(arg);
284+
consumeNextAsOptionValue = false;
285+
continue;
286+
}
287+
if (arg === "--") {
288+
optionArgs.push(arg);
289+
continue;
290+
}
291+
if (arg.startsWith("-")) {
292+
optionArgs.push(arg);
293+
consumeNextAsOptionValue = !arg.includes("=") && OPTION_TAKES_VALUE.has(arg);
294+
continue;
295+
}
296+
fileFilters.push(arg);
297+
}
298+
299+
return { fileFilters, optionArgs };
300+
};
301+
const { fileFilters: passthroughFileFilters, optionArgs: passthroughOptionArgs } =
302+
parsePassthroughArgs(passthroughArgs);
303+
const passthroughRequiresSingleRun = passthroughOptionArgs.some((arg) => {
304+
if (!arg.startsWith("-")) {
305+
return false;
306+
}
307+
const [flag] = arg.split("=", 1);
308+
return SINGLE_RUN_ONLY_FLAGS.has(flag);
309+
});
310+
const channelPrefixes = ["src/telegram/", "src/discord/", "src/web/", "src/browser/", "src/line/"];
311+
const baseConfigPrefixes = ["src/agents/", "src/auto-reply/", "src/commands/", "test/", "ui/"];
312+
const inferTargetKind = (fileFilter) => {
313+
if (fileFilter.endsWith(".live.test.ts")) {
314+
return "live";
315+
}
316+
if (fileFilter.endsWith(".e2e.test.ts")) {
317+
return "e2e";
318+
}
319+
if (fileFilter.startsWith("extensions/")) {
320+
return "extensions";
321+
}
322+
if (fileFilter.startsWith("src/gateway/")) {
323+
return "gateway";
324+
}
325+
if (channelPrefixes.some((prefix) => fileFilter.startsWith(prefix))) {
326+
return "channels";
327+
}
328+
if (baseConfigPrefixes.some((prefix) => fileFilter.startsWith(prefix))) {
329+
return "base";
330+
}
331+
if (fileFilter.startsWith("src/")) {
332+
return unitIsolatedFiles.includes(fileFilter) ? "unit-isolated" : "unit";
333+
}
334+
return "base";
335+
};
336+
const createTargetedEntry = (kind, filters) => {
337+
if (kind === "unit") {
338+
return {
339+
name: "unit",
340+
args: [
341+
"vitest",
342+
"run",
343+
"--config",
344+
"vitest.unit.config.ts",
345+
`--pool=${useVmForks ? "vmForks" : "forks"}`,
346+
...(disableIsolation ? ["--isolate=false"] : []),
347+
...filters,
348+
],
349+
};
350+
}
351+
if (kind === "unit-isolated") {
352+
return {
353+
name: "unit-isolated",
354+
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...filters],
355+
};
356+
}
357+
if (kind === "extensions") {
358+
return {
359+
name: "extensions",
360+
args: [
361+
"vitest",
362+
"run",
363+
"--config",
364+
"vitest.extensions.config.ts",
365+
...(useVmForks ? ["--pool=vmForks"] : []),
366+
...filters,
367+
],
368+
};
369+
}
370+
if (kind === "gateway") {
371+
return {
372+
name: "gateway",
373+
args: ["vitest", "run", "--config", "vitest.gateway.config.ts", "--pool=forks", ...filters],
374+
};
375+
}
376+
if (kind === "channels") {
377+
return {
378+
name: "channels",
379+
args: ["vitest", "run", "--config", "vitest.channels.config.ts", ...filters],
380+
};
381+
}
382+
if (kind === "live") {
383+
return {
384+
name: "live",
385+
args: ["vitest", "run", "--config", "vitest.live.config.ts", ...filters],
386+
};
387+
}
388+
if (kind === "e2e") {
389+
return {
390+
name: "e2e",
391+
args: ["vitest", "run", "--config", "vitest.e2e.config.ts", ...filters],
392+
};
393+
}
394+
return {
395+
name: "base",
396+
args: ["vitest", "run", "--config", "vitest.config.ts", ...filters],
397+
};
398+
};
399+
const targetedEntries =
400+
passthroughFileFilters.length > 0
401+
? Array.from(
402+
passthroughFileFilters.reduce((groups, fileFilter) => {
403+
const kind = inferTargetKind(fileFilter);
404+
const files = groups.get(kind) ?? [];
405+
files.push(fileFilter);
406+
groups.set(kind, files);
407+
return groups;
408+
}, new Map()),
409+
([kind, filters]) => createTargetedEntry(kind, filters),
410+
)
411+
: [];
232412
const topLevelParallelEnabled = testProfile !== "low" && testProfile !== "serial";
233413
const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10);
234414
const resolvedOverride =
@@ -397,32 +577,32 @@ const runOnce = (entry, extraArgs = []) =>
397577
});
398578
});
399579

400-
const run = async (entry) => {
580+
const run = async (entry, extraArgs = []) => {
401581
if (shardCount <= 1) {
402-
return runOnce(entry);
582+
return runOnce(entry, extraArgs);
403583
}
404584
if (shardIndexOverride !== null) {
405-
return runOnce(entry, ["--shard", `${shardIndexOverride}/${shardCount}`]);
585+
return runOnce(entry, ["--shard", `${shardIndexOverride}/${shardCount}`, ...extraArgs]);
406586
}
407587
for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) {
408588
// eslint-disable-next-line no-await-in-loop
409-
const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]);
589+
const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`, ...extraArgs]);
410590
if (code !== 0) {
411591
return code;
412592
}
413593
}
414594
return 0;
415595
};
416596

417-
const runEntries = async (entries) => {
597+
const runEntries = async (entries, extraArgs = []) => {
418598
if (topLevelParallelEnabled) {
419-
const codes = await Promise.all(entries.map(run));
599+
const codes = await Promise.all(entries.map((entry) => run(entry, extraArgs)));
420600
return codes.find((code) => code !== 0);
421601
}
422602

423603
for (const entry of entries) {
424604
// eslint-disable-next-line no-await-in-loop
425-
const code = await run(entry);
605+
const code = await run(entry, extraArgs);
426606
if (code !== 0) {
427607
return code;
428608
}
@@ -440,57 +620,48 @@ const shutdown = (signal) => {
440620
process.on("SIGINT", () => shutdown("SIGINT"));
441621
process.on("SIGTERM", () => shutdown("SIGTERM"));
442622

443-
if (passthroughArgs.length > 0) {
444-
const maxWorkers = maxWorkersForRun("unit");
445-
const args = maxWorkers
446-
? [
447-
"vitest",
448-
"run",
449-
"--maxWorkers",
450-
String(maxWorkers),
451-
...silentArgs,
452-
...windowsCiArgs,
453-
...passthroughArgs,
454-
]
455-
: ["vitest", "run", ...silentArgs, ...windowsCiArgs, ...passthroughArgs];
456-
const nodeOptions = process.env.NODE_OPTIONS ?? "";
457-
const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce(
458-
(acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()),
459-
nodeOptions,
460-
);
461-
const code = await new Promise((resolve) => {
462-
let child;
463-
try {
464-
child = spawn(pnpm, args, {
465-
stdio: "inherit",
466-
env: { ...process.env, NODE_OPTIONS: nextNodeOptions },
467-
shell: isWindows,
468-
});
469-
} catch (err) {
470-
console.error(`[test-parallel] spawn failed: ${String(err)}`);
471-
resolve(1);
472-
return;
623+
if (targetedEntries.length > 0) {
624+
if (passthroughRequiresSingleRun && targetedEntries.length > 1) {
625+
console.error(
626+
"[test-parallel] The provided Vitest args require a single run, but the selected test filters span multiple wrapper configs. Run one target/config at a time.",
627+
);
628+
process.exit(2);
629+
}
630+
const targetedParallelRuns = keepGatewaySerial
631+
? targetedEntries.filter((entry) => entry.name !== "gateway")
632+
: targetedEntries;
633+
const targetedSerialRuns = keepGatewaySerial
634+
? targetedEntries.filter((entry) => entry.name === "gateway")
635+
: [];
636+
const failedTargetedParallel = await runEntries(targetedParallelRuns, passthroughOptionArgs);
637+
if (failedTargetedParallel !== undefined) {
638+
process.exit(failedTargetedParallel);
639+
}
640+
for (const entry of targetedSerialRuns) {
641+
// eslint-disable-next-line no-await-in-loop
642+
const code = await run(entry, passthroughOptionArgs);
643+
if (code !== 0) {
644+
process.exit(code);
473645
}
474-
children.add(child);
475-
child.on("error", (err) => {
476-
console.error(`[test-parallel] child error: ${String(err)}`);
477-
});
478-
child.on("exit", (exitCode, signal) => {
479-
children.delete(child);
480-
resolve(exitCode ?? (signal ? 1 : 0));
481-
});
482-
});
483-
process.exit(Number(code) || 0);
646+
}
647+
process.exit(0);
648+
}
649+
650+
if (passthroughRequiresSingleRun && passthroughOptionArgs.length > 0) {
651+
console.error(
652+
"[test-parallel] The provided Vitest args require a single run. Use the dedicated npm script for that workflow (for example `pnpm test:coverage`) or target a single test file/filter.",
653+
);
654+
process.exit(2);
484655
}
485656

486-
const failedParallel = await runEntries(parallelRuns);
657+
const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs);
487658
if (failedParallel !== undefined) {
488659
process.exit(failedParallel);
489660
}
490661

491662
for (const entry of serialRuns) {
492663
// eslint-disable-next-line no-await-in-loop
493-
const code = await run(entry);
664+
const code = await run(entry, passthroughOptionArgs);
494665
if (code !== 0) {
495666
process.exit(code);
496667
}

0 commit comments

Comments
 (0)