better handling of events. added tab search via targetId
This commit is contained in:
@@ -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
@@ -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',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user