inspect (#736)
* inspect * fixes * improvements * fixes * fixes * improvements * fix null cdp url * fix rust reader loop * improvements * improvements * fixes
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'node:child_process';
|
||||
import type { Page, Frame } from 'playwright-core';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import type { BrowserManager, ScreencastFrame } from './browser.js';
|
||||
@@ -431,6 +432,10 @@ async function dispatchAction(command: Command, browser: BrowserManager): Promis
|
||||
return await handleReload(command, browser);
|
||||
case 'url':
|
||||
return await handleUrl(command, browser);
|
||||
case 'cdp_url':
|
||||
return handleCdpUrl(command, browser);
|
||||
case 'inspect':
|
||||
return await handleInspect(command, browser);
|
||||
case 'title':
|
||||
return await handleTitle(command, browser);
|
||||
case 'getattribute':
|
||||
@@ -1577,6 +1582,73 @@ async function handleUrl(
|
||||
return successResponse(command.id, { url: page.url() });
|
||||
}
|
||||
|
||||
function handleCdpUrl(command: Command & { action: 'cdp_url' }, browser: BrowserManager): Response {
|
||||
const cdpUrl = browser.getCdpUrl();
|
||||
if (!cdpUrl) {
|
||||
return errorResponse(command.id, 'CDP URL not available (browser may not be launched)');
|
||||
}
|
||||
return successResponse(command.id, { cdpUrl });
|
||||
}
|
||||
|
||||
async function handleInspect(
|
||||
command: Command & { action: 'inspect' },
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const cdpUrl = browser.getCdpUrl();
|
||||
if (!cdpUrl) {
|
||||
return errorResponse(command.id, 'CDP URL not available (browser may not be launched)');
|
||||
}
|
||||
|
||||
// Shut down any existing inspect server so we always target the current page
|
||||
browser.stopInspectServer();
|
||||
|
||||
const stripped = cdpUrl.replace(/^(wss?|https?):\/\//, '');
|
||||
const hostPort = stripped.split('/')[0];
|
||||
|
||||
// Get the target ID so the inspect server can create its own dedicated CDP session
|
||||
const page = browser.getPage();
|
||||
const context = page.context();
|
||||
const tmpCdp = await context.newCDPSession(page);
|
||||
let targetId = '';
|
||||
try {
|
||||
const info: any = await tmpCdp.send('Target.getTargetInfo' as any);
|
||||
targetId = info?.targetInfo?.targetId || '';
|
||||
} catch (err) {
|
||||
console.error('[inspect] getTargetInfo failed:', err);
|
||||
}
|
||||
await tmpCdp.detach();
|
||||
|
||||
if (!targetId) {
|
||||
return errorResponse(command.id, 'Could not determine target ID for active page');
|
||||
}
|
||||
|
||||
const { InspectServer } = await import('./inspect-server.js');
|
||||
const server = new InspectServer({
|
||||
chromeHostPort: hostPort,
|
||||
targetId,
|
||||
chromeWsUrl: cdpUrl,
|
||||
});
|
||||
await server.start();
|
||||
browser.setInspectServer(server);
|
||||
|
||||
const url = `http://127.0.0.1:${server.port}`;
|
||||
openUrlInBrowser(url);
|
||||
return successResponse(command.id, { opened: true, url });
|
||||
}
|
||||
|
||||
function openUrlInBrowser(url: string): void {
|
||||
const platform = process.platform;
|
||||
const cmd =
|
||||
platform === 'darwin'
|
||||
? `open "${url}"`
|
||||
: platform === 'win32'
|
||||
? `start "" "${url}"`
|
||||
: `xdg-open "${url}"`;
|
||||
exec(cmd, (err) => {
|
||||
if (err) console.error('[inspect] Failed to open browser:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTitle(
|
||||
command: Command & { action: 'title' },
|
||||
browser: BrowserManager
|
||||
|
||||
@@ -19,6 +19,7 @@ import os from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import type { LaunchCommand, TraceEvent } from './types.js';
|
||||
import type { InspectServer } from './inspect-server.js';
|
||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||
import { safeHeaderMerge } from './state-utils.js';
|
||||
import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js';
|
||||
@@ -96,6 +97,7 @@ interface PageError {
|
||||
export class BrowserManager {
|
||||
private browser: Browser | null = null;
|
||||
private cdpEndpoint: string | null = null; // stores port number or full URL
|
||||
private resolvedWsUrl: string | null = null;
|
||||
private isPersistentContext: boolean = false;
|
||||
private browserbaseSessionId: string | null = null;
|
||||
private browserbaseApiKey: string | null = null;
|
||||
@@ -119,6 +121,19 @@ export class BrowserManager {
|
||||
private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
|
||||
private downloadPath: string | null = null;
|
||||
private allowedDomains: string[] = [];
|
||||
private inspectServer: InspectServer | null = null;
|
||||
|
||||
stopInspectServer(): void {
|
||||
if (this.inspectServer) {
|
||||
this.inspectServer.stop();
|
||||
this.inspectServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
setInspectServer(server: InspectServer): void {
|
||||
this.stopInspectServer();
|
||||
this.inspectServer = server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the persistent color scheme preference.
|
||||
@@ -167,6 +182,18 @@ export class BrowserManager {
|
||||
return this.browser !== null || this.isPersistentContext;
|
||||
}
|
||||
|
||||
getCdpUrl(): string | null {
|
||||
if (this.resolvedWsUrl) return this.resolvedWsUrl;
|
||||
if (this.cdpEndpoint?.startsWith('ws://') || this.cdpEndpoint?.startsWith('wss://')) {
|
||||
return this.cdpEndpoint;
|
||||
}
|
||||
try {
|
||||
return (this.browser as any)?.wsEndpoint?.() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enhanced snapshot with refs and cache the ref map
|
||||
*/
|
||||
@@ -1402,6 +1429,7 @@ export class BrowserManager {
|
||||
...(this.downloadPath && { downloadsPath: this.downloadPath }),
|
||||
});
|
||||
this.cdpEndpoint = null;
|
||||
this.resolvedWsUrl = null;
|
||||
|
||||
// Check for auto-load state file (supports encrypted files)
|
||||
let storageState:
|
||||
@@ -1557,6 +1585,23 @@ export class BrowserManager {
|
||||
this.browser = browser;
|
||||
this.cdpEndpoint = cdpEndpoint;
|
||||
|
||||
let resolvedWs: string | null = null;
|
||||
try {
|
||||
resolvedWs = (browser as any).wsEndpoint?.() ?? null;
|
||||
} catch (err) {
|
||||
console.error('[inspect] wsEndpoint() failed:', err);
|
||||
}
|
||||
if (!resolvedWs && (cdpUrl.startsWith('http://') || cdpUrl.startsWith('https://'))) {
|
||||
try {
|
||||
const resp = await fetch(`${cdpUrl}/json/version`);
|
||||
const info: any = await resp.json();
|
||||
resolvedWs = info.webSocketDebuggerUrl ?? null;
|
||||
} catch (err) {
|
||||
console.error('[inspect] /json/version fetch failed:', err);
|
||||
}
|
||||
}
|
||||
this.resolvedWsUrl = resolvedWs;
|
||||
|
||||
for (const context of contexts) {
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
@@ -2471,6 +2516,8 @@ export class BrowserManager {
|
||||
* Close the browser and clean up
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.stopInspectServer();
|
||||
|
||||
// Stop recording if active (saves video)
|
||||
if (this.recordingContext) {
|
||||
await this.stopRecording();
|
||||
@@ -2551,6 +2598,7 @@ export class BrowserManager {
|
||||
this.pages = [];
|
||||
this.contexts = [];
|
||||
this.cdpEndpoint = null;
|
||||
this.resolvedWsUrl = null;
|
||||
this.browserbaseSessionId = null;
|
||||
this.browserbaseApiKey = null;
|
||||
this.browserUseSessionId = null;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { injectSessionId, stripSessionId } from './inspect-server.js';
|
||||
|
||||
describe('injectSessionId', () => {
|
||||
it('should inject sessionId into a command', () => {
|
||||
const input = '{"id":1,"method":"DOM.getDocument"}';
|
||||
const result = JSON.parse(injectSessionId(input, 'abc123'));
|
||||
expect(result.sessionId).toBe('abc123');
|
||||
expect(result.method).toBe('DOM.getDocument');
|
||||
expect(result.id).toBe(1);
|
||||
});
|
||||
|
||||
it('should inject sessionId into an empty object', () => {
|
||||
const result = JSON.parse(injectSessionId('{}', 'abc'));
|
||||
expect(result.sessionId).toBe('abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripSessionId', () => {
|
||||
it('should remove sessionId from a message', () => {
|
||||
const input = '{"id":1,"result":{},"sessionId":"abc123"}';
|
||||
const result = JSON.parse(stripSessionId(input));
|
||||
expect(result.sessionId).toBeUndefined();
|
||||
expect(result.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inject then strip roundtrip', () => {
|
||||
it('should return the original message after inject + strip', () => {
|
||||
const input = '{"id":42,"method":"Runtime.evaluate"}';
|
||||
const injected = injectSessionId(input, 'sess1');
|
||||
const stripped = stripSessionId(injected);
|
||||
expect(JSON.parse(stripped)).toEqual(JSON.parse(input));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import http from 'node:http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
|
||||
export interface InspectServerOptions {
|
||||
chromeHostPort: string;
|
||||
targetId: string;
|
||||
chromeWsUrl: string;
|
||||
}
|
||||
|
||||
let nextAttachId = -1000;
|
||||
|
||||
export function injectSessionId(json: string, sessionId: string): string {
|
||||
const msg = JSON.parse(json);
|
||||
msg.sessionId = sessionId;
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
export function stripSessionId(json: string): string {
|
||||
const msg = JSON.parse(json);
|
||||
delete msg.sessionId;
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
// The Node.js path opens its own WebSocket to Chrome rather than sharing
|
||||
// Playwright's internal connection. This avoids interfering with Playwright's
|
||||
// CDP session management. The Rust/native path takes the opposite approach,
|
||||
// sharing the daemon's existing browser-level WebSocket via InspectProxyHandle.
|
||||
export class InspectServer {
|
||||
private httpServer: http.Server;
|
||||
private wss: WebSocketServer;
|
||||
private chromeWs: WebSocket | null = null;
|
||||
private sessions = new Map<string, WebSocket>();
|
||||
private pendingAttaches = new Map<number, (sessionId: string | null) => void>();
|
||||
private _port: number = 0;
|
||||
|
||||
constructor(private options: InspectServerOptions) {
|
||||
this.httpServer = http.createServer(this.handleHttp.bind(this));
|
||||
this.wss = new WebSocketServer({ server: this.httpServer, path: '/ws' });
|
||||
this.wss.on('connection', this.handleWsConnection.bind(this));
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.connectChrome();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.httpServer.listen(0, '127.0.0.1', () => {
|
||||
const addr = this.httpServer.address();
|
||||
if (addr && typeof addr !== 'string') {
|
||||
this._port = addr.port;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
this.httpServer.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const [sessionId, devtoolsWs] of this.sessions) {
|
||||
this.detachSession(sessionId);
|
||||
devtoolsWs.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
this.chromeWs?.close();
|
||||
this.chromeWs = null;
|
||||
this.wss.close();
|
||||
this.httpServer.close();
|
||||
}
|
||||
|
||||
private connectChrome(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.options.chromeWsUrl);
|
||||
ws.on('open', () => {
|
||||
this.chromeWs = ws;
|
||||
resolve();
|
||||
});
|
||||
ws.on('error', (err) => {
|
||||
if (!this.chromeWs) {
|
||||
reject(new Error(`Chrome WebSocket connection failed: ${err.message}`));
|
||||
} else {
|
||||
console.error('[inspect] Chrome WebSocket error:', err.message);
|
||||
for (const devtoolsWs of this.sessions.values()) {
|
||||
devtoolsWs.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
});
|
||||
ws.on('close', () => {
|
||||
this.chromeWs = null;
|
||||
for (const devtoolsWs of this.sessions.values()) {
|
||||
devtoolsWs.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
});
|
||||
ws.on('message', (data) => this.handleChromeMessage(data));
|
||||
});
|
||||
}
|
||||
|
||||
private handleChromeMessage(data: unknown): void {
|
||||
try {
|
||||
const text = String(data);
|
||||
const msg = JSON.parse(text);
|
||||
|
||||
// Check if this is a response to a pending attachToTarget request
|
||||
if (msg.id != null && msg.id < 0) {
|
||||
const resolve = this.pendingAttaches.get(msg.id);
|
||||
if (resolve) {
|
||||
this.pendingAttaches.delete(msg.id);
|
||||
resolve(msg.result?.sessionId ?? null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Route session-scoped messages to the correct DevTools client
|
||||
const sessionId: string | undefined = msg.sessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
const devtoolsWs = this.sessions.get(sessionId);
|
||||
if (!devtoolsWs || devtoolsWs.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
devtoolsWs.send(stripSessionId(text));
|
||||
} catch (err) {
|
||||
console.error('[inspect] Chrome message handling error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
if (req.url === '/' || req.url === '') {
|
||||
const location = `http://${this.options.chromeHostPort}/devtools/devtools_app.html?ws=127.0.0.1:${this._port}/ws`;
|
||||
res.writeHead(302, { Location: location, 'Content-Type': 'text/html' });
|
||||
res.end(`<html><body>Redirecting to <a href="${location}">${location}</a></body></html>`);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
|
||||
private handleWsConnection(devtoolsWs: WebSocket): void {
|
||||
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) {
|
||||
devtoolsWs.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const attachId = nextAttachId--;
|
||||
const attachMsg = JSON.stringify({
|
||||
id: attachId,
|
||||
method: 'Target.attachToTarget',
|
||||
params: { targetId: this.options.targetId, flatten: true },
|
||||
});
|
||||
|
||||
// Track the session ID once attach completes; closed by close/error handlers
|
||||
// that are registered immediately (before the async attach resolves) so
|
||||
// early disconnects still trigger cleanup.
|
||||
let sessionId: string | null = null;
|
||||
|
||||
devtoolsWs.on('close', () => {
|
||||
if (sessionId) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.detachSession(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
devtoolsWs.on('error', () => {
|
||||
if (sessionId) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.detachSession(sessionId);
|
||||
}
|
||||
devtoolsWs.close();
|
||||
});
|
||||
|
||||
const messageBuffer: string[] = [];
|
||||
|
||||
devtoolsWs.on('message', (data) => {
|
||||
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return;
|
||||
const text = String(data);
|
||||
if (!sessionId) {
|
||||
messageBuffer.push(text);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.chromeWs.send(injectSessionId(text, sessionId));
|
||||
} catch (err) {
|
||||
console.error('[inspect] DevTools message forwarding error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
const attachPromise = new Promise<string | null>((resolve) => {
|
||||
this.pendingAttaches.set(attachId, resolve);
|
||||
this.chromeWs!.send(attachMsg);
|
||||
setTimeout(() => {
|
||||
if (this.pendingAttaches.has(attachId)) {
|
||||
this.pendingAttaches.delete(attachId);
|
||||
resolve(null);
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
attachPromise.then((sid) => {
|
||||
if (!sid) {
|
||||
console.error('[inspect] Failed to attach to target');
|
||||
devtoolsWs.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (devtoolsWs.readyState !== WebSocket.OPEN) {
|
||||
this.detachSession(sid);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionId = sid;
|
||||
this.sessions.set(sid, devtoolsWs);
|
||||
|
||||
for (const buffered of messageBuffer) {
|
||||
try {
|
||||
this.chromeWs!.send(injectSessionId(buffered, sid));
|
||||
} catch (err) {
|
||||
console.error('[inspect] DevTools message forwarding error:', err);
|
||||
}
|
||||
}
|
||||
messageBuffer.length = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private detachSession(sessionId: string): void {
|
||||
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return;
|
||||
const detachId = nextAttachId--;
|
||||
const detachMsg = JSON.stringify({
|
||||
id: detachId,
|
||||
method: 'Target.detachFromTarget',
|
||||
params: { sessionId },
|
||||
});
|
||||
try {
|
||||
this.chromeWs.send(detachMsg);
|
||||
} catch (err) {
|
||||
console.error('[inspect] Failed to detach session:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,14 @@ export interface UrlCommand extends BaseCommand {
|
||||
action: 'url';
|
||||
}
|
||||
|
||||
export interface CdpUrlCommand extends BaseCommand {
|
||||
action: 'cdp_url';
|
||||
}
|
||||
|
||||
export interface InspectCommand extends BaseCommand {
|
||||
action: 'inspect';
|
||||
}
|
||||
|
||||
export interface TitleCommand extends BaseCommand {
|
||||
action: 'title';
|
||||
}
|
||||
@@ -946,6 +954,8 @@ export type Command =
|
||||
| ForwardCommand
|
||||
| ReloadCommand
|
||||
| UrlCommand
|
||||
| CdpUrlCommand
|
||||
| InspectCommand
|
||||
| TitleCommand
|
||||
| GetAttributeCommand
|
||||
| GetTextCommand
|
||||
|
||||
Reference in New Issue
Block a user