Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/dashboard/src/dashboardModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 12 additions & 1 deletion packages/playwright-core/src/tools/backend/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*/

import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';

import * as z from 'zod';

Expand All @@ -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',

Expand Down Expand Up @@ -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.
Expand Down
22 changes: 19 additions & 3 deletions packages/playwright-core/src/tools/cli-client/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<void>(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<void>(resolve => annotate.on('exit', () => resolve()));
Expand All @@ -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<void>(resolve => child.on('exit', () => resolve()));
Expand Down
90 changes: 72 additions & 18 deletions packages/playwright-core/src/tools/dashboard/dashboardApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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.
Expand Down Expand Up @@ -276,27 +293,54 @@ async function acquireSingleton(options: DashboardOptions): Promise<net.Server>
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<boolean>(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;
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> {
Expand Down
71 changes: 64 additions & 7 deletions tests/mcp/cli-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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)
Expand All @@ -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 ?? '<undefined>'}`,
`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 || '<empty>', 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<string[]>(() => [])) {
if (dir.startsWith('ud-')) {
await fs.promises.rm(path.join(daemonDir, dir), { recursive: true, force: true }).catch(() => {});
Expand Down Expand Up @@ -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'),
};
}

Expand All @@ -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,
}),
});
Expand Down Expand Up @@ -204,6 +250,17 @@ async function loadSnapshot(output: string): Promise<{ snapshot?: string, inline
}
}

async function* walk(dir: string): AsyncGenerator<string> {
const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch<fs.Dirent[]>(() => []);
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory())
yield* walk(full);
else
yield full;
}
}

export const eventsPage = `<!DOCTYPE html>
<html>
<body style="width: 400px; height: 400px; margin: 0; padding: 0;">
Expand Down
Loading
Loading