diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index bb65bc71d9..0993ddb415 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -17,7 +17,6 @@ import fs from 'fs'; import path from 'path'; -import { gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils'; import { isRegExp } from 'playwright-core/lib/utils'; import { requireOrImport, setSingleTSConfig, setTransformConfig } from '../transform/transform'; @@ -25,7 +24,6 @@ import { errorWithFile, fileIsModule } from '../util'; import { FullConfigInternal } from './config'; import { configureESMLoader, configureESMLoaderTransformConfig, registerESMLoader } from './esmLoaderHost'; import { addToCompilationCache } from '../transform/compilationCache'; -import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils'; import type { ConfigLocation } from './config'; import type { ConfigCLIOverrides, SerializedConfig } from './ipc'; @@ -102,6 +100,14 @@ async function loadUserConfig(location: ConfigLocation): Promise { } export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false, metadata?: Config['metadata']): Promise { + // 0. Setup ESM loader if needed. + if (!registerESMLoader()) { + // In Node.js < 18, complain if the config file is ESM. Historically, we would restart + // the process with --loader, but now we require newer Node.js. + if (location.resolvedConfigFile && fileIsModule(location.resolvedConfigFile)) + throw errorWithFile(location.resolvedConfigFile, `Playwright requires Node.js 18 or higher to load esm modules. Please update your version of Node.js.`); + } + // 1. Setup tsconfig; configure ESM loader with tsconfig and compilation cache. setSingleTSConfig(overrides?.tsconfig); await configureESMLoader(); @@ -359,57 +365,11 @@ function resolveConfigFile(configFileOrDirectory: string): string | undefined { return configFileOrDirectory!; } -export async function loadConfigFromFileRestartIfNeeded(configFile: string | undefined, overrides?: ConfigCLIOverrides, ignoreDeps?: boolean): Promise { - const location = resolveConfigLocation(configFile); - if (restartWithExperimentalTsEsm(location.resolvedConfigFile)) - return null; - return await loadConfig(location, overrides, ignoreDeps); +export async function loadConfigFromFile(configFile: string | undefined, overrides?: ConfigCLIOverrides, ignoreDeps?: boolean): Promise { + return await loadConfig(resolveConfigLocation(configFile), overrides, ignoreDeps); } export async function loadEmptyConfigForMergeReports() { // Merge reports is "different" for no good reason. It should not pick up local config from the cwd. return await loadConfig({ configDir: process.cwd() }); } - -export function restartWithExperimentalTsEsm(configFile: string | undefined, force: boolean = false): boolean { - // Opt-out switch. - if (process.env.PW_DISABLE_TS_ESM) - return false; - - // There are two esm loader APIs: - // - Older API that needs a process restart. Available in Node 16, 17, and non-latest 18, 19 and 20. - // - Newer API that works in-process. Available in Node 21+ and latest 18, 19 and 20. - - // First check whether we have already restarted with the ESM loader from the older API. - if ((globalThis as any).__esmLoaderPortPreV20) { - // clear execArgv after restart, so that childProcess.fork in user code does not inherit our loader. - process.execArgv = execArgvWithoutExperimentalLoaderOptions(); - return false; - } - - // Now check for the newer API presence. - if (!require('node:module').register) { - // With older API requiring a process restart, do so conditionally on the config. - const configIsModule = !!configFile && fileIsModule(configFile); - if (!force && !configIsModule) - return false; - - const innerProcess = (require('child_process') as typeof import('child_process')).fork(require.resolve('../../cli'), process.argv.slice(2), { - env: { - ...process.env, - PW_TS_ESM_LEGACY_LOADER_ON: '1', - }, - execArgv: execArgvWithExperimentalLoaderOptions(), - }); - - innerProcess.on('close', (code: number | null) => { - if (code !== 0 && code !== null) - gracefullyProcessExitDoNotHang(code); - }); - return true; - } - - // With the newer API, always enable the ESM loader, because it does not need a restart. - registerESMLoader(); - return false; -} diff --git a/packages/playwright/src/common/esmLoaderHost.ts b/packages/playwright/src/common/esmLoaderHost.ts index 1c8ef09585..6a44c24166 100644 --- a/packages/playwright/src/common/esmLoaderHost.ts +++ b/packages/playwright/src/common/esmLoaderHost.ts @@ -21,21 +21,27 @@ import { PortTransport } from '../transform/portTransport'; import { singleTSConfig, transformConfig } from '../transform/transform'; let loaderChannel: PortTransport | undefined; -// Node.js < 20 -if ((globalThis as any).__esmLoaderPortPreV20) - loaderChannel = createPortTransport((globalThis as any).__esmLoaderPortPreV20); -// Node.js >= 20 -export let esmLoaderRegistered = false; export function registerESMLoader() { + // Opt-out switch. + if (process.env.PW_DISABLE_TS_ESM) + return true; + + if (loaderChannel) + return true; + + const register = require('node:module').register; + if (!register) + return false; + const { port1, port2 } = new MessageChannel(); // register will wait until the loader is initialized. - require('node:module').register(url.pathToFileURL(require.resolve('../transform/esmLoader')), { + register(url.pathToFileURL(require.resolve('../transform/esmLoader')), { data: { port: port2 }, transferList: [port2], }); loaderChannel = createPortTransport(port1); - esmLoaderRegistered = true; + return true; } function createPortTransport(port: MessagePort) { diff --git a/packages/playwright/src/common/process.ts b/packages/playwright/src/common/process.ts index b29f2d6926..904290406d 100644 --- a/packages/playwright/src/common/process.ts +++ b/packages/playwright/src/common/process.ts @@ -17,8 +17,6 @@ import { setTimeOrigin, startProfiling, stopProfiling } from 'playwright-core/lib/utils'; import { serializeError } from '../util'; -import { registerESMLoader } from './esmLoaderHost'; -import { execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils'; import type { EnvProducedPayload, ProcessInitParams, TestInfoErrorImpl } from './ipc'; @@ -54,13 +52,6 @@ process.on('disconnect', () => gracefullyCloseAndExit(true)); process.on('SIGINT', () => {}); process.on('SIGTERM', () => {}); -// Clear execArgv immediately, so that the user-code does not inherit our loader. -process.execArgv = execArgvWithoutExperimentalLoaderOptions(); - -// Node.js >= 20 -if (process.env.PW_TS_ESM_LOADER_ON) - registerESMLoader(); - let processRunner: ProcessRunner | undefined; let processName: string | undefined; const startingEnv = { ...process.env }; diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index eb9bb84eec..874f645001 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -23,7 +23,7 @@ import { program } from 'playwright-core/lib/cli/program'; import { gracefullyProcessExitDoNotHang, startProfiling, stopProfiling } from 'playwright-core/lib/utils'; import { builtInReporters, defaultReporter, defaultTimeout } from './common/config'; -import { loadConfigFromFileRestartIfNeeded, loadEmptyConfigForMergeReports, resolveConfigLocation } from './common/configLoader'; +import { loadConfigFromFile, loadEmptyConfigForMergeReports, resolveConfigLocation } from './common/configLoader'; export { program } from 'playwright-core/lib/cli/program'; import { prepareErrorStack } from './reporters/base'; import { showHTMLReport } from './reporters/html'; @@ -77,9 +77,7 @@ function addClearCacheCommand(program: Command) { command.description('clears build and test caches'); command.option('-c, --config ', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`); command.action(async opts => { - const config = await loadConfigFromFileRestartIfNeeded(opts.config); - if (!config) - return; + const config = await loadConfigFromFile(opts.config); const runner = new Runner(config); const { status } = await runner.clearCache(); const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); @@ -102,9 +100,7 @@ function addDevServerCommand(program: Command) { command.description('start dev server'); command.option('-c, --config ', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`); command.action(async options => { - const config = await loadConfigFromFileRestartIfNeeded(options.config); - if (!config) - return; + const config = await loadConfigFromFile(options.config); const runner = new Runner(config); const { status } = await runner.runDevServer(); const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); @@ -161,10 +157,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) { await startProfiling(); const cliOverrides = overridesFromOptions(opts); - const config = await loadConfigFromFileRestartIfNeeded(opts.config, cliOverrides, opts.deps === false); - if (!config) - return; - + const config = await loadConfigFromFile(opts.config, cliOverrides, opts.deps === false); config.cliArgs = args; config.cliGrep = opts.grep as string | undefined; config.cliOnlyChanged = opts.onlyChanged === true ? 'HEAD' : opts.onlyChanged; @@ -191,8 +184,6 @@ async function runTests(args: string[], opts: { [key: string]: any }) { reporter: Array.isArray(opts.reporter) ? opts.reporter : opts.reporter ? [opts.reporter] : undefined, }); await stopProfiling('runner'); - if (status === 'restarted') - return; const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); gracefullyProcessExitDoNotHang(exitCode); return; @@ -211,8 +202,6 @@ async function runTests(args: string[], opts: { [key: string]: any }) { } ); await stopProfiling('runner'); - if (status === 'restarted') - return; const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); gracefullyProcessExitDoNotHang(exitCode); return; @@ -229,8 +218,6 @@ async function runTestServer(opts: { [key: string]: any }) { const host = opts.host || 'localhost'; const port = opts.port ? +opts.port : 0; const status = await testServer.runTestServer(opts.config, { }, { host, port }); - if (status === 'restarted') - return; const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); gracefullyProcessExitDoNotHang(exitCode); } @@ -240,9 +227,7 @@ export async function withRunnerAndMutedWrite(configFile: string | undefined, ca const stdoutWrite = process.stdout.write.bind(process.stdout); process.stdout.write = ((a: any, b: any, c: any) => process.stderr.write(a, b, c)) as any; try { - const config = await loadConfigFromFileRestartIfNeeded(configFile); - if (!config) - return; + const config = await loadConfigFromFile(configFile); const runner = new Runner(config); const result = await callback(runner); stdoutWrite(JSON.stringify(result, undefined, 2), () => { @@ -265,9 +250,7 @@ async function listTestFiles(opts: { [key: string]: any }) { async function mergeReports(reportDir: string | undefined, opts: { [key: string]: any }) { const configFile = opts.config; - const config = configFile ? await loadConfigFromFileRestartIfNeeded(configFile) : await loadEmptyConfigForMergeReports(); - if (!config) - return; + const config = configFile ? await loadConfigFromFile(configFile) : await loadEmptyConfigForMergeReports(); const dir = path.resolve(process.cwd(), reportDir || ''); const dirStat = await fs.promises.stat(dir).catch(e => null); diff --git a/packages/playwright/src/runner/processHost.ts b/packages/playwright/src/runner/processHost.ts index d78095c0ec..06882a4d1b 100644 --- a/packages/playwright/src/runner/processHost.ts +++ b/packages/playwright/src/runner/processHost.ts @@ -20,9 +20,6 @@ import { EventEmitter } from 'events'; import { assert, timeOrigin } from 'playwright-core/lib/utils'; import { debug } from 'playwright-core/lib/utilsBundle'; -import { esmLoaderRegistered } from '../common/esmLoaderHost'; -import { execArgvWithExperimentalLoaderOptions } from '../transform/esmUtils'; - import type { EnvProducedPayload, ProcessInitParams } from '../common/ipc'; import type { ProtocolResponse } from '../common/process'; @@ -58,7 +55,6 @@ export class ProcessHost extends EventEmitter { env: { ...process.env, ...this._extraEnv, - ...(esmLoaderRegistered ? { PW_TS_ESM_LOADER_ON: '1' } : {}), }, stdio: [ 'ignore', @@ -66,7 +62,6 @@ export class ProcessHost extends EventEmitter { (options.onStdErr && !process.env.PW_RUNNER_DEBUG) ? 'pipe' : 'inherit', 'ipc', ], - ...(process.env.PW_TS_ESM_LEGACY_LOADER_ON ? { execArgv: execArgvWithExperimentalLoaderOptions() } : {}), }); this.process.on('exit', async (code, signal) => { this._processDidExit = true; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index a6f8b0b7ca..138df4cb1c 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -24,7 +24,7 @@ import { open } from 'playwright-core/lib/utilsBundle'; import { createErrorCollectingReporter, createReporterForTestServer, createReporters } from './reporters'; import { SigIntWatcher } from './sigIntWatcher'; import { TestRun, createApplyRebaselinesTask, createClearCacheTask, createGlobalSetupTasks, createListFilesTask, createLoadTask, createReportBeginTask, createRunTestsTasks, createStartDevServerTask, runTasks, runTasksDeferCleanup } from './tasks'; -import { loadConfig, resolveConfigLocation, restartWithExperimentalTsEsm } from '../common/configLoader'; +import { loadConfig, resolveConfigLocation } from '../common/configLoader'; import { Watcher } from '../fsWatcher'; import { baseFullConfig } from '../isomorphic/teleReceiver'; import { addGitCommitInfoPlugin } from '../plugins/gitCommitInfoPlugin'; @@ -435,7 +435,7 @@ export class TestServerDispatcher implements TestServerInterface { } } -export async function runUIMode(configFile: string | undefined, configCLIOverrides: ConfigCLIOverrides, options: TraceViewerServerOptions & TraceViewerRedirectOptions): Promise { +export async function runUIMode(configFile: string | undefined, configCLIOverrides: ConfigCLIOverrides, options: TraceViewerServerOptions & TraceViewerRedirectOptions): Promise { const configLocation = resolveConfigLocation(configFile); return await innerRunTestServer(configLocation, configCLIOverrides, options, async (server: HttpServer, cancelPromise: ManualPromise) => { await installRootRedirect(server, [], { ...options, webApp: 'uiMode.html' }); @@ -469,7 +469,7 @@ async function installedChromiumChannelForUI(configLocation: ConfigLocation, con return undefined; } -export async function runTestServer(configFile: string | undefined, configCLIOverrides: ConfigCLIOverrides, options: { host?: string, port?: number }): Promise { +export async function runTestServer(configFile: string | undefined, configCLIOverrides: ConfigCLIOverrides, options: { host?: string, port?: number }): Promise { const configLocation = resolveConfigLocation(configFile); return await innerRunTestServer(configLocation, configCLIOverrides, options, async server => { // eslint-disable-next-line no-console @@ -477,9 +477,7 @@ export async function runTestServer(configFile: string | undefined, configCLIOve }); } -async function innerRunTestServer(configLocation: ConfigLocation, configCLIOverrides: ConfigCLIOverrides, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise) => Promise): Promise { - if (restartWithExperimentalTsEsm(undefined, true)) - return 'restarted'; +async function innerRunTestServer(configLocation: ConfigLocation, configCLIOverrides: ConfigCLIOverrides, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise) => Promise): Promise { const testServer = new TestServer(configLocation, configCLIOverrides); const cancelPromise = new ManualPromise(); const sigintWatcher = new SigIntWatcher(); diff --git a/packages/playwright/src/runner/watchMode.ts b/packages/playwright/src/runner/watchMode.ts index 4aa8264312..1c58a5fcef 100644 --- a/packages/playwright/src/runner/watchMode.ts +++ b/packages/playwright/src/runner/watchMode.ts @@ -25,7 +25,6 @@ import { colors } from 'playwright-core/lib/utils'; import { separator, terminalScreen } from '../reporters/base'; import { enquirer } from '../utilsBundle'; import { TestServerDispatcher } from './testServer'; -import { restartWithExperimentalTsEsm } from '../common/configLoader'; import { TeleSuiteUpdater } from '../isomorphic/teleSuiteUpdater'; import { TestServerConnection } from '../isomorphic/testServerConnection'; @@ -73,10 +72,7 @@ interface WatchModeOptions { grep?: string; } -export async function runWatchModeLoop(configLocation: ConfigLocation, initialOptions: WatchModeOptions): Promise { - if (restartWithExperimentalTsEsm(undefined, true)) - return 'restarted'; - +export async function runWatchModeLoop(configLocation: ConfigLocation, initialOptions: WatchModeOptions): Promise { const options: WatchModeOptions = { ...initialOptions }; let bufferMode = false; diff --git a/packages/playwright/src/transform/esmLoader.ts b/packages/playwright/src/transform/esmLoader.ts index 25c3ab2845..a74b20110d 100644 --- a/packages/playwright/src/transform/esmLoader.ts +++ b/packages/playwright/src/transform/esmLoader.ts @@ -86,15 +86,6 @@ async function load(moduleUrl: string, context: { format?: string }, defaultLoad let transport: PortTransport | undefined; -// Node.js < 20 -function globalPreload(context: { port: MessagePort }) { - transport = createTransport(context.port); - return ` - globalThis.__esmLoaderPortPreV20 = port; - `; -} - -// Node.js >= 20 function initialize(data: { port: MessagePort }) { transport = createTransport(data?.port); } @@ -132,4 +123,4 @@ function createTransport(port: MessagePort) { } -module.exports = { globalPreload, initialize, load, resolve }; +module.exports = { initialize, load, resolve }; diff --git a/packages/playwright/src/transform/esmUtils.ts b/packages/playwright/src/transform/esmUtils.ts deleted file mode 100644 index 31851f61d3..0000000000 --- a/packages/playwright/src/transform/esmUtils.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import url from 'url'; - -const kExperimentalLoaderOptions = [ - '--no-warnings', - `--experimental-loader=${url.pathToFileURL(require.resolve('playwright/lib/transform/esmLoader')).toString()}`, -]; - -export function execArgvWithExperimentalLoaderOptions() { - return [ - ...process.execArgv, - ...kExperimentalLoaderOptions, - ]; -} - -export function execArgvWithoutExperimentalLoaderOptions() { - return process.execArgv.filter(arg => !kExperimentalLoaderOptions.includes(arg)); -}