204 lines
8.4 KiB
TypeScript
204 lines
8.4 KiB
TypeScript
/**
|
|
* Copyright (c) Microsoft Corporation.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
export type ProtocolCommand = {
|
|
id: number;
|
|
method: string;
|
|
params?: any;
|
|
};
|
|
|
|
// The narrow surface of RelayConnection that protocol handlers use.
|
|
export interface RelayContext {
|
|
readonly attachedTabs: ReadonlySet<number>;
|
|
sendMessage(message: any): void;
|
|
// Records that a tab's debugger is now attached. Fires ontabattached on the
|
|
// owning RelayConnection.
|
|
notifyTabAttached(tabId: number): void;
|
|
// Records that a tab's debugger is now detached. Fires ontabdetached on the
|
|
// owning RelayConnection.
|
|
notifyTabDetached(tabId: number): void;
|
|
}
|
|
|
|
export interface ProtocolHandler {
|
|
handleCommand(message: ProtocolCommand): Promise<any>;
|
|
// Forwards an already-filtered chrome.* event (concerning a currently-attached
|
|
// tab) to the relay. Shape is protocol-specific.
|
|
forwardChromeEvent(fullMethod: string, args: any[]): void;
|
|
// The UI added a tab to the Playwright group, whether as the initial pick
|
|
// from the connect page or from a later drag-in. Handler tells the relay
|
|
// the tab is now available; the relay attaches via the usual command path.
|
|
onUserAttachRequest(tab: chrome.tabs.Tab): void;
|
|
// The UI removed a tab. RelayConnection has already detached the debugger
|
|
// and called notifyTabDetached; the handler only sends the wire-level
|
|
// detach notification (if the protocol has one).
|
|
onUserDetachRequest(tabId: number): void;
|
|
// Signals that the initial set of `onUserAttachRequest` calls is complete.
|
|
// For v2 this sends `extension.initialized` so the relay can unblock CDP
|
|
// traffic from Playwright; v1 has no handshake and ignores it.
|
|
didInitialize(): void;
|
|
}
|
|
|
|
// ─── Protocol v1 (legacy single-tab) ───────────────────────────────────────
|
|
|
|
export class ProtocolV1Handler implements ProtocolHandler {
|
|
private _context: RelayContext;
|
|
private _selectedTabPromise: Promise<number>;
|
|
private _selectedTabResolve!: (tabId: number) => void;
|
|
|
|
constructor(context: RelayContext) {
|
|
this._context = context;
|
|
this._selectedTabPromise = new Promise(resolve => this._selectedTabResolve = resolve);
|
|
}
|
|
|
|
async handleCommand(message: ProtocolCommand): Promise<any> {
|
|
if (message.method === 'attachToTab') {
|
|
const tabId = await this._selectedTabPromise;
|
|
const debuggee: chrome.debugger.Debuggee = { tabId };
|
|
await chrome.debugger.attach(debuggee, '1.3');
|
|
this._context.notifyTabAttached(tabId);
|
|
const result: any = await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo');
|
|
return { targetInfo: result?.targetInfo };
|
|
}
|
|
if (message.method === 'forwardCDPCommand') {
|
|
const { sessionId, method, params } = message.params;
|
|
if (method === 'Target.createTarget')
|
|
throw new Error('Tab creation is not supported yet. Update Playwright MCP or CLI to the latest version.');
|
|
const tabId = [...this._context.attachedTabs][0];
|
|
if (tabId === undefined)
|
|
throw new Error('No tab is connected');
|
|
const debuggerSession: chrome.debugger.DebuggerSession = { tabId, sessionId };
|
|
return await chrome.debugger.sendCommand(debuggerSession, method, params);
|
|
}
|
|
throw new Error(`Unknown method: ${message.method}`);
|
|
}
|
|
|
|
forwardChromeEvent(fullMethod: string, args: any[]): void {
|
|
// v1 only forwards CDP events from the single attached tab; all other
|
|
// chrome events have no v1 equivalent.
|
|
if (fullMethod !== 'chrome.debugger.onEvent')
|
|
return;
|
|
const [source, method, params] = args as [chrome.debugger.DebuggerSession, string, any];
|
|
this._context.sendMessage({
|
|
method: 'forwardCDPEvent',
|
|
params: { sessionId: source.sessionId, method, params },
|
|
});
|
|
}
|
|
|
|
onUserAttachRequest(tab: chrome.tabs.Tab): void {
|
|
// v1 is single-tab by design: the first attach call determines the tab
|
|
// used by the pending `attachToTab` command. Later attach requests are
|
|
// silently ignored (Promise.resolve is a no-op once resolved).
|
|
if (tab.id !== undefined)
|
|
this._selectedTabResolve(tab.id);
|
|
}
|
|
|
|
onUserDetachRequest(_tabId: number): void {
|
|
// v1 has no wire-level detach notification; when the last tab detaches the
|
|
// socket closes and the relay notices.
|
|
}
|
|
|
|
didInitialize(): void {
|
|
// v1 has no initial-tab-list handshake. `_selectedTabPromise` is resolved
|
|
// by the first `onUserAttachRequest`, which already unblocks `attachToTab`.
|
|
}
|
|
}
|
|
|
|
// ─── Protocol v2 (reflective chrome.*) ─────────────────────────────────────
|
|
|
|
// Allow-listed chrome.* commands the relay may invoke. The handler resolves
|
|
// the method reflectively and spreads positional params.
|
|
const ALLOWED_CHROME_COMMANDS = new Set([
|
|
'chrome.debugger.attach',
|
|
'chrome.debugger.detach',
|
|
'chrome.debugger.sendCommand',
|
|
'chrome.tabs.create',
|
|
'chrome.tabs.remove',
|
|
]);
|
|
|
|
export class ProtocolV2Handler implements ProtocolHandler {
|
|
private _context: RelayContext;
|
|
|
|
constructor(context: RelayContext) {
|
|
this._context = context;
|
|
}
|
|
|
|
async handleCommand(message: ProtocolCommand): Promise<any> {
|
|
if (ALLOWED_CHROME_COMMANDS.has(message.method)) {
|
|
const args = (message.params ?? []) as any[];
|
|
const result = await invokeChromeMethod(message.method, args);
|
|
// Attach bookkeeping; detach flows through the chrome.debugger.onDetach event.
|
|
if (message.method === 'chrome.debugger.attach') {
|
|
const target = args[0] as chrome.debugger.Debuggee | undefined;
|
|
if (target?.tabId !== undefined)
|
|
this._context.notifyTabAttached(target.tabId);
|
|
}
|
|
return result ?? {};
|
|
}
|
|
throw new Error(`Unknown method: ${message.method}`);
|
|
}
|
|
|
|
forwardChromeEvent(fullMethod: string, args: any[]): void {
|
|
this._context.sendMessage({ method: fullMethod, params: args });
|
|
}
|
|
|
|
onUserAttachRequest(tab: chrome.tabs.Tab): void {
|
|
// Simulate a "new tab opened" event; the relay responds by calling
|
|
// chrome.debugger.attach, which flows through handleCommand.
|
|
this._context.sendMessage({ method: 'chrome.tabs.onCreated', params: [tab] });
|
|
}
|
|
|
|
didInitialize(): void {
|
|
// Signals the end of the initial-tab handshake. The relay holds CDP
|
|
// traffic from Playwright until it sees this event, so that
|
|
// `Target.setAutoAttach` is answered from a populated tab model.
|
|
this._context.sendMessage({ method: 'extension.initialized', params: [] });
|
|
}
|
|
|
|
onUserDetachRequest(tabId: number): void {
|
|
// chrome.debugger.detach does not fire onDetach for the caller, so we
|
|
// synthesize one so the relay notices the tab is gone.
|
|
this._context.sendMessage({
|
|
method: 'chrome.debugger.onDetach',
|
|
params: [{ tabId }, 'target_closed'],
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Reflective chrome.* invocation ────────────────────────────────────────
|
|
|
|
// Resolves chrome.<api>.<member>. Exported so RelayConnection can install
|
|
// listeners on the same set of chrome events without duplicating the traversal.
|
|
export function resolveChromeMember(fullMethod: string): { obj: any; name: string } {
|
|
const parts = fullMethod.split('.');
|
|
if (parts[0] !== 'chrome' || parts.length < 3)
|
|
throw new Error(`Invalid chrome method: ${fullMethod}`);
|
|
let obj: any = chrome;
|
|
for (let i = 1; i < parts.length - 1; i++) {
|
|
obj = obj?.[parts[i]];
|
|
if (obj === undefined)
|
|
throw new Error(`Unknown chrome path: ${parts.slice(0, i + 1).join('.')}, calling ${fullMethod}`);
|
|
}
|
|
return { obj, name: parts[parts.length - 1] };
|
|
}
|
|
|
|
async function invokeChromeMethod(fullMethod: string, args: any[]): Promise<any> {
|
|
const { obj, name } = resolveChromeMember(fullMethod);
|
|
const fn = obj[name] as (...a: any[]) => any;
|
|
if (typeof fn !== 'function')
|
|
throw new Error(`Not a function: ${fullMethod}`);
|
|
return await fn.apply(obj, args);
|
|
}
|