Skip to content
Merged
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
17 changes: 14 additions & 3 deletions packages/playwright-core/src/tools/cli-client/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,16 @@ export async function program(options?: { embedderVersion?: string}) {
const daemonScript = libPath('entry', 'dashboardApp.js');
const daemonArgs = [
daemonScript,
`--sessionName=${sessionName}`,
`--workspaceDir=${clientInfo.workspaceDir ?? ''}`,
];
// Only pass --sessionName when the user explicitly requested a session
// (via -s/--session or PLAYWRIGHT_CLI_SESSION). Bare `playwright cli show`
// opens the dashboard generically, with no specific session to reveal,
// so the daemon should ack as soon as it's ready rather than waiting for
// a reveal that was never asked for.
const explicit = explicitSessionName(args.session as string);
if (explicit)
daemonArgs.push(`--sessionName=${explicit}`);
if (args.port !== undefined)
daemonArgs.push(`--port=${args.port}`);
if (args.host !== undefined)
Expand Down Expand Up @@ -237,13 +244,17 @@ export async function program(options?: { embedderVersion?: string}) {
}
const timer = setTimeout(() => child.stdin!.destroy(), 60_000);
child.unref();
let daemonPid: number;
try {
await new Promise<void>((resolve, reject) => {
let outLog = '';
child.stdout!.on('data', data => {
outLog += data.toString();
if (outLog.includes('Dashboard is running'))
const match = outLog.match(/Dashboard is running pid=(\d+)/);
if (match) {
daemonPid = Number(match[1]);
resolve();
}
});
child.once('exit', (code, signal) => reject(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY${outLog ? '\n' + outLog : ''}`)));
});
Expand All @@ -253,7 +264,7 @@ export async function program(options?: { embedderVersion?: string}) {
child.stdin!.destroy();
child.stdout!.destroy();
}
output.show(sessionName, child.pid);
output.show(sessionName, daemonPid!);
return;
}
default: {
Expand Down
150 changes: 78 additions & 72 deletions packages/playwright-core/src/tools/dashboard/dashboardApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import http from 'http';
import { HttpServer } from '@utils/httpServer';
import { makeSocketPath } from '@utils/fileUtils';
import { gracefullyProcessExitDoNotHang } from '@utils/processLauncher';
import { ManualPromise } from '@isomorphic/manualPromise';
import { libPath } from '../../package';
import { playwright } from '../../inprocess';
import { findChromiumChannelBestEffort, registryDirectory } from '../../server/registry/index';
Expand All @@ -41,9 +42,8 @@ declare const __PW_HMR__: boolean;

type DashboardServer = {
url: string;
reveal: (options: DashboardOptions) => void;
triggerAnnotate: () => void;
registerAnnotateWaiter: (socket: net.Socket) => void;
reveal: (options: DashboardOptions) => Promise<void>;
triggerAnnotate: (socket: net.Socket) => Promise<void>;
close: () => Promise<void>;
};

Expand All @@ -52,8 +52,7 @@ async function startDashboardServer(provider: SessionProvider, options: Dashboar
const httpServer = new HttpServer(dashboardDir);

const connections = new Set<DashboardConnection>();
let currentReveal: DashboardOptions = options;
let pendingAnnotate = false;
let connectionLanded = new ManualPromise<void>();
const waitingSockets = new Set<net.Socket>();

const submitAnnotation = (frames: SubmittedAnnotationFrame[], feedback: string) => {
Expand All @@ -70,15 +69,12 @@ async function startDashboardServer(provider: SessionProvider, options: Dashboar
httpServer.createWebSocket(() => {
let connection: DashboardConnection;
// eslint-disable-next-line prefer-const
connection = new DashboardConnection(provider, () => connections.delete(connection), () => {
if (currentReveal.pageId)
connection.revealPage(currentReveal.pageId);
else if (currentReveal.sessionName)
connection.revealSession(currentReveal.sessionName, currentReveal.workspaceDir);
if (pendingAnnotate) {
pendingAnnotate = false;
connection.emitAnnotate();
}
connection = new DashboardConnection(provider, () => {
connections.delete(connection);
if (connections.size === 0)
connectionLanded = new ManualPromise<void>();
}, () => {
connectionLanded.resolve();
}, submitAnnotation);
connections.add(connection);
return connection;
Expand All @@ -102,48 +98,38 @@ async function startDashboardServer(provider: SessionProvider, options: Dashboar
attachDashboardStaticServer(httpServer, dashboardDir);
await httpServer.start({ port: options.port, host: options.host });

const reveal = (next: DashboardOptions) => {
currentReveal = next;
if (next.pageId) {
for (const connection of connections)
connection.revealPage(next.pageId);
return;
}
if (!next.sessionName)
return;
for (const connection of connections)
connection.revealSession(next.sessionName, next.workspaceDir);
};

const triggerAnnotate = () => {
if (connections.size === 0) {
pendingAnnotate = true;
return;
}
for (const connection of connections)
connection.emitAnnotate();
};

const notifyAnnotateEnded = () => {
pendingAnnotate = false;
for (const connection of connections)
connection.emitCancelAnnotate();
const reveal = async (next: DashboardOptions): Promise<void> => {
await connectionLanded;
await Promise.all([...connections].map(async c => {
if (next.pageId)
await c.revealPage(next.pageId);
else if (next.sessionName)
await c.revealSession(next.sessionName, next.workspaceDir);
}));
};

const registerAnnotateWaiter = (socket: net.Socket) => {
const triggerAnnotate = async (socket: net.Socket) => {
waitingSockets.add(socket);
const cleanup = () => {
if (!waitingSockets.delete(socket))
return;
if (waitingSockets.size === 0)
notifyAnnotateEnded();
if (waitingSockets.size === 0) {
for (const connection of connections)
connection.emitCancelAnnotate();
}
};
socket.on('close', cleanup);
socket.on('error', cleanup);

await connectionLanded;
if (waitingSockets.size === 0)
return;
for (const connection of connections)
connection.emitAnnotate();
};

const close = () => httpServer.stop();
return { url: httpServer.urlPrefix('human-readable'), reveal, triggerAnnotate, registerAnnotateWaiter, close };
return { url: httpServer.urlPrefix('human-readable'), reveal, triggerAnnotate, close };
}

function attachDashboardStaticServer(httpServer: HttpServer, dashboardDir: string) {
Expand All @@ -168,6 +154,7 @@ async function attachDashboardDevServer(httpServer: HttpServer) {

async function innerOpenDashboardApp(options: DashboardOptions): Promise<{ page: api.Page; server: DashboardServer }> {
const server = await startDashboardServer(new RegistrySessionProvider(), options);
void server.reveal(options).catch(() => {});
const { page } = await launchApp('dashboard', { onClose: () => gracefullyProcessExitDoNotHang(0) });
await page.goto(server.url);
return { page, server };
Expand Down Expand Up @@ -266,25 +253,38 @@ function parseOpenArgs(): DashboardOptions {
};
}

async function acquireSingleton(options: DashboardOptions): Promise<net.Server> {
type AcquireResult =
| { role: 'winner', server: net.Server }
| { role: 'loser', daemonPid: number };

async function acquireSingleton(options: DashboardOptions): Promise<AcquireResult> {
const socketPath = dashboardSocketPath();
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.listen(socketPath, () => resolve({ role: 'winner', server }));
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code !== 'EADDRINUSE')
if (err.code !== 'EADDRINUSE' && err.code !== 'EEXIST')
return reject(err);
let ackBuffer = '';
const client = net.connect(socketPath, () => {
client.write(JSON.stringify(options) + '\n');
reject(new Error('already running'));
});
client.on('data', chunk => { ackBuffer += chunk.toString(); });
client.on('end', () => {
try {
const { pid } = JSON.parse(ackBuffer.trim());
resolve({ role: 'loser', daemonPid: pid });
} catch (e) {
reject(e);
}
});
client.on('error', () => {
if (process.platform !== 'win32')
fs.unlinkSync(socketPath);
server.listen(socketPath, () => resolve(server));
server.listen(socketPath, () => resolve({ role: 'winner', server }));
});
});
});
Expand All @@ -305,9 +305,10 @@ export async function openDashboardApp() {
console.error('Unhandled promise rejection:', error);
});
if (options.port !== undefined) {
const { url } = await startDashboardServer(new RegistrySessionProvider(), options);
const server = await startDashboardServer(new RegistrySessionProvider(), options);
void server.reveal(options).catch(() => {});
// eslint-disable-next-line no-console
console.log(`Listening on ${url}`);
console.log(`Listening on ${server.url}`);
// eslint-disable-next-line no-restricted-properties
await new Promise(f => process.stdout.write('', f)); // Make sure stdout is flushed.
selfDestructOnParentGone();
Expand All @@ -316,24 +317,23 @@ export async function openDashboardApp() {
// Self-destruct if the parent CLI dies before we signal READY. Unregistered
// before we signal so the daemon outlives the parent.
const stopSelfDestruct = selfDestructOnParentGone();
let server: net.Server;
try {
server = await acquireSingleton(options);
} catch {
const acquired = await acquireSingleton(options);
if (acquired.role === 'loser') {
// Another daemon is already running, signal success.
stopSelfDestruct();
// eslint-disable-next-line no-console
console.log('Dashboard is running');
console.log(`Dashboard is running pid=${acquired.daemonPid}`);
// eslint-disable-next-line no-restricted-properties
await new Promise(f => process.stdout.write('', f)); // Make sure stdout is flushed.
return;
}
const { server } = acquired;
process.on('exit', () => server.close());
try {
await startApp(server, options);
stopSelfDestruct();
// eslint-disable-next-line no-console
console.log('Dashboard is running');
console.log(`Dashboard is running pid=${process.pid}`);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
Expand All @@ -345,7 +345,7 @@ async function startApp(server: net.Server, options: DashboardOptions) {
const statePromise = innerOpenDashboardApp(options);
server.on('connection', socket => {
let buffer = '';
socket.on('data', data => {
socket.on('data', async data => {
buffer += data.toString();
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex === -1)
Expand All @@ -362,21 +362,27 @@ async function startApp(server: net.Server, options: DashboardOptions) {
socket.end();
return;
}
void statePromise.then(({ page, server: dashboard }) => {
if (parsed.annotate) {
page?.bringToFront().catch(() => {});
dashboard.reveal(parsed);
dashboard.triggerAnnotate();
dashboard.registerAnnotateWaiter(socket);
} else if (parsed.kill) {
socket.end();
gracefullyProcessExitDoNotHang(0);
} else {
page?.bringToFront().catch(() => {});
dashboard.reveal(parsed);
socket.end();
const { page, server: dashboard } = await statePromise;
if (parsed.annotate) {
try {
await page?.bringToFront();
await dashboard.reveal(parsed);
await dashboard.triggerAnnotate(socket);
} catch (e) {
socket.end(e);
}
});
} else if (parsed.kill) {
socket.end();
gracefullyProcessExitDoNotHang(0);
} else {
try {
await page?.bringToFront();
await dashboard.reveal(parsed);
socket.end(JSON.stringify({ pid: process.pid }) + '\n');
} catch (e) {
socket.end(e);
}
}
});
});
await statePromise;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import fs from 'fs';
import crypto from 'crypto';
import { execFile } from 'child_process';
import { Disposable } from '@isomorphic/disposable';
import { ManualPromise } from '@isomorphic/manualPromise';
import { eventsHelper } from '@utils/eventsHelper';
import { createClientInfo } from '../cli-client/registry';

Expand All @@ -42,7 +43,7 @@ export class DashboardConnection implements Transport {
private _onAnnotationSubmit?: (frames: SubmittedAnnotationFrame[], feedback: string) => void;
private _pushTabsScheduled = false;
private _visible = true;
private _pendingReveal: { sessionName?: string; workspaceDir?: string; pageId?: string } | undefined;
private _pendingReveal: { sessionName?: string; workspaceDir?: string; pageId?: string; done: ManualPromise<void> } | undefined;
private _annotateWaitingForAttach = false;

_recordingDir: string;
Expand Down Expand Up @@ -80,6 +81,9 @@ export class DashboardConnection implements Transport {
this._provider.dispose();
this._attachedPage?.dispose();
this._attachedPage = undefined;
// Reject any in-flight reveal so callers awaiting it don't hang.
this._pendingReveal?.done.reject(new Error('Dashboard connection closed'));
this._pendingReveal = undefined;
for (const stream of this._streams.values()) {
void stream.handle.close()
.catch(() => {})
Expand Down Expand Up @@ -137,14 +141,29 @@ export class DashboardConnection implements Transport {
await this._attachedPage?.setScreencastActive(params.visible);
}

revealSession(sessionName: string, workspaceDir?: string) {
this._pendingReveal = { sessionName, workspaceDir };
revealSession(sessionName: string, workspaceDir?: string): Promise<void> {
const existing = this._pendingReveal;
if (existing
&& existing.pageId === undefined
&& existing.sessionName === sessionName
&& existing.workspaceDir === workspaceDir)
return existing.done;
existing?.done.reject(new Error('Reveal superseded'));
const done = new ManualPromise<void>();
this._pendingReveal = { sessionName, workspaceDir, done };
void this._tryRevealPending();
return done;
}

revealPage(pageId: string) {
this._pendingReveal = { pageId };
revealPage(pageId: string): Promise<void> {
const existing = this._pendingReveal;
if (existing && existing.pageId === pageId)
return existing.done;
existing?.done.reject(new Error('Reveal superseded'));
const done = new ManualPromise<void>();
this._pendingReveal = { pageId, done };
void this._tryRevealPending();
return done;
}

private async _tryRevealPending() {
Expand All @@ -163,8 +182,14 @@ export class DashboardConnection implements Transport {
if (!page)
return;
this._pendingReveal = undefined;
await this._switchAttachedTo(page);
this._pushTabs();
try {
await this._switchAttachedTo(page);
this._pushTabs();
pending.done.resolve();
} catch (e) {
pending.done.reject(e instanceof Error ? e : new Error(String(e)));
throw e;
}
}

async submitAnnotation(params: { frames: SubmittedAnnotationFrame[]; feedback: string }) {
Expand Down
Loading
Loading