From 7eb3f76f49e1eadf5cbd510a98c6e455104f730c Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 24 Nov 2021 12:42:48 -0800 Subject: [PATCH] feat(esm): allow running tests in type module projects (#10503) --- .github/workflows/tests_primary.yml | 6 +- .../playwright-test/src/experimentalLoader.ts | 42 ++++++++ packages/playwright-test/src/loader.ts | 40 +++++-- packages/playwright-test/src/transform.ts | 101 +++++++++--------- tests/playwright-test/loader.spec.ts | 28 ++++- .../playwright-test-fixtures.ts | 6 +- 6 files changed, 159 insertions(+), 64 deletions(-) create mode 100644 packages/playwright-test/src/experimentalLoader.ts diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml index 780c62c849..bb37892d5c 100644 --- a/.github/workflows/tests_primary.yml +++ b/.github/workflows/tests_primary.yml @@ -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: diff --git a/packages/playwright-test/src/experimentalLoader.ts b/packages/playwright-test/src/experimentalLoader.ts new file mode 100644 index 0000000000..b61e84d273 --- /dev/null +++ b/packages/playwright-test/src/experimentalLoader.ts @@ -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 }; diff --git a/packages/playwright-test/src/loader.ts b/packages/playwright-test/src/loader.ts index 293a2c4641..f5f4d5118e 100644 --- a/packages/playwright-test/src/loader.ts +++ b/packages/playwright-test/src/loader.ts @@ -36,6 +36,7 @@ export class Loader { private _configFile: string | undefined; private _projects: ProjectImpl[] = []; private _fileSuites = new Map(); + 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]; diff --git a/packages/playwright-test/src/transform.ts b/packages/playwright-test/src/transform.ts index ba4266c40b..adde50a689 100644 --- a/packages/playwright-test/src/transform.ts +++ b/packages/playwright-test/src/transform.ts @@ -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(func: (location: Location, ...args: A) => R): (...args: A) => R { diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index 4ad1ca69e8..1d1821bcd1 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -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); +}); diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index d360bf668b..e2191afc01 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -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)) {