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 });