diff --git a/test/common/watch.js b/test/common/watch.js index 068790780e1470..1831939b5a99e5 100644 --- a/test/common/watch.js +++ b/test/common/watch.js @@ -1,7 +1,24 @@ 'use strict'; const common = require('./index.js'); +const tmpdir = require('./tmpdir.js'); +const fixtures = require('./fixtures.js'); +const { writeFileSync, readdirSync, readFileSync, renameSync, unlinkSync } = require('node:fs'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const assert = require('node:assert'); +const { setTimeout } = require('node:timers/promises'); -exports.skipIfNoWatchModeSignals = function() { +function skipIfNoWatch() { + if (common.isIBMi) { + common.skip('IBMi does not support `fs.watch()`'); + } + + if (common.isAIX) { + common.skip('folder watch capability is limited in AIX.'); + } +} + +function skipIfNoWatchModeSignals() { if (common.isWindows) { common.skip('no signals on Windows'); } @@ -13,4 +30,144 @@ exports.skipIfNoWatchModeSignals = function() { if (common.isAIX) { common.skip('folder watch capability is limited in AIX.'); } +} + +const fixturePaths = {}; +const fixtureContent = {}; + +function refreshForTestRunnerWatch() { + tmpdir.refresh(); + const files = readdirSync(fixtures.path('test-runner-watch')); + for (const file of files) { + const src = fixtures.path('test-runner-watch', file); + const dest = tmpdir.resolve(file); + fixturePaths[file] = dest; + fixtureContent[file] = readFileSync(src, 'utf8'); + writeFileSync(dest, fixtureContent[file]); + } +} + +async function testRunnerWatch({ + fileToUpdate, + file, + action = 'update', + fileToCreate, + isolation, +}) { + 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 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]; + writeFileSync(path, content); + await setTimeout(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.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}`); + renameSync(fileToRenamePath, newFileNamePath); + await setTimeout(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.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; + runs.push(currentRun); + currentRun = ''; + const fileToDeletePath = tmpdir.resolve(fileToUpdate); + unlinkSync(fileToDeletePath); + await setTimeout(common.platformTimeout(2000)); + ran2.resolve(); + 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); + writeFileSync(newFilePath, 'module.exports = {};'); + await setTimeout(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 === 'delete' && await testDelete(); + action === 'create' && await testCreate(); +} + + +module.exports = { + skipIfNoWatch, + skipIfNoWatchModeSignals, + testRunnerWatch, + refreshForTestRunnerWatch, }; diff --git a/test/fixtures/test-runner-watch/dependency.js b/test/fixtures/test-runner-watch/dependency.js new file mode 100644 index 00000000000000..a0995453769c8a --- /dev/null +++ b/test/fixtures/test-runner-watch/dependency.js @@ -0,0 +1 @@ +module.exports = {}; \ No newline at end of file diff --git a/test/fixtures/test-runner-watch/dependency.mjs b/test/fixtures/test-runner-watch/dependency.mjs new file mode 100644 index 00000000000000..d852a426b2bf36 --- /dev/null +++ b/test/fixtures/test-runner-watch/dependency.mjs @@ -0,0 +1 @@ +export const a = 1; \ No newline at end of file diff --git a/test/fixtures/test-runner-watch/test.js b/test/fixtures/test-runner-watch/test.js new file mode 100644 index 00000000000000..85606b1535b286 --- /dev/null +++ b/test/fixtures/test-runner-watch/test.js @@ -0,0 +1,5 @@ +const test = require('node:test'); +require('./dependency.js'); +import('./dependency.mjs'); +import('data:text/javascript,'); +test('test has ran'); \ No newline at end of file diff --git a/test/parallel/parallel.status b/test/parallel/parallel.status index 5cb15393e42d4f..bd4ad779d69382 100644 --- a/test/parallel/parallel.status +++ b/test/parallel/parallel.status @@ -50,10 +50,6 @@ test-runner-run-watch: PASS, FLAKY # https://github.com/nodejs/node/issues/58353 test-http2-debug: PASS, FLAKY -[$system==linux || $system==win32] -# https://github.com/nodejs/node/issues/49605 -test-runner-watch-mode: PASS,FLAKY - [$system==macos] # https://github.com/nodejs/node/issues/42741 test-http-server-headers-timeout-keepalive: PASS,FLAKY diff --git a/test/parallel/test-runner-watch-mode.mjs b/test/parallel/test-runner-watch-mode.mjs deleted file mode 100644 index d23830cbae1704..00000000000000 --- a/test/parallel/test-runner-watch-mode.mjs +++ /dev/null @@ -1,193 +0,0 @@ -import * as common from '../common/index.mjs'; -import { describe, it, beforeEach } from 'node:test'; -import { once } from 'node:events'; -import assert from 'node:assert'; -import { spawn } from 'node:child_process'; -import { writeFileSync, renameSync, unlinkSync } from 'node:fs'; -import { setTimeout } from 'node:timers/promises'; -import tmpdir from '../common/tmpdir.js'; - -if (common.isIBMi) - common.skip('IBMi does not support `fs.watch()`'); - -if (common.isAIX) - common.skip('folder watch capability is limited in AIX.'); - -let fixturePaths; - -// 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');`, -}; - -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)); -} - -async function testWatch({ - fileToUpdate, - file, - action = 'update', - fileToCreate, - isolation, -}) { - 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 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]; - writeFileSync(path, content); - await setTimeout(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.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}`); - renameSync(fileToRenamePath, newFileNamePath); - await setTimeout(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.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; - runs.push(currentRun); - currentRun = ''; - const fileToDeletePath = tmpdir.resolve(fileToUpdate); - unlinkSync(fileToDeletePath); - await setTimeout(common.platformTimeout(2000)); - ran2.resolve(); - 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); - writeFileSync(newFilePath, 'module.exports = {};'); - await setTimeout(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 === 'delete' && await testDelete(); - action === 'create' && await testCreate(); -} - -describe('test runner watch mode', () => { - beforeEach(refresh); - for (const isolation of ['none', 'process']) { - describe(`isolation: ${isolation}`, () => { - it('should run tests repeatedly', async () => { - await testWatch({ file: 'test.js', fileToUpdate: 'test.js', isolation }); - }); - - it('should run tests with dependency repeatedly', async () => { - await testWatch({ file: 'test.js', fileToUpdate: 'dependency.js', isolation }); - }); - - it('should run tests with ESM dependency', async () => { - await testWatch({ file: 'test.js', fileToUpdate: 'dependency.mjs', isolation }); - }); - - it('should support running tests without a file', async () => { - await testWatch({ fileToUpdate: 'test.js', isolation }); - }); - - it('should support a watched test file rename', async () => { - await testWatch({ fileToUpdate: 'test.js', action: 'rename', isolation }); - }); - - it('should not throw when delete a watched test file', async () => { - await testWatch({ fileToUpdate: 'test.js', action: 'delete', isolation }); - }); - - it('should run new tests when a new file is created in the watched directory', { - todo: isolation === 'none' ? - 'This test is failing when isolation is set to none and must be fixed' : - undefined, - }, async () => { - await testWatch({ action: 'create', fileToCreate: 'new-test-file.test.js', isolation }); - }); - }); - } -}); diff --git a/test/test-runner/test-runner.status b/test/test-runner/test-runner.status index 65ae96eb9e3726..e8b4ff2d32248c 100644 --- a/test/test-runner/test-runner.status +++ b/test/test-runner/test-runner.status @@ -5,3 +5,7 @@ prefix test-runner # sample-test : PASS,FLAKY [true] # This section applies to all platforms + +# https://github.com/nodejs/node/pull/54888#issuecomment-2351128116 +# TODO(pmarchini): This test is failing when isolation is set to none and must be fixed. +test-watch-create-isolation-none: SKIP diff --git a/test/test-runner/test-watch-create-isolation-none.mjs b/test/test-runner/test-watch-create-isolation-none.mjs new file mode 100644 index 00000000000000..c7035ea63afd2c --- /dev/null +++ b/test/test-runner/test-watch-create-isolation-none.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=none runs 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', isolation: 'none' }); diff --git a/test/test-runner/test-watch-create-isolation-process.mjs b/test/test-runner/test-watch-create-isolation-process.mjs new file mode 100644 index 00000000000000..d1eaa9667d2e32 --- /dev/null +++ b/test/test-runner/test-watch-create-isolation-process.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=process runs 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', isolation: 'process' }); diff --git a/test/test-runner/test-watch-delete-isolation-none.mjs b/test/test-runner/test-watch-delete-isolation-none.mjs new file mode 100644 index 00000000000000..210d2a87f82666 --- /dev/null +++ b/test/test-runner/test-watch-delete-isolation-none.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=none 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', isolation: 'none' }); diff --git a/test/test-runner/test-watch-delete-isolation-process.mjs b/test/test-runner/test-watch-delete-isolation-process.mjs new file mode 100644 index 00000000000000..d09b84caf53ea7 --- /dev/null +++ b/test/test-runner/test-watch-delete-isolation-process.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=process 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', isolation: 'process' }); diff --git a/test/test-runner/test-watch-dependency-isolation-none.mjs b/test/test-runner/test-watch-dependency-isolation-none.mjs new file mode 100644 index 00000000000000..7171a334ce03bd --- /dev/null +++ b/test/test-runner/test-watch-dependency-isolation-none.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=none 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', isolation: 'none' }); diff --git a/test/test-runner/test-watch-dependency-isolation-process.mjs b/test/test-runner/test-watch-dependency-isolation-process.mjs new file mode 100644 index 00000000000000..25b8d00f3b0283 --- /dev/null +++ b/test/test-runner/test-watch-dependency-isolation-process.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=process 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', isolation: 'process' }); diff --git a/test/test-runner/test-watch-esm-dependency-isolation-none.mjs b/test/test-runner/test-watch-esm-dependency-isolation-none.mjs new file mode 100644 index 00000000000000..d066bd90d74ce5 --- /dev/null +++ b/test/test-runner/test-watch-esm-dependency-isolation-none.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=none 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', isolation: 'none' }); diff --git a/test/test-runner/test-watch-esm-dependency-isolation-process.mjs b/test/test-runner/test-watch-esm-dependency-isolation-process.mjs new file mode 100644 index 00000000000000..7e0ad48ece03d7 --- /dev/null +++ b/test/test-runner/test-watch-esm-dependency-isolation-process.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=process 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', isolation: 'process' }); diff --git a/test/test-runner/test-watch-no-file-isolation-none.mjs b/test/test-runner/test-watch-no-file-isolation-none.mjs new file mode 100644 index 00000000000000..ebf14d828a32c5 --- /dev/null +++ b/test/test-runner/test-watch-no-file-isolation-none.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=none runs tests without a file argument +import '../common/index.mjs'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ fileToUpdate: 'test.js', isolation: 'none' }); diff --git a/test/test-runner/test-watch-no-file-isolation-process.mjs b/test/test-runner/test-watch-no-file-isolation-process.mjs new file mode 100644 index 00000000000000..76991b37189894 --- /dev/null +++ b/test/test-runner/test-watch-no-file-isolation-process.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=process runs tests without a file argument +import '../common/index.mjs'; +import { skipIfNoWatch, refreshForTestRunnerWatch, testRunnerWatch } from '../common/watch.js'; + +skipIfNoWatch(); +refreshForTestRunnerWatch(); + +await testRunnerWatch({ fileToUpdate: 'test.js', isolation: 'process' }); diff --git a/test/test-runner/test-watch-rename-isolation-none.mjs b/test/test-runner/test-watch-rename-isolation-none.mjs new file mode 100644 index 00000000000000..d125b33ce83d84 --- /dev/null +++ b/test/test-runner/test-watch-rename-isolation-none.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=none handles 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', isolation: 'none' }); diff --git a/test/test-runner/test-watch-rename-isolation-process.mjs b/test/test-runner/test-watch-rename-isolation-process.mjs new file mode 100644 index 00000000000000..e784831a33bfa0 --- /dev/null +++ b/test/test-runner/test-watch-rename-isolation-process.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=process handles 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', isolation: 'process' }); diff --git a/test/test-runner/test-watch-run-repeatedly-isolation-none.mjs b/test/test-runner/test-watch-run-repeatedly-isolation-none.mjs new file mode 100644 index 00000000000000..763f5a3be05f14 --- /dev/null +++ b/test/test-runner/test-watch-run-repeatedly-isolation-none.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=none 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', isolation: 'none' }); diff --git a/test/test-runner/test-watch-run-repeatedly-isolation-process.mjs b/test/test-runner/test-watch-run-repeatedly-isolation-process.mjs new file mode 100644 index 00000000000000..07719cfd77710f --- /dev/null +++ b/test/test-runner/test-watch-run-repeatedly-isolation-process.mjs @@ -0,0 +1,8 @@ +// Test --test --watch --test-isolation=process 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', isolation: 'process' });