chore: iterate towards recording into trace (3) (#32718)

This commit is contained in:
Pavel Feldman 2024-09-20 13:08:33 -07:00 committed by GitHub
parent bef1e990ac
commit dfb3fdf217
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 224 additions and 63 deletions

View file

@ -397,7 +397,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
process.stdout.write('\n-------------8<-------------\n'); process.stdout.write('\n-------------8<-------------\n');
const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN; const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN;
if (autoExitCondition && text.includes(autoExitCondition)) if (autoExitCondition && text.includes(autoExitCondition))
Promise.all(context.pages().map(async p => p.close())); closeBrowser();
}; };
// Make sure we exit abnormally when browser crashes. // Make sure we exit abnormally when browser crashes.
const logs: string[] = []; const logs: string[] = [];
@ -504,7 +504,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
if (hasPage) if (hasPage)
return; return;
// Avoid the error when the last page is closed because the browser has been closed. // Avoid the error when the last page is closed because the browser has been closed.
closeBrowser().catch(e => null); closeBrowser().catch(() => {});
}); });
}); });
process.on('SIGINT', async () => { process.on('SIGINT', async () => {

View file

@ -39,6 +39,7 @@ export class Dialog extends SdkObject {
this._onHandle = onHandle; this._onHandle = onHandle;
this._defaultValue = defaultValue || ''; this._defaultValue = defaultValue || '';
this._page._frameManager.dialogDidOpen(this); this._page._frameManager.dialogDidOpen(this);
this.instrumentation.onDialog(this);
} }
page() { page() {

View file

@ -35,16 +35,25 @@ export class Download {
this._suggestedFilename = suggestedFilename; this._suggestedFilename = suggestedFilename;
page._browserContext._downloads.add(this); page._browserContext._downloads.add(this);
if (suggestedFilename !== undefined) if (suggestedFilename !== undefined)
this._page.emit(Page.Events.Download, this); this._fireDownloadEvent();
}
page(): Page {
return this._page;
} }
_filenameSuggested(suggestedFilename: string) { _filenameSuggested(suggestedFilename: string) {
assert(this._suggestedFilename === undefined); assert(this._suggestedFilename === undefined);
this._suggestedFilename = suggestedFilename; this._suggestedFilename = suggestedFilename;
this._page.emit(Page.Events.Download, this); this._fireDownloadEvent();
} }
suggestedFilename(): string { suggestedFilename(): string {
return this._suggestedFilename!; return this._suggestedFilename!;
} }
private _fireDownloadEvent() {
this._page.instrumentation.onDownload(this._page, this);
this._page.emit(Page.Events.Download, this);
}
} }

View file

@ -35,6 +35,8 @@ export type Attribution = {
}; };
import type { CallMetadata } from '@protocol/callMetadata'; import type { CallMetadata } from '@protocol/callMetadata';
import type { Dialog } from './dialog';
import type { Download } from './download';
export type { CallMetadata } from '@protocol/callMetadata'; export type { CallMetadata } from '@protocol/callMetadata';
export class SdkObject extends EventEmitter { export class SdkObject extends EventEmitter {
@ -62,6 +64,8 @@ export interface Instrumentation {
onPageClose(page: Page): void; onPageClose(page: Page): void;
onBrowserOpen(browser: Browser): void; onBrowserOpen(browser: Browser): void;
onBrowserClose(browser: Browser): void; onBrowserClose(browser: Browser): void;
onDialog(dialog: Dialog): void;
onDownload(page: Page, download: Download): void;
} }
export interface InstrumentationListener { export interface InstrumentationListener {
@ -73,6 +77,8 @@ export interface InstrumentationListener {
onPageClose?(page: Page): void; onPageClose?(page: Page): void;
onBrowserOpen?(browser: Browser): void; onBrowserOpen?(browser: Browser): void;
onBrowserClose?(browser: Browser): void; onBrowserClose?(browser: Browser): void;
onDialog?(dialog: Dialog): void;
onDownload?(page: Page, download: Download): void;
} }
export function createInstrumentation(): Instrumentation { export function createInstrumentation(): Instrumentation {

View file

@ -162,8 +162,10 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}).toString(), { isFunction: true }, sources).catch(() => {}); }).toString(), { isFunction: true }, sources).catch(() => {});
// Testing harness for runCLI mode. // Testing harness for runCLI mode.
if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) {
(process as any)._didSetSourcesForTest(sources[0].text); if ((process as any)._didSetSourcesForTest(sources[0].text))
this.close();
}
} }
async setSelector(selector: string, userGesture?: boolean): Promise<void> { async setSelector(selector: string, userGesture?: boolean): Promise<void> {

View file

@ -21,77 +21,97 @@ import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFro
import { installRootRedirect, openTraceViewerApp, startTraceViewerServer } from '../trace/viewer/traceViewer'; import { installRootRedirect, openTraceViewerApp, startTraceViewerServer } from '../trace/viewer/traceViewer';
import type { TraceViewerServerOptions } from '../trace/viewer/traceViewer'; import type { TraceViewerServerOptions } from '../trace/viewer/traceViewer';
import type { BrowserContext } from '../browserContext'; import type { BrowserContext } from '../browserContext';
import { gracefullyProcessExitDoNotHang } from '../../utils/processLauncher'; import type { HttpServer, Transport } from '../../utils/httpServer';
import type { Transport } from '../../utils/httpServer'; import type { Page } from '../page';
import { ManualPromise } from '../../utils/manualPromise';
export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp { export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp {
readonly wsEndpointForTest: string | undefined; readonly wsEndpointForTest: string | undefined;
private _recorder: IRecorder; private _transport: RecorderTransport;
private _transport: Transport; private _tracePage: Page;
private _traceServer: HttpServer;
static factory(context: BrowserContext): IRecorderAppFactory { static factory(context: BrowserContext): IRecorderAppFactory {
return async (recorder: IRecorder) => { return async (recorder: IRecorder) => {
const transport = new RecorderTransport(); const transport = new RecorderTransport();
const trace = path.join(context._browser.options.tracesDir, 'trace'); const trace = path.join(context._browser.options.tracesDir, 'trace');
const wsEndpointForTest = await openApp(trace, { transport, headless: !context._browser.options.headful }); const { wsEndpointForTest, tracePage, traceServer } = await openApp(trace, { transport, headless: !context._browser.options.headful });
return new RecorderInTraceViewer(context, recorder, transport, wsEndpointForTest); return new RecorderInTraceViewer(transport, tracePage, traceServer, wsEndpointForTest);
}; };
} }
constructor(context: BrowserContext, recorder: IRecorder, transport: Transport, wsEndpointForTest: string | undefined) { constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) {
super(); super();
this._recorder = recorder;
this._transport = transport; this._transport = transport;
this._tracePage = tracePage;
this._traceServer = traceServer;
this.wsEndpointForTest = wsEndpointForTest; this.wsEndpointForTest = wsEndpointForTest;
this._tracePage.once('close', () => {
this.close();
});
} }
async close(): Promise<void> { async close(): Promise<void> {
this._transport.sendEvent?.('close', {}); await this._tracePage.context().close({ reason: 'Recorder window closed' });
await this._traceServer.stop();
} }
async setPaused(paused: boolean): Promise<void> { async setPaused(paused: boolean): Promise<void> {
this._transport.sendEvent?.('setPaused', { paused }); this._transport.deliverEvent('setPaused', { paused });
} }
async setMode(mode: Mode): Promise<void> { async setMode(mode: Mode): Promise<void> {
this._transport.sendEvent?.('setMode', { mode }); this._transport.deliverEvent('setMode', { mode });
} }
async setFile(file: string): Promise<void> { async setFile(file: string): Promise<void> {
this._transport.sendEvent?.('setFileIfNeeded', { file }); this._transport.deliverEvent('setFileIfNeeded', { file });
} }
async setSelector(selector: string, userGesture?: boolean): Promise<void> { async setSelector(selector: string, userGesture?: boolean): Promise<void> {
this._transport.sendEvent?.('setSelector', { selector, userGesture }); this._transport.deliverEvent('setSelector', { selector, userGesture });
} }
async updateCallLogs(callLogs: CallLog[]): Promise<void> { async updateCallLogs(callLogs: CallLog[]): Promise<void> {
this._transport.sendEvent?.('updateCallLogs', { callLogs }); this._transport.deliverEvent('updateCallLogs', { callLogs });
} }
async setSources(sources: Source[]): Promise<void> { async setSources(sources: Source[]): Promise<void> {
this._transport.sendEvent?.('setSources', { sources }); this._transport.deliverEvent('setSources', { sources });
if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) {
if ((process as any)._didSetSourcesForTest(sources[0].text))
this.close();
}
} }
} }
async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }): Promise<string | undefined> { async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }): Promise<{ wsEndpointForTest: string | undefined, tracePage: Page, traceServer: HttpServer }> {
const server = await startTraceViewerServer(options); const traceServer = await startTraceViewerServer(options);
await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' }); await installRootRedirect(traceServer, [trace], { ...options, webApp: 'recorder.html' });
const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options); const page = await openTraceViewerApp(traceServer.urlPrefix('precise'), 'chromium', options);
page.on('close', () => gracefullyProcessExitDoNotHang(0)); return { wsEndpointForTest: page.context()._browser.options.wsEndpoint, tracePage: page, traceServer };
return page.context()._browser.options.wsEndpoint;
} }
class RecorderTransport implements Transport { class RecorderTransport implements Transport {
private _connected = new ManualPromise<void>();
constructor() { constructor() {
} }
async dispatch(method: string, params: any) { onconnect() {
this._connected.resolve();
}
async dispatch(method: string, params: any): Promise<any> {
} }
onclose() { onclose() {
} }
deliverEvent(method: string, params: any) {
this._connected.then(() => this.sendEvent?.(method, params));
}
sendEvent?: (method: string, params: any) => void; sendEvent?: (method: string, params: any) => void;
close?: () => void; close?: () => void;
} }

View file

@ -25,7 +25,7 @@ import type * as trace from '@trace/trace';
import { fromKeyboardModifiers, toKeyboardModifiers } from '../codegen/language'; import { fromKeyboardModifiers, toKeyboardModifiers } from '../codegen/language';
import { serializeExpectedTextValues } from '../../utils/expectUtils'; import { serializeExpectedTextValues } from '../../utils/expectUtils';
import { createGuid, monotonicTime } from '../../utils'; import { createGuid, monotonicTime } from '../../utils';
import { serializeValue } from '../../protocol/serializers'; import { parseSerializedValue, serializeValue } from '../../protocol/serializers';
import type { SmartKeyboardModifier } from '../types'; import type { SmartKeyboardModifier } from '../types';
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
@ -158,7 +158,7 @@ export function traceParamsForAction(actionInContext: ActionInContext): { method
const params: channels.FrameExpectParams = { const params: channels.FrameExpectParams = {
selector: action.selector, selector: action.selector,
expression: 'to.be.checked', expression: 'to.be.checked',
isNot: action.checked, isNot: !action.checked,
}; };
return { method: 'expect', params }; return { method: 'expect', params };
} }
@ -166,7 +166,7 @@ export function traceParamsForAction(actionInContext: ActionInContext): { method
const params: channels.FrameExpectParams = { const params: channels.FrameExpectParams = {
selector, selector,
expression: 'to.have.text', expression: 'to.have.text',
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }), expectedText: serializeExpectedTextValues([action.text], { matchSubstring: action.substring, normalizeWhiteSpace: true }),
isNot: false, isNot: false,
}; };
return { method: 'expect', params }; return { method: 'expect', params };
@ -195,6 +195,7 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
const mainFrame = mainFrameForAction(pageAliases, actionInContext); const mainFrame = mainFrameForAction(pageAliases, actionInContext);
const { action } = actionInContext; const { action } = actionInContext;
const { method, params } = traceParamsForAction(actionInContext); const { method, params } = traceParamsForAction(actionInContext);
const callMetadata: CallMetadata = { const callMetadata: CallMetadata = {
id: `call@${createGuid()}`, id: `call@${createGuid()}`,
stepId: `recorder@${createGuid()}`, stepId: `recorder@${createGuid()}`,
@ -215,38 +216,70 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
export function traceEventsToAction(events: trace.TraceEvent[]): ActionInContext[] { export function traceEventsToAction(events: trace.TraceEvent[]): ActionInContext[] {
const result: ActionInContext[] = []; const result: ActionInContext[] = [];
const pageAliases = new Map<string, string>(); const pageAliases = new Map<string, string>();
let lastDownloadOrdinal = 0;
let lastDialogOrdinal = 0;
const addSignal = (signal: actions.Signal) => {
const lastAction = result[result.length - 1];
if (!lastAction)
return;
lastAction.action.signals.push(signal);
};
for (const event of events) { for (const event of events) {
if (event.type === 'event' && event.class === 'BrowserContext' && event.method === 'page') { if (event.type === 'event' && event.class === 'BrowserContext') {
const pageAlias = 'page' + pageAliases.size; const { method, params } = event;
pageAliases.set(event.params.pageId, pageAlias); if (method === 'page') {
const lastAction = result[result.length - 1]; const pageAlias = 'page' + (pageAliases.size || '');
lastAction.action.signals.push({ pageAliases.set(params.pageId, pageAlias);
name: 'popup', addSignal({
popupAlias: pageAlias, name: 'popup',
}); popupAlias: pageAlias,
result.push({ });
frame: { pageAlias, framePath: [] }, result.push({
action: { frame: { pageAlias, framePath: [] },
name: 'openPage', action: {
url: '', name: 'openPage',
signals: [], url: '',
}, signals: [],
timestamp: event.time, },
}); timestamp: event.time,
continue; });
} continue;
}
if (event.type === 'event' && event.class === 'BrowserContext' && event.method === 'pageClosed') { if (method === 'pageClosed') {
const pageAlias = pageAliases.get(event.params.pageId) || 'page'; const pageAlias = pageAliases.get(event.params.pageId) || 'page';
result.push({ result.push({
frame: { pageAlias, framePath: [] }, frame: { pageAlias, framePath: [] },
action: { action: {
name: 'closePage', name: 'closePage',
signals: [], signals: [],
}, },
timestamp: event.time, timestamp: event.time,
}); });
continue;
}
if (method === 'download') {
const downloadAlias = lastDownloadOrdinal ? String(lastDownloadOrdinal) : '';
++lastDownloadOrdinal;
addSignal({
name: 'download',
downloadAlias,
});
continue;
}
if (method === 'dialog') {
const dialogAlias = lastDialogOrdinal ? String(lastDialogOrdinal) : '';
++lastDialogOrdinal;
addSignal({
name: 'dialog',
dialogAlias,
});
continue;
}
continue; continue;
} }
@ -389,6 +422,67 @@ export function traceEventsToAction(events: trace.TraceEvent[]): ActionInContext
}); });
continue; continue;
} }
if (method === 'expect') {
const params = untypedParams as channels.FrameExpectParams;
if (params.expression === 'to.have.text') {
const entry = params.expectedText?.[0];
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertText',
selector: params.selector,
signals: [],
text: entry?.string!,
substring: !!entry?.matchSubstring,
},
timestamp: event.startTime
});
continue;
}
if (params.expression === 'to.have.value') {
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertValue',
selector: params.selector,
signals: [],
value: parseSerializedValue(params.expectedValue!.value, params.expectedValue!.handles),
},
timestamp: event.startTime
});
continue;
}
if (params.expression === 'to.be.checked') {
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertChecked',
selector: params.selector,
signals: [],
checked: !params.isNot,
},
timestamp: event.startTime
});
continue;
}
if (params.expression === 'to.be.visible') {
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertVisible',
selector: params.selector,
signals: [],
},
timestamp: event.startTime
});
continue;
}
continue;
}
} }
return result; return result;

View file

@ -38,6 +38,8 @@ import { Snapshotter } from './snapshotter';
import type { ConsoleMessage } from '../../console'; import type { ConsoleMessage } from '../../console';
import { Dispatcher } from '../../dispatchers/dispatcher'; import { Dispatcher } from '../../dispatchers/dispatcher';
import { serializeError } from '../../errors'; import { serializeError } from '../../errors';
import type { Dialog } from '../../dialog';
import type { Download } from '../../download';
const version: trace.VERSION = 7; const version: trace.VERSION = 7;
@ -454,6 +456,28 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
this._appendTraceEvent(event); this._appendTraceEvent(event);
} }
onDialog(dialog: Dialog) {
const event: trace.EventTraceEvent = {
type: 'event',
time: monotonicTime(),
class: 'BrowserContext',
method: 'dialog',
params: { pageId: dialog.page().guid, type: dialog.type(), message: dialog.message(), defaultValue: dialog.defaultValue() },
};
this._appendTraceEvent(event);
}
onDownload(page: Page, download: Download) {
const event: trace.EventTraceEvent = {
type: 'event',
time: monotonicTime(),
class: 'BrowserContext',
method: 'download',
params: { pageId: page.guid, url: download.url, suggestedFilename: download.suggestedFilename() },
};
this._appendTraceEvent(event);
}
onPageOpen(page: Page) { onPageOpen(page: Page) {
const event: trace.EventTraceEvent = { const event: trace.EventTraceEvent = {
type: 'event', type: 'event',

View file

@ -223,6 +223,9 @@ class StdinServer implements Transport {
process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0)); process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0));
} }
onconnect() {
}
async dispatch(method: string, params: any) { async dispatch(method: string, params: any) {
if (method === 'initialize') { if (method === 'initialize') {
if (this._traceUrl) if (this._traceUrl)

View file

@ -27,8 +27,9 @@ export type ServerRouteHandler = (request: http.IncomingMessage, response: http.
export type Transport = { export type Transport = {
sendEvent?: (method: string, params: any) => void; sendEvent?: (method: string, params: any) => void;
dispatch: (method: string, params: any) => Promise<any>;
close?: () => void; close?: () => void;
onconnect: () => void;
dispatch: (method: string, params: any) => Promise<any>;
onclose: () => void; onclose: () => void;
}; };
@ -82,6 +83,7 @@ export class HttpServer {
this._wsGuid = guid || createGuid(); this._wsGuid = guid || createGuid();
const wss = new wsServer({ server: this._server, path: '/' + this._wsGuid }); const wss = new wsServer({ server: this._server, path: '/' + this._wsGuid });
wss.on('connection', ws => { wss.on('connection', ws => {
transport.onconnect();
transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params })); transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params }));
transport.close = () => ws.close(); transport.close = () => ws.close();
ws.on('message', async message => { ws.on('message', async message => {

View file

@ -84,6 +84,7 @@ export class TestServerDispatcher implements TestServerInterface {
constructor(configLocation: ConfigLocation) { constructor(configLocation: ConfigLocation) {
this._configLocation = configLocation; this._configLocation = configLocation;
this.transport = { this.transport = {
onconnect: () => {},
dispatch: (method, params) => (this as any)[method](params), dispatch: (method, params) => (this as any)[method](params),
onclose: () => { onclose: () => {
if (this._closeOnDisconnect) if (this._closeOnDisconnect)

View file

@ -45,8 +45,6 @@ export const RecorderView: React.FunctionComponent = () => {
connection.setMode('recording'); connection.setMode('recording');
}, [connection]); }, [connection]);
window.playwrightSourcesEchoForTest = sources;
return <div className='vbox workbench-loader'> return <div className='vbox workbench-loader'>
<TraceView <TraceView
traceLocation={trace} traceLocation={trace}
@ -165,6 +163,7 @@ class Connection {
if (method === 'setSources') { if (method === 'setSources') {
const { sources } = params as { sources: Source[] }; const { sources } = params as { sources: Source[] };
this._options.setSources(sources); this._options.setSources(sources);
window.playwrightSourcesEchoForTest = sources;
} }
} }
} }