feat(test-runner): support esm modules in more places (#7542)
This commit is contained in:
parent
25a43aef3c
commit
eb31b9e4a9
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue