better handling of events. added tab search via targetId

This commit is contained in:
Tommy D. Rossi
2025-11-24 19:23:57 +01:00
parent b6ecd3b3d9
commit 48089d1f40
3 changed files with 269 additions and 238 deletions
-1
View File
@@ -18,7 +18,6 @@
"scripts": { "scripts": {
"build": "tsc --project . && vite build --config vite.config.mts", "build": "tsc --project . && vite build --config vite.config.mts",
"watch": "vite build --watch --config vite.config.mts", "watch": "vite build --watch --config vite.config.mts",
"test": "playwright test",
"clean": "rm -rf dist" "clean": "rm -rf dist"
}, },
"devDependencies": { "devDependencies": {
+268 -231
View File
@@ -14,10 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types'; import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types'
import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/extension/protocol'; import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/extension/protocol'
let activeConnection: RelayConnection | undefined; let activeConnection: RelayConnection | undefined
export const logger = { export const logger = {
log: (...args: any[]) => logToRemote('log', args), log: (...args: any[]) => logToRemote('log', args),
@@ -25,132 +25,135 @@ export const logger = {
info: (...args: any[]) => logToRemote('info', args), info: (...args: any[]) => logToRemote('info', args),
warn: (...args: any[]) => logToRemote('warn', args), warn: (...args: any[]) => logToRemote('warn', args),
error: (...args: any[]) => logToRemote('error', args), error: (...args: any[]) => logToRemote('error', args),
}; }
function logToRemote(level: 'log' | 'debug' | 'info' | 'warn' | 'error', args: any[]) { function logToRemote(level: 'log' | 'debug' | 'info' | 'warn' | 'error', args: any[]) {
// Always log to local console // Always log to local console
console[level](...args); console[level](...args)
if (activeConnection) { if (activeConnection) {
activeConnection.sendLog(level, args); activeConnection.sendLog(level, args)
} }
} }
function safeSerialize(arg: any): string { function safeSerialize(arg: any): string {
if (arg === undefined) return 'undefined'; if (arg === undefined) return 'undefined'
if (arg === null) return 'null'; if (arg === null) return 'null'
if (typeof arg === 'function') return `[Function: ${arg.name || 'anonymous'}]`; if (typeof arg === 'function') return `[Function: ${arg.name || 'anonymous'}]`
if (typeof arg === 'symbol') return String(arg); if (typeof arg === 'symbol') return String(arg)
if (arg instanceof Error) { if (arg instanceof Error) {
return arg.stack || arg.message || String(arg); return arg.stack || arg.message || String(arg)
} }
if (typeof arg === 'object') { if (typeof arg === 'object') {
try { try {
const seen = new WeakSet(); const seen = new WeakSet()
return JSON.stringify(arg, (key, value) => { return JSON.stringify(arg, (key, value) => {
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]'; if (seen.has(value)) return '[Circular]'
seen.add(value); seen.add(value)
if (value instanceof Map) { if (value instanceof Map) {
return { return {
dataType: 'Map', dataType: 'Map',
value: Array.from(value.entries()) value: Array.from(value.entries()),
}; }
} }
if (value instanceof Set) { if (value instanceof Set) {
return { return {
dataType: 'Set', dataType: 'Set',
value: Array.from(value.values()) value: Array.from(value.values()),
}; }
} }
} }
return value; return value
}); })
} catch (e) { } catch (e) {
return String(arg); return String(arg)
} }
} }
return String(arg); return String(arg)
} }
interface AttachedTab { interface AttachedTab {
debuggee: chrome.debugger.Debuggee; debuggee: chrome.debugger.Debuggee
targetId: string; targetId: string
sessionId: string; sessionId: string
targetInfo: Protocol.Target.TargetInfo; targetInfo: Protocol.Target.TargetInfo
} }
export class RelayConnection { export class RelayConnection {
private _attachedTabs: Map<number, AttachedTab> = new Map(); private _attachedTabs: Map<number, AttachedTab> = new Map()
private _nextSessionId: number = 1; private _childSessions: Map<string, number> = new Map()
private _ws: WebSocket; private _nextSessionId: number = 1
private _closed = false; private _ws: WebSocket
private _onCloseCallback?: (reason: string, code: number) => void; private _closed = false
private _onCloseCallback?: (reason: string, code: number) => void
private _onTabDetachedCallback?: (tabId: number, reason: `${chrome.debugger.DetachReason}`) => void private _onTabDetachedCallback?: (tabId: number, reason: `${chrome.debugger.DetachReason}`) => void
private _onTabAttachedCallback?: (tabId: number, targetId: string) => void private _onTabAttachedCallback?: (tabId: number, targetId: string) => void
constructor({ ws, onClose, onTabDetached, onTabAttached }: { constructor({
ws: WebSocket; ws,
onClose?: (reason: string, code: number) => void; onClose,
onTabDetached?: (tabId: number, reason: `${chrome.debugger.DetachReason}`) => void; onTabDetached,
onTabAttached?: (tabId: number, targetId: string) => void; onTabAttached,
}: {
ws: WebSocket
onClose?: (reason: string, code: number) => void
onTabDetached?: (tabId: number, reason: `${chrome.debugger.DetachReason}`) => void
onTabAttached?: (tabId: number, targetId: string) => void
}) { }) {
this._ws = ws; this._ws = ws
this._onCloseCallback = onClose; this._onCloseCallback = onClose
this._onTabDetachedCallback = onTabDetached; this._onTabDetachedCallback = onTabDetached
this._onTabAttachedCallback = onTabAttached; this._onTabAttachedCallback = onTabAttached
this._ws.onmessage = async (event: MessageEvent) => { this._ws.onmessage = async (event: MessageEvent) => {
let message: ExtensionCommandMessage; let message: ExtensionCommandMessage
try { try {
message = JSON.parse(event.data); message = JSON.parse(event.data)
} catch (error: any) { } catch (error: any) {
logger.debug('Error parsing message:', error); logger.debug('Error parsing message:', error)
this._sendMessage({ this._sendMessage({
error: { error: {
code: -32700, code: -32700,
message: `Error parsing message: ${error.message}`, message: `Error parsing message: ${error.message}`,
}, },
}); })
return; return
} }
logger.debug('Received message:', message); logger.debug('Received message:', message)
const response: ExtensionResponseMessage = { const response: ExtensionResponseMessage = {
id: message.id, id: message.id,
};
try {
response.result = await this._handleCommand(message);
} catch (error: any) {
logger.debug('Error handling command:', error);
response.error = error.message;
} }
logger.debug('Sending response:', response); try {
this._sendMessage(response); response.result = await this._handleCommand(message)
}; } catch (error: any) {
logger.debug('Error handling command:', error)
response.error = error.message
}
logger.debug('Sending response:', response)
this._sendMessage(response)
}
this._ws.onclose = (event: CloseEvent) => { this._ws.onclose = (event: CloseEvent) => {
logger.debug('WebSocket onclose event:', { logger.debug('WebSocket onclose event:', {
code: event.code, code: event.code,
reason: event.reason, reason: event.reason,
wasClean: event.wasClean wasClean: event.wasClean,
}); })
this._onClose(event.reason, event.code); this._onClose(event.reason, event.code)
}; }
this._ws.onerror = (event: Event) => { this._ws.onerror = (event: Event) => {
logger.debug('WebSocket onerror event:', event); logger.debug('WebSocket onerror event:', event)
}; }
chrome.debugger.onEvent.addListener(this._onDebuggerEvent); chrome.debugger.onEvent.addListener(this._onDebuggerEvent)
chrome.debugger.onDetach.addListener(this._onDebuggerDetach); chrome.debugger.onDetach.addListener(this._onDebuggerDetach)
logger.debug('RelayConnection created, WebSocket readyState:', this._ws.readyState); logger.debug('RelayConnection created, WebSocket readyState:', this._ws.readyState)
activeConnection = this; activeConnection = this
} }
sendLog(level: string, args: any[]) { sendLog(level: string, args: any[]) {
@@ -158,43 +161,43 @@ export class RelayConnection {
method: 'log', method: 'log',
params: { params: {
level, level,
args: args.map(arg => safeSerialize(arg)) args: args.map((arg) => safeSerialize(arg)),
} },
}); })
} }
async attachTab(tabId: number): Promise<Protocol.Target.TargetInfo> { async attachTab(tabId: number): Promise<Protocol.Target.TargetInfo> {
const debuggee = { tabId }; const debuggee = { tabId }
logger.debug('Attaching debugger to tab:', tabId, 'WebSocket state:', this._ws.readyState); logger.debug('Attaching debugger to tab:', tabId, 'WebSocket state:', this._ws.readyState)
try { try {
await chrome.debugger.attach(debuggee, '1.3'); await chrome.debugger.attach(debuggee, '1.3')
logger.debug('Debugger attached successfully to tab:', tabId); logger.debug('Debugger attached successfully to tab:', tabId)
} catch (error: any) { } catch (error: any) {
logger.debug('ERROR attaching debugger to tab:', tabId, error); logger.debug('ERROR attaching debugger to tab:', tabId, error)
throw error; throw error
} }
logger.debug('Sending Target.getTargetInfo command for tab:', tabId); logger.debug('Sending Target.getTargetInfo command for tab:', tabId)
const result = await chrome.debugger.sendCommand( const result = (await chrome.debugger.sendCommand(
debuggee, debuggee,
'Target.getTargetInfo' 'Target.getTargetInfo',
) as Protocol.Target.GetTargetInfoResponse; )) as Protocol.Target.GetTargetInfoResponse
logger.debug('Received targetInfo for tab:', tabId, result.targetInfo); logger.debug('Received targetInfo for tab:', tabId, result.targetInfo)
const targetInfo = result.targetInfo; const targetInfo = result.targetInfo
const sessionId = `pw-tab-${this._nextSessionId++}`; const sessionId = `pw-tab-${this._nextSessionId++}`
this._attachedTabs.set(tabId, { this._attachedTabs.set(tabId, {
debuggee, debuggee,
targetId: targetInfo.targetId, targetId: targetInfo.targetId,
sessionId, sessionId,
targetInfo, targetInfo,
}); })
logger.debug('Sending Target.attachedToTarget event, WebSocket state:', this._ws.readyState); logger.debug('Sending Target.attachedToTarget event, WebSocket state:', this._ws.readyState)
this._sendMessage({ this._sendMessage({
method: 'forwardCDPEvent', method: 'forwardCDPEvent',
params: { params: {
@@ -203,30 +206,30 @@ export class RelayConnection {
sessionId, sessionId,
targetInfo: { targetInfo: {
...targetInfo, ...targetInfo,
attached: true attached: true,
}, },
waitingForDebugger: false waitingForDebugger: false,
} },
} },
}); })
logger.debug('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId); logger.debug('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId)
this._onTabAttachedCallback?.(tabId, targetInfo.targetId); this._onTabAttachedCallback?.(tabId, targetInfo.targetId)
return targetInfo; return targetInfo
} }
detachTab(tabId: number): void { detachTab(tabId: number): void {
this._cleanupTab(tabId, true); this._cleanupTab(tabId, true)
} }
private _cleanupTab(tabId: number, shouldDetachDebugger: boolean): void { private _cleanupTab(tabId: number, shouldDetachDebugger: boolean): void {
const tab = this._attachedTabs.get(tabId); const tab = this._attachedTabs.get(tabId)
if (!tab) { if (!tab) {
logger.debug('cleanupTab: tab not found in map:', tabId); logger.debug('cleanupTab: tab not found in map:', tabId)
return; return
} }
logger.debug('Cleaning up tab:', tabId, 'sessionId:', tab.sessionId, 'shouldDetach:', shouldDetachDebugger); logger.debug('Cleaning up tab:', tabId, 'sessionId:', tab.sessionId, 'shouldDetach:', shouldDetachDebugger)
this._sendMessage({ this._sendMessage({
method: 'forwardCDPEvent', method: 'forwardCDPEvent',
@@ -234,77 +237,96 @@ export class RelayConnection {
method: 'Target.detachedFromTarget', method: 'Target.detachedFromTarget',
params: { params: {
sessionId: tab.sessionId, sessionId: tab.sessionId,
targetId: tab.targetId targetId: tab.targetId,
} },
} },
}); })
this._attachedTabs.delete(tabId); this._attachedTabs.delete(tabId)
logger.debug('Removed tab from _attachedTabs map. Remaining tabs:', this._attachedTabs.size);
for (const [childSessionId, parentTabId] of this._childSessions.entries()) {
if (parentTabId === tabId) {
logger.debug('Cleaning up child session:', childSessionId, 'for tab:', tabId)
this._childSessions.delete(childSessionId)
}
}
logger.debug('Removed tab from _attachedTabs map. Remaining tabs:', this._attachedTabs.size)
if (shouldDetachDebugger) { if (shouldDetachDebugger) {
chrome.debugger.detach(tab.debuggee) chrome.debugger
.detach(tab.debuggee)
.then(() => { .then(() => {
logger.debug('Successfully detached debugger from tab:', tabId); logger.debug('Successfully detached debugger from tab:', tabId)
}) })
.catch((err) => { .catch((err) => {
logger.debug('Error detaching debugger from tab:', tabId, err.message); logger.debug('Error detaching debugger from tab:', tabId, err.message)
}); })
} }
} }
close(message: string): void { close(message: string): void {
logger.debug('Closing RelayConnection, reason:', message, 'current state:', this._ws.readyState); logger.debug('Closing RelayConnection, reason:', message, 'current state:', this._ws.readyState)
this._ws.close(1000, message); this._ws.close(1000, message)
this._onClose(message, 1000); this._onClose(message, 1000)
} }
private _onClose(reason: string = 'Unknown', code: number = 1000) { private _onClose(reason: string = 'Unknown', code: number = 1000) {
if (this._closed) { if (this._closed) {
logger.debug('_onClose called but already closed'); logger.debug('_onClose called but already closed')
return; return
} }
logger.debug('Connection closing, attached tabs count:', this._attachedTabs.size); logger.debug('Connection closing, attached tabs count:', this._attachedTabs.size)
this._closed = true; this._closed = true
chrome.debugger.onEvent.removeListener(this._onDebuggerEvent)
chrome.debugger.onDetach.removeListener(this._onDebuggerDetach)
chrome.debugger.onEvent.removeListener(this._onDebuggerEvent); const tabIds = Array.from(this._attachedTabs.keys())
chrome.debugger.onDetach.removeListener(this._onDebuggerDetach); logger.debug('Detaching all tabs:', tabIds)
const tabIds = Array.from(this._attachedTabs.keys());
logger.debug('Detaching all tabs:', tabIds);
for (const [tabId, tab] of this._attachedTabs) { for (const [tabId, tab] of this._attachedTabs) {
logger.debug('Detaching debugger from tab:', tabId); logger.debug('Detaching debugger from tab:', tabId)
chrome.debugger.detach(tab.debuggee) chrome.debugger
.detach(tab.debuggee)
.then(() => { .then(() => {
logger.debug('Successfully detached from tab:', tabId); logger.debug('Successfully detached from tab:', tabId)
}) })
.catch((err) => { .catch((err) => {
logger.debug('Error detaching from tab:', tabId, err.message); logger.debug('Error detaching from tab:', tabId, err.message)
}); })
} }
this._attachedTabs.clear(); this._attachedTabs.clear()
logger.debug('All tabs cleared from map. Chrome automation bar should disappear in a few seconds.'); logger.debug('All tabs cleared from map. Chrome automation bar should disappear in a few seconds.')
logger.debug('Connection closed, calling onClose callback'); logger.debug('Connection closed, calling onClose callback')
if (activeConnection === this) { if (activeConnection === this) {
activeConnection = undefined; activeConnection = undefined
} }
this._onCloseCallback?.(reason, code); this._onCloseCallback?.(reason, code)
} }
private _onDebuggerEvent = (source: chrome.debugger.DebuggerSession, method: string, params: any): void => { private _onDebuggerEvent = (source: chrome.debugger.DebuggerSession, method: string, params: any): void => {
const tab = this._attachedTabs.get(source.tabId!); const tab = this._attachedTabs.get(source.tabId!)
if (!tab) return; if (!tab) return
// Track execution contexts so we can replay them when Playwright reconnects. // Track execution contexts so we can replay them when Playwright reconnects.
// Chrome's debugger only sends Runtime.executionContextCreated events once per context, // Chrome's debugger only sends Runtime.executionContextCreated events once per context,
// not on every Runtime.enable call. We cache them here and replay on reconnection. // not on every Runtime.enable call. We cache them here and replay on reconnection.
logger.debug('Forwarding CDP event:', method, 'from tab:', source.tabId); logger.debug('Forwarding CDP event:', method, 'from tab:', source.tabId)
if (method === 'Target.attachedToTarget' && params?.sessionId) {
logger.debug('Child target attached:', params.sessionId, 'for tab:', source.tabId)
this._childSessions.set(params.sessionId, source.tabId!)
}
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
logger.debug('Child target detached:', params.sessionId)
this._childSessions.delete(params.sessionId)
}
this._sendMessage({ this._sendMessage({
method: 'forwardCDPEvent', method: 'forwardCDPEvent',
@@ -313,144 +335,159 @@ export class RelayConnection {
method, method,
params, params,
}, },
}); })
}; }
private _onDebuggerDetach = (source: chrome.debugger.Debuggee, reason: `${chrome.debugger.DetachReason}`): void => { private _onDebuggerDetach = (source: chrome.debugger.Debuggee, reason: `${chrome.debugger.DetachReason}`): void => {
const tabId = source.tabId; const tabId = source.tabId
logger.debug('_onDebuggerDetach called for tab:', tabId, 'reason:', reason, 'isAttached:', tabId ? this._attachedTabs.has(tabId) : false); logger.debug(
'_onDebuggerDetach called for tab:',
tabId,
'reason:',
reason,
'isAttached:',
tabId ? this._attachedTabs.has(tabId) : false,
)
if (!tabId || !this._attachedTabs.has(tabId)) { if (!tabId || !this._attachedTabs.has(tabId)) {
logger.debug('Ignoring debugger detach event for untracked tab:', tabId); logger.debug('Ignoring debugger detach event for untracked tab:', tabId)
return; return
} }
logger.debug(`Manual debugger detachment detected for tab ${tabId}: ${reason}`); logger.debug(`Manual debugger detachment detected for tab ${tabId}: ${reason}`)
logger.debug('User closed debugger via Chrome automation bar, calling onTabDetached callback'); logger.debug('User closed debugger via Chrome automation bar, calling onTabDetached callback')
this._onTabDetachedCallback?.(tabId, reason); this._onTabDetachedCallback?.(tabId, reason)
this._cleanupTab(tabId, false); this._cleanupTab(tabId, false)
}; }
private async _handleCommand(msg: ExtensionCommandMessage): Promise<any> { private async _handleCommand(msg: ExtensionCommandMessage): Promise<any> {
if (msg.method === 'attachToTab') {
return {};
}
if (msg.method === 'forwardCDPCommand') { if (msg.method === 'forwardCDPCommand') {
let targetTab: AttachedTab | undefined
// find tab by sessionId
if (msg.params.method === 'Target.createTarget') { if (msg.params.sessionId) {
const url = msg.params.params?.url || 'about:blank';
logger.debug('Creating new tab with URL:', url);
const tab = await chrome.tabs.create({ url, active: false });
if (!tab.id) {
throw new Error('Failed to create tab');
}
logger.debug('Created tab:', tab.id, 'waiting for it to load...');
// Wait a bit for tab to initialize
await new Promise(resolve => setTimeout(resolve, 100));
// Attach to the new tab
const targetInfo = await this.attachTab(tab.id);
return { targetId: targetInfo.targetId };
}
if (msg.params.method === 'Target.closeTarget' && msg.params.params?.targetId) {
logger.debug('Closing target:', msg.params.params.targetId);
for (const [tabId, tab] of this._attachedTabs) { for (const [tabId, tab] of this._attachedTabs) {
if (tab.targetId === msg.params.params.targetId) { if (tab.sessionId === msg.params.sessionId) {
logger.debug('Found tab to close:', tabId); targetTab = tab
await chrome.tabs.remove(tabId); break
return { success: true };
} }
} }
logger.debug('Target not found:', msg.params.params.targetId);
throw new Error(`Target not found: ${msg.params.params.targetId}`);
} }
let targetTab: AttachedTab | undefined; // find parent tab if this is a child target
if (!targetTab && msg.params.sessionId) {
const parentTabId = this._childSessions.get(msg.params.sessionId)
if (parentTabId) {
targetTab = this._attachedTabs.get(parentTabId)
logger.debug('Found parent tab for child session:', msg.params.sessionId, 'tabId:', parentTabId)
}
}
for (const [tabId, tab] of this._attachedTabs) { // find tab by targetId in params
if (tab.sessionId === msg.params.sessionId) { if (!targetTab && msg.params.params && 'targetId' in msg.params.params && msg.params.params.targetId) {
targetTab = tab; const paramTargetId = msg.params.params.targetId as string
break; for (const [tabId, tab] of this._attachedTabs) {
if (tab.targetId === paramTargetId) {
targetTab = tab
logger.debug('Found tab for targetId:', paramTargetId, 'tabId:', tabId)
break
}
}
}
switch (msg.params.method) {
// CRITICAL FIX FOR PLAYWRIGHT HANGS:
// When Playwright connects to an existing page, it sends 'Runtime.enable' and waits for
// 'Runtime.executionContextCreated' events to map out available execution contexts (frames, workers).
//
// However, if the Runtime domain is already enabled or if Chrome doesn't re-emit these events
// for existing contexts upon a standard 'Runtime.enable' call, Playwright never gets the
// context IDs it needs. This causes commands like `page.evaluate()` to hang indefinitely
// because Playwright is waiting for a context that it believes hasn't been created yet.
//
// To fix this, we intercept 'Runtime.enable' and force a reset:
// 1. We send 'Runtime.disable'. This clears Chrome's internal Runtime state for this session.
// 2. We wait a brief moment (100ms) to ensure the disable command propagates.
// 3. Then we allow the original 'Runtime.enable' command to proceed.
//
// This sequence forces Chrome to treat the enablement as a fresh start, causing it to
// re-scan and emit 'Runtime.executionContextCreated' events for ALL existing contexts.
// Playwright receives these events, populates its context map, and the hang is resolved.
case 'Runtime.enable': {
if (!targetTab?.debuggee) throw new Error(`No debuggee found for tab (sessionId: ${msg.params.sessionId}, tabId: ${targetTab?.debuggee?.tabId ?? 'unknown'})`)
try {
await chrome.debugger.sendCommand(targetTab?.debuggee!, 'Runtime.disable')
await new Promise((resolve) => setTimeout(resolve, 200))
} catch (e) {
logger.debug('Error disabling Runtime (ignoring):', e)
}
const result = await chrome.debugger.sendCommand(targetTab?.debuggee!, 'Runtime.enable', msg.params.params)
return result
}
case 'Target.createTarget': {
const url = msg.params.params?.url || 'about:blank'
logger.debug('Creating new tab with URL:', url)
const tab = await chrome.tabs.create({ url, active: false })
if (!tab.id) {
throw new Error('Failed to create tab')
}
logger.debug('Created tab:', tab.id, 'waiting for it to load...')
await new Promise((resolve) => setTimeout(resolve, 100))
const targetInfo = await this.attachTab(tab.id)
return { targetId: targetInfo.targetId } satisfies Protocol.Target.CreateTargetResponse
}
case 'Target.closeTarget': {
if (!targetTab || !targetTab.debuggee.tabId) {
throw new Error(`Target not found: ${msg.params.params?.targetId}`)
}
await chrome.tabs.remove(targetTab.debuggee.tabId!)
return { success: true } satisfies Protocol.Target.CloseTargetResponse
} }
} }
if (!targetTab) { if (!targetTab) {
if (msg.params.method === 'Browser.getVersion' || msg.params.method === 'Target.getTargets') { throw new Error(
targetTab = this._attachedTabs.values().next().value; `No tab found for method ${msg.params.method} sessionId: ${msg.params.sessionId} params: ${JSON.stringify(msg.params.params || null)}`,
} )
if (!targetTab) {
throw new Error(`No tab found for method ${msg.params.method} sessionId: ${msg.params.sessionId}`);
}
} }
logger.debug('CDP command:', msg.params.method, 'for tab:', targetTab.debuggee.tabId); logger.debug('CDP command:', msg.params.method, 'for tab:', targetTab.debuggee.tabId)
const debuggerSession: chrome.debugger.DebuggerSession = { const debuggerSession: chrome.debugger.DebuggerSession = {
...targetTab.debuggee, ...targetTab.debuggee,
sessionId: msg.params.sessionId !== targetTab.sessionId ? msg.params.sessionId : undefined, sessionId: msg.params.sessionId !== targetTab.sessionId ? msg.params.sessionId : undefined,
};
// CRITICAL FIX FOR PLAYWRIGHT HANGS:
// When Playwright connects to an existing page, it sends 'Runtime.enable' and waits for
// 'Runtime.executionContextCreated' events to map out available execution contexts (frames, workers).
//
// However, if the Runtime domain is already enabled or if Chrome doesn't re-emit these events
// for existing contexts upon a standard 'Runtime.enable' call, Playwright never gets the
// context IDs it needs. This causes commands like `page.evaluate()` to hang indefinitely
// because Playwright is waiting for a context that it believes hasn't been created yet.
//
// To fix this, we intercept 'Runtime.enable' and force a reset:
// 1. We send 'Runtime.disable'. This clears Chrome's internal Runtime state for this session.
// 2. We wait a brief moment (100ms) to ensure the disable command propagates.
// 3. Then we allow the original 'Runtime.enable' command to proceed.
//
// This sequence forces Chrome to treat the enablement as a fresh start, causing it to
// re-scan and emit 'Runtime.executionContextCreated' events for ALL existing contexts.
// Playwright receives these events, populates its context map, and the hang is resolved.
if (msg.params.method === 'Runtime.enable') {
logger.debug('Runtime.enable called, disabling first to force context refresh for tab:', targetTab.debuggee.tabId);
try {
await chrome.debugger.sendCommand(debuggerSession, 'Runtime.disable');
await new Promise(resolve => setTimeout(resolve, 100));
} catch (e) {
logger.debug('Error disabling Runtime (ignoring):', e);
}
} }
const result = await chrome.debugger.sendCommand( const result = await chrome.debugger.sendCommand(debuggerSession, msg.params.method, msg.params.params)
debuggerSession,
msg.params.method,
msg.params.params
);
return result; return result
} }
} }
private _sendMessage(message: any): void { private _sendMessage(message: any): void {
if (this._ws.readyState === WebSocket.OPEN) { if (this._ws.readyState === WebSocket.OPEN) {
try { try {
this._ws.send(JSON.stringify(message)); this._ws.send(JSON.stringify(message))
// logger.debug('Message sent successfully, type:', message.method || 'response'); // logger.debug('Message sent successfully, type:', message.method || 'response');
} catch (error: any) { } catch (error: any) {
// Use console directly to avoid infinite recursion if logger tries to send log over this same connection // Use console directly to avoid infinite recursion if logger tries to send log over this same connection
console.debug('ERROR sending message:', error, 'message type:', message.method || 'response'); console.debug('ERROR sending message:', error, 'message type:', message.method || 'response')
} }
} else { } else {
// Use console directly to avoid infinite recursion // Use console directly to avoid infinite recursion
console.debug('Cannot send message, WebSocket not open. State:', this._ws.readyState, 'message type:', message.method || 'response'); console.debug(
'Cannot send message, WebSocket not open. State:',
this._ws.readyState,
'message type:',
message.method || 'response',
)
} }
} }
} }
+1 -6
View File
@@ -15,12 +15,7 @@ type ForwardCDPCommand =
} }
}[keyof ProtocolMapping.Commands] }[keyof ProtocolMapping.Commands]
export type ExtensionCommandMessage = export type ExtensionCommandMessage = ForwardCDPCommand
| {
id: number
method: 'attachToTab'
}
| ForwardCDPCommand
export type ExtensionResponseMessage = { export type ExtensionResponseMessage = {
id: number id: number