diff --git a/packages/playwright-core/src/grid/gridServer.ts b/packages/playwright-core/src/grid/gridServer.ts index 2b99c6bedc..22e1790836 100644 --- a/packages/playwright-core/src/grid/gridServer.ts +++ b/packages/playwright-core/src/grid/gridServer.ts @@ -393,7 +393,7 @@ export class GridServer { } async start(port?: number) { - await this._server.start(port); + await this._server.start({ port }); } gridURL(): string { diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 48564666bf..a6ba863633 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -26,7 +26,7 @@ import { serverSideCallMetadata } from '../../instrumentation'; import { createPlaywright } from '../../playwright'; import { ProgressController } from '../../progress'; -export async function showTraceViewer(traceUrls: string[], browserName: string, headless = false, port?: number): Promise { +export async function showTraceViewer(traceUrls: string[], browserName: string, headless = false, preferredPort?: number): Promise { for (const traceUrl of traceUrls) { if (!traceUrl.startsWith('http://') && !traceUrl.startsWith('https://') && !fs.existsSync(traceUrl)) { // eslint-disable-next-line no-console @@ -49,7 +49,7 @@ export async function showTraceViewer(traceUrls: string[], browserName: string, return server.serveFile(request, response, absolutePath); }); - const urlPrefix = await server.start(port); + const urlPrefix = await server.start({ preferredPort }); const traceViewerPlaywright = createPlaywright('javascript', true); const traceViewerBrowser = isUnderTest() ? 'chromium' : browserName; diff --git a/packages/playwright-core/src/utils/httpServer.ts b/packages/playwright-core/src/utils/httpServer.ts index 10de7e4dfe..28c92992fb 100644 --- a/packages/playwright-core/src/utils/httpServer.ts +++ b/packages/playwright-core/src/utils/httpServer.ts @@ -20,6 +20,7 @@ import path from 'path'; import { mime, wsServer } from '../utilsBundle'; import type { WebSocketServer } from '../utilsBundle'; import { assert } from './'; +import { ManualPromise } from './manualPromise'; export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => boolean; @@ -51,15 +52,43 @@ export class HttpServer { return this._port; } - async start(port?: number, host = 'localhost'): Promise { + private async _tryStart(port: number | undefined, host: string) { + const errorPromise = new ManualPromise(); + const errorListener = (error: Error) => errorPromise.reject(error); + this._server.on('error', errorListener); + + try { + this._server.listen(port, host); + await Promise.race([ + new Promise(cb => this._server!.once('listening', cb)), + errorPromise, + ]); + } finally { + this._server.removeListener('error', errorListener); + } + } + + async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise { assert(!this._started, 'server already started'); this._started = true; this._server.on('connection', socket => { this._activeSockets.add(socket); socket.once('close', () => this._activeSockets.delete(socket)); }); - this._server.listen(port, host); - await new Promise(cb => this._server!.once('listening', cb)); + + const host = options.host || 'localhost'; + if (options.preferredPort) { + try { + await this._tryStart(options.preferredPort, host); + } catch (e) { + if (!e || !e.message || !e.message.includes('EADDRINUSE')) + throw e; + await this._tryStart(undefined, host); + } + } else { + await this._tryStart(options.port, host); + } + const address = this._server.address(); assert(address, 'Could not bind server socket'); if (!this._urlPrefix) { diff --git a/packages/playwright-test/src/reporters/html.ts b/packages/playwright-test/src/reporters/html.ts index 92dde15dc3..b4bc773d70 100644 --- a/packages/playwright-test/src/reporters/html.ts +++ b/packages/playwright-test/src/reporters/html.ts @@ -149,7 +149,7 @@ function standaloneDefaultFolder(): string { return reportFolderFromEnv() ?? defaultReportFolder(process.cwd()); } -export async function showHTMLReport(reportFolder: string | undefined, host: string = 'localhost', port: number = 9223, testId?: string) { +export async function showHTMLReport(reportFolder: string | undefined, host: string = 'localhost', port?: number, testId?: string) { const folder = reportFolder ?? standaloneDefaultFolder(); try { assert(fs.statSync(folder).isDirectory()); @@ -159,7 +159,7 @@ export async function showHTMLReport(reportFolder: string | undefined, host: str return; } const server = startHtmlReportServer(folder); - let url = await server.start(port, host); + let url = await server.start({ port, host, preferredPort: port ? undefined : 9223 }); console.log(''); console.log(colors.cyan(` Serving HTML report at ${url}. Press Ctrl+C to quit.`)); if (testId) diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index 00ecf7a4da..470b8f731f 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -29,7 +29,7 @@ type BaseWorkerFixtures = { }; export type TraceViewerFixtures = { - showTraceViewer: (trace: string[]) => Promise; + showTraceViewer: (trace: string[], preferredPort?: number) => Promise; runAndTrace: (body: () => Promise) => Promise; }; @@ -110,15 +110,19 @@ class TraceViewerPage { export const traceViewerFixtures: Fixtures = { showTraceViewer: async ({ playwright, browserName, headless }, use) => { - let browser: Browser; - let contextImpl: any; - await use(async (traces: string[]) => { - contextImpl = await showTraceViewer(traces, browserName, headless); - browser = await playwright.chromium.connectOverCDP(contextImpl._browser.options.wsEndpoint); + const browsers: Browser[] = []; + const contextImpls: any[] = []; + await use(async (traces: string[], preferredPort?: number) => { + const contextImpl = await showTraceViewer(traces, browserName, headless, preferredPort); + const browser = await playwright.chromium.connectOverCDP(contextImpl._browser.options.wsEndpoint); + browsers.push(browser); + contextImpls.push(contextImpl); return new TraceViewerPage(browser.contexts()[0].pages()[0]); }); - await browser?.close(); - await contextImpl?._browser.close(); + for (const browser of browsers) + await browser.close(); + for (const contextImpl of contextImpls) + await contextImpl._browser.close(); }, runAndTrace: async ({ context, showTraceViewer }, use, testInfo) => { diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 635ecbe694..888e87c05a 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -78,6 +78,14 @@ test('should show empty trace viewer', async ({ showTraceViewer }, testInfo) => await expect(traceViewer.page).toHaveTitle('Playwright Trace Viewer'); }); +test('should open two trace viewers', async ({ showTraceViewer }, testInfo) => { + const preferredPort = testInfo.workerIndex + 48321; + const traceViewer1 = await showTraceViewer([testInfo.outputPath()], preferredPort); + await expect(traceViewer1.page).toHaveTitle('Playwright Trace Viewer'); + const traceViewer2 = await showTraceViewer([testInfo.outputPath()], preferredPort); + await expect(traceViewer2.page).toHaveTitle('Playwright Trace Viewer'); +}); + test('should open simple trace viewer', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer([traceFile]); await expect(traceViewer.actionTitles).toHaveText([