diff --git a/packages/dashboard/src/dashboardModel.ts b/packages/dashboard/src/dashboardModel.ts index 3896ab71d1da9..40635ac8314e2 100644 --- a/packages/dashboard/src/dashboardModel.ts +++ b/packages/dashboard/src/dashboardModel.ts @@ -295,8 +295,9 @@ export class DashboardModel { let frame: AnnotateFrame | undefined; try { frame = await this._client.screenshot(); - } catch { - // frame stays undefined + } catch (e) { + // eslint-disable-next-line no-console + console.error('[dashboard] screenshot failed:', e); } if (id !== this._requestId) return; diff --git a/packages/playwright-core/src/tools/backend/devtools.ts b/packages/playwright-core/src/tools/backend/devtools.ts index 4fc8cf7794e41..d53f9ec376aaa 100644 --- a/packages/playwright-core/src/tools/backend/devtools.ts +++ b/packages/playwright-core/src/tools/backend/devtools.ts @@ -15,6 +15,8 @@ */ import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; import * as z from 'zod'; @@ -24,6 +26,15 @@ import { elementSchema, optionalElementSchema } from './snapshot'; import type { AnnotationData } from '@dashboard/dashboardChannel'; +function detachedStdio(): 'ignore' | ['ignore', number, number] { + const logFile = process.env.PWTEST_DASHBOARD_DAEMON_LOG; + if (!logFile) + return 'ignore'; + fs.mkdirSync(path.dirname(logFile), { recursive: true }); + const fd = fs.openSync(logFile, 'a'); + return ['ignore', fd, fd]; +} + const resume = defineTool({ capability: 'devtools', @@ -131,7 +142,7 @@ const annotate = defineTabTool({ const daemonArgs = [daemonScript, `--pageId=${pageId}`]; // Spawn the dashboard daemon (idempotent — the singleton socket guards against duplicates). - const daemon = spawn(process.execPath, daemonArgs, { detached: true, stdio: 'ignore' }); + const daemon = spawn(process.execPath, daemonArgs, { detached: true, stdio: detachedStdio() }); daemon.unref(); // Spawn the annotate client in JSON mode to capture the raw payload over stdout. diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts index 0f8d900c05ede..3fd7d3afb2f24 100644 --- a/packages/playwright-core/src/tools/cli-client/program.ts +++ b/packages/playwright-core/src/tools/cli-client/program.ts @@ -19,6 +19,7 @@ import { execSync, spawn } from 'child_process'; import crypto from 'crypto'; +import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -211,14 +212,29 @@ export async function program(options?: { embedderVersion?: string}) { daemonArgs.push(`--port=${args.port}`); if (args.host !== undefined) daemonArgs.push(`--host=${args.host as string}`); + const detachedStdio = (): 'ignore' | ['ignore', number, number] => { + const logFile = process.env.PWTEST_DASHBOARD_DAEMON_LOG; + process.stderr.write(`[cli pid=${process.pid}] detachedStdio called, logFile=${JSON.stringify(logFile)} platform=${process.platform}\n`); + if (!logFile) + return 'ignore'; + try { + fs.mkdirSync(path.dirname(logFile), { recursive: true }); + const fd = fs.openSync(logFile, 'a'); + fs.writeSync(fd, `[cli pid=${process.pid}] opened log for show args=${JSON.stringify({ kill: !!args.kill, annotate: !!args.annotate, port: args.port })} bindTitle=${process.env.PWTEST_DASHBOARD_APP_BIND_TITLE} platform=${process.platform}\n`); + return ['ignore', fd, fd]; + } catch (e) { + process.stderr.write(`[cli pid=${process.pid}] detachedStdio failed to open ${logFile}: ${(e as Error).message}\n`); + return 'ignore'; + } + }; if (args.kill) { daemonArgs.push(`--kill`); - const child = spawn(process.execPath, daemonArgs, { stdio: 'ignore' }); + const child = spawn(process.execPath, daemonArgs, { stdio: detachedStdio() }); await new Promise(resolve => child.on('exit', () => resolve())); return; } if (args.annotate) { - const dashboard = spawn(process.execPath, daemonArgs, { detached: true, stdio: 'ignore' }); + const dashboard = spawn(process.execPath, daemonArgs, { detached: true, stdio: detachedStdio() }); dashboard.unref(); const annotate = spawn(process.execPath, [...daemonArgs, '--annotate'], { stdio: 'inherit' }); await new Promise(resolve => annotate.on('exit', () => resolve())); @@ -227,7 +243,7 @@ export async function program(options?: { embedderVersion?: string}) { const foreground = args.port !== undefined; const child = spawn(process.execPath, daemonArgs, { detached: !foreground, - stdio: foreground ? 'inherit' : 'ignore', + stdio: foreground ? 'inherit' : detachedStdio(), }); if (foreground) { await new Promise(resolve => child.on('exit', () => resolve())); diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index 5a0634513ee9b..8797a4ef38d27 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -169,12 +169,18 @@ async function attachDashboardDevServer(httpServer: HttpServer) { } // HMR end +/* eslint-disable no-console */ async function innerOpenDashboardApp(options: DashboardOptions): Promise<{ page: api.Page; server: DashboardServer }> { + console.error(`[dashboardApp pid=${process.pid}] innerOpenDashboardApp start`); const server = await startDashboardServer(new RegistrySessionProvider(), options); + console.error(`[dashboardApp pid=${process.pid}] dashboard server started at ${server.url}, calling launchApp`); const { page } = await launchApp('dashboard', { onClose: () => gracefullyProcessExitDoNotHang(0) }); + console.error(`[dashboardApp pid=${process.pid}] launchApp returned, navigating`); await page.goto(server.url); + console.error(`[dashboardApp pid=${process.pid}] innerOpenDashboardApp done`); return { page, server }; } +/* eslint-enable no-console */ async function launchApp(appName: string, options?: { onClose?: () => void }) { const channel = findChromiumChannelBestEffort('javascript'); @@ -190,8 +196,19 @@ async function launchApp(appName: string, options?: { onClose?: () => void }) { ], viewport: null, }); - if (process.env.PWTEST_DASHBOARD_APP_BIND_TITLE) - await context.browser()?.bind(process.env.PWTEST_DASHBOARD_APP_BIND_TITLE, { workspaceDir: process.cwd() }); + if (process.env.PWTEST_DASHBOARD_APP_BIND_TITLE) { + // eslint-disable-next-line no-console + console.error(`[dashboardApp pid=${process.pid}] launchPersistentContext done, browser=${!!context.browser()}, calling bind(${process.env.PWTEST_DASHBOARD_APP_BIND_TITLE})`); + try { + await context.browser()?.bind(process.env.PWTEST_DASHBOARD_APP_BIND_TITLE, { workspaceDir: process.cwd() }); + // eslint-disable-next-line no-console + console.error(`[dashboardApp pid=${process.pid}] bind succeeded`); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`[dashboardApp pid=${process.pid}] bind failed:`, e); + throw e; + } + } const [page] = context.pages(); // Chromium on macOS opens a new tab when clicking on the dock icon. @@ -276,27 +293,54 @@ async function acquireSingleton(options: DashboardOptions): Promise if (process.platform !== 'win32') await fs.promises.mkdir(path.dirname(socketPath), { recursive: true }); - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(socketPath, () => resolve(server)); - server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code !== 'EADDRINUSE') - return reject(err); - const client = net.connect(socketPath, () => { - client.write(JSON.stringify(options) + '\n'); - reject(new Error('already running')); - }); - client.on('error', () => { - if (process.platform !== 'win32') - fs.unlinkSync(socketPath); - server.listen(socketPath, () => resolve(server)); + // Try to acquire the singleton. The OS may report a number of error codes + // when the socket / named-pipe name is in use (EADDRINUSE on Unix and TCP, + // EACCES / ENOENT / EBUSY on Windows pipes depending on libuv mapping and + // pipe instance state). Treat any listen failure as "in use" and probe the + // existing holder with connect(): + // - If connect succeeds, the holder is alive and serving -> "already running". + // - If connect fails, the holder is dead/dying (server has stopped accepting) + // -> wait briefly and retry listen, until either we acquire it or we time out. + const deadline = Date.now() + 30000; + let lastListenError: NodeJS.ErrnoException | undefined; + while (Date.now() < deadline) { + const tryListen = await new Promise<{ server?: net.Server, listenErr?: NodeJS.ErrnoException }>(resolve => { + const server = net.createServer(); + const onError = (err: NodeJS.ErrnoException) => { server.removeAllListeners(); resolve({ listenErr: err }); }; + server.once('error', onError); + server.listen(socketPath, () => { server.off('error', onError); resolve({ server }); }); + }); + if (tryListen.server) + return tryListen.server; + lastListenError = tryListen.listenErr; + const holderAlive = await new Promise(resolve => { + const client = net.connect(socketPath); + let settled = false; + const settle = (alive: boolean) => { + if (settled) + return; + settled = true; + resolve(alive); + }; + client.once('connect', () => { + client.end(JSON.stringify(options) + '\n'); + settle(true); }); + client.once('error', () => settle(false)); }); - }); + if (holderAlive) + throw new Error('already running'); + if (process.platform !== 'win32') + try { fs.unlinkSync(socketPath); } catch {} + await new Promise(r => setTimeout(r, 50)); + } + throw new Error(`Timed out acquiring dashboard singleton at ${socketPath}: ${lastListenError?.code ?? 'unknown'}`); } export async function openDashboardApp() { const options = parseOpenArgs(); + // eslint-disable-next-line no-console + console.error(`[dashboardApp pid=${process.pid}] openDashboardApp start, options=${JSON.stringify({ kill: options.kill, annotate: options.annotate, port: options.port })}, bindTitle=${process.env.PWTEST_DASHBOARD_APP_BIND_TITLE}`); if (options.kill) { await runKillClient(); return; @@ -320,7 +364,11 @@ export async function openDashboardApp() { process.on('exit', () => server?.close()); try { server = await acquireSingleton(options); - } catch { + // eslint-disable-next-line no-console + console.error(`[dashboardApp pid=${process.pid}] acquireSingleton ok`); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`[dashboardApp pid=${process.pid}] acquireSingleton failed:`, e); return; } const statePromise = innerOpenDashboardApp(options); @@ -350,6 +398,12 @@ export async function openDashboardApp() { dashboard.triggerAnnotate(); dashboard.registerAnnotateWaiter(socket); } else if (parsed.kill) { + // Stop accepting new connections immediately so a concurrent + // acquireSingleton() in another process gets a connect-error + // (rather than seeing us as "already running") and waits for the + // binding to be released. Existing connections (this kill RPC) + // stay alive long enough for socket.end() to drain. + server?.close(); socket.end(); gracefullyProcessExitDoNotHang(0); } else { diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index d67269d184e0a..b031367ffec59 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -426,15 +426,21 @@ class AttachedPage { } async screenshot(): Promise<{ data: string; viewportWidth: number; viewportHeight: number, ariaSnapshot: string }> { - const buffer = await this._page.screenshot({ type: 'png' }); - const vp = await this._viewportSize(); - const ariaSnapshot = await this._page.ariaSnapshot({ boxes: true, mode: 'ai' }); - return { - data: buffer.toString('base64'), - viewportWidth: vp.width, - viewportHeight: vp.height, - ariaSnapshot, - }; + try { + const buffer = await this._page.screenshot({ type: 'png' }); + const vp = await this._viewportSize(); + const ariaSnapshot = await this._page.ariaSnapshot({ boxes: true, mode: 'ai' }); + return { + data: buffer.toString('base64'), + viewportWidth: vp.width, + viewportHeight: vp.height, + ariaSnapshot, + }; + } catch (e) { + // eslint-disable-next-line no-console + console.error('[dashboardController] screenshot failed:', e); + throw e; + } } private async _viewportSize(): Promise<{ width: number; height: number }> { diff --git a/tests/mcp/cli-fixtures.ts b/tests/mcp/cli-fixtures.ts index 887894218a7b0..390a4b6295c12 100644 --- a/tests/mcp/cli-fixtures.ts +++ b/tests/mcp/cli-fixtures.ts @@ -62,12 +62,21 @@ export const test = baseTest.extend<{ connectToDashboard: async ({ cli, playwright }, use) => { await use(async (bindTitle: string) => { let endpoint = ''; - await expect(async () => { - const { output } = await cli('list', '--all', '--json'); - const { servers } = JSON.parse(output); - const server = servers.find(s => s.title === bindTitle); - endpoint = server.endpoint; - }).toPass(); + let lastListOutput = ''; + try { + await expect(async () => { + const { output } = await cli('list', '--all', '--json'); + lastListOutput = output; + const { servers } = JSON.parse(output); + const server = servers.find(s => s.title === bindTitle); + if (!server) + throw new Error(`No server with title ${JSON.stringify(bindTitle)} in list. Got titles: ${JSON.stringify(servers.map(s => s.title))}`); + endpoint = server.endpoint; + }).toPass(); + } catch (e) { + await test.info().attach('connect-to-dashboard-last-list.json', { body: lastListOutput, contentType: 'application/json' }); + throw e; + } return await playwright.chromium.connect(endpoint); }); await cli('show', '--kill'); @@ -76,11 +85,13 @@ export const test = baseTest.extend<{ cli: async ({ mcpBrowser, mcpHeadless, childProcess }, use) => { await fs.promises.mkdir(test.info().outputPath('.playwright'), { recursive: true }); const allPids: number[] = []; + const cliInvocations: { args: string[]; output: string; error: string; exitCode: number | null }[] = []; await use(async (...args: string[]) => { const cliArgs = args.filter(arg => typeof arg === 'string'); const cliOptions = args.findLast(arg => typeof arg === 'object') || {}; const result = await runCli(childProcess, cliArgs, cliOptions, { mcpBrowser, mcpHeadless }); + cliInvocations.push({ args: cliArgs, output: result.output, error: result.error, exitCode: result.exitCode }); if (result.daemonPid) allPids.push(result.daemonPid); if (result.dashboardPid) @@ -91,7 +102,40 @@ export const test = baseTest.extend<{ for (const pid of allPids) killProcessGroup(pid); - const daemonDir = test.info().outputPath('daemon'); + const testInfo = test.info(); + const failed = testInfo.status !== testInfo.expectedStatus; + const daemonDir = testInfo.outputPath('daemon'); + + if (failed) { + const daemonLog = testInfo.outputPath('dashboard-daemon.log'); + const dashStat = await fs.promises.stat(daemonLog).catch(e => e as NodeJS.ErrnoException); + const dashContents = await fs.promises.readFile(daemonLog, 'utf8').catch(() => undefined); + const summary = [ + `daemonLog path: ${daemonLog}`, + `stat: ${dashStat instanceof Error ? `ERROR ${dashStat.code}: ${dashStat.message}` : `size=${(dashStat as any).size}`}`, + `PWTEST_DASHBOARD_DAEMON_LOG env (test process): ${process.env.PWTEST_DASHBOARD_DAEMON_LOG ?? ''}`, + `cwd: ${process.cwd()}`, + `platform: ${process.platform}`, + '', + `cli invocations (${cliInvocations.length}):`, + ...cliInvocations.map((c, i) => [ + `--- [${i}] cli ${c.args.join(' ')} (exit=${c.exitCode}) ---`, + c.output ? `stdout:\n${c.output}` : '(no stdout)', + c.error ? `stderr:\n${c.error}` : '(no stderr)', + ].join('\n')), + ].join('\n'); + await testInfo.attach('cli-fixture-debug.txt', { body: summary, contentType: 'text/plain' }); + if (dashContents !== undefined) + await testInfo.attach('dashboard-daemon.log', { body: dashContents || '', contentType: 'text/plain' }); + for await (const entry of walk(daemonDir)) { + if (!entry.endsWith('.err') && !entry.endsWith('.session')) + continue; + const contents = await fs.promises.readFile(entry, 'utf8').catch(() => ''); + if (contents) + await testInfo.attach(path.relative(daemonDir, entry).replaceAll(path.sep, '/'), { body: contents, contentType: 'text/plain' }); + } + } + for (const dir of await fs.promises.readdir(daemonDir).catch(() => [])) { if (dir.startsWith('ud-')) { await fs.promises.rm(path.join(daemonDir, dir), { recursive: true, force: true }).catch(() => {}); @@ -120,6 +164,7 @@ function cliEnv() { PLAYWRIGHT_DAEMON_SESSION_DIR: test.info().outputPath('daemon'), PLAYWRIGHT_SOCKETS_DIR: path.join(os.tmpdir(), 'ds' + String(test.info().workerIndex)), PWTEST_CLI_CHANNEL_SCAN_DISABLED_FOR_TEST: '1', + PWTEST_DASHBOARD_DAEMON_LOG: test.info().outputPath('dashboard-daemon.log'), }; } @@ -134,6 +179,7 @@ async function runCli(childProcess: CommonFixtures['childProcess'], args: string PLAYWRIGHT_MCP_HEADLESS: String(options.mcpHeadless), PWTEST_PRINT_DASHBOARD_PID_FOR_TEST: '1', PWTEST_DASHBOARD_APP_BIND_TITLE: cliOptions.bindTitle, + DEBUG: [process.env.DEBUG, 'pw:browser*'].filter(Boolean).join(','), ...cliOptions.env, }), }); @@ -204,6 +250,17 @@ async function loadSnapshot(output: string): Promise<{ snapshot?: string, inline } } +async function* walk(dir: string): AsyncGenerator { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) + yield* walk(full); + else + yield full; + } +} + export const eventsPage = ` diff --git a/tests/mcp/http.spec.ts b/tests/mcp/http.spec.ts index 6ea03c9e8482e..5ebe1c29985e7 100644 --- a/tests/mcp/http.spec.ts +++ b/tests/mcp/http.spec.ts @@ -29,6 +29,7 @@ import { ListRootsRequestSchema } from 'playwright-core/lib/utilsBundle'; const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({ serverEndpoint: async ({ mcpHeadless }, use, testInfo) => { let cp: ChildProcess | undefined; + let stderr = ''; const userDataDir = testInfo.outputPath('user-data-dir'); await use(async (options?: { args?: string[], noPort?: boolean }) => { if (cp) @@ -43,13 +44,12 @@ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noP ], { stdio: 'pipe', env: inheritAndCleanEnv({ - DEBUG: 'pw:mcp:test', + DEBUG: ['pw:mcp:test', 'pw:browser*'].join(','), DEBUG_COLORS: '0', DEBUG_HIDE_DATE: '1', }), cwd: testInfo.outputPath(), }); - let stderr = ''; const url = await new Promise(resolve => cp!.stderr?.on('data', data => { stderr += data.toString(); const match = stderr.match(/Listening on (http:\/\/.*)/); @@ -60,6 +60,8 @@ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noP return { url: new URL(url), stderr: () => stderr }; }); cp?.kill('SIGTERM'); + if (stderr && testInfo.status !== testInfo.expectedStatus) + await testInfo.attach('mcp-server.stderr', { body: stderr, contentType: 'text/plain' }); }, }); diff --git a/tests/mcp/network.spec.ts b/tests/mcp/network.spec.ts index 7efc31f6f255e..39c07fe2266ce 100644 --- a/tests/mcp/network.spec.ts +++ b/tests/mcp/network.spec.ts @@ -113,13 +113,17 @@ test('browser_network_requests numbers requests with stable indexes', async ({ c }); // Index assignment is stable across calls — the same request keeps the same number. - const response = parseResponse(await client.callTool({ - name: 'browser_network_requests', - arguments: { static: true }, - })); - const lines = response.result.split('\n').filter(Boolean); - expect(lines[0]).toMatch(/^1\. \[GET\] /); - expect(lines).toHaveLength(3); + // browser_navigate resolves on `load`, which can fire before the fire-and-forget + // fetches in the inline script complete; retry until all 3 requests are visible. + await expect(async () => { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + arguments: { static: true }, + })); + const lines = response.result.split('\n').filter(Boolean); + expect(lines[0]).toMatch(/^1\. \[GET\] /); + expect(lines).toHaveLength(3); + }).toPass(); }); test('browser_network_request shows full request and response details', async ({ client, server }) => {