diff --git a/packages/playwright/src/cli.ts b/packages/playwright/src/cli.ts index 1a20424e9a..34a0b2372c 100644 --- a/packages/playwright/src/cli.ts +++ b/packages/playwright/src/cli.ts @@ -21,7 +21,7 @@ import fs from 'fs'; import path from 'path'; import { Runner } from './runner/runner'; import { stopProfiling, startProfiling, gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils'; -import { execArgvWithoutExperimentalLoaderOptions, execArgvWithExperimentalLoaderOptions, fileIsModule, serializeError } from './util'; +import { fileIsModule, serializeError } from './util'; import { showHTMLReport } from './reporters/html'; import { createMergedReport } from './reporters/merge'; import { ConfigLoader, resolveConfigFile } from './common/configLoader'; @@ -33,6 +33,8 @@ import type { FullConfigInternal } from './common/config'; import program from 'playwright-core/lib/cli/program'; import type { ReporterDescription } from '../types/test'; import { prepareErrorStack } from './reporters/base'; +import { registerESMLoader } from './common/esmLoaderHost'; +import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from './transform/esmUtils'; function addTestCommand(program: Command) { const command = program.command('test [test-filter...]'); @@ -277,26 +279,33 @@ function restartWithExperimentalTsEsm(configFile: string | null): boolean { return false; if (process.env.PW_DISABLE_TS_ESM) return false; - if (process.env.PW_TS_ESM_ON) { + // Node.js < 20 + 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; } if (!fileIsModule(configFile)) 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_ON: '1', - }, - execArgv: execArgvWithExperimentalLoaderOptions(), - }); + // Node.js < 20 + if (!require('node:module').register) { + 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; + innerProcess.on('close', (code: number | null) => { + if (code !== 0 && code !== null) + gracefullyProcessExitDoNotHang(code); + }); + return true; + } + // Nodejs >= 21 + registerESMLoader(); + return false; } const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries', 'retain-on-failure']; diff --git a/packages/playwright/src/common/esmLoaderHost.ts b/packages/playwright/src/common/esmLoaderHost.ts index 979a0cce17..24f55784ea 100644 --- a/packages/playwright/src/common/esmLoaderHost.ts +++ b/packages/playwright/src/common/esmLoaderHost.ts @@ -14,16 +14,36 @@ * limitations under the License. */ +import url from 'url'; import { addToCompilationCache, serializeCompilationCache } from '../transform/compilationCache'; import { transformConfig } from '../transform/transform'; import { PortTransport } from '../transform/portTransport'; -const port = (globalThis as any).__esmLoaderPort; +let loaderChannel: PortTransport | undefined; +// Node.js < 20 +if ((globalThis as any).__esmLoaderPortPreV20) + loaderChannel = createPortTransport((globalThis as any).__esmLoaderPortPreV20); -const loaderChannel = port ? new PortTransport(port, async (method, params) => { - if (method === 'pushToCompilationCache') - addToCompilationCache(params.cache); -}) : undefined; +// Node.js >= 20 +export let esmLoaderRegistered = false; +export function registerESMLoader() { + const { port1, port2 } = new MessageChannel(); + // register will wait until the loader is initialized. + require('node:module').register(url.pathToFileURL(require.resolve('../transform/esmLoader')), { + parentURL: url.pathToFileURL(__filename), + data: { port: port2 }, + transferList: [port2], + }); + loaderChannel = createPortTransport(port1); + esmLoaderRegistered = true; +} + +function createPortTransport(port: MessagePort) { + return new PortTransport(port, async (method, params) => { + if (method === 'pushToCompilationCache') + addToCompilationCache(params.cache); + }); +} export async function startCollectingFileDeps() { if (!loaderChannel) diff --git a/packages/playwright/src/common/process.ts b/packages/playwright/src/common/process.ts index 1103476acb..f082e958c2 100644 --- a/packages/playwright/src/common/process.ts +++ b/packages/playwright/src/common/process.ts @@ -18,7 +18,9 @@ import type { WriteStream } from 'tty'; import type { EnvProducedPayload, ProcessInitParams, TtyParams } from './ipc'; import { startProfiling, stopProfiling } from 'playwright-core/lib/utils'; import type { TestInfoError } from '../../types/test'; -import { execArgvWithoutExperimentalLoaderOptions, serializeError } from '../util'; +import { serializeError } from '../util'; +import { registerESMLoader } from './esmLoaderHost'; +import { execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils'; export type ProtocolRequest = { id: number; @@ -54,6 +56,10 @@ 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/runner/processHost.ts b/packages/playwright/src/runner/processHost.ts index 381cebb3d1..2a3e00aa20 100644 --- a/packages/playwright/src/runner/processHost.ts +++ b/packages/playwright/src/runner/processHost.ts @@ -19,8 +19,9 @@ import { EventEmitter } from 'events'; import { debug } from 'playwright-core/lib/utilsBundle'; import type { EnvProducedPayload, ProcessInitParams } from '../common/ipc'; import type { ProtocolResponse } from '../common/process'; -import { execArgvWithExperimentalLoaderOptions } from '../util'; +import { execArgvWithExperimentalLoaderOptions } from '../transform/esmUtils'; import { assert } from 'playwright-core/lib/utils'; +import { esmLoaderRegistered } from '../common/esmLoaderHost'; export type ProcessExitData = { unexpectedly: boolean; @@ -51,14 +52,18 @@ export class ProcessHost extends EventEmitter { assert(!this.process, 'Internal error: starting the same process twice'); this.process = child_process.fork(require.resolve('../common/process'), { detached: false, - env: { ...process.env, ...this._extraEnv }, + env: { + ...process.env, + ...this._extraEnv, + ...(esmLoaderRegistered ? { PW_TS_ESM_LOADER_ON: '1' } : {}), + }, stdio: [ 'ignore', options.onStdOut ? 'pipe' : 'inherit', (options.onStdErr && !process.env.PW_RUNNER_DEBUG) ? 'pipe' : 'inherit', 'ipc', ], - ...(process.env.PW_TS_ESM_ON ? { execArgv: execArgvWithExperimentalLoaderOptions() } : {}), + ...(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/transform/esmLoader.ts b/packages/playwright/src/transform/esmLoader.ts index 1285193daf..4a02b8511e 100644 --- a/packages/playwright/src/transform/esmLoader.ts +++ b/packages/playwright/src/transform/esmLoader.ts @@ -66,8 +66,21 @@ async function load(moduleUrl: string, context: { format?: string }, defaultLoad let transport: PortTransport | undefined; +// Node.js < 20 function globalPreload(context: { port: MessagePort }) { - transport = new PortTransport(context.port, async (method, params) => { + transport = createTransport(context.port); + return ` + globalThis.__esmLoaderPortPreV20 = port; + `; +} + +// Node.js >= 20 +function initialize(data: { port: MessagePort }) { + transport = createTransport(data?.port); +} + +function createTransport(port: MessagePort) { + return new PortTransport(port, async (method, params) => { if (method === 'setTransformConfig') { setTransformConfig(params.config); return; @@ -91,10 +104,7 @@ function globalPreload(context: { port: MessagePort }) { return; } }); - - return ` - globalThis.__esmLoaderPort = port; - `; } -module.exports = { resolve, load, globalPreload }; + +module.exports = { resolve, load, globalPreload, initialize }; diff --git a/packages/playwright/src/transform/esmUtils.ts b/packages/playwright/src/transform/esmUtils.ts new file mode 100644 index 0000000000..31851f61d3 --- /dev/null +++ b/packages/playwright/src/transform/esmUtils.ts @@ -0,0 +1,33 @@ +/** + * 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)); +} diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 1e1acc62ba..17e406d514 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -286,22 +286,6 @@ function folderIsModule(folder: string): boolean { return require(packageJsonPath).type === 'module'; } -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)); -} - // This follows the --moduleResolution=bundler strategy from tsc. // https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-beta/#moduleresolution-bundler const kExtLookups = new Map([