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
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
node-version: [12]
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
node-version: 16
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: 12
|
node-version: ${{matrix.node-version}}
|
||||||
- run: npm i -g npm@8
|
- run: npm i -g npm@8
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
env:
|
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 _configFile: string | undefined;
|
||||||
private _projects: ProjectImpl[] = [];
|
private _projects: ProjectImpl[] = [];
|
||||||
private _fileSuites = new Map<string, Suite>();
|
private _fileSuites = new Map<string, Suite>();
|
||||||
|
private _lastModuleInfo: { rootFolder: string, isModule: boolean } | null = null;
|
||||||
|
|
||||||
constructor(defaultConfig: Config, configOverrides: Config) {
|
constructor(defaultConfig: Config, configOverrides: Config) {
|
||||||
this._defaultConfig = defaultConfig;
|
this._defaultConfig = defaultConfig;
|
||||||
@ -192,20 +193,37 @@ export class Loader {
|
|||||||
|
|
||||||
private async _requireOrImport(file: string) {
|
private async _requireOrImport(file: string) {
|
||||||
const revertBabelRequire = installTransform();
|
const revertBabelRequire = installTransform();
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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 {
|
try {
|
||||||
const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`);
|
const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`);
|
||||||
if (file.endsWith('.mjs')) {
|
if (isModule)
|
||||||
return await esmImport();
|
return await esmImport();
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
return require(file);
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'ERR_MODULE_NOT_FOUND' && error.message.includes('Did you mean to import')) {
|
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];
|
const didYouMean = /Did you mean to import (.*)\?/.exec(error.message)?.[1];
|
||||||
|
@ -52,8 +52,7 @@ function calculateCachePath(content: string, filePath: string): string {
|
|||||||
return path.join(cacheDir, hash[0] + hash[1], fileName);
|
return path.join(cacheDir, hash[0] + hash[1], fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installTransform(): () => void {
|
export function transformHook(code: string, filename: string, isModule = false): string {
|
||||||
return pirates.addHook((code, filename) => {
|
|
||||||
const cachePath = calculateCachePath(code, filename);
|
const cachePath = calculateCachePath(code, filename);
|
||||||
const codePath = cachePath + '.js';
|
const codePath = cachePath + '.js';
|
||||||
const sourceMapPath = cachePath + '.map';
|
const sourceMapPath = cachePath + '.map';
|
||||||
@ -64,6 +63,24 @@ export function installTransform(): () => void {
|
|||||||
// Silence the annoying warning.
|
// Silence the annoying warning.
|
||||||
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
|
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
|
||||||
const babel: typeof import('@babel/core') = require('@babel/core');
|
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, {
|
const result = babel.transformFileSync(filename, {
|
||||||
babelrc: false,
|
babelrc: false,
|
||||||
configFile: false,
|
configFile: false,
|
||||||
@ -75,20 +92,7 @@ export function installTransform(): () => void {
|
|||||||
presets: [
|
presets: [
|
||||||
[require.resolve('@babel/preset-typescript'), { onlyRemoveTypeImports: true }],
|
[require.resolve('@babel/preset-typescript'), { onlyRemoveTypeImports: true }],
|
||||||
],
|
],
|
||||||
plugins: [
|
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',
|
sourceMaps: 'both',
|
||||||
} as babel.TransformOptions)!;
|
} as babel.TransformOptions)!;
|
||||||
if (result.code) {
|
if (result.code) {
|
||||||
@ -98,9 +102,10 @@ export function installTransform(): () => void {
|
|||||||
fs.writeFileSync(codePath, result.code, 'utf8');
|
fs.writeFileSync(codePath, result.code, 'utf8');
|
||||||
}
|
}
|
||||||
return result.code || '';
|
return result.code || '';
|
||||||
}, {
|
}
|
||||||
exts: ['.ts']
|
|
||||||
});
|
export function installTransform(): () => void {
|
||||||
|
return pirates.addHook(transformHook, { exts: ['.ts'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wrapFunctionWithLocation<A extends any[], R>(func: (location: Location, ...args: A) => R): (...args: A) => R {
|
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'}] };
|
export default { projects: [{name: 'foo'}] };
|
||||||
`,
|
`,
|
||||||
'package.json': JSON.stringify({ type: 'module' }),
|
'package.json': JSON.stringify({ type: 'module' }),
|
||||||
'a.test.ts': `
|
'a.esm.test.js': `
|
||||||
const { test } = pwt;
|
const { test } = pwt;
|
||||||
test('check project name', ({}, testInfo) => {
|
test('check project name', ({}, testInfo) => {
|
||||||
expect(testInfo.project.name).toBe('foo');
|
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.exitCode).toBe(1);
|
||||||
expect(result.output).toContain('Cannot import a typescript file from an esmodule');
|
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 = `
|
const headerTS = `
|
||||||
import * as pwt from '@playwright/test';
|
import * as pwt from '@playwright/test';
|
||||||
`;
|
`;
|
||||||
const headerMJS = `
|
const headerESM = `
|
||||||
import * as pwt from '@playwright/test';
|
import * as pwt from '@playwright/test';
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -73,8 +73,8 @@ async function writeFiles(testInfo: TestInfo, files: Files) {
|
|||||||
const fullName = path.join(baseDir, name);
|
const fullName = path.join(baseDir, name);
|
||||||
await fs.promises.mkdir(path.dirname(fullName), { recursive: true });
|
await fs.promises.mkdir(path.dirname(fullName), { recursive: true });
|
||||||
const isTypeScriptSourceFile = name.endsWith('.ts') && !name.endsWith('.d.ts');
|
const isTypeScriptSourceFile = name.endsWith('.ts') && !name.endsWith('.d.ts');
|
||||||
const isJSModule = name.endsWith('.mjs');
|
const isJSModule = name.endsWith('.mjs') || name.includes('esm');
|
||||||
const header = isTypeScriptSourceFile ? headerTS : (isJSModule ? headerMJS : headerJS);
|
const header = isTypeScriptSourceFile ? headerTS : (isJSModule ? headerESM : headerJS);
|
||||||
if (typeof files[name] === 'string' && files[name].includes('//@no-header')) {
|
if (typeof files[name] === 'string' && files[name].includes('//@no-header')) {
|
||||||
await fs.promises.writeFile(fullName, files[name]);
|
await fs.promises.writeFile(fullName, files[name]);
|
||||||
} else if (/(spec|test)\.(js|ts|mjs)$/.test(name)) {
|
} else if (/(spec|test)\.(js|ts|mjs)$/.test(name)) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user