/** * Copyright (c) 2019 The xterm.js authors. All rights reserved. * @license MIT */ import { Browser, JSHandle, Page } from '@playwright/test'; import { deepStrictEqual, strictEqual } from 'assert'; import type { IRenderDimensions as IRenderDimensionsInternal } from 'browser/renderer/shared/Types'; import type { IRenderService } from 'browser/services/Services'; import type { ICoreTerminal, IDisposable, IMarker } from 'common/Types'; import * as playwright from '@playwright/test'; import { IBuffer, IBufferCell, IBufferLine, IBufferNamespace, IBufferRange, IDecoration, IDecorationOptions, IModes, IRenderDimensions, ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from '@xterm/xterm'; type PageFunction = (arg: Arg) => R | Promise; export interface ITestContext { browser: Browser; page: Page; termHandle: JSHandle; proxy: TerminalProxy; } export async function createTestContext(browser: Browser): Promise { const page = await browser.newPage(); page.on('console', e => console.log(`[${browser.browserType().name()}:${e.type()}]`, e)); page.on('pageerror', e => console.error(`[${browser.browserType().name()}]`, e)); await page.goto('/test'); const proxy = new TerminalProxy(page); proxy.initPage(); return { browser, page, termHandle: await page.evaluateHandle('window.term'), proxy }; } interface IListener { (arg1: T, arg2: U): void; } export interface IEvent { (listener: (arg1: T, arg2: U) => any): IDisposable; } class EventEmitter { private _listeners: Set> = new Set(); private _event?: IEvent; private _disposed: boolean = false; public get event(): IEvent { this._event ??= (listener: (arg1: T, arg2: U) => any) => { this._listeners.add(listener); const disposable = { dispose: () => { if (!this._disposed) { this._listeners.delete(listener); } } }; return disposable; }; return this._event; } public fire(arg1: T, arg2: U): void { const queue: IListener[] = []; for (const l of this._listeners.values()) { queue.push(l); } for (let i = 0; i < queue.length; i++) { queue[i].call(undefined, arg1, arg2); } } public dispose(): void { this.clearListeners(); this._disposed = true; } public clearListeners(): void { if (this._listeners) { this._listeners.clear(); } } } type EnsureAsync = T extends PromiseLike ? T : Promise; type EnsureAsyncProperties = { [Key in keyof T]: EnsureAsync }; type EnsureAsyncMethods = { [K in keyof T]: T[K] extends (...args: infer Args) => infer Return ? (...args: Args) => Promise : T[K]; }; /** * A type that proxy objects implement that enables overriding a base interface with a set of types * to override as async (ie. properties that are not synced to the node side), and a set of types to * omit from the interface (ie. properties that map to completely different implementations). */ type PlaywrightApiProxy = ( // Interfaces that the proxy implements as async EnsureAsyncProperties> & EnsureAsyncMethods> & // Interfaces that the proxy implements as is (exclude async/custom) Omit ); interface ITerminalProxyCustomMethods { evaluate(pageFunction: PageFunction): Promise; write(data: string | Uint8Array): Promise; } type TerminalProxyAsyncPropOverrides = 'cols' | 'rows' | 'modes'; type TerminalProxyAsyncMethodOverrides = 'hasSelection' | 'getSelection' | 'getSelectionPosition' | 'registerMarker' | 'registerDecoration'; type TerminalProxyCustomOverrides = 'buffer' | 'dimensions' | ( // The below are not implemented yet 'element' | 'screenElement' | 'textarea' | 'markers' | 'unicode' | 'parser' | 'options' | 'open' | 'attachCustomKeyEventHandler' | 'attachCustomWheelEventHandler' | 'registerLinkProvider' | 'registerCharacterJoiner' | 'deregisterCharacterJoiner' | 'loadAddon' ); export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApiProxy { constructor(private readonly _page: Page) { } /** * Initialize the proxy for a new playwright page. */ public async initPage(): Promise { await this._page.exposeFunction('onBell', () => this._onBell.fire()); await this._page.exposeFunction('onBinary', (e: string) => this._onBinary.fire(e)); await this._page.exposeFunction('onCursorMove', () => this._onCursorMove.fire()); await this._page.exposeFunction('onData', (e: string) => this._onData.fire(e)); await this._page.exposeFunction('onKey', (e: { key: string, domEvent: KeyboardEvent }) => this._onKey.fire(e)); await this._page.exposeFunction('onLineFeed', () => this._onLineFeed.fire()); await this._page.exposeFunction('onRender', (e: { start: number, end: number }) => this._onRender.fire(e)); await this._page.exposeFunction('onResize', (e: { cols: number, rows: number }) => this._onResize.fire(e)); await this._page.exposeFunction('onScroll', (e: number) => this._onScroll.fire(e)); await this._page.exposeFunction('onSelectionChange', () => this._onSelectionChange.fire()); await this._page.exposeFunction('onTitleChange', (e: string) => this._onTitleChange.fire(e)); await this._page.exposeFunction('onWriteParsed', () => this._onWriteParsed.fire()); await this._page.exposeFunction('onDimensionsChange', (e: IRenderDimensions) => this._onDimensionsChange.fire(e)); } /** * Initialize the proxy for a new terminal object. */ public async initTerm(): Promise { this._onBell.dispose(); this._onBinary.dispose(); this._onCursorMove.dispose(); this._onData.dispose(); this._onKey.dispose(); this._onLineFeed.dispose(); this._onRender.dispose(); this._onResize.dispose(); this._onScroll.dispose(); this._onSelectionChange.dispose(); this._onTitleChange.dispose(); this._onWriteParsed.dispose(); this._onDimensionsChange.dispose(); this._onBell = new EventEmitter(); this._onBinary = new EventEmitter(); this._onCursorMove = new EventEmitter(); this._onData = new EventEmitter(); this._onKey = new EventEmitter(); this._onLineFeed = new EventEmitter(); this._onRender = new EventEmitter(); this._onResize = new EventEmitter(); this._onScroll = new EventEmitter(); this._onSelectionChange = new EventEmitter(); this._onTitleChange = new EventEmitter(); this._onWriteParsed = new EventEmitter(); this._onDimensionsChange = new EventEmitter(); await this.evaluate(([term]) => term.onBell((window as any).onBell)); await this.evaluate(([term]) => term.onBinary((window as any).onBinary)); await this.evaluate(([term]) => term.onCursorMove((window as any).onCursorMove)); await this.evaluate(([term]) => term.onData((window as any).onData)); await this.evaluate(([term]) => term.onKey((window as any).onKey)); await this.evaluate(([term]) => term.onLineFeed((window as any).onLineFeed)); await this.evaluate(([term]) => term.onRender((window as any).onRender)); await this.evaluate(([term]) => term.onResize((window as any).onResize)); await this.evaluate(([term]) => term.onScroll((window as any).onScroll)); await this.evaluate(([term]) => term.onSelectionChange((window as any).onSelectionChange)); await this.evaluate(([term]) => term.onTitleChange((window as any).onTitleChange)); await this.evaluate(([term]) => term.onWriteParsed((window as any).onWriteParsed)); await this.evaluate(([term]) => term.onDimensionsChange((window as any).onDimensionsChange)); } // #region Events private _onBell = new EventEmitter(); public get onBell(): IEvent { return this._onBell.event; } private _onBinary = new EventEmitter(); public get onBinary(): IEvent { return this._onBinary.event; } private _onCursorMove = new EventEmitter(); public get onCursorMove(): IEvent { return this._onCursorMove.event; } private _onData = new EventEmitter(); public get onData(): IEvent { return this._onData.event; } private _onKey = new EventEmitter<{ key: string, domEvent: KeyboardEvent }>(); public get onKey(): IEvent<{ key: string, domEvent: KeyboardEvent }> { return this._onKey.event; } private _onLineFeed = new EventEmitter(); public get onLineFeed(): IEvent { return this._onLineFeed.event; } private _onRender = new EventEmitter<{ start: number, end: number }>(); public get onRender(): IEvent<{ start: number, end: number }> { return this._onRender.event; } private _onResize = new EventEmitter<{ cols: number, rows: number }>(); public get onResize(): IEvent<{ cols: number, rows: number }> { return this._onResize.event; } private _onScroll = new EventEmitter(); public get onScroll(): IEvent { return this._onScroll.event; } private _onDimensionsChange = new EventEmitter(); public get onDimensionsChange(): IEvent { return this._onDimensionsChange.event; } private _onSelectionChange = new EventEmitter(); public get onSelectionChange(): IEvent { return this._onSelectionChange.event; } private _onTitleChange = new EventEmitter(); public get onTitleChange(): IEvent { return this._onTitleChange.event; } private _onWriteParsed = new EventEmitter(); public get onWriteParsed(): IEvent { return this._onWriteParsed.event; } // #endregion // #region Simple properties public get cols(): Promise { return this.evaluate(([term]) => term.cols); } public get rows(): Promise { return this.evaluate(([term]) => term.rows); } public get modes(): Promise { return this.evaluate(([term]) => term.modes); } public get dimensions(): Promise { return this.evaluate(([term]) => term.dimensions); } // #endregion // #region Complex properties public get buffer(): TerminalBufferNamespaceProxy { return new TerminalBufferNamespaceProxy(this._page, this); } /** * Exposes somewhat unsafe access to internals for testing things difficult to do with the regular * API */ public get core(): TerminalCoreProxy { return new TerminalCoreProxy(this._page, this); } // #endregion // #region Proxied methods public async dispose(): Promise { return this.evaluate(([term]) => term.dispose()); } public async reset(): Promise { return this.evaluate(([term]) => term.reset()); } public async clear(): Promise { return this.evaluate(([term]) => term.clear()); } public async focus(): Promise { return this.evaluate(([term]) => term.focus()); } public async blur(): Promise { return this.evaluate(([term]) => term.blur()); } public async hasSelection(): Promise { return this.evaluate(([term]) => term.hasSelection()); } public async getSelection(): Promise { return this.evaluate(([term]) => term.getSelection()); } public async getSelectionPosition(): Promise { return this.evaluate(([term]) => term.getSelectionPosition()); } public async selectAll(): Promise { return this.evaluate(([term]) => term.selectAll()); } public async selectLines(start: number, end: number): Promise { return this._page.evaluate(([term, start, end]) => term.selectLines(start, end), [await this.getHandle(), start, end] as const); } public async clearSelection(): Promise { return this.evaluate(([term]) => term.clearSelection()); } public async select(column: number, row: number, length: number): Promise { return this._page.evaluate(([term, column, row, length]) => term.select(column, row, length), [await this.getHandle(), column, row, length] as const); } public async paste(data: string): Promise { return this._page.evaluate(([term, data]) => term.paste(data), [await this.getHandle(), data] as const); } public async refresh(start: number, end: number): Promise { return this._page.evaluate(([term, start, end]) => term.refresh(start, end), [await this.getHandle(), start, end] as const); } public async getOption(key: T): Promise { return this._page.evaluate(([term, key]) => term.options[key as T], [await this.getHandle(), key] as const); } public async setOption(key: T, value: ITerminalOptions[T]): Promise { return this._page.evaluate(([term, key, value]) => term.options[key as T] = (value as ITerminalOptions[T]), [await this.getHandle(), key, value] as const); } public async setOptions(value: Partial): Promise { return this._page.evaluate(([term, value]) => { term.options = value; }, [await this.getHandle(), value] as const); } public async scrollToTop(): Promise { return this.evaluate(([term]) => term.scrollToTop()); } public async scrollToBottom(): Promise { return this.evaluate(([term]) => term.scrollToBottom()); } public async scrollPages(pageCount: number): Promise { return this._page.evaluate(([term, pageCount]) => term.scrollPages(pageCount), [await this.getHandle(), pageCount] as const); } public async scrollToLine(line: number): Promise { return this._page.evaluate(([term, line]) => term.scrollToLine(line), [await this.getHandle(), line] as const); } public async scrollLines(amount: number): Promise { return this._page.evaluate(([term, amount]) => term.scrollLines(amount), [await this.getHandle(), amount] as const); } public async write(data: string | Uint8Array): Promise { return this._page.evaluate(([term, data]) => { return new Promise(r => term.write(typeof data === 'string' ? data : new Uint8Array(data), r)); }, [await this.getHandle(), typeof data === 'string' ? data : Array.from(data)] as const); } public async writeln(data: string | Uint8Array): Promise { return this._page.evaluate(([term, data]) => { return new Promise(r => term.writeln(typeof data === 'string' ? data : new Uint8Array(data), r)); }, [await this.getHandle(), typeof data === 'string' ? data : Array.from(data)] as const); } public async input(data: string, wasUserInput: boolean = true): Promise { return this.evaluate(([term]) => term.input(data, wasUserInput)); } public async resize(cols: number, rows: number): Promise { return this._page.evaluate(([term, cols, rows]) => term.resize(cols, rows), [await this.getHandle(), cols, rows] as const); } public async registerMarker(y?: number | undefined): Promise { return this._page.evaluate(([term, y]) => term.registerMarker(y), [await this.getHandle(), y] as const); } public async registerDecoration(decorationOptions: IDecorationOptions): Promise { return this._page.evaluate(([term, decorationOptions]) => term.registerDecoration(decorationOptions), [await this.getHandle(), decorationOptions] as const); } public async clearTextureAtlas(): Promise { return this.evaluate(([term]) => term.clearTextureAtlas()); } // #endregion public async evaluate(pageFunction: PageFunction): Promise { return this._page.evaluate(pageFunction, [await this.getHandle()]); } public async evaluateHandle(pageFunction: PageFunction): Promise> { return this._page.evaluateHandle(pageFunction, [await this.getHandle()]); } public async getHandle(): Promise> { // This is async because it must be evaluated each time it is called since term may have changed return this._page.evaluateHandle('window.term'); } } class TerminalBufferNamespaceProxy implements PlaywrightApiProxy { private _onBufferChange = new EventEmitter(); public readonly onBufferChange = this._onBufferChange.event; constructor( private readonly _page: Page, private readonly _proxy: TerminalProxy ) { } public get active(): TerminalBufferProxy { return new TerminalBufferProxy(this._page, this._proxy, this._proxy.evaluateHandle(([term]) => term.buffer.active)); } public get normal(): TerminalBufferProxy { return new TerminalBufferProxy(this._page, this._proxy, this._proxy.evaluateHandle(([term]) => term.buffer.normal)); } public get alternate(): TerminalBufferProxy { return new TerminalBufferProxy(this._page, this._proxy, this._proxy.evaluateHandle(([term]) => term.buffer.alternate)); } } // TODO: Adopt PlaywrightApiProxy class TerminalBufferProxy /* implements EnsureAsyncProperties*/ { constructor( private readonly _page: Page, private readonly _proxy: TerminalProxy, private readonly _handle: Promise> ) { } public get type(): Promise<'normal' | 'alternate'> { return this.evaluate(([buffer]) => buffer.type); } public get cursorY(): Promise { return this.evaluate(([buffer]) => buffer.cursorY); } public get cursorX(): Promise { return this.evaluate(([buffer]) => buffer.cursorX); } public get viewportY(): Promise { return this.evaluate(([buffer]) => buffer.viewportY); } public get baseY(): Promise { return this.evaluate(([buffer]) => buffer.baseY); } public get length(): Promise { return this.evaluate(([buffer]) => buffer.length); } public async getLine(y: number): Promise { const lineHandle = await this._page.evaluateHandle(([buffer, y]) => buffer.getLine(y), [await this._handle, y] as const); const value = await lineHandle.jsonValue(); if (value) { return new TerminalBufferLine(this._page, lineHandle as JSHandle); } return undefined; } public async evaluate(pageFunction: PageFunction): Promise { return this._page.evaluate(pageFunction, [await this._handle]); } } // TODO: Adopt PlaywrightApiProxy class TerminalBufferLine { constructor( private readonly _page: Page, private readonly _handle: JSHandle ) { } public get length(): Promise { return this.evaluate(([bufferLine]) => bufferLine.length); } public get isWrapped(): Promise { return this.evaluate(([bufferLine]) => bufferLine.isWrapped); } public translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): Promise { return this._page.evaluate(([bufferLine, trimRight, startColumn, endColumn]) => { return bufferLine.translateToString(trimRight, startColumn, endColumn); }, [this._handle, trimRight, startColumn, endColumn] as const); } public async getCell(x: number): Promise { const cellHandle = await this._page.evaluateHandle(([bufferLine, x]) => bufferLine.getCell(x), [this._handle, x] as const); const value = await cellHandle.jsonValue(); if (value) { return new TerminalBufferCell(this._page, cellHandle as JSHandle); } return undefined; } public async evaluate(pageFunction: PageFunction): Promise { return this._page.evaluate(pageFunction, [this._handle]); } } // TODO: Adopt PlaywrightApiProxy class TerminalBufferCell { constructor( private readonly _page: Page, private readonly _handle: JSHandle ) { } public getWidth(): Promise { return this.evaluate(([cell]) => cell.getWidth()); } public getChars(): Promise { return this.evaluate(([cell]) => cell.getChars()); } public getCode(): Promise { return this.evaluate(([cell]) => cell.getCode()); } public getFgColorMode(): Promise { return this.evaluate(([cell]) => cell.getFgColorMode()); } public getBgColorMode(): Promise { return this.evaluate(([cell]) => cell.getBgColorMode()); } public getFgColor(): Promise { return this.evaluate(([cell]) => cell.getFgColor()); } public getBgColor(): Promise { return this.evaluate(([cell]) => cell.getBgColor()); } public isBold(): Promise { return this.evaluate(([cell]) => cell.isBold()); } public isItalic(): Promise { return this.evaluate(([cell]) => cell.isItalic()); } public isDim(): Promise { return this.evaluate(([cell]) => cell.isDim()); } public isUnderline(): Promise { return this.evaluate(([cell]) => cell.isUnderline()); } public isBlink(): Promise { return this.evaluate(([cell]) => cell.isBlink()); } public isInverse(): Promise { return this.evaluate(([cell]) => cell.isInverse()); } public isInvisible(): Promise { return this.evaluate(([cell]) => cell.isInvisible()); } public isStrikethrough(): Promise { return this.evaluate(([cell]) => cell.isStrikethrough()); } public isOverline(): Promise { return this.evaluate(([cell]) => cell.isOverline()); } public isFgRGB(): Promise { return this.evaluate(([cell]) => cell.isFgRGB()); } public isBgRGB(): Promise { return this.evaluate(([cell]) => cell.isBgRGB()); } public isFgPalette(): Promise { return this.evaluate(([cell]) => cell.isFgPalette()); } public isBgPalette(): Promise { return this.evaluate(([cell]) => cell.isBgPalette()); } public isFgDefault(): Promise { return this.evaluate(([cell]) => cell.isFgDefault()); } public isBgDefault(): Promise { return this.evaluate(([cell]) => cell.isBgDefault()); } public isAttributeDefault(): Promise { return this.evaluate(([cell]) => cell.isAttributeDefault()); } public async evaluate(pageFunction: PageFunction): Promise { return this._page.evaluate(pageFunction, [this._handle]); } } class TerminalCoreProxy { constructor( private readonly _page: Page, private readonly _proxy: TerminalProxy ) { } public get isDisposed(): Promise { return this.evaluate(([core]) => (core as any)._isDisposed); } public get renderDimensions(): Promise { return this.evaluate(([core]) => ((core as any)._renderService as IRenderService).dimensions); } public async triggerBinaryEvent(data: string): Promise { return this._page.evaluate(([core, data]) => core.coreService.triggerBinaryEvent(data), [await this._getCoreHandle(), data] as const); } private async _getCoreHandle(): Promise> { return this._proxy.evaluateHandle(([term]) => (term as any)._core as ICoreTerminal); } public async evaluate(pageFunction: PageFunction): Promise { return this._page.evaluate(pageFunction, [await this._getCoreHandle()]); } } export async function openTerminal( ctx: ITestContext, options: ITerminalOptions | ITerminalInitOnlyOptions = {}, testOptions: { useShadowDom?: boolean, loadUnicodeGraphemesAddon?: boolean } = {} ): Promise { testOptions.useShadowDom ??= false; testOptions.loadUnicodeGraphemesAddon ??= true; await ctx.page.evaluate(` if ('term' in window) { try { window.term.dispose(); } catch {} } `); // HACK: Tests may have side effects that could cause the terminal not to be removed. This // assertion catches this case early. strictEqual(await ctx.page.evaluate(`document.querySelector('#terminal-container').children.length`), 0, 'there must be no terminals on the page'); let script = ` window.term = new window.Terminal(${JSON.stringify({ allowProposedApi: true, ...options })}); let element = document.querySelector('#terminal-container'); // Remove shadow root if it exists const newElement = element.cloneNode(false); element.replaceWith(newElement); element = newElement `; if (testOptions.useShadowDom) { script += ` const shadowRoot = element.attachShadow({ mode: "open" }); // Copy parent styles to shadow DOM const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"]')); styles.forEach((styleEl) => { const clone = document.createElement('link'); clone.rel = 'stylesheet'; clone.href = styleEl.href; shadowRoot.appendChild(clone); }); // Create new element inside the shadow DOM element = document.createElement('div'); element.style.width = '100%'; element.style.height = '100%'; shadowRoot.appendChild(element); `; } script += ` window.term.open(element); `; await ctx.page.evaluate(script); // HACK: This is a soft layer breaker that's temporarily included until unicode graphemes have // more complete integration tests. See https://github.com/xtermjs/xterm.js/pull/4519#discussion_r1285234453 if (testOptions.loadUnicodeGraphemesAddon) { await ctx.page.evaluate(` window.unicode = new UnicodeGraphemesAddon(); window.term.loadAddon(window.unicode); window.term.unicode.activeVersion = '15-graphemes'; `); } await ctx.page.waitForSelector('.xterm-rows'); ctx.termHandle = await ctx.page.evaluateHandle('window.term'); await ctx.proxy.initTerm(); } export type MaybeAsync = Promise | T; interface IPollForOptions { equalityFn?: (a: T, b: T) => boolean; maxDuration?: number; stack?: string; } export async function pollFor(page: playwright.Page, evalOrFn: string | (() => MaybeAsync), val: T, preFn?: () => Promise, options?: IPollForOptions): Promise { options ??= {}; options.stack ??= new Error().stack; if (preFn) { await preFn(); } const result = typeof evalOrFn === 'string' ? await page.evaluate(evalOrFn) : await evalOrFn(); if (process.env.DEBUG) { console.log('pollFor\n actual: ', JSON.stringify(result), ' expected: ', JSON.stringify(val)); } let equalityCheck: boolean; if (options.equalityFn) { equalityCheck = options.equalityFn(result as T, val); } else { equalityCheck = true; try { deepStrictEqual(result, val); } catch { equalityCheck = false; } } if (!equalityCheck) { options.maxDuration ??= 2000; if (options.maxDuration <= 0) { deepStrictEqual(result, val, ([ `pollFor max duration exceeded.`, (`Last comparison: ` + `${typeof result === 'object' ? JSON.stringify(result) : result} (actual) !== ` + `${typeof val === 'object' ? JSON.stringify(val) : val} (expected)`), `Stack: ${options.stack}` ].join('\n'))); } return new Promise(r => { setTimeout(() => r(pollFor(page, evalOrFn, val, preFn, { ...options, maxDuration: options!.maxDuration! - 10, stack: options!.stack })), 10); }); } } export async function pollForApproximate(page: playwright.Page, marginOfError: number, evalOrFn: string | (() => MaybeAsync), val: T, preFn?: () => Promise, maxDuration?: number, stack?: string): Promise { await pollFor(page, evalOrFn, val, preFn, { maxDuration, stack, equalityFn: (a, b) => { if (a === b) { return true; } if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) { let success = true; for (let i = 0 ; i < a.length; i++) { if (Math.abs(a[i] - b[i]) > marginOfError) { success = false; break; } } if (success) { return true; } } return false; } }); } export async function writeSync(page: playwright.Page, data: string): Promise { await page.evaluate(` window.ready = false; window.term.write('${data}', () => window.ready = true); `); await pollFor(page, 'window.ready', true); } export async function timeout(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } export function getBrowserType(): playwright.BrowserType | playwright.BrowserType | playwright.BrowserType { // Default to chromium let browserType: playwright.BrowserType | playwright.BrowserType | playwright.BrowserType = playwright['chromium']; const index = process.argv.indexOf('--browser'); if (index !== -1 && process.argv.length > index + 1 && typeof process.argv[index + 1] === 'string') { const string = process.argv[index + 1]; if (string === 'firefox' || string === 'webkit') { browserType = playwright[string]; } } return browserType; } export function launchBrowser(opts?: playwright.LaunchOptions): Promise { const browserType = getBrowserType(); const options: playwright.LaunchOptions = { ...opts, headless: process.argv.includes('--headless') }; const index = process.argv.indexOf('--executablePath'); if (index > 0 && process.argv.length > index + 1 && typeof process.argv[index + 1] === 'string') { options.executablePath = process.argv[index + 1]; } return browserType.launch(options); }