From 27796e6b5b4e46e018483771da2cc74b6b268ae6 Mon Sep 17 00:00:00 2001 From: Joyee Cheung Date: Sun, 9 Nov 2025 04:44:51 +0100 Subject: [PATCH] test: split test-runner-run-watch.mjs This test contains too many independent test cases and as a result, marking it as flaky on all major platforms means actual regressions could be covered up, and it's constantly making the CI orange and requires extra resuming on the flaked platforms which is still not great. Split it into individual files so that the actual flake can be identified out of the monolith. --- test/common/watch.js | 130 ++++-- test/parallel/parallel.status | 8 - test/parallel/test-runner-run-watch.mjs | 400 ------------------ test/test-runner/test-run-watch-create.mjs | 8 + .../test-run-watch-cwd-isolation-none.mjs | 27 ++ .../test-run-watch-cwd-isolation-process.mjs | 38 ++ test/test-runner/test-run-watch-cwd.mjs | 26 ++ test/test-runner/test-run-watch-delete.mjs | 8 + .../test-run-watch-dependency-repeatedly.mjs | 8 + ...est-run-watch-different-cwd-dependency.mjs | 16 + .../test-run-watch-different-cwd-rename.mjs | 16 + ...different-runner-cwd-isolation-process.mjs | 17 + .../test-run-watch-different-runner-cwd.mjs | 15 + .../test-run-watch-emit-restarted.mjs | 47 ++ .../test-run-watch-esm-dependency.mjs | 8 + ...t-run-watch-no-emit-restarted-disabled.mjs | 21 + test/test-runner/test-run-watch-rename.mjs | 8 + .../test-runner/test-run-watch-repeatedly.mjs | 8 + .../test-run-watch-run-duration.mjs | 19 + .../test-run-watch-without-file.mjs | 8 + 20 files changed, 400 insertions(+), 436 deletions(-) delete mode 100644 test/parallel/test-runner-run-watch.mjs create mode 100644 test/test-runner/test-run-watch-create.mjs create mode 100644 test/test-runner/test-run-watch-cwd-isolation-none.mjs create mode 100644 test/test-runner/test-run-watch-cwd-isolation-process.mjs create mode 100644 test/test-runner/test-run-watch-cwd.mjs create mode 100644 test/test-runner/test-run-watch-delete.mjs create mode 100644 test/test-runner/test-run-watch-dependency-repeatedly.mjs create mode 100644 test/test-runner/test-run-watch-different-cwd-dependency.mjs create mode 100644 test/test-runner/test-run-watch-different-cwd-rename.mjs create mode 100644 test/test-runner/test-run-watch-different-runner-cwd-isolation-process.mjs create mode 100644 test/test-runner/test-run-watch-different-runner-cwd.mjs create mode 100644 test/test-runner/test-run-watch-emit-restarted.mjs create mode 100644 test/test-runner/test-run-watch-esm-dependency.mjs create mode 100644 test/test-runner/test-run-watch-no-emit-restarted-disabled.mjs create mode 100644 test/test-runner/test-run-watch-rename.mjs create mode 100644 test/test-runner/test-run-watch-repeatedly.mjs create mode 100644 test/test-runner/test-run-watch-run-duration.mjs create mode 100644 test/test-runner/test-run-watch-without-file.mjs diff --git a/test/common/watch.js b/test/common/watch.js index 1831939b5a99e5..81defc49835eb1 100644 --- a/test/common/watch.js +++ b/test/common/watch.js @@ -47,20 +47,58 @@ function refreshForTestRunnerWatch() { } } +async function performFileOperation(operation, useRunApi, timeout = 1000) { + if (useRunApi) { + const interval = setInterval(() => { + operation(); + clearInterval(interval); + }, common.platformTimeout(timeout)); + } else { + operation(); + await setTimeout(common.platformTimeout(timeout)); + } +} + +function assertTestOutput(run, shouldCheckRecursion = false) { + if (shouldCheckRecursion) { + 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/); +} + async function testRunnerWatch({ fileToUpdate, file, action = 'update', fileToCreate, isolation, + useRunApi = false, + cwd = tmpdir.path, + runnerCwd, }) { const ran1 = Promise.withResolvers(); const ran2 = Promise.withResolvers(); - const child = spawn(process.execPath, - ['--watch', '--test', '--test-reporter=spec', - isolation ? `--test-isolation=${isolation}` : '', - file ? fixturePaths[file] : undefined].filter(Boolean), - { encoding: 'utf8', stdio: 'pipe', cwd: tmpdir.path }); + + let args; + if (useRunApi) { + // Use the fixture that calls run() API + const runner = fixtures.path('test-runner-watch.mjs'); + args = [runner]; + if (file) args.push('--file', file); + if (runnerCwd) args.push('--cwd', runnerCwd); + if (isolation) args.push('--isolation', isolation); + } else { + // Use CLI --watch --test flags + args = ['--watch', '--test', '--test-reporter=spec', + isolation ? `--test-isolation=${isolation}` : '', + file ? fixturePaths[file] : undefined].filter(Boolean); + } + + const child = spawn(process.execPath, args, + { encoding: 'utf8', stdio: 'pipe', cwd }); let stdout = ''; let currentRun = ''; const runs = []; @@ -79,9 +117,20 @@ async function testRunnerWatch({ currentRun = ''; const content = fixtureContent[fileToUpdate]; const path = fixturePaths[fileToUpdate]; - writeFileSync(path, content); - await setTimeout(common.platformTimeout(1000)); - await ran2.promise; + + if (useRunApi) { + const interval = setInterval( + () => writeFileSync(path, content), + common.platformTimeout(1000), + ); + await ran2.promise; + clearInterval(interval); + } else { + writeFileSync(path, content); + await setTimeout(common.platformTimeout(1000)); + await ran2.promise; + } + runs.push(currentRun); child.kill(); await once(child, 'exit'); @@ -89,10 +138,7 @@ async function testRunnerWatch({ assert.strictEqual(runs.length, 2); for (const run of runs) { - assert.match(run, /tests 1/); - assert.match(run, /pass 1/); - assert.match(run, /fail 0/); - assert.match(run, /cancelled 0/); + assertTestOutput(run, useRunApi); } }; @@ -102,21 +148,28 @@ async function testRunnerWatch({ currentRun = ''; const fileToRenamePath = tmpdir.resolve(fileToUpdate); const newFileNamePath = tmpdir.resolve(`test-renamed-${fileToUpdate}`); - renameSync(fileToRenamePath, newFileNamePath); - await setTimeout(common.platformTimeout(1000)); + + await performFileOperation( + () => renameSync(fileToRenamePath, newFileNamePath), + useRunApi, + ); await ran2.promise; + runs.push(currentRun); child.kill(); await once(child, 'exit'); assert.strictEqual(runs.length, 2); - for (const run of runs) { - assert.match(run, /tests 1/); - assert.match(run, /pass 1/); - assert.match(run, /fail 0/); - assert.match(run, /cancelled 0/); + const [firstRun, secondRun] = runs; + assertTestOutput(firstRun, useRunApi); + + if (action === 'rename2') { + assert.match(secondRun, /MODULE_NOT_FOUND/); + return; } + + assertTestOutput(secondRun, useRunApi); }; const testDelete = async () => { @@ -124,9 +177,24 @@ async function testRunnerWatch({ runs.push(currentRun); currentRun = ''; const fileToDeletePath = tmpdir.resolve(fileToUpdate); - unlinkSync(fileToDeletePath); - await setTimeout(common.platformTimeout(2000)); - ran2.resolve(); + + if (useRunApi) { + const { existsSync } = require('node:fs'); + const interval = setInterval(() => { + if (existsSync(fileToDeletePath)) { + unlinkSync(fileToDeletePath); + } else { + ran2.resolve(); + clearInterval(interval); + } + }, common.platformTimeout(1000)); + await ran2.promise; + } else { + unlinkSync(fileToDeletePath); + await setTimeout(common.platformTimeout(2000)); + ran2.resolve(); + } + runs.push(currentRun); child.kill(); await once(child, 'exit'); @@ -143,25 +211,29 @@ async function testRunnerWatch({ runs.push(currentRun); currentRun = ''; const newFilePath = tmpdir.resolve(fileToCreate); - writeFileSync(newFilePath, 'module.exports = {};'); - await setTimeout(common.platformTimeout(1000)); + + await performFileOperation( + () => writeFileSync(newFilePath, 'module.exports = {};'), + useRunApi, + ); await ran2.promise; + runs.push(currentRun); child.kill(); await once(child, 'exit'); for (const run of runs) { - assert.match(run, /tests 1/); - assert.match(run, /pass 1/); - assert.match(run, /fail 0/); - assert.match(run, /cancelled 0/); + assertTestOutput(run, false); } }; action === 'update' && await testUpdate(); action === 'rename' && await testRename(); + action === 'rename2' && await testRename(); action === 'delete' && await testDelete(); action === 'create' && await testCreate(); + + return runs; } @@ -170,4 +242,6 @@ module.exports = { skipIfNoWatchModeSignals, testRunnerWatch, refreshForTestRunnerWatch, + fixtureContent, + fixturePaths, }; diff --git a/test/parallel/parallel.status b/test/parallel/parallel.status index 8ac30c589926a4..5799eb27d24210 100644 --- a/test/parallel/parallel.status +++ b/test/parallel/parallel.status @@ -24,8 +24,6 @@ test-snapshot-incompatible: SKIP test-inspector-network-fetch: PASS, FLAKY # https://github.com/nodejs/node/issues/54808 test-async-context-frame: PASS, FLAKY -# https://github.com/nodejs/node/issues/54534 -test-runner-run-watch: PASS, FLAKY # https://github.com/nodejs/node/issues/59636 test-fs-cp-sync-error-on-exist: SKIP test-fs-cp-sync-symlink-points-to-dest-error: SKIP @@ -43,8 +41,6 @@ test-without-async-context-frame: PASS, FLAKY test-performance-function: PASS, FLAKY # https://github.com/nodejs/node/issues/54346 test-esm-loader-hooks-inspect-wait: PASS, FLAKY -# https://github.com/nodejs/node/issues/54534 -test-runner-run-watch: PASS, FLAKY [$system==linux && $arch==s390x] # https://github.com/nodejs/node/issues/58353 @@ -54,8 +50,6 @@ test-http2-debug: PASS, FLAKY # https://github.com/nodejs/node/issues/42741 test-http-server-headers-timeout-keepalive: PASS,FLAKY test-http-server-request-timeout-keepalive: PASS,FLAKY -# https://github.com/nodejs/node/issues/54534 -test-runner-run-watch: PASS, FLAKY # https://github.com/nodejs/node/issues/60050 test-cluster-dgram-1: SKIP @@ -85,8 +79,6 @@ test-esm-loader-hooks-inspect-wait: PASS, FLAKY test-fs-promises-watch-iterator: SKIP # https://github.com/nodejs/node/issues/50050 test-tick-processor-arguments: SKIP -# https://github.com/nodejs/node/issues/54534 -test-runner-run-watch: PASS, FLAKY [$system==freebsd] # https://github.com/nodejs/node/issues/54346 diff --git a/test/parallel/test-runner-run-watch.mjs b/test/parallel/test-runner-run-watch.mjs deleted file mode 100644 index c0c5c0b676108e..00000000000000 --- a/test/parallel/test-runner-run-watch.mjs +++ /dev/null @@ -1,400 +0,0 @@ -import * as common from '../common/index.mjs'; -import { describe, it, beforeEach, run } from 'node:test'; -import assert from 'node:assert'; -import { spawn } from 'node:child_process'; -import { once } from 'node:events'; -import { writeFileSync, renameSync, unlinkSync, existsSync } from 'node:fs'; -import tmpdir from '../common/tmpdir.js'; -import { join } from 'node:path'; - -if (common.isIBMi) - common.skip('IBMi does not support `fs.watch()`'); - -if (common.isAIX) - common.skip('folder watch capability is limited in AIX.'); - -// 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, - fileToCreate, - runnerCwd, - isolation - } -) { - const ran1 = Promise.withResolvers(); - const ran2 = Promise.withResolvers(); - const args = [runner]; - if (file) args.push('--file', file); - if (runnerCwd) args.push('--cwd', runnerCwd); - if (isolation) args.push('--isolation', isolation); - 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; - runs.push(currentRun); - currentRun = ''; - 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(); - await once(child, 'exit'); - - assert.strictEqual(runs.length, 2); - - 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; - runs.push(currentRun); - currentRun = ''; - const fileToRenamePath = tmpdir.resolve(fileToUpdate); - const newFileNamePath = tmpdir.resolve(`test-renamed-${fileToUpdate}`); - const interval = setInterval(() => { - renameSync(fileToRenamePath, newFileNamePath); - clearInterval(interval); - }, common.platformTimeout(1000)); - await ran2.promise; - runs.push(currentRun); - child.kill(); - await once(child, 'exit'); - - assert.strictEqual(runs.length, 2); - - const [firstRun, secondRun] = runs; - assert.match(firstRun, /tests 1/); - assert.match(firstRun, /pass 1/); - assert.match(firstRun, /fail 0/); - assert.match(firstRun, /cancelled 0/); - assert.doesNotMatch(firstRun, /run\(\) is being called recursively/); - - if (action === 'rename2') { - assert.match(secondRun, /MODULE_NOT_FOUND/); - return; - } - - assert.match(secondRun, /tests 1/); - assert.match(secondRun, /pass 1/); - assert.match(secondRun, /fail 0/); - assert.match(secondRun, /cancelled 0/); - assert.doesNotMatch(secondRun, /run\(\) is being called recursively/); - }; - - const testDelete = async () => { - await ran1.promise; - runs.push(currentRun); - currentRun = ''; - const fileToDeletePath = tmpdir.resolve(fileToUpdate); - const interval = setInterval(() => { - if (existsSync(fileToDeletePath)) { - unlinkSync(fileToDeletePath); - } else { - ran2.resolve(); - clearInterval(interval); - } - }, common.platformTimeout(1000)); - await ran2.promise; - runs.push(currentRun); - child.kill(); - await once(child, 'exit'); - - assert.strictEqual(runs.length, 2); - - for (const run of runs) { - assert.doesNotMatch(run, /MODULE_NOT_FOUND/); - } - }; - - const testCreate = async () => { - await ran1.promise; - runs.push(currentRun); - currentRun = ''; - const newFilePath = tmpdir.resolve(fileToCreate); - const interval = setInterval( - () => { - writeFileSync( - newFilePath, - 'module.exports = {};' - ); - clearInterval(interval); - }, - common.platformTimeout(1000) - ); - await ran2.promise; - runs.push(currentRun); - child.kill(); - await once(child, 'exit'); - - for (const run of runs) { - assert.match(run, /tests 1/); - assert.match(run, /pass 1/); - assert.match(run, /fail 0/); - assert.match(run, /cancelled 0/); - } - }; - - action === 'update' && await testUpdate(); - action === 'rename' && await testRename(); - action === 'rename2' && await testRename(); - action === 'delete' && await testDelete(); - action === 'create' && await testCreate(); - - return runs; -} - -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' - }); - }); - - it( - 'should run new tests when a new file is created in the watched directory', - async () => { - await testWatch({ action: 'create', fileToCreate: 'new-test-file.test.js' }); - }); - - // This test is flaky by its nature as it relies on the timing of 2 different runs - // considering the number of digits in the duration_ms is 9 - // the chances of having the same duration_ms are very low - // but not impossible - // In case of costant failures, consider increasing the number of tests - it('should recalculate the run duration on a watch restart', async () => { - const testRuns = await testWatch({ file: 'test.js', fileToUpdate: 'test.js' }); - const durations = testRuns.map((run) => { - const runDuration = run.match(/# duration_ms\s([\d.]+)/); - return runDuration; - }); - assert.notDeepStrictEqual(durations[0][1], durations[1][1]); - }); - - it('should emit test:watch:restarted when file is updated', async () => { - let alreadyDrained = false; - const events = []; - const testWatchRestarted = common.mustCall(1); - - const controller = new AbortController(); - const stream = run({ - cwd: tmpdir.path, - watch: true, - signal: controller.signal, - }).on('data', function({ type }) { - events.push(type); - if (type === 'test:watch:restarted') { - testWatchRestarted(); - } - if (type === 'test:watch:drained') { - if (alreadyDrained) { - controller.abort(); - } - alreadyDrained = true; - } - }); - - await once(stream, 'test:watch:drained'); - - writeFileSync(join(tmpdir.path, 'test.js'), fixtureContent['test.js']); - - // eslint-disable-next-line no-unused-vars - for await (const _ of stream); - - assert.partialDeepStrictEqual(events, [ - 'test:watch:drained', - 'test:watch:restarted', - 'test:watch:drained', - ]); - }); - - it('should not emit test:watch:restarted since watch mode is disabled', async () => { - const stream = run({ - cwd: tmpdir.path, - watch: false, - }); - - stream.on('test:watch:restarted', common.mustNotCall()); - writeFileSync(join(tmpdir.path, 'test.js'), fixtureContent['test.js']); - - // eslint-disable-next-line no-unused-vars - for await (const _ of stream); - }); - - describe('test runner watch mode with different cwd', () => { - it( - 'should execute run using a different cwd for the runner than the process cwd', - async () => { - await testWatch( - { - fileToUpdate: 'test.js', - action: 'rename', - cwd: import.meta.dirname, - runnerCwd: tmpdir.path - } - ); - }); - - it( - 'should execute run using a different cwd for the runner than the process cwd with isolation process', - async () => { - await testWatch( - { - fileToUpdate: 'test.js', - action: 'rename', - cwd: import.meta.dirname, - runnerCwd: tmpdir.path, - isolation: 'process' - } - ); - }); - - it('should run with different cwd while in watch mode', async () => { - const controller = new AbortController(); - const stream = run({ - cwd: tmpdir.path, - watch: true, - signal: controller.signal, - }).on('data', function({ type }) { - if (type === 'test:watch:drained') { - stream.removeAllListeners('test:fail'); - stream.removeAllListeners('test:pass'); - controller.abort(); - } - }); - - stream.on('test:fail', common.mustNotCall()); - stream.on('test:pass', common.mustCall(1)); - // eslint-disable-next-line no-unused-vars - for await (const _ of stream); - }); - - it('should run with different cwd while in watch mode and isolation "none"', async () => { - const controller = new AbortController(); - const stream = run({ - cwd: tmpdir.path, - watch: true, - signal: controller.signal, - isolation: 'none', - }).on('data', function({ type }) { - if (type === 'test:watch:drained') { - stream.removeAllListeners('test:fail'); - stream.removeAllListeners('test:pass'); - controller.abort(); - } - }); - - stream.on('test:fail', common.mustNotCall()); - stream.on('test:pass', common.mustCall(1)); - // eslint-disable-next-line no-unused-vars - for await (const _ of stream); - }); - - it('should run with different cwd while in watch mode and isolation "process"', async () => { - const controller = new AbortController(); - const stream = run({ - cwd: tmpdir.path, - watch: true, - signal: controller.signal, - isolation: 'process', - }).on('data', function({ type }) { - if (type === 'test:watch:drained') { - stream.removeAllListeners('test:fail'); - stream.removeAllListeners('test:pass'); - controller.abort(); - } - }); - - stream.on('test:fail', common.mustNotCall()); - stream.on('test:pass', common.mustCall(1)); - // eslint-disable-next-line no-unused-vars - for await (const _ of stream); - }); - }); -}); diff --git a/test/test-runner/test-run-watch-create.mjs b/test/test-runner/test-run-watch-create.mjs new file mode 100644 index 00000000000000..57f44a4cfe5675 --- /dev/null +++ b/test/test-runner/test-run-watch-create.mjs @@ -0,0 +1,8 @@ +// Test run({ watch: true }) runs new tests when a new file is created in the watched directory +import '../common/index.mjs'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ action: 'create', fileToCreate: 'new-test-file.test.js', useRunApi: true }); diff --git a/test/test-runner/test-run-watch-cwd-isolation-none.mjs b/test/test-runner/test-run-watch-cwd-isolation-none.mjs new file mode 100644 index 00000000000000..ce65bfb5e4162c --- /dev/null +++ b/test/test-runner/test-run-watch-cwd-isolation-none.mjs @@ -0,0 +1,27 @@ +// Test run({ watch: true, cwd, isolation: 'none' }) runs with different cwd while in watch mode and isolation none +import * as common from '../common/index.mjs'; +import { run } from 'node:test'; +import tmpdir from '../common/tmpdir.js'; +import { refreshForTestRunnerWatch, skipIfNoWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +const controller = new AbortController(); +const stream = run({ + cwd: tmpdir.path, + watch: true, + signal: controller.signal, + isolation: 'none', +}).on('data', function({ type }) { + if (type === 'test:watch:drained') { + stream.removeAllListeners('test:fail'); + stream.removeAllListeners('test:pass'); + controller.abort(); + } +}); + +stream.on('test:fail', common.mustNotCall()); +stream.on('test:pass', common.mustCall(1)); +// eslint-disable-next-line no-unused-vars +for await (const _ of stream); diff --git a/test/test-runner/test-run-watch-cwd-isolation-process.mjs b/test/test-runner/test-run-watch-cwd-isolation-process.mjs new file mode 100644 index 00000000000000..126459545a790a --- /dev/null +++ b/test/test-runner/test-run-watch-cwd-isolation-process.mjs @@ -0,0 +1,38 @@ +// Test run({ watch: true, cwd, isolation: 'process' }) runs with different +// cwd while in watch mode and isolation process +import * as common from '../common/index.mjs'; +import { run } from 'node:test'; +import { writeFileSync, readFileSync } from 'node:fs'; +import tmpdir from '../common/tmpdir.js'; +import { join } from 'node:path'; +import fixtures from '../common/fixtures.js'; +import { skipIfNoWatch } from '../common/watch.js'; + +skipIfNoWatch(); + +tmpdir.refresh(); + +const testJs = fixtures.path('test-runner-watch', 'test.js'); +const testJsContent = readFileSync(testJs, 'utf8'); +writeFileSync(join(tmpdir.path, 'test.js'), testJsContent); +writeFileSync(join(tmpdir.path, 'dependency.js'), 'module.exports = {};'); +writeFileSync(join(tmpdir.path, 'dependency.mjs'), 'export const a = 1;'); + +const controller = new AbortController(); +const stream = run({ + cwd: tmpdir.path, + watch: true, + signal: controller.signal, + isolation: 'process', +}).on('data', function({ type }) { + if (type === 'test:watch:drained') { + stream.removeAllListeners('test:fail'); + stream.removeAllListeners('test:pass'); + controller.abort(); + } +}); + +stream.on('test:fail', common.mustNotCall()); +stream.on('test:pass', common.mustCall(1)); +// eslint-disable-next-line no-unused-vars +for await (const _ of stream); diff --git a/test/test-runner/test-run-watch-cwd.mjs b/test/test-runner/test-run-watch-cwd.mjs new file mode 100644 index 00000000000000..b3faa65399bba6 --- /dev/null +++ b/test/test-runner/test-run-watch-cwd.mjs @@ -0,0 +1,26 @@ +// Test run({ watch: true, cwd }) runs with different cwd while in watch mode +import * as common from '../common/index.mjs'; +import { run } from 'node:test'; +import tmpdir from '../common/tmpdir.js'; +import { refreshForTestRunnerWatch, skipIfNoWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +const controller = new AbortController(); +const stream = run({ + cwd: tmpdir.path, + watch: true, + signal: controller.signal, +}).on('data', function({ type }) { + if (type === 'test:watch:drained') { + stream.removeAllListeners('test:fail'); + stream.removeAllListeners('test:pass'); + controller.abort(); + } +}); + +stream.on('test:fail', common.mustNotCall()); +stream.on('test:pass', common.mustCall(1)); +// eslint-disable-next-line no-unused-vars +for await (const _ of stream); diff --git a/test/test-runner/test-run-watch-delete.mjs b/test/test-runner/test-run-watch-delete.mjs new file mode 100644 index 00000000000000..a8a51a4ef7c7e7 --- /dev/null +++ b/test/test-runner/test-run-watch-delete.mjs @@ -0,0 +1,8 @@ +// Test run({ watch: true }) does not throw when deleting a watched test file +import '../common/index.mjs'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ fileToUpdate: 'test.js', action: 'delete', useRunApi: true }); diff --git a/test/test-runner/test-run-watch-dependency-repeatedly.mjs b/test/test-runner/test-run-watch-dependency-repeatedly.mjs new file mode 100644 index 00000000000000..b0a45d206f87ac --- /dev/null +++ b/test/test-runner/test-run-watch-dependency-repeatedly.mjs @@ -0,0 +1,8 @@ +// Test run({ watch: true }) runs tests with dependency repeatedly +import '../common/index.mjs'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ file: 'test.js', fileToUpdate: 'dependency.js', useRunApi: true }); diff --git a/test/test-runner/test-run-watch-different-cwd-dependency.mjs b/test/test-runner/test-run-watch-different-cwd-dependency.mjs new file mode 100644 index 00000000000000..b03555681769ef --- /dev/null +++ b/test/test-runner/test-run-watch-different-cwd-dependency.mjs @@ -0,0 +1,16 @@ +// Test run({ watch: true }) runs tests with dependency repeatedly in a different cwd +import '../common/index.mjs'; +import { join } from 'node:path'; +import tmpdir from '../common/tmpdir.js'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ + file: join(tmpdir.path, 'test.js'), + fileToUpdate: 'dependency.js', + cwd: import.meta.dirname, + action: 'rename2', + useRunApi: true, +}); diff --git a/test/test-runner/test-run-watch-different-cwd-rename.mjs b/test/test-runner/test-run-watch-different-cwd-rename.mjs new file mode 100644 index 00000000000000..ce059a4c54841a --- /dev/null +++ b/test/test-runner/test-run-watch-different-cwd-rename.mjs @@ -0,0 +1,16 @@ +// Test run({ watch: true }) handles renames in a different cwd +import '../common/index.mjs'; +import { join } from 'node:path'; +import tmpdir from '../common/tmpdir.js'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ + file: join(tmpdir.path, 'test.js'), + fileToUpdate: 'test.js', + cwd: import.meta.dirname, + action: 'rename2', + useRunApi: true, +}); diff --git a/test/test-runner/test-run-watch-different-runner-cwd-isolation-process.mjs b/test/test-runner/test-run-watch-different-runner-cwd-isolation-process.mjs new file mode 100644 index 00000000000000..504a95d5dbb639 --- /dev/null +++ b/test/test-runner/test-run-watch-different-runner-cwd-isolation-process.mjs @@ -0,0 +1,17 @@ +// Test run({ watch: true, cwd, isolation: 'process' }) executes using a +// different cwd for the runner than the process cwd with isolation process +import '../common/index.mjs'; +import tmpdir from '../common/tmpdir.js'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ + fileToUpdate: 'test.js', + action: 'rename', + cwd: import.meta.dirname, + runnerCwd: tmpdir.path, + isolation: 'process', + useRunApi: true, +}); diff --git a/test/test-runner/test-run-watch-different-runner-cwd.mjs b/test/test-runner/test-run-watch-different-runner-cwd.mjs new file mode 100644 index 00000000000000..868278f5ce8b7b --- /dev/null +++ b/test/test-runner/test-run-watch-different-runner-cwd.mjs @@ -0,0 +1,15 @@ +// Test run({ watch: true, cwd }) executes using a different cwd for the runner than the process cwd +import '../common/index.mjs'; +import tmpdir from '../common/tmpdir.js'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ + fileToUpdate: 'test.js', + action: 'rename', + cwd: import.meta.dirname, + runnerCwd: tmpdir.path, + useRunApi: true, +}); diff --git a/test/test-runner/test-run-watch-emit-restarted.mjs b/test/test-runner/test-run-watch-emit-restarted.mjs new file mode 100644 index 00000000000000..200231c6904303 --- /dev/null +++ b/test/test-runner/test-run-watch-emit-restarted.mjs @@ -0,0 +1,47 @@ +// Test run({ watch: true }) emits test:watch:restarted when file is updated +import * as common from '../common/index.mjs'; +import { run } from 'node:test'; +import assert from 'node:assert'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { once } from 'node:events'; +import tmpdir from '../common/tmpdir.js'; +import { refreshForTestRunnerWatch, skipIfNoWatch, fixtureContent } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +let alreadyDrained = false; +const events = []; +const testWatchRestarted = common.mustCall(1); + +const controller = new AbortController(); +const stream = run({ + cwd: tmpdir.path, + watch: true, + signal: controller.signal, +}).on('data', function({ type }) { + events.push(type); + if (type === 'test:watch:restarted') { + testWatchRestarted(); + } + if (type === 'test:watch:drained') { + if (alreadyDrained) { + controller.abort(); + } + alreadyDrained = true; + } +}); + +await once(stream, 'test:watch:drained'); + +writeFileSync(join(tmpdir.path, 'test.js'), fixtureContent['test.js']); + +// eslint-disable-next-line no-unused-vars +for await (const _ of stream); + +assert.partialDeepStrictEqual(events, [ + 'test:watch:drained', + 'test:watch:restarted', + 'test:watch:drained', +]); diff --git a/test/test-runner/test-run-watch-esm-dependency.mjs b/test/test-runner/test-run-watch-esm-dependency.mjs new file mode 100644 index 00000000000000..8a7d41208cb796 --- /dev/null +++ b/test/test-runner/test-run-watch-esm-dependency.mjs @@ -0,0 +1,8 @@ +// Test run({ watch: true }) runs tests with ESM dependency +import '../common/index.mjs'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ file: 'test.js', fileToUpdate: 'dependency.mjs', useRunApi: true }); diff --git a/test/test-runner/test-run-watch-no-emit-restarted-disabled.mjs b/test/test-runner/test-run-watch-no-emit-restarted-disabled.mjs new file mode 100644 index 00000000000000..7f6f14a48fba3b --- /dev/null +++ b/test/test-runner/test-run-watch-no-emit-restarted-disabled.mjs @@ -0,0 +1,21 @@ +// Test run({ watch: false }) does not emit test:watch:restarted +import * as common from '../common/index.mjs'; +import { run } from 'node:test'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import tmpdir from '../common/tmpdir.js'; +import { refreshForTestRunnerWatch, skipIfNoWatch, fixtureContent } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +const stream = run({ + cwd: tmpdir.path, + watch: false, +}); + +stream.on('test:watch:restarted', common.mustNotCall()); +writeFileSync(join(tmpdir.path, 'test.js'), fixtureContent['test.js']); + +// eslint-disable-next-line no-unused-vars +for await (const _ of stream); diff --git a/test/test-runner/test-run-watch-rename.mjs b/test/test-runner/test-run-watch-rename.mjs new file mode 100644 index 00000000000000..aecb712d47a237 --- /dev/null +++ b/test/test-runner/test-run-watch-rename.mjs @@ -0,0 +1,8 @@ +// Test run({ watch: true }) supports a watched test file rename +import '../common/index.mjs'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ fileToUpdate: 'test.js', action: 'rename', useRunApi: true }); diff --git a/test/test-runner/test-run-watch-repeatedly.mjs b/test/test-runner/test-run-watch-repeatedly.mjs new file mode 100644 index 00000000000000..2dcc4c63fca9d5 --- /dev/null +++ b/test/test-runner/test-run-watch-repeatedly.mjs @@ -0,0 +1,8 @@ +// Test run({ watch: true }) runs tests repeatedly +import '../common/index.mjs'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ file: 'test.js', fileToUpdate: 'test.js', useRunApi: true }); diff --git a/test/test-runner/test-run-watch-run-duration.mjs b/test/test-runner/test-run-watch-run-duration.mjs new file mode 100644 index 00000000000000..df4b2c73384f81 --- /dev/null +++ b/test/test-runner/test-run-watch-run-duration.mjs @@ -0,0 +1,19 @@ +// Test run({ watch: true }) recalculates the run duration on a watch restart +// This test is flaky by its nature as it relies on the timing of 2 different runs +// considering the number of digits in the duration_ms is 9 +// the chances of having the same duration_ms are very low +// but not impossible +// In case of costant failures, consider increasing the number of tests +import '../common/index.mjs'; +import assert from 'node:assert'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +const testRuns = await testRunnerWatch({ file: 'test.js', fileToUpdate: 'test.js', useRunApi: true }); +const durations = testRuns.map((run) => { + const runDuration = run.match(/# duration_ms\s([\d.]+)/); + return runDuration; +}); +assert.notDeepStrictEqual(durations[0][1], durations[1][1]); diff --git a/test/test-runner/test-run-watch-without-file.mjs b/test/test-runner/test-run-watch-without-file.mjs new file mode 100644 index 00000000000000..eccef33f68d8f7 --- /dev/null +++ b/test/test-runner/test-run-watch-without-file.mjs @@ -0,0 +1,8 @@ +// Test run({ watch: true }) supports running tests without a file +import '../common/index.mjs'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ fileToUpdate: 'test.js', useRunApi: true });