From 6faadf5160884833fe681316a64520e84ddc512f Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 18 Mar 2024 09:50:11 -0700 Subject: [PATCH] chore: prepare to reuse test server from ui mode (#29965) --- packages/playwright-core/src/cli/program.ts | 19 +- packages/playwright-core/src/server/index.ts | 2 +- .../src/server/trace/viewer/traceViewer.ts | 92 +++---- .../playwright-core/src/utils/httpServer.ts | 32 ++- packages/playwright/src/program.ts | 22 +- packages/playwright/src/runner/runner.ts | 32 ++- packages/playwright/src/runner/tasks.ts | 8 +- packages/playwright/src/runner/testServer.ts | 226 ------------------ packages/playwright/src/runner/uiMode.ts | 56 ++--- tests/config/traceViewerFixtures.ts | 4 +- 10 files changed, 165 insertions(+), 328 deletions(-) delete mode 100644 packages/playwright/src/runner/testServer.ts diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index c38683a832..ad943c049e 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -23,8 +23,8 @@ import type { Command } from '../utilsBundle'; import { program } from '../utilsBundle'; export { program } from '../utilsBundle'; import { runDriver, runServer, printApiJson, launchBrowserServer } from './driver'; -import type { OpenTraceViewerOptions } from '../server/trace/viewer/traceViewer'; -import { openTraceInBrowser, openTraceViewerApp } from '../server/trace/viewer/traceViewer'; +import { runTraceInBrowser, runTraceViewerApp } from '../server/trace/viewer/traceViewer'; +import type { TraceViewerServerOptions } from '../server/trace/viewer/traceViewer'; import * as playwright from '../..'; import type { BrowserContext } from '../client/browserContext'; import type { Browser } from '../client/browser'; @@ -305,19 +305,16 @@ program if (options.browser === 'wk') options.browser = 'webkit'; - const openOptions: OpenTraceViewerOptions = { - headless: false, + const openOptions: TraceViewerServerOptions = { host: options.host, port: +options.port, isServer: !!options.stdin, }; - if (options.port !== undefined || options.host !== undefined) { - openTraceInBrowser(traces, openOptions).catch(logErrorAndExit); - } else { - openTraceViewerApp(traces, options.browser, openOptions).then(page => { - page.on('close', () => gracefullyProcessExitDoNotHang(0)); - }).catch(logErrorAndExit); - } + + if (options.port !== undefined || options.host !== undefined) + runTraceInBrowser(traces, openOptions).catch(logErrorAndExit); + else + runTraceViewerApp(traces, options.browser, openOptions, true).catch(logErrorAndExit); }).addHelpText('afterAll', ` Examples: diff --git a/packages/playwright-core/src/server/index.ts b/packages/playwright-core/src/server/index.ts index 9d619c205a..439febfe18 100644 --- a/packages/playwright-core/src/server/index.ts +++ b/packages/playwright-core/src/server/index.ts @@ -29,6 +29,6 @@ export { createPlaywright } from './playwright'; export type { DispatcherScope } from './dispatchers/dispatcher'; export type { Playwright } from './playwright'; -export { openTraceInBrowser, openTraceViewerApp } from './trace/viewer/traceViewer'; +export { openTraceInBrowser, openTraceViewerApp, runTraceViewerApp, startTraceViewerServer, installRootRedirect } from './trace/viewer/traceViewer'; export { serverSideCallMetadata } from './instrumentation'; export { SocksProxy } from '../common/socksProxy'; diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index f328ec6f05..0b9ad35d5a 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -17,34 +17,35 @@ import path from 'path'; import fs from 'fs'; import { HttpServer } from '../../../utils/httpServer'; -import { createGuid, gracefullyProcessExitDoNotHang, isUnderTest } from '../../../utils'; +import type { Transport } from '../../../utils/httpServer'; +import { gracefullyProcessExitDoNotHang, isUnderTest } from '../../../utils'; import { syncLocalStorageWithSettings } from '../../launchApp'; import { serverSideCallMetadata } from '../../instrumentation'; import { createPlaywright } from '../../playwright'; import { ProgressController } from '../../progress'; -import { open, wsServer } from '../../../utilsBundle'; +import { open } from '../../../utilsBundle'; import type { Page } from '../../page'; import type { BrowserType } from '../../browserType'; import { launchApp } from '../../launchApp'; -export type Transport = { - sendEvent?: (method: string, params: any) => void; - dispatch: (method: string, params: any) => Promise; - close?: () => void; - onclose: () => void; -}; - -export type OpenTraceViewerOptions = { - app?: string; - headless?: boolean; +export type TraceViewerServerOptions = { host?: string; port?: number; isServer?: boolean; transport?: Transport; +}; + +export type TraceViewerRedirectOptions = { + webApp?: string; + isServer?: boolean; +}; + +export type TraceViewerAppOptions = { + headless?: boolean; persistentContextOptions?: Parameters[2]; }; -async function startTraceViewerServer(traceUrls: string[], options?: OpenTraceViewerOptions): Promise<{ server: HttpServer, url: string }> { +async function validateTraceUrls(traceUrls: string[]) { for (const traceUrl of traceUrls) { let traceFile = traceUrl; // If .json is requested, we'll synthesize it. @@ -54,10 +55,13 @@ async function startTraceViewerServer(traceUrls: string[], options?: OpenTraceVi if (!traceUrl.startsWith('http://') && !traceUrl.startsWith('https://') && !fs.existsSync(traceFile) && !fs.existsSync(traceFile + '.trace')) { // eslint-disable-next-line no-console console.error(`Trace file ${traceUrl} does not exist!`); - gracefullyProcessExitDoNotHang(1); + return false; } } + return true; +} +export async function startTraceViewerServer(options?: TraceViewerServerOptions): Promise { const server = new HttpServer(); server.routePrefix('/trace', (request, response) => { const url = new URL('http://localhost' + request.url!); @@ -88,36 +92,25 @@ async function startTraceViewerServer(traceUrls: string[], options?: OpenTraceVi return server.serveFile(request, response, absolutePath); }); - const params = traceUrls.map(t => `trace=${encodeURIComponent(t)}`); const transport = options?.transport || (options?.isServer ? new StdinServer() : undefined); + if (transport) + server.createWebSocket(transport); - if (transport) { - const guid = createGuid(); - params.push('ws=' + guid); - const wss = new wsServer({ server: server.server(), path: '/' + guid }); - wss.on('connection', ws => { - transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params })); - transport.close = () => ws.close(); - ws.on('message', async (message: string) => { - const { id, method, params } = JSON.parse(message); - const result = await transport.dispatch(method, params); - ws.send(JSON.stringify({ id, result })); - }); - ws.on('close', () => transport.onclose()); - ws.on('error', () => transport.onclose()); - }); - } + const { host, port } = options || {}; + await server.start({ preferredPort: port, host }); + return server; +} +export async function installRootRedirect(server: HttpServer, traceUrls: string[], options: TraceViewerRedirectOptions) { + const params = (traceUrls || []).map(t => `trace=${encodeURIComponent(t)}`); + if (server.wsGuid()) + params.push('ws=' + server.wsGuid()); if (options?.isServer) params.push('isServer'); if (isUnderTest()) params.push('isUnderTest=true'); - - const { host, port } = options || {}; - const url = await server.start({ preferredPort: port, host }); - const { app } = options || {}; const searchQuery = params.length ? '?' + params.join('&') : ''; - const urlPath = `/trace/${app || 'index.html'}${searchQuery}`; + const urlPath = `/trace/${options.webApp || 'index.html'}${searchQuery}`; server.routePath('/', (request, response) => { response.statusCode = 302; @@ -125,12 +118,28 @@ async function startTraceViewerServer(traceUrls: string[], options?: OpenTraceVi response.end(); return true; }); - - return { server, url }; } -export async function openTraceViewerApp(traceUrls: string[], browserName: string, options?: OpenTraceViewerOptions): Promise { - const { url } = await startTraceViewerServer(traceUrls, options); +export async function runTraceViewerApp(traceUrls: string[], browserName: string, options: TraceViewerServerOptions, exitOnClose?: boolean) { + if (!validateTraceUrls(traceUrls)) + return; + const server = await startTraceViewerServer(options); + await installRootRedirect(server, traceUrls, options); + const page = await openTraceViewerApp(server.urlPrefix(), browserName); + if (exitOnClose) + page.on('close', () => gracefullyProcessExitDoNotHang(0)); + return page; +} + +export async function runTraceInBrowser(traceUrls: string[], options: TraceViewerServerOptions) { + if (!validateTraceUrls(traceUrls)) + return; + const server = await startTraceViewerServer(options); + await installRootRedirect(server, traceUrls, options); + await openTraceInBrowser(server.urlPrefix()); +} + +export async function openTraceViewerApp(url: string, browserName: string, options?: TraceViewerAppOptions): Promise { const traceViewerPlaywright = createPlaywright({ sdkLanguage: 'javascript', isInternalPlaywright: true }); const traceViewerBrowser = isUnderTest() ? 'chromium' : browserName; @@ -163,8 +172,7 @@ export async function openTraceViewerApp(traceUrls: string[], browserName: strin return page; } -export async function openTraceInBrowser(traceUrls: string[], options?: OpenTraceViewerOptions) { - const { url } = await startTraceViewerServer(traceUrls, options); +export async function openTraceInBrowser(url: string) { // eslint-disable-next-line no-console console.log('\nListening on ' + url); if (!isUnderTest()) diff --git a/packages/playwright-core/src/utils/httpServer.ts b/packages/playwright-core/src/utils/httpServer.ts index 32901eb3d4..a420675096 100644 --- a/packages/playwright-core/src/utils/httpServer.ts +++ b/packages/playwright-core/src/utils/httpServer.ts @@ -17,19 +17,28 @@ import type http from 'http'; import fs from 'fs'; import path from 'path'; -import { mime } from '../utilsBundle'; +import { mime, wsServer } from '../utilsBundle'; import { assert } from './debug'; import { createHttpServer } from './network'; import { ManualPromise } from './manualPromise'; +import { createGuid } from './crypto'; export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => boolean; +export type Transport = { + sendEvent?: (method: string, params: any) => void; + dispatch: (method: string, params: any) => Promise; + close?: () => void; + onclose: () => void; +}; + export class HttpServer { private _server: http.Server; private _urlPrefix: string; private _port: number = 0; private _started = false; private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = []; + private _wsGuid: string | undefined; constructor(address: string = '') { this._urlPrefix = address; @@ -68,6 +77,27 @@ export class HttpServer { } } + createWebSocket(transport: Transport, guid?: string) { + assert(!this._wsGuid, 'can only create one main websocket transport per server'); + this._wsGuid = guid || createGuid(); + const wss = new wsServer({ server: this._server, path: '/' + this._wsGuid }); + wss.on('connection', ws => { + transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params })); + transport.close = () => ws.close(); + ws.on('message', async (message: string) => { + const { id, method, params } = JSON.parse(message); + const result = await transport.dispatch(method, params); + ws.send(JSON.stringify({ id, result })); + }); + ws.on('close', () => transport.onclose()); + ws.on('error', () => transport.onclose()); + }); + } + + wsGuid(): string | undefined { + return this._wsGuid; + } + async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise { assert(!this._started, 'server already started'); this._started = true; diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index fc67d5353e..bb65d1982a 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -34,7 +34,6 @@ export { program } from 'playwright-core/lib/cli/program'; import type { ReporterDescription } from '../types/test'; import { prepareErrorStack } from './reporters/base'; import { cacheDir } from './transform/compilationCache'; -import { runTestServer } from './runner/testServer'; function addTestCommand(program: Command) { const command = program.command('test [test-filter...]'); @@ -108,9 +107,9 @@ function addFindRelatedTestFilesCommand(program: Command) { function addTestServerCommand(program: Command) { const command = program.command('test-server', { hidden: true }); command.description('start test server'); - command.action(() => { - void runTestServer(); - }); + command.option('--host ', 'Host to start the server on', 'localhost'); + command.option('--port ', 'Port to start the server on', '0'); + command.action(opts => runTestServer(opts)); } function addShowReportCommand(program: Command) { @@ -166,7 +165,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) { const runner = new Runner(config); let status: FullResult['status']; if (opts.ui || opts.uiHost || opts.uiPort) - status = await runner.uiAllTests({ host: opts.uiHost, port: opts.uiPort ? +opts.uiPort : undefined }); + status = await runner.runUIMode({ host: opts.uiHost, port: opts.uiPort ? +opts.uiPort : undefined }); else if (process.env.PWTEST_WATCH) status = await runner.watchAllTests(); else @@ -176,6 +175,19 @@ async function runTests(args: string[], opts: { [key: string]: any }) { gracefullyProcessExitDoNotHang(exitCode); } +async function runTestServer(opts: { [key: string]: any }) { + const config = await loadConfigFromFileRestartIfNeeded(opts.config, overridesFromOptions(opts), opts.deps === false); + if (!config) + return; + config.cliPassWithNoTests = true; + const runner = new Runner(config); + const host = opts.host || 'localhost'; + const port = opts.port ? +opts.port : 0; + const status = await runner.runTestServer({ host, port }); + const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); + gracefullyProcessExitDoNotHang(exitCode); +} + export async function withRunnerAndMutedWrite(configFile: string | undefined, callback: (runner: Runner) => Promise) { // Redefine process.stdout.write in case config decides to pollute stdio. const stdoutWrite = process.stdout.write.bind(process.stdout); diff --git a/packages/playwright/src/runner/runner.ts b/packages/playwright/src/runner/runner.ts index fc6a803c3c..8b25370855 100644 --- a/packages/playwright/src/runner/runner.ts +++ b/packages/playwright/src/runner/runner.ts @@ -16,7 +16,8 @@ */ import path from 'path'; -import { monotonicTime } from 'playwright-core/lib/utils'; +import type { HttpServer, ManualPromise } from 'playwright-core/lib/utils'; +import { isUnderTest, monotonicTime } from 'playwright-core/lib/utils'; import type { FullResult, TestError } from '../../types/testReporter'; import { webServerPluginsForConfig } from '../plugins/webServerPlugin'; import { collectFilesForProject, filterProjects } from './projectUtils'; @@ -25,12 +26,13 @@ import { TestRun, createTaskRunner, createTaskRunnerForList } from './tasks'; import type { FullConfigInternal } from '../common/config'; import { colors } from 'playwright-core/lib/utilsBundle'; import { runWatchModeLoop } from './watchMode'; -import { runUIMode } from './uiMode'; +import { runTestServer } from './uiMode'; import { InternalReporter } from '../reporters/internalReporter'; import { Multiplexer } from '../reporters/multiplexer'; import type { Suite } from '../common/test'; import { wrapReporterAsV2 } from '../reporters/reporterV2'; import { affectedTestFiles } from '../transform/compilationCache'; +import { installRootRedirect, openTraceInBrowser, openTraceViewerApp } from 'playwright-core/lib/server'; type ProjectConfigWithFiles = { name: string; @@ -146,10 +148,32 @@ export class Runner { return await runWatchModeLoop(config); } - async uiAllTests(options: { host?: string, port?: number }): Promise { + async runUIMode(options: { host?: string, port?: number }): Promise { const config = this._config; webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); - return await runUIMode(config, options); + return await runTestServer(config, options, async (server: HttpServer, cancelPromise: ManualPromise) => { + await installRootRedirect(server, [], { webApp: 'uiMode.html' }); + if (options.host !== undefined || options.port !== undefined) { + await openTraceInBrowser(server.urlPrefix()); + } else { + const page = await openTraceViewerApp(server.urlPrefix(), 'chromium', { + headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1', + persistentContextOptions: { + handleSIGINT: false, + }, + }); + page.on('close', () => cancelPromise.resolve()); + } + }); + } + + async runTestServer(options: { host?: string, port?: number }): Promise { + const config = this._config; + webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); + return await runTestServer(config, options, async server => { + // eslint-disable-next-line no-console + console.log('Listening on ' + server.urlPrefix().replace('http:', 'ws:') + '/' + server.wsGuid()); + }); } async findRelatedTestFiles(mode: 'in-process' | 'out-of-process', files: string[]): Promise { diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 1cd57034e4..1f890586bc 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -78,7 +78,7 @@ export function createTaskRunnerForWatchSetup(config: FullConfigInternal, report export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: ReporterV2, additionalFileMatcher?: Matcher): TaskRunner { const taskRunner = new TaskRunner(reporter, 0); - taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunTestsOutsideProjectFilter: true, additionalFileMatcher })); + taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true, additionalFileMatcher })); addRunTasks(taskRunner, config); return taskRunner; } @@ -86,7 +86,7 @@ export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: R export function createTaskRunnerForTestServer(config: FullConfigInternal, reporter: ReporterV2): TaskRunner { const taskRunner = new TaskRunner(reporter, 0); addGlobalSetupTasks(taskRunner, config); - taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunTestsOutsideProjectFilter: true })); + taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true })); addRunTasks(taskRunner, config); return taskRunner; } @@ -195,10 +195,10 @@ function createRemoveOutputDirsTask(): Task { }; } -function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunTestsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task { +function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task { return { setup: async (testRun, errors, softErrors) => { - await collectProjectsAndTestFiles(testRun, !!options.doNotRunTestsOutsideProjectFilter, options.additionalFileMatcher); + await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter, options.additionalFileMatcher); await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors); testRun.rootSuite = await createRootSuite(testRun, options.failOnLoadErrors ? errors : softErrors, !!options.filterOnly); testRun.failureTracker.onRootSuite(testRun.rootSuite); diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts deleted file mode 100644 index 38c950d164..0000000000 --- a/packages/playwright/src/runner/testServer.ts +++ /dev/null @@ -1,226 +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 type http from 'http'; -import path from 'path'; -import { ManualPromise, createGuid, gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils'; -import { WSServer } from 'playwright-core/lib/utils'; -import type { WebSocket } from 'playwright-core/lib/utilsBundle'; -import type { FullResult, TestError } from 'playwright/types/testReporter'; -import { loadConfig, restartWithExperimentalTsEsm } from '../common/configLoader'; -import { InternalReporter } from '../reporters/internalReporter'; -import { Multiplexer } from '../reporters/multiplexer'; -import { createReporterForTestServer, createReporters } from './reporters'; -import { TestRun, createTaskRunnerForList, createTaskRunnerForTestServer } from './tasks'; -import type { ConfigCLIOverrides } from '../common/ipc'; -import { Runner } from './runner'; -import type { FindRelatedTestFilesReport } from './runner'; -import type { FullConfigInternal } from '../common/config'; -import type { TestServerInterface } from './testServerInterface'; -import { serializeError } from '../util'; -import { prepareErrorStack } from '../reporters/base'; - -export async function runTestServer() { - if (restartWithExperimentalTsEsm(undefined, true)) - return null; - process.env.PW_TEST_HTML_REPORT_OPEN = 'never'; - const wss = new WSServer({ - onConnection(request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) { - const dispatcher = new Dispatcher(ws); - ws.on('message', async message => { - const { id, method, params } = JSON.parse(String(message)); - try { - const result = await (dispatcher as any)[method](params); - ws.send(JSON.stringify({ id, result })); - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - } - }); - return { - async close() {} - }; - }, - }); - const url = await wss.listen(0, 'localhost', '/' + createGuid()); - // eslint-disable-next-line no-console - process.on('exit', () => wss.close().catch(console.error)); - // eslint-disable-next-line no-console - console.log(`Listening on ${url}`); - process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0)); -} - -class Dispatcher implements TestServerInterface { - private _testRun: { run: Promise, stop: ManualPromise } | undefined; - private _ws: WebSocket; - - constructor(ws: WebSocket) { - this._ws = ws; - - process.stdout.write = ((chunk: string | Buffer, cb?: Buffer | Function, cb2?: Function) => { - this._dispatchEvent('stdio', chunkToPayload('stdout', chunk)); - if (typeof cb === 'function') - (cb as any)(); - if (typeof cb2 === 'function') - (cb2 as any)(); - return true; - }) as any; - process.stderr.write = ((chunk: string | Buffer, cb?: Buffer | Function, cb2?: Function) => { - this._dispatchEvent('stdio', chunkToPayload('stderr', chunk)); - if (typeof cb === 'function') - (cb as any)(); - if (typeof cb2 === 'function') - (cb2 as any)(); - return true; - }) as any; - } - - async listFiles(params: { - configFile: string; - }): Promise<{ - projects: { - name: string; - testDir: string; - use: { testIdAttribute?: string }; - files: string[]; - }[]; - cliEntryPoint?: string; - error?: TestError; - }> { - try { - const config = await this._loadConfig(params.configFile); - const runner = new Runner(config); - return runner.listTestFiles(); - } catch (e) { - const error: TestError = serializeError(e); - error.location = prepareErrorStack(e.stack).location; - return { projects: [], error }; - } - } - - async listTests(params: { - configFile: string; - locations: string[]; - reporter: string; - env: NodeJS.ProcessEnv; - }) { - const config = await this._loadConfig(params.configFile); - config.cliArgs = params.locations || []; - const wireReporter = await createReporterForTestServer(config, params.reporter, 'list', message => this._dispatchEvent('report', message)); - const reporter = new InternalReporter(new Multiplexer([wireReporter])); - const taskRunner = createTaskRunnerForList(config, reporter, 'out-of-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(); - } - - async test(params: { - configFile: string; - locations: string[]; - reporter: string; - env: NodeJS.ProcessEnv; - headed?: boolean; - oneWorker?: boolean; - trace?: 'on' | 'off'; - projects?: string[]; - grep?: string; - reuseContext?: boolean; - connectWsEndpoint?: string; - }) { - await this._stopTests(); - - const overrides: ConfigCLIOverrides = { - repeatEach: 1, - retries: 0, - preserveOutputDir: true, - use: { - trace: params.trace, - headless: params.headed ? false : undefined, - _optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined, - _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, - }, - workers: params.oneWorker ? 1 : undefined, - }; - - const config = await this._loadConfig(params.configFile, overrides); - config.cliListOnly = false; - config.cliArgs = params.locations || []; - config.cliGrep = params.grep; - config.cliProjectFilter = params.projects?.length ? params.projects : undefined; - - const wireReporter = await createReporterForTestServer(config, params.reporter, 'test', message => this._dispatchEvent('report', message)); - const configReporters = await createReporters(config, 'test'); - const reporter = new InternalReporter(new Multiplexer([...configReporters, wireReporter])); - const taskRunner = createTaskRunnerForTestServer(config, reporter); - const testRun = new TestRun(config, reporter); - reporter.onConfigure(config.config); - const stop = new ManualPromise(); - const run = taskRunner.run(testRun, 0, stop).then(async status => { - await reporter.onEnd({ status }); - await reporter.onExit(); - this._testRun = undefined; - return status; - }); - this._testRun = { run, stop }; - await run; - } - - async findRelatedTestFiles(params: { - configFile: string; - files: string[]; - }): Promise { - const config = await this._loadConfig(params.configFile); - const runner = new Runner(config); - return runner.findRelatedTestFiles('out-of-process', params.files); - } - - async stop(params: { - configFile: string; - }) { - await this._stopTests(); - } - - async closeGracefully() { - gracefullyProcessExitDoNotHang(0); - } - - private async _stopTests() { - this._testRun?.stop?.resolve(); - await this._testRun?.run; - } - - private _dispatchEvent(method: string, params: any) { - this._ws.send(JSON.stringify({ method, params })); - } - - private async _loadConfig(configFile: string, overrides?: ConfigCLIOverrides): Promise { - return loadConfig({ resolvedConfigFile: configFile, configDir: path.dirname(configFile) }, overrides); - } -} - -function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string) { - if (chunk instanceof Buffer) - return { type, buffer: chunk.toString('base64') }; - return { type, text: chunk }; -} diff --git a/packages/playwright/src/runner/uiMode.ts b/packages/playwright/src/runner/uiMode.ts index ac6c6bf11b..2d6024f06d 100644 --- a/packages/playwright/src/runner/uiMode.ts +++ b/packages/playwright/src/runner/uiMode.ts @@ -14,8 +14,9 @@ * limitations under the License. */ -import { openTraceViewerApp, openTraceInBrowser, registry } from 'playwright-core/lib/server'; -import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils'; +import { registry, startTraceViewerServer } from 'playwright-core/lib/server'; +import { ManualPromise } from 'playwright-core/lib/utils'; +import type { Transport, HttpServer } from 'playwright-core/lib/utils'; import type { FullResult } from '../../types/testReporter'; import { collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache'; import type { FullConfigInternal } from '../common/config'; @@ -25,14 +26,13 @@ import { createReporters } from './reporters'; import { TestRun, createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks'; 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 { +class TestServer { private _config: FullConfigInternal; - private _transport!: Transport; + private _transport: Transport | undefined; private _testRun: { run: Promise, stop: ManualPromise } | undefined; globalCleanup: (() => Promise) | undefined; private _globalWatcher: Watcher; @@ -80,10 +80,9 @@ class UIMode { return status; } - async showUI(options: { host?: string, port?: number }, cancelPromise: ManualPromise) { + async start(options: { host?: string, port?: number }): Promise { let queue = Promise.resolve(); - - this._transport = { + const transport: Transport = { dispatch: async (method, params) => { if (method === 'ping') return; @@ -118,25 +117,13 @@ class UIMode { await queue; }, - onclose: () => { }, + onclose: () => {}, }; - const openOptions: OpenTraceViewerOptions = { - app: 'uiMode.html', - headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1', - transport: this._transport, - host: options.host, - port: options.port, - persistentContextOptions: { - handleSIGINT: false, - }, - }; - if (options.host !== undefined || options.port !== undefined) { - await openTraceInBrowser([], openOptions); - } else { - const page = await openTraceViewerApp([], 'chromium', openOptions); - page.on('close', () => cancelPromise.resolve()); - } + this._transport = transport; + return await startTraceViewerServer({ ...options, transport }); + } + wireStdIO() { if (!process.env.PWTEST_DEBUG) { process.stdout.write = (chunk: string | Buffer) => { this._dispatchEvent('stdio', chunkToPayload('stdout', chunk)); @@ -147,8 +134,9 @@ class UIMode { return true; }; } - await cancelPromise; + } + unwireStdIO() { if (!process.env.PWTEST_DEBUG) { process.stdout.write = this._originalStdoutWrite; process.stderr.write = this._originalStderrWrite; @@ -163,7 +151,7 @@ class UIMode { } private _dispatchEvent(method: string, params?: any) { - this._transport.sendEvent?.(method, params); + this._transport?.sendEvent?.(method, params); } private async _listTests() { @@ -227,20 +215,24 @@ class UIMode { } } -export async function runUIMode(config: FullConfigInternal, options: { host?: string, port?: number }): Promise { - const uiMode = new UIMode(config); - const globalSetupStatus = await uiMode.runGlobalSetup(); +export async function runTestServer(config: FullConfigInternal, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise) => Promise): Promise { + const testServer = new TestServer(config); + const globalSetupStatus = await testServer.runGlobalSetup(); if (globalSetupStatus !== 'passed') return globalSetupStatus; const cancelPromise = new ManualPromise(); const sigintWatcher = new SigIntWatcher(); void sigintWatcher.promise().then(() => cancelPromise.resolve()); try { - await uiMode.showUI(options, cancelPromise); + const server = await testServer.start(options); + await openUI(server, cancelPromise); + testServer.wireStdIO(); + await cancelPromise; } finally { + testServer.unwireStdIO(); sigintWatcher.disarm(); } - return await uiMode.globalCleanup?.() || (sigintWatcher.hadSignal() ? 'interrupted' : 'passed'); + return await testServer.globalCleanup?.() || (sigintWatcher.hadSignal() ? 'interrupted' : 'passed'); } type StdioPayload = { diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index 995233e4d6..2e6fab2f18 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -16,7 +16,7 @@ import type { Fixtures, FrameLocator, Locator, Page, Browser, BrowserContext } from '@playwright/test'; import { step } from './baseTest'; -import { openTraceViewerApp } from '../../packages/playwright-core/lib/server'; +import { runTraceViewerApp } from '../../packages/playwright-core/lib/server'; type BaseTestFixtures = { context: BrowserContext; @@ -107,7 +107,7 @@ export const traceViewerFixtures: Fixtures { - const pageImpl = await openTraceViewerApp(traces, browserName, { headless, host, port }); + const pageImpl = await runTraceViewerApp(traces, browserName, { headless, host, port }); const contextImpl = pageImpl.context(); const browser = await playwright.chromium.connectOverCDP(contextImpl._browser.options.wsEndpoint); browsers.push(browser);