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 tsConfig = 'playwright.config.ts';
const jsConfig = 'playwright.config.js';
const mjsConfig = 'playwright.config.mjs';
const defaultConfig: Config = {
preserveOutput: 'always',
reporter: [ [defaultReporter] ],
@ -100,11 +101,11 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
overrides.use = { headless: false };
const runner = new Runner(defaultConfig, overrides);
function loadConfig(configFile: string) {
async function loadConfig(configFile: string) {
if (fs.existsSync(configFile)) {
if (process.stdout.isTTY)
console.log(`Using config at ` + configFile);
const loadedConfig = runner.loadConfigFile(configFile);
const loadedConfig = await runner.loadConfigFile(configFile);
if (('projects' in loadedConfig) && opts.browser)
throw new Error(`Cannot use --browser option when configuration file defines projects. Specify browserName in the projects instead.`);
return true;
@ -112,21 +113,30 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
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) {
const configFile = path.resolve(process.cwd(), opts.config);
if (!fs.existsSync(configFile))
throw new Error(`${opts.config} does not exist`);
if (fs.statSync(configFile).isDirectory()) {
// 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.
runner.loadEmptyConfig(configFile);
}
} else {
// 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.
// If not, scan the world.
runner.loadEmptyConfig(process.cwd());

View file

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

View file

@ -62,7 +62,7 @@ export class Runner {
this._loader = new Loader(defaultConfig, configOverrides);
}
private _createReporter() {
private async _createReporter() {
const reporters: Reporter[] = [];
const defaultReporters = {
dot: DotReporter,
@ -77,14 +77,14 @@ export class Runner {
if (name in defaultReporters) {
reporters.push(new defaultReporters[name as keyof typeof defaultReporters](arg));
} else {
const reporterConstructor = this._loader.loadReporter(name);
const reporterConstructor = await this._loader.loadReporter(name);
reporters.push(new reporterConstructor(arg));
}
}
return new Multiplexer(reporters);
}
loadConfigFile(file: string): Config {
loadConfigFile(file: string): Promise<Config> {
return this._loader.loadConfigFile(file);
}
@ -93,7 +93,7 @@ export class Runner {
}
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 globalDeadline = config.globalTimeout ? config.globalTimeout + monotonicTime() : undefined;
const { result, timedOut } = await raceAgainstDeadline(this._run(list, filePatternFilters, projectName), globalDeadline);
@ -169,7 +169,7 @@ export class Runner {
let globalSetupResult: any;
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;
try {
for (const file of allTestFiles)
@ -267,7 +267,7 @@ export class Runner {
if (globalSetupResult && typeof globalSetupResult === 'function')
await globalSetupResult(this._loader.fullConfig());
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() {
// We have to load the project to get the right deadline below.
this._loadIfNeeded();
await this._loadIfNeeded();
// TODO: separate timeout for teardown?
const result = await raceAgainstDeadline((async () => {
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;
}
private _loadIfNeeded() {
private async _loadIfNeeded() {
if (this._loader)
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._projectNamePathSegment = sanitizeForFilePath(this._project.config.name);
@ -115,7 +115,7 @@ export class WorkerRunner extends EventEmitter {
async run(runPayload: RunPayload) {
this._entries = new Map(runPayload.entries.map(e => [ e.testId, e ]));
this._loadIfNeeded();
await this._loadIfNeeded();
const fileSuite = await this._loader.loadTestFile(runPayload.file);
let anySpec: Spec | undefined;
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('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 isJSModule = name.endsWith('.mjs');
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';
await fs.promises.writeFile(fullName, fileHeader + files[name]);
} else if (/\.(js|ts)$/.test(name) && !name.endsWith('d.ts')) {