Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 43 additions & 10 deletions packages/playwright/src/common/esmLoaderHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}

Expand Down
35 changes: 26 additions & 9 deletions packages/playwright/src/transform/esmLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,21 @@ 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);
const resolved = resolveHook(filename, specifier);
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://'))
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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 }) {
Expand Down Expand Up @@ -135,4 +152,4 @@ function createTransport(port: MessagePort) {
}


module.exports = { initialize, load, resolve };
module.exports = { initialize, load, loadSync, resolve, resolveSync };
38 changes: 38 additions & 0 deletions tests/playwright-test/loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': `
Expand Down