blob: a3062652706921eb2c0157736901be35ec902157 [file]
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, relative } from 'node:path';
import { test } from 'node:test';
import { runWorkspaceTests } from './run-workspace-tests-parallel.mjs';
/**
* `plan` is keyed by workspace directory, not by spawn order.
*
* Keying on order made the outcome depend on which worker's `mkdtemp`
* resolved first — something the runner does not promise and a test cannot
* control — so `[core] failed with code 1` was a coin flip that landed wrong
* about one run in three. Keying on the workspace makes "core exits 1" a fact
* of the fixture, which lets the assertions get stricter rather than looser.
*/
function makeSpawn(plan) {
const calls = [];
const spawn = (command, options) => {
const child = new EventEmitter();
const dir = String(options.cwd).replaceAll('\\', '/').split('/').slice(-2).join('/');
calls.push({ command, cwd: options.cwd, shell: options.shell });
queueMicrotask(() => {
const step = plan[dir] ?? { close: 0 };
if (step.error) {
child.emit(
'error',
step.error instanceof Error ? step.error : new Error(String(step.error)),
);
return;
}
child.emit('close', step.close ?? 0);
});
return child;
};
return { spawn, calls };
}
test('parallel mode aggregates every failed workspace name', async () => {
const repoRoot = '/repo';
const workspaceDirs = ['packages/core', 'packages/ui', 'packages/headless'];
const { spawn, calls } = makeSpawn({
'packages/core': { close: 1 },
'packages/ui': { close: 2 },
'packages/headless': { close: 0 },
});
await assert.rejects(
() =>
runWorkspaceTests({
repoRoot,
workspaceDirs,
serial: false,
spawn,
}),
(err) => {
assert.match(err.message, /\[core\] failed with code 1/);
assert.match(err.message, /\[ui\] failed with code 2/);
return true;
},
);
// The serial workspace runs even though the parallel batch failed — the
// regression that had headless silently not running whenever anything else
// did.
assert.ok(calls.some((call) => call.cwd.replaceAll('\\', '/').endsWith('packages/headless')));
});
test('each workspace runs in its own temp namespace, removed however it ends', async () => {
const observed = [];
const spawn = (_command, options) => {
const child = new EventEmitter();
observed.push({
tmpdir: options.env.TMPDIR,
tmp: options.env.TMP,
temp: options.env.TEMP,
presentWhileRunning: existsSync(options.env.TMPDIR),
});
// First workspace passes, second fails: both paths must still clean up.
queueMicrotask(() => child.emit('close', observed.length - 1));
return child;
};
await assert.rejects(
() =>
runWorkspaceTests({
repoRoot: '/repo',
workspaceDirs: ['packages/core', 'packages/ui'],
concurrency: 1,
spawn,
}),
/\[ui\] failed with code 1/,
);
assert.equal(observed.length, 2);
assert.notEqual(observed[0].tmpdir, observed[1].tmpdir);
for (const entry of observed) {
assert.equal(entry.presentWhileRunning, true);
assert.equal(entry.tmp, entry.tmpdir);
assert.equal(entry.temp, entry.tmpdir);
assert.equal(relative(tmpdir(), entry.tmpdir).startsWith('..'), false);
assert.equal(existsSync(entry.tmpdir), false);
}
assert.notEqual(observed[0].tmpdir, process.env.TMPDIR);
});
test('the temp namespace is removed even when spawn throws synchronously', async () => {
let observed;
const spawn = (_command, options) => {
observed = options.env.TMPDIR;
throw new Error('spawn exploded');
};
await assert.rejects(
() => runWorkspaceTests({ repoRoot: '/repo', workspaceDirs: ['packages/core'], spawn }),
/spawn exploded/,
);
assert.equal(existsSync(observed), false);
});
test('bounded parallel mode never exceeds its configured concurrency', async () => {
const repoRoot = '/repo';
const workspaceDirs = ['packages/core', 'packages/ui', 'apps/desktop'];
let active = 0;
let maxActive = 0;
let started = 0;
const spawn = () => {
const child = new EventEmitter();
active += 1;
started += 1;
maxActive = Math.max(maxActive, active);
setImmediate(() => {
active -= 1;
child.emit('close', 0);
});
return child;
};
await runWorkspaceTests({ repoRoot, workspaceDirs, concurrency: 2, spawn });
// The contract is the one in this test's name: never MORE than the
// configured concurrency. Asserting exactly 2 additionally demanded that the
// scheduler be saturated at the moment of measurement, which depends on
// which worker's mkdtemp resolved first — so it failed about one run in
// three on main, before this file was touched.
assert.ok(maxActive <= 2, `expected at most 2 concurrent workspaces, saw ${maxActive}`);
// …and the cap must not be met by doing less work: every workspace ran.
assert.equal(started, workspaceDirs.length);
});
test('cancellation terminates active workspace process trees and starts no queued work', async () => {
const repoRoot = await mkdtemp(join(tmpdir(), 'maka-workspace-runner-'));
const workspace = join(repoRoot, 'packages', 'fixture');
const pidFile = join(workspace, 'pids.json');
let pids;
try {
await mkdir(workspace, { recursive: true });
await writeFile(
join(workspace, 'package.json'),
JSON.stringify({
private: true,
scripts: { 'test:dist': 'node fixture.mjs' },
}),
);
await writeFile(
join(workspace, 'fixture.mjs'),
[
"import { spawn } from 'node:child_process';",
"import { writeFileSync } from 'node:fs';",
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });",
"writeFileSync(new URL('./pids.json', import.meta.url), JSON.stringify({ parent: process.pid, child: child.pid }));",
'setInterval(() => {}, 1000);',
].join('\n'),
);
const controller = new AbortController();
const running = runWorkspaceTests({
repoRoot,
workspaceDirs: ['packages/fixture', 'packages/never-started'],
concurrency: 1,
signal: controller.signal,
});
void running.catch(() => undefined);
pids = await waitForPidFile(pidFile);
controller.abort();
await assert.rejects(running, /Workspace test run cancelled/);
await Promise.all([waitForProcessExit(pids.parent), waitForProcessExit(pids.child)]);
} finally {
terminateProcess(pids?.parent);
terminateProcess(pids?.child);
await rm(repoRoot, { recursive: true, force: true });
}
});
test('workspace timeout waits for process cleanup before reporting failure', async () => {
const child = new EventEmitter();
child.exitCode = null;
child.signalCode = null;
child.pid = 42;
let terminationStarted = false;
let terminationFinished = false;
let tempRoot;
const spawn = (_command, options) => {
tempRoot = options.env.TMPDIR;
return child;
};
const terminateWorkspace = async (target) => {
assert.equal(target, child);
terminationStarted = true;
await new Promise((resolvePromise) => setImmediate(resolvePromise));
terminationFinished = true;
};
await assert.rejects(
() =>
runWorkspaceTests({
repoRoot: '/repo',
workspaceDirs: ['packages/core'],
workspaceTimeoutMs: 10,
spawn,
terminateWorkspace,
}),
/\[core\] timed out after 10ms/,
);
assert.equal(terminationStarted, true);
assert.equal(terminationFinished, true);
// The stop path reaps the process tree before the namespace goes away.
assert.equal(existsSync(tempRoot), false);
});
async function waitForPidFile(path) {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
try {
const parsed = JSON.parse(await readFile(path, 'utf8'));
if (Number.isSafeInteger(parsed.parent) && Number.isSafeInteger(parsed.child)) return parsed;
} catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
}
await sleep(20);
}
throw new Error('workspace fixture did not publish its process ids');
}
async function waitForProcessExit(pid) {
const deadline = Date.now() + 2_000;
while (isProcessAlive(pid) && Date.now() < deadline) await sleep(20);
assert.equal(isProcessAlive(pid), false, `process ${pid} remained alive`);
}
function terminateProcess(pid) {
if (!pid || !isProcessAlive(pid)) return;
try {
process.kill(pid, 'SIGKILL');
} catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) throw error;
}
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ESRCH') return false;
throw error;
}
}
function sleep(ms) {
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
}