test(inspector): add some tests (#5461)

This commit is contained in:
Pavel Feldman 2021-02-15 08:32:13 -08:00 committed by GitHub
parent 1f3449c7da
commit 0c7da44465
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 175 additions and 51 deletions

View File

@ -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({

View File

@ -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 {

View File

@ -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<string>(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 extends types.LaunchOptions>(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 };
}

View File

@ -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) {

View File

@ -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<BrowserContext, Promise<RecorderSupplement>>();
private _waitOperations = new Map<string, CallMetadata>();
async onContextCreated(context: BrowserContext): Promise<void> {
if (isDebugMode())
this._recorders.set(context, RecorderSupplement.getOrCreate(context));
}
async onContextDidDestroy(context: BrowserContext): Promise<void> {
this._recorders.delete(context);
RecorderSupplement.getOrCreate(context);
}
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
@ -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<void> {
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]);
}
}

View File

@ -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<string> {
let callback: ((log: string) => void) | undefined;
const result = new Promise<string>(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<void> {
await this._page.mainFrame()._evaluateExpression(((mode: Mode) => {
window.playwrightSetMode(mode);

View File

@ -67,6 +67,10 @@ export class RecorderSupplement {
return recorderPromise;
}
static getNoCreate(context: BrowserContext): Promise<RecorderSupplement> | undefined {
return (context as any)[symbol] as Promise<RecorderSupplement> | 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<void> {
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<void> {
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<void> {
if (this._mode === 'recording')
return;
if (this._pauseOnNextStatement)
await this.pause(metadata);
}
async updateCallLog(metadatas: CallMetadata[]): Promise<void> {
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';

View File

@ -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<WebSocketTransport> {
progress.log(`<ws connecting> ${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,

View File

@ -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 },

View File

@ -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);

View File

@ -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}) => {

View File

@ -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(`<button onclick="console.log('click')">Submit</button>`);

View File

@ -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 }) => {

View File

@ -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<void>;
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'
});

View File

@ -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('<button>Submit</button>');
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('<button>Submit</button>');
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 <button>Submit</button>',
'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('<button onclick="console.log(1)">Submit</button>');
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 <button onclick=\"console.log()\">Submit</button>',
'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('<button onclick="console.log(1)">Submit</button>');
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 <button onclick=\"console.log()\">Submit</button>',
'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<string[]> {
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(/\(.*\)/, '()');
});
}