Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test_runner: fix support watch with run(), add globPatterns option #53866

Merged
merged 24 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,9 @@ added:
- v18.9.0
- v16.19.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/53866
description: Added the `globPatterns` option.
- version:
- v22.0.0
- v20.14.0
Expand All @@ -1265,6 +1268,9 @@ changes:
* `forceExit`: {boolean} Configures the test runner to exit the process once
all known tests have finished executing even if the event loop would
otherwise remain active. **Default:** `false`.
* `globPatterns`: {Array} An array containing the list of glob patterns to
mcollina marked this conversation as resolved.
Show resolved Hide resolved
mcollina marked this conversation as resolved.
Show resolved Hide resolved
match test files. This option cannot be used together with `files`.
**Default:** matching files from [test runner execution model][].
* `inspectPort` {number|Function} Sets inspector port of test child process.
This can be a number, or a function that takes no arguments and returns a
number. If a nullish value is provided, each process gets its own port,
Expand Down
5 changes: 5 additions & 0 deletions lib/internal/main/test_runner.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
'use strict';

const {
ArrayPrototypeSlice,
} = primordials;

const {
prepareMainThreadExecution,
markBootstrapComplete,
Expand Down Expand Up @@ -42,6 +46,7 @@ const options = {
setup: setupTestReporters,
timeout: perFileTimeout,
shard,
globPatterns: ArrayPrototypeSlice(process.argv, 1),
};
debug('test runner configuration:', options);
run(options).on('test:fail', (data) => {
Expand Down
30 changes: 22 additions & 8 deletions lib/internal/test_runner/runner.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use strict';

const {
ArrayIsArray,
ArrayPrototypeEvery,
Expand All @@ -10,7 +11,6 @@ const {
ArrayPrototypeMap,
ArrayPrototypePush,
ArrayPrototypeShift,
ArrayPrototypeSlice,
ArrayPrototypeSome,
ArrayPrototypeSort,
ObjectAssign,
Expand Down Expand Up @@ -88,10 +88,12 @@ const kCanceledTests = new SafeSet()

let kResistStopPropagation;

function createTestFileList() {
function createTestFileList(patterns) {
const cwd = process.cwd();
const hasUserSuppliedPattern = process.argv.length > 1;
const patterns = hasUserSuppliedPattern ? ArrayPrototypeSlice(process.argv, 1) : [kDefaultPattern];
const hasUserSuppliedPattern = patterns != null;
if (!patterns || patterns.length === 0) {
patterns = [kDefaultPattern];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would assign the default patterns in the run() validation. You can do that in this PR, otherwise, I will probably do it in a follow up.

}
const glob = new Glob(patterns, {
__proto__: null,
cwd,
Expand Down Expand Up @@ -345,7 +347,6 @@ function runTestFile(path, filesWatcher, opts) {

let err;


mcollina marked this conversation as resolved.
Show resolved Hide resolved
child.on('error', (error) => {
err = error;
});
Expand Down Expand Up @@ -419,8 +420,8 @@ function watchFiles(testFiles, opts) {
opts.root.harness.watching = true;

watcher.on('changed', ({ owners, eventType }) => {
if (eventType === 'rename') {
const updatedTestFiles = createTestFileList();
if (!opts.hasFiles && eventType === 'rename') {
const updatedTestFiles = createTestFileList(opts.globPatterns);

const newFileName = ArrayPrototypeFind(updatedTestFiles, (x) => !ArrayPrototypeIncludes(testFiles, x));
const previousFileName = ArrayPrototypeFind(testFiles, (x) => !ArrayPrototypeIncludes(updatedTestFiles, x));
Expand Down Expand Up @@ -483,6 +484,7 @@ function run(options = kEmptyObject) {
watch,
setup,
only,
globPatterns,
} = options;

if (files != null) {
Expand All @@ -503,6 +505,16 @@ function run(options = kEmptyObject) {
if (only != null) {
validateBoolean(only, 'options.only');
}
if (globPatterns != null) {
validateArray(globPatterns, 'options.globPatterns');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably validate that the array is not empty. You can add the logic here. Otherwise, I will probably do it in a follow up.

}

if (globPatterns?.length > 0 && files?.length > 0) {
throw new ERR_INVALID_ARG_VALUE(
'options.globPatterns', globPatterns, 'is not supported when specifying \'options.files\'',
);
}

if (shard != null) {
validateObject(shard, 'options.shard');
// Avoid re-evaluating the shard object in case it's a getter
Expand Down Expand Up @@ -559,7 +571,7 @@ function run(options = kEmptyObject) {
root.postRun();
return root.reporter;
}
let testFiles = files ?? createTestFileList();
let testFiles = files ?? createTestFileList(globPatterns);

if (shard) {
testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1);
Expand All @@ -574,6 +586,8 @@ function run(options = kEmptyObject) {
inspectPort,
testNamePatterns,
testSkipPatterns,
hasFiles: files != null,
globPatterns,
only,
forceExit,
};
Expand Down
24 changes: 24 additions & 0 deletions test/fixtures/test-runner-watch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { run } from 'node:test';
import { tap } from 'node:test/reporters';
import { parseArgs } from 'node:util';

const options = {
file: {
type: 'string',
},
};
const {
values,
positionals,
} = parseArgs({ args: process.argv.slice(2), options });

let files;

if (values.file) {
files = [values.file];
}

run({
files,
watch: true
}).compose(tap).pipe(process.stdout);
51 changes: 51 additions & 0 deletions test/parallel/test-runner-run-files-undefined.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as common from '../common/index.mjs';
import tmpdir from '../common/tmpdir.js';
import { describe, it, run, beforeEach } from 'node:test';
import { dot, spec, tap } from 'node:test/reporters';
import { fork } from 'node:child_process';

if (process.env.CHILD === 'true') {
describe('require(\'node:test\').run with no files', { concurrency: true }, () => {
beforeEach(() => {
tmpdir.refresh();
process.chdir(tmpdir.path);
});

it('should neither pass or fail', async () => {
const stream = run({
files: undefined
}).compose(tap);
stream.on('test:fail', common.mustNotCall());
stream.on('test:pass', common.mustNotCall());

// eslint-disable-next-line no-unused-vars
for await (const _ of stream);
});

it('can use the spec reporter', async () => {
const stream = run({
files: undefined
}).compose(spec);
stream.on('test:fail', common.mustNotCall());
stream.on('test:pass', common.mustNotCall());

// eslint-disable-next-line no-unused-vars
for await (const _ of stream);
});

it('can use the dot reporter', async () => {
const stream = run({
files: undefined
}).compose(dot);
stream.on('test:fail', common.mustNotCall());
stream.on('test:pass', common.mustNotCall());

// eslint-disable-next-line no-unused-vars
for await (const _ of stream);
});
});
} else {
fork(import.meta.filename, [], {
env: { CHILD: 'true' }
});
mcollina marked this conversation as resolved.
Show resolved Hide resolved
}
171 changes: 171 additions & 0 deletions test/parallel/test-runner-run-watch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Flags: --expose-internals
import * as common from '../common/index.mjs';
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
import { spawn } from 'node:child_process';
import { writeFileSync, renameSync, unlinkSync, existsSync } from 'node:fs';
import util from 'internal/util';
import tmpdir from '../common/tmpdir.js';
import { join } from 'node:path';

if (common.isIBMi)
common.skip('IBMi does not support `fs.watch()`');

// This test updates these files repeatedly,
// Reading them from disk is unreliable due to race conditions.
const fixtureContent = {
'dependency.js': 'module.exports = {};',
'dependency.mjs': 'export const a = 1;',
'test.js': `
const test = require('node:test');
require('./dependency.js');
import('./dependency.mjs');
import('data:text/javascript,');
test('test has ran');`,
};

let fixturePaths;

function refresh() {
tmpdir.refresh();

fixturePaths = Object.keys(fixtureContent)
.reduce((acc, file) => ({ ...acc, [file]: tmpdir.resolve(file) }), {});
Object.entries(fixtureContent)
.forEach(([file, content]) => writeFileSync(fixturePaths[file], content));
}

const runner = join(import.meta.dirname, '..', 'fixtures', 'test-runner-watch.mjs');

async function testWatch({ fileToUpdate, file, action = 'update', cwd = tmpdir.path }) {
const ran1 = util.createDeferredPromise();
const ran2 = util.createDeferredPromise();
MoLow marked this conversation as resolved.
Show resolved Hide resolved
const args = [runner];
if (file) args.push('--file', file);
const child = spawn(process.execPath,
args,
{ encoding: 'utf8', stdio: 'pipe', cwd });
let stdout = '';
let currentRun = '';
const runs = [];

child.stdout.on('data', (data) => {
stdout += data.toString();
currentRun += data.toString();
const testRuns = stdout.match(/# duration_ms\s\d+/g);
if (testRuns?.length >= 1) ran1.resolve();
if (testRuns?.length >= 2) ran2.resolve();
});

const testUpdate = async () => {
await ran1.promise;
const content = fixtureContent[fileToUpdate];
const path = fixturePaths[fileToUpdate];
const interval = setInterval(() => writeFileSync(path, content), common.platformTimeout(1000));
await ran2.promise;
runs.push(currentRun);
clearInterval(interval);
child.kill();
for (const run of runs) {
assert.doesNotMatch(run, /run\(\) is being called recursively/);
assert.match(run, /# tests 1/);
assert.match(run, /# pass 1/);
assert.match(run, /# fail 0/);
assert.match(run, /# cancelled 0/);
}
};

const testRename = async () => {
await ran1.promise;
const fileToRenamePath = tmpdir.resolve(fileToUpdate);
const newFileNamePath = tmpdir.resolve(`test-renamed-${fileToUpdate}`);
const interval = setInterval(() => renameSync(fileToRenamePath, newFileNamePath), common.platformTimeout(1000));
await ran2.promise;
runs.push(currentRun);
clearInterval(interval);
child.kill();

for (const run of runs) {
assert.doesNotMatch(run, /run\(\) is being called recursively/);
if (action === 'rename2') {
assert.match(run, /MODULE_NOT_FOUND/);
} else {
assert.doesNotMatch(run, /MODULE_NOT_FOUND/);
}
assert.match(run, /# tests 1/);
assert.match(run, /# pass 1/);
assert.match(run, /# fail 0/);
assert.match(run, /# cancelled 0/);
}
};

const testDelete = async () => {
await ran1.promise;
const fileToDeletePath = tmpdir.resolve(fileToUpdate);
const interval = setInterval(() => {
if (existsSync(fileToDeletePath)) {
unlinkSync(fileToDeletePath);
} else {
ran2.resolve();
}
}, common.platformTimeout(1000));
await ran2.promise;
runs.push(currentRun);
clearInterval(interval);
child.kill();

for (const run of runs) {
assert.doesNotMatch(run, /MODULE_NOT_FOUND/);
}
};

action === 'update' && await testUpdate();
action === 'rename' && await testRename();
action === 'rename2' && await testRename();
action === 'delete' && await testDelete();
}

describe('test runner watch mode', () => {
beforeEach(refresh);
it('should run tests repeatedly', async () => {
await testWatch({ file: 'test.js', fileToUpdate: 'test.js' });
});

it('should run tests with dependency repeatedly', async () => {
await testWatch({ file: 'test.js', fileToUpdate: 'dependency.js' });
});

it('should run tests with ESM dependency', async () => {
await testWatch({ file: 'test.js', fileToUpdate: 'dependency.mjs' });
});

it('should support running tests without a file', async () => {
await testWatch({ fileToUpdate: 'test.js' });
});

it('should support a watched test file rename', async () => {
await testWatch({ fileToUpdate: 'test.js', action: 'rename' });
});

it('should not throw when deleting a watched test file', async () => {
await testWatch({ fileToUpdate: 'test.js', action: 'delete' });
});

it('should run tests with dependency repeatedly in a different cwd', async () => {
await testWatch({
file: join(tmpdir.path, 'test.js'),
fileToUpdate: 'dependency.js',
cwd: import.meta.dirname,
action: 'rename2'
});
});

it('should handle renames in a different cwd', async () => {
await testWatch({
file: join(tmpdir.path, 'test.js'),
fileToUpdate: 'test.js',
cwd: import.meta.dirname,
action: 'rename2'
});
});
});
Loading
Loading