125 lines
7.9 KiB
JavaScript
125 lines
7.9 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { spawn } from 'node:child_process'
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import test from 'node:test'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const launcher = fileURLToPath(new URL('./local-check.ps1', import.meta.url))
|
|
const quote = value => `'${String(value).replaceAll("'", "''")}'`
|
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
|
|
function run(state, args, { cwd = os.tmpdir(), timeout = 15, wait = 15 } = {}) {
|
|
const script = `& ${quote(launcher)} -WorkingDirectory ${quote(cwd)} -TimeoutSeconds ${timeout} -WaitSeconds ${wait} -Command @(${args.map(quote).join(',')}); exit $LASTEXITCODE`
|
|
const env = { ...process.env, LOCALAPPDATA: state }
|
|
delete env.CHONGCHONG_LOCAL_CHECK_OWNER
|
|
const child = spawn('pwsh', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { env })
|
|
let output = ''
|
|
child.stdout.on('data', chunk => { output += chunk })
|
|
child.stderr.on('data', chunk => { output += chunk })
|
|
const done = new Promise((resolve, reject) => {
|
|
child.on('error', reject)
|
|
child.on('close', code => resolve({ code, output }))
|
|
})
|
|
return { child, done, output: () => output }
|
|
}
|
|
|
|
async function until(check, message) {
|
|
const deadline = Date.now() + 12000
|
|
while (Date.now() < deadline) {
|
|
if (await check()) return
|
|
await delay(100)
|
|
}
|
|
assert.fail(message)
|
|
}
|
|
|
|
test('local-check bounds different directories to two slots, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 120000 }, async t => {
|
|
const state = await mkdtemp(path.join(os.tmpdir(), 'local-check-test-'))
|
|
const processes = []
|
|
const start = (...args) => { const result = run(state, ...args); processes.push(result); return result }
|
|
try {
|
|
await t.test('literal arguments, environment caps and exit codes are preserved', async () => {
|
|
const result = await start([process.execPath, '-e', 'console.log(JSON.stringify([process.argv[1],process.env.VITEST_MAX_WORKERS,process.env.NODE_OPTIONS,process.env.JAVA_TOOL_OPTIONS]));process.exit(7)', 'space quote \' $() ` ; 中文']).done
|
|
assert.equal(result.code, 7, result.output)
|
|
assert.ok(result.output.includes(JSON.stringify('space quote \' $() ` ; 中文')))
|
|
assert.match(result.output, /"2","--max-old-space-size=2048"/)
|
|
assert.match(result.output, /ActiveProcessorCount=2 -Xmx1536m/)
|
|
})
|
|
await t.test('two directories run together; a third times out or waits until one lease is released', async () => {
|
|
const releaseFirst = path.join(state, 'release-first'), releaseSecond = path.join(state, 'release-second')
|
|
const hold = (file, label) => `const fs=require('fs');console.log(${JSON.stringify(label)});const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(file)}))clearInterval(timer)},50)`
|
|
const first = start([process.execPath, '-e', hold(releaseFirst, 'FIRST_READY')], { timeout: 30 })
|
|
await until(() => first.output().includes('FIRST_READY'), first.output())
|
|
const second = start([process.execPath, '-e', hold(releaseSecond, 'SECOND_READY')], { cwd: path.dirname(launcher), timeout: 30 })
|
|
await until(() => second.output().includes('SECOND_READY'), second.output())
|
|
assert.equal(first.child.exitCode, null)
|
|
const owners = await Promise.all(['owner.json', 'owner-2.json'].map(name => readFile(path.join(state, 'Chongchong', 'LocalChecks', name), 'utf8').then(JSON.parse)))
|
|
assert.deepEqual(owners.map(owner => owner.slot).sort(), [1, 2])
|
|
assert.equal(new Set(owners.map(owner => owner.directory)).size, 2)
|
|
const timedOut = await start([process.execPath, '-e', 'console.log("SHOULD_NOT_RUN")'], { wait: 0, cwd: path.dirname(launcher) }).done
|
|
assert.equal(timedOut.code, 75, timedOut.output)
|
|
assert.doesNotMatch(timedOut.output, /SHOULD_NOT_RUN/)
|
|
const third = start([process.execPath, '-e', 'console.log("THIRD_READY")'], { cwd: state })
|
|
await until(() => third.output().includes('WAITING'), third.output())
|
|
assert.doesNotMatch(third.output(), /THIRD_READY/)
|
|
await writeFile(releaseFirst, '')
|
|
assert.equal((await first.done).code, 0)
|
|
const result = await third.done
|
|
assert.equal(result.code, 0, result.output)
|
|
assert.match(result.output, /THIRD_READY/)
|
|
assert.equal(second.child.exitCode, null)
|
|
await writeFile(releaseSecond, '')
|
|
assert.equal((await second.done).code, 0)
|
|
})
|
|
await t.test('execution timeout kills descendants and frees the slot', async () => {
|
|
const pidFile = path.join(state, 'descendant.pid')
|
|
const code = `const {spawn}=require('child_process');const fs=require('fs');const c=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});fs.writeFileSync(${JSON.stringify(pidFile)},String(c.pid));setInterval(()=>{},1000)`
|
|
const result = await start([process.execPath, '-e', code], { timeout: 3 }).done
|
|
assert.equal(result.code, 124, result.output)
|
|
const descendant = Number(await readFile(pidFile, 'utf8'))
|
|
await until(() => { try { process.kill(descendant, 0); return false } catch { return true } }, 'descendant survived timeout')
|
|
assert.equal((await start([process.execPath, '-e', 'process.exit(0)']).done).code, 0)
|
|
})
|
|
await t.test('abrupt wrapper termination kills its descendants and releases the file lock', async () => {
|
|
const pidFile = path.join(state, 'abrupt.pid')
|
|
const code = `require('fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));console.log('ABRUPT_READY');setInterval(()=>{},1000)`
|
|
const active = start([process.execPath, '-e', code])
|
|
await until(() => active.output().includes('ABRUPT_READY'), active.output())
|
|
const descendant = Number(await readFile(pidFile, 'utf8'))
|
|
active.child.kill()
|
|
await active.done
|
|
await until(() => { try { process.kill(descendant, 0); return false } catch { return true } }, 'descendant survived wrapper termination')
|
|
assert.equal((await start([process.execPath, '-e', 'process.exit(0)']).done).code, 0)
|
|
})
|
|
await t.test('missing commands fail and do not leave the slot occupied', async () => {
|
|
const result = await start(['chongchong-missing-command-for-test']).done
|
|
assert.notEqual(result.code, 0, result.output)
|
|
assert.equal((await start([process.execPath, '-e', 'process.exit(0)']).done).code, 0)
|
|
})
|
|
await t.test('Maven arguments are limited without launching Maven', async () => {
|
|
const fakeMaven = path.join(state, 'mvn.ps1')
|
|
await writeFile(fakeMaven, 'ConvertTo-Json -InputObject @($args) -Compress\n')
|
|
const result = await start([fakeMaven, '-pl', 'example', 'test']).done
|
|
assert.equal(result.code, 0, result.output)
|
|
assert.match(result.output, /\["-T","1","-DforkCount=1","-DreuseForks=true","-pl","example","test"\]/)
|
|
const override = await start([fakeMaven, '-T4', 'test']).done
|
|
assert.notEqual(override.code, 0, override.output)
|
|
assert.match(override.output, /Do not override Maven concurrency/)
|
|
})
|
|
await t.test('direct Node test concurrency is supplied and nested locks are rejected', async () => {
|
|
const nodeTest = path.join(state, 'probe.test.mjs')
|
|
await writeFile(nodeTest, `import assert from 'node:assert/strict'; assert.ok(process.execArgv.includes('--test-concurrency=2'));\n`)
|
|
const result = await start([process.execPath, '--test', nodeTest]).done
|
|
assert.equal(result.code, 0, result.output)
|
|
const nested = await start(['pwsh', '-NoProfile', '-File', launcher, '-Command', 'node']).done
|
|
assert.notEqual(nested.code, 0, nested.output)
|
|
assert.match(nested.output, /Already inside local-check/)
|
|
})
|
|
} finally {
|
|
for (const item of processes) if (item.child.exitCode === null) item.child.kill()
|
|
await Promise.all(processes.map(item => item.done))
|
|
await rm(state, { recursive: true, force: true })
|
|
}
|
|
})
|