diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 59ba309b2e..08a9c0a48d 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -334,25 +334,33 @@ export async function loadEmptyConfigForMergeReports() { } export function restartWithExperimentalTsEsm(configFile: string | undefined, force: boolean = false): boolean { - const nodeVersion = +process.versions.node.split('.')[0]; - // New experimental loader is only supported on Node 16+. - if (nodeVersion < 16) - return false; - if (!configFile && !force) - return false; + // Opt-out switch. if (process.env.PW_DISABLE_TS_ESM) return false; - // Node.js < 20 + + // 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; } - if (!force && !fileIsModule(configFile!)) - return false; - // Node.js < 20 + // Now check for the newer API presence. if (!require('node:module').register) { + // Older API is experimental, only supported on Node 16+. + const nodeVersion = +process.versions.node.split('.')[0]; + if (nodeVersion < 16) + return false; + + // 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, @@ -367,7 +375,8 @@ export function restartWithExperimentalTsEsm(configFile: string | undefined, for }); return true; } - // Nodejs >= 21 + + // With the newer API, always enable the ESM loader, because it does not need a restart. registerESMLoader(); return false; } diff --git a/tests/playwright-test/esm.spec.ts b/tests/playwright-test/esm.spec.ts index 7097659d0d..ff542f212e 100644 --- a/tests/playwright-test/esm.spec.ts +++ b/tests/playwright-test/esm.spec.ts @@ -552,7 +552,9 @@ test('should load cjs config and test in non-ESM mode', async ({ runInlineTest } expect(result.passed).toBe(2); }); -test('should disallow ESM when config is cjs', async ({ runInlineTest }) => { +test('should allow ESM when config is cjs', async ({ runInlineTest, nodeVersion }) => { + test.skip(nodeVersion.major < 18, 'ESM loader is enabled conditionally with older API'); + const result = await runInlineTest({ 'package.json': `{ "type": "module" }`, 'playwright.config.cjs': ` @@ -567,8 +569,24 @@ test('should disallow ESM when config is cjs', async ({ runInlineTest }) => { `, }); - expect(result.exitCode).toBe(1); - expect(result.output).toContain('Unknown file extension ".ts"'); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should load mts without config', async ({ runInlineTest, nodeVersion }) => { + test.skip(nodeVersion.major < 18, 'ESM loader is enabled conditionally with older API'); + + const result = await runInlineTest({ + 'a.test.mts': ` + import { test, expect } from '@playwright/test'; + test('check project name', ({}, testInfo) => { + expect(true).toBe(true); + }); + `, + }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); }); test('should be able to use use execSync with a Node.js file inside a spec', async ({ runInlineTest }) => {