Skip to content

Commit 3296c22

Browse files
committed
improve integration test process cleanup
1 parent 6cf1af2 commit 3296c22

2 files changed

Lines changed: 92 additions & 15 deletions

File tree

integrations/utils.ts

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import dedent from 'dedent'
22
import fastGlob from 'fast-glob'
3-
import { exec, spawn } from 'node:child_process'
3+
import { exec, execFile, spawn, type ChildProcess } from 'node:child_process'
44
import fs from 'node:fs/promises'
5+
import { createServer } from 'node:net'
56
import { platform, tmpdir } from 'node:os'
67
import path from 'node:path'
7-
import { stripVTControlCharacters } from 'node:util'
8+
import { promisify, stripVTControlCharacters } from 'node:util'
89
import { RawSourceMap, SourceMapConsumer } from 'source-map-js'
910
import { test as defaultTest, type ExpectStatic } from 'vitest'
1011
import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table'
@@ -70,6 +71,8 @@ type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void
7071

7172
export const IS_WINDOWS = platform() === 'win32'
7273

74+
const execFileAsync = promisify(execFile)
75+
7376
const TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000
7477
const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000
7578

@@ -170,6 +173,7 @@ export function test(
170173
if (debug) console.log(`>& ${command}`)
171174
let child = spawn(command, {
172175
cwd,
176+
detached: !IS_WINDOWS,
173177
shell: true,
174178
...childProcessOptions,
175179
env: {
@@ -178,19 +182,22 @@ export function test(
178182
},
179183
})
180184

181-
function dispose() {
182-
if (!child.kill()) {
183-
child.kill('SIGKILL')
184-
}
185+
let disposed = false
186+
187+
async function dispose() {
188+
if (disposed) return disposePromise
189+
disposed = true
190+
191+
await killProcessTree(child)
185192

186-
let timer = setTimeout(
187-
() =>
188-
rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)),
189-
ASSERTION_TIMEOUT,
193+
let timer = setTimeout(() => {
194+
forceKillProcessTree(child)
195+
rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`))
196+
}, ASSERTION_TIMEOUT)
197+
disposePromise.then(
198+
() => clearTimeout(timer),
199+
() => clearTimeout(timer),
190200
)
191-
disposePromise.finally(() => {
192-
clearTimeout(timer)
193-
})
194201
return disposePromise
195202
}
196203
disposables.push(dispose)
@@ -607,6 +614,65 @@ export async function fetchStyles(base: string, path = '/'): Promise<string> {
607614
}, '')
608615
}
609616

617+
export async function getRandomPort() {
618+
return new Promise<number>((resolve, reject) => {
619+
let server = createServer()
620+
server.unref()
621+
server.on('error', reject)
622+
server.listen(0, '127.0.0.1', () => {
623+
let address = server.address()
624+
server.close(() => {
625+
if (address && typeof address === 'object') {
626+
resolve(address.port)
627+
} else {
628+
reject(new Error('Unable to allocate random port'))
629+
}
630+
})
631+
})
632+
})
633+
}
634+
635+
async function killProcessTree(child: ChildProcess) {
636+
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) {
637+
return
638+
}
639+
640+
if (IS_WINDOWS) {
641+
await execFileAsync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
642+
timeout: ASSERTION_TIMEOUT,
643+
windowsHide: true,
644+
}).catch(() => {})
645+
return
646+
}
647+
648+
try {
649+
process.kill(-child.pid, 'SIGTERM')
650+
} catch (error: any) {
651+
if (error?.code !== 'ESRCH') {
652+
child.kill()
653+
}
654+
}
655+
}
656+
657+
function forceKillProcessTree(child: ChildProcess) {
658+
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) {
659+
return
660+
}
661+
662+
if (IS_WINDOWS) {
663+
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }, () => {})
664+
return
665+
}
666+
667+
try {
668+
process.kill(-child.pid, 'SIGKILL')
669+
} catch (error: any) {
670+
if (error?.code !== 'ESRCH') {
671+
child.kill('SIGKILL')
672+
}
673+
}
674+
}
675+
610676
async function gracefullyRemove(dir: string) {
611677
// Skip removing the directory in CI because it can stall on Windows
612678
if (!process.env.CI) {

integrations/vite/nuxt.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'
1+
import {
2+
candidate,
3+
css,
4+
fetchStyles,
5+
getRandomPort,
6+
html,
7+
json,
8+
retryAssertion,
9+
test,
10+
ts,
11+
} from '../utils'
212

313
const SETUP = {
414
fs: {
@@ -84,7 +94,8 @@ test('build', SETUP, async ({ spawn, exec, expect }) => {
8494
await exec('pnpm nuxt build')
8595
// The Nuxt preview server does not automatically assign a free port if 3000
8696
// is taken, so we use a random port instead.
87-
let process = await spawn(`pnpm nuxt preview --port 8724`, {
97+
let port = await getRandomPort()
98+
let process = await spawn(`pnpm nuxt preview --port ${port}`, {
8899
env: {
89100
TEST: 'false',
90101
NODE_ENV: 'development',

0 commit comments

Comments
 (0)