/** * 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. */ import WebSocket from 'ws'; import { EventEmitter } from 'events'; export type ProtocolRequest = { id: number; method: string; params: any; }; export type ProtocolResponse = { id?: number; method?: string; error?: { message: string; data: any; }; params?: any; result?: any; }; export interface ConnectionTransport { send(s: ProtocolRequest): void; close(): void; // Note: calling close is expected to issue onclose at some point. isClosed(): boolean, onmessage?: (message: ProtocolResponse) => void, onclose?: () => void, } class WebSocketTransport implements ConnectionTransport { private _ws: WebSocket; onmessage?: (message: ProtocolResponse) => void; onclose?: () => void; readonly wsEndpoint: string; static async connect(url: string, headers: Record = {}): Promise { const transport = new WebSocketTransport(url, headers); await new Promise((fulfill, reject) => { transport._ws.addEventListener('open', async () => { fulfill(transport); }); transport._ws.addEventListener('error', event => { reject(new Error('WebSocket error: ' + event.message)); transport._ws.close(); }); }); return transport; } constructor(url: string, headers: Record = {}) { this.wsEndpoint = url; this._ws = new WebSocket(url, [], { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024, // 256Mb, handshakeTimeout: 30000, headers }); this._ws.addEventListener('message', event => { try { if (this.onmessage) this.onmessage.call(null, JSON.parse(event.data.toString())); } catch (e) { this._ws.close(); } }); this._ws.addEventListener('close', event => { if (this.onclose) this.onclose.call(null); }); // Prevent Error: read ECONNRESET. this._ws.addEventListener('error', () => {}); } isClosed() { return this._ws.readyState === WebSocket.CLOSING || this._ws.readyState === WebSocket.CLOSED; } send(message: ProtocolRequest) { this._ws.send(JSON.stringify(message)); } close() { this._ws.close(); } async closeAndWait() { const promise = new Promise(f => this._ws.once('close', f)); this.close(); await promise; // Make sure to await the actual disconnect. } } export class Backend extends EventEmitter { private static _lastId = 0; private _callbacks = new Map void, reject: (e: Error) => void }>(); private _transport!: WebSocketTransport; constructor() { super(); } async connect(wsEndpoint: string) { this._transport = await WebSocketTransport.connect(wsEndpoint, { 'x-playwright-debug-controller': 'true' }); this._transport.onmessage = (message: any) => { if (!message.id) { this.emit(message.method, message.params); return; } const pair = this._callbacks.get(message.id); if (!pair) return; this._callbacks.delete(message.id); if (message.error) { const error = new Error(message.error.error?.message || message.error.value); error.stack = message.error.error?.stack; pair.reject(error); } else { pair.fulfill(message.result); } }; } async initialize() { await this._send('initialize', { codegenId: 'playwright-test', sdkLanguage: 'javascript' }); } async close() { await this._transport.closeAndWait(); } async resetForReuse() { await this._send('resetForReuse'); } async navigate(params: { url: string }) { await this._send('navigate', params); } async setMode(params: { mode: 'none' | 'inspecting' | 'recording', language?: string, file?: string, testIdAttributeName?: string }) { await this._send('setRecorderMode', params); } async setReportStateChanged(params: { enabled: boolean }) { await this._send('setReportStateChanged', params); } async highlight(params: { selector: string }) { await this._send('highlight', params); } async hideHighlight() { await this._send('hideHighlight'); } async resume() { this._send('resume'); } async kill() { this._send('kill'); } private _send(method: string, params: any = {}): Promise { return new Promise((fulfill, reject) => { const id = ++Backend._lastId; const command = { id, guid: 'DebugController', method, params, metadata: {} }; this._transport.send(command as any); this._callbacks.set(id, { fulfill, reject }); }); } }