mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(esm): allow running tests in type module projects (#10503)
This commit is contained in:
parent
685892dd62
commit
7eb3f76f49
6
.github/workflows/tests_primary.yml
vendored
6
.github/workflows/tests_primary.yml
vendored
@ -55,12 +55,16 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
node-version: [12]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
node-version: 16
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: ${{matrix.node-version}}
|
||||
- run: npm i -g npm@8
|
||||
- run: npm ci
|
||||
env:
|
||||
|
42
packages/playwright-test/src/experimentalLoader.ts
Normal file
42
packages/playwright-test/src/experimentalLoader.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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 { transformHook } from './transform';
|
||||
|
||||
async function resolve(specifier: string, context: { parentURL: string }, defaultResolve: any) {
|
||||
if (specifier.endsWith('.js') || specifier.endsWith('.ts') || specifier.endsWith('.mjs'))
|
||||
return defaultResolve(specifier, context, defaultResolve);
|
||||
let url = new URL(specifier, context.parentURL).toString();
|
||||
url = url.substring('file://'.length);
|
||||
if (fs.existsSync(url + '.ts'))
|
||||
return defaultResolve(specifier + '.ts', context, defaultResolve);
|
||||
if (fs.existsSync(url + '.js'))
|
||||
return defaultResolve(specifier + '.js', context, defaultResolve);
|
||||
return defaultResolve(specifier, context, defaultResolve);
|
||||
}
|
||||
|
||||
async function load(url: string, context: any, defaultLoad: any) {
|
||||
if (url.endsWith('.ts')) {
|
||||
const filename = url.substring('file://'.length);
|
||||
const code = fs.readFileSync(filename, 'utf-8');
|
||||
const source = transformHook(code, filename, true);
|
||||
return { format: 'module', source };
|
||||
}
|
||||
return defaultLoad(url, context, defaultLoad);
|
||||
}
|
||||
|
||||
module.exports = { resolve, load };
|
@ -36,6 +36,7 @@ export class Loader {
|
||||
private _configFile: string | undefined;
|
||||
private _projects: ProjectImpl[] = [];
|
||||
private _fileSuites = new Map<string, Suite>();
|
||||
private _lastModuleInfo: { rootFolder: string, isModule: boolean } | null = null;
|
||||
|
||||
constructor(defaultConfig: Config, configOverrides: Config) {
|
||||
this._defaultConfig = defaultConfig;
|
||||
@ -192,20 +193,37 @@ export class Loader {
|
||||
|
||||
private async _requireOrImport(file: string) {
|
||||
const revertBabelRequire = installTransform();
|
||||
try {
|
||||
const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`);
|
||||
if (file.endsWith('.mjs')) {
|
||||
return await esmImport();
|
||||
} else {
|
||||
|
||||
// Figure out if we are importing or requiring.
|
||||
let isModule: boolean;
|
||||
if (file.endsWith('.mjs')) {
|
||||
isModule = true;
|
||||
} else {
|
||||
if (!this._lastModuleInfo || !file.startsWith(this._lastModuleInfo.rootFolder)) {
|
||||
this._lastModuleInfo = null;
|
||||
try {
|
||||
return require(file);
|
||||
} catch (e) {
|
||||
// Attempt to load this module as ESM if a normal require didn't work.
|
||||
if (e.code === 'ERR_REQUIRE_ESM')
|
||||
return await esmImport();
|
||||
throw e;
|
||||
const pathSegments = file.split(path.sep);
|
||||
for (let i = pathSegments.length - 1; i >= 0; --i) {
|
||||
const rootFolder = pathSegments.slice(0, i).join(path.sep);
|
||||
const packageJson = path.join(rootFolder, 'package.json');
|
||||
if (fs.existsSync(packageJson)) {
|
||||
isModule = require(packageJson).type === 'module';
|
||||
this._lastModuleInfo = { rootFolder, isModule };
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent catch.
|
||||
}
|
||||
}
|
||||
isModule = this._lastModuleInfo?.isModule || false;
|
||||
}
|
||||
|
||||
try {
|
||||
const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`);
|
||||
if (isModule)
|
||||
return await esmImport();
|
||||
return require(file);
|
||||
} catch (error) {
|
||||
if (error.code === 'ERR_MODULE_NOT_FOUND' && error.message.includes('Did you mean to import')) {
|
||||
const didYouMean = /Did you mean to import (.*)\?/.exec(error.message)?.[1];
|
||||
|
@ -52,55 +52,60 @@ function calculateCachePath(content: string, filePath: string): string {
|
||||
return path.join(cacheDir, hash[0] + hash[1], fileName);
|
||||
}
|
||||
|
||||
export function transformHook(code: string, filename: string, isModule = false): string {
|
||||
const cachePath = calculateCachePath(code, filename);
|
||||
const codePath = cachePath + '.js';
|
||||
const sourceMapPath = cachePath + '.map';
|
||||
sourceMaps.set(filename, sourceMapPath);
|
||||
if (fs.existsSync(codePath))
|
||||
return fs.readFileSync(codePath, 'utf8');
|
||||
// We don't use any browserslist data, but babel checks it anyway.
|
||||
// Silence the annoying warning.
|
||||
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
|
||||
const babel: typeof import('@babel/core') = require('@babel/core');
|
||||
|
||||
const plugins = [
|
||||
[require.resolve('@babel/plugin-proposal-class-properties')],
|
||||
[require.resolve('@babel/plugin-proposal-numeric-separator')],
|
||||
[require.resolve('@babel/plugin-proposal-logical-assignment-operators')],
|
||||
[require.resolve('@babel/plugin-proposal-nullish-coalescing-operator')],
|
||||
[require.resolve('@babel/plugin-proposal-optional-chaining')],
|
||||
[require.resolve('@babel/plugin-syntax-json-strings')],
|
||||
[require.resolve('@babel/plugin-syntax-optional-catch-binding')],
|
||||
[require.resolve('@babel/plugin-syntax-async-generators')],
|
||||
[require.resolve('@babel/plugin-syntax-object-rest-spread')],
|
||||
[require.resolve('@babel/plugin-proposal-export-namespace-from')],
|
||||
];
|
||||
if (!isModule) {
|
||||
plugins.push([require.resolve('@babel/plugin-transform-modules-commonjs')]);
|
||||
plugins.push([require.resolve('@babel/plugin-proposal-dynamic-import')]);
|
||||
}
|
||||
|
||||
const result = babel.transformFileSync(filename, {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
assumptions: {
|
||||
// Without this, babel defines a top level function that
|
||||
// breaks playwright evaluates.
|
||||
setPublicClassFields: true,
|
||||
},
|
||||
presets: [
|
||||
[require.resolve('@babel/preset-typescript'), { onlyRemoveTypeImports: true }],
|
||||
],
|
||||
plugins,
|
||||
sourceMaps: 'both',
|
||||
} as babel.TransformOptions)!;
|
||||
if (result.code) {
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
if (result.map)
|
||||
fs.writeFileSync(sourceMapPath, JSON.stringify(result.map), 'utf8');
|
||||
fs.writeFileSync(codePath, result.code, 'utf8');
|
||||
}
|
||||
return result.code || '';
|
||||
}
|
||||
|
||||
export function installTransform(): () => void {
|
||||
return pirates.addHook((code, filename) => {
|
||||
const cachePath = calculateCachePath(code, filename);
|
||||
const codePath = cachePath + '.js';
|
||||
const sourceMapPath = cachePath + '.map';
|
||||
sourceMaps.set(filename, sourceMapPath);
|
||||
if (fs.existsSync(codePath))
|
||||
return fs.readFileSync(codePath, 'utf8');
|
||||
// We don't use any browserslist data, but babel checks it anyway.
|
||||
// Silence the annoying warning.
|
||||
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
|
||||
const babel: typeof import('@babel/core') = require('@babel/core');
|
||||
const result = babel.transformFileSync(filename, {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
assumptions: {
|
||||
// Without this, babel defines a top level function that
|
||||
// breaks playwright evaluates.
|
||||
setPublicClassFields: true,
|
||||
},
|
||||
presets: [
|
||||
[require.resolve('@babel/preset-typescript'), { onlyRemoveTypeImports: true }],
|
||||
],
|
||||
plugins: [
|
||||
[require.resolve('@babel/plugin-proposal-class-properties')],
|
||||
[require.resolve('@babel/plugin-proposal-numeric-separator')],
|
||||
[require.resolve('@babel/plugin-proposal-logical-assignment-operators')],
|
||||
[require.resolve('@babel/plugin-proposal-nullish-coalescing-operator')],
|
||||
[require.resolve('@babel/plugin-proposal-optional-chaining')],
|
||||
[require.resolve('@babel/plugin-syntax-json-strings')],
|
||||
[require.resolve('@babel/plugin-syntax-optional-catch-binding')],
|
||||
[require.resolve('@babel/plugin-syntax-async-generators')],
|
||||
[require.resolve('@babel/plugin-syntax-object-rest-spread')],
|
||||
[require.resolve('@babel/plugin-proposal-export-namespace-from')],
|
||||
[require.resolve('@babel/plugin-transform-modules-commonjs')],
|
||||
[require.resolve('@babel/plugin-proposal-dynamic-import')],
|
||||
],
|
||||
sourceMaps: 'both',
|
||||
} as babel.TransformOptions)!;
|
||||
if (result.code) {
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
if (result.map)
|
||||
fs.writeFileSync(sourceMapPath, JSON.stringify(result.map), 'utf8');
|
||||
fs.writeFileSync(codePath, result.code, 'utf8');
|
||||
}
|
||||
return result.code || '';
|
||||
}, {
|
||||
exts: ['.ts']
|
||||
});
|
||||
return pirates.addHook(transformHook, { exts: ['.ts'] });
|
||||
}
|
||||
|
||||
export function wrapFunctionWithLocation<A extends any[], R>(func: (location: Location, ...args: A) => R): (...args: A) => R {
|
||||
|
@ -185,7 +185,7 @@ test('should load esm when package.json has type module', async ({ runInlineTest
|
||||
export default { projects: [{name: 'foo'}] };
|
||||
`,
|
||||
'package.json': JSON.stringify({ type: 'module' }),
|
||||
'a.test.ts': `
|
||||
'a.esm.test.js': `
|
||||
const { test } = pwt;
|
||||
test('check project name', ({}, testInfo) => {
|
||||
expect(testInfo.project.name).toBe('foo');
|
||||
@ -239,3 +239,29 @@ test('should fail to load ts from esm when package.json has type module', async
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.output).toContain('Cannot import a typescript file from an esmodule');
|
||||
});
|
||||
|
||||
test('should import esm from ts when package.json has type module in experimental mode', async ({ runInlineTest }) => {
|
||||
// We only support experimental esm mode on Node 16+
|
||||
test.skip(parseInt(process.version.slice(1), 10) < 16);
|
||||
const result = await runInlineTest({
|
||||
'playwright.config.ts': `
|
||||
import * as fs from 'fs';
|
||||
export default { projects: [{name: 'foo'}] };
|
||||
`,
|
||||
'package.json': JSON.stringify({ type: 'module' }),
|
||||
'a.test.ts': `
|
||||
import { foo } from './b.ts';
|
||||
const { test } = pwt;
|
||||
test('check project name', ({}, testInfo) => {
|
||||
expect(testInfo.project.name).toBe('foo');
|
||||
});
|
||||
`,
|
||||
'b.ts': `
|
||||
export const foo: string = 'foo';
|
||||
`
|
||||
}, {}, {
|
||||
NODE_OPTIONS: `--experimental-loader=${require.resolve('../../packages/playwright-test/lib/experimentalLoader.js')}`
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
@ -55,7 +55,7 @@ async function writeFiles(testInfo: TestInfo, files: Files) {
|
||||
const headerTS = `
|
||||
import * as pwt from '@playwright/test';
|
||||
`;
|
||||
const headerMJS = `
|
||||
const headerESM = `
|
||||
import * as pwt from '@playwright/test';
|
||||
`;
|
||||
|
||||
@ -73,8 +73,8 @@ async function writeFiles(testInfo: TestInfo, files: Files) {
|
||||
const fullName = path.join(baseDir, name);
|
||||
await fs.promises.mkdir(path.dirname(fullName), { recursive: true });
|
||||
const isTypeScriptSourceFile = name.endsWith('.ts') && !name.endsWith('.d.ts');
|
||||
const isJSModule = name.endsWith('.mjs');
|
||||
const header = isTypeScriptSourceFile ? headerTS : (isJSModule ? headerMJS : headerJS);
|
||||
const isJSModule = name.endsWith('.mjs') || name.includes('esm');
|
||||
const header = isTypeScriptSourceFile ? headerTS : (isJSModule ? headerESM : headerJS);
|
||||
if (typeof files[name] === 'string' && files[name].includes('//@no-header')) {
|
||||
await fs.promises.writeFile(fullName, files[name]);
|
||||
} else if (/(spec|test)\.(js|ts|mjs)$/.test(name)) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user