Skip to content

Commit 7eb3f76

Browse files
authored
feat(esm): allow running tests in type module projects (microsoft#10503)
1 parent 685892d commit 7eb3f76

File tree

6 files changed

+159
-64
lines changed

6 files changed

+159
-64
lines changed

.github/workflows/tests_primary.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,16 @@ jobs:
5555
fail-fast: false
5656
matrix:
5757
os: [ubuntu-latest, windows-latest, macos-latest]
58+
node-version: [12]
59+
include:
60+
- os: ubuntu-latest
61+
node-version: 16
5862
runs-on: ${{ matrix.os }}
5963
steps:
6064
- uses: actions/checkout@v2
6165
- uses: actions/setup-node@v2
6266
with:
63-
node-version: 12
67+
node-version: ${{matrix.node-version}}
6468
- run: npm i -g npm@8
6569
- run: npm ci
6670
env:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import fs from 'fs';
18+
import { transformHook } from './transform';
19+
20+
async function resolve(specifier: string, context: { parentURL: string }, defaultResolve: any) {
21+
if (specifier.endsWith('.js') || specifier.endsWith('.ts') || specifier.endsWith('.mjs'))
22+
return defaultResolve(specifier, context, defaultResolve);
23+
let url = new URL(specifier, context.parentURL).toString();
24+
url = url.substring('file://'.length);
25+
if (fs.existsSync(url + '.ts'))
26+
return defaultResolve(specifier + '.ts', context, defaultResolve);
27+
if (fs.existsSync(url + '.js'))
28+
return defaultResolve(specifier + '.js', context, defaultResolve);
29+
return defaultResolve(specifier, context, defaultResolve);
30+
}
31+
32+
async function load(url: string, context: any, defaultLoad: any) {
33+
if (url.endsWith('.ts')) {
34+
const filename = url.substring('file://'.length);
35+
const code = fs.readFileSync(filename, 'utf-8');
36+
const source = transformHook(code, filename, true);
37+
return { format: 'module', source };
38+
}
39+
return defaultLoad(url, context, defaultLoad);
40+
}
41+
42+
module.exports = { resolve, load };

packages/playwright-test/src/loader.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export class Loader {
3636
private _configFile: string | undefined;
3737
private _projects: ProjectImpl[] = [];
3838
private _fileSuites = new Map<string, Suite>();
39+
private _lastModuleInfo: { rootFolder: string, isModule: boolean } | null = null;
3940

4041
constructor(defaultConfig: Config, configOverrides: Config) {
4142
this._defaultConfig = defaultConfig;
@@ -192,20 +193,37 @@ export class Loader {
192193

193194
private async _requireOrImport(file: string) {
194195
const revertBabelRequire = installTransform();
195-
try {
196-
const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`);
197-
if (file.endsWith('.mjs')) {
198-
return await esmImport();
199-
} else {
196+
197+
// Figure out if we are importing or requiring.
198+
let isModule: boolean;
199+
if (file.endsWith('.mjs')) {
200+
isModule = true;
201+
} else {
202+
if (!this._lastModuleInfo || !file.startsWith(this._lastModuleInfo.rootFolder)) {
203+
this._lastModuleInfo = null;
200204
try {
201-
return require(file);
202-
} catch (e) {
203-
// Attempt to load this module as ESM if a normal require didn't work.
204-
if (e.code === 'ERR_REQUIRE_ESM')
205-
return await esmImport();
206-
throw e;
205+
const pathSegments = file.split(path.sep);
206+
for (let i = pathSegments.length - 1; i >= 0; --i) {
207+
const rootFolder = pathSegments.slice(0, i).join(path.sep);
208+
const packageJson = path.join(rootFolder, 'package.json');
209+
if (fs.existsSync(packageJson)) {
210+
isModule = require(packageJson).type === 'module';
211+
this._lastModuleInfo = { rootFolder, isModule };
212+
break;
213+
}
214+
}
215+
} catch {
216+
// Silent catch.
207217
}
208218
}
219+
isModule = this._lastModuleInfo?.isModule || false;
220+
}
221+
222+
try {
223+
const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`);
224+
if (isModule)
225+
return await esmImport();
226+
return require(file);
209227
} catch (error) {
210228
if (error.code === 'ERR_MODULE_NOT_FOUND' && error.message.includes('Did you mean to import')) {
211229
const didYouMean = /Did you mean to import (.*)\?/.exec(error.message)?.[1];

packages/playwright-test/src/transform.ts

Lines changed: 53 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -52,55 +52,60 @@ function calculateCachePath(content: string, filePath: string): string {
5252
return path.join(cacheDir, hash[0] + hash[1], fileName);
5353
}
5454

55+
export function transformHook(code: string, filename: string, isModule = false): string {
56+
const cachePath = calculateCachePath(code, filename);
57+
const codePath = cachePath + '.js';
58+
const sourceMapPath = cachePath + '.map';
59+
sourceMaps.set(filename, sourceMapPath);
60+
if (fs.existsSync(codePath))
61+
return fs.readFileSync(codePath, 'utf8');
62+
// We don't use any browserslist data, but babel checks it anyway.
63+
// Silence the annoying warning.
64+
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
65+
const babel: typeof import('@babel/core') = require('@babel/core');
66+
67+
const plugins = [
68+
[require.resolve('@babel/plugin-proposal-class-properties')],
69+
[require.resolve('@babel/plugin-proposal-numeric-separator')],
70+
[require.resolve('@babel/plugin-proposal-logical-assignment-operators')],
71+
[require.resolve('@babel/plugin-proposal-nullish-coalescing-operator')],
72+
[require.resolve('@babel/plugin-proposal-optional-chaining')],
73+
[require.resolve('@babel/plugin-syntax-json-strings')],
74+
[require.resolve('@babel/plugin-syntax-optional-catch-binding')],
75+
[require.resolve('@babel/plugin-syntax-async-generators')],
76+
[require.resolve('@babel/plugin-syntax-object-rest-spread')],
77+
[require.resolve('@babel/plugin-proposal-export-namespace-from')],
78+
];
79+
if (!isModule) {
80+
plugins.push([require.resolve('@babel/plugin-transform-modules-commonjs')]);
81+
plugins.push([require.resolve('@babel/plugin-proposal-dynamic-import')]);
82+
}
83+
84+
const result = babel.transformFileSync(filename, {
85+
babelrc: false,
86+
configFile: false,
87+
assumptions: {
88+
// Without this, babel defines a top level function that
89+
// breaks playwright evaluates.
90+
setPublicClassFields: true,
91+
},
92+
presets: [
93+
[require.resolve('@babel/preset-typescript'), { onlyRemoveTypeImports: true }],
94+
],
95+
plugins,
96+
sourceMaps: 'both',
97+
} as babel.TransformOptions)!;
98+
if (result.code) {
99+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
100+
if (result.map)
101+
fs.writeFileSync(sourceMapPath, JSON.stringify(result.map), 'utf8');
102+
fs.writeFileSync(codePath, result.code, 'utf8');
103+
}
104+
return result.code || '';
105+
}
106+
55107
export function installTransform(): () => void {
56-
return pirates.addHook((code, filename) => {
57-
const cachePath = calculateCachePath(code, filename);
58-
const codePath = cachePath + '.js';
59-
const sourceMapPath = cachePath + '.map';
60-
sourceMaps.set(filename, sourceMapPath);
61-
if (fs.existsSync(codePath))
62-
return fs.readFileSync(codePath, 'utf8');
63-
// We don't use any browserslist data, but babel checks it anyway.
64-
// Silence the annoying warning.
65-
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
66-
const babel: typeof import('@babel/core') = require('@babel/core');
67-
const result = babel.transformFileSync(filename, {
68-
babelrc: false,
69-
configFile: false,
70-
assumptions: {
71-
// Without this, babel defines a top level function that
72-
// breaks playwright evaluates.
73-
setPublicClassFields: true,
74-
},
75-
presets: [
76-
[require.resolve('@babel/preset-typescript'), { onlyRemoveTypeImports: true }],
77-
],
78-
plugins: [
79-
[require.resolve('@babel/plugin-proposal-class-properties')],
80-
[require.resolve('@babel/plugin-proposal-numeric-separator')],
81-
[require.resolve('@babel/plugin-proposal-logical-assignment-operators')],
82-
[require.resolve('@babel/plugin-proposal-nullish-coalescing-operator')],
83-
[require.resolve('@babel/plugin-proposal-optional-chaining')],
84-
[require.resolve('@babel/plugin-syntax-json-strings')],
85-
[require.resolve('@babel/plugin-syntax-optional-catch-binding')],
86-
[require.resolve('@babel/plugin-syntax-async-generators')],
87-
[require.resolve('@babel/plugin-syntax-object-rest-spread')],
88-
[require.resolve('@babel/plugin-proposal-export-namespace-from')],
89-
[require.resolve('@babel/plugin-transform-modules-commonjs')],
90-
[require.resolve('@babel/plugin-proposal-dynamic-import')],
91-
],
92-
sourceMaps: 'both',
93-
} as babel.TransformOptions)!;
94-
if (result.code) {
95-
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
96-
if (result.map)
97-
fs.writeFileSync(sourceMapPath, JSON.stringify(result.map), 'utf8');
98-
fs.writeFileSync(codePath, result.code, 'utf8');
99-
}
100-
return result.code || '';
101-
}, {
102-
exts: ['.ts']
103-
});
108+
return pirates.addHook(transformHook, { exts: ['.ts'] });
104109
}
105110

106111
export function wrapFunctionWithLocation<A extends any[], R>(func: (location: Location, ...args: A) => R): (...args: A) => R {

tests/playwright-test/loader.spec.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ test('should load esm when package.json has type module', async ({ runInlineTest
185185
export default { projects: [{name: 'foo'}] };
186186
`,
187187
'package.json': JSON.stringify({ type: 'module' }),
188-
'a.test.ts': `
188+
'a.esm.test.js': `
189189
const { test } = pwt;
190190
test('check project name', ({}, testInfo) => {
191191
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
239239
expect(result.exitCode).toBe(1);
240240
expect(result.output).toContain('Cannot import a typescript file from an esmodule');
241241
});
242+
243+
test('should import esm from ts when package.json has type module in experimental mode', async ({ runInlineTest }) => {
244+
// We only support experimental esm mode on Node 16+
245+
test.skip(parseInt(process.version.slice(1), 10) < 16);
246+
const result = await runInlineTest({
247+
'playwright.config.ts': `
248+
import * as fs from 'fs';
249+
export default { projects: [{name: 'foo'}] };
250+
`,
251+
'package.json': JSON.stringify({ type: 'module' }),
252+
'a.test.ts': `
253+
import { foo } from './b.ts';
254+
const { test } = pwt;
255+
test('check project name', ({}, testInfo) => {
256+
expect(testInfo.project.name).toBe('foo');
257+
});
258+
`,
259+
'b.ts': `
260+
export const foo: string = 'foo';
261+
`
262+
}, {}, {
263+
NODE_OPTIONS: `--experimental-loader=${require.resolve('../../packages/playwright-test/lib/experimentalLoader.js')}`
264+
});
265+
266+
expect(result.exitCode).toBe(0);
267+
});

tests/playwright-test/playwright-test-fixtures.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ async function writeFiles(testInfo: TestInfo, files: Files) {
5555
const headerTS = `
5656
import * as pwt from '@playwright/test';
5757
`;
58-
const headerMJS = `
58+
const headerESM = `
5959
import * as pwt from '@playwright/test';
6060
`;
6161

@@ -73,8 +73,8 @@ async function writeFiles(testInfo: TestInfo, files: Files) {
7373
const fullName = path.join(baseDir, name);
7474
await fs.promises.mkdir(path.dirname(fullName), { recursive: true });
7575
const isTypeScriptSourceFile = name.endsWith('.ts') && !name.endsWith('.d.ts');
76-
const isJSModule = name.endsWith('.mjs');
77-
const header = isTypeScriptSourceFile ? headerTS : (isJSModule ? headerMJS : headerJS);
76+
const isJSModule = name.endsWith('.mjs') || name.includes('esm');
77+
const header = isTypeScriptSourceFile ? headerTS : (isJSModule ? headerESM : headerJS);
7878
if (typeof files[name] === 'string' && files[name].includes('//@no-header')) {
7979
await fs.promises.writeFile(fullName, files[name]);
8080
} else if (/(spec|test)\.(js|ts|mjs)$/.test(name)) {

0 commit comments

Comments
 (0)