From 75650110d353a180f3da8548acaefcb6f427f749 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 15 Feb 2024 08:23:42 -0800 Subject: [PATCH] chore: extract backend server util (#419) --- src/backend.ts | 135 ++++++++++++++++++++++++++++ src/reusedBrowser.ts | 204 ++++++++++++++++--------------------------- 2 files changed, 212 insertions(+), 127 deletions(-) create mode 100644 src/backend.ts diff --git a/src/backend.ts b/src/backend.ts new file mode 100644 index 000000000..6dc33dfe1 --- /dev/null +++ b/src/backend.ts @@ -0,0 +1,135 @@ +/** + * 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 { spawn } from 'child_process'; +import { findNode } from './utils'; +import * as vscodeTypes from './vscodeTypes'; +import EventEmitter from 'events'; +import { WebSocketTransport } from './transport'; + +export class BackendServer { + private _vscode: vscodeTypes.VSCode; + private _args: string[]; + private _cwd: string; + private _envProvider: () => NodeJS.ProcessEnv; + private _clientFactory: () => T; + + constructor(vscode: vscodeTypes.VSCode, args: string[], cwd: string, envProvider: () => NodeJS.ProcessEnv, clientFactory: () => T) { + this._vscode = vscode; + this._args = args; + this._cwd = cwd; + this._envProvider = envProvider; + this._clientFactory = clientFactory; + } + + async start(): Promise { + const node = await findNode(this._vscode, this._cwd); + const serverProcess = spawn(node, this._args, { + cwd: this._cwd, + stdio: 'pipe', + env: { + ...process.env, + ...this._envProvider(), + }, + }); + + const client = this._clientFactory(); + + serverProcess.stderr?.on('data', () => {}); + serverProcess.on('error', error => { + client._onErrorEvent.fire(error); + }); + + return new Promise(fulfill => { + serverProcess.stdout?.on('data', async data => { + const match = data.toString().match(/Listening on (.*)/); + if (!match) + return; + const wse = match[1]; + await client._connect(wse); + fulfill(client); + }); + serverProcess.on('exit', () => { + fulfill(null); + client._onCloseEvent.fire(); + }); + }); + } +} + +export class BackendClient extends EventEmitter { + private static _lastId = 0; + private _callbacks = new Map void, reject: (e: Error) => void }>(); + private _transport!: WebSocketTransport; + wsEndpoint!: string; + + readonly onClose: vscodeTypes.Event; + readonly _onCloseEvent: vscodeTypes.EventEmitter; + readonly onError: vscodeTypes.Event; + readonly _onErrorEvent: vscodeTypes.EventEmitter; + + constructor(vscode: vscodeTypes.VSCode) { + super(); + this._onCloseEvent = new vscode.EventEmitter(); + this.onClose = this._onCloseEvent.event; + this._onErrorEvent = new vscode.EventEmitter(); + this.onError = this._onErrorEvent.event; + } + + rewriteWsEndpoint(wsEndpoint: string): string { + return wsEndpoint; + } + + rewriteWsHeaders(headers: Record): Record { + return headers; + } + + async _connect(wsEndpoint: string) { + this.wsEndpoint = wsEndpoint; + this._transport = await WebSocketTransport.connect(this.rewriteWsEndpoint(wsEndpoint), this.rewriteWsHeaders({})); + 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); + } + }; + await this.initialize(); + } + + async initialize() { } + + requestGracefulTermination() { } + + protected send(method: string, params: any = {}): Promise { + return new Promise((fulfill, reject) => { + const id = ++BackendClient._lastId; + const command = { id, guid: 'DebugController', method, params, metadata: {} }; + this._transport.send(command as any); + this._callbacks.set(id, { fulfill, reject }); + }); + } +} diff --git a/src/reusedBrowser.ts b/src/reusedBrowser.ts index d0611129f..c75502662 100644 --- a/src/reusedBrowser.ts +++ b/src/reusedBrowser.ts @@ -14,18 +14,15 @@ * limitations under the License. */ -import { spawn } from 'child_process'; import { TestConfig } from './playwrightTest'; import { TestModel, TestProject } from './testModel'; -import { createGuid, findNode } from './utils'; +import { createGuid } from './utils'; import * as vscodeTypes from './vscodeTypes'; import path from 'path'; import fs from 'fs'; -import events from 'events'; -import EventEmitter from 'events'; import { installBrowsers } from './installer'; -import { WebSocketTransport } from './transport'; import { SettingsModel } from './settingsModel'; +import { BackendServer, BackendClient } from './backend'; export type Snapshot = { browsers: BrowserSnapshot[]; @@ -62,7 +59,6 @@ export type Source = { export class ReusedBrowser implements vscodeTypes.Disposable { private _vscode: vscodeTypes.VSCode; - private _browserServerWS: string | undefined; private _shouldReuseBrowserForTests = false; private _backend: Backend | undefined; private _cancelRecording: (() => void) | undefined; @@ -101,7 +97,7 @@ export class ReusedBrowser implements vscodeTypes.Disposable { } dispose() { - this._reset(true).catch(() => {}); + this._stop(); for (const d of this._disposables) d.dispose(); this._disposables = []; @@ -110,43 +106,42 @@ export class ReusedBrowser implements vscodeTypes.Disposable { private async _startBackendIfNeeded(config: TestConfig) { // Unconditionally close selector dialog, it might send inspect(enabled: false). if (this._backend) { - await this._reset(false); + this._resetNoWait(); return; } - const node = await findNode(this._vscode, config.workspaceFolder); - const allArgs = [ + const args = [ config.cli, 'run-server', `--path=/${createGuid()}` ]; - - const serverProcess = spawn(node, allArgs, { - cwd: config.workspaceFolder, - stdio: 'pipe', - env: { - ...process.env, - ...this._envProvider(), - PW_CODEGEN_NO_INSPECTOR: '1', - PW_EXTENSION_MODE: '1', - }, + const cwd = config.workspaceFolder; + const envProvider = () => ({ + ...this._envProvider(), + PW_CODEGEN_NO_INSPECTOR: '1', + PW_EXTENSION_MODE: '1', }); - const backend = new Backend(); - this._backend = backend; - - serverProcess.stderr?.on('data', () => {}); - serverProcess.on('exit', () => { + const backendServer = new BackendServer(this._vscode, args, cwd, envProvider, () => new Backend(this._vscode)); + const backend = await backendServer.start(); + if (!backend) + return; + backend.onClose(() => { if (backend === this._backend) { this._backend = undefined; - this._reset(false); + this._resetNoWait(); } }); - serverProcess.on('error', error => { - this._vscode.window.showErrorMessage(error.message); - this._reset(true).catch(() => {}); + backend.onError(e => { + if (backend === this._backend) { + this._vscode.window.showErrorMessage(e.message); + this._backend = undefined; + this._resetNoWait(); + } }); + this._backend = backend; + this._backend.on('inspectRequested', params => { if (!this._updateOrCancelInspecting) this._showInspectingBox(); @@ -155,7 +150,7 @@ export class ReusedBrowser implements vscodeTypes.Disposable { this._backend.on('setModeRequested', params => { if (params.mode === 'standby') - this._reset(false); + this._resetNoWait(); }); this._backend.on('paused', async params => { @@ -163,7 +158,7 @@ export class ReusedBrowser implements vscodeTypes.Disposable { this._pausedOnPagePause = true; await this._vscode.window.showInformationMessage('Paused', { modal: false }, 'Resume'); this._pausedOnPagePause = false; - this._backend?.resume(); + this._backend?.resumeNoWait(); } }); this._backend.on('stateChanged', params => { @@ -201,27 +196,6 @@ export class ReusedBrowser implements vscodeTypes.Disposable { } }); }); - - let connectedCallback: (wsEndpoint: string) => void; - const wsEndpointPromise = new Promise(f => connectedCallback = f); - - serverProcess.stdout?.on('data', async data => { - const match = data.toString().match(/Listening on (.*)/); - if (!match) - return; - const wse = match[1]; - await (this._backend as Backend).connect(wse); - await this._backend?.initialize(); - connectedCallback(wse); - }); - - await Promise.race([ - wsEndpointPromise.then(wse => { - this._browserServerWS = wse; - this._backend!.setReportStateChanged({ enabled: true }); - }), - events.once(serverProcess, 'exit'), - ]); } private _scheduleEdit(callback: () => Promise) { @@ -243,23 +217,23 @@ export class ReusedBrowser implements vscodeTypes.Disposable { return; if (pageCount) return; - this._reset(true).catch(() => {}); + this._stop(); } browserServerEnv(debug: boolean): NodeJS.ProcessEnv | undefined { - return (debug || this._shouldReuseBrowserForTests) && this._browserServerWS ? { + return (debug || this._shouldReuseBrowserForTests) && this._backend?.wsEndpoint ? { PW_TEST_REUSE_CONTEXT: this._shouldReuseBrowserForTests ? '1' : undefined, // "Show browser" mode forces context reuse that survives over multiple test runs. // Playwright Test sets up `tracesDir` inside the `test-results` folder, so it will be removed between runs. // When context is reused, its ongoing tracing will fail with ENOENT because trace files // were suddenly removed. So we disable tracing in this case. PW_TEST_DISABLE_TRACING: this._shouldReuseBrowserForTests ? '1' : undefined, - PW_TEST_CONNECT_WS_ENDPOINT: this._browserServerWS, + PW_TEST_CONNECT_WS_ENDPOINT: this._backend?.wsEndpoint, } : undefined; } browserServerWSForTest() { - return this._browserServerWS; + return this._backend?.wsEndpoint; } async inspect(models: TestModel[]) { @@ -286,7 +260,7 @@ export class ReusedBrowser implements vscodeTypes.Disposable { selectorExplorerBox.onDidChangeValue(selector => { this._backend?.highlight({ selector }).catch(() => {}); }); - selectorExplorerBox.onDidHide(() => this._reset(false).catch(() => {})); + selectorExplorerBox.onDidHide(() => this._resetNoWait()); selectorExplorerBox.onDidAccept(() => { this._vscode.env.clipboard.writeText(selectorExplorerBox!.value); selectorExplorerBox.hide(); @@ -377,7 +351,7 @@ export class ReusedBrowser implements vscodeTypes.Disposable { await this._backend?.setMode({ mode: 'recording', testIdAttributeName: model.config.testIdAttributeName }); } catch (e) { showExceptionAsUserError(this._vscode, model, e as Error); - await this._reset(true); + this._stop(); return; } @@ -387,7 +361,7 @@ export class ReusedBrowser implements vscodeTypes.Disposable { new Promise(f => token.onCancellationRequested(f)), new Promise(f => this._cancelRecording = f), ]); - await this._reset(false); + this._resetNoWait(); } private async _createFileForNewTest(model: TestModel) { @@ -425,16 +399,14 @@ test('test', async ({ page }) => { this._isRunningTests = true; this._onRunningTestsChangedEvent.fire(true); await this._startBackendIfNeeded(config); - await this._backend!.setAutoClose({ enabled: false }); } async didRunTests(debug: boolean) { if (debug && !this._shouldReuseBrowserForTests) { - this._reset(true); + this._stop(); } else { - this._backend?.setAutoClose({ enabled: true }); if (!this._pageCount) - await this._reset(true); + this._stop(); } this._isRunningTests = false; this._onRunningTestsChangedEvent.fire(false); @@ -447,108 +419,86 @@ test('test', async ({ page }) => { ); return; } - this._reset(true).catch(() => {}); + this._stop(); } - private async _reset(stop: boolean) { - // This won't wait for setMode(none). + private _resetExtensionState() { this._editor = undefined; this._insertedEditActionCount = 0; this._updateOrCancelInspecting?.({ cancel: true }); this._updateOrCancelInspecting = undefined; this._cancelRecording?.(); this._cancelRecording = undefined; + } - // This will though. - if (stop) { - this._backend?.kill(); - this._backend = undefined; - this._browserServerWS = undefined; - this._pageCount = 0; - } else { - await this._backend?.setMode({ mode: 'none' }); - } + private _resetNoWait() { + this._resetExtensionState(); + this._backend?.resetRecorderModeNoWait(); + } + + private _stop() { + this._resetExtensionState(); + this._backend?.requestGracefulTermination(); + this._backend = undefined; + this._pageCount = 0; } } -export class Backend extends EventEmitter { - private static _lastId = 0; - private _callbacks = new Map void, reject: (e: Error) => void }>(); - private _transport!: WebSocketTransport; +export class Backend extends BackendClient { + constructor(vscode: vscodeTypes.VSCode) { + super(vscode); + } - constructor() { - super(); + override rewriteWsEndpoint(wsEndpoint: string): string { + return wsEndpoint + '?debug-controller'; } - async connect(wsEndpoint: string) { - this._transport = await WebSocketTransport.connect(wsEndpoint + '?debug-controller', { + override rewriteWsHeaders(headers: Record): Record { + return { + ...headers, 'x-playwright-debug-controller': 'true' // Remove after v1.35 - }); - 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' }); + override async initialize() { + await this.send('initialize', { codegenId: 'playwright-test', sdkLanguage: 'javascript' }); + await this.send('setReportStateChanged', { enabled: true }); + } + + override requestGracefulTermination() { + this.send('kill').catch(() => {}); } async resetForReuse() { - await this._send('resetForReuse'); + await this.send('resetForReuse'); } - async navigate(params: { url: string }) { - await this._send('navigate', params); + resetRecorderModeNoWait() { + this.resetRecorderMode().catch(() => {}); } - async setMode(params: { mode: 'none' | 'inspecting' | 'recording', testIdAttributeName?: string }) { - await this._send('setRecorderMode', params); + async resetRecorderMode() { + await this.send('setRecorderMode', { mode: 'none' }); } - async setReportStateChanged(params: { enabled: boolean }) { - await this._send('setReportStateChanged', params); + async navigate(params: { url: string }) { + await this.send('navigate', params); } - async setAutoClose(params: { enabled: boolean }) { + async setMode(params: { mode: 'none' | 'inspecting' | 'recording', testIdAttributeName?: string }) { + await this.send('setRecorderMode', params); } async highlight(params: { selector: string }) { - await this._send('highlight', params); + await this.send('highlight', params); } async hideHighlight() { - await this._send('hideHighlight'); - } - - async resume() { - this._send('resume'); - } - - async kill() { - this._send('kill'); + await this.send('hideHighlight'); } - 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 }); - }); + resumeNoWait() { + this.send('resume').catch(() => {}); } }