feat(esm): allow running tests in type module projects (#10503)

This commit is contained in:
Pavel Feldman 2021-11-24 12:42:48 -08:00 committed by GitHub
parent 685892dd62
commit 7eb3f76f49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 159 additions and 64 deletions

View File

@ -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:

View 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 };

View File

@ -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];

View File

@ -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 {

View File

@ -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);
});

View File

@ -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)) {