chore: add find-related-tests command (#29439)

This commit is contained in:
Pavel Feldman 2024-02-09 19:02:42 -08:00 committed by GitHub
parent f0244b8a76
commit 586d14f02c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 498 additions and 294 deletions

View File

@ -16,42 +16,30 @@
import fs from 'fs';
import path from 'path';
import type { FullConfigInternal } from 'playwright/lib/common/config';
import { ConfigLoader, resolveConfigFile } from 'playwright/lib/common/configLoader';
import { Watcher } from 'playwright/lib/fsWatcher';
import { restartWithExperimentalTsEsm } from 'playwright/lib/program';
import { loadConfigFromFile } from 'playwright/lib/common/configLoader';
import { Runner } from 'playwright/lib/runner/runner';
import type { PluginContext } from 'rollup';
import { source as injectedSource } from './generated/indexSource';
import { createConfig, populateComponentsFromTests, resolveDirs, transformIndexFile } from './viteUtils';
import type { ComponentRegistry } from './viteUtils';
export async function loadConfig(configFile: string): Promise<FullConfigInternal | null> {
const configFileOrDirectory = configFile ? path.resolve(process.cwd(), configFile) : process.cwd();
const resolvedConfigFile = resolveConfigFile(configFileOrDirectory);
if (restartWithExperimentalTsEsm(resolvedConfigFile))
return null;
const configLoader = new ConfigLoader();
let config: FullConfigInternal;
if (resolvedConfigFile)
config = await configLoader.loadConfigFile(resolvedConfigFile);
else
config = await configLoader.loadEmptyConfig(configFileOrDirectory);
return config;
}
export async function runDevServer(configFile: string, registerSourceFile: string, frameworkPluginFactory: () => Promise<any>) {
const config = await loadConfig(configFile);
const config = await loadConfigFromFile(configFile);
if (!config)
return;
const runner = new Runner(config);
await runner.loadAllTests(true);
await runner.loadAllTests();
const componentRegistry: ComponentRegistry = new Map();
await populateComponentsFromTests(componentRegistry);
const dirs = await resolveDirs(config.configDir, config.config);
if (!dirs) {
// eslint-disable-next-line no-console
console.log(`Template file playwright/index.html is missing.`);
return;
}
const registerSource = injectedSource + '\n' + await fs.promises.readFile(registerSourceFile, 'utf-8');
const viteConfig = await createConfig(dirs, config.config, frameworkPluginFactory, false);
viteConfig.plugins.push({

View File

@ -16,55 +16,56 @@
import type { Command } from 'playwright-core/lib/utilsBundle';
import fs from 'fs';
import { program } from 'playwright/lib/program';
import { loadConfig, runDevServer } from './devServer';
import path from 'path';
import { program, removeFolder, setClearCacheCommandOverride, setFindRelatedTestsCommandOverride, withRunnerAndMutedWrite } from 'playwright/lib/program';
import { runDevServer } from './devServer';
import { resolveDirs } from './viteUtils';
import { cacheDir } from 'playwright/lib/transform/compilationCache';
import { affectedTestFiles, cacheDir } from 'playwright/lib/transform/compilationCache';
import { loadConfigFromFile } from 'playwright/lib/common/configLoader';
import { buildBundle } from './vitePlugin';
export { program } from 'playwright/lib/program';
let registerSourceFile: string;
let frameworkPluginFactory: () => Promise<any>;
let _framework: { registerSource: string, frameworkPluginFactory: () => Promise<any> };
export function initializePlugin(registerSource: string, factory: () => Promise<any>) {
registerSourceFile = registerSource;
frameworkPluginFactory = factory;
export function initializePlugin(framework: { registerSource: string, frameworkPluginFactory: () => Promise<any> }) {
_framework = framework;
}
function addDevServerCommand(program: Command) {
const command = program.command('dev-server');
command.description('start dev server');
command.option('-c, --config <file>', `Configuration file.`);
command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`);
command.action(options => {
runDevServer(options.config, registerSourceFile, frameworkPluginFactory);
runDevServer(options.config, _framework.registerSource, _framework.frameworkPluginFactory);
});
}
function addClearCacheCommand(program: Command) {
const command = program.command('clear-caches');
command.description('clears build and test caches');
command.option('-c, --config <file>', `Configuration file.`);
command.action(async options => {
const configFile = options.config;
const config = await loadConfig(configFile);
if (!config)
return;
const { outDir } = await resolveDirs(config.configDir, config.config);
await removeFolder(outDir);
await removeFolder(cacheDir);
setFindRelatedTestsCommandOverride(async (files, options) => {
await withRunnerAndMutedWrite(options.config, async (runner, config, configDir) => {
const result = await runner.loadAllTests();
if (result.status !== 'passed' || !result.suite)
return { errors: result.errors };
await buildBundle({
config,
configDir,
suite: result.suite,
registerSourceFile: _framework.registerSource,
frameworkPluginFactory: _framework.frameworkPluginFactory,
});
const resolvedFiles = (files as string[]).map(file => path.resolve(process.cwd(), file));
return { relatedTests: affectedTestFiles(resolvedFiles) };
});
}
});
async function removeFolder(folder: string) {
try {
if (!fs.existsSync(folder))
return;
// eslint-disable-next-line no-console
console.log(`Removing ${await fs.promises.realpath(folder)}`);
await fs.promises.rm(folder, { recursive: true, force: true });
} catch {
}
}
setClearCacheCommandOverride(async options => {
const configFile = options.config;
const config = await loadConfigFromFile(configFile);
if (!config)
return;
const dirs = await resolveDirs(config.configDir, config.config);
if (dirs)
await removeFolder(dirs.outDir);
await removeFolder(cacheDir);
});
addDevServerCommand(program);
addClearCacheCommand(program);

View File

@ -52,103 +52,18 @@ export function createPlugin(
},
begin: async (suite: Suite) => {
{
// Detect a running dev server and use it if available.
const endpoint = resolveEndpoint(config);
const protocol = endpoint.https ? 'https:' : 'http:';
const url = new URL(`${protocol}//${endpoint.host}:${endpoint.port}`);
if (await isURLAvailable(url, true)) {
// eslint-disable-next-line no-console
console.log(`Test Server is already running at ${url.toString()}, using it.\n`);
process.env.PLAYWRIGHT_TEST_BASE_URL = url.toString();
return;
}
}
const dirs = await resolveDirs(configDir, config);
const buildInfoFile = path.join(dirs.outDir, 'metainfo.json');
let buildExists = false;
let buildInfo: BuildInfo;
const registerSource = injectedSource + '\n' + await fs.promises.readFile(registerSourceFile, 'utf-8');
const registerSourceHash = calculateSha1(registerSource);
const { version: viteVersion, build, preview, mergeConfig } = await import('vite');
try {
buildInfo = JSON.parse(await fs.promises.readFile(buildInfoFile, 'utf-8')) as BuildInfo;
assert(buildInfo.version === playwrightVersion);
assert(buildInfo.viteVersion === viteVersion);
assert(buildInfo.registerSourceHash === registerSourceHash);
buildExists = true;
} catch (e) {
buildInfo = {
version: playwrightVersion,
viteVersion,
registerSourceHash,
components: [],
sources: {},
deps: {},
};
}
log('build exists:', buildExists);
const componentRegistry: ComponentRegistry = new Map();
const componentsByImportingFile = new Map<string, string[]>();
// 1. Populate component registry based on tests' component imports.
await populateComponentsFromTests(componentRegistry, componentsByImportingFile);
// 2. Check if the set of required components has changed.
const hasNewComponents = await checkNewComponents(buildInfo, componentRegistry);
log('has new components:', hasNewComponents);
// 3. Check component sources.
const sourcesDirty = !buildExists || hasNewComponents || await checkSources(buildInfo);
log('sourcesDirty:', sourcesDirty);
// 4. Update component info.
buildInfo.components = [...componentRegistry.values()];
const jsxInJS = hasJSComponents(buildInfo.components);
const viteConfig = await createConfig(dirs, config, frameworkPluginFactory, jsxInJS);
if (sourcesDirty) {
// Only add out own plugin when we actually build / transform.
log('build');
const depsCollector = new Map<string, string[]>();
const buildConfig = mergeConfig(viteConfig, {
plugins: [vitePlugin(registerSource, dirs.templateDir, buildInfo, componentRegistry, depsCollector)]
});
await build(buildConfig);
buildInfo.deps = Object.fromEntries(depsCollector.entries());
// Update dependencies based on the vite build.
for (const projectSuite of suite.suites) {
for (const fileSuite of projectSuite.suites) {
// For every test file...
const testFile = fileSuite.location!.file;
const deps = new Set<string>();
// Collect its JS dependencies (helpers).
for (const file of [testFile, ...(internalDependenciesForTestFile(testFile) || [])]) {
// For each helper, get all the imported components.
for (const componentFile of componentsByImportingFile.get(file) || []) {
// For each component, get all the dependencies.
for (const d of depsCollector.get(componentFile) || [])
deps.add(d);
}
}
// Now we have test file => all components along with dependencies.
setExternalDependencies(testFile, [...deps]);
}
}
}
if (hasNewComponents || sourcesDirty) {
log('write manifest');
await fs.promises.writeFile(buildInfoFile, JSON.stringify(buildInfo, undefined, 2));
}
const result = await buildBundle({
config,
configDir,
suite,
registerSourceFile,
frameworkPluginFactory: frameworkPluginFactory,
});
if (!result)
return;
const { viteConfig } = result;
const { preview } = await import('vite');
const previewServer = await preview(viteConfig);
stoppableServer = stoppable(previewServer.httpServer as http.Server, 0);
const isAddressInfo = (x: any): x is AddressInfo => x?.address;
@ -181,6 +96,120 @@ type BuildInfo = {
}
};
export async function buildBundle(options: {
config: FullConfig,
configDir: string,
suite: Suite,
registerSourceFile: string,
frameworkPluginFactory?: () => Promise<Plugin>
}): Promise<{ buildInfo: BuildInfo, viteConfig: Record<string, any> } | null> {
{
// Detect a running dev server and use it if available.
const endpoint = resolveEndpoint(options.config);
const protocol = endpoint.https ? 'https:' : 'http:';
const url = new URL(`${protocol}//${endpoint.host}:${endpoint.port}`);
if (await isURLAvailable(url, true)) {
// eslint-disable-next-line no-console
console.log(`Test Server is already running at ${url.toString()}, using it.\n`);
process.env.PLAYWRIGHT_TEST_BASE_URL = url.toString();
return null;
}
}
const dirs = await resolveDirs(options.configDir, options.config);
if (!dirs) {
// eslint-disable-next-line no-console
console.log(`Template file playwright/index.html is missing.`);
return null;
}
const buildInfoFile = path.join(dirs.outDir, 'metainfo.json');
let buildExists = false;
let buildInfo: BuildInfo;
const registerSource = injectedSource + '\n' + await fs.promises.readFile(options.registerSourceFile, 'utf-8');
const registerSourceHash = calculateSha1(registerSource);
const { version: viteVersion, build, mergeConfig } = await import('vite');
try {
buildInfo = JSON.parse(await fs.promises.readFile(buildInfoFile, 'utf-8')) as BuildInfo;
assert(buildInfo.version === playwrightVersion);
assert(buildInfo.viteVersion === viteVersion);
assert(buildInfo.registerSourceHash === registerSourceHash);
buildExists = true;
} catch (e) {
buildInfo = {
version: playwrightVersion,
viteVersion,
registerSourceHash,
components: [],
sources: {},
deps: {},
};
}
log('build exists:', buildExists);
const componentRegistry: ComponentRegistry = new Map();
const componentsByImportingFile = new Map<string, string[]>();
// 1. Populate component registry based on tests' component imports.
await populateComponentsFromTests(componentRegistry, componentsByImportingFile);
// 2. Check if the set of required components has changed.
const hasNewComponents = await checkNewComponents(buildInfo, componentRegistry);
log('has new components:', hasNewComponents);
// 3. Check component sources.
const sourcesDirty = !buildExists || hasNewComponents || await checkSources(buildInfo);
log('sourcesDirty:', sourcesDirty);
// 4. Update component info.
buildInfo.components = [...componentRegistry.values()];
const jsxInJS = hasJSComponents(buildInfo.components);
const viteConfig = await createConfig(dirs, options.config, options.frameworkPluginFactory, jsxInJS);
if (sourcesDirty) {
// Only add out own plugin when we actually build / transform.
log('build');
const depsCollector = new Map<string, string[]>();
const buildConfig = mergeConfig(viteConfig, {
plugins: [vitePlugin(registerSource, dirs.templateDir, buildInfo, componentRegistry, depsCollector)]
});
await build(buildConfig);
buildInfo.deps = Object.fromEntries(depsCollector.entries());
}
{
// Update dependencies based on the vite build.
for (const projectSuite of options.suite.suites) {
for (const fileSuite of projectSuite.suites) {
// For every test file...
const testFile = fileSuite.location!.file;
const deps = new Set<string>();
// Collect its JS dependencies (helpers).
for (const file of [testFile, ...(internalDependenciesForTestFile(testFile) || [])]) {
// For each helper, get all the imported components.
for (const componentFile of componentsByImportingFile.get(file) || []) {
// For each component, get all the dependencies.
for (const d of buildInfo.deps[componentFile] || [])
deps.add(d);
}
}
// Now we have test file => all components along with dependencies.
setExternalDependencies(testFile, [...deps]);
}
}
}
if (hasNewComponents || sourcesDirty) {
log('write manifest');
await fs.promises.writeFile(buildInfoFile, JSON.stringify(buildInfo, undefined, 2));
}
return { buildInfo, viteConfig };
}
async function checkSources(buildInfo: BuildInfo): Promise<boolean> {
for (const [source, sourceInfo] of Object.entries(buildInfo.sources)) {
try {

View File

@ -39,13 +39,15 @@ export type ComponentDirs = {
templateDir: string;
};
export async function resolveDirs(configDir: string, config: FullConfig): Promise<ComponentDirs> {
export async function resolveDirs(configDir: string, config: FullConfig): Promise<ComponentDirs | null> {
const use = config.projects[0].use as CtConfig;
// FIXME: use build plugin to determine html location to resolve this.
// TemplateDir must be relative, otherwise we can't move the final index.html into its target location post-build.
// This regressed in https://github.com/microsoft/playwright/pull/26526
const relativeTemplateDir = use.ctTemplateDir || 'playwright';
const templateDir = await fs.promises.realpath(path.normalize(path.join(configDir, relativeTemplateDir)));
const templateDir = await fs.promises.realpath(path.normalize(path.join(configDir, relativeTemplateDir))).catch(() => undefined);
if (!templateDir)
return null;
const outDir = use.ctCacheDir ? path.resolve(configDir, use.ctCacheDir) : path.resolve(templateDir, '.cache');
return {
configDir,

View File

@ -15,8 +15,8 @@
* limitations under the License.
*/
const path = require('path');
const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program');
const { _framework } = require('./index');
initializePlugin(path.join(__dirname, 'registerSource.mjs'), () => import('@vitejs/plugin-react').then(plugin => plugin.default()))
initializePlugin(_framework);
program.parse(process.argv);

View File

@ -17,13 +17,14 @@
const { test, expect, devices, defineConfig: originalDefineConfig } = require('@playwright/experimental-ct-core');
const path = require('path');
const registerSource = path.join(__dirname, 'registerSource.mjs');
const frameworkPluginFactory = () => import('@vitejs/plugin-react').then(plugin => plugin.default());
const plugin = () => {
// Only fetch upon request to avoid resolution in workers.
const { createPlugin } = require('@playwright/experimental-ct-core/plugin');
return createPlugin(
path.join(__dirname, 'registerSource.mjs'),
() => import('@vitejs/plugin-react').then(plugin => plugin.default()));
return createPlugin(registerSource, frameworkPluginFactory);
};
const defineConfig = (config, ...configs) => originalDefineConfig({ ...config, _plugins: [plugin] }, ...configs);
module.exports = { test, expect, devices, defineConfig };
module.exports = { test, expect, devices, defineConfig, _framework: { registerSource, frameworkPluginFactory } };

View File

@ -15,5 +15,8 @@
* limitations under the License.
*/
const { program } = require('@playwright/experimental-ct-core/lib/program');
const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program');
const { _framework } = require('./index');
initializePlugin(_framework);
program.parse(process.argv);

View File

@ -17,13 +17,14 @@
const { test, expect, devices, defineConfig: originalDefineConfig } = require('@playwright/experimental-ct-core');
const path = require('path');
const registerSource = path.join(__dirname, 'registerSource.mjs');
const frameworkPluginFactory = () => import('@vitejs/plugin-react').then(plugin => plugin.default());
const plugin = () => {
// Only fetch upon request to avoid resolution in workers.
const { createPlugin } = require('@playwright/experimental-ct-core/plugin');
return createPlugin(
path.join(__dirname, 'registerSource.mjs'),
() => import('@vitejs/plugin-react').then(plugin => plugin.default()));
return createPlugin(registerSource, frameworkPluginFactory);
};
const defineConfig = (config, ...configs) => originalDefineConfig({ ...config, _plugins: [plugin] }, ...configs);
module.exports = { test, expect, devices, defineConfig };
module.exports = { test, expect, devices, defineConfig, _framework: { registerSource, frameworkPluginFactory } };

View File

@ -15,5 +15,8 @@
* limitations under the License.
*/
const { program } = require('@playwright/experimental-ct-core/lib/program');
const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program');
const { _framework } = require('./index');
initializePlugin(_framework);
program.parse(process.argv);

View File

@ -17,13 +17,14 @@
const { test, expect, devices, defineConfig: originalDefineConfig } = require('@playwright/experimental-ct-core');
const path = require('path');
const registerSource = path.join(__dirname, 'registerSource.mjs');
const frameworkPluginFactory = () => import('vite-plugin-solid').then(plugin => plugin.default());
const plugin = () => {
// Only fetch upon request to avoid resolution in workers.
const { createPlugin } = require('@playwright/experimental-ct-core/plugin');
return createPlugin(
path.join(__dirname, 'registerSource.mjs'),
() => import('vite-plugin-solid').then(plugin => plugin.default()));
return createPlugin(registerSource, frameworkPluginFactory);
};
const defineConfig = (config, ...configs) => originalDefineConfig({ ...config, _plugins: [plugin] }, ...configs);
module.exports = { test, expect, devices, defineConfig };
module.exports = { test, expect, devices, defineConfig, _framework: { registerSource, frameworkPluginFactory } };

View File

@ -15,5 +15,8 @@
* limitations under the License.
*/
const { program } = require('@playwright/experimental-ct-core/lib/program');
const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program');
const { _framework } = require('./index');
initializePlugin(_framework);
program.parse(process.argv);

View File

@ -17,13 +17,14 @@
const { test, expect, devices, defineConfig: originalDefineConfig } = require('@playwright/experimental-ct-core');
const path = require('path');
const registerSource = path.join(__dirname, 'registerSource.mjs');
const frameworkPluginFactory = () => import('@sveltejs/vite-plugin-svelte').then(plugin => plugin.svelte());
const plugin = () => {
// Only fetch upon request to avoid resolution in workers.
const { createPlugin } = require('@playwright/experimental-ct-core/plugin');
return createPlugin(
path.join(__dirname, 'registerSource.mjs'),
() => import('@sveltejs/vite-plugin-svelte').then(plugin => plugin.svelte()));
return createPlugin(registerSource, frameworkPluginFactory);
};
const defineConfig = (config, ...configs) => originalDefineConfig({ ...config, _plugins: [plugin] }, ...configs);
module.exports = { test, expect, devices, defineConfig };
module.exports = { test, expect, devices, defineConfig, _framework: { registerSource, frameworkPluginFactory } };

View File

@ -15,5 +15,8 @@
* limitations under the License.
*/
const { program } = require('@playwright/experimental-ct-core/lib/program');
const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program');
const { _framework } = require('./index');
initializePlugin(_framework);
program.parse(process.argv);

View File

@ -17,13 +17,14 @@
const { test, expect, devices, defineConfig: originalDefineConfig } = require('@playwright/experimental-ct-core');
const path = require('path');
const registerSource = path.join(__dirname, 'registerSource.mjs');
const frameworkPluginFactory = () => import('@vitejs/plugin-vue').then(plugin => plugin.default());
const plugin = () => {
// Only fetch upon request to avoid resolution in workers.
const { createPlugin } = require('@playwright/experimental-ct-core/plugin');
return createPlugin(
path.join(__dirname, 'registerSource.mjs'),
() => import('@vitejs/plugin-vue').then(plugin => plugin.default()));
}
return createPlugin(registerSource, frameworkPluginFactory);
};
const defineConfig = (config, ...configs) => originalDefineConfig({ ...config, _plugins: [plugin] }, ...configs);
module.exports = { test, expect, devices, defineConfig };
module.exports = { test, expect, devices, defineConfig, _framework: { registerSource, frameworkPluginFactory } };

View File

@ -15,5 +15,8 @@
* limitations under the License.
*/
const { program } = require('@playwright/experimental-ct-core/lib/program');
const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program');
const { _framework } = require('./index');
initializePlugin(_framework);
program.parse(process.argv);

View File

@ -17,13 +17,14 @@
const { test, expect, devices, defineConfig: originalDefineConfig } = require('@playwright/experimental-ct-core');
const path = require('path');
const registerSource = path.join(__dirname, 'registerSource.mjs');
const frameworkPluginFactory = () => import('@vitejs/plugin-vue2').then(plugin => plugin.default());
const plugin = () => {
// Only fetch upon request to avoid resolution in workers.
const { createPlugin } = require('@playwright/experimental-ct-core/plugin');
return createPlugin(
path.join(__dirname, 'registerSource.mjs'),
() => import('@vitejs/plugin-vue2').then(plugin => plugin.default()));
return createPlugin(registerSource, frameworkPluginFactory);
};
const defineConfig = (config, ...configs) => originalDefineConfig({ ...config, _plugins: [plugin] }, ...configs);
module.exports = { test, expect, devices, defineConfig };
module.exports = { test, expect, devices, defineConfig, _framework: { registerSource, frameworkPluginFactory } };

View File

@ -16,15 +16,16 @@
import * as fs from 'fs';
import * as path from 'path';
import { isRegExp } from 'playwright-core/lib/utils';
import { gracefullyProcessExitDoNotHang, isRegExp } from 'playwright-core/lib/utils';
import type { ConfigCLIOverrides, SerializedConfig } from './ipc';
import { requireOrImport } from '../transform/transform';
import type { Config, Project } from '../../types/test';
import { errorWithFile } from '../util';
import { errorWithFile, fileIsModule } from '../util';
import { setCurrentConfig } from './globals';
import { FullConfigInternal } from './config';
import { addToCompilationCache } from '../transform/compilationCache';
import { initializeEsmLoader } from './esmLoaderHost';
import { initializeEsmLoader, registerESMLoader } from './esmLoaderHost';
import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils';
const kDefineConfigWasUsed = Symbol('defineConfigWasUsed');
export const defineConfig = (...configs: any[]) => {
@ -339,3 +340,55 @@ export function resolveConfigFile(configFileOrDirectory: string): string | null
return configFile!;
}
}
export async function loadConfigFromFile(configFile: string | undefined, overrides?: ConfigCLIOverrides, ignoreDeps?: boolean): Promise<FullConfigInternal | null> {
const configFileOrDirectory = configFile ? path.resolve(process.cwd(), configFile) : process.cwd();
const resolvedConfigFile = resolveConfigFile(configFileOrDirectory);
if (restartWithExperimentalTsEsm(resolvedConfigFile))
return null;
const configLoader = new ConfigLoader(overrides);
let config: FullConfigInternal;
if (resolvedConfigFile)
config = await configLoader.loadConfigFile(resolvedConfigFile, ignoreDeps);
else
config = await configLoader.loadEmptyConfig(configFileOrDirectory);
return config;
}
export function restartWithExperimentalTsEsm(configFile: string | null): boolean {
const nodeVersion = +process.versions.node.split('.')[0];
// New experimental loader is only supported on Node 16+.
if (nodeVersion < 16)
return false;
if (!configFile)
return false;
if (process.env.PW_DISABLE_TS_ESM)
return false;
// Node.js < 20
if ((globalThis as any).__esmLoaderPortPreV20) {
// clear execArgv after restart, so that childProcess.fork in user code does not inherit our loader.
process.execArgv = execArgvWithoutExperimentalLoaderOptions();
return false;
}
if (!fileIsModule(configFile))
return false;
// Node.js < 20
if (!require('node:module').register) {
const innerProcess = (require('child_process') as typeof import('child_process')).fork(require.resolve('../../cli'), process.argv.slice(2), {
env: {
...process.env,
PW_TS_ESM_LEGACY_LOADER_ON: '1',
},
execArgv: execArgvWithExperimentalLoaderOptions(),
});
innerProcess.on('close', (code: number | null) => {
if (code !== 0 && code !== null)
gracefullyProcessExitDoNotHang(code);
});
return true;
}
// Nodejs >= 21
registerESMLoader();
return false;
}

View File

@ -21,21 +21,20 @@ import fs from 'fs';
import path from 'path';
import { Runner } from './runner/runner';
import { stopProfiling, startProfiling, gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils';
import { fileIsModule, serializeError } from './util';
import { serializeError } from './util';
import { showHTMLReport } from './reporters/html';
import { createMergedReport } from './reporters/merge';
import { ConfigLoader, resolveConfigFile } from './common/configLoader';
import { ConfigLoader, loadConfigFromFile } from './common/configLoader';
import type { ConfigCLIOverrides } from './common/ipc';
import type { FullResult, TestError } from '../types/testReporter';
import type { TraceMode } from '../types/test';
import type { FullConfig, TraceMode } from '../types/test';
import { builtInReporters, defaultReporter, defaultTimeout } from './common/config';
import type { FullConfigInternal } from './common/config';
import { program } from 'playwright-core/lib/cli/program';
export { program } from 'playwright-core/lib/cli/program';
import type { ReporterDescription } from '../types/test';
import { prepareErrorStack } from './reporters/base';
import { registerESMLoader } from './common/esmLoaderHost';
import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from './transform/esmUtils';
import { affectedTestFiles, cacheDir } from './transform/compilationCache';
function addTestCommand(program: Command) {
const command = program.command('test [test-filter...]');
@ -76,6 +75,54 @@ function addListFilesCommand(program: Command) {
});
}
let clearCacheCommandOverride: (opts: any) => Promise<void>;
export function setClearCacheCommandOverride(body: (opts: any) => Promise<void>) {
clearCacheCommandOverride = body;
}
function addClearCacheCommand(program: Command) {
const command = program.command('clear-cache');
command.description('clears build and test caches');
command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`);
command.action(async opts => {
if (clearCacheCommandOverride)
return clearCacheCommandOverride(opts);
await removeFolder(cacheDir);
});
}
export async function removeFolder(folder: string) {
try {
if (!fs.existsSync(folder))
return;
console.log(`Removing ${await fs.promises.realpath(folder)}`);
await fs.promises.rm(folder, { recursive: true, force: true });
} catch {
}
}
let findRelatedTestsCommandOverride: (files: string[], opts: any) => Promise<void>;
export function setFindRelatedTestsCommandOverride(body: (files: string[], opts: any) => Promise<void>) {
findRelatedTestsCommandOverride = body;
}
function addFindRelatedTestsCommand(program: Command) {
const command = program.command('find-related-tests [source-files...]');
command.description('Returns the list of related tests to the given files');
command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`);
command.action(async (files, options) => {
if (findRelatedTestsCommandOverride)
return findRelatedTestsCommandOverride(files, options);
await withRunnerAndMutedWrite(options.config, async runner => {
const result = await runner.loadAllTests();
if (result.status !== 'passed' || !result.suite)
return { errors: result.errors };
const resolvedFiles = (files as string[]).map(file => path.resolve(process.cwd(), file));
return { relatedTests: affectedTestFiles(resolvedFiles) };
});
});
}
function addShowReportCommand(program: Command) {
const command = program.command('show-report [report]');
command.description('show HTML report');
@ -115,21 +162,10 @@ Examples:
async function runTests(args: string[], opts: { [key: string]: any }) {
await startProfiling();
// When no --config option is passed, let's look for the config file in the current directory.
const configFileOrDirectory = opts.config ? path.resolve(process.cwd(), opts.config) : process.cwd();
const resolvedConfigFile = resolveConfigFile(configFileOrDirectory);
if (restartWithExperimentalTsEsm(resolvedConfigFile))
const config = await loadConfigFromFile(opts.config, overridesFromOptions(opts), opts.deps === false);
if (!config)
return;
const overrides = overridesFromOptions(opts);
const configLoader = new ConfigLoader(overrides);
let config: FullConfigInternal;
if (resolvedConfigFile)
config = await configLoader.loadConfigFile(resolvedConfigFile, opts.deps === false);
else
config = await configLoader.loadEmptyConfig(configFileOrDirectory);
config.cliArgs = args;
config.cliGrep = opts.grep as string | undefined;
config.cliGrepInvert = opts.grepInvert as string | undefined;
@ -151,47 +187,44 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
gracefullyProcessExitDoNotHang(exitCode);
}
async function listTestFiles(opts: { [key: string]: any }) {
export async function withRunnerAndMutedWrite(configFile: string | undefined, callback: (runner: Runner, config: FullConfig, configDir: string) => Promise<any>) {
// Redefine process.stdout.write in case config decides to pollute stdio.
const stdoutWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (() => {}) as any;
process.stderr.write = (() => {}) as any;
const configFileOrDirectory = opts.config ? path.resolve(process.cwd(), opts.config) : process.cwd();
const resolvedConfigFile = resolveConfigFile(configFileOrDirectory)!;
if (restartWithExperimentalTsEsm(resolvedConfigFile))
return;
process.stdout.write = ((a: any, b: any, c: any) => process.stderr.write(a, b, c)) as any;
try {
const configLoader = new ConfigLoader();
const config = await configLoader.loadConfigFile(resolvedConfigFile);
const config = await loadConfigFromFile(configFile);
if (!config)
return;
const runner = new Runner(config);
const report = await runner.listTestFiles(opts.project);
stdoutWrite(JSON.stringify(report), () => {
const result = await callback(runner, config.config, config.configDir);
stdoutWrite(JSON.stringify(result, undefined, 2), () => {
gracefullyProcessExitDoNotHang(0);
});
} catch (e) {
const error: TestError = serializeError(e);
error.location = prepareErrorStack(e.stack).location;
stdoutWrite(JSON.stringify({ error }), () => {
stdoutWrite(JSON.stringify({ error }, undefined, 2), () => {
gracefullyProcessExitDoNotHang(0);
});
}
}
async function listTestFiles(opts: { [key: string]: any }) {
await withRunnerAndMutedWrite(opts.config, async runner => runner.listTestFiles(opts.project));
}
async function mergeReports(reportDir: string | undefined, opts: { [key: string]: any }) {
let configFile = opts.config;
const configFile = opts.config;
let config: FullConfigInternal | null;
if (configFile) {
configFile = path.resolve(process.cwd(), configFile);
if (!fs.existsSync(configFile))
throw new Error(`${configFile} does not exist`);
if (!fs.statSync(configFile).isFile())
throw new Error(`${configFile} is not a file`);
config = await loadConfigFromFile(configFile);
} else {
const configLoader = new ConfigLoader();
config = await configLoader.loadEmptyConfig(process.cwd());
}
if (restartWithExperimentalTsEsm(configFile))
if (!config)
return;
const configLoader = new ConfigLoader();
const config = await (configFile ? configLoader.loadConfigFile(configFile) : configLoader.loadEmptyConfig(process.cwd()));
const dir = path.resolve(process.cwd(), reportDir || '');
const dirStat = await fs.promises.stat(dir).catch(e => null);
if (!dirStat)
@ -272,44 +305,6 @@ function resolveReporter(id: string) {
return require.resolve(id, { paths: [process.cwd()] });
}
export function restartWithExperimentalTsEsm(configFile: string | null): boolean {
const nodeVersion = +process.versions.node.split('.')[0];
// New experimental loader is only supported on Node 16+.
if (nodeVersion < 16)
return false;
if (!configFile)
return false;
if (process.env.PW_DISABLE_TS_ESM)
return false;
// Node.js < 20
if ((globalThis as any).__esmLoaderPortPreV20) {
// clear execArgv after restart, so that childProcess.fork in user code does not inherit our loader.
process.execArgv = execArgvWithoutExperimentalLoaderOptions();
return false;
}
if (!fileIsModule(configFile))
return false;
// Node.js < 20
if (!require('node:module').register) {
const innerProcess = (require('child_process') as typeof import('child_process')).fork(require.resolve('../cli'), process.argv.slice(2), {
env: {
...process.env,
PW_TS_ESM_LEGACY_LOADER_ON: '1',
},
execArgv: execArgvWithExperimentalLoaderOptions(),
});
innerProcess.on('close', (code: number | null) => {
if (code !== 0 && code !== null)
gracefullyProcessExitDoNotHang(code);
});
return true;
}
// Nodejs >= 21
registerESMLoader();
return false;
}
const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries', 'retain-on-failure'];
const testOptions: [string, string][] = [
@ -349,3 +344,5 @@ addTestCommand(program);
addShowReportCommand(program);
addListFilesCommand(program);
addMergeReportsCommand(program);
addClearCacheCommand(program);
addFindRelatedTestsCommand(program);

View File

@ -16,7 +16,7 @@
*/
import { monotonicTime } from 'playwright-core/lib/utils';
import type { FullResult } from '../../types/testReporter';
import type { FullResult, TestError } from '../../types/testReporter';
import { webServerPluginsForConfig } from '../plugins/webServerPlugin';
import { collectFilesForProject, filterProjects } from './projectUtils';
import { createReporters } from './reporters';
@ -27,6 +27,8 @@ import { runWatchModeLoop } from './watchMode';
import { runUIMode } from './uiMode';
import { InternalReporter } from '../reporters/internalReporter';
import { Multiplexer } from '../reporters/multiplexer';
import type { Suite } from '../common/test';
import { wrapReporterAsV2 } from '../reporters/reporterV2';
type ProjectConfigWithFiles = {
name: string;
@ -104,10 +106,15 @@ export class Runner {
return status;
}
async loadAllTests(outOfProcess?: boolean): Promise<FullResult['status']> {
async loadAllTests(): Promise<{ status: FullResult['status'], suite?: Suite, errors: TestError[] }> {
const config = this._config;
const reporter = new InternalReporter(new Multiplexer([]));
const taskRunner = createTaskRunnerForList(config, reporter, outOfProcess ? 'out-of-process' : 'in-process', { failOnLoadErrors: true });
const errors: TestError[] = [];
const reporter = new InternalReporter(new Multiplexer([wrapReporterAsV2({
onError(error: TestError) {
errors.push(error);
}
})]));
const taskRunner = createTaskRunnerForList(config, reporter, 'in-process', { failOnLoadErrors: true });
const testRun = new TestRun(config, reporter);
reporter.onConfigure(config.config);
@ -119,7 +126,7 @@ export class Runner {
if (modifiedResult && modifiedResult.status)
status = modifiedResult.status;
await reporter.onExit();
return status;
return { status, suite: testRun.rootSuite, errors };
}
async watchAllTests(): Promise<FullResult['status']> {

View File

@ -200,7 +200,8 @@ export function fileDependenciesForTest() {
}
export function collectAffectedTestFiles(dependency: string, testFileCollector: Set<string>) {
testFileCollector.add(dependency);
if (fileDependencies.has(dependency))
testFileCollector.add(dependency);
for (const [testFile, deps] of fileDependencies) {
if (deps.has(dependency))
testFileCollector.add(testFile);
@ -211,6 +212,13 @@ export function collectAffectedTestFiles(dependency: string, testFileCollector:
}
}
export function affectedTestFiles(changes: string[]): string[] {
const result = new Set<string>();
for (const change of changes)
collectAffectedTestFiles(change, result);
return [...result];
}
export function internalDependenciesForTestFile(filename: string): Set<string> | undefined{
return fileDependencies.get(filename);
}

View File

@ -96,6 +96,8 @@ export class TestChildProcess {
params: TestChildParams;
process: ChildProcess;
output = '';
stdout = '';
stderr = '';
fullOutput = '';
onOutput?: (chunk: string | Buffer) => void;
exited: Promise<{ exitCode: number | null, signal: string | null }>;
@ -121,8 +123,12 @@ export class TestChildProcess {
process.stdout.write(`\n\nLaunching ${params.command.join(' ')}\n`);
this.onOutput = params.onOutput;
const appendChunk = (chunk: string | Buffer) => {
const appendChunk = (type: 'stdout' | 'stderr', chunk: string | Buffer) => {
this.output += String(chunk);
if (type === 'stderr')
this.stderr += String(chunk);
else
this.stdout += String(chunk);
if (process.env.PWTEST_DEBUG)
process.stdout.write(String(chunk));
else
@ -133,8 +139,8 @@ export class TestChildProcess {
this._outputCallbacks.clear();
};
this.process.stderr!.on('data', appendChunk);
this.process.stdout!.on('data', appendChunk);
this.process.stderr!.on('data', appendChunk.bind(null, 'stderr'));
this.process.stdout!.on('data', appendChunk.bind(null, 'stdout'));
const killProcessGroup = this._killProcessTree.bind(this, 'SIGKILL');
process.on('exit', killProcessGroup);

View File

@ -0,0 +1,88 @@
/**
* 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 { test, expect } from './playwright-test-fixtures';
import path from 'path';
export const ctReactCliEntrypoint = path.join(__dirname, '../../packages/playwright-ct-react/cli.js');
test('should list related tests', async ({ runCLICommand }) => {
const result = await runCLICommand({
'playwright.config.ts': `
import { defineConfig } from '@playwright/test';
export default defineConfig({});
`,
'helper.ts': `
export const value = 42;
`,
'helper2.ts': `
export { value } from './helper';
`,
'a.spec.ts': `
import { test } from '@playwright/test';
import { value } from './helper2';
if (value) {}
test('', () => {});
`,
'b.spec.ts': `
import { test } from '@playwright/test';
import { value } from './helper';
if (value) {}
test('', () => {});
`,
}, 'find-related-tests', ['helper.ts']);
expect(result.exitCode).toBe(0);
expect(result.stderr).toBeFalsy();
const data = JSON.parse(result.stdout);
expect(data).toEqual({
relatedTests: [
expect.stringContaining('a.spec.ts'),
expect.stringContaining('b.spec.ts'),
]
});
});
test('should list related tests for ct', async ({ runCLICommand }) => {
const result = await runCLICommand({
'playwright.config.ts': `
import { defineConfig } from '@playwright/experimental-ct-react';
export default defineConfig({});
`,
'playwright/index.html': `<script type="module" src="./index.js"></script>`,
'playwright/index.js': ``,
'helper.tsx': `
export const HelperButton = () => <button>Click me</button>;
`,
'button.tsx': `
import { HelperButton } from './helper';
export const Button = () => <HelperButton>Click me</HelperButton>;
`,
'button.spec.tsx': `
import { test } from '@playwright/experimental-ct-react';
import { Button } from './button';
test('foo', async ({ mount }) => {
await mount(<Button />);
});
`,
}, 'find-related-tests', ['helper.tsx'], ctReactCliEntrypoint);
expect(result.exitCode).toBe(0);
const data = JSON.parse(result.stdout);
expect(data).toEqual({
relatedTests: [
expect.stringContaining('button.spec.tsx'),
]
});
});

View File

@ -16,16 +16,16 @@
import { test, expect } from './playwright-test-fixtures';
test('should list files', async ({ runListFiles }) => {
const result = await runListFiles({
test('should list files', async ({ runCLICommand }) => {
const result = await runCLICommand({
'playwright.config.ts': `
module.exports = { projects: [{ name: 'foo' }, { name: 'bar' }] };
`,
'a.test.js': ``
});
}, 'list-files');
expect(result.exitCode).toBe(0);
const data = JSON.parse(result.output);
const data = JSON.parse(result.stdout);
expect(data).toEqual({
projects: [
{
@ -48,18 +48,18 @@ test('should list files', async ({ runListFiles }) => {
});
});
test('should include testIdAttribute', async ({ runListFiles }) => {
const result = await runListFiles({
test('should include testIdAttribute', async ({ runCLICommand }) => {
const result = await runCLICommand({
'playwright.config.ts': `
module.exports = {
use: { testIdAttribute: 'myid' }
};
`,
'a.test.js': ``
});
}, 'list-files');
expect(result.exitCode).toBe(0);
const data = JSON.parse(result.output);
const data = JSON.parse(result.stdout);
expect(data).toEqual({
projects: [
{
@ -76,17 +76,17 @@ test('should include testIdAttribute', async ({ runListFiles }) => {
});
});
test('should report error', async ({ runListFiles }) => {
const result = await runListFiles({
test('should report error', async ({ runCLICommand }) => {
const result = await runCLICommand({
'playwright.config.ts': `
const a = 1;
a = 2;
`,
'a.test.js': ``
});
}, 'list-files');
expect(result.exitCode).toBe(0);
const data = JSON.parse(result.output);
const data = JSON.parse(result.stdout);
expect(data).toEqual({
error: {
location: {

View File

@ -37,6 +37,8 @@ type CliRunResult = {
export type RunResult = {
exitCode: number,
output: string,
stdout: string,
stderr: string,
outputLines: string[],
rawOutput: string,
passed: number,
@ -185,19 +187,21 @@ async function runPlaywrightTest(childProcess: CommonFixtures['childProcess'], b
...parsed,
exitCode,
rawOutput: output,
stdout: testProcess.stdout,
stderr: testProcess.stderr,
report,
results,
};
}
async function runPlaywrightListFiles(childProcess: CommonFixtures['childProcess'], baseDir: string, env: NodeJS.ProcessEnv): Promise<{ output: string, exitCode: number }> {
async function runPlaywrightCLI(childProcess: CommonFixtures['childProcess'], args: string[], baseDir: string, env: NodeJS.ProcessEnv, entryPoint?: string): Promise<{ output: string, stdout: string, stderr: string, exitCode: number }> {
const testProcess = childProcess({
command: ['node', cliEntrypoint, 'list-files'],
command: ['node', entryPoint || cliEntrypoint, ...args],
env: cleanEnv(env),
cwd: baseDir,
});
const { exitCode } = await testProcess.exited;
return { exitCode, output: testProcess.output };
return { exitCode, output: testProcess.output, stdout: testProcess.stdout, stderr: testProcess.stderr };
}
export function cleanEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
@ -235,7 +239,7 @@ type Fixtures = {
writeFiles: (files: Files) => Promise<string>;
deleteFile: (file: string) => Promise<void>;
runInlineTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<RunResult>;
runListFiles: (files: Files) => Promise<{ output: string, exitCode: number }>;
runCLICommand: (files: Files, command: string, args?: string[], entryPoint?: string) => Promise<{ stdout: string, stderr: string, exitCode: number }>;
runWatchTest: (files: Files, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<TestChildProcess>;
interactWithTestRunner: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<TestChildProcess>;
runTSC: (files: Files) => Promise<TSCResult>;
@ -268,11 +272,11 @@ export const test = base
await removeFolders([cacheDir]);
},
runListFiles: async ({ childProcess }, use, testInfo: TestInfo) => {
runCLICommand: async ({ childProcess }, use, testInfo: TestInfo) => {
const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-'));
await use(async (files: Files) => {
await use(async (files: Files, command: string, args?: string[], entryPoint?: string) => {
const baseDir = await writeFiles(testInfo, files, true);
return await runPlaywrightListFiles(childProcess, baseDir, { PWTEST_CACHE_DIR: cacheDir });
return await runPlaywrightCLI(childProcess, [command, ...(args || [])], baseDir, { PWTEST_CACHE_DIR: cacheDir }, entryPoint);
});
await removeFolders([cacheDir]);
},