1+ import type { ChildProcess } from 'node:child_process' ;
12import fs from 'node:fs' ;
23import { createRequire } from 'node:module' ;
34import os from 'node:os' ;
@@ -11,7 +12,7 @@ import {
1112 resetUserNodeCaches ,
1213} from '../../../src/shared/nodeResolution' ;
1314import { status } from '../../../src/stacks/test/status' ;
14- import type { StatusReporter } from '../../../src/types' ;
15+ import type { StackState , StatusReporter } from '../../../src/types' ;
1516import { createStatusRecorder } from './statusRecorder' ;
1617
1718// The Rstest runner injects its own `@rstest/core` into every resolution path so
@@ -117,6 +118,18 @@ rs.mock('vscode', () => {
117118// the workspace `node_modules` and `@rstest/core` is genuinely missing.
118119const noCoreDir = os . tmpdir ( ) ;
119120
121+ // The opposite fixture: a cwd where `@rstest/core` resolves, for suites whose
122+ // case under test sits past the resolution step.
123+ const packageDir = path . resolve ( __dirname , '../../..' ) ;
124+
125+ // Seeding the memo is how the probe is injected: `resolveWorkerNodeCommand`
126+ // takes no probe option (it is called from deep inside a spawn path), and the
127+ // memo is keyed by executable path, so a seeded entry is the answer it gets.
128+ const seedNodeProbe = ( executable : string , probe : NodeProbe ) =>
129+ configuredNodeBelowFloor ( executable , {
130+ probe : ( ) => Promise . resolve ( probe ) ,
131+ } ) ;
132+
120133// Settings are a module-level bag every suite writes into; clearing them per
121134// test keeps one suite's configuration from leaking into the next.
122135afterEach ( ( ) => {
@@ -357,13 +370,7 @@ describe('RstestApi with a configured nodeExecutable', () => {
357370 versionMismatch : ( detail ) => mismatches . push ( detail ) ,
358371 } ;
359372
360- // Seeding the memo is how the probe is injected: `resolveWorkerNodeCommand`
361- // takes no probe option (it is called from deep inside a spawn path), and the
362- // memo is keyed by executable path, so a seeded entry is the answer it gets.
363- const seedProbe = ( probe : NodeProbe ) =>
364- configuredNodeBelowFloor ( configuredNode , {
365- probe : ( ) => Promise . resolve ( probe ) ,
366- } ) ;
373+ const seedProbe = ( probe : NodeProbe ) => seedNodeProbe ( configuredNode , probe ) ;
367374
368375 // Reaching the private method keeps these cases on the decision under test
369376 // instead of spawning a real worker process for each one.
@@ -438,13 +445,89 @@ describe('RstestApi with a configured nodeExecutable', () => {
438445
439446 it ( 'should refuse to spawn a worker after dispose' , async ( ) => {
440447 // The seed keeps the configured executable's probe off the real spawn
441- // path; the package dir (not `process.cwd()`) is a cwd where
442- // `@rstest/core` resolves, so the abort observed is the disposed check
443- // and not an earlier resolution failure.
448+ // path, so the abort observed is the disposed check and not an earlier
449+ // resolution failure.
444450 await seedProbe ( { kind : 'ok' , version : '24.0.0' } ) ;
445- const api = createApi ( path . resolve ( __dirname , '../../..' ) ) ;
451+ const api = createApi ( packageDir ) ;
446452 const spawning = api . createChildProcess ( ) ;
447453 api . dispose ( ) ;
448454 await expect ( spawning ) . rejects . toThrow ( 'disposed' ) ;
449455 } ) ;
450456} ) ;
457+
458+ // Worker spawn failures: only the wrong-executable case is the user's to fix
459+ // (and keeps its notification); the rest is absorbed — the rationale lives on
460+ // the guard and the 'error' handler in `master.ts`.
461+ describe ( 'RstestApi worker spawn failures' , ( ) => {
462+ let api : RstestApi | undefined ;
463+ let reported : StackState [ ] ;
464+
465+ const crashes = ( ) => reported . filter ( ( state ) => state . kind === 'crashed' ) ;
466+
467+ const seedConfiguredNode = ( executable : string ) => {
468+ settings [ 'rstack.nodeExecutable' ] = executable ;
469+ return seedNodeProbe ( executable , { kind : 'ok' , version : '24.0.0' } ) ;
470+ } ;
471+
472+ beforeEach ( ( ) => {
473+ shownMessages . length = 0 ;
474+ loggedWarnings . length = 0 ;
475+ resetUserNodeCaches ( ) ;
476+ const recorder = createStatusRecorder ( ) ;
477+ reported = recorder . reported ;
478+ status . bind ( recorder . reporter ) ;
479+ } ) ;
480+
481+ afterEach ( ( ) => {
482+ api ?. dispose ( ) ;
483+ api = undefined ;
484+ status . unbind ( ) ;
485+ resetUserNodeCaches ( ) ;
486+ } ) ;
487+
488+ it ( 'should log, not notify, when the spawn cwd no longer exists' , async ( ) => {
489+ await seedConfiguredNode ( process . execPath ) ;
490+ const cwd = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'rstest-gone-' ) ) ;
491+ api = createApi ( cwd , packageDir ) ;
492+ fs . rmSync ( cwd , { recursive : true , force : true } ) ;
493+
494+ await expect ( api . createChildProcess ( ) ) . rejects . toThrow ( 'no longer exists' ) ;
495+
496+ expect ( shownMessages ) . toEqual ( [ ] ) ;
497+ expect ( crashes ( ) ) . toEqual ( [ ] ) ;
498+ expect ( loggedWarnings . join ( '\n' ) ) . toContain ( cwd ) ;
499+ } ) ;
500+
501+ it ( 'should keep notifying when the executable itself fails to spawn' , async ( ) => {
502+ await seedConfiguredNode ( path . join ( os . tmpdir ( ) , 'no-such-node-xyz' ) ) ;
503+ api = createApi ( packageDir ) ;
504+
505+ await api . createChildProcess ( ) ;
506+ await expect
507+ . poll ( ( ) => shownMessages [ 0 ] ?? '' , { timeout : 5000 } )
508+ . toContain ( 'Rstest worker process failed' ) ;
509+
510+ expect ( crashes ( ) ) . toHaveLength ( 1 ) ;
511+ } ) ;
512+
513+ it ( 'should absorb a post-spawn error instead of notifying' , async ( ) => {
514+ await seedConfiguredNode ( process . execPath ) ;
515+ // `--eval` wins over the worker script path, so the child is a plain
516+ // long-lived node — the point is the handler, not the worker protocol.
517+ settings . nodeExecArgs = [ '--eval' , 'setInterval(() => {}, 1000)' ] ;
518+ api = createApi ( packageDir ) ;
519+
520+ await api . createChildProcess ( ) ;
521+ const child = [ ...( ( api as any ) . childProcesses as Set < ChildProcess > ) ] [ 0 ] ! ;
522+ // The handler's spawned latch is set by the master's own 'spawn'
523+ // listener, which registered first and therefore runs first.
524+ await new Promise < void > ( ( resolve ) => {
525+ child . once ( 'spawn' , ( ) => resolve ( ) ) ;
526+ } ) ;
527+
528+ child . emit ( 'error' , new Error ( 'write EPIPE' ) ) ;
529+
530+ expect ( shownMessages ) . toEqual ( [ ] ) ;
531+ expect ( crashes ( ) ) . toEqual ( [ ] ) ;
532+ } ) ;
533+ } ) ;
0 commit comments