diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 675c432aa5..dd19a5a289 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -325,7 +325,7 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi } async function open(options: Options, url: string | undefined, language: string) { - const { context, launchOptions, contextOptions } = await launchContext(options, false); + const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWCLI_HEADLESS_FOR_TEST); await context._enableRecorder({ language, launchOptions, @@ -339,7 +339,7 @@ async function open(options: Options, url: string | undefined, language: string) } async function codegen(options: Options, url: string | undefined, language: string, outputFile?: string) { - const { context, launchOptions, contextOptions } = await launchContext(options, false); + const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWCLI_HEADLESS_FOR_TEST); if (process.env.PWTRACE) contextOptions._traceDir = path.join(process.cwd(), '.trace'); await context._enableRecorder({ diff --git a/src/server/browser.ts b/src/server/browser.ts index 698964a600..15f5c67d4b 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -47,6 +47,7 @@ export type BrowserOptions = PlaywrightOptions & { protocolLogger: types.ProtocolLogger, browserLogsCollector: RecentLogsCollector, slowMo?: number; + wsEndpoint?: string; // Only there when connected over web socket. }; export abstract class Browser extends SdkObject { diff --git a/src/server/browserType.ts b/src/server/browserType.ts index d1c3fb8c9f..752a78c39d 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -20,7 +20,7 @@ import path from 'path'; import * as util from 'util'; import { BrowserContext, normalizeProxySettings, validateBrowserContextOptions } from './browserContext'; import * as registry from '../utils/registry'; -import { ConnectionTransport } from './transport'; +import { ConnectionTransport, WebSocketTransport } from './transport'; import { BrowserOptions, Browser, BrowserProcess, PlaywrightOptions } from './browser'; import { launchProcess, Env, envArrayToObject } from './processLauncher'; import { PipeTransport } from './pipeTransport'; @@ -112,6 +112,7 @@ export abstract class BrowserType extends SdkObject { proxy: options.proxy, protocolLogger, browserLogsCollector, + wsEndpoint: options.useWebSocket ? (transport as WebSocketTransport).wsEndpoint : undefined, }; if (persistent) validateBrowserContextOptions(persistent, browserOptions); @@ -180,6 +181,8 @@ export abstract class BrowserType extends SdkObject { await validateHostRequirements(this._registry, this._name); } + let wsEndpointCallback: ((wsEndpoint: string) => void) | undefined; + const wsEndpoint = options.useWebSocket ? new Promise(f => wsEndpointCallback = f) : undefined; // Note: it is important to define these variables before launchProcess, so that we don't get // "Cannot access 'browserServer' before initialization" if something went wrong. let transport: ConnectionTransport | undefined = undefined; @@ -192,6 +195,11 @@ export abstract class BrowserType extends SdkObject { handleSIGTERM, handleSIGHUP, log: (message: string) => { + if (wsEndpointCallback) { + const match = message.match(/DevTools listening on (.*)/); + if (match) + wsEndpointCallback(match[1]); + } progress.log(message); browserLogsCollector.log(message); }, @@ -217,9 +225,12 @@ export abstract class BrowserType extends SdkObject { kill }; progress.cleanupWhenAborted(() => browserProcess && closeOrKill(browserProcess, progress.timeUntilDeadline())); - - const stdio = launchedProcess.stdio as unknown as [NodeJS.ReadableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.ReadableStream]; - transport = new PipeTransport(stdio[3], stdio[4]); + if (options.useWebSocket) { + transport = await WebSocketTransport.connect(progress, await wsEndpoint!); + } else { + const stdio = launchedProcess.stdio as unknown as [NodeJS.ReadableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.ReadableStream]; + transport = new PipeTransport(stdio[3], stdio[4]); + } return { browserProcess, downloadsPath, transport }; } @@ -242,7 +253,10 @@ function copyTestHooks(from: object, to: object) { } function validateLaunchOptions(options: Options): Options { - const { devtools = false, headless = !isDebugMode() && !devtools } = options; + const { devtools = false } = options; + let { headless = !devtools } = options; + if (isDebugMode()) + headless = false; return { ...options, devtools, headless }; } diff --git a/src/server/chromium/chromium.ts b/src/server/chromium/chromium.ts index db9b1c39d7..16de00bb24 100644 --- a/src/server/chromium/chromium.ts +++ b/src/server/chromium/chromium.ts @@ -119,7 +119,10 @@ export class Chromium extends BrowserType { throw new Error('Arguments can not specify page to be opened'); const chromeArguments = [...DEFAULT_ARGS]; chromeArguments.push(`--user-data-dir=${userDataDir}`); - chromeArguments.push('--remote-debugging-pipe'); + if (options.useWebSocket) + chromeArguments.push('--remote-debugging-port=0'); + else + chromeArguments.push('--remote-debugging-pipe'); if (options.devtools) chromeArguments.push('--auto-open-devtools-for-tabs'); if (options.headless) { diff --git a/src/server/supplements/inspectorController.ts b/src/server/supplements/inspectorController.ts index db0ca6f75d..ae08695482 100644 --- a/src/server/supplements/inspectorController.ts +++ b/src/server/supplements/inspectorController.ts @@ -21,16 +21,11 @@ import { CallMetadata, InstrumentationListener, SdkObject } from '../instrumenta import { isDebugMode, isUnderTest } from '../../utils/utils'; export class InspectorController implements InstrumentationListener { - private _recorders = new Map>(); private _waitOperations = new Map(); async onContextCreated(context: BrowserContext): Promise { if (isDebugMode()) - this._recorders.set(context, RecorderSupplement.getOrCreate(context)); - } - - async onContextDidDestroy(context: BrowserContext): Promise { - this._recorders.delete(context); + RecorderSupplement.getOrCreate(context); } async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { @@ -61,10 +56,10 @@ export class InspectorController implements InstrumentationListener { // Force create recorder on pause. if (!context._browser.options.headful && !isUnderTest()) return; - this._recorders.set(context, RecorderSupplement.getOrCreate(context)); + RecorderSupplement.getOrCreate(context); } - const recorder = await this._recorders.get(context); + const recorder = await RecorderSupplement.getNoCreate(context); await recorder?.onBeforeCall(sdkObject, metadata); } @@ -87,14 +82,14 @@ export class InspectorController implements InstrumentationListener { } } - const recorder = await this._recorders.get(sdkObject.attribution.context); + const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context); await recorder?.onAfterCall(metadata); } async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { if (!sdkObject.attribution.context) return; - const recorder = await this._recorders.get(sdkObject.attribution.context); + const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context); await recorder?.onBeforeInputAction(metadata); } @@ -102,7 +97,7 @@ export class InspectorController implements InstrumentationListener { debugLogger.log(logName as any, message); if (!sdkObject.attribution.context) return; - const recorder = await this._recorders.get(sdkObject.attribution.context); + const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context); await recorder?.updateCallLog([metadata]); } } diff --git a/src/server/supplements/recorder/recorderApp.ts b/src/server/supplements/recorder/recorderApp.ts index 670c6409e7..a8e0eacd8e 100644 --- a/src/server/supplements/recorder/recorderApp.ts +++ b/src/server/supplements/recorder/recorderApp.ts @@ -26,7 +26,6 @@ import { internalCallMetadata } from '../../instrumentation'; import type { CallLog, EventData, Mode, Source } from './recorderTypes'; import { BrowserContext } from '../../browserContext'; import { isUnderTest } from '../../../utils/utils'; -import { RecentLogsCollector } from '../../../utils/debugLogger'; const readFileAsync = util.promisify(fs.readFile); @@ -104,34 +103,20 @@ export class RecorderApp extends EventEmitter { sdkLanguage: inspectedContext._options.sdkLanguage, args, noDefaultViewport: true, - headless: isUnderTest() && !inspectedContext._browser.options.headful + headless: !!process.env.PWCLI_HEADLESS_FOR_TEST || (isUnderTest() && !inspectedContext._browser.options.headful), + useWebSocket: isUnderTest() }); - const wsEndpoint = isUnderTest() ? await this._parseWsEndpoint(context._browser.options.browserLogsCollector) : undefined; const controller = new ProgressController(internalCallMetadata(), context._browser); await controller.run(async progress => { await context._browser._defaultContext!._loadDefaultContextAsIs(progress); }); const [page] = context.pages(); - const result = new RecorderApp(page, wsEndpoint); + const result = new RecorderApp(page, context._browser.options.wsEndpoint); await result._init(); return result; } - private static async _parseWsEndpoint(recentLogs: RecentLogsCollector): Promise { - let callback: ((log: string) => void) | undefined; - const result = new Promise(f => callback = f); - const check = (log: string) => { - const match = log.match(/DevTools listening on (.*)/); - if (match) - callback!(match[1]); - }; - for (const log of recentLogs.recentLogs()) - check(log); - recentLogs.on('log', check); - return result; - } - async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise { await this._page.mainFrame()._evaluateExpression(((mode: Mode) => { window.playwrightSetMode(mode); diff --git a/src/server/supplements/recorderSupplement.ts b/src/server/supplements/recorderSupplement.ts index ae6ae6ea1b..8c94d6db24 100644 --- a/src/server/supplements/recorderSupplement.ts +++ b/src/server/supplements/recorderSupplement.ts @@ -67,6 +67,10 @@ export class RecorderSupplement { return recorderPromise; } + static getNoCreate(context: BrowserContext): Promise | undefined { + return (context as any)[symbol] as Promise | undefined; + } + constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { this._context = context; this._params = params; @@ -325,6 +329,8 @@ export class RecorderSupplement { } async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + if (this._mode === 'recording') + return; this._currentCallsMetadata.set(metadata, sdkObject); this._updateUserSources(); this.updateCallLog([metadata]); @@ -333,6 +339,8 @@ export class RecorderSupplement { } async onAfterCall(metadata: CallMetadata): Promise { + if (this._mode === 'recording') + return; if (!metadata.error) this._currentCallsMetadata.delete(metadata); this._pausedCallsMetadata.delete(metadata); @@ -372,16 +380,20 @@ export class RecorderSupplement { } async onBeforeInputAction(metadata: CallMetadata): Promise { + if (this._mode === 'recording') + return; if (this._pauseOnNextStatement) await this.pause(metadata); } async updateCallLog(metadatas: CallMetadata[]): Promise { + if (this._mode === 'recording') + return; const logs: CallLog[] = []; for (const metadata of metadatas) { if (!metadata.method) continue; - const title = metadata.stack?.[0]?.function || metadata.method; + const title = metadata.method; let status: 'done' | 'in-progress' | 'paused' | 'error' = 'done'; if (this._currentCallsMetadata.has(metadata)) status = 'in-progress'; diff --git a/src/server/transport.ts b/src/server/transport.ts index 4ff282feec..bef9cd1136 100644 --- a/src/server/transport.ts +++ b/src/server/transport.ts @@ -50,6 +50,7 @@ export class WebSocketTransport implements ConnectionTransport { onmessage?: (message: ProtocolResponse) => void; onclose?: () => void; + readonly wsEndpoint: string; static async connect(progress: Progress, url: string): Promise { progress.log(` ${url}`); @@ -75,6 +76,7 @@ export class WebSocketTransport implements ConnectionTransport { } constructor(progress: Progress, url: string) { + this.wsEndpoint = url; this._ws = new WebSocket(url, [], { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024, // 256Mb, diff --git a/src/server/types.ts b/src/server/types.ts index 7d172f8af5..e49d8e8005 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -264,7 +264,8 @@ type LaunchOptionsBase = { proxy?: ProxySettings, downloadsPath?: string, chromiumSandbox?: boolean, - slowMo?: number; + slowMo?: number, + useWebSocket?: boolean, }; export type LaunchOptions = LaunchOptionsBase & { firefoxUserPrefs?: { [key: string]: string | number | boolean }, diff --git a/src/utils/debugLogger.ts b/src/utils/debugLogger.ts index 5bef3c9e26..a2485c982a 100644 --- a/src/utils/debugLogger.ts +++ b/src/utils/debugLogger.ts @@ -16,7 +16,6 @@ import debug from 'debug'; import fs from 'fs'; -import { EventEmitter } from 'events'; const debugLoggerColorMap = { 'api': 45, // cyan @@ -64,11 +63,10 @@ class DebugLogger { export const debugLogger = new DebugLogger(); const kLogCount = 50; -export class RecentLogsCollector extends EventEmitter { +export class RecentLogsCollector { private _logs: string[] = []; log(message: string) { - this.emit('log', message); this._logs.push(message); if (this._logs.length === kLogCount * 2) this._logs.splice(0, kLogCount); diff --git a/test/browsertype-launch-server.spec.ts b/test/browsertype-launch-server.spec.ts index 1a6f86d325..7b22f7564b 100644 --- a/test/browsertype-launch-server.spec.ts +++ b/test/browsertype-launch-server.spec.ts @@ -18,7 +18,7 @@ import { folio } from './remoteServer.fixture'; const { it, expect, describe } = folio; -describe('lauch server', (suite, { mode }) => { +describe('launch server', (suite, { mode }) => { suite.skip(mode !== 'default'); }, () => { it('should work', async ({browserType, browserOptions}) => { diff --git a/test/cli/cli-codegen-1.spec.ts b/test/cli/cli-codegen-1.spec.ts index 8e4f67355d..088ebe5c63 100644 --- a/test/cli/cli-codegen-1.spec.ts +++ b/test/cli/cli-codegen-1.spec.ts @@ -19,9 +19,9 @@ import * as http from 'http'; const { it, describe, expect } = folio; -describe('cli codegen', (suite, { mode, browserName, headful }) => { - suite.fixme(browserName === 'firefox' && headful, 'Focus is off'); +describe('cli codegen', (suite, { browserName, headful, mode }) => { suite.skip(mode !== 'default'); + suite.fixme(browserName === 'firefox' && headful, 'Focus is off'); }, () => { it('should click', async ({ page, recorder }) => { await recorder.setContentAndWait(``); diff --git a/test/cli/cli-codegen-2.spec.ts b/test/cli/cli-codegen-2.spec.ts index d6e1574027..b4e90eb6a7 100644 --- a/test/cli/cli-codegen-2.spec.ts +++ b/test/cli/cli-codegen-2.spec.ts @@ -20,8 +20,7 @@ import * as url from 'url'; const { it, describe, expect } = folio; -describe('cli codegen', (suite, { mode, browserName, headful }) => { - // suite.fixme(browserName === 'firefox' && headful, 'Focus is off'); +describe('cli codegen', (suite, { mode }) => { suite.skip(mode !== 'default'); }, () => { it('should contain open page', async ({ recorder }) => { diff --git a/test/cli/cli.fixtures.ts b/test/cli/cli.fixtures.ts index f59c518421..5d4be1d699 100644 --- a/test/cli/cli.fixtures.ts +++ b/test/cli/cli.fixtures.ts @@ -146,10 +146,10 @@ class Recorder { } } -fixtures.runCLI.init(async ({ browserName }, runTest) => { +fixtures.runCLI.init(async ({ browserName, headful }, runTest) => { let cli: CLIMock; const cliFactory = (args: string[]) => { - cli = new CLIMock(browserName, args); + cli = new CLIMock(browserName, !headful, args); return cli; }; await runTest(cliFactory); @@ -163,7 +163,7 @@ class CLIMock { private waitForCallback: () => void; exited: Promise; - constructor(browserName, args: string[]) { + constructor(browserName: string, headless: boolean, args: string[]) { this.data = ''; this.process = spawn('node', [ path.join(__dirname, '..', '..', 'lib', 'cli', 'cli.js'), @@ -172,7 +172,8 @@ class CLIMock { ], { env: { ...process.env, - PWCLI_EXIT_FOR_TEST: '1' + PWCLI_EXIT_FOR_TEST: '1', + PWCLI_HEADLESS_FOR_TEST: headless ? '1' : undefined, }, stdio: 'pipe' }); diff --git a/test/pause.spec.ts b/test/pause.spec.ts index 3abf9c19fe..7537b731b4 100644 --- a/test/pause.spec.ts +++ b/test/pause.spec.ts @@ -15,6 +15,7 @@ */ import { expect } from 'folio'; +import { Page } from '..'; import { folio } from './recorder.fixtures'; const { it, describe} = folio; @@ -119,4 +120,116 @@ describe('pause', (suite, { mode }) => { await recorderPage.click('[title="Step over"]'); await scriptPromise; }); + + it('should skip input when resuming', async ({page, recorderPageGetter}) => { + await page.setContent(''); + const scriptPromise = (async () => { + await page.pause(); + await page.click('button'); + await page.pause(); // 2 + })(); + const recorderPage = await recorderPageGetter(); + await recorderPage.click('[title="Resume"]'); + await recorderPage.waitForSelector('.source-line-paused:has-text("page.pause(); // 2")'); + await recorderPage.click('[title=Resume]'); + await scriptPromise; + }); + + it('should populate log', async ({page, recorderPageGetter}) => { + await page.setContent(''); + const scriptPromise = (async () => { + await page.pause(); + await page.click('button'); + await page.pause(); // 2 + })(); + const recorderPage = await recorderPageGetter(); + await recorderPage.click('[title="Resume"]'); + await recorderPage.waitForSelector('.source-line-paused:has-text("page.pause(); // 2")'); + expect(await sanitizeLog(recorderPage)).toEqual([ + 'pause', + 'click', + 'waiting for selector "button"', + 'selector resolved to visible ', + 'attempting click action', + 'waiting for element to be visible, enabled and stable', + 'element is visible, enabled and stable', + 'scrolling into view if needed', + 'done scrolling', + 'checking that element receives pointer events at ()', + 'element does receive pointer events', + 'performing click action', + 'click action done', + 'waiting for scheduled navigations to finish', + 'navigations have finished', + 'pause', + ]); + await recorderPage.click('[title="Resume"]'); + await scriptPromise; + }); + + it('should populate log with waitForEvent', async ({page, recorderPageGetter}) => { + await page.setContent(''); + const scriptPromise = (async () => { + await page.pause(); + await Promise.all([ + page.waitForEvent('console'), + page.click('button'), + ]); + await page.pause(); // 2 + })(); + const recorderPage = await recorderPageGetter(); + await recorderPage.click('[title="Resume"]'); + await recorderPage.waitForSelector('.source-line-paused:has-text("page.pause(); // 2")'); + expect(await sanitizeLog(recorderPage)).toEqual([ + 'pause', + 'waitForEvent()', + 'waiting for event \"console\"', + 'click', + 'waiting for selector "button"', + 'selector resolved to visible ', + 'attempting click action', + 'waiting for element to be visible, enabled and stable', + 'element is visible, enabled and stable', + 'scrolling into view if needed', + 'done scrolling', + 'checking that element receives pointer events at ()', + 'element does receive pointer events', + 'performing click action', + 'click action done', + 'waiting for scheduled navigations to finish', + 'navigations have finished', + 'pause', + ]); + await recorderPage.click('[title="Resume"]'); + await scriptPromise; + }); + + it('should populate log with error', async ({page, recorderPageGetter}) => { + await page.setContent(''); + const scriptPromise = (async () => { + await page.pause(); + await page.isChecked('button'); + })().catch(e => e); + const recorderPage = await recorderPageGetter(); + await recorderPage.click('[title="Resume"]'); + await recorderPage.waitForSelector('.source-line-error'); + expect(await sanitizeLog(recorderPage)).toEqual([ + 'pause', + 'isChecked', + 'checking \"checked\" state of \"button\"', + 'selector resolved to ', + 'Not a checkbox or radio button', + ]); + const error = await scriptPromise; + expect(error.message).toContain('Not a checkbox or radio button'); + }); }); + +async function sanitizeLog(recorderPage: Page): Promise { + const text = await recorderPage.innerText('.recorder-log'); + return text.split('\n').filter(l => { + return l !== 'element is not stable - waiting...'; + }).map(l => { + return l.replace(/\(.*\)/, '()'); + }); +}