Skip to content
Closed
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
11 changes: 7 additions & 4 deletions packages/devtools/src/devtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,13 +240,15 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
role='tab'
aria-selected={tab.selected}
title={tab.url || ''}
aria-disabled={!channel}
onClick={() => channel?.selectTab({ pageId: tab.pageId })}
>
<span className='tab-favicon' aria-hidden='true'>{tabFavicon(tab.url)}</span>
<span className='tab-label'>{tab.title || 'New Tab'}</span>
<button
className='tab-close'
title='Close tab'
disabled={!channel}
onClick={e => {
e.stopPropagation();
channel?.closeTab({ pageId: tab.pageId });
Expand All @@ -257,7 +259,7 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
</div>
))}
</div>
<button id='new-tab-btn' className='new-tab-btn' title='New Tab' onClick={() => channel?.newTab()}>
<button id='new-tab-btn' className='new-tab-btn' title='New Tab' disabled={!channel} onClick={() => channel?.newTab()}>
<PlusIcon />
</button>
<div className='interactive-controls'>
Expand Down Expand Up @@ -292,13 +294,13 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {

{/* Toolbar */}
<div ref={toolbarRef} className='toolbar'>
<button className='nav-btn' title='Back' onClick={() => channel?.back()}>
<button className='nav-btn' title='Back' disabled={!channel} onClick={() => channel?.back()}>
<ChevronLeftIcon />
</button>
<button className='nav-btn' title='Forward' onClick={() => channel?.forward()}>
<button className='nav-btn' title='Forward' disabled={!channel} onClick={() => channel?.forward()}>
<ChevronRightIcon />
</button>
<button className='nav-btn' title='Reload' onClick={() => channel?.reload()}>
<button className='nav-btn' title='Reload' disabled={!channel} onClick={() => channel?.reload()}>
<ReloadIcon />
</button>
<input
Expand All @@ -309,6 +311,7 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
spellCheck={false}
autoComplete='off'
value={url}
disabled={!channel}
onChange={e => setUrl(e.target.value)}
onKeyDown={onOmniboxKeyDown}
onFocus={e => e.target.select()}
Expand Down
4 changes: 2 additions & 2 deletions packages/devtools/src/grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ const SessionChip: React.FC<{ sessionFile: SessionFile; canConnect: boolean; vis
}, [channel]);

const chipTitle = selectedTab ? `[${config.name}] ${selectedTab.url} \u2014 ${selectedTab.title}` : config.name;
const clickable = canConnect && wsUrl !== null;
const clickable = canConnect && !!wsUrl;

return (
<a className={'session-chip' + (canConnect ? '' : ' disconnected') + (wsUrl === null ? ' not-supported' : '')} href={clickable ? href : undefined} title={chipTitle} onClick={e => {
<a className={'session-chip' + (canConnect ? '' : ' disconnected') + (wsUrl === null ? ' not-supported' : '')} href={clickable ? href : undefined} aria-disabled={!clickable} title={chipTitle} onClick={e => {
e.preventDefault();
if (clickable)
navigate(href);
Expand Down
2 changes: 1 addition & 1 deletion packages/devtools/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const App: React.FC = () => {
};
}, [model]);

React.useEffect(() => {
React.useLayoutEffect(() => {
const onPopState = () => setSocketPath(parseHash());
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
Expand Down
68 changes: 46 additions & 22 deletions packages/playwright/src/cli/client/devtoolsApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import net from 'net';

import { chromium } from 'playwright-core';
import { gracefullyProcessExitDoNotHang, HttpServer } from 'playwright-core/lib/utils';
import { gracefullyProcessExitDoNotHang, HttpServer, isUnderTest } from 'playwright-core/lib/utils';
import { findChromiumChannelBestEffort, registryDirectory } from 'playwright-core/lib/server/registry/index';

import { createClientInfo, Registry } from './registry';
Expand Down Expand Up @@ -116,7 +117,7 @@ async function handleApiRequest(clientInfo: ClientInfo, request: http.IncomingMe
response.end(JSON.stringify({ error: 'Not found' }));
}

async function openDevToolsApp(): Promise<Page> {
async function openDevToolsApp(): Promise<{ page?: Page, url: string }> {
const httpServer = new HttpServer();
const libDir = require.resolve('playwright-core/package.json');
const devtoolsDir = path.join(path.dirname(libDir), 'lib/vite/devtools');
Expand All @@ -140,10 +141,12 @@ async function openDevToolsApp(): Promise<Page> {
});
await httpServer.start();
const url = httpServer.urlPrefix('human-readable');
if (isUnderTest())
return { url };

const { page } = await launchApp('devtools');
await page.goto(url);
return page;
return { page, url };
}

async function launchApp(appName: string) {
Expand Down Expand Up @@ -212,13 +215,15 @@ function socketsDirectory() {
}

function devtoolsSocketPath() {
const suffix = process.env.PLAYWRIGHT_DAEMON_SESSION_DIR ? crypto.createHash('sha256').update(process.env.PLAYWRIGHT_DAEMON_SESSION_DIR).digest('hex').substring(0, 8) : '';
return process.platform === 'win32'
? `\\\\.\\pipe\\playwright-devtools-${process.env.USERNAME || 'default'}`
: path.join(socketsDirectory(), 'devtools.sock');
? `\\\\.\\pipe\\playwright-devtools-${process.env.USERNAME || 'default'}${suffix}`
: path.join(socketsDirectory(), `devtools${suffix}.sock`);
}

async function acquireSingleton(): Promise<net.Server> {
async function acquireSingleton(): Promise<net.Server | string> {
const socketPath = devtoolsSocketPath();
await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });

return await new Promise((resolve, reject) => {
const server = net.createServer();
Expand All @@ -228,8 +233,11 @@ async function acquireSingleton(): Promise<net.Server> {
return reject(err);
const client = net.connect(socketPath, () => {
client.write('bringToFront');
client.end();
reject(new Error('already running'));
});
let data = '';
client.on('data', chunk => { data += chunk.toString(); });
client.on('end', () => {
resolve(data);
});
client.on('error', () => {
if (process.platform !== 'win32')
Expand All @@ -241,20 +249,36 @@ async function acquireSingleton(): Promise<net.Server> {
}

async function main() {
let server: net.Server | undefined;
process.on('exit', () => server?.close());
try {
server = await acquireSingleton();
} catch {
return;
}
const page = await openDevToolsApp();
server.on('connection', socket => {
socket.on('data', data => {
if (data.toString() === 'bringToFront')
page?.bringToFront().catch(() => {});
const result = await acquireSingleton();
let status = typeof result === 'string' ? result : 'Starting';

if (typeof result !== 'string') {
const server = result;
process.on('exit', () => server.close());

let page: Page | undefined = undefined;
server.on('connection', socket => {
socket.on('data', data => {
if (data.toString() === 'bringToFront')
page?.bringToFront().catch(() => {});
socket.end(status);
});
});
});

const app = await openDevToolsApp();
page = app.page;
status = `DevTools pid ${process.pid} listening on ${app.url}`;
}


// eslint-disable-next-line no-console
console.log(status);
// eslint-disable-next-line no-console
console.log('<EOF>');
}

void main();
void main().catch(e => {
// eslint-disable-next-line no-console
console.log(e);
throw e;
});
26 changes: 23 additions & 3 deletions packages/playwright/src/cli/client/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,29 @@ export async function program(options?: { embedderVersion?: string}) {
const daemonScript = path.join(__dirname, 'devtoolsApp.js');
const child = spawn(process.execPath, [daemonScript], {
detached: true,
stdio: 'ignore',
stdio: ['ignore', 'pipe', 'ignore'],
});

const status = await new Promise<string>((resolve, reject) => {
let outLog = '';
child.stdout!.on('data', (data: Buffer) => {
outLog += data.toString();
if (outLog.includes('<EOF>'))
resolve(outLog.split('<EOF>')[0]);
});
child.on('close', code => {
process.exitCode = code || 1;
reject(new Error(outLog));
});
});

child.stdout!.destroy();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure we want to do this.

child.unref();

// TODO: update check-deps to allow importing isUnderTest()
if (process.env.PWTEST_UNDER_TEST)
console.log(status);

return;
}
default: {
Expand Down Expand Up @@ -347,7 +367,7 @@ async function killAllDaemons(): Promise<void> {
const result = execSync(
`powershell -NoProfile -NonInteractive -Command `
+ `"Get-CimInstance Win32_Process `
+ `| Where-Object { $_.CommandLine -like '*-server*' -and $_.CommandLine -like '*--daemon-session*' } `
+ `| Where-Object { ($_.CommandLine -like '*-server*' -and $_.CommandLine -like '*--daemon-session*') -or $_.CommandLine -like '*devtoolsApp.js*' } `
+ `| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $_.ProcessId }"`,
{ encoding: 'utf-8' }
);
Expand All @@ -361,7 +381,7 @@ async function killAllDaemons(): Promise<void> {
const result = execSync('ps aux', { encoding: 'utf-8' });
const lines = result.split('\n');
for (const line of lines) {
if ((line.includes('-server')) && line.includes('--daemon-session')) {
if ((line.includes('-server') && line.includes('--daemon-session')) || line.includes('devtoolsApp.js')) {
const parts = line.trim().split(/\s+/);
const pid = parts[1];
if (pid && /^\d+$/.test(pid)) {
Expand Down
27 changes: 17 additions & 10 deletions tests/mcp/cli-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,23 @@ export const test = baseTest.extend<{
}>;
}>({
cli: async ({ mcpBrowser, mcpHeadless, childProcess }, use) => {
const sessions: { name: string, pid: number }[] = [];
const daemons: { session?: string, pid: number }[] = [];
await fs.promises.mkdir(test.info().outputPath('.playwright'), { recursive: true });

await use(async (...args: string[]) => {
const cliArgs = args.filter(arg => typeof arg === 'string');
const cliOptions = args.findLast(arg => typeof arg === 'object') || {};
return await runCli(childProcess, cliArgs, cliOptions, { mcpBrowser, mcpHeadless }, sessions);
return await runCli(childProcess, cliArgs, cliOptions, { mcpBrowser, mcpHeadless }, daemons);
});

for (const session of sessions) {
await runCli(childProcess, ['--session=' + session.name, 'close'], {}, { mcpBrowser, mcpHeadless }, []).catch(e => {
if (!e.message.includes('is not running'))
throw e;
});
killProcessGroup(session.pid);
for (const daemon of daemons) {
if (daemon.session) {
await runCli(childProcess, ['--session=' + daemon.session, 'close'], {}, { mcpBrowser, mcpHeadless }, []).catch(e => {
if (!e.message.includes('is not running'))
throw e;
});
}
killProcessGroup(daemon.pid);
}

const daemonDir = path.join(test.info().outputDir, 'daemon');
Expand All @@ -57,7 +59,7 @@ export const test = baseTest.extend<{
},
});

async function runCli(childProcess: CommonFixtures['childProcess'], args: string[], cliOptions: { cwd?: string, env?: Record<string, string> }, options: { mcpBrowser: string, mcpHeadless: boolean }, sessions: { name: string, pid: number }[]) {
async function runCli(childProcess: CommonFixtures['childProcess'], args: string[], cliOptions: { cwd?: string, env?: Record<string, string> }, options: { mcpBrowser: string, mcpHeadless: boolean }, daemons: { session?: string, pid: number }[]) {
const stepTitle = `cli ${args.join(' ')}`;
return await test.step(stepTitle, async () => {
const testInfo = test.info();
Expand Down Expand Up @@ -86,7 +88,12 @@ async function runCli(childProcess: CommonFixtures['childProcess'], args: string
const matches = cli.stdout.includes('### Browser') ? cli.stdout.match(/Browser `(.+)` opened with pid (\d+)\./) : undefined;
const [, sessionName, pid] = matches ?? [];
if (sessionName && pid)
sessions.push({ name: sessionName, pid: +pid });
daemons.push({ session: sessionName, pid: +pid });

const devtoolsMatch = cli.stdout.match(/DevTools pid (\d+) listening/);
if (devtoolsMatch)
daemons.push({ pid: +devtoolsMatch[1] });

return {
exitCode: await cli.exitCode,
output: cli.stdout.trim(),
Expand Down
Loading
Loading