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 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());
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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')) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue