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');
const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN;
if (autoExitCondition && text.includes(autoExitCondition))
Promise.all(context.pages().map(async p => p.close()));
closeBrowser();
};
// Make sure we exit abnormally when browser crashes.
const logs: string[] = [];
@ -504,7 +504,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
if (hasPage)
return;
// 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 () => {

View file

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

View file

@ -35,16 +35,25 @@ export class Download {
this._suggestedFilename = suggestedFilename;
page._browserContext._downloads.add(this);
if (suggestedFilename !== undefined)
this._page.emit(Page.Events.Download, this);
this._fireDownloadEvent();
}
page(): Page {
return this._page;
}
_filenameSuggested(suggestedFilename: string) {
assert(this._suggestedFilename === undefined);
this._suggestedFilename = suggestedFilename;
this._page.emit(Page.Events.Download, this);
this._fireDownloadEvent();
}
suggestedFilename(): string {
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 { Dialog } from './dialog';
import type { Download } from './download';
export type { CallMetadata } from '@protocol/callMetadata';
export class SdkObject extends EventEmitter {
@ -62,6 +64,8 @@ export interface Instrumentation {
onPageClose(page: Page): void;
onBrowserOpen(browser: Browser): void;
onBrowserClose(browser: Browser): void;
onDialog(dialog: Dialog): void;
onDownload(page: Page, download: Download): void;
}
export interface InstrumentationListener {
@ -73,6 +77,8 @@ export interface InstrumentationListener {
onPageClose?(page: Page): void;
onBrowserOpen?(browser: Browser): void;
onBrowserClose?(browser: Browser): void;
onDialog?(dialog: Dialog): void;
onDownload?(page: Page, download: Download): void;
}
export function createInstrumentation(): Instrumentation {

View file

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

View file

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

View file

@ -38,6 +38,8 @@ import { Snapshotter } from './snapshotter';
import type { ConsoleMessage } from '../../console';
import { Dispatcher } from '../../dispatchers/dispatcher';
import { serializeError } from '../../errors';
import type { Dialog } from '../../dialog';
import type { Download } from '../../download';
const version: trace.VERSION = 7;
@ -454,6 +456,28 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
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) {
const event: trace.EventTraceEvent = {
type: 'event',

View file

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

View file

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

View file

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

View file

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