diff --git a/packages/playwright/src/common/esmLoaderHost.ts b/packages/playwright/src/common/esmLoaderHost.ts index b426810e4d50f..8886e4918c705 100644 --- a/packages/playwright/src/common/esmLoaderHost.ts +++ b/packages/playwright/src/common/esmLoaderHost.ts @@ -21,6 +21,33 @@ import { PortTransport } from '../transform/portTransport'; import { singleTSConfig, transformConfig } from '../transform/transform'; let loaderChannel: PortTransport | undefined; +let loaderRegisteredWithRegisterHooks = false; + +type NodeModuleWithRegisterHooks = { + register?: (specifier: any, options: any) => unknown; + registerHooks?: (hooks: { resolve: Function, load: Function }) => unknown; +}; + +type ESMHooks = { + resolve: Function; + load: Function; + resolveSync: Function; + loadSync: Function; +}; + +export function registerESMLoaderOnModuleForTest(nodeModule: NodeModuleWithRegisterHooks, hooks: ESMHooks, registerFallback: () => void): 'registerHooks' | 'register' | undefined { + // Node 26 prefers registerHooks for module loader hooks. Keep module.register + // as a fallback for older supported Node versions that do not expose it. + if (typeof nodeModule.registerHooks === 'function') { + nodeModule.registerHooks({ resolve: hooks.resolveSync, load: hooks.loadSync }); + return 'registerHooks'; + } + + if (typeof nodeModule.register === 'function') { + registerFallback(); + return 'register'; + } +} export function registerESMLoader() { // Opt-out switch. @@ -32,20 +59,26 @@ export function registerESMLoader() { if ('Bun' in globalThis) return true; - if (loaderChannel) + if (loaderChannel || loaderRegisteredWithRegisterHooks) return true; - const register = require('node:module').register; - if (!register) + const nodeModule = require('node:module') as NodeModuleWithRegisterHooks; + const esmLoader = require('../transform/esmLoader.js') as ESMHooks; + const mode = registerESMLoaderOnModuleForTest(nodeModule, esmLoader, () => { + const { port1, port2 } = new MessageChannel(); + // register will wait until the loader is initialized. + nodeModule.register!(url.pathToFileURL(require.resolve('../transform/esmLoader.js')), { + data: { port: port2 }, + transferList: [port2], + }); + loaderChannel = createPortTransport(port1); + }); + if (!mode) 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); + if (mode === 'registerHooks') + loaderRegisteredWithRegisterHooks = true; + return true; } diff --git a/packages/playwright/src/transform/esmLoader.ts b/packages/playwright/src/transform/esmLoader.ts index 57e8ab6cbf7bc..d30fb1f2c8ecf 100644 --- a/packages/playwright/src/transform/esmLoader.ts +++ b/packages/playwright/src/transform/esmLoader.ts @@ -23,12 +23,10 @@ import { resolveHook, setSingleTSConfig, setTransformConfig, shouldTransform, tr import { fileIsModule } from '../util'; // Before each import of the ESM module, a preflight request with the .esm.preflight extension is issued. -// When handled, it is resolved similarly to the reqular import, but loading it yields empty content. +// When handled, it is resolved similarly to the regular import, but loading it yields empty content. const esmPreflightExtension = '.esm.preflight'; -// Node < 18.6: defaultResolve takes 3 arguments. -// Node >= 18.6: nextResolve from the chain takes 2 arguments. -async function resolve(originalSpecifier: string, context: { parentURL?: string }, defaultResolve: Function) { +function resolveSpecifier(originalSpecifier: string, context: { parentURL?: string }) { let specifier = originalSpecifier.replace(esmPreflightExtension, ''); if (context.parentURL && context.parentURL.startsWith('file://')) { const filename = url.fileURLToPath(context.parentURL); @@ -36,7 +34,10 @@ async function resolve(originalSpecifier: string, context: { parentURL?: string if (resolved !== undefined) specifier = url.pathToFileURL(resolved).toString(); } - const result = await defaultResolve(specifier, context, defaultResolve); + return specifier; +} + +function finishResolve(originalSpecifier: string, result: { url?: string }) { // Note: we collect dependencies here that will be sent to the main thread // (and optionally runner process) after the loading finishes. if (result?.url && result.url.startsWith('file://')) @@ -47,6 +48,18 @@ async function resolve(originalSpecifier: string, context: { parentURL?: string return result; } +// Node < 18.6: defaultResolve takes 3 arguments. +// Node >= 18.6: nextResolve from the chain takes 2 arguments. +async function resolve(originalSpecifier: string, context: { parentURL?: string }, defaultResolve: Function) { + const specifier = resolveSpecifier(originalSpecifier, context); + return finishResolve(originalSpecifier, await defaultResolve(specifier, context, defaultResolve)); +} + +function resolveSync(originalSpecifier: string, context: { parentURL?: string }, defaultResolve: Function) { + const specifier = resolveSpecifier(originalSpecifier, context); + return finishResolve(originalSpecifier, defaultResolve(specifier, context, defaultResolve)); +} + // non-js files have undefined // some js files have null // {module/commonjs}-typescript are changed to {module,commonjs} because we handle typescript ourselves @@ -59,9 +72,7 @@ const kSupportedFormats = new Map([ [undefined, undefined] ]); -// Node < 18.6: defaultLoad takes 3 arguments. -// Node >= 18.6: nextLoad from the chain takes 2 arguments. -async function load(moduleUrl: string, context: { format?: string }, defaultLoad: Function) { +function loadSync(moduleUrl: string, context: { format?: string }, defaultLoad: Function) { // Bail out for wasm, json, etc. if (!kSupportedFormats.has(context.format)) return defaultLoad(moduleUrl, context, defaultLoad); @@ -96,6 +107,12 @@ async function load(moduleUrl: string, context: { format?: string }, defaultLoad }; } +// Node < 18.6: defaultLoad takes 3 arguments. +// Node >= 18.6: nextLoad from the chain takes 2 arguments. +async function load(moduleUrl: string, context: { format?: string }, defaultLoad: Function) { + return loadSync(moduleUrl, context, defaultLoad); +} + let transport: PortTransport | undefined; function initialize(data: { port: MessagePort }) { @@ -135,4 +152,4 @@ function createTransport(port: MessagePort) { } -module.exports = { initialize, load, resolve }; +module.exports = { initialize, load, loadSync, resolve, resolveSync }; diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index fcc56e0c79dfa..94523c7a08373 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -15,9 +15,47 @@ */ import { test, expect, playwrightCtConfigText } from './playwright-test-fixtures'; +import { registerESMLoaderOnModuleForTest } from '../../packages/playwright/src/common/esmLoaderHost'; import path from 'path'; import url from 'url'; +test('should prefer module.registerHooks when available for playwright ESM loader registration', () => { + const calls: string[] = []; + const hooks = { + resolve: () => {}, + load: () => {}, + resolveSync: () => {}, + loadSync: () => {}, + }; + + const mode = registerESMLoaderOnModuleForTest({ + registerHooks: registeredHooks => { + expect(registeredHooks).toEqual({ resolve: hooks.resolveSync, load: hooks.loadSync }); + calls.push('registerHooks'); + }, + register: () => calls.push('register'), + }, hooks, () => calls.push('register')); + + expect(mode).toBe('registerHooks'); + expect(calls).toEqual(['registerHooks']); +}); + +test('should fall back to module.register when registerHooks is unavailable for playwright ESM loader registration', () => { + const calls: string[] = []; + + const mode = registerESMLoaderOnModuleForTest({ + register: () => calls.push('nodeModule.register'), + }, { + resolve: () => {}, + load: () => {}, + resolveSync: () => {}, + loadSync: () => {}, + }, () => calls.push('register')); + + expect(mode).toBe('register'); + expect(calls).toEqual(['register']); +}); + test('should return the location of a syntax error', async ({ runInlineTest }) => { const result = await runInlineTest({ 'error.spec.js': `