diff --git a/src/test/cli.ts b/src/test/cli.ts index d6bc126cf6..48b08f9fe8 100644 --- a/src/test/cli.ts +++ b/src/test/cli.ts @@ -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()); diff --git a/src/test/loader.ts b/src/test/loader.ts index d425ca22bb..bfc24291c6 100644 --- a/src/test/loader.ts +++ b/src/test/loader.ts @@ -40,31 +40,26 @@ export class Loader { this._fullConfig = baseFullConfig; } - static deserialize(data: SerializedLoaderData): Loader { + static async deserialize(data: SerializedLoaderData): Promise { 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 { 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 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(...args: (T | undefined)[]): T { diff --git a/src/test/runner.ts b/src/test/runner.ts index 5d2cb3becb..12b6ac53a6 100644 --- a/src/test/runner.ts +++ b/src/test/runner.ts @@ -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 { return this._loader.loadConfigFile(file); } @@ -93,7 +93,7 @@ export class Runner { } async run(list: boolean, filePatternFilters: FilePatternFilter[], projectName?: string): Promise { - 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()); } } } diff --git a/src/test/workerRunner.ts b/src/test/workerRunner.ts index 463f6c4120..9672fab677 100644 --- a/src/test/workerRunner.ts +++ b/src/test/workerRunner.ts @@ -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 => { diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index 0ed6ba8675..22cafc6fb4 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -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); +}); diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index 98a98be9ed..e63080ae45 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -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')) {