Skip to content

Commit 96b73a4

Browse files
fix(test): spawn one via run.mjs in standalone tests so they run on Windows
metro-compiler, metro-startup-order, and process-cleanup spawned `node node_modules/.bin/one` - on Windows the .bin shim is a .cmd/.exe wrapper that node cannot load (MODULE_NOT_FOUND), so the dev server never started and all three suites failed before testing anything. Resolve one's real JS entry via createRequire (the same pattern as packages/test/src/setupTest.ts and the test-warm-routes fix) and kill the spawned tree with taskkill /T on Windows so dev-server workers don't orphan. POSIX kill behavior is unchanged in the metro tests (SIGTERM then delayed SIGKILL, as before); process-cleanup's final cleanup now force-kills on POSIX too, matching what its own afterEach already did. The orphan-detection test is now skipIf(win32): setupOrphanDetection in packages/vxrn/src/exports/dev.ts is POSIX-only by design (returns early on win32) and the test discovers the grandchild via pgrep, which doesn't exist on Windows. Its spawn site is still fixed so the test stays correct where it runs. Also give packages/test/src/setup.ts's killProcessTree a Windows branch - negative-pid process groups don't exist there, so the previous fallback killed only the direct child and orphaned its workers. Validated on Windows 11 (Bun 1.3.13, monorepo at v1.18.0): tests/test goes from 4 failed suites (MODULE_NOT_FOUND) to 30 test files passed / 1 skipped, 145 tests passed / 10 skipped, 0 failures - Metro bundler boots (600ms), the React-compiler bundle assertion and the startup-order assertion both pass. oxlint 0/0, oxfmt clean, tsc --noEmit clean.
1 parent ddd092a commit 96b73a4

4 files changed

Lines changed: 175 additions & 87 deletions

File tree

packages/test/src/setup.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ export async function setup(project: TestProject) {
3232

3333
// Kill a process group (for detached processes) or process tree
3434
async function killProcessTree(pid: number, name: string): Promise<void> {
35+
if (process.platform === 'win32') {
36+
// negative-pid process groups don't exist on Windows — the POSIX path below
37+
// would degrade to killing only the direct child and orphan its workers.
38+
// taskkill /T terminates the whole tree (mirrors setupTest.ts).
39+
try {
40+
const { execSync } = await import('node:child_process')
41+
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' })
42+
console.info(`${name} process (PID: ${pid}) killed successfully.`)
43+
} catch {
44+
// process may already be gone, which is fine
45+
}
46+
return
47+
}
48+
3549
try {
3650
// for detached processes, kill the entire process group using negative pid
3751
try {

tests/test/tests/metro-compiler.test.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,35 @@
11
import { spawn, type ChildProcess } from 'node:child_process'
2+
import { createRequire } from 'node:module'
3+
import { dirname, join } from 'node:path'
24
import getPort from 'get-port'
35
import { describe, expect, it, afterEach } from 'vitest'
46

7+
// Resolve one's JS entry (run.mjs) instead of node_modules/.bin/one: on Windows
8+
// the .bin shim is a .cmd/.ps1/.exe wrapper that `node <path>` can't load
9+
// (MODULE_NOT_FOUND), so the dev server never starts. Resolving via package.json
10+
// gives the real JS file on every platform (mirrors packages/test/src/setupTest.ts).
11+
const oneRunEntry = join(
12+
dirname(createRequire(import.meta.url).resolve('one/package.json')),
13+
'run.mjs'
14+
)
15+
16+
// node cannot signal a process tree on Windows; taskkill /T kills spawned workers too
17+
function killTree(proc: ChildProcess) {
18+
if (!proc.pid) return
19+
if (process.platform === 'win32') {
20+
try {
21+
spawn('taskkill', ['/F', '/T', '/PID', String(proc.pid)], { stdio: 'ignore' })
22+
} catch {}
23+
} else {
24+
proc.kill('SIGTERM')
25+
setTimeout(() => {
26+
try {
27+
proc.kill('SIGKILL')
28+
} catch {}
29+
}, 1000)
30+
}
31+
}
32+
533
/**
634
* Test that the React compiler works through the Metro bundling path.
735
*
@@ -22,8 +50,7 @@ describe('Metro React compiler', { retry: 1 }, () => {
2250

2351
afterEach(() => {
2452
if (devServer) {
25-
devServer.kill('SIGTERM')
26-
setTimeout(() => devServer?.kill('SIGKILL'), 1000)
53+
killTree(devServer)
2754
devServer = null
2855
}
2956
})
@@ -35,18 +62,14 @@ describe('Metro React compiler', { retry: 1 }, () => {
3562
let metroReady = false
3663
let processExited = false
3764

38-
devServer = spawn(
39-
'node',
40-
['../../node_modules/.bin/one', 'dev', '--port', port.toString()],
41-
{
42-
cwd: process.cwd(),
43-
env: {
44-
...process.env,
45-
DEBUG: 'vxrn:*,vite-plugin-metro:*',
46-
},
47-
stdio: ['ignore', 'pipe', 'pipe'],
48-
}
49-
)
65+
devServer = spawn('node', [oneRunEntry, 'dev', '--port', port.toString()], {
66+
cwd: process.cwd(),
67+
env: {
68+
...process.env,
69+
DEBUG: 'vxrn:*,vite-plugin-metro:*',
70+
},
71+
stdio: ['ignore', 'pipe', 'pipe'],
72+
})
5073

5174
devServer.on('exit', (code) => {
5275
processExited = true

tests/test/tests/metro-startup-order.test.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,35 @@
1-
import { spawn } from 'node:child_process'
1+
import { spawn, type ChildProcess } from 'node:child_process'
2+
import { createRequire } from 'node:module'
3+
import { dirname, join } from 'node:path'
24
import getPort from 'get-port'
35
import { describe, expect, it } from 'vitest'
46

7+
// Resolve one's JS entry (run.mjs) instead of node_modules/.bin/one: on Windows
8+
// the .bin shim is a .cmd/.ps1/.exe wrapper that `node <path>` can't load
9+
// (MODULE_NOT_FOUND), so the dev server never starts. Resolving via package.json
10+
// gives the real JS file on every platform (mirrors packages/test/src/setupTest.ts).
11+
const oneRunEntry = join(
12+
dirname(createRequire(import.meta.url).resolve('one/package.json')),
13+
'run.mjs'
14+
)
15+
16+
// node cannot signal a process tree on Windows; taskkill /T kills spawned workers too
17+
function killTree(proc: ChildProcess) {
18+
if (!proc.pid) return
19+
if (process.platform === 'win32') {
20+
try {
21+
spawn('taskkill', ['/F', '/T', '/PID', String(proc.pid)], { stdio: 'ignore' })
22+
} catch {}
23+
} else {
24+
proc.kill('SIGTERM')
25+
setTimeout(() => {
26+
try {
27+
proc.kill('SIGKILL')
28+
} catch {}
29+
}, 1000)
30+
}
31+
}
32+
533
/**
634
* Test that Metro initialization happens AFTER Vite server is fully started.
735
*
@@ -23,21 +51,17 @@ describe('Metro startup order', () => {
2351
let viteReadyPos = -1
2452
let metroReadyPos = -1
2553

26-
const devServer = spawn(
27-
'node',
28-
['../../node_modules/.bin/one', 'dev', '--port', port.toString()],
29-
{
30-
cwd: process.cwd(),
31-
env: {
32-
...process.env,
33-
// Enable Metro mode
34-
ONE_METRO_MODE: '1',
35-
// Enable debug logging to see Metro ready message
36-
DEBUG: 'vxrn:*',
37-
},
38-
stdio: ['ignore', 'pipe', 'pipe'],
39-
}
40-
)
54+
const devServer = spawn('node', [oneRunEntry, 'dev', '--port', port.toString()], {
55+
cwd: process.cwd(),
56+
env: {
57+
...process.env,
58+
// Enable Metro mode
59+
ONE_METRO_MODE: '1',
60+
// Enable debug logging to see Metro ready message
61+
DEBUG: 'vxrn:*',
62+
},
63+
stdio: ['ignore', 'pipe', 'pipe'],
64+
})
4165

4266
const collectLogs = (data: Buffer) => {
4367
const text = data.toString()
@@ -80,10 +104,9 @@ describe('Metro startup order', () => {
80104
}, 100)
81105
})
82106

83-
// Kill the server
84-
devServer.kill('SIGTERM')
107+
// Kill the server (tree-kill on Windows; SIGTERM→SIGKILL on POSIX)
108+
killTree(devServer)
85109
await new Promise((r) => setTimeout(r, 500))
86-
devServer.kill('SIGKILL')
87110

88111
// Verify the order - Vite should be ready BEFORE Metro
89112
console.info('Output captured:\n', allOutput)
Lines changed: 82 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,32 @@
11
import { spawn, type ChildProcess } from 'node:child_process'
2+
import { createRequire } from 'node:module'
3+
import { dirname, join } from 'node:path'
24
import { afterEach, describe, expect, test } from 'vitest'
35
import getPort from 'get-port'
46

7+
// Resolve one's JS entry (run.mjs) instead of node_modules/.bin/one: on Windows
8+
// the .bin shim is a .cmd/.ps1/.exe wrapper that `node <path>` can't load
9+
// (MODULE_NOT_FOUND), so the dev server never starts. Resolving via package.json
10+
// gives the real JS file on every platform (mirrors packages/test/src/setupTest.ts).
11+
const oneRunEntry = join(
12+
dirname(createRequire(import.meta.url).resolve('one/package.json')),
13+
'run.mjs'
14+
)
15+
16+
// node cannot signal a process tree on Windows; taskkill /T kills spawned workers too
17+
function killTree(proc: ChildProcess) {
18+
if (!proc.pid) return
19+
if (process.platform === 'win32') {
20+
try {
21+
spawn('taskkill', ['/F', '/T', '/PID', String(proc.pid)], { stdio: 'ignore' })
22+
} catch {}
23+
} else {
24+
try {
25+
process.kill(proc.pid, 'SIGKILL')
26+
} catch {}
27+
}
28+
}
29+
530
// Helper to wait for a process to exit
631
function waitForExit(proc: ChildProcess, timeout = 5000): Promise<number | null> {
732
return new Promise((resolve) => {
@@ -28,80 +53,83 @@ describe('process cleanup', () => {
2853
let wrapper: ChildProcess | null = null
2954

3055
afterEach(() => {
31-
// Clean up any leftover processes
56+
// Clean up any leftover processes (tree-kill so dev-server workers don't orphan)
3257
if (devServer?.pid && isRunning(devServer.pid)) {
33-
process.kill(devServer.pid, 'SIGKILL')
58+
killTree(devServer)
3459
}
3560
if (wrapper?.pid && isRunning(wrapper.pid)) {
36-
process.kill(wrapper.pid, 'SIGKILL')
61+
killTree(wrapper)
3762
}
3863
})
3964

40-
test('dev server exits when parent is killed (orphan detection)', async () => {
41-
const port = await getPort()
42-
43-
// Spawn a wrapper process that spawns the dev server
44-
// When we kill the wrapper, the dev server should detect it's orphaned and exit
45-
wrapper = spawn(
46-
'node',
47-
[
48-
'-e',
49-
`
65+
// orphan detection is POSIX-only by design (setupOrphanDetection in
66+
// packages/vxrn/src/exports/dev.ts returns early on win32), and the test
67+
// discovers the grandchild via pgrep — skip on Windows rather than fail
68+
test.skipIf(process.platform === 'win32')(
69+
'dev server exits when parent is killed (orphan detection)',
70+
async () => {
71+
const port = await getPort()
72+
73+
// Spawn a wrapper process that spawns the dev server
74+
// When we kill the wrapper, the dev server should detect it's orphaned and exit
75+
wrapper = spawn(
76+
'node',
77+
[
78+
'-e',
79+
`
5080
const { spawn } = require('child_process');
51-
const dev = spawn('node', ['../../node_modules/.bin/one', 'dev', '--port', '${port}'], {
81+
const dev = spawn('node', [${JSON.stringify(oneRunEntry)}, 'dev', '--port', '${port}'], {
5282
stdio: 'inherit'
5383
});
5484
dev.unref();
5585
// Keep wrapper alive
5686
setInterval(() => {}, 1000);
5787
`,
58-
],
59-
{
60-
cwd: process.cwd(),
61-
stdio: 'pipe',
62-
}
63-
)
64-
65-
// Wait for dev server to start
66-
await new Promise((r) => setTimeout(r, 8000))
67-
68-
// Find the dev server process
69-
const devPid = await new Promise<number | null>((resolve) => {
70-
const pgrep = spawn('pgrep', ['-f', `one.*dev.*port.*${port}`])
71-
let output = ''
72-
pgrep.stdout?.on('data', (d) => (output += d.toString()))
73-
pgrep.on('close', () => {
74-
const pid = parseInt(output.trim().split('\n')[0], 10)
75-
resolve(isNaN(pid) ? null : pid)
88+
],
89+
{
90+
cwd: process.cwd(),
91+
stdio: 'pipe',
92+
}
93+
)
94+
95+
// Wait for dev server to start
96+
await new Promise((r) => setTimeout(r, 8000))
97+
98+
// Find the dev server process
99+
const devPid = await new Promise<number | null>((resolve) => {
100+
const pgrep = spawn('pgrep', ['-f', `one.*dev.*port.*${port}`])
101+
let output = ''
102+
pgrep.stdout?.on('data', (d) => (output += d.toString()))
103+
pgrep.on('close', () => {
104+
const pid = parseInt(output.trim().split('\n')[0], 10)
105+
resolve(isNaN(pid) ? null : pid)
106+
})
76107
})
77-
})
78108

79-
expect(devPid).toBeTruthy()
109+
expect(devPid).toBeTruthy()
80110

81-
// Kill the wrapper (simulating vitest being killed)
82-
if (wrapper.pid) {
83-
process.kill(wrapper.pid, 'SIGKILL')
84-
}
111+
// Kill the wrapper (simulating vitest being killed)
112+
if (wrapper.pid) {
113+
process.kill(wrapper.pid, 'SIGKILL')
114+
}
85115

86-
// Wait for dev server to detect orphan and exit (should happen within 2 seconds)
87-
await new Promise((r) => setTimeout(r, 2000))
116+
// Wait for dev server to detect orphan and exit (should happen within 2 seconds)
117+
await new Promise((r) => setTimeout(r, 2000))
88118

89-
// Dev server should have exited
90-
const stillRunning = devPid ? isRunning(devPid) : false
91-
expect(stillRunning).toBe(false)
92-
}, 20000)
119+
// Dev server should have exited
120+
const stillRunning = devPid ? isRunning(devPid) : false
121+
expect(stillRunning).toBe(false)
122+
},
123+
20000
124+
)
93125

94126
test('dev server does not exit prematurely when parent is alive', async () => {
95127
const port = await getPort()
96128

97-
devServer = spawn(
98-
'node',
99-
['../../node_modules/.bin/one', 'dev', '--port', port.toString()],
100-
{
101-
cwd: process.cwd(),
102-
stdio: 'pipe',
103-
}
104-
)
129+
devServer = spawn('node', [oneRunEntry, 'dev', '--port', port.toString()], {
130+
cwd: process.cwd(),
131+
stdio: 'pipe',
132+
})
105133

106134
// Wait for dev server to start
107135
await new Promise((r) => setTimeout(r, 6000))
@@ -114,7 +142,7 @@ describe('process cleanup', () => {
114142
await new Promise((r) => setTimeout(r, 2000))
115143
expect(isRunning(devServer.pid!)).toBe(true)
116144

117-
// Clean up
118-
devServer.kill('SIGTERM')
145+
// Clean up (tree-kill so dev-server workers don't orphan on Windows)
146+
killTree(devServer)
119147
}, 15000)
120148
})

0 commit comments

Comments
 (0)