@@ -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
209253if ( shardIndexOverride !== null && shardCount <= 1 ) {
210254 console . error (
@@ -229,6 +273,142 @@ const silentArgs =
229273const rawPassthroughArgs = process . argv . slice ( 2 ) ;
230274const 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+ : [ ] ;
232412const topLevelParallelEnabled = testProfile !== "low" && testProfile !== "serial" ;
233413const overrideWorkers = Number . parseInt ( process . env . OPENCLAW_TEST_WORKERS ?? "" , 10 ) ;
234414const 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) => {
440620process . on ( "SIGINT" , ( ) => shutdown ( "SIGINT" ) ) ;
441621process . 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 ) ;
487658if ( failedParallel !== undefined ) {
488659 process . exit ( failedParallel ) ;
489660}
490661
491662for ( 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