diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index b7bd1c9b1bbe2..0af93470ee5aa 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -20,9 +20,8 @@ import path from 'path'; import { isRegExp } from '@isomorphic/rtti'; import { requireOrImport, setSingleTSConfig, setTransformConfig } from '../transform/transform'; -import { errorWithFile, fileIsModule } from '../util'; +import { errorWithFile } from '../util'; import { FullConfigInternal } from './config'; -import { configureESMLoader, configureESMLoaderTransformConfig, registerESMLoader } from './esmLoaderHost'; import { addToCompilationCache } from '../transform/compilationCache'; import type { ConfigLocation } from './config'; @@ -101,17 +100,8 @@ 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.19 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(); + // 1. Set the initial tsconfig before loading the config file. + await setSingleTSConfig(overrides?.tsconfig); // 2. Load and validate playwright config. const userConfig = await loadUserConfig(location); @@ -129,12 +119,9 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI const babelPlugins = (userConfig as any)['@playwright/test']?.babelPlugins || []; const external = userConfig.build?.external || []; const jsxImportSource = path.dirname(require.resolve('playwright')); - setTransformConfig({ babelPlugins, external, jsxImportSource }); + await setTransformConfig({ babelPlugins, external, jsxImportSource }); if (!overrides?.tsconfig) - setSingleTSConfig(fullConfig?.singleTSConfigPath); - - // 4. Send transform options to ESM loader. - await configureESMLoaderTransformConfig(); + await setSingleTSConfig(fullConfig?.singleTSConfigPath); return fullConfig; } diff --git a/packages/playwright/src/common/esmLoaderHost.ts b/packages/playwright/src/common/esmLoaderHost.ts deleted file mode 100644 index b426810e4d50f..0000000000000 --- a/packages/playwright/src/common/esmLoaderHost.ts +++ /dev/null @@ -1,93 +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'; - -import { addToCompilationCache, serializeCompilationCache } from '../transform/compilationCache'; -import { PortTransport } from '../transform/portTransport'; -import { singleTSConfig, transformConfig } from '../transform/transform'; - -let loaderChannel: PortTransport | undefined; - -export function registerESMLoader() { - // Opt-out switch. - if (process.env.PW_DISABLE_TS_ESM) - return true; - - // Transpilation in `bun` is not necessary, and trying to register a hook would cause issues. - // https://github.com/oven-sh/bun/issues/8222#issuecomment-3665364677 - if ('Bun' in globalThis) - 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. - register(url.pathToFileURL(require.resolve('../transform/esmLoader.js')), { - data: { port: port2 }, - transferList: [port2], - }); - loaderChannel = createPortTransport(port1); - return true; -} - -function createPortTransport(port: MessagePort) { - return new PortTransport(port, async (method, params) => { - if (method === 'pushToCompilationCache') - addToCompilationCache(params.cache); - }); -} - -export async function startCollectingFileDeps() { - if (!loaderChannel) - return; - await loaderChannel.send('startCollectingFileDeps', {}); -} - -export async function stopCollectingFileDeps(file: string) { - if (!loaderChannel) - return; - await loaderChannel.send('stopCollectingFileDeps', { file }); -} - -export async function incorporateCompilationCache() { - if (!loaderChannel) - return; - // This is needed to gather dependency information from the esm loader - // that is populated from the resolve hook. We do not need to push - // this information proactively during load, but gather it at the end. - const result = await loaderChannel.send('getCompilationCache', {}); - addToCompilationCache(result.cache); -} - -export async function configureESMLoader() { - if (!loaderChannel) - return; - await loaderChannel.send('setSingleTSConfig', { tsconfig: singleTSConfig() }); - await loaderChannel.send('addToCompilationCache', { cache: serializeCompilationCache() }); -} - -export async function configureESMLoaderTransformConfig() { - if (!loaderChannel) - return; - await loaderChannel.send('setSingleTSConfig', { tsconfig: singleTSConfig() }); - await loaderChannel.send('setTransformConfig', { config: transformConfig() }); -} diff --git a/packages/playwright/src/common/index.ts b/packages/playwright/src/common/index.ts index 225e229b8b56f..a3d8b2fdef574 100644 --- a/packages/playwright/src/common/index.ts +++ b/packages/playwright/src/common/index.ts @@ -17,7 +17,6 @@ export * as cc from '../transform/compilationCache'; export * as config from './config'; export * as configLoader from './configLoader'; -export * as esm from './esmLoaderHost'; export * as fixtures from './fixtures'; export * as ipc from './ipc'; export * as poolBuilder from './poolBuilder'; diff --git a/packages/playwright/src/common/testLoader.ts b/packages/playwright/src/common/testLoader.ts index 52eb49a5f6a6b..6e982cb220cd6 100644 --- a/packages/playwright/src/common/testLoader.ts +++ b/packages/playwright/src/common/testLoader.ts @@ -17,11 +17,9 @@ import path from 'path'; import util from 'util'; -import * as esmLoaderHost from './esmLoaderHost'; import { isWorkerProcess, setCurrentlyLoadingFileSuite } from '../globals'; import { Suite } from './test'; -import { startCollectingFileDeps, stopCollectingFileDeps } from '../transform/compilationCache'; -import { requireOrImport } from '../transform/transform'; +import { requireOrImport, startCollectingFileDeps, stopCollectingFileDeps } from '../transform/transform'; import { filterStackTrace } from '../util'; import type { TestError } from '../../types/testReporter'; @@ -42,10 +40,8 @@ export async function loadTestFile(file: string, config: FullConfigInternal, tes suite._tags = [...config.config.tags]; setCurrentlyLoadingFileSuite(suite); - if (!isWorkerProcess()) { - startCollectingFileDeps(); - await esmLoaderHost.startCollectingFileDeps(); - } + if (!isWorkerProcess()) + await startCollectingFileDeps(); try { await requireOrImport(file); cachedFileSuites.set(file, suite); @@ -55,10 +51,8 @@ export async function loadTestFile(file: string, config: FullConfigInternal, tes testErrors.push(serializeLoadError(file, e)); } finally { setCurrentlyLoadingFileSuite(undefined); - if (!isWorkerProcess()) { - stopCollectingFileDeps(file); - await esmLoaderHost.stopCollectingFileDeps(file); - } + if (!isWorkerProcess()) + await stopCollectingFileDeps(file); } { diff --git a/packages/playwright/src/loader/loaderMain.ts b/packages/playwright/src/loader/loaderMain.ts index e8f95d0918705..f8b97e29d1566 100644 --- a/packages/playwright/src/loader/loaderMain.ts +++ b/packages/playwright/src/loader/loaderMain.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { cc, configLoader, esm, FullConfigInternal, ipc, poolBuilder, ProcessRunner, testLoader } from '../common'; +import { cc, configLoader, FullConfigInternal, ipc, poolBuilder, ProcessRunner, testLoader, transform } from '../common'; import type { TestError } from '../../types/testReporter'; @@ -43,7 +43,7 @@ export class LoaderMain extends ProcessRunner { } async getCompilationCacheFromLoader() { - await esm.incorporateCompilationCache(); + await transform.incorporateCompilationCache(); return cc.serializeCompilationCache(); } } diff --git a/packages/playwright/src/runner/loaderHost.ts b/packages/playwright/src/runner/loaderHost.ts index d08587a16ca10..1f41e5d7f22e1 100644 --- a/packages/playwright/src/runner/loaderHost.ts +++ b/packages/playwright/src/runner/loaderHost.ts @@ -15,7 +15,7 @@ */ import { ProcessHost } from './processHost'; -import { cc, esm, FullConfigInternal, ipc, poolBuilder, test as testNs, testLoader } from '../common'; +import { cc, FullConfigInternal, ipc, poolBuilder, test as testNs, testLoader, transform } from '../common'; import type { TestError } from '../../types/testReporter'; @@ -40,7 +40,7 @@ export class InProcessLoaderHost { } async stop() { - await esm.incorporateCompilationCache(); + await transform.incorporateCompilationCache(); } } diff --git a/packages/playwright/src/transform/esmLoader.ts b/packages/playwright/src/transform/esmLoader.ts index 57e8ab6cbf7bc..ed90b79403b38 100644 --- a/packages/playwright/src/transform/esmLoader.ts +++ b/packages/playwright/src/transform/esmLoader.ts @@ -105,12 +105,12 @@ function initialize(data: { port: MessagePort }) { function createTransport(port: MessagePort) { return new PortTransport(port, async (method, params) => { if (method === 'setSingleTSConfig') { - setSingleTSConfig(params.tsconfig); + await setSingleTSConfig(params.tsconfig); return; } if (method === 'setTransformConfig') { - setTransformConfig(params.config); + await setTransformConfig(params.config); return; } diff --git a/packages/playwright/src/transform/esmLoaderSync.ts b/packages/playwright/src/transform/esmLoaderSync.ts new file mode 100644 index 0000000000000..13457a7979fce --- /dev/null +++ b/packages/playwright/src/transform/esmLoaderSync.ts @@ -0,0 +1,84 @@ +/** + * 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 fs from 'fs'; +import url from 'url'; + +import { currentFileDepsCollector } from './compilationCache'; +import { resolveHook, shouldTransform, transformHook } from './transform'; +import { fileIsModule } from '../util'; + +export function resolve(specifier: string, context: { parentURL?: string }, nextResolve: Function) { + if (context.parentURL && context.parentURL.startsWith('file://')) { + const filename = url.fileURLToPath(context.parentURL); + const resolved = resolveHook(filename, specifier); + // Pass the resolved absolute path (not a file:// URL) to nextResolve: unlike the + // ESM-only asynchronous loader, these hooks also serve require(), whose default + // resolver rejects file:// specifiers but accepts absolute paths for both kinds. + if (resolved !== undefined) + specifier = resolved; + } + const result = nextResolve(specifier, context); + if (result?.url && result.url.startsWith('file://')) + currentFileDepsCollector()?.add(url.fileURLToPath(result.url)); + return result; +} + +// non-js files have undefined +// some js files have null +// {module/commonjs}-typescript are changed to {module,commonjs} because we handle typescript ourselves +// plain 'typescript' (a require()-d .ts with module kind not yet determined) maps to null, +// so the module kind is computed below via fileIsModule(). +const kSupportedFormats = new Map([ + ['commonjs', 'commonjs'], + ['module', 'module'], + ['commonjs-typescript', 'commonjs'], + ['module-typescript', 'module'], + ['typescript', null], + [null, null], + [undefined, undefined] +]); + +export function load(moduleUrl: string, context: { format?: string }, nextLoad: Function) { + // Bail out for wasm, json, etc. + if (!kSupportedFormats.has(context.format)) + return nextLoad(moduleUrl, context); + + // Bail for built-in modules. + if (!moduleUrl.startsWith('file://')) + return nextLoad(moduleUrl, context); + + const filename = url.fileURLToPath(moduleUrl); + + // Bail for node_modules. + if (!shouldTransform(filename)) + return nextLoad(moduleUrl, context); + + // Output format is required, so we determine it manually when unknown. + const format = kSupportedFormats.get(context.format) || (fileIsModule(filename) ? 'module' : 'commonjs'); + + const code = fs.readFileSync(filename, 'utf-8'); + // Pass `moduleUrl` only for ESM. For CommonJS we omit it so that babel + // down-transpiles `import`/`export` to `require`/`exports`. + const transformed = transformHook(code, filename, format === 'module' ? moduleUrl : undefined); + + // shortCircuit is required to designate no more loaders should be called. + return { + format, + source: transformed.code, + shortCircuit: true, + }; +} diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 9811d97eabedf..c88c228f4188a 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -18,15 +18,15 @@ import fs from 'fs'; import Module from 'module'; import path from 'path'; import url from 'url'; - import crypto from 'crypto'; - import sourceMapSupport from 'source-map-support'; import { loadTsConfig } from './tsconfig-loader'; import { libPath, packageJSON } from '../package'; import { createFileMatcher, debugTest, fileIsModule, resolveImportSpecifierAfterMapping } from '../util'; -import { belongsToNodeModules, currentFileDepsCollector, getFromCompilationCache, installSourceMapSupport } from './compilationCache'; +import * as cc from './compilationCache'; +import * as esmLoaderSync from './esmLoaderSync'; import { addHook } from './pirates'; +import { PortTransport } from './portTransport'; import type { BabelPlugin, BabelTransformFunction } from './babelBundle'; import type { Location } from '../../types/testReporter'; @@ -56,24 +56,20 @@ let _transformConfig: TransformConfig = { let _externalMatcher: Matcher = () => false; -export function setTransformConfig(config: TransformConfig) { +export async function setTransformConfig(config: TransformConfig) { _transformConfig = config; _externalMatcher = createFileMatcher(_transformConfig.external); -} - -export function transformConfig(): TransformConfig { - return _transformConfig; + if (loaderChannel) + await loaderChannel.send('setTransformConfig', { config }); } let _singleTSConfigPath: string | undefined; let _singleTSConfig: ParsedTsConfigData[] | undefined; -export function setSingleTSConfig(value: string | undefined) { +export async function setSingleTSConfig(value: string | undefined) { _singleTSConfigPath = value; -} - -export function singleTSConfig(): string | undefined { - return _singleTSConfigPath; + if (loaderChannel) + await loaderChannel.send('setSingleTSConfig', { tsconfig: value }); } function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData { @@ -212,7 +208,7 @@ export function resolveHook(filename: string, specifier: string): string | undef export function shouldTransform(filename: string): boolean { if (_externalMatcher(filename)) return false; - return !belongsToNodeModules(filename); + return !cc.belongsToNodeModules(filename); } let transformData: Map; @@ -229,7 +225,7 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? const pluginsPrologue = _transformConfig.babelPlugins; const pluginsEpilogue = hasPreprocessor ? [[process.env.PW_TEST_SOURCE_TRANSFORM!]] as BabelPlugin[] : []; const hash = calculateHash(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue); - const { cachedCode, addToCache, serializedCache } = getFromCompilationCache(filename, hash, moduleUrl); + const { cachedCode, addToCache, serializedCache } = cc.getFromCompilationCache(filename, hash, moduleUrl); if (cachedCode !== undefined) return { code: cachedCode, serializedCache }; @@ -276,11 +272,15 @@ export async function requireOrImport(file: string) { const fileName = url.pathToFileURL(file); const esmImport = () => eval(`import(${JSON.stringify(fileName)})`); - // For ESM imports, issue a preflight to populate the compilation cache with the - // source maps. This allows inline test() calls to resolve wrapFunctionWithLocation. - await eval(`import(${JSON.stringify(fileName + '.esm.preflight')})`) - .catch((error: any) => debugTest('Failed to load preflight for ' + file + ', source maps may be missing for errors thrown during loading.', error)) - .finally(nextTask); + // For ESM imports handled by the asynchronous loader, issue a preflight to populate + // the compilation cache with the source maps. This allows inline test() calls to + // resolve wrapFunctionWithLocation. The synchronous loader populates the cache + // in-process, so no preflight is needed. + if (loaderChannel) { + await eval(`import(${JSON.stringify(fileName + '.esm.preflight')})`) + .catch((error: any) => debugTest('Failed to load preflight for ' + file + ', source maps may be missing for errors thrown during loading.', error)) + .finally(nextTask); + } // Compilation cache, which includes source maps, is populated in a post task. // When importing a module results in an error, the very next access to `error.stack` @@ -290,11 +290,20 @@ export async function requireOrImport(file: string) { return await esmImport().finally(nextTask); } const result = require(file); - const depsCollector = currentFileDepsCollector(); + const depsCollector = cc.currentFileDepsCollector(); if (depsCollector) { const module = require.cache[file]; - if (module) - collectCJSDependencies(module, depsCollector); + if (module) { + // Walk the CJS module tree into a fresh set, then merge into the global + // collector. We can't pass `depsCollector` directly: the sync loader's + // resolve hook pre-populates it, and Node short-circuits that hook for + // already-resolved (parent_dir, request) pairs via its relativeResolveCache, + // so the walker would skip transitive deps the hook missed. + const cjsDeps = new Set(); + collectCJSDependencies(module, cjsDeps); + for (const dep of cjsDeps) + depsCollector.add(dep); + } } return result; } @@ -306,8 +315,25 @@ function installTransformIfNeeded() { return; transformInstalled = true; - installSourceMapSupport(); + registerESMLoader(); + cc.installSourceMapSupport(); + + // Async ESM loader ony covers "import", so install CJS hooks to cover "require". + if (loaderChannel) { + installCJSHooks(); + return; + } + // Sync hooks intercept `require()`, but not the `require.resolve(id, { paths })` form. + // The mere presence of these dummy loaders teaches the default resolver that our extensions + // should be considered. + // Hopefully, one day `registerHooks({ resolve })` will also handle `require.resolve()`. + const extensions = (Module as any)._extensions; + for (const ext of ['.ts', '.cts', '.tsx', '.jsx']) + extensions[ext] = extensions['.js']; +} + +function installCJSHooks() { const originalResolveFilename = (Module as any)._resolveFilename; function resolveFilename(this: any, specifier: string, parent: Module, ...rest: any[]) { if (parent) { @@ -319,7 +345,6 @@ function installTransformIfNeeded() { } (Module as any)._resolveFilename = resolveFilename; - // Hopefully, one day we can migrate to synchronous loader hooks instead, similar to our esmLoader... addHook((code, filename) => { return transformHook(code, filename).code; }, shouldTransform, ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cjs', '.cts']); @@ -327,7 +352,7 @@ function installTransformIfNeeded() { const collectCJSDependencies = (module: Module, dependencies: Set) => { module.children.forEach(child => { - if (!belongsToNodeModules(child.filename) && !dependencies.has(child.filename)) { + if (!cc.belongsToNodeModules(child.filename) && !dependencies.has(child.filename)) { dependencies.add(child.filename); collectCJSDependencies(child, dependencies); } @@ -366,3 +391,65 @@ function isRelativeSpecifier(specifier: string) { async function nextTask() { return new Promise(resolve => setTimeout(resolve, 0)); } + +let loaderChannel: PortTransport | undefined; + +function registerESMLoader() { + // Opt-out switch. + if (process.env.PW_DISABLE_TS_ESM) + return; + + // Transpilation in `bun` is not necessary, and trying to register a hook would cause issues. + // https://github.com/oven-sh/bun/issues/8222#issuecomment-3665364677 + if ('Bun' in globalThis) + return; + + const nodeModule = require('node:module'); + + if (nodeModule.registerHooks && !process.env.PLAYWRIGHT_FORCE_ASYNC_LOADER) { + nodeModule.registerHooks({ resolve: esmLoaderSync.resolve, load: esmLoaderSync.load }); + return; + } + + if (!nodeModule.register) + return; + + const { port1, port2 } = new MessageChannel(); + // register will wait until the loader is initialized. The path is relative to + // the bundle output layout (lib/common/index.js → ../transform/esmLoader.js), + // not the source layout — esmLoader.js is its own esbuild entry point. + nodeModule.register(url.pathToFileURL(require.resolve('../transform/esmLoader.js')), { + data: { port: port2 }, + transferList: [port2], + }); + loaderChannel = new PortTransport(port1, async (method, params) => { + if (method === 'pushToCompilationCache') + cc.addToCompilationCache(params.cache); + }); + // Seed the loader thread with the state accumulated so far. Subsequent updates + // are pushed by setSingleTSConfig() / setTransformConfig() / startCollectingFileDeps(). + void loaderChannel.send('setSingleTSConfig', { tsconfig: _singleTSConfigPath }); + void loaderChannel.send('setTransformConfig', { config: _transformConfig }); + void loaderChannel.send('addToCompilationCache', { cache: cc.serializeCompilationCache() }); +} + +export async function startCollectingFileDeps() { + cc.startCollectingFileDeps(); + if (loaderChannel) + await loaderChannel.send('startCollectingFileDeps', {}); +} + +export async function stopCollectingFileDeps(file: string) { + cc.stopCollectingFileDeps(file); + if (loaderChannel) + await loaderChannel.send('stopCollectingFileDeps', { file }); +} + +export async function incorporateCompilationCache() { + if (!loaderChannel) + return; + // Gather dependency information from the esm loader that was populated by + // its resolve hook. We don't push this proactively during load — only at end. + const result = await loaderChannel.send('getCompilationCache', {}); + cc.addToCompilationCache(result.cache); +} diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index f21353c8796be..6f6063de5295a 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -274,12 +274,23 @@ export function fileIsModule(file: string): boolean { return folderIsModule(folder); } +const packageJsonIsModuleCache = new Map(); + function folderIsModule(folder: string): boolean { const packageJsonPath = getPackageJsonPath(folder); if (!packageJsonPath) return false; - // Rely on `require` internal caching logic. - return require(packageJsonPath).type === 'module'; + // Note: do not `require()` the package.json here to avoid running + // our resolve hook from inside itself. + if (!packageJsonIsModuleCache.has(packageJsonPath)) { + let isModule = false; + try { + isModule = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).type === 'module'; + } catch { + } + packageJsonIsModuleCache.set(packageJsonPath, isModule); + } + return packageJsonIsModuleCache.get(packageJsonPath)!; } const packageJsonMainFieldCache = new Map(); diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index fcc56e0c79dfa..97c0f364101cf 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -1196,7 +1196,7 @@ test('should compose with a custom ESM loader before playwright', { expect(1 + 1).toBe(2); }); `, - }); + }, {}, { PLAYWRIGHT_FORCE_ASYNC_LOADER: '1' }); expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); @@ -1259,6 +1259,7 @@ test('should compose with a custom ESM loader after playwright', { `, }, {}, { NODE_OPTIONS: `--import ${url.pathToFileURL(testInfo.outputPath('register-loader.mjs')).toString()}`, + PLAYWRIGHT_FORCE_ASYNC_LOADER: '1', }); expect(result.exitCode).toBe(0); @@ -1309,7 +1310,7 @@ test('preflight should survive faulty ESM loader ahead of playwright', { expect(1 + 1).toBe(2); }); `, - }, {}, { DEBUG: 'pw:test' }); + }, {}, { DEBUG: 'pw:test', PLAYWRIGHT_FORCE_ASYNC_LOADER: '1' }); expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); diff --git a/utils/build/build.js b/utils/build/build.js index 3ef3339cd13d1..964c2a620fdb8 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -645,9 +645,11 @@ function assertCoreBundleHasNoNodeModules() { steps.push(new CustomCallbackStep(assertCoreBundleHasNoNodeModules)); // playwright/lib/transform/esmLoader.js — bundled ESM loader registered by -// common/esmLoaderHost.ts via node:module register. Output sits next to -// babelBundle.js so source-relative `./babelBundle` matches the runtime -// sibling external. +// transform.ts via node:module register. Output sits next to babelBundle.js +// so source-relative `./babelBundle` matches the runtime sibling external. +// '../transform/esmLoader.js' is also external: transform.ts has a +// require.resolve() for it (dead code in this bundle, but esbuild still +// parses it). { const playwrightSrc = filePath('packages/playwright/src'); steps.push(new EsbuildStep({ @@ -659,6 +661,7 @@ steps.push(new CustomCallbackStep(assertCoreBundleHasNoNodeModules)); 'playwright-core/*', '../package', '../globals', + '../transform/esmLoader.js', ], plugins: [], }, [playwrightSrc]));