feat(test-runner): support esm modules in more places (#7542)

This commit is contained in:
Joel Einbinder 2021-07-12 11:59:58 -05:00 committed by GitHub
parent 25a43aef3c
commit eb31b9e4a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 120 additions and 69 deletions

View file

@ -29,6 +29,7 @@ const defaultReporter = process.env.CI ? 'dot' : 'list';
const builtinReporters = ['list', 'line', 'dot', 'json', 'junit', 'null']; const builtinReporters = ['list', 'line', 'dot', 'json', 'junit', 'null'];
const tsConfig = 'playwright.config.ts'; const tsConfig = 'playwright.config.ts';
const jsConfig = 'playwright.config.js'; const jsConfig = 'playwright.config.js';
const mjsConfig = 'playwright.config.mjs';
const defaultConfig: Config = { const defaultConfig: Config = {
preserveOutput: 'always', preserveOutput: 'always',
reporter: [ [defaultReporter] ], reporter: [ [defaultReporter] ],
@ -100,11 +101,11 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
overrides.use = { headless: false }; overrides.use = { headless: false };
const runner = new Runner(defaultConfig, overrides); const runner = new Runner(defaultConfig, overrides);
function loadConfig(configFile: string) { async function loadConfig(configFile: string) {
if (fs.existsSync(configFile)) { if (fs.existsSync(configFile)) {
if (process.stdout.isTTY) if (process.stdout.isTTY)
console.log(`Using config at ` + configFile); console.log(`Using config at ` + configFile);
const loadedConfig = runner.loadConfigFile(configFile); const loadedConfig = await runner.loadConfigFile(configFile);
if (('projects' in loadedConfig) && opts.browser) if (('projects' in loadedConfig) && opts.browser)
throw new Error(`Cannot use --browser option when configuration file defines projects. Specify browserName in the projects instead.`); throw new Error(`Cannot use --browser option when configuration file defines projects. Specify browserName in the projects instead.`);
return true; return true;
@ -112,21 +113,30 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
return false; return false;
} }
async function loadConfigFromDirectory(directory: string) {
const configNames = [tsConfig, jsConfig, mjsConfig];
for (const configName of configNames) {
if (await loadConfig(path.resolve(directory, configName)))
return true;
}
return false;
}
if (opts.config) { if (opts.config) {
const configFile = path.resolve(process.cwd(), opts.config); const configFile = path.resolve(process.cwd(), opts.config);
if (!fs.existsSync(configFile)) if (!fs.existsSync(configFile))
throw new Error(`${opts.config} does not exist`); throw new Error(`${opts.config} does not exist`);
if (fs.statSync(configFile).isDirectory()) { if (fs.statSync(configFile).isDirectory()) {
// When passed a directory, look for a config file inside. // When passed a directory, look for a config file inside.
if (!loadConfig(path.join(configFile, tsConfig)) && !loadConfig(path.join(configFile, jsConfig))) { if (!await loadConfigFromDirectory(configFile)) {
// If there is no config, assume this as a root testing directory. // If there is no config, assume this as a root testing directory.
runner.loadEmptyConfig(configFile); runner.loadEmptyConfig(configFile);
} }
} else { } else {
// When passed a file, it must be a config file. // When passed a file, it must be a config file.
loadConfig(configFile); await loadConfig(configFile);
} }
} else if (!loadConfig(path.resolve(process.cwd(), tsConfig)) && !loadConfig(path.resolve(process.cwd(), jsConfig))) { } else if (!await loadConfigFromDirectory(process.cwd())) {
// No --config option, let's look for the config file in the current directory. // No --config option, let's look for the config file in the current directory.
// If not, scan the world. // If not, scan the world.
runner.loadEmptyConfig(process.cwd()); runner.loadEmptyConfig(process.cwd());

View file

@ -40,31 +40,26 @@ export class Loader {
this._fullConfig = baseFullConfig; this._fullConfig = baseFullConfig;
} }
static deserialize(data: SerializedLoaderData): Loader { static async deserialize(data: SerializedLoaderData): Promise<Loader> {
const loader = new Loader(data.defaultConfig, data.overrides); const loader = new Loader(data.defaultConfig, data.overrides);
if ('file' in data.configFile) if ('file' in data.configFile)
loader.loadConfigFile(data.configFile.file); await loader.loadConfigFile(data.configFile.file);
else else
loader.loadEmptyConfig(data.configFile.rootDir); loader.loadEmptyConfig(data.configFile.rootDir);
return loader; return loader;
} }
loadConfigFile(file: string): Config { async loadConfigFile(file: string): Promise<Config> {
if (this._configFile) if (this._configFile)
throw new Error('Cannot load two config files'); throw new Error('Cannot load two config files');
const revertBabelRequire = installTransform(); let config = await this._requireOrImport(file);
try { if (config && typeof config === 'object' && ('default' in config))
let config = require(file); config = config['default'];
if (config && typeof config === 'object' && ('default' in config)) this._config = config;
config = config['default']; this._configFile = file;
this._config = config; const rawConfig = { ...config };
this._configFile = file; this._processConfigObject(path.dirname(file));
const rawConfig = { ...config }; return rawConfig;
this._processConfigObject(path.dirname(file));
return rawConfig;
} finally {
revertBabelRequire();
}
} }
loadEmptyConfig(rootDir: string) { loadEmptyConfig(rootDir: string) {
@ -113,57 +108,35 @@ export class Loader {
async loadTestFile(file: string) { async loadTestFile(file: string) {
if (this._fileSuites.has(file)) if (this._fileSuites.has(file))
return this._fileSuites.get(file)!; return this._fileSuites.get(file)!;
const revertBabelRequire = installTransform();
try { try {
const suite = new Suite(''); const suite = new Suite('');
suite._requireFile = file; suite._requireFile = file;
suite.file = file; suite.file = file;
setCurrentlyLoadingFileSuite(suite); setCurrentlyLoadingFileSuite(suite);
if (file.endsWith('.mjs')) { await this._requireOrImport(file);
// eval to prevent typescript from transpiling us here.
await eval(`import(${JSON.stringify(url.pathToFileURL(file))})`);
} else {
require(file);
}
this._fileSuites.set(file, suite); this._fileSuites.set(file, suite);
return suite; return suite;
} catch (error) {
if (error instanceof SyntaxError && error.message.includes('Cannot use import statement outside a module'))
throw errorWithFile(file, 'JavaScript files must end with .mjs to use import.');
throw error;
} finally { } finally {
revertBabelRequire();
setCurrentlyLoadingFileSuite(undefined); setCurrentlyLoadingFileSuite(undefined);
} }
} }
loadGlobalHook(file: string, name: string): (config: FullConfig) => any { async loadGlobalHook(file: string, name: string): Promise<(config: FullConfig) => any> {
const revertBabelRequire = installTransform(); let hook = await this._requireOrImport(file);
try { if (hook && typeof hook === 'object' && ('default' in hook))
let hook = require(file); hook = hook['default'];
if (hook && typeof hook === 'object' && ('default' in hook)) if (typeof hook !== 'function')
hook = hook['default']; throw errorWithFile(file, `${name} file must export a single function.`);
if (typeof hook !== 'function') return hook;
throw errorWithFile(file, `${name} file must export a single function.`);
return hook;
} finally {
revertBabelRequire();
}
} }
loadReporter(file: string): new (arg?: any) => Reporter { async loadReporter(file: string): Promise<new (arg?: any) => Reporter> {
const revertBabelRequire = installTransform(); let func = await this._requireOrImport(path.resolve(this._fullConfig.rootDir, file));
try { if (func && typeof func === 'object' && ('default' in func))
let func = require(path.resolve(this._fullConfig.rootDir, file)); func = func['default'];
if (func && typeof func === 'object' && ('default' in func)) if (typeof func !== 'function')
func = func['default']; throw errorWithFile(file, `reporter file must export a single class.`);
if (typeof func !== 'function') return func;
throw errorWithFile(file, `reporter file must export a single class.`);
return func;
} finally {
revertBabelRequire();
}
} }
fullConfig(): FullConfig { fullConfig(): FullConfig {
@ -209,6 +182,33 @@ export class Loader {
}; };
this._projects.push(new ProjectImpl(fullProject, this._projects.length)); this._projects.push(new ProjectImpl(fullProject, this._projects.length));
} }
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 {
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;
}
}
} catch (error) {
if (error instanceof SyntaxError && error.message.includes('Cannot use import statement outside a module'))
throw errorWithFile(file, 'JavaScript files must end with .mjs to use import.');
throw error;
} finally {
revertBabelRequire();
}
}
} }
function takeFirst<T>(...args: (T | undefined)[]): T { function takeFirst<T>(...args: (T | undefined)[]): T {

View file

@ -62,7 +62,7 @@ export class Runner {
this._loader = new Loader(defaultConfig, configOverrides); this._loader = new Loader(defaultConfig, configOverrides);
} }
private _createReporter() { private async _createReporter() {
const reporters: Reporter[] = []; const reporters: Reporter[] = [];
const defaultReporters = { const defaultReporters = {
dot: DotReporter, dot: DotReporter,
@ -77,14 +77,14 @@ export class Runner {
if (name in defaultReporters) { if (name in defaultReporters) {
reporters.push(new defaultReporters[name as keyof typeof defaultReporters](arg)); reporters.push(new defaultReporters[name as keyof typeof defaultReporters](arg));
} else { } else {
const reporterConstructor = this._loader.loadReporter(name); const reporterConstructor = await this._loader.loadReporter(name);
reporters.push(new reporterConstructor(arg)); reporters.push(new reporterConstructor(arg));
} }
} }
return new Multiplexer(reporters); return new Multiplexer(reporters);
} }
loadConfigFile(file: string): Config { loadConfigFile(file: string): Promise<Config> {
return this._loader.loadConfigFile(file); return this._loader.loadConfigFile(file);
} }
@ -93,7 +93,7 @@ export class Runner {
} }
async run(list: boolean, filePatternFilters: FilePatternFilter[], projectName?: string): Promise<RunResultStatus> { async run(list: boolean, filePatternFilters: FilePatternFilter[], projectName?: string): Promise<RunResultStatus> {
this._reporter = this._createReporter(); this._reporter = await this._createReporter();
const config = this._loader.fullConfig(); const config = this._loader.fullConfig();
const globalDeadline = config.globalTimeout ? config.globalTimeout + monotonicTime() : undefined; const globalDeadline = config.globalTimeout ? config.globalTimeout + monotonicTime() : undefined;
const { result, timedOut } = await raceAgainstDeadline(this._run(list, filePatternFilters, projectName), globalDeadline); const { result, timedOut } = await raceAgainstDeadline(this._run(list, filePatternFilters, projectName), globalDeadline);
@ -169,7 +169,7 @@ export class Runner {
let globalSetupResult: any; let globalSetupResult: any;
if (config.globalSetup) if (config.globalSetup)
globalSetupResult = await this._loader.loadGlobalHook(config.globalSetup, 'globalSetup')(this._loader.fullConfig()); globalSetupResult = await (await this._loader.loadGlobalHook(config.globalSetup, 'globalSetup'))(this._loader.fullConfig());
const webServer: WebServer|null = config.webServer ? await WebServer.create(config.webServer) : null; const webServer: WebServer|null = config.webServer ? await WebServer.create(config.webServer) : null;
try { try {
for (const file of allTestFiles) for (const file of allTestFiles)
@ -267,7 +267,7 @@ export class Runner {
if (globalSetupResult && typeof globalSetupResult === 'function') if (globalSetupResult && typeof globalSetupResult === 'function')
await globalSetupResult(this._loader.fullConfig()); await globalSetupResult(this._loader.fullConfig());
if (config.globalTeardown) if (config.globalTeardown)
await this._loader.loadGlobalHook(config.globalTeardown, 'globalTeardown')(this._loader.fullConfig()); await (await this._loader.loadGlobalHook(config.globalTeardown, 'globalTeardown'))(this._loader.fullConfig());
} }
} }
} }

View file

@ -58,7 +58,7 @@ export class WorkerRunner extends EventEmitter {
async cleanup() { async cleanup() {
// We have to load the project to get the right deadline below. // We have to load the project to get the right deadline below.
this._loadIfNeeded(); await this._loadIfNeeded();
// TODO: separate timeout for teardown? // TODO: separate timeout for teardown?
const result = await raceAgainstDeadline((async () => { const result = await raceAgainstDeadline((async () => {
await this._fixtureRunner.teardownScope('test'); await this._fixtureRunner.teardownScope('test');
@ -89,11 +89,11 @@ export class WorkerRunner extends EventEmitter {
return this._project.config.timeout ? monotonicTime() + this._project.config.timeout : undefined; return this._project.config.timeout ? monotonicTime() + this._project.config.timeout : undefined;
} }
private _loadIfNeeded() { private async _loadIfNeeded() {
if (this._loader) if (this._loader)
return; return;
this._loader = Loader.deserialize(this._params.loader); this._loader = await Loader.deserialize(this._params.loader);
this._project = this._loader.projects()[this._params.projectIndex]; this._project = this._loader.projects()[this._params.projectIndex];
this._projectNamePathSegment = sanitizeForFilePath(this._project.config.name); this._projectNamePathSegment = sanitizeForFilePath(this._project.config.name);
@ -115,7 +115,7 @@ export class WorkerRunner extends EventEmitter {
async run(runPayload: RunPayload) { async run(runPayload: RunPayload) {
this._entries = new Map(runPayload.entries.map(e => [ e.testId, e ])); this._entries = new Map(runPayload.entries.map(e => [ e.testId, e ]));
this._loadIfNeeded(); await this._loadIfNeeded();
const fileSuite = await this._loader.loadTestFile(runPayload.file); const fileSuite = await this._loader.loadTestFile(runPayload.file);
let anySpec: Spec | undefined; let anySpec: Spec | undefined;
fileSuite.findSpec(spec => { fileSuite.findSpec(spec => {

View file

@ -176,3 +176,42 @@ test('should throw a nice error if a js file uses import', async ({ runInlineTes
expect(output).toContain('a.spec.js'); expect(output).toContain('a.spec.js');
expect(output).toContain('JavaScript files must end with .mjs to use import.'); expect(output).toContain('JavaScript files must end with .mjs to use import.');
}); });
test('should load esm when package.json has type module', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.js': `
//@no-header
import * as fs from 'fs';
export default { projects: [{name: 'foo'}] };
`,
'package.json': JSON.stringify({type: 'module'}),
'a.test.ts': `
const { test } = pwt;
test('check project name', ({}, testInfo) => {
expect(testInfo.project.name).toBe('foo');
});
`
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should load esm config files', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.mjs': `
//@no-header
import * as fs from 'fs';
export default { projects: [{name: 'foo'}] };
`,
'a.test.ts': `
const { test } = pwt;
test('check project name', ({}, testInfo) => {
expect(testInfo.project.name).toBe('foo');
});
`
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});

View file

@ -76,7 +76,9 @@ async function writeFiles(testInfo: TestInfo, files: Files) {
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');
const header = isTypeScriptSourceFile ? headerTS : (isJSModule ? headerMJS : headerJS); const header = isTypeScriptSourceFile ? headerTS : (isJSModule ? headerMJS : headerJS);
if (/(spec|test)\.(js|ts|mjs)$/.test(name)) { 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)) {
const fileHeader = header + 'const { expect } = pwt;\n'; const fileHeader = header + 'const { expect } = pwt;\n';
await fs.promises.writeFile(fullName, fileHeader + files[name]); await fs.promises.writeFile(fullName, fileHeader + files[name]);
} else if (/\.(js|ts)$/.test(name) && !name.endsWith('d.ts')) { } else if (/\.(js|ts)$/.test(name) && !name.endsWith('d.ts')) {