diff --git a/doc/api/cli.md b/doc/api/cli.md index c3a10e830a60c7..61a047e5ac159f 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -2452,6 +2452,19 @@ added: Configures the test runner to exit the process once all known tests have finished executing even if the event loop would otherwise remain active. +### `--test-global-setup=module` + + + +> Stability: 1.0 - Early development + +Specify a module that will be evaluated before all tests are executed and +can be used to setup global state or fixtures for tests. + +See the documentation on [global setup and teardown][] for more details. + ### `--test-isolation=mode` + +> Stability: 1.0 - Early development + +The test runner supports specifying a module that will be evaluated before all tests are executed and +can be used to setup global state or fixtures for tests. This is useful for preparing resources or setting up +shared state that is required by multiple tests. + +This module can export any of the following: + +* A `globalSetup` function which runs once before all tests start +* A `globalTeardown` function which runs once after all tests complete + +The module is specified using the `--test-global-setup` flag when running tests from the command line. + +```cjs +// setup-module.js +async function globalSetup() { + // Setup shared resources, state, or environment + console.log('Global setup executed'); + // Run servers, create files, prepare databases, etc. +} + +async function globalTeardown() { + // Clean up resources, state, or environment + console.log('Global teardown executed'); + // Close servers, remove files, disconnect from databases, etc. +} + +module.exports = { globalSetup, globalTeardown }; +``` + +```mjs +// setup-module.mjs +export async function globalSetup() { + // Setup shared resources, state, or environment + console.log('Global setup executed'); + // Run servers, create files, prepare databases, etc. +} + +export async function globalTeardown() { + // Clean up resources, state, or environment + console.log('Global teardown executed'); + // Close servers, remove files, disconnect from databases, etc. +} +``` + +If the global setup function throws an error, no tests will be run and the process will exit with a non-zero exit code. +The global teardown function will not be called in this case. + ## Running tests from the command line The Node.js test runner can be invoked from the command line by passing the diff --git a/doc/node-config-schema.json b/doc/node-config-schema.json index ca10429ef578ed..1648957888096d 100644 --- a/doc/node-config-schema.json +++ b/doc/node-config-schema.json @@ -392,6 +392,9 @@ "test-coverage-lines": { "type": "number" }, + "test-global-setup": { + "type": "string" + }, "test-isolation": { "type": "string" }, diff --git a/doc/node.1 b/doc/node.1 index 9ed99be3b7771e..de6a3f7aa5f6fd 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -464,6 +464,9 @@ Require a minimum threshold for line coverage (0 - 100). Configures the test runner to exit the process once all known tests have finished executing even if the event loop would otherwise remain active. . +.It Fl -test-global-setup +Specifies a module containing global setup and teardown functions for the test runner. +. .It Fl -test-isolation Ns = Ns Ar mode Configures the type of test isolation used in the test runner. . diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js index ab8d1c24ae6d52..9774326a4255c7 100644 --- a/lib/internal/test_runner/harness.js +++ b/lib/internal/test_runner/harness.js @@ -25,21 +25,22 @@ const { parseCommandLine, reporterScope, shouldColorizeTestFiles, + setupGlobalSetupTeardownFunctions, } = require('internal/test_runner/utils'); const { queueMicrotask } = require('internal/process/task_queues'); const { TIMEOUT_MAX } = require('internal/timers'); const { clearInterval, setInterval } = require('timers'); const { bigint: hrtime } = process.hrtime; -const resolvedPromise = PromiseResolve(); const testResources = new SafeMap(); let globalRoot; +let globalSetupExecuted = false; testResources.set(reporterScope.asyncId(), reporterScope); function createTestTree(rootTestOptions, globalOptions) { const buildPhaseDeferred = PromiseWithResolvers(); const isFilteringByName = globalOptions.testNamePatterns || - globalOptions.testSkipPatterns; + globalOptions.testSkipPatterns; const isFilteringByOnly = (globalOptions.isolation === 'process' || process.env.NODE_TEST_CONTEXT) ? globalOptions.only : true; const harness = { @@ -47,7 +48,6 @@ function createTestTree(rootTestOptions, globalOptions) { buildPromise: buildPhaseDeferred.promise, buildSuites: [], isWaitingForBuildPhase: false, - bootstrapPromise: resolvedPromise, watching: false, config: globalOptions, coverage: null, @@ -71,6 +71,21 @@ function createTestTree(rootTestOptions, globalOptions) { snapshotManager: null, isFilteringByName, isFilteringByOnly, + async runBootstrap() { + if (globalSetupExecuted) { + return PromiseResolve(); + } + globalSetupExecuted = true; + const globalSetupFunctions = await setupGlobalSetupTeardownFunctions( + globalOptions.globalSetupPath, + globalOptions.cwd, + ); + harness.globalTeardownFunction = globalSetupFunctions.globalTeardownFunction; + if (typeof globalSetupFunctions.globalSetupFunction === 'function') { + return globalSetupFunctions.globalSetupFunction(); + } + return PromiseResolve(); + }, async waitForBuildPhase() { if (harness.buildSuites.length > 0) { await SafePromiseAllReturnVoid(harness.buildSuites); @@ -81,6 +96,7 @@ function createTestTree(rootTestOptions, globalOptions) { }; harness.resetCounters(); + harness.bootstrapPromise = harness.runBootstrap(); globalRoot = new Test({ __proto__: null, ...rootTestOptions, @@ -232,6 +248,11 @@ function setupProcessState(root, globalOptions) { 'Promise resolution is still pending but the event loop has already resolved', kCancelledByParent)); + if (root.harness.globalTeardownFunction) { + await root.harness.globalTeardownFunction(); + root.harness.globalTeardownFunction = null; + } + hook.disable(); process.removeListener('uncaughtException', exceptionHandler); process.removeListener('unhandledRejection', rejectionHandler); @@ -278,7 +299,10 @@ function lazyBootstrapRoot() { process.exitCode = kGenericUserError; } }); - globalRoot.harness.bootstrapPromise = globalOptions.setup(globalRoot.reporter); + globalRoot.harness.bootstrapPromise = SafePromiseAllReturnVoid([ + globalRoot.harness.bootstrapPromise, + globalOptions.setup(globalRoot.reporter), + ]); } return globalRoot; } diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 98ffefb9cc3e8c..188559f2ae4c95 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -87,6 +87,7 @@ const { } = require('internal/test_runner/utils'); const { Glob } = require('internal/fs/glob'); const { once } = require('events'); +const { validatePath } = require('internal/fs/utils'); const { triggerUncaughtException, exitCodes: { kGenericUserError }, @@ -556,6 +557,7 @@ function run(options = kEmptyObject) { isolation = 'process', watch, setup, + globalSetupPath, only, globPatterns, coverage = false, @@ -665,6 +667,10 @@ function run(options = kEmptyObject) { validateStringArray(argv, 'options.argv'); validateStringArray(execArgv, 'options.execArgv'); + if (globalSetupPath != null) { + validatePath(globalSetupPath, 'options.globalSetupPath'); + } + const rootTestOptions = { __proto__: null, concurrency, timeout, signal }; const globalOptions = { __proto__: null, @@ -679,6 +685,7 @@ function run(options = kEmptyObject) { branchCoverage: branchCoverage, functionCoverage: functionCoverage, cwd, + globalSetupPath, }; const root = createTestTree(rootTestOptions, globalOptions); let testFiles = files ?? createTestFileList(globPatterns, cwd); @@ -751,7 +758,9 @@ function run(options = kEmptyObject) { const cascadedLoader = esmLoader.getOrInitializeCascadedLoader(); let topLevelTestCount = 0; - root.harness.bootstrapPromise = promise; + root.harness.bootstrapPromise = root.harness.bootstrapPromise ? + SafePromiseAllReturnVoid([root.harness.bootstrapPromise, promise]) : + promise; const userImports = getOptionValue('--import'); for (let i = 0; i < userImports.length; i++) { @@ -796,12 +805,15 @@ function run(options = kEmptyObject) { debug('beginning test execution'); root.entryFile = null; finishBootstrap(); - root.processPendingSubtests(); + return root.processPendingSubtests(); }; } } const runChain = async () => { + if (root.harness?.bootstrapPromise) { + await root.harness.bootstrapPromise; + } if (typeof setup === 'function') { await setup(root.reporter); } diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index 6cfe0fa8d9e88e..f7019db3359387 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -27,7 +27,7 @@ const { } = primordials; const { AsyncResource } = require('async_hooks'); -const { relative, sep } = require('path'); +const { relative, sep, resolve } = require('path'); const { createWriteStream } = require('fs'); const { pathToFileURL } = require('internal/url'); const { getOptionValue } = require('internal/options'); @@ -41,7 +41,12 @@ const { kIsNodeError, } = require('internal/errors'); const { compose } = require('stream'); -const { validateInteger } = require('internal/validators'); +const { + validateInteger, + validateFunction, +} = require('internal/validators'); +const { validatePath } = require('internal/fs/utils'); +const { kEmptyObject } = require('internal/util'); const coverageColors = { __proto__: null, @@ -199,6 +204,7 @@ function parseCommandLine() { const timeout = getOptionValue('--test-timeout') || Infinity; const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child'; const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8'; + let globalSetupPath; let concurrency; let coverageExcludeGlobs; let coverageIncludeGlobs; @@ -223,6 +229,7 @@ function parseCommandLine() { } else { destinations = getOptionValue('--test-reporter-destination'); reporters = getOptionValue('--test-reporter'); + globalSetupPath = getOptionValue('--test-global-setup'); if (reporters.length === 0 && destinations.length === 0) { ArrayPrototypePush(reporters, kDefaultReporter); } @@ -328,6 +335,7 @@ function parseCommandLine() { only, reporters, setup, + globalSetupPath, shard, sourceMaps, testNamePatterns, @@ -597,6 +605,27 @@ function getCoverageReport(pad, summary, symbol, color, table) { return report; } +async function setupGlobalSetupTeardownFunctions(globalSetupPath, cwd) { + let globalSetupFunction; + let globalTeardownFunction; + if (globalSetupPath) { + validatePath(globalSetupPath, 'options.globalSetupPath'); + const fileURL = pathToFileURL(resolve(cwd, globalSetupPath)); + const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); + const globalSetupModule = await cascadedLoader + .import(fileURL, pathToFileURL(cwd + sep).href, kEmptyObject); + if (globalSetupModule.globalSetup) { + validateFunction(globalSetupModule.globalSetup, 'globalSetupModule.globalSetup'); + globalSetupFunction = globalSetupModule.globalSetup; + } + if (globalSetupModule.globalTeardown) { + validateFunction(globalSetupModule.globalTeardown, 'globalSetupModule.globalTeardown'); + globalTeardownFunction = globalSetupModule.globalTeardown; + } + } + return { __proto__: null, globalSetupFunction, globalTeardownFunction }; +} + module.exports = { convertStringToRegExp, countCompletedTest, @@ -607,4 +636,5 @@ module.exports = { reporterScope, shouldColorizeTestFiles, getCoverageReport, + setupGlobalSetupTeardownFunctions, }; diff --git a/src/node_options.cc b/src/node_options.cc index 16cce0df0e2263..c0a3384735c258 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -761,6 +761,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "exclude files from coverage report that match this glob pattern", &EnvironmentOptions::coverage_exclude_pattern, kAllowedInEnvvar); + AddOption("--test-global-setup", + "specifies the path to the global setup file", + &EnvironmentOptions::test_global_setup_path, + kAllowedInEnvvar); AddOption("--test-udp-no-try-send", "", // For testing only. &EnvironmentOptions::test_udp_no_try_send); AddOption("--throw-deprecation", diff --git a/src/node_options.h b/src/node_options.h index baa615e310e17b..8c2025e849a856 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -197,6 +197,7 @@ class EnvironmentOptions : public Options { std::vector test_name_pattern; std::vector test_reporter; std::vector test_reporter_destination; + std::string test_global_setup_path; bool test_only = false; bool test_udp_no_try_send = false; std::string test_isolation = "process"; diff --git a/test/fixtures/test-runner/global-setup-teardown/another-test-file.js b/test/fixtures/test-runner/global-setup-teardown/another-test-file.js new file mode 100644 index 00000000000000..6d39ef2ec79aa8 --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/another-test-file.js @@ -0,0 +1,13 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); + +test('Another test that verifies setup flag existance', (t) => { + const setupFlagPath = process.env.SETUP_FLAG_PATH; + assert.ok(fs.existsSync(setupFlagPath), 'Setup flag file should exist'); + + const content = fs.readFileSync(setupFlagPath, 'utf8'); + assert.strictEqual(content, 'Setup was executed'); +}); diff --git a/test/fixtures/test-runner/global-setup-teardown/async-setup-teardown.js b/test/fixtures/test-runner/global-setup-teardown/async-setup-teardown.js new file mode 100644 index 00000000000000..266971cdf55cb4 --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/async-setup-teardown.js @@ -0,0 +1,26 @@ +'use strict'; + +const fs = require('node:fs'); +const { setTimeout } = require('node:timers/promises'); + +const asyncFlagPath = process.env.ASYNC_FLAG_PATH; + +async function globalSetup() { + console.log('Async setup starting'); + + await setTimeout(500); + + fs.writeFileSync(asyncFlagPath, 'Setup part'); + console.log('Async setup completed'); +} + +async function globalTeardown() { + console.log('Async teardown starting'); + + await setTimeout(100); + + fs.appendFileSync(asyncFlagPath, ', Teardown part'); + console.log('Async teardown completed'); +} + +module.exports = { globalSetup, globalTeardown }; diff --git a/test/fixtures/test-runner/global-setup-teardown/basic-setup-teardown.js b/test/fixtures/test-runner/global-setup-teardown/basic-setup-teardown.js new file mode 100644 index 00000000000000..a8a0bfe5ca5999 --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/basic-setup-teardown.js @@ -0,0 +1,20 @@ +'use strict'; + +const fs = require('node:fs'); + +// Path for temporary file to track execution +const setupFlagPath = process.env.SETUP_FLAG_PATH; +const teardownFlagPath = process.env.TEARDOWN_FLAG_PATH; + +async function globalSetup() { + console.log('Global setup executed'); + fs.writeFileSync(setupFlagPath, 'Setup was executed'); +} + +async function globalTeardown() { + console.log('Global teardown executed'); + fs.writeFileSync(teardownFlagPath, 'Teardown was executed'); + fs.rmSync(setupFlagPath, { force: true }); +} + +module.exports = { globalSetup, globalTeardown }; diff --git a/test/fixtures/test-runner/global-setup-teardown/basic-setup-teardown.mjs b/test/fixtures/test-runner/global-setup-teardown/basic-setup-teardown.mjs new file mode 100644 index 00000000000000..62785577c0811c --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/basic-setup-teardown.mjs @@ -0,0 +1,18 @@ +import * as fs from 'node:fs'; + +// Path for temporary file to track execution +const setupFlagPath = process.env.SETUP_FLAG_PATH; +const teardownFlagPath = process.env.TEARDOWN_FLAG_PATH; + +async function globalSetup() { + console.log('Global setup executed'); + fs.writeFileSync(setupFlagPath, 'Setup was executed'); +} + +async function globalTeardown() { + console.log('Global teardown executed'); + fs.writeFileSync(teardownFlagPath, 'Teardown was executed'); + fs.rmSync(setupFlagPath, { force: true }); +} + +export { globalSetup, globalTeardown }; diff --git a/test/fixtures/test-runner/global-setup-teardown/basic-setup-teardown.ts b/test/fixtures/test-runner/global-setup-teardown/basic-setup-teardown.ts new file mode 100644 index 00000000000000..abf3795d489a60 --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/basic-setup-teardown.ts @@ -0,0 +1,24 @@ +import * as fs from 'node:fs'; + +// Path for temporary file to track execution +const setupFlagPath = process.env.SETUP_FLAG_PATH; +const teardownFlagPath = process.env.TEARDOWN_FLAG_PATH; + +async function globalSetup(): Promise { + console.log('Global setup executed'); + if (setupFlagPath) { + fs.writeFileSync(setupFlagPath, 'Setup was executed'); + } +} + +async function globalTeardown(): Promise { + console.log('Global teardown executed'); + if (teardownFlagPath) { + fs.writeFileSync(teardownFlagPath, 'Teardown was executed'); + } + if (setupFlagPath) { + fs.rmSync(setupFlagPath, { force: true }); + } +} + +export { globalSetup, globalTeardown }; diff --git a/test/fixtures/test-runner/global-setup-teardown/error-in-setup.js b/test/fixtures/test-runner/global-setup-teardown/error-in-setup.js new file mode 100644 index 00000000000000..478e7604fc6a97 --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/error-in-setup.js @@ -0,0 +1,11 @@ +'use strict'; + +async function globalSetup() { + throw new Error('Deliberate error in global setup'); +} + +async function globalTeardown() { + console.log('Teardown should not run if setup fails'); +} + +module.exports = { globalSetup, globalTeardown }; diff --git a/test/fixtures/test-runner/global-setup-teardown/imported-module-with-test.mjs b/test/fixtures/test-runner/global-setup-teardown/imported-module-with-test.mjs new file mode 100644 index 00000000000000..51642ae1e2e55b --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/imported-module-with-test.mjs @@ -0,0 +1,4 @@ +import { test } from 'node:test'; + +test('Imported module Ok', () => {}); +test('Imported module Fail', () => { throw new Error('fail'); }); diff --git a/test/fixtures/test-runner/global-setup-teardown/imported-module.mjs b/test/fixtures/test-runner/global-setup-teardown/imported-module.mjs new file mode 100644 index 00000000000000..41e860337f42a0 --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/imported-module.mjs @@ -0,0 +1 @@ +console.log('Imported module executed'); diff --git a/test/fixtures/test-runner/global-setup-teardown/required-module.cjs b/test/fixtures/test-runner/global-setup-teardown/required-module.cjs new file mode 100644 index 00000000000000..039185f911d790 --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/required-module.cjs @@ -0,0 +1 @@ +console.log('Required module executed'); diff --git a/test/fixtures/test-runner/global-setup-teardown/setup-only.js b/test/fixtures/test-runner/global-setup-teardown/setup-only.js new file mode 100644 index 00000000000000..94893577646d59 --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/setup-only.js @@ -0,0 +1,12 @@ +'use strict'; + +const fs = require('node:fs'); + +const setupFlagPath = process.env.SETUP_ONLY_FLAG_PATH; + +async function globalSetup() { + console.log('Setup-only module executed'); + fs.writeFileSync(setupFlagPath, 'Setup-only was executed'); +} + +module.exports = { globalSetup }; diff --git a/test/fixtures/test-runner/global-setup-teardown/teardown-only.js b/test/fixtures/test-runner/global-setup-teardown/teardown-only.js new file mode 100644 index 00000000000000..ff95423db0c039 --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/teardown-only.js @@ -0,0 +1,12 @@ +'use strict'; + +const fs = require('node:fs'); + +const teardownFlagPath = process.env.TEARDOWN_ONLY_FLAG_PATH; + +async function globalTeardown() { + console.log('Teardown-only module executed'); + fs.writeFileSync(teardownFlagPath, 'Teardown-only was executed'); +} + +module.exports = { globalTeardown }; diff --git a/test/fixtures/test-runner/global-setup-teardown/test-file.js b/test/fixtures/test-runner/global-setup-teardown/test-file.js new file mode 100644 index 00000000000000..933d2d67756957 --- /dev/null +++ b/test/fixtures/test-runner/global-setup-teardown/test-file.js @@ -0,0 +1,17 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); + +test('verify setup was executed', (t) => { + const setupFlagPath = process.env.SETUP_FLAG_PATH; + assert.ok(fs.existsSync(setupFlagPath), 'Setup flag file should exist'); + + const content = fs.readFileSync(setupFlagPath, 'utf8'); + assert.strictEqual(content, 'Setup was executed'); +}); + +test('another simple test', (t) => { + assert.ok(true); +}); diff --git a/test/fixtures/test-runner/test-runner-global-hooks.mjs b/test/fixtures/test-runner/test-runner-global-hooks.mjs new file mode 100644 index 00000000000000..fb22b289604c6e --- /dev/null +++ b/test/fixtures/test-runner/test-runner-global-hooks.mjs @@ -0,0 +1,35 @@ +import { run } from 'node:test'; +import { spec } from 'node:test/reporters'; +import { parseArgs } from 'node:util'; + +const options = { + file: { + type: 'string', + }, + globalSetup: { + type: 'string', + }, + isolation: { + type: 'string', + }, +}; + +const { + values, +} = parseArgs({ args: process.argv.slice(2), options }); + +let files; +let globalSetupPath; + +if (values.file) { + files = [values.file]; +} + +if (values.globalSetup) { + globalSetupPath = values.globalSetup; +} + +run({ + files, + globalSetupPath, +}).compose(spec).pipe(process.stdout); diff --git a/test/parallel/test-runner-global-setup-teardown.mjs b/test/parallel/test-runner-global-setup-teardown.mjs new file mode 100644 index 00000000000000..eceba774d161e3 --- /dev/null +++ b/test/parallel/test-runner-global-setup-teardown.mjs @@ -0,0 +1,568 @@ +import '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import { spawn } from 'node:child_process'; +import tmpdir from '../common/tmpdir.js'; +import { once } from 'node:events'; +import { join } from 'node:path'; + +const testFixtures = fixtures.path('test-runner'); + +async function runTest( + { + isolation, + globalSetupFile, + testFiles = ['test-file.js'], + env = {}, + additionalFlags = [], + runnerEnabled = true, + } +) { + const globalSetupPath = join(testFixtures, 'global-setup-teardown', globalSetupFile); + + const args = []; + + if (runnerEnabled) { + args.push('--test'); + } + + if (isolation) { + args.push(`--test-isolation=${isolation}`); + } + + args.push( + '--test-reporter=spec', + `--test-global-setup=${globalSetupPath}` + ); + + if (additionalFlags.length > 0) { + args.push(...additionalFlags); + } + + const testFilePaths = testFiles.map((file) => join(testFixtures, 'global-setup-teardown', file)); + args.push(...testFilePaths); + + const child = spawn( + process.execPath, + args, + { + encoding: 'utf8', + stdio: 'pipe', + env: { + ...process.env, + ...env + } + } + ); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + await once(child, 'exit'); + + return { stdout, stderr }; +} + +[ + { + isolation: 'none', + runnerEnabled: true + }, + { + isolation: 'process', + runnerEnabled: true + }, + { + isolation: undefined, + runnerEnabled: false + }, +].forEach((testCase) => { + const { isolation, runnerEnabled } = testCase; + describe(`test runner global hooks with isolation=${isolation} and --test: ${runnerEnabled}`, { concurrency: false }, () => { + beforeEach(() => { + tmpdir.refresh(); + }); + + it('should run globalSetup and globalTeardown functions', async () => { + const setupFlagPath = tmpdir.resolve('setup-executed.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-executed.tmp'); + + const { stdout, stderr } = await runTest({ + isolation, + globalSetupFile: 'basic-setup-teardown.js', + env: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + runnerEnabled + }); + + assert.match(stdout, /pass 2/); + assert.match(stdout, /fail 0/); + assert.match(stdout, /Global setup executed/); + assert.match(stdout, /Global teardown executed/); + assert.strictEqual(stderr.length, 0); + + // After all tests complete, the teardown should have run + assert.ok(fs.existsSync(teardownFlagPath), 'Teardown flag file should exist'); + const content = fs.readFileSync(teardownFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown was executed'); + + // Setup flag should have been removed by teardown + assert.ok(!fs.existsSync(setupFlagPath), 'Setup flag file should have been removed'); + }); + + it('should run setup-only module', async () => { + const setupOnlyFlagPath = tmpdir.resolve('setup-only-executed.tmp'); + + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'setup-only.js', + env: { + SETUP_ONLY_FLAG_PATH: setupOnlyFlagPath, + SETUP_FLAG_PATH: setupOnlyFlagPath + }, + runnerEnabled + }); + + assert.match(stdout, /Setup-only module executed/); + + assert.ok(fs.existsSync(setupOnlyFlagPath), 'Setup-only flag file should exist'); + const content = fs.readFileSync(setupOnlyFlagPath, 'utf8'); + assert.strictEqual(content, 'Setup-only was executed'); + }); + + it('should run teardown-only module', async () => { + const teardownOnlyFlagPath = tmpdir.resolve('teardown-only-executed.tmp'); + const setupFlagPath = tmpdir.resolve('setup-for-teardown-only.tmp'); + + // Create a setup file for test-file.js to find + fs.writeFileSync(setupFlagPath, 'Setup was executed'); + + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'teardown-only.js', + env: { + TEARDOWN_ONLY_FLAG_PATH: teardownOnlyFlagPath, + SETUP_FLAG_PATH: setupFlagPath + }, + runnerEnabled + }); + + assert.match(stdout, /pass 2/); + assert.match(stdout, /fail 0/); + assert.match(stdout, /Teardown-only module executed/); + + assert.ok(fs.existsSync(teardownOnlyFlagPath), 'Teardown-only flag file should exist'); + const content = fs.readFileSync(teardownOnlyFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown-only was executed'); + }); + + // Create a file in globalSetup and delete it in globalTeardown + // two test files should both verify that the file exists + // This works as the globalTeardown removes the setupFlag + it('should run globalTeardown only after all tests are done in case of more than one test file', async () => { + const teardownOnlyFlagPath = tmpdir.resolve('teardown-only-executed.tmp'); + const setupFlagPath = tmpdir.resolve('setup-for-teardown-only.tmp'); + + // Create a setup file for test-file.js to find + fs.writeFileSync(setupFlagPath, 'Setup was executed'); + + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'teardown-only.js', + env: { + TEARDOWN_ONLY_FLAG_PATH: teardownOnlyFlagPath, + SETUP_FLAG_PATH: setupFlagPath + }, + runnerEnabled, + testFiles: ['test-file.js', 'another-test-file.js'] + }); + + if (runnerEnabled) { + assert.match(stdout, /pass 3/); + } else { + assert.match(stdout, /pass 2/); + } + + assert.match(stdout, /fail 0/); + assert.match(stdout, /Teardown-only module executed/); + + assert.ok(fs.existsSync(teardownOnlyFlagPath), 'Teardown-only flag file should exist'); + const content = fs.readFileSync(teardownOnlyFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown-only was executed'); + }); + + // TODO(pmarchini): We should be able to share context between setup and teardown + it.todo('should share context between setup and teardown'); + + it('should handle async setup and teardown', async () => { + const asyncFlagPath = tmpdir.resolve('async-executed.tmp'); + const setupFlagPath = tmpdir.resolve('setup-for-async.tmp'); + + // Create a setup file for test-file.js to find + fs.writeFileSync(setupFlagPath, 'Setup was executed'); + + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'async-setup-teardown.js', + env: { + ASYNC_FLAG_PATH: asyncFlagPath, + SETUP_FLAG_PATH: setupFlagPath + }, + runnerEnabled + }); + + assert.match(stdout, /pass 2/); + assert.match(stdout, /fail 0/); + assert.match(stdout, /Async setup starting/); + assert.match(stdout, /Async setup completed/); + assert.match(stdout, /Async teardown starting/); + assert.match(stdout, /Async teardown completed/); + + assert.ok(fs.existsSync(asyncFlagPath), 'Async flag file should exist'); + const content = fs.readFileSync(asyncFlagPath, 'utf8'); + assert.strictEqual(content, 'Setup part, Teardown part'); + }); + + it('should handle error in setup', async () => { + const setupFlagPath = tmpdir.resolve('setup-for-error.tmp'); + + const { stdout, stderr } = await runTest({ + isolation, + globalSetupFile: 'error-in-setup.js', + env: { + SETUP_FLAG_PATH: setupFlagPath + }, + runnerEnabled + }); + + // Verify that the error is reported properly + const errorReported = stderr.includes('Deliberate error in global setup'); + assert.ok(errorReported, 'Should report the error from global setup'); + + // Verify the teardown wasn't executed + assert.ok(!stdout.includes('Teardown should not run if setup fails'), + 'Teardown should not run after setup fails'); + }); + + it('should run TypeScript globalSetup and globalTeardown functions', async () => { + const setupFlagPath = tmpdir.resolve('setup-executed-ts.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-executed-ts.tmp'); + + const { stdout, stderr } = await runTest({ + isolation, + globalSetupFile: 'basic-setup-teardown.ts', + env: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + additionalFlags: ['--no-warnings'], + runnerEnabled + }); + + assert.match(stdout, /pass 2/); + assert.match(stdout, /fail 0/); + assert.match(stdout, /Global setup executed/); + assert.match(stdout, /Global teardown executed/); + assert.strictEqual(stderr.length, 0); + + // After all tests complete, the teardown should have run + assert.ok(fs.existsSync(teardownFlagPath), 'Teardown flag file should exist'); + const content = fs.readFileSync(teardownFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown was executed'); + + // Setup flag should have been removed by teardown + assert.ok(!fs.existsSync(setupFlagPath), 'Setup flag file should have been removed'); + }); + + it('should run ESM globalSetup and globalTeardown functions', async () => { + const setupFlagPath = tmpdir.resolve('setup-executed-esm.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-executed-esm.tmp'); + + const { stdout, stderr } = await runTest({ + isolation, + globalSetupFile: 'basic-setup-teardown.mjs', + env: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + runnerEnabled + }); + + assert.match(stdout, /pass 2/); + assert.match(stdout, /fail 0/); + assert.match(stdout, /Global setup executed/); + assert.match(stdout, /Global teardown executed/); + assert.strictEqual(stderr.length, 0); + + // After all tests complete, the teardown should have run + assert.ok(fs.existsSync(teardownFlagPath), 'Teardown flag file should exist'); + const content = fs.readFileSync(teardownFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown was executed'); + + // Setup flag should have been removed by teardown + assert.ok(!fs.existsSync(setupFlagPath), 'Setup flag file should have been removed'); + }); + + it('should run globalSetup only once for run', async () => { + const setupFlagPath = tmpdir.resolve('setup-executed-once.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-executed-once.tmp'); + + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'basic-setup-teardown.js', + env: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + runnerEnabled + }); + + const GlobalSetupOccurrences = (stdout.match(/Global setup executed/g) || []).length; + + // Global setup should run only once + assert.strictEqual(GlobalSetupOccurrences, 1); + }); + + it('should run globalSetup and globalTeardown only once for run', async () => { + const setupFlagPath = tmpdir.resolve('setup-executed-once.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-executed-once.tmp'); + + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'basic-setup-teardown.js', + env: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + runnerEnabled + }); + + const GlobalTeardownOccurrences = (stdout.match(/Global teardown executed/g) || []).length; + + // Global teardown should run only once + assert.strictEqual(GlobalTeardownOccurrences, 1); + }); + + it('should run globalSetup and globalTeardown only once for run with multiple test files', + { + skip: !runnerEnabled ? 'Skipping test as --test is not enabled' : false + }, + async () => { + const setupFlagPath = tmpdir.resolve('setup-executed-once.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-executed-once.tmp'); + const testFiles = ['test-file.js', 'another-test-file.js']; + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'basic-setup-teardown.js', + env: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + runnerEnabled, + testFiles + }); + const GlobalSetupOccurrences = (stdout.match(/Global setup executed/g) || []).length; + const GlobalTeardownOccurrences = (stdout.match(/Global teardown executed/g) || []).length; + + assert.strictEqual(GlobalSetupOccurrences, 1); + assert.strictEqual(GlobalTeardownOccurrences, 1); + + assert.match(stdout, /pass 3/); + assert.match(stdout, /fail 0/); + } + ); + + describe('interop with --require and --import', () => { + const cjsPath = join(testFixtures, 'global-setup-teardown', 'required-module.cjs'); + const esmpFile = fixtures.fileURL('test-runner', 'global-setup-teardown', 'imported-module.mjs'); + + it('should run required module before globalSetup', async () => { + const setupFlagPath = tmpdir.resolve('setup-for-required.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-for-required.tmp'); + + // Create a setup file for test-file.js to find + fs.writeFileSync(setupFlagPath, ''); + + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'basic-setup-teardown.js', + requirePath: './required-module.js', + env: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + additionalFlags: [ + `--require=${cjsPath}`, + ], + runnerEnabled + }); + + assert.match(stdout, /pass 2/); + assert.match(stdout, /fail 0/); + assert.match(stdout, /Required module executed/); + assert.match(stdout, /Global setup executed/); + assert.match(stdout, /Global teardown executed/); + + // Verify that the required module was executed before the global setup + const requiredExecutedPosition = stdout.indexOf('Required module executed'); + const globalSetupExecutedPosition = stdout.indexOf('Global setup executed'); + assert.ok(requiredExecutedPosition < globalSetupExecutedPosition, + 'Required module should have been executed before global setup'); + + // After all tests complete, the teardown should have run + assert.ok(fs.existsSync(teardownFlagPath), 'Teardown flag file should exist'); + const content = fs.readFileSync(teardownFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown was executed'); + + // Setup flag should have been removed by teardown + assert.ok(!fs.existsSync(setupFlagPath), 'Setup flag file should have been removed'); + }); + + // This difference in behavior is due to the way --import is being handled by + // run_main entry point or test_runner entry point + if (runnerEnabled) { + it('should run imported module after globalSetup', async () => { + const setupFlagPath = tmpdir.resolve('setup-for-imported.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-for-imported.tmp'); + + // Create a setup file for test-file.js to find + fs.writeFileSync(setupFlagPath, 'non-empty'); + + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'basic-setup-teardown.mjs', + importPath: './imported-module.js', + env: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + additionalFlags: [ + `--import=${esmpFile}`, + ], + runnerEnabled + }); + + assert.match(stdout, /pass 2/); + assert.match(stdout, /fail 0/); + assert.match(stdout, /Imported module executed/); + assert.match(stdout, /Global setup executed/); + assert.match(stdout, /Global teardown executed/); + + // Verify that the imported module was executed after the global setup + const globalSetupExecutedPosition = stdout.indexOf('Global setup executed'); + const importedExecutedPosition = stdout.indexOf('Imported module executed'); + assert.ok(globalSetupExecutedPosition < importedExecutedPosition, + 'Imported module should be executed after global setup'); + + // After all tests complete, the teardown should have run + assert.ok(fs.existsSync(teardownFlagPath), 'Teardown flag file should exist'); + const content = fs.readFileSync(teardownFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown was executed'); + + // Setup flag should have been removed by teardown + assert.ok(!fs.existsSync(setupFlagPath), 'Setup flag file should have been removed'); + }); + } else { + it('should run imported module before globalSetup', async () => { + const setupFlagPath = tmpdir.resolve('setup-for-imported.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-for-imported.tmp'); + + // Create a setup file for test-file.js to find + fs.writeFileSync(setupFlagPath, 'non-empty'); + + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'basic-setup-teardown.mjs', + importPath: './imported-module.js', + env: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + additionalFlags: [ + `--import=${esmpFile}`, + ], + runnerEnabled + }); + + assert.match(stdout, /pass 2/); + assert.match(stdout, /fail 0/); + assert.match(stdout, /Imported module executed/); + assert.match(stdout, /Global setup executed/); + assert.match(stdout, /Global teardown executed/); + + // Verify that the imported module was executed before the global setup + const importedExecutedPosition = stdout.indexOf('Imported module executed'); + const globalSetupExecutedPosition = stdout.indexOf('Global setup executed'); + assert.ok(importedExecutedPosition < globalSetupExecutedPosition, + 'Imported module should be executed before global setup'); + + // After all tests complete, the teardown should have run + assert.ok(fs.existsSync(teardownFlagPath), 'Teardown flag file should exist'); + const content = fs.readFileSync(teardownFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown was executed'); + + // Setup flag should have been removed by teardown + assert.ok(!fs.existsSync(setupFlagPath), 'Setup flag file should have been removed'); + }); + } + + it('should execute globalSetup and globalTeardown correctly with imported module containing tests', async () => { + const setupFlagPath = tmpdir.resolve('setup-executed.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-executed.tmp'); + const importedModuleWithTestFile = fixtures.fileURL( + 'test-runner', + 'global-setup-teardown', + 'imported-module-with-test.mjs' + ); + // Create a setup file for test-file.js to find + fs.writeFileSync(setupFlagPath, 'non-empty'); + + const { stdout } = await runTest({ + isolation, + globalSetupFile: 'basic-setup-teardown.js', + env: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + additionalFlags: [ + `--import=${importedModuleWithTestFile}`, + ], + runnerEnabled + }); + + assert.match(stdout, /Global setup executed/); + assert.match(stdout, /Imported module Ok/); + assert.match(stdout, /Imported module Fail/); + assert.match(stdout, /verify setup was executed/); + assert.match(stdout, /another simple test/); + assert.match(stdout, /Global teardown executed/); + assert.match(stdout, /tests 4/); + assert.match(stdout, /suites 0/); + assert.match(stdout, /pass 3/); + assert.match(stdout, /fail 1/); + assert.match(stdout, /cancelled 0/); + assert.match(stdout, /skipped 0/); + assert.match(stdout, /todo 0/); + + // After all tests complete, the teardown should have run + assert.ok(fs.existsSync(teardownFlagPath), 'Teardown flag file should exist'); + const content = fs.readFileSync(teardownFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown was executed'); + // Setup flag should have been removed by teardown + assert.ok(!fs.existsSync(setupFlagPath), 'Setup flag file should have been removed'); + }); + }); + }); +}); diff --git a/test/parallel/test-runner-global-setup-watch-mode.mjs b/test/parallel/test-runner-global-setup-watch-mode.mjs new file mode 100644 index 00000000000000..95ea73e7c7c396 --- /dev/null +++ b/test/parallel/test-runner-global-setup-watch-mode.mjs @@ -0,0 +1,125 @@ +import * as common from '../common/index.mjs'; +import { beforeEach, describe, it } from 'node:test'; +import { once } from 'node:events'; +import assert from 'node:assert'; +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +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 = { + 'test.js': ` +const test = require('node:test'); +test('test with global hooks', (t) => { + t.assert.ok('test passed'); +}); +`, + 'global-setup-teardown.js': ` +async function globalSetup() { + console.log('Global setup executed'); + process.on('message', (message) => { + if (message === 'exit') { + process.kill(process.pid, 'SIGINT'); + } + }); +} + +async function globalTeardown() { + console.log('Global teardown executed'); +} + +module.exports = { globalSetup, globalTeardown }; +` +}; + +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)); +} + +describe('test runner watch mode with global setup hooks', () => { + beforeEach(refresh); + for (const isolation of ['none', 'process']) { + describe(`isolation: ${isolation}`, () => { + it(`should run global setup/teardown hooks with each test run in watch mode`, + // TODO(pmarchini): Skip test on Windows as the VS2022 build + // has issues handling SIGTERM and SIGINT signals correctly. + // See: https://github.com/nodejs/node/issues/46097 + { todo: common.isWindows }, + async () => { + const globalSetupFileFixture = fixturePaths['global-setup-teardown.js']; + const ran1 = Promise.withResolvers(); + const ran2 = Promise.withResolvers(); + + const child = spawn(process.execPath, + [ + '--watch', + '--test', + '--test-reporter=spec', + `--test-isolation=${isolation}`, + '--test-global-setup=' + globalSetupFileFixture, + fixturePaths['test.js'], + ], + { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + 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(); + }); + + await ran1.promise; + runs.push(currentRun); + currentRun = ''; + + const content = fixtureContent['test.js']; + const path = fixturePaths['test.js']; + writeFileSync(path, content); + + await ran2.promise; + runs.push(currentRun); + + currentRun = ''; + child.send('exit'); + await once(child, 'exit'); + + assert.match(runs[0], /Global setup executed/); + assert.match(runs[0], /tests 1/); + assert.match(runs[0], /pass 1/); + assert.match(runs[0], /fail 0/); + + assert.doesNotMatch(runs[1], /Global setup executed/); + assert.doesNotMatch(runs[1], /Global teardown executed/); + assert.match(runs[1], /tests 1/); + assert.match(runs[1], /pass 1/); + assert.match(runs[1], /fail 0/); + + // Verify stdout after killing the child + assert.doesNotMatch(currentRun, /Global setup executed/); + assert.match(currentRun, /Global teardown executed/); + }); + }); + } +}); diff --git a/test/parallel/test-runner-run-global-hooks.mjs b/test/parallel/test-runner-run-global-hooks.mjs new file mode 100644 index 00000000000000..bc1865391b8dd0 --- /dev/null +++ b/test/parallel/test-runner-run-global-hooks.mjs @@ -0,0 +1,227 @@ +import '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { describe, it, beforeEach, run } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import tmpdir from '../common/tmpdir.js'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; + +const testFixtures = fixtures.path('test-runner', 'global-setup-teardown'); +const runnerFixture = fixtures.path('test-runner', 'test-runner-global-hooks.mjs'); + +describe('require(\'node:test\').run with global hooks', { concurrency: false }, () => { + beforeEach(() => { + tmpdir.refresh(); + }); + + async function runTestWithGlobalHooks({ + globalSetupFile, + testFile = 'test-file.js', + runnerEnv = {}, + isolation = 'process' + }) { + const testFilePath = path.join(testFixtures, testFile); + const globalSetupPath = path.join(testFixtures, globalSetupFile); + + const child = spawn( + process.execPath, + [ + runnerFixture, + '--file', testFilePath, + '--globalSetup', globalSetupPath, + '--isolation', isolation, + ], + { + encoding: 'utf8', + stdio: 'pipe', + env: { + ...runnerEnv, + ...process.env, + AVOID_PRINT_LOGS: 'true', + NODE_OPTIONS: '--no-warnings', + } + } + ); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + await once(child, 'exit'); + + // Assert in order to print a detailed error message if the test fails + assert.partialDeepStrictEqual(stderr, ''); + assert.match(stdout, /pass (\d+)/); + assert.match(stdout, /fail (\d+)/); + + const results = { + passed: parseInt((stdout.match(/pass (\d+)/) || [])[1] || '0', 10), + failed: parseInt((stdout.match(/fail (\d+)/) || [])[1] || '0', 10) + }; + + return { results }; + } + + for (const isolation of ['none', 'process']) { + describe(`with isolation : ${isolation}`, () => { + it('should run globalSetup and globalTeardown functions', async () => { + const setupFlagPath = tmpdir.resolve('setup-executed.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-executed.tmp'); + + const { results } = await runTestWithGlobalHooks({ + globalSetupFile: 'basic-setup-teardown.js', + runnerEnv: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + isolation + }); + + assert.strictEqual(results.passed, 2); + assert.strictEqual(results.failed, 0); + // After all tests complete, the teardown should have run + assert.ok(fs.existsSync(teardownFlagPath), 'Teardown flag file should exist'); + const content = fs.readFileSync(teardownFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown was executed'); + // Setup flag should have been removed by teardown + assert.ok(!fs.existsSync(setupFlagPath), 'Setup flag file should have been removed'); + }); + + it('should run setup-only module', async () => { + const setupOnlyFlagPath = tmpdir.resolve('setup-only-executed.tmp'); + + const { results } = await runTestWithGlobalHooks({ + globalSetupFile: 'setup-only.js', + runnerEnv: { + SETUP_ONLY_FLAG_PATH: setupOnlyFlagPath, + SETUP_FLAG_PATH: setupOnlyFlagPath + }, + isolation + }); + + assert.strictEqual(results.passed, 1); + assert.strictEqual(results.failed, 1); + assert.ok(fs.existsSync(setupOnlyFlagPath), 'Setup-only flag file should exist'); + const content = fs.readFileSync(setupOnlyFlagPath, 'utf8'); + assert.strictEqual(content, 'Setup-only was executed'); + }); + + it('should run teardown-only module', async () => { + const teardownOnlyFlagPath = tmpdir.resolve('teardown-only-executed.tmp'); + const setupFlagPath = tmpdir.resolve('setup-for-teardown-only.tmp'); + + // Create a setup file for test-file.js to find + fs.writeFileSync(setupFlagPath, 'Setup was executed'); + + const { results } = await runTestWithGlobalHooks({ + globalSetupFile: 'teardown-only.js', + runnerEnv: { + TEARDOWN_ONLY_FLAG_PATH: teardownOnlyFlagPath, + SETUP_FLAG_PATH: setupFlagPath + }, + isolation + }); + + assert.strictEqual(results.passed, 2); + assert.strictEqual(results.failed, 0); + assert.ok(fs.existsSync(teardownOnlyFlagPath), 'Teardown-only flag file should exist'); + const content = fs.readFileSync(teardownOnlyFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown-only was executed'); + }); + + // TODO(pmarchini): We should be able to share context between setup and teardown + it.todo('should share context between setup and teardown'); + + it('should handle async setup and teardown', async () => { + const asyncFlagPath = tmpdir.resolve('async-executed.tmp'); + const setupFlagPath = tmpdir.resolve('setup-for-async.tmp'); + + // Create a setup file for test-file.js to find + fs.writeFileSync(setupFlagPath, 'Setup was executed'); + + const { results } = await runTestWithGlobalHooks({ + globalSetupFile: 'async-setup-teardown.js', + runnerEnv: { + ASYNC_FLAG_PATH: asyncFlagPath, + SETUP_FLAG_PATH: setupFlagPath + }, + isolation + }); + + assert.strictEqual(results.passed, 2); + assert.strictEqual(results.failed, 0); + assert.ok(fs.existsSync(asyncFlagPath), 'Async flag file should exist'); + const content = fs.readFileSync(asyncFlagPath, 'utf8'); + assert.strictEqual(content, 'Setup part, Teardown part'); + }); + + it('should run TypeScript globalSetup and globalTeardown functions', async () => { + const setupFlagPath = tmpdir.resolve('setup-executed-ts.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-executed-ts.tmp'); + + const { results } = await runTestWithGlobalHooks({ + globalSetupFile: 'basic-setup-teardown.ts', + runnerEnv: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + isolation + }); + + assert.strictEqual(results.passed, 2); + assert.strictEqual(results.failed, 0); + // After all tests complete, the teardown should have run + assert.ok(fs.existsSync(teardownFlagPath), 'Teardown flag file should exist'); + const content = fs.readFileSync(teardownFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown was executed'); + + // Setup flag should have been removed by teardown + assert.ok(!fs.existsSync(setupFlagPath), 'Setup flag file should have been removed'); + }); + + it('should run ESM globalSetup and globalTeardown functions', async () => { + const setupFlagPath = tmpdir.resolve('setup-executed-esm.tmp'); + const teardownFlagPath = tmpdir.resolve('teardown-executed-esm.tmp'); + + const { results } = await runTestWithGlobalHooks({ + globalSetupFile: 'basic-setup-teardown.mjs', + runnerEnv: { + SETUP_FLAG_PATH: setupFlagPath, + TEARDOWN_FLAG_PATH: teardownFlagPath + }, + isolation + }); + + assert.strictEqual(results.passed, 2); + assert.strictEqual(results.failed, 0); + // After all tests complete, the teardown should have run + assert.ok(fs.existsSync(teardownFlagPath), 'Teardown flag file should exist'); + const content = fs.readFileSync(teardownFlagPath, 'utf8'); + assert.strictEqual(content, 'Teardown was executed'); + // Setup flag should have been removed by teardown + assert.ok(!fs.existsSync(setupFlagPath), 'Setup flag file should have been removed'); + }); + }); + } + + it('should validate that globalSetupPath is a string', () => { + [123, {}, [], true, false].forEach((invalidValue) => { + assert.throws(() => { + run({ + files: [path.join(testFixtures, 'test-file.js')], + globalSetupPath: invalidValue + }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.globalSetupPath" property must be of type string/ + }); + }); + }); +}); diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index 09de6f146f1e1b..2028aa11cc0e36 100644 --- a/test/parallel/test-runner-run.mjs +++ b/test/parallel/test-runner-run.mjs @@ -206,7 +206,7 @@ describe('require(\'node:test\').run', { concurrency: true }, () => { if (data.name === 'this is a test') { t.assert.strictEqual(data.type, 'test'); } - }, 2)); + }, 3)); stream.on('test:dequeue', common.mustCall((data) => { if (data.name === 'this is a suite') { t.assert.strictEqual(data.type, 'suite'); @@ -214,7 +214,7 @@ describe('require(\'node:test\').run', { concurrency: true }, () => { if (data.name === 'this is a test') { t.assert.strictEqual(data.type, 'test'); } - }, 2)); + }, 3)); // eslint-disable-next-line no-unused-vars for await (const _ of stream);