@rushstack/playwright-browser-tunnel 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.json +38 -0
  2. package/CHANGELOG.md +15 -1
  3. package/LICENSE +24 -0
  4. package/lib-dts/tsdoc-metadata.json +1 -1
  5. package/package.json +7 -5
  6. package/.rush/temp/chunked-rush-logs/playwright-browser-tunnel._phase_build.chunks.jsonl +0 -8
  7. package/.rush/temp/operation/_phase_build/all.log +0 -8
  8. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +0 -8
  9. package/.rush/temp/operation/_phase_build/state.json +0 -3
  10. package/.rush/temp/rushstack+playwright-browser-tunnel-_phase_build-7739ed2cac57efca3ad3fa4de550f08e1f482854.tar.log +0 -84
  11. package/.rush/temp/shrinkwrap-deps.json +0 -104
  12. package/config/api-extractor.json +0 -19
  13. package/config/rig.json +0 -7
  14. package/eslint.config.js +0 -18
  15. package/playwright.config.ts +0 -45
  16. package/rush-logs/playwright-browser-tunnel._phase_build.cache.log +0 -3
  17. package/rush-logs/playwright-browser-tunnel._phase_build.log +0 -8
  18. package/src/HttpServer.ts +0 -87
  19. package/src/LaunchOptionsValidator.ts +0 -234
  20. package/src/PlaywrightBrowserTunnel.ts +0 -650
  21. package/src/index.ts +0 -38
  22. package/src/tunneledBrowserConnection/ITunneledBrowser.ts +0 -20
  23. package/src/tunneledBrowserConnection/ITunneledBrowserConnection.ts +0 -37
  24. package/src/tunneledBrowserConnection/TunneledBrowser.ts +0 -52
  25. package/src/tunneledBrowserConnection/TunneledBrowserConnection.ts +0 -232
  26. package/src/tunneledBrowserConnection/constants.ts +0 -5
  27. package/src/tunneledBrowserConnection/index.ts +0 -8
  28. package/src/utilities.ts +0 -136
  29. package/temp/build/lint/_eslint-5eVG3S6w.json +0 -50
  30. package/temp/build/lint/lint.sarif +0 -258
  31. package/temp/build/typescript/ts_lnwgbP5O.json +0 -1
  32. package/temp/playwright-browser-tunnel.api.md +0 -120
  33. package/tests/demo.spec.ts +0 -10
  34. package/tests/testFixture.ts +0 -22
  35. package/tsconfig.json +0 -6
@@ -1,650 +0,0 @@
1
- // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2
- // See LICENSE in the project root for license information.
3
-
4
- import type { ChildProcess } from 'node:child_process';
5
- import { once } from 'node:events';
6
-
7
- import type { BrowserServer, BrowserType, LaunchOptions } from 'playwright-core';
8
- import { type RawData, WebSocket, type WebSocketServer } from 'ws';
9
- import semver from 'semver';
10
-
11
- import { TerminalProviderSeverity, TerminalStreamWritable, type ITerminal } from '@rushstack/terminal';
12
- import { Executable, FileSystem, Async } from '@rushstack/node-core-library';
13
-
14
- import {
15
- getNormalizedErrorString,
16
- getWebSocketCloseReason,
17
- getWebSocketReadyStateString,
18
- WebSocketCloseCode
19
- } from './utilities';
20
- import { LaunchOptionsValidator, type ILaunchOptionsValidationResult } from './LaunchOptionsValidator';
21
-
22
- /**
23
- * Allowed Playwright browser names.
24
- * @beta
25
- */
26
- export type BrowserName = 'chromium' | 'firefox' | 'webkit';
27
- const validBrowserNames: Set<string> = new Set(['chromium', 'firefox', 'webkit'] satisfies BrowserName[]);
28
- function isValidBrowserName(browserName: string): browserName is BrowserName {
29
- return validBrowserNames.has(browserName);
30
- }
31
-
32
- /**
33
- * Status values reported by {@link PlaywrightTunnel}.
34
- * @beta
35
- */
36
- export type TunnelStatus =
37
- | 'waiting-for-connection'
38
- | 'browser-server-running'
39
- | 'stopped'
40
- | 'setting-up-browser-server'
41
- | 'error';
42
-
43
- /**
44
- * Handshake data exchanged during the initial WebSocket connection.
45
- * @beta
46
- */
47
- export interface IHandshake {
48
- action: 'handshake';
49
- browserName: BrowserName;
50
- launchOptions: LaunchOptions;
51
- playwrightVersion: semver.SemVer;
52
- }
53
-
54
- type TunnelMode = 'poll-connection' | 'wait-for-incoming-connection';
55
-
56
- /**
57
- * Options for configuring a {@link PlaywrightTunnel} instance.
58
- * @beta
59
- */
60
- export type IPlaywrightTunnelOptions = {
61
- terminal: ITerminal;
62
- onStatusChange: (status: TunnelStatus) => void;
63
- playwrightInstallPath: string;
64
- /**
65
- * Optional callback invoked before launching the browser server.
66
- * Receives the handshake data including launch options.
67
- * If the callback returns false, the browser server launch will be aborted.
68
- * This allows the client to prompt the user for approval before starting.
69
- */
70
- onBeforeLaunch?: (handshake: IHandshake) => Promise<boolean> | boolean;
71
- } & (
72
- | {
73
- mode: 'poll-connection';
74
- wsEndpoint: string;
75
- }
76
- | {
77
- mode: 'wait-for-incoming-connection';
78
- listenPort: number;
79
- }
80
- );
81
-
82
- interface IBrowserServerProxy {
83
- browserServer: BrowserServer;
84
- client: WebSocket;
85
- }
86
-
87
- /**
88
- * Hosts a Playwright browser server and forwards traffic over a WebSocket tunnel.
89
- * @beta
90
- */
91
- export class PlaywrightTunnel {
92
- private readonly _terminal: ITerminal;
93
- private readonly _onStatusChange: (status: TunnelStatus) => void;
94
- private readonly _onBeforeLaunch?: (handshake: IHandshake) => Promise<boolean> | boolean;
95
- private readonly _playwrightBrowsersInstalled: Set<string> = new Set();
96
- private readonly _wsEndpoint: string | undefined;
97
- private readonly _listenPort: number | undefined;
98
- private readonly _playwrightInstallPath: string;
99
- private _status: TunnelStatus = 'stopped';
100
- private _initWsPromise?: Promise<WebSocket>;
101
- private _keepRunning: boolean = false;
102
- private _ws?: WebSocket;
103
- private _mode: TunnelMode;
104
- private _pendingConnectionAttempt?: Promise<WebSocket>;
105
- private _pollInterval?: NodeJS.Timeout;
106
-
107
- public constructor(options: IPlaywrightTunnelOptions) {
108
- const { mode, terminal, onStatusChange, playwrightInstallPath, onBeforeLaunch } = options;
109
-
110
- switch (mode) {
111
- case 'poll-connection':
112
- if (!options.wsEndpoint) {
113
- throw new Error('wsEndpoint is required for poll-connection mode');
114
- }
115
- this._wsEndpoint = options.wsEndpoint;
116
- this._listenPort = undefined;
117
- break;
118
- case 'wait-for-incoming-connection':
119
- if (options.listenPort === undefined) {
120
- throw new Error('listenPort is required for wait-for-incoming-connection mode');
121
- }
122
- this._wsEndpoint = undefined;
123
- this._listenPort = options.listenPort;
124
- break;
125
- default:
126
- throw new Error(`Invalid mode: ${mode}`);
127
- }
128
-
129
- this._mode = mode;
130
- this._terminal = terminal;
131
- this._onStatusChange = onStatusChange;
132
- this._onBeforeLaunch = onBeforeLaunch;
133
- this._playwrightInstallPath = playwrightInstallPath;
134
- }
135
-
136
- public get status(): TunnelStatus {
137
- return this._status;
138
- }
139
-
140
- // eslint-disable-next-line @typescript-eslint/naming-convention
141
- private set status(newStatus: TunnelStatus) {
142
- this._status = newStatus;
143
- this._onStatusChange(newStatus);
144
- }
145
-
146
- public async waitForCloseAsync(): Promise<void> {
147
- const terminal: ITerminal = this._terminal;
148
- const initWsPromise: Promise<WebSocket> | undefined = this._initWsPromise;
149
- if (initWsPromise) {
150
- const ws: WebSocket = await initWsPromise;
151
- await once(ws, 'close');
152
- terminal.writeDebugLine('WebSocket connection closed. resolving init promise.');
153
- this._initWsPromise = undefined;
154
- }
155
- }
156
-
157
- public async startAsync(options: { keepRunning?: boolean } = {}): Promise<void> {
158
- this._keepRunning = options.keepRunning ?? true;
159
- const terminal: ITerminal = this._terminal;
160
- terminal.writeLine(`keepRunning: ${this._keepRunning}`);
161
- while (this._keepRunning) {
162
- if (!this._initWsPromise) {
163
- this._initWsPromise = this._initPlaywrightBrowserTunnelAsync();
164
- } else {
165
- terminal.writeLine(`Tunnel is already running with status: ${this.status}`);
166
- }
167
- await this.waitForCloseAsync();
168
- }
169
- }
170
-
171
- public async stopAsync(): Promise<void> {
172
- this._keepRunning = false;
173
- if (this._pollInterval) {
174
- clearInterval(this._pollInterval);
175
- this._pollInterval = undefined;
176
- }
177
- await this._initWsPromise?.finally(() => {
178
- this._ws?.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Tunnel stopped');
179
- });
180
- }
181
-
182
- public async [Symbol.asyncDispose](): Promise<void> {
183
- this._terminal.writeLine('Disposing WebSocket connection.');
184
- await this.stopAsync();
185
- }
186
-
187
- public async cleanTempFilesAsync(): Promise<void> {
188
- const tmpPath: string = this._playwrightInstallPath;
189
- this._terminal.writeLine(`Cleaning up temporary files in ${tmpPath}`);
190
- try {
191
- await FileSystem.ensureEmptyFolderAsync(tmpPath);
192
- this._terminal.writeLine(`Temporary files cleaned up.`);
193
- } catch (error) {
194
- this._terminal.writeLine(`Failed to clean up temporary files: ${getNormalizedErrorString(error)}`);
195
- }
196
- }
197
-
198
- // TODO: We should implement an uninstall command to remove installed Playwright browsers
199
- // public async uninstallPlaywrightBrowsersAsync(): Promise<void> {}
200
-
201
- private async _runCommandAsync(command: string, args: string[]): Promise<void> {
202
- const tmpPath: string = this._playwrightInstallPath;
203
- await FileSystem.ensureFolderAsync(tmpPath);
204
- this._terminal.writeLine(`Running command: ${command} ${args.join(' ')} in ${tmpPath}`);
205
-
206
- const cp: ChildProcess = Executable.spawn(command, args, {
207
- stdio: [
208
- 'ignore', // stdin
209
- 'pipe', // stdout
210
- 'pipe' // stderr
211
- ],
212
- currentWorkingDirectory: tmpPath
213
- });
214
-
215
- cp.stdout?.pipe(
216
- new TerminalStreamWritable({
217
- terminal: this._terminal,
218
- severity: TerminalProviderSeverity.log
219
- })
220
- );
221
- cp.stderr?.pipe(
222
- new TerminalStreamWritable({
223
- terminal: this._terminal,
224
- severity: TerminalProviderSeverity.error
225
- })
226
- );
227
-
228
- await Executable.waitForExitAsync(cp, { throwOnNonZeroExitCode: true, throwOnSignal: true });
229
- }
230
-
231
- private async _installPlaywrightCoreAsync({
232
- playwrightVersion
233
- }: Pick<IHandshake, 'playwrightVersion'>): Promise<void> {
234
- this._terminal.writeLine(`Installing playwright-core version ${playwrightVersion}`);
235
- await this._runCommandAsync('npm', [
236
- 'install',
237
- `playwright-core-${playwrightVersion}@npm:playwright-core@${playwrightVersion}`
238
- ]);
239
- }
240
-
241
- private async _installPlaywrightBrowsersAsync({
242
- playwrightVersion,
243
- browserName
244
- }: Pick<IHandshake, 'playwrightVersion' | 'browserName'>): Promise<void> {
245
- await this._installPlaywrightCoreAsync({ playwrightVersion });
246
- this._terminal.writeLine(`Executing playwright-core version ${playwrightVersion}`);
247
- await this._runCommandAsync('node', [
248
- `node_modules/playwright-core-${playwrightVersion}/cli.js`,
249
- 'install',
250
- browserName
251
- ]);
252
- }
253
-
254
- private async _tryConnectAsync(): Promise<WebSocket> {
255
- const wsEndpoint: string | undefined = this._wsEndpoint;
256
- if (!wsEndpoint) {
257
- throw new Error('WebSocket endpoint is not defined');
258
- }
259
- return await new Promise<WebSocket>((resolve, reject) => {
260
- const ws: WebSocket = new WebSocket(wsEndpoint);
261
- ws.on('open', () => {
262
- this._terminal.writeLine(`WebSocket connection opened`);
263
- resolve(ws);
264
- });
265
- ws.once('error', (error) => {
266
- reject(error);
267
- });
268
- });
269
- }
270
-
271
- // TODO: Only supporting one test at a time.
272
- // Need to support multiple simultaneous connections for parallel tests.
273
- private async _pollConnectionAsync(): Promise<WebSocket> {
274
- this._terminal.writeLine(`Waiting for WebSocket connection`);
275
- return await new Promise((resolve, reject) => {
276
- this._pollInterval = setInterval(() => {
277
- if (this._pendingConnectionAttempt) {
278
- return; // Skip if a connection attempt is already in progress
279
- }
280
- const connectionPromise: Promise<WebSocket> = this._tryConnectAsync();
281
- this._pendingConnectionAttempt = connectionPromise;
282
- connectionPromise
283
- .then((ws: WebSocket) => {
284
- clearInterval(this._pollInterval);
285
- this._pollInterval = undefined;
286
- ws.removeAllListeners();
287
- this._pendingConnectionAttempt = undefined;
288
- resolve(ws);
289
- })
290
- .catch(() => {
291
- // no-op - will retry on next interval
292
- this._pendingConnectionAttempt = undefined;
293
- });
294
- }, 500);
295
- });
296
- }
297
-
298
- private async _waitForIncomingConnectionAsync(): Promise<WebSocket> {
299
- this._terminal.writeLine('Waiting for incoming WebSocket connection');
300
-
301
- return await new Promise<WebSocket>((resolve, reject) => {
302
- const server: WebSocketServer = new WebSocket.Server({ port: this._listenPort });
303
-
304
- const cleanup = (): void => {
305
- server.removeAllListeners();
306
- };
307
-
308
- server.once('connection', (ws) => {
309
- this._terminal.writeLine('Incoming WebSocket connection established');
310
-
311
- // Stop listening immediately so the port is released
312
- cleanup();
313
- server.close((closeError?: Error) => {
314
- if (closeError) {
315
- this._terminal.writeLine(
316
- `Failed to close WebSocket server: ${
317
- closeError instanceof Error ? closeError.message : closeError
318
- }`
319
- );
320
- }
321
- resolve(ws);
322
- });
323
- });
324
-
325
- server.once('error', (error) => {
326
- this._terminal.writeLine(`WebSocket server error: ${getNormalizedErrorString(error)}`);
327
-
328
- cleanup();
329
- // Try to close (best-effort), then reject
330
- server.close(() => reject(error));
331
- });
332
- });
333
- }
334
-
335
- // TODO: If a user runs this for the first time, `this._playwrightBrowsersInstalled` will be empty
336
- // and it will try to install the browsers every time. We should persist this information. Maybe a cache file with text per
337
- // machine instance?
338
- private async _setupPlaywrightAsync({
339
- playwrightVersion,
340
- browserName
341
- }: Pick<IHandshake, 'playwrightVersion' | 'browserName'>): Promise<typeof import('playwright-core')> {
342
- const browserKey: string = `${playwrightVersion}-${browserName}`;
343
- this._terminal.writeLine(`Checking for installed playwright browsers. Installed browsers: ${browserKey}`);
344
- if (!this._playwrightBrowsersInstalled.has(browserKey)) {
345
- this._terminal.writeLine(
346
- `Playwright browser not found. Installing playwright-core version ${playwrightVersion}`
347
- );
348
- await this._installPlaywrightBrowsersAsync({ playwrightVersion, browserName });
349
- this._playwrightBrowsersInstalled.add(browserKey);
350
- }
351
-
352
- this._terminal.writeLine(`Using playwright-core version ${playwrightVersion} for browser server`);
353
- return await import(`${this._playwrightInstallPath}/node_modules/playwright-core-${playwrightVersion}`);
354
- }
355
-
356
- private async _getPlaywrightBrowserServerProxyAsync({
357
- browserName,
358
- playwrightVersion,
359
- launchOptions
360
- }: Pick<IHandshake, 'playwrightVersion' | 'browserName' | 'launchOptions'>): Promise<IBrowserServerProxy> {
361
- const terminal: ITerminal = this._terminal;
362
-
363
- // Validate launch options against security allowlist
364
- terminal.writeLine('Validating launch options against security allowlist...');
365
- const validationResult: ILaunchOptionsValidationResult =
366
- await LaunchOptionsValidator.validateLaunchOptionsAsync(launchOptions, terminal);
367
-
368
- if (!validationResult.isValid) {
369
- terminal.writeWarningLine(
370
- `Some launch options were denied: ${validationResult.deniedOptions.join(', ')}`
371
- );
372
- terminal.writeWarningLine(`Using filtered launch options. Denied options have been removed.`);
373
- }
374
-
375
- // Use filtered options and ensure headless: false for headed tests in codespaces
376
- // This is critical for the extension's purpose - enabling headed Playwright tests remotely
377
- const safeOptions: LaunchOptions = {
378
- ...validationResult.filteredOptions,
379
- headless: false
380
- };
381
-
382
- // Log the validated options, excluding 'headless' since it's always false for this extension
383
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
384
- const { headless, ...logOptions } = safeOptions;
385
- terminal.writeLine(
386
- `Launch options after validation: ${JSON.stringify(logOptions)} (headless: false enforced)`
387
- );
388
-
389
- const playwright: typeof import('playwright-core') = await this._setupPlaywrightAsync({
390
- playwrightVersion,
391
- browserName
392
- });
393
-
394
- const { chromium, firefox, webkit } = playwright;
395
- const browsers: Record<BrowserName, BrowserType> = { chromium, firefox, webkit };
396
-
397
- const browserServer: BrowserServer = await browsers[browserName].launchServer(safeOptions);
398
-
399
- if (!browserServer) {
400
- throw new Error(
401
- `Failed to launch browser server for ${browserName} with options: ${JSON.stringify(safeOptions)}`
402
- );
403
- }
404
-
405
- terminal.writeLine(`Launched ${browserName} browser server`);
406
- const client: WebSocket = new WebSocket(browserServer.wsEndpoint());
407
-
408
- return {
409
- browserServer,
410
- client
411
- };
412
- }
413
-
414
- private _validateHandshake(rawHandshake: unknown): IHandshake {
415
- if (
416
- typeof rawHandshake !== 'object' ||
417
- rawHandshake === null ||
418
- 'action' in rawHandshake === false ||
419
- 'browserName' in rawHandshake === false ||
420
- 'playwrightVersion' in rawHandshake === false ||
421
- 'launchOptions' in rawHandshake === false ||
422
- typeof rawHandshake.action !== 'string' ||
423
- typeof rawHandshake.browserName !== 'string' ||
424
- typeof rawHandshake.playwrightVersion !== 'string' ||
425
- typeof rawHandshake.launchOptions !== 'object'
426
- ) {
427
- throw new Error(`Invalid handshake: ${JSON.stringify(rawHandshake)}. Must be an object.`);
428
- }
429
-
430
- const { action, browserName, playwrightVersion, launchOptions } = rawHandshake;
431
-
432
- if (action !== 'handshake') {
433
- throw new Error(`Invalid action: ${action}. Expected 'handshake'.`);
434
- }
435
- const playwrightVersionSemver: semver.SemVer | null = semver.coerce(playwrightVersion);
436
- if (!playwrightVersionSemver) {
437
- throw new Error(`Invalid Playwright version: ${playwrightVersion}. Must be a valid semver version.`);
438
- }
439
- if (!isValidBrowserName(browserName)) {
440
- throw new Error(
441
- `Invalid browser name: ${browserName}. Must be one of ${Array.from(validBrowserNames).join(', ')}.`
442
- );
443
- }
444
-
445
- return {
446
- action,
447
- launchOptions: launchOptions as LaunchOptions,
448
- playwrightVersion: playwrightVersionSemver,
449
- browserName
450
- };
451
- }
452
-
453
- // ws1 is the tunnel websocket, ws2 is the browser server websocket
454
- private async _setupForwardingAsync(ws1: WebSocket, ws2: WebSocket): Promise<void> {
455
- this._terminal.writeLine('Setting up message forwarding between ws1 and ws2');
456
- this._terminal.writeLine(` ws1 (tunnel) readyState: ${getWebSocketReadyStateString(ws1.readyState)}`);
457
- this._terminal.writeLine(` ws2 (browser) readyState: ${getWebSocketReadyStateString(ws2.readyState)}`);
458
-
459
- const messageCount: { ws1ToWs2: number; ws2ToWs1: number } = { ws1ToWs2: 0, ws2ToWs1: 0 };
460
-
461
- ws1.on('message', (data) => {
462
- messageCount.ws1ToWs2++;
463
- if (ws2.readyState === WebSocket.OPEN) {
464
- ws2.send(data);
465
- } else {
466
- this._terminal.writeLine(
467
- `ws2 not open (state: ${getWebSocketReadyStateString(ws2.readyState)}). Dropping message #${messageCount.ws1ToWs2}`
468
- );
469
- }
470
- });
471
- ws2.on('message', (data) => {
472
- messageCount.ws2ToWs1++;
473
- if (ws1.readyState === WebSocket.OPEN) {
474
- ws1.send(data);
475
- } else {
476
- this._terminal.writeLine(
477
- `ws1 not open (state: ${getWebSocketReadyStateString(ws1.readyState)}). Dropping message #${messageCount.ws2ToWs1}`
478
- );
479
- }
480
- });
481
-
482
- ws1.once('close', (code: number, reason: Buffer) => {
483
- const reasonStr: string = reason.toString() || 'no reason provided';
484
- const codeDescription: string = getWebSocketCloseReason(code);
485
- this._terminal.writeLine(
486
- `ws1 (tunnel) closed - code: ${code} (${codeDescription}), reason: ${reasonStr}`
487
- );
488
- this._terminal.writeLine(
489
- ` Messages forwarded: ws1->ws2: ${messageCount.ws1ToWs2}, ws2->ws1: ${messageCount.ws2ToWs1}`
490
- );
491
- if (ws2.readyState === WebSocket.OPEN) {
492
- this._terminal.writeLine(' Closing ws2 (browser) in response');
493
- ws2.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Tunnel closed');
494
- }
495
- });
496
- ws2.once('close', (code: number, reason: Buffer) => {
497
- const reasonStr: string = reason.toString() || 'no reason provided';
498
- const codeDescription: string = getWebSocketCloseReason(code);
499
- this._terminal.writeLine(
500
- `ws2 (browser) closed - code: ${code} (${codeDescription}), reason: ${reasonStr}`
501
- );
502
- this._terminal.writeLine(
503
- ` Messages forwarded: ws1->ws2: ${messageCount.ws1ToWs2}, ws2->ws1: ${messageCount.ws2ToWs1}`
504
- );
505
- if (ws1.readyState === WebSocket.OPEN) {
506
- this._terminal.writeLine(' Closing ws1 (tunnel) in response');
507
- ws1.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Browser closed');
508
- }
509
- });
510
-
511
- ws1.once('error', (error) => {
512
- this._terminal.writeErrorLine(`ws1 (tunnel) WebSocket error: ${getNormalizedErrorString(error)}`);
513
- this._terminal.writeErrorLine(` ws1 readyState: ${getWebSocketReadyStateString(ws1.readyState)}`);
514
- });
515
- ws2.once('error', (error) => {
516
- this._terminal.writeErrorLine(`ws2 (browser) WebSocket error: ${getNormalizedErrorString(error)}`);
517
- this._terminal.writeErrorLine(` ws2 readyState: ${getWebSocketReadyStateString(ws2.readyState)}`);
518
- });
519
- }
520
-
521
- /**
522
- * Initializes the Playwright browser tunnel by establishing a WebSocket connection
523
- * and setting up the browser server.
524
- * Returns when the handshake is complete and the browser server is running.
525
- */
526
- private async _initPlaywrightBrowserTunnelAsync(): Promise<WebSocket> {
527
- let handshake: IHandshake | undefined = undefined;
528
- let client: WebSocket | undefined = undefined;
529
- let browserServer: BrowserServer | undefined = undefined;
530
-
531
- this.status = 'waiting-for-connection';
532
- const ws: WebSocket =
533
- this._mode === 'poll-connection'
534
- ? await this._pollConnectionAsync()
535
- : await this._waitForIncomingConnectionAsync();
536
-
537
- ws.on('open', () => {
538
- this._terminal.writeLine(`WebSocket connection established`);
539
- handshake = undefined;
540
- });
541
-
542
- ws.on('error', (error) => {
543
- this._terminal.writeLine(`WebSocket error occurred: ${getNormalizedErrorString(error)}`);
544
- });
545
-
546
- ws.on('close', async (code: number, reason: Buffer) => {
547
- const reasonStr: string = reason.toString() || 'no reason provided';
548
- const codeDescription: string = getWebSocketCloseReason(code);
549
- this._initWsPromise = undefined;
550
- this.status = 'stopped';
551
- this._terminal.writeLine(
552
- `WebSocket connection closed - code: ${code} (${codeDescription}), reason: ${reasonStr}`
553
- );
554
- this._terminal.writeLine(` handshake received: ${handshake !== undefined}`);
555
- this._terminal.writeLine(` browserServer active: ${browserServer !== undefined}`);
556
- if (browserServer) {
557
- this._terminal.writeLine(' Closing browser server...');
558
- await browserServer.close();
559
- this._terminal.writeLine(' Browser server closed');
560
- }
561
- });
562
-
563
- return await new Promise<WebSocket>((resolve, reject) => {
564
- const onMessageHandler = async (data: RawData): Promise<void> => {
565
- const terminal: ITerminal = this._terminal;
566
- if (!handshake) {
567
- try {
568
- const rawHandshakeString: string = data.toString();
569
- const rawHandshake: unknown = JSON.parse(rawHandshakeString);
570
- terminal.writeLine(`Received handshake: ${rawHandshakeString}`);
571
- handshake = this._validateHandshake(rawHandshake);
572
-
573
- // Call the onBeforeLaunch callback if provided
574
- if (this._onBeforeLaunch) {
575
- terminal.writeLine('Requesting user approval before launching browser server...');
576
- const shouldProceed: boolean = await this._onBeforeLaunch(handshake);
577
- if (!shouldProceed) {
578
- terminal.writeLine('Browser server launch cancelled by user.');
579
- ws.off('message', onMessageHandler);
580
- ws.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Launch cancelled by user');
581
- reject(new Error('Browser server launch cancelled by user'));
582
- return;
583
- }
584
- terminal.writeLine('User approved browser server launch.');
585
- }
586
-
587
- this.status = 'setting-up-browser-server';
588
- const browserServerProxy: IBrowserServerProxy =
589
- await this._getPlaywrightBrowserServerProxyAsync(handshake);
590
- client = browserServerProxy.client;
591
- browserServer = browserServerProxy.browserServer;
592
-
593
- // Monitor browser server process for crashes
594
- const browserProcess: ChildProcess | null = browserServer.process();
595
- if (browserProcess) {
596
- browserProcess.on('exit', (code: number | null, signal: string | null) => {
597
- terminal.writeErrorLine(`Browser server process exited - code: ${code}, signal: ${signal}`);
598
- });
599
- browserProcess.on('error', (err: Error) => {
600
- terminal.writeErrorLine(`Browser server process error: ${getNormalizedErrorString(err)}`);
601
- });
602
- terminal.writeDebugLine(`Browser server process started with PID: ${browserProcess.pid}`);
603
- } else {
604
- terminal.writeDebugLine('Warning: Browser server process handle not available for monitoring');
605
- }
606
-
607
- this.status = 'browser-server-running';
608
-
609
- // Send ack so that the counterpart also knows to start forwarding messages.
610
- // NOTE: The 1-second delay is an intentional workaround. In the current
611
- // protocol, the remote tunnel endpoint does not expose an explicit "ready"
612
- // signal for when it has finished initializing its own forwarding logic
613
- // after receiving the initial handshake. This
614
- // delay avoids races where early messages could be dropped or mishandled
615
- // if they arrive before the remote side is fully ready.
616
- //
617
- // TODO: A future improvement would be to replace this delay with a deterministic
618
- // synchronization mechanism (e.g. an explicit "ready" message or event)
619
- // instead of relying on a fixed timeout.
620
- await Async.sleepAsync(2000);
621
-
622
- ws.send(JSON.stringify({ action: 'handshakeAck' }));
623
- await this._setupForwardingAsync(ws, client);
624
-
625
- // Clean up message handler after successful handshake
626
- ws.off('message', onMessageHandler);
627
- resolve(ws);
628
- } catch (error) {
629
- terminal.writeLine(`Error processing handshake: ${error}`);
630
- this.status = 'error';
631
-
632
- // Cleanup and close connection on error
633
- ws.off('message', onMessageHandler);
634
- ws.close(WebSocketCloseCode.INTERNAL_ERROR, 'Handshake error');
635
- reject(error);
636
- return;
637
- }
638
- } else {
639
- if (!client) {
640
- terminal.writeLine('Browser WebSocket client is not initialized.');
641
- ws.off('message', onMessageHandler);
642
- ws.close(WebSocketCloseCode.INTERNAL_ERROR, 'Browser client not initialized');
643
- return;
644
- }
645
- }
646
- };
647
- ws.on('message', onMessageHandler);
648
- });
649
- }
650
- }
package/src/index.ts DELETED
@@ -1,38 +0,0 @@
1
- // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2
- // See LICENSE in the project root for license information.
3
-
4
- /**
5
- * Run a Playwright browser server in one environment and drive it from another environment by
6
- * forwarding Playwright's WebSocket traffic through a tunnel.
7
- *
8
- * @remarks
9
- * This package is intended for remote development and CI scenarios (for example: Codespaces,
10
- * devcontainers, or a separate "browser host" machine) where you want tests to run in one
11
- * environment but the actual browser process to run in another.
12
- *
13
- * The package provides two main APIs:
14
- * - {@link PlaywrightTunnel} - Run on the browser host to launch the real browser server and forward messages
15
- * - {@link tunneledBrowserConnection} - Run on the test runner to create a local endpoint that your Playwright client can connect to
16
- *
17
- * @packageDocumentation
18
- */
19
-
20
- export { PlaywrightTunnel } from './PlaywrightBrowserTunnel';
21
- export type {
22
- BrowserName,
23
- TunnelStatus,
24
- IPlaywrightTunnelOptions,
25
- IHandshake
26
- } from './PlaywrightBrowserTunnel';
27
- export { createTunneledBrowserAsync, tunneledBrowserConnection } from './tunneledBrowserConnection';
28
- export type {
29
- IDisposableTunneledBrowserConnection,
30
- IDisposableTunneledBrowser
31
- } from './tunneledBrowserConnection';
32
- export {
33
- isExtensionInstalledAsync,
34
- EXTENSION_INSTALLED_FILENAME,
35
- getNormalizedErrorString
36
- } from './utilities';
37
- export { LaunchOptionsValidator, LAUNCH_OPTIONS_ALLOWLIST_FILENAME } from './LaunchOptionsValidator';
38
- export type { ILaunchOptionsAllowlist, ILaunchOptionsValidationResult } from './LaunchOptionsValidator';