In the coverage job npm signals only frontend-coverage.mjs. It blocked in spawnSync with no listener, so Playwright never saw a step timeout. It now runs each step's child with async spawn, in its own process group, and forwards SIGTERM and SIGINT to the running child. Step order, exit codes and the "Command failed (N)" message are unchanged. Locally, in the pinned image, `timeout -s TERM` on test:coverage printed the still-running tests and wrote both blob reports. TESTING.md records that a SIGTERM to npm alone stops at npm's `sh -c`, and that delivery through `docker exec` in CI is not yet observed. Refs #425
139 lines
7.4 KiB
JavaScript
139 lines
7.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { EventEmitter } from 'node:events';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { plan, runCoverage, runNode } from '../../tools/vendor/frontend-coverage.mjs';
|
|
|
|
const steps = argv => plan(argv).map(({ step, ...options }) => (Object.keys(options).length ? { step, ...options } : step));
|
|
|
|
function tempPaths(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'pm-coverage-cli-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const reportRoot = join(root, 'coverage/js');
|
|
return { root, paths: { repositoryRoot: root, tools: join(root, 'tools'), reportRoot, raw: join(reportRoot, 'raw'), sourceRoot: join(root, 'web/js') } };
|
|
}
|
|
|
|
function recorder() {
|
|
const calls = [];
|
|
const run = (args, env = {}) => { calls.push({ args, env }); };
|
|
return { calls, run };
|
|
}
|
|
|
|
test('no arguments plans the unchanged full run', () => {
|
|
assert.deepEqual(steps([]), [
|
|
'clean', 'node-units', 'viewer', { step: 'workflows', shard: null }, 'report', { step: 'check', updateBaseline: false },
|
|
]);
|
|
assert.deepEqual(steps(['--update-baseline']), [
|
|
'clean', 'node-units', 'viewer', { step: 'workflows', shard: null }, 'report', { step: 'check', updateBaseline: true },
|
|
]);
|
|
});
|
|
|
|
test('--shard 2/3 plans only workflows with --shard 2/3 and no report', () => {
|
|
assert.deepEqual(steps(['--shard', '2/3']), ['prepare', { step: 'workflows', shard: '2/3' }]);
|
|
assert.deepEqual(steps(['--shard=3/3']), ['prepare', { step: 'workflows', shard: '3/3' }]);
|
|
});
|
|
|
|
test('--shard 1/N also runs the node units and the viewer suite', () => {
|
|
assert.deepEqual(steps(['--shard', '1/3']), ['prepare', 'node-units', 'viewer', { step: 'workflows', shard: '1/3' }]);
|
|
assert.deepEqual(steps(['--shard', '1/1']), ['prepare', 'node-units', 'viewer', { step: 'workflows', shard: '1/1' }]);
|
|
});
|
|
|
|
test('--merge-only plans the report and the check over existing raw coverage', () => {
|
|
assert.deepEqual(steps(['--merge-only']), ['require-raw', 'report', { step: 'check', updateBaseline: false }]);
|
|
assert.deepEqual(steps(['--merge-only', '--update-baseline']), ['require-raw', 'report', { step: 'check', updateBaseline: true }]);
|
|
});
|
|
|
|
test('invalid shard and mode combinations are refused', () => {
|
|
for (const argv of [['--shard'], ['--shard', '0/3'], ['--shard', '4/3'], ['--shard', '2'], ['--shard', 'a/b'], ['--shard', '1/0'], ['--shard', '1.5/3']]) {
|
|
assert.throws(() => plan(argv), /--shard needs I\/N/, argv.join(' '));
|
|
}
|
|
assert.throws(() => plan(['--shard', '1/2', '--merge-only']), /either --shard or --merge-only/);
|
|
assert.throws(() => plan(['--shard', '1/2', '--update-baseline']), /--update-baseline .* --merge-only or a full run/);
|
|
assert.throws(() => plan(['--shard', '1/2', '--shard', '2/2']), /--shard once/);
|
|
assert.throws(() => plan(['--report']), /Unknown argument: --report/);
|
|
});
|
|
|
|
test('--merge-only refuses an empty raw dir', async t => {
|
|
const { paths } = tempPaths(t);
|
|
const { calls, run } = recorder();
|
|
await assert.rejects(runCoverage({ argv: ['--merge-only'], run, paths }), /no raw coverage .*coverage\/js\/raw.* run the --shard steps first/i);
|
|
mkdirSync(paths.raw, { recursive: true });
|
|
await assert.rejects(runCoverage({ argv: ['--merge-only'], run, paths }), /no raw coverage/i);
|
|
writeFileSync(join(paths.raw, 'notes.txt'), 'not coverage');
|
|
await assert.rejects(runCoverage({ argv: ['--merge-only'], run, paths }), /no raw coverage/i);
|
|
assert.deepEqual(calls, [], 'nothing is reported or checked over partial or empty coverage');
|
|
});
|
|
|
|
test('a shard keeps raw coverage other shards wrote and passes its shard to Playwright', async t => {
|
|
const { paths } = tempPaths(t);
|
|
mkdirSync(paths.raw, { recursive: true });
|
|
const earlier = join(paths.raw, 'browser-earlier-shard.json');
|
|
writeFileSync(earlier, '{"result":[]}');
|
|
const { calls, run } = recorder();
|
|
await runCoverage({ argv: ['--shard', '2/3'], run, paths });
|
|
assert.ok(existsSync(earlier), 'raw files from another shard survive');
|
|
assert.equal(calls.length, 1, 'shard 2 runs only the workflows');
|
|
const [{ args, env }] = calls;
|
|
assert.deepEqual(args.slice(-6), ['--project', 'chromium', '--project', 'touch', '--shard', '2/3']);
|
|
assert.equal(env.PM_JS_COVERAGE, paths.raw);
|
|
});
|
|
|
|
test('shard 1 runs the node units into the shared raw dir, then the viewer suite, then its workflows', async t => {
|
|
const { root, paths } = tempPaths(t);
|
|
mkdirSync(join(root, 'tests/js'), { recursive: true });
|
|
writeFileSync(join(root, 'tests/js/a.test.js'), '');
|
|
const { calls, run } = recorder();
|
|
await runCoverage({ argv: ['--shard', '1/3'], run, paths });
|
|
assert.equal(calls.length, 3);
|
|
assert.deepEqual(calls[0].args, ['--test', 'tests/js/a.test.js']);
|
|
assert.equal(calls[0].env.NODE_V8_COVERAGE, paths.raw);
|
|
assert.ok(calls[1].args.includes(join(paths.tools, 'playwright.config.mjs')));
|
|
assert.deepEqual(calls[2].args.slice(-2), ['--shard', '1/3']);
|
|
});
|
|
|
|
// #425: npm and a CI step timeout signal only frontend-coverage.mjs; its running child must get it.
|
|
function hostWithSignals() {
|
|
return new EventEmitter();
|
|
}
|
|
|
|
test('runNode resolves on exit 0, rejects with the exit code otherwise and leaves no signal listener', async () => {
|
|
const host = hostWithSignals();
|
|
await runNode(['-e', 'process.exit(0)'], {}, { host });
|
|
await assert.rejects(runNode(['-e', 'process.exit(3)'], {}, { host }), /Command failed \(3\): -e process\.exit\(3\)/);
|
|
assert.equal(host.listenerCount('SIGTERM') + host.listenerCount('SIGINT'), 0);
|
|
});
|
|
|
|
test('runNode forwards SIGTERM to the running child, which can report before it exits', async t => {
|
|
const root = mkdtempSync(join(tmpdir(), 'pm-coverage-signal-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const ready = join(root, 'ready');
|
|
const seen = join(root, 'seen');
|
|
const child = `const fs = require('node:fs');
|
|
process.on('SIGTERM', () => { fs.writeFileSync(${JSON.stringify(seen)}, 'SIGTERM'); process.exit(7); });
|
|
fs.writeFileSync(${JSON.stringify(ready)}, ''); setInterval(() => {}, 1000);`;
|
|
const host = hostWithSignals();
|
|
const running = runNode(['-e', child], {}, { host });
|
|
while (!existsSync(ready)) await delay(20);
|
|
host.emit('SIGTERM');
|
|
await assert.rejects(running, /Command failed \(7\)/);
|
|
assert.equal(readFileSync(seen, 'utf8'), 'SIGTERM', 'the child handled the forwarded signal');
|
|
assert.equal(host.listenerCount('SIGTERM'), 0);
|
|
});
|
|
|
|
test('runNode forwards SIGINT and starts the child in its own process group', async () => {
|
|
const host = hostWithSignals();
|
|
const kills = [];
|
|
let options;
|
|
const fake = new EventEmitter();
|
|
fake.kill = signal => { kills.push(signal); queueMicrotask(() => fake.emit('exit', null, signal)); };
|
|
const running = runNode(['x.mjs'], {}, { host, spawnChild: (command, args, spawnOptions) => { options = spawnOptions; return fake; } });
|
|
assert.equal(options.detached, true, 'a group-wide signal reaches the child only through forwarding, once');
|
|
host.emit('SIGINT');
|
|
await assert.rejects(running, /Command failed \(null\): x\.mjs/);
|
|
assert.deepEqual(kills, ['SIGINT']);
|
|
assert.equal(host.listenerCount('SIGINT'), 0);
|
|
});
|