diff --git a/packages/playwright-ct-core/src/devServer.ts b/packages/playwright-ct-core/src/devServer.ts index eec7f9c0d7..ab70ebfa04 100644 --- a/packages/playwright-ct-core/src/devServer.ts +++ b/packages/playwright-ct-core/src/devServer.ts @@ -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 { - 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) { - 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({ diff --git a/packages/playwright-ct-core/src/program.ts b/packages/playwright-ct-core/src/program.ts index d934324052..9acb80ad14 100644 --- a/packages/playwright-ct-core/src/program.ts +++ b/packages/playwright-ct-core/src/program.ts @@ -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; +let _framework: { registerSource: string, frameworkPluginFactory: () => Promise }; -export function initializePlugin(registerSource: string, factory: () => Promise) { - registerSourceFile = registerSource; - frameworkPluginFactory = factory; +export function initializePlugin(framework: { registerSource: string, frameworkPluginFactory: () => Promise }) { + _framework = framework; } function addDevServerCommand(program: Command) { const command = program.command('dev-server'); command.description('start dev server'); - command.option('-c, --config ', `Configuration file.`); + command.option('-c, --config ', `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 ', `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); diff --git a/packages/playwright-ct-core/src/vitePlugin.ts b/packages/playwright-ct-core/src/vitePlugin.ts index 9e2f96bfa7..3ac7286240 100644 --- a/packages/playwright-ct-core/src/vitePlugin.ts +++ b/packages/playwright-ct-core/src/vitePlugin.ts @@ -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(); - // 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(); - 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(); - // 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 +}): Promise<{ buildInfo: BuildInfo, viteConfig: Record } | 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(); + // 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(); + 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(); + // 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 { for (const [source, sourceInfo] of Object.entries(buildInfo.sources)) { try { diff --git a/packages/playwright-ct-core/src/viteUtils.ts b/packages/playwright-ct-core/src/viteUtils.ts index 25e6060e65..e6d6c50e4b 100644 --- a/packages/playwright-ct-core/src/viteUtils.ts +++ b/packages/playwright-ct-core/src/viteUtils.ts @@ -39,13 +39,15 @@ export type ComponentDirs = { templateDir: string; }; -export async function resolveDirs(configDir: string, config: FullConfig): Promise { +export async function resolveDirs(configDir: string, config: FullConfig): Promise { 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, diff --git a/packages/playwright-ct-react/cli.js b/packages/playwright-ct-react/cli.js index c3d7b6fb5f..b6f935eb52 100755 --- a/packages/playwright-ct-react/cli.js +++ b/packages/playwright-ct-react/cli.js @@ -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); diff --git a/packages/playwright-ct-react/index.js b/packages/playwright-ct-react/index.js index ca8a11da4d..a7dac3605b 100644 --- a/packages/playwright-ct-react/index.js +++ b/packages/playwright-ct-react/index.js @@ -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 } }; diff --git a/packages/playwright-ct-react17/cli.js b/packages/playwright-ct-react17/cli.js index db414c5904..b6f935eb52 100755 --- a/packages/playwright-ct-react17/cli.js +++ b/packages/playwright-ct-react17/cli.js @@ -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); diff --git a/packages/playwright-ct-react17/index.js b/packages/playwright-ct-react17/index.js index ca8a11da4d..a7dac3605b 100644 --- a/packages/playwright-ct-react17/index.js +++ b/packages/playwright-ct-react17/index.js @@ -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 } }; diff --git a/packages/playwright-ct-solid/cli.js b/packages/playwright-ct-solid/cli.js index db414c5904..b6f935eb52 100755 --- a/packages/playwright-ct-solid/cli.js +++ b/packages/playwright-ct-solid/cli.js @@ -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); diff --git a/packages/playwright-ct-solid/index.js b/packages/playwright-ct-solid/index.js index bd5e59e868..faff525f19 100644 --- a/packages/playwright-ct-solid/index.js +++ b/packages/playwright-ct-solid/index.js @@ -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 } }; diff --git a/packages/playwright-ct-svelte/cli.js b/packages/playwright-ct-svelte/cli.js index db414c5904..b6f935eb52 100755 --- a/packages/playwright-ct-svelte/cli.js +++ b/packages/playwright-ct-svelte/cli.js @@ -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); diff --git a/packages/playwright-ct-svelte/index.js b/packages/playwright-ct-svelte/index.js index 5d624ac9c4..dfaa0ab87e 100644 --- a/packages/playwright-ct-svelte/index.js +++ b/packages/playwright-ct-svelte/index.js @@ -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 } }; diff --git a/packages/playwright-ct-vue/cli.js b/packages/playwright-ct-vue/cli.js index db414c5904..b6f935eb52 100755 --- a/packages/playwright-ct-vue/cli.js +++ b/packages/playwright-ct-vue/cli.js @@ -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); diff --git a/packages/playwright-ct-vue/index.js b/packages/playwright-ct-vue/index.js index 2f557fcb8d..263a667480 100644 --- a/packages/playwright-ct-vue/index.js +++ b/packages/playwright-ct-vue/index.js @@ -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 } }; diff --git a/packages/playwright-ct-vue2/cli.js b/packages/playwright-ct-vue2/cli.js index db414c5904..b6f935eb52 100755 --- a/packages/playwright-ct-vue2/cli.js +++ b/packages/playwright-ct-vue2/cli.js @@ -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); diff --git a/packages/playwright-ct-vue2/index.js b/packages/playwright-ct-vue2/index.js index a942ad4b38..450f656ff6 100644 --- a/packages/playwright-ct-vue2/index.js +++ b/packages/playwright-ct-vue2/index.js @@ -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 } }; diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 7fbbed257a..83e90f5820 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -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 { + 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; +} diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 8b167910b7..c02f0f8095 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -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; +export function setClearCacheCommandOverride(body: (opts: any) => Promise) { + clearCacheCommandOverride = body; +} + +function addClearCacheCommand(program: Command) { + const command = program.command('clear-cache'); + command.description('clears build and test caches'); + command.option('-c, --config ', `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; +export function setFindRelatedTestsCommandOverride(body: (files: string[], opts: any) => Promise) { + 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 ', `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) { // 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); diff --git a/packages/playwright/src/runner/runner.ts b/packages/playwright/src/runner/runner.ts index bd83616f0c..2f265e5ebe 100644 --- a/packages/playwright/src/runner/runner.ts +++ b/packages/playwright/src/runner/runner.ts @@ -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 { + 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 { diff --git a/packages/playwright/src/transform/compilationCache.ts b/packages/playwright/src/transform/compilationCache.ts index f840e13080..0730630249 100644 --- a/packages/playwright/src/transform/compilationCache.ts +++ b/packages/playwright/src/transform/compilationCache.ts @@ -200,7 +200,8 @@ export function fileDependenciesForTest() { } export function collectAffectedTestFiles(dependency: string, testFileCollector: Set) { - 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(); + for (const change of changes) + collectAffectedTestFiles(change, result); + return [...result]; +} + export function internalDependenciesForTestFile(filename: string): Set | undefined{ return fileDependencies.get(filename); } diff --git a/tests/config/commonFixtures.ts b/tests/config/commonFixtures.ts index 35621ab426..57f1d76373 100644 --- a/tests/config/commonFixtures.ts +++ b/tests/config/commonFixtures.ts @@ -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); diff --git a/tests/playwright-test/find-related-tests.spec.ts b/tests/playwright-test/find-related-tests.spec.ts new file mode 100644 index 0000000000..c70bdcf04f --- /dev/null +++ b/tests/playwright-test/find-related-tests.spec.ts @@ -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': ``, + 'playwright/index.js': ``, + 'helper.tsx': ` + export const HelperButton = () => ; + `, + 'button.tsx': ` + import { HelperButton } from './helper'; + export const Button = () => Click me; + `, + 'button.spec.tsx': ` + import { test } from '@playwright/experimental-ct-react'; + import { Button } from './button'; + test('foo', async ({ mount }) => { + await mount(