From f7fb1e4d4e8b2fd80fa047a7d89240d580bfd1d8 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 25 Jan 2024 08:36:13 -0800 Subject: [PATCH] chore(ct): use sticky test server if available (#29136) --- package-lock.json | 18 ++-- packages/playwright-core/src/utils/network.ts | 31 +++++++ packages/playwright-ct-core/src/DEPS.list | 3 + packages/playwright-ct-core/src/devServer.ts | 92 +++++++++++++++++++ packages/playwright-ct-core/src/program.ts | 23 +++++ .../playwright-ct-core/src/viteDevPlugin.ts | 64 ------------- packages/playwright-ct-core/src/vitePlugin.ts | 17 +++- packages/playwright-ct-core/src/viteUtils.ts | 24 ++--- packages/playwright-ct-react/cli.js | 5 +- packages/playwright-ct-react/package.json | 3 +- packages/playwright-ct-react17/package.json | 3 +- packages/playwright-ct-solid/package.json | 3 +- packages/playwright-ct-svelte/package.json | 3 +- packages/playwright-ct-vue/package.json | 3 +- packages/playwright-ct-vue2/package.json | 3 +- packages/playwright/package.json | 3 + packages/playwright/src/fsWatcher.ts | 71 ++++++++++++++ .../playwright/src/plugins/webServerPlugin.ts | 35 +------ packages/playwright/src/program.ts | 2 +- packages/playwright/src/runner/DEPS.list | 1 + packages/playwright/src/runner/runner.ts | 18 ++++ packages/playwright/src/runner/uiMode.ts | 56 +---------- 22 files changed, 302 insertions(+), 179 deletions(-) create mode 100644 packages/playwright-ct-core/src/devServer.ts delete mode 100644 packages/playwright-ct-core/src/viteDevPlugin.ts create mode 100644 packages/playwright/src/fsWatcher.ts diff --git a/package-lock.json b/package-lock.json index b4de536501..42c4c1562c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8299,7 +8299,8 @@ "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-react": "cli.js" }, "engines": { "node": ">=16" @@ -8314,7 +8315,8 @@ "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-react17": "cli.js" }, "engines": { "node": ">=16" @@ -8329,7 +8331,8 @@ "vite-plugin-solid": "^2.7.0" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-solid": "cli.js" }, "devDependencies": { "solid-js": "^1.7.0" @@ -8347,7 +8350,8 @@ "@sveltejs/vite-plugin-svelte": "^3.0.1" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-svelte": "cli.js" }, "devDependencies": { "svelte": "^4.2.8" @@ -8365,7 +8369,8 @@ "@vitejs/plugin-vue": "^4.2.1" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-vue": "cli.js" }, "engines": { "node": ">=16" @@ -8380,7 +8385,8 @@ "@vitejs/plugin-vue2": "^2.2.0" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-vue2": "cli.js" }, "devDependencies": { "vue": "^2.7.14" diff --git a/packages/playwright-core/src/utils/network.ts b/packages/playwright-core/src/utils/network.ts index deb12d2c59..8cacb8aeb6 100644 --- a/packages/playwright-core/src/utils/network.ts +++ b/packages/playwright-core/src/utils/network.ts @@ -169,6 +169,37 @@ export function createHttpsServer(...args: any[]): https.Server { return server; } +export async function isURLAvailable(url: URL, ignoreHTTPSErrors: boolean, onLog?: (data: string) => void, onStdErr?: (data: string) => void) { + let statusCode = await httpStatusCode(url, ignoreHTTPSErrors, onLog, onStdErr); + if (statusCode === 404 && url.pathname === '/') { + const indexUrl = new URL(url); + indexUrl.pathname = '/index.html'; + statusCode = await httpStatusCode(indexUrl, ignoreHTTPSErrors, onLog, onStdErr); + } + return statusCode >= 200 && statusCode < 404; +} + +async function httpStatusCode(url: URL, ignoreHTTPSErrors: boolean, onLog?: (data: string) => void, onStdErr?: (data: string) => void): Promise { + return new Promise(resolve => { + onLog?.(`HTTP GET: ${url}`); + httpRequest({ + url: url.toString(), + headers: { Accept: '*/*' }, + rejectUnauthorized: !ignoreHTTPSErrors + }, res => { + res.resume(); + const statusCode = res.statusCode ?? 0; + onLog?.(`HTTP Status: ${statusCode}`); + resolve(statusCode); + }, error => { + if ((error as NodeJS.ErrnoException).code === 'DEPTH_ZERO_SELF_SIGNED_CERT') + onStdErr?.(`[WebServer] Self-signed certificate detected. Try adding ignoreHTTPSErrors: true to config.webServer.`); + onLog?.(`Error while checking if ${url} is available: ${error.message}`); + resolve(0); + }); + }); +} + function decorateServer(server: http.Server | http.Server) { const sockets = new Set(); server.on('connection', socket => { diff --git a/packages/playwright-ct-core/src/DEPS.list b/packages/playwright-ct-core/src/DEPS.list index ef95f39088..76c2d71bd6 100644 --- a/packages/playwright-ct-core/src/DEPS.list +++ b/packages/playwright-ct-core/src/DEPS.list @@ -4,6 +4,9 @@ generated/indexSource.ts [viteDevPlugin.ts] generated/indexSource.ts +[devServer.ts] +generated/indexSource.ts + [mount.ts] generated/serializers.ts injected/** diff --git a/packages/playwright-ct-core/src/devServer.ts b/packages/playwright-ct-core/src/devServer.ts new file mode 100644 index 0000000000..8362502cc9 --- /dev/null +++ b/packages/playwright-ct-core/src/devServer.ts @@ -0,0 +1,92 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * 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 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 { 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 runDevServer(configFile: string, registerSourceFile: string, frameworkPluginFactory: () => Promise) { + const configFileOrDirectory = configFile ? path.resolve(process.cwd(), configFile) : process.cwd(); + const resolvedConfigFile = resolveConfigFile(configFileOrDirectory); + if (restartWithExperimentalTsEsm(resolvedConfigFile)) + return; + + const configLoader = new ConfigLoader(); + let config: FullConfigInternal; + if (resolvedConfigFile) + config = await configLoader.loadConfigFile(resolvedConfigFile); + else + config = await configLoader.loadEmptyConfig(configFileOrDirectory); + + const runner = new Runner(config); + await runner.loadAllTests(true); + const componentRegistry: ComponentRegistry = new Map(); + await populateComponentsFromTests(componentRegistry); + + const dirs = resolveDirs(config.configDir, config.config); + const registerSource = injectedSource + '\n' + await fs.promises.readFile(registerSourceFile, 'utf-8'); + const viteConfig = await createConfig(dirs, config.config, frameworkPluginFactory, false); + viteConfig.plugins.push({ + name: 'playwright:component-index', + + async transform(this: PluginContext, content: string, id: string) { + return transformIndexFile(id, content, dirs.templateDir, registerSource, componentRegistry); + }, + }); + + const { createServer } = await import('vite'); + const devServer = await createServer(viteConfig); + await devServer.listen(); + const protocol = viteConfig.server.https ? 'https:' : 'http:'; + // eslint-disable-next-line no-console + console.log(`Test Server listening on ${protocol}//${viteConfig.server.host || 'localhost'}:${viteConfig.server.port}`); + + const projectDirs = new Set(); + const projectOutputs = new Set(); + for (const p of config.projects) { + projectDirs.add(p.project.testDir); + projectOutputs.add(p.project.outputDir); + } + + const globalWatcher = new Watcher('deep', async () => { + const registry: ComponentRegistry = new Map(); + await populateComponentsFromTests(registry); + // compare componentRegistry to registry key sets. + if (componentRegistry.size === registry.size && [...componentRegistry.keys()].every(k => registry.has(k))) + return; + + // eslint-disable-next-line no-console + console.log('List of components changed'); + componentRegistry.clear(); + for (const [k, v] of registry) + componentRegistry.set(k, v); + + const id = path.join(dirs.templateDir, 'index'); + const modules = [...devServer.moduleGraph.urlToModuleMap.values()]; + const rootModule = modules.find(m => m.file?.startsWith(id + '.ts') || m.file?.startsWith(id + '.js')); + if (rootModule) + devServer.moduleGraph.onFileChange(rootModule.file!); + }); + globalWatcher.update([...projectDirs], [...projectOutputs], false); +} diff --git a/packages/playwright-ct-core/src/program.ts b/packages/playwright-ct-core/src/program.ts index 3014578d69..fa4eeb4ccd 100644 --- a/packages/playwright-ct-core/src/program.ts +++ b/packages/playwright-ct-core/src/program.ts @@ -14,4 +14,27 @@ * limitations under the License. */ +import type { Command } from 'playwright-core/lib/utilsBundle'; + +import { program } from 'playwright/lib/program'; +import { runDevServer } from './devServer'; export { program } from 'playwright/lib/program'; + +let registerSourceFile: string; +let frameworkPluginFactory: () => Promise; + +export function initializePlugin(registerSource: string, factory: () => Promise) { + registerSourceFile = registerSource; + frameworkPluginFactory = factory; +} + +function addDevServerCommand(program: Command) { + const command = program.command('dev-server'); + command.description('start dev server'); + command.option('-c, --config ', `Configuration file. Can be used to specify additional configuration for the output report.`); + command.action(options => { + runDevServer(options.config, registerSourceFile, frameworkPluginFactory); + }); +} + +addDevServerCommand(program); diff --git a/packages/playwright-ct-core/src/viteDevPlugin.ts b/packages/playwright-ct-core/src/viteDevPlugin.ts deleted file mode 100644 index 6a3be397f9..0000000000 --- a/packages/playwright-ct-core/src/viteDevPlugin.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * 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 fs from 'fs'; -import type { FullConfig } from 'playwright/test'; -import type { PluginContext } from 'rollup'; -import type { Plugin } from 'vite'; -import type { TestRunnerPlugin } from '../../playwright/src/plugins'; -import { source as injectedSource } from './generated/indexSource'; -import type { ImportInfo } from './tsxTransform'; -import type { ComponentRegistry } from './viteUtils'; -import { createConfig, hasJSComponents, populateComponentsFromTests, resolveDirs, transformIndexFile } from './viteUtils'; - -export function createPlugin( - registerSourceFile: string, - frameworkPluginFactory?: () => Promise): TestRunnerPlugin { - let configDir: string; - let config: FullConfig; - return { - name: 'playwright-vite-plugin', - - setup: async (configObject: FullConfig, configDirectory: string) => { - config = configObject; - configDir = configDirectory; - }, - - begin: async () => { - const registerSource = injectedSource + '\n' + await fs.promises.readFile(registerSourceFile, 'utf-8'); - const componentRegistry: ComponentRegistry = new Map(); - await populateComponentsFromTests(componentRegistry); - const dirs = resolveDirs(configDir, config); - const viteConfig = await createConfig(dirs, config, frameworkPluginFactory, hasJSComponents([...componentRegistry.values()])); - viteConfig.plugins.push(vitePlugin(registerSource, dirs.templateDir, componentRegistry)); - const { createServer } = await import('vite'); - const devServer = await createServer(viteConfig); - await devServer.listen(); - const protocol = viteConfig.server.https ? 'https:' : 'http:'; - process.env.PLAYWRIGHT_TEST_BASE_URL = `${protocol}//${viteConfig.server.host || 'localhost'}:${viteConfig.server.port}`; - }, - }; -} - -function vitePlugin(registerSource: string, templateDir: string, importInfos: Map): Plugin { - return { - name: 'playwright:component-index', - - async transform(this: PluginContext, content, id) { - return transformIndexFile(id, content, templateDir, registerSource, importInfos); - }, - }; -} diff --git a/packages/playwright-ct-core/src/vitePlugin.ts b/packages/playwright-ct-core/src/vitePlugin.ts index 4ad7839767..734fd70459 100644 --- a/packages/playwright-ct-core/src/vitePlugin.ts +++ b/packages/playwright-ct-core/src/vitePlugin.ts @@ -18,7 +18,7 @@ import fs from 'fs'; import type http from 'http'; import type { AddressInfo } from 'net'; import path from 'path'; -import { assert, calculateSha1, getPlaywrightVersion } from 'playwright-core/lib/utils'; +import { assert, calculateSha1, getPlaywrightVersion, isURLAvailable } from 'playwright-core/lib/utils'; import { debug } from 'playwright-core/lib/utilsBundle'; import { internalDependenciesForTestFile, setExternalDependencies } from 'playwright/lib/transform/compilationCache'; import { stoppable } from 'playwright/lib/utilsBundle'; @@ -30,7 +30,7 @@ import type { TestRunnerPlugin } from '../../playwright/src/plugins'; import { source as injectedSource } from './generated/indexSource'; import type { ImportInfo } from './tsxTransform'; import type { ComponentRegistry } from './viteUtils'; -import { createConfig, hasJSComponents, populateComponentsFromTests, resolveDirs, transformIndexFile } from './viteUtils'; +import { createConfig, hasJSComponents, populateComponentsFromTests, resolveDirs, resolveEndpoint, transformIndexFile } from './viteUtils'; const log = debug('pw:vite'); @@ -51,6 +51,19 @@ 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 (process.env.PW_CT_DEV && 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 = resolveDirs(configDir, config); const buildInfoFile = path.join(dirs.outDir, 'metainfo.json'); diff --git a/packages/playwright-ct-core/src/viteUtils.ts b/packages/playwright-ct-core/src/viteUtils.ts index dda59e27d7..266487f94d 100644 --- a/packages/playwright-ct-core/src/viteUtils.ts +++ b/packages/playwright-ct-core/src/viteUtils.ts @@ -53,14 +53,24 @@ export function resolveDirs(configDir: string, config: FullConfig): ComponentDir }; } +export function resolveEndpoint(config: FullConfig) { + const use = config.projects[0].use as CtConfig; + const baseURL = new URL(use.baseURL || 'http://localhost'); + return { + https: baseURL.protocol.startsWith('https:') ? {} : undefined, + host: baseURL.hostname, + port: use.ctPort || Number(baseURL.port) || 3100 + }; +} + export async function createConfig(dirs: ComponentDirs, config: FullConfig, frameworkPluginFactory: (() => Promise) | undefined, supportJsxInJs: boolean) { // We are going to have 3 config files: // - the defaults that user config overrides (baseConfig) // - the user config (userConfig) // - frameworks overrides (frameworkOverrides); + const endpoint = resolveEndpoint(config); const use = config.projects[0].use as CtConfig; - const baseURL = new URL(use.baseURL || 'http://localhost'); // Compose base config from the playwright config only. const baseConfig: InlineConfig = { @@ -76,16 +86,8 @@ export async function createConfig(dirs: ComponentDirs, config: FullConfig, fram build: { outDir: dirs.outDir }, - preview: { - https: baseURL.protocol.startsWith('https:') ? {} : undefined, - host: baseURL.hostname, - port: use.ctPort || Number(baseURL.port) || 3100 - }, - server: { - https: baseURL.protocol.startsWith('https:') ? {} : undefined, - host: baseURL.hostname, - port: use.ctPort || Number(baseURL.port) || 3100 - }, + preview: endpoint, + server: endpoint, // Vite preview server will otherwise always return the index.html with 200. appType: 'mpa', }; diff --git a/packages/playwright-ct-react/cli.js b/packages/playwright-ct-react/cli.js index db414c5904..c3d7b6fb5f 100755 --- a/packages/playwright-ct-react/cli.js +++ b/packages/playwright-ct-react/cli.js @@ -15,5 +15,8 @@ * limitations under the License. */ -const { program } = require('@playwright/experimental-ct-core/lib/program'); +const path = require('path'); +const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); + +initializePlugin(path.join(__dirname, 'registerSource.mjs'), () => import('@vitejs/plugin-react').then(plugin => plugin.default())) program.parse(process.argv); diff --git a/packages/playwright-ct-react/package.json b/packages/playwright-ct-react/package.json index 676456d80d..5702342623 100644 --- a/packages/playwright-ct-react/package.json +++ b/packages/playwright-ct-react/package.json @@ -33,6 +33,7 @@ "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-react": "cli.js" } } diff --git a/packages/playwright-ct-react17/package.json b/packages/playwright-ct-react17/package.json index af7aef81ba..4e926cca74 100644 --- a/packages/playwright-ct-react17/package.json +++ b/packages/playwright-ct-react17/package.json @@ -33,6 +33,7 @@ "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-react17": "cli.js" } } diff --git a/packages/playwright-ct-solid/package.json b/packages/playwright-ct-solid/package.json index b58f72e168..ac65ccb548 100644 --- a/packages/playwright-ct-solid/package.json +++ b/packages/playwright-ct-solid/package.json @@ -36,6 +36,7 @@ "solid-js": "^1.7.0" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-solid": "cli.js" } } diff --git a/packages/playwright-ct-svelte/package.json b/packages/playwright-ct-svelte/package.json index 99d4e89b73..f0809a3d4a 100644 --- a/packages/playwright-ct-svelte/package.json +++ b/packages/playwright-ct-svelte/package.json @@ -36,6 +36,7 @@ "svelte": "^4.2.8" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-svelte": "cli.js" } } diff --git a/packages/playwright-ct-vue/package.json b/packages/playwright-ct-vue/package.json index dd5be1e340..7ab9b652cb 100644 --- a/packages/playwright-ct-vue/package.json +++ b/packages/playwright-ct-vue/package.json @@ -33,6 +33,7 @@ "@vitejs/plugin-vue": "^4.2.1" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-vue": "cli.js" } } diff --git a/packages/playwright-ct-vue2/package.json b/packages/playwright-ct-vue2/package.json index 44ef3a75d7..0ba67d6bd6 100644 --- a/packages/playwright-ct-vue2/package.json +++ b/packages/playwright-ct-vue2/package.json @@ -36,6 +36,7 @@ "vue": "^2.7.14" }, "bin": { - "playwright": "cli.js" + "playwright": "cli.js", + "pw-vue2": "cli.js" } } diff --git a/packages/playwright/package.json b/packages/playwright/package.json index d48364ec90..02e509b036 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -19,9 +19,12 @@ "default": "./index.js" }, "./package.json": "./package.json", + "./lib/common/configLoader": "./lib/common/configLoader.js", + "./lib/fsWatcher": "./lib/fsWatcher.js", "./lib/program": "./lib/program.js", "./lib/transform/babelBundle": "./lib/transform/babelBundle.js", "./lib/transform/compilationCache": "./lib/transform/compilationCache.js", + "./lib/runner/runner": "./lib/runner/runner.js", "./lib/transform/esmLoader": "./lib/transform/esmLoader.js", "./lib/transform/transform": "./lib/transform/transform.js", "./lib/internalsForTest": "./lib/internalsForTest.js", diff --git a/packages/playwright/src/fsWatcher.ts b/packages/playwright/src/fsWatcher.ts new file mode 100644 index 0000000000..a4627beb88 --- /dev/null +++ b/packages/playwright/src/fsWatcher.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * 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 { chokidar } from './utilsBundle'; +import type { FSWatcher } from 'chokidar'; + +export type FSEvent = { event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', file: string }; + +export class Watcher { + private _onChange: (events: FSEvent[]) => void; + private _watchedFiles: string[] = []; + private _ignoredFolders: string[] = []; + private _collector: FSEvent[] = []; + private _fsWatcher: FSWatcher | undefined; + private _throttleTimer: NodeJS.Timeout | undefined; + private _mode: 'flat' | 'deep'; + + constructor(mode: 'flat' | 'deep', onChange: (events: FSEvent[]) => void) { + this._mode = mode; + this._onChange = onChange; + } + + update(watchedFiles: string[], ignoredFolders: string[], reportPending: boolean) { + if (JSON.stringify([this._watchedFiles, this._ignoredFolders]) === JSON.stringify(watchedFiles, ignoredFolders)) + return; + + if (reportPending) + this._reportEventsIfAny(); + + this._watchedFiles = watchedFiles; + this._ignoredFolders = ignoredFolders; + void this._fsWatcher?.close(); + this._fsWatcher = undefined; + this._collector.length = 0; + clearTimeout(this._throttleTimer); + this._throttleTimer = undefined; + + if (!this._watchedFiles.length) + return; + + this._fsWatcher = chokidar.watch(watchedFiles, { ignoreInitial: true, ignored: this._ignoredFolders }).on('all', async (event, file) => { + if (this._throttleTimer) + clearTimeout(this._throttleTimer); + if (this._mode === 'flat' && event !== 'add' && event !== 'change') + return; + if (this._mode === 'deep' && event !== 'add' && event !== 'change' && event !== 'unlink' && event !== 'addDir' && event !== 'unlinkDir') + return; + this._collector.push({ event, file }); + this._throttleTimer = setTimeout(() => this._reportEventsIfAny(), 250); + }); + } + + private _reportEventsIfAny() { + if (this._collector.length) + this._onChange(this._collector.slice()); + this._collector.length = 0; + } +} diff --git a/packages/playwright/src/plugins/webServerPlugin.ts b/packages/playwright/src/plugins/webServerPlugin.ts index 8fb863ba05..86f84ad652 100644 --- a/packages/playwright/src/plugins/webServerPlugin.ts +++ b/packages/playwright/src/plugins/webServerPlugin.ts @@ -17,7 +17,7 @@ import path from 'path'; import net from 'net'; import { colors, debug } from 'playwright-core/lib/utilsBundle'; -import { raceAgainstDeadline, launchProcess, httpRequest, monotonicTime } from 'playwright-core/lib/utils'; +import { raceAgainstDeadline, launchProcess, monotonicTime, isURLAvailable } from 'playwright-core/lib/utils'; import type { FullConfig } from '../../types/testReporter'; import type { TestRunnerPlugin } from '.'; @@ -155,37 +155,6 @@ async function isPortUsed(port: number): Promise { return await innerIsPortUsed('127.0.0.1') || await innerIsPortUsed('::1'); } -async function isURLAvailable(url: URL, ignoreHTTPSErrors: boolean, onStdErr: ReporterV2['onStdErr']) { - let statusCode = await httpStatusCode(url, ignoreHTTPSErrors, onStdErr); - if (statusCode === 404 && url.pathname === '/') { - const indexUrl = new URL(url); - indexUrl.pathname = '/index.html'; - statusCode = await httpStatusCode(indexUrl, ignoreHTTPSErrors, onStdErr); - } - return statusCode >= 200 && statusCode < 404; -} - -async function httpStatusCode(url: URL, ignoreHTTPSErrors: boolean, onStdErr: ReporterV2['onStdErr']): Promise { - return new Promise(resolve => { - debugWebServer(`HTTP GET: ${url}`); - httpRequest({ - url: url.toString(), - headers: { Accept: '*/*' }, - rejectUnauthorized: !ignoreHTTPSErrors - }, res => { - res.resume(); - const statusCode = res.statusCode ?? 0; - debugWebServer(`HTTP Status: ${statusCode}`); - resolve(statusCode); - }, error => { - if ((error as NodeJS.ErrnoException).code === 'DEPTH_ZERO_SELF_SIGNED_CERT') - onStdErr?.(`[WebServer] Self-signed certificate detected. Try adding ignoreHTTPSErrors: true to config.webServer.`); - debugWebServer(`Error while checking if ${url} is available: ${error.message}`); - resolve(0); - }); - }); -} - async function waitFor(waitFn: () => Promise, cancellationToken: { canceled: boolean }) { const logScale = [100, 250, 500]; while (!cancellationToken.canceled) { @@ -201,7 +170,7 @@ async function waitFor(waitFn: () => Promise, cancellationToken: { canc function getIsAvailableFunction(url: string, checkPortOnly: boolean, ignoreHTTPSErrors: boolean, onStdErr: ReporterV2['onStdErr']) { const urlObject = new URL(url); if (!checkPortOnly) - return () => isURLAvailable(urlObject, ignoreHTTPSErrors, onStdErr); + return () => isURLAvailable(urlObject, ignoreHTTPSErrors, debugWebServer, onStdErr); const port = urlObject.port; return () => isPortUsed(+port); } diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 98bf8f1d12..ef6ad1c8d0 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -271,7 +271,7 @@ function resolveReporter(id: string) { return require.resolve(id, { paths: [process.cwd()] }); } -function restartWithExperimentalTsEsm(configFile: string | null): boolean { +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) diff --git a/packages/playwright/src/runner/DEPS.list b/packages/playwright/src/runner/DEPS.list index eb8f6309e7..828f2307e9 100644 --- a/packages/playwright/src/runner/DEPS.list +++ b/packages/playwright/src/runner/DEPS.list @@ -8,3 +8,4 @@ ../util.ts ../utilsBundle.ts ../isomorphic/folders.ts +../fsWatcher.ts diff --git a/packages/playwright/src/runner/runner.ts b/packages/playwright/src/runner/runner.ts index 3592afb701..bd83616f0c 100644 --- a/packages/playwright/src/runner/runner.ts +++ b/packages/playwright/src/runner/runner.ts @@ -104,6 +104,24 @@ export class Runner { return status; } + async loadAllTests(outOfProcess?: boolean): Promise { + const config = this._config; + const reporter = new InternalReporter(new Multiplexer([])); + const taskRunner = createTaskRunnerForList(config, reporter, outOfProcess ? 'out-of-process' : 'in-process', { failOnLoadErrors: true }); + const testRun = new TestRun(config, reporter); + reporter.onConfigure(config.config); + + const taskStatus = await taskRunner.run(testRun, 0); + let status: FullResult['status'] = testRun.failureTracker.result(); + if (status === 'passed' && taskStatus !== 'passed') + status = taskStatus; + const modifiedResult = await reporter.onEnd({ status }); + if (modifiedResult && modifiedResult.status) + status = modifiedResult.status; + await reporter.onExit(); + return status; + } + async watchAllTests(): Promise { const config = this._config; webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); diff --git a/packages/playwright/src/runner/uiMode.ts b/packages/playwright/src/runner/uiMode.ts index 922aeb2968..3846aaad78 100644 --- a/packages/playwright/src/runner/uiMode.ts +++ b/packages/playwright/src/runner/uiMode.ts @@ -23,13 +23,12 @@ import { InternalReporter } from '../reporters/internalReporter'; import { TeleReporterEmitter } from '../reporters/teleEmitter'; import { createReporters } from './reporters'; import { TestRun, createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks'; -import { chokidar } from '../utilsBundle'; -import type { FSWatcher } from 'chokidar'; import { open } from 'playwright-core/lib/utilsBundle'; import ListReporter from '../reporters/list'; import type { OpenTraceViewerOptions, Transport } from 'playwright-core/lib/server/trace/viewer/traceViewer'; import { Multiplexer } from '../reporters/multiplexer'; import { SigIntWatcher } from './sigIntWatcher'; +import { Watcher } from '../fsWatcher'; class UIMode { private _config: FullConfigInternal; @@ -258,59 +257,6 @@ function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string): Stdi return { type, text: chunk }; } -type FSEvent = { event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', file: string }; - -class Watcher { - private _onChange: (events: FSEvent[]) => void; - private _watchedFiles: string[] = []; - private _ignoredFolders: string[] = []; - private _collector: FSEvent[] = []; - private _fsWatcher: FSWatcher | undefined; - private _throttleTimer: NodeJS.Timeout | undefined; - private _mode: 'flat' | 'deep'; - - constructor(mode: 'flat' | 'deep', onChange: (events: FSEvent[]) => void) { - this._mode = mode; - this._onChange = onChange; - } - - update(watchedFiles: string[], ignoredFolders: string[], reportPending: boolean) { - if (JSON.stringify([this._watchedFiles, this._ignoredFolders]) === JSON.stringify(watchedFiles, ignoredFolders)) - return; - - if (reportPending) - this._reportEventsIfAny(); - - this._watchedFiles = watchedFiles; - this._ignoredFolders = ignoredFolders; - void this._fsWatcher?.close(); - this._fsWatcher = undefined; - this._collector.length = 0; - clearTimeout(this._throttleTimer); - this._throttleTimer = undefined; - - if (!this._watchedFiles.length) - return; - - this._fsWatcher = chokidar.watch(watchedFiles, { ignoreInitial: true, ignored: this._ignoredFolders }).on('all', async (event, file) => { - if (this._throttleTimer) - clearTimeout(this._throttleTimer); - if (this._mode === 'flat' && event !== 'add' && event !== 'change') - return; - if (this._mode === 'deep' && event !== 'add' && event !== 'change' && event !== 'unlink' && event !== 'addDir' && event !== 'unlinkDir') - return; - this._collector.push({ event, file }); - this._throttleTimer = setTimeout(() => this._reportEventsIfAny(), 250); - }); - } - - private _reportEventsIfAny() { - if (this._collector.length) - this._onChange(this._collector.slice()); - this._collector.length = 0; - } -} - function hasSomeBrowsers(): boolean { for (const browserName of ['chromium', 'webkit', 'firefox']) { try {