/** * Copyright Microsoft Corporation. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as fs from 'fs'; import * as path from 'path'; import { isRegExp } from 'playwright-core/lib/utils'; import type { ConfigCLIOverrides, SerializedConfig } from './ipc'; import { requireOrImport } from './transform'; import type { Config, Project } from '../../types/test'; import { errorWithFile } from '../util'; import { setCurrentConfig } from './globals'; import { FullConfigInternal } from './config'; const kDefineConfigWasUsed = Symbol('defineConfigWasUsed'); export const defineConfig = (config: any) => { config[kDefineConfigWasUsed] = true; return config; }; export class ConfigLoader { private _configCLIOverrides: ConfigCLIOverrides; private _fullConfig: FullConfigInternal | undefined; constructor(configCLIOverrides?: ConfigCLIOverrides) { this._configCLIOverrides = configCLIOverrides || {}; } static async deserialize(data: SerializedConfig): Promise { const loader = new ConfigLoader(data.configCLIOverrides); if (data.configFile) return await loader.loadConfigFile(data.configFile); return await loader.loadEmptyConfig(data.configDir); } async loadConfigFile(file: string, ignoreProjectDependencies = false): Promise { if (this._fullConfig) throw new Error('Cannot load two config files'); const config = await requireOrImportDefaultObject(file) as Config; const fullConfig = await this._loadConfig(config, path.dirname(file), file); setCurrentConfig(fullConfig); if (ignoreProjectDependencies) { for (const project of fullConfig.projects) project.deps = []; } this._fullConfig = fullConfig; return fullConfig; } async loadEmptyConfig(configDir: string): Promise { const fullConfig = await this._loadConfig({}, configDir); setCurrentConfig(fullConfig); return fullConfig; } private async _loadConfig(config: Config, configDir: string, configFile?: string): Promise { // 1. Validate data provided in the config file. validateConfig(configFile || '', config); const fullConfig = new FullConfigInternal(configDir, configFile, config, this._configCLIOverrides); fullConfig.defineConfigWasUsed = !!(config as any)[kDefineConfigWasUsed]; return fullConfig; } } async function requireOrImportDefaultObject(file: string) { let object = await requireOrImport(file); if (object && typeof object === 'object' && ('default' in object)) object = object['default']; return object; } function validateConfig(file: string, config: Config) { if (typeof config !== 'object' || !config) throw errorWithFile(file, `Configuration file must export a single object`); validateProject(file, config, 'config'); if ('forbidOnly' in config && config.forbidOnly !== undefined) { if (typeof config.forbidOnly !== 'boolean') throw errorWithFile(file, `config.forbidOnly must be a boolean`); } if ('globalSetup' in config && config.globalSetup !== undefined) { if (typeof config.globalSetup !== 'string') throw errorWithFile(file, `config.globalSetup must be a string`); } if ('globalTeardown' in config && config.globalTeardown !== undefined) { if (typeof config.globalTeardown !== 'string') throw errorWithFile(file, `config.globalTeardown must be a string`); } if ('globalTimeout' in config && config.globalTimeout !== undefined) { if (typeof config.globalTimeout !== 'number' || config.globalTimeout < 0) throw errorWithFile(file, `config.globalTimeout must be a non-negative number`); } if ('grep' in config && config.grep !== undefined) { if (Array.isArray(config.grep)) { config.grep.forEach((item, index) => { if (!isRegExp(item)) throw errorWithFile(file, `config.grep[${index}] must be a RegExp`); }); } else if (!isRegExp(config.grep)) { throw errorWithFile(file, `config.grep must be a RegExp`); } } if ('grepInvert' in config && config.grepInvert !== undefined) { if (Array.isArray(config.grepInvert)) { config.grepInvert.forEach((item, index) => { if (!isRegExp(item)) throw errorWithFile(file, `config.grepInvert[${index}] must be a RegExp`); }); } else if (!isRegExp(config.grepInvert)) { throw errorWithFile(file, `config.grepInvert must be a RegExp`); } } if ('maxFailures' in config && config.maxFailures !== undefined) { if (typeof config.maxFailures !== 'number' || config.maxFailures < 0) throw errorWithFile(file, `config.maxFailures must be a non-negative number`); } if ('preserveOutput' in config && config.preserveOutput !== undefined) { if (typeof config.preserveOutput !== 'string' || !['always', 'never', 'failures-only'].includes(config.preserveOutput)) throw errorWithFile(file, `config.preserveOutput must be one of "always", "never" or "failures-only"`); } if ('projects' in config && config.projects !== undefined) { if (!Array.isArray(config.projects)) throw errorWithFile(file, `config.projects must be an array`); config.projects.forEach((project, index) => { validateProject(file, project, `config.projects[${index}]`); }); } if ('quiet' in config && config.quiet !== undefined) { if (typeof config.quiet !== 'boolean') throw errorWithFile(file, `config.quiet must be a boolean`); } if ('reporter' in config && config.reporter !== undefined) { if (Array.isArray(config.reporter)) { config.reporter.forEach((item, index) => { if (!Array.isArray(item) || item.length <= 0 || item.length > 2 || typeof item[0] !== 'string') throw errorWithFile(file, `config.reporter[${index}] must be a tuple [name, optionalArgument]`); }); } else if (typeof config.reporter !== 'string') { throw errorWithFile(file, `config.reporter must be a string`); } } if ('reportSlowTests' in config && config.reportSlowTests !== undefined && config.reportSlowTests !== null) { if (!config.reportSlowTests || typeof config.reportSlowTests !== 'object') throw errorWithFile(file, `config.reportSlowTests must be an object`); if (!('max' in config.reportSlowTests) || typeof config.reportSlowTests.max !== 'number' || config.reportSlowTests.max < 0) throw errorWithFile(file, `config.reportSlowTests.max must be a non-negative number`); if (!('threshold' in config.reportSlowTests) || typeof config.reportSlowTests.threshold !== 'number' || config.reportSlowTests.threshold < 0) throw errorWithFile(file, `config.reportSlowTests.threshold must be a non-negative number`); } if ('shard' in config && config.shard !== undefined && config.shard !== null) { if (!config.shard || typeof config.shard !== 'object') throw errorWithFile(file, `config.shard must be an object`); if (!('total' in config.shard) || typeof config.shard.total !== 'number' || config.shard.total < 1) throw errorWithFile(file, `config.shard.total must be a positive number`); if (!('current' in config.shard) || typeof config.shard.current !== 'number' || config.shard.current < 1 || config.shard.current > config.shard.total) throw errorWithFile(file, `config.shard.current must be a positive number, not greater than config.shard.total`); } if ('ignoreSnapshots' in config && config.ignoreSnapshots !== undefined) { if (typeof config.ignoreSnapshots !== 'boolean') throw errorWithFile(file, `config.ignoreSnapshots must be a boolean`); } if ('updateSnapshots' in config && config.updateSnapshots !== undefined) { if (typeof config.updateSnapshots !== 'string' || !['all', 'none', 'missing'].includes(config.updateSnapshots)) throw errorWithFile(file, `config.updateSnapshots must be one of "all", "none" or "missing"`); } if ('workers' in config && config.workers !== undefined) { if (typeof config.workers === 'number' && config.workers <= 0) throw errorWithFile(file, `config.workers must be a positive number`); else if (typeof config.workers === 'string' && !config.workers.endsWith('%')) throw errorWithFile(file, `config.workers must be a number or percentage`); } } function validateProject(file: string, project: Project, title: string) { if (typeof project !== 'object' || !project) throw errorWithFile(file, `${title} must be an object`); if ('name' in project && project.name !== undefined) { if (typeof project.name !== 'string') throw errorWithFile(file, `${title}.name must be a string`); } if ('outputDir' in project && project.outputDir !== undefined) { if (typeof project.outputDir !== 'string') throw errorWithFile(file, `${title}.outputDir must be a string`); } if ('repeatEach' in project && project.repeatEach !== undefined) { if (typeof project.repeatEach !== 'number' || project.repeatEach < 0) throw errorWithFile(file, `${title}.repeatEach must be a non-negative number`); } if ('retries' in project && project.retries !== undefined) { if (typeof project.retries !== 'number' || project.retries < 0) throw errorWithFile(file, `${title}.retries must be a non-negative number`); } if ('testDir' in project && project.testDir !== undefined) { if (typeof project.testDir !== 'string') throw errorWithFile(file, `${title}.testDir must be a string`); } for (const prop of ['testIgnore', 'testMatch'] as const) { if (prop in project && project[prop] !== undefined) { const value = project[prop]; if (Array.isArray(value)) { value.forEach((item, index) => { if (typeof item !== 'string' && !isRegExp(item)) throw errorWithFile(file, `${title}.${prop}[${index}] must be a string or a RegExp`); }); } else if (typeof value !== 'string' && !isRegExp(value)) { throw errorWithFile(file, `${title}.${prop} must be a string or a RegExp`); } } } if ('timeout' in project && project.timeout !== undefined) { if (typeof project.timeout !== 'number' || project.timeout < 0) throw errorWithFile(file, `${title}.timeout must be a non-negative number`); } if ('use' in project && project.use !== undefined) { if (!project.use || typeof project.use !== 'object') throw errorWithFile(file, `${title}.use must be an object`); } } export const kDefaultConfigFiles = ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs']; export function resolveConfigFile(configFileOrDirectory: string): string | null { const resolveConfig = (configFile: string) => { if (fs.existsSync(configFile)) return configFile; }; const resolveConfigFileFromDirectory = (directory: string) => { for (const configName of kDefaultConfigFiles) { const configFile = resolveConfig(path.resolve(directory, configName)); if (configFile) return configFile; } }; if (!fs.existsSync(configFileOrDirectory)) throw new Error(`${configFileOrDirectory} does not exist`); if (fs.statSync(configFileOrDirectory).isDirectory()) { // When passed a directory, look for a config file inside. const configFile = resolveConfigFileFromDirectory(configFileOrDirectory); if (configFile) return configFile; // If there is no config, assume this as a root testing directory. return null; } else { // When passed a file, it must be a config file. const configFile = resolveConfig(configFileOrDirectory); return configFile!; } }