chore: recorder is trace viewer experiment (#32598)

This commit is contained in:
Pavel Feldman 2024-09-12 13:39:44 -07:00 committed by GitHub
parent de08e729ae
commit 7e3348eb0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 419 additions and 33 deletions

View file

@ -348,10 +348,10 @@ type CaptureOptions = {
fullPage: boolean;
};
async function launchContext(options: Options, headless: boolean, executablePath?: string): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> {
async function launchContext(options: Options, extraOptions: LaunchOptions): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> {
validateOptions(options);
const browserType = lookupBrowserType(options);
const launchOptions: LaunchOptions = { headless, executablePath };
const launchOptions: LaunchOptions = extraOptions;
if (options.channel)
launchOptions.channel = options.channel as any;
launchOptions.handleSIGINT = false;
@ -363,7 +363,7 @@ async function launchContext(options: Options, headless: boolean, executablePath
// In headful mode, use host device scale factor for things to look nice.
// In headless, keep things the way it works in Playwright by default.
// Assume high-dpi on MacOS. TODO: this is not perfect.
if (!headless)
if (!extraOptions.headless)
contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1;
// Work around the WebKit GTK scrolling issue.
@ -547,7 +547,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, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH);
const { context, launchOptions, contextOptions } = await launchContext(options, { headless: !!process.env.PWTEST_CLI_HEADLESS, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH });
await context._enableRecorder({
language,
launchOptions,
@ -560,8 +560,17 @@ async function open(options: Options, url: string | undefined, language: string)
async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) {
const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options;
const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH);
const tracesDir = path.join(os.tmpdir(), `recorder-trace-${Date.now()}`);
const { context, launchOptions, contextOptions } = await launchContext(options, {
headless: !!process.env.PWTEST_CLI_HEADLESS,
executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH,
tracesDir,
});
dotenv.config({ path: 'playwright.env' });
if (process.env.PW_RECORDER_IS_TRACE_VIEWER) {
await fs.promises.mkdir(tracesDir, { recursive: true });
await context.tracing.start({ name: 'trace', _live: true });
}
await context._enableRecorder({
language,
launchOptions,
@ -587,7 +596,7 @@ async function waitForPage(page: Page, captureOptions: CaptureOptions) {
}
async function screenshot(options: Options, captureOptions: CaptureOptions, url: string, path: string) {
const { context } = await launchContext(options, true);
const { context } = await launchContext(options, { headless: true });
console.log('Navigating to ' + url);
const page = await openPage(context, url);
await waitForPage(page, captureOptions);
@ -600,7 +609,7 @@ async function screenshot(options: Options, captureOptions: CaptureOptions, url:
async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) {
if (options.browser !== 'chromium')
throw new Error('PDF creation is only working with Chromium');
const { context } = await launchContext({ ...options, browser: 'chromium' }, true);
const { context } = await launchContext({ ...options, browser: 'chromium' }, { headless: true });
console.log('Navigating to ' + url);
const page = await openPage(context, url);
await waitForPage(page, captureOptions);

View file

@ -39,6 +39,7 @@ import type { Dialog } from '../dialog';
import type { ConsoleMessage } from '../console';
import { serializeError } from '../errors';
import { ElementHandleDispatcher } from './elementHandlerDispatcher';
import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer';
import { RecorderApp } from '../recorder/recorderApp';
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
@ -292,7 +293,8 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
}
async recorderSupplementEnable(params: channels.BrowserContextRecorderSupplementEnableParams): Promise<void> {
await Recorder.show(this._context, RecorderApp.factory(this._context), params);
const factory = process.env.PW_RECORDER_IS_TRACE_VIEWER ? RecorderInTraceViewer.factory(this._context) : RecorderApp.factory(this._context);
await Recorder.show(this._context, factory, params);
}
async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) {

View file

@ -26,14 +26,12 @@ import { type Language } from './codegen/types';
import { Debugger } from './debugger';
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder';
import { type IRecorderApp } from './recorder/recorderApp';
import type { IRecorderAppFactory, IRecorderApp, IRecorder } from './recorder/recorderFrontend';
import { buildFullSelector, metadataToCallLog } from './recorder/recorderUtils';
const recorderSymbol = Symbol('recorderSymbol');
export type RecorderAppFactory = (recorder: Recorder) => Promise<IRecorderApp>;
export class Recorder implements InstrumentationListener {
export class Recorder implements InstrumentationListener, IRecorder {
private _context: BrowserContext;
private _mode: Mode;
private _highlightedSelector = '';
@ -47,14 +45,14 @@ export class Recorder implements InstrumentationListener {
private _omitCallTracking = false;
private _currentLanguage: Language;
static showInspector(context: BrowserContext, recorderAppFactory: RecorderAppFactory) {
static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) {
const params: channels.BrowserContextRecorderSupplementEnableParams = {};
if (isUnderTest())
params.language = process.env.TEST_INSPECTOR_LANGUAGE;
Recorder.show(context, recorderAppFactory, params).catch(() => {});
}
static show(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
if (!recorderPromise) {
recorderPromise = Recorder._create(context, recorderAppFactory, params);
@ -63,7 +61,7 @@ export class Recorder implements InstrumentationListener {
return recorderPromise;
}
private static async _create(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
private static async _create(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
const recorder = new Recorder(context, params);
const recorderApp = await recorderAppFactory(recorder);
await recorder._install(recorderApp);

View file

@ -10,3 +10,6 @@
../../utils/**
../../utilsBundle.ts
../../zipBundle.ts
[recorderInTraceViewer.ts]
../trace/viewer/traceViewer.ts

View file

@ -69,7 +69,7 @@ export class ContextRecorder extends EventEmitter {
// Make a copy of options to modify them later.
const languageGeneratorOptions: LanguageGeneratorOptions = {
browserName: context._browser.options.name,
launchOptions: { headless: false, ...params.launchOptions },
launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined },
contextOptions: { ...params.contextOptions },
deviceName: params.device,
saveStorage: params.saveStorage,

View file

@ -24,9 +24,9 @@ import type { CallLog, EventData, Mode, Source } from '@recorder/recorderTypes';
import { isUnderTest } from '../../utils';
import { mime } from '../../utilsBundle';
import { syncLocalStorageWithSettings } from '../launchApp';
import type { Recorder, RecorderAppFactory } from '../recorder';
import type { BrowserContext } from '../browserContext';
import { launchApp } from '../launchApp';
import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend';
declare global {
interface Window {
@ -42,16 +42,6 @@ declare global {
}
}
export interface IRecorderApp extends EventEmitter {
close(): Promise<void>;
setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>;
setFileIfNeeded(file: string): Promise<void>;
setSelector(selector: string, userGesture?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>;
setSources(sources: Source[]): Promise<void>;
}
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
async close(): Promise<void> {}
async setPaused(paused: boolean): Promise<void> {}
@ -65,9 +55,9 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
export class RecorderApp extends EventEmitter implements IRecorderApp {
private _page: Page;
readonly wsEndpoint: string | undefined;
private _recorder: Recorder;
private _recorder: IRecorder;
constructor(recorder: Recorder, page: Page, wsEndpoint: string | undefined) {
constructor(recorder: IRecorder, page: Page, wsEndpoint: string | undefined) {
super();
this.setMaxListeners(0);
this._recorder = recorder;
@ -113,7 +103,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html');
}
static factory(context: BrowserContext): RecorderAppFactory {
static factory(context: BrowserContext): IRecorderAppFactory {
return async recorder => {
if (process.env.PW_CODEGEN_NO_INSPECTOR)
return new EmptyRecorderApp();
@ -121,7 +111,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
};
}
private static async _open(recorder: Recorder, inspectedContext: BrowserContext): Promise<IRecorderApp> {
private static async _open(recorder: IRecorder, inspectedContext: BrowserContext): Promise<IRecorderApp> {
const sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage;
const headed = !!inspectedContext._browser.options.headful;
const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true });

View file

@ -0,0 +1,35 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { CallLog, Mode, Source } from '@recorder/recorderTypes';
import type { EventEmitter } from 'events';
export interface IRecorder {
setMode(mode: Mode): void;
mode(): Mode;
}
export interface IRecorderApp extends EventEmitter {
close(): Promise<void>;
setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>;
setFileIfNeeded(file: string): Promise<void>;
setSelector(selector: string, userGesture?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>;
setSources(sources: Source[]): Promise<void>;
}
export type IRecorderAppFactory = (recorder: IRecorder) => Promise<IRecorderApp>;

View file

@ -0,0 +1,94 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import path from 'path';
import type { CallLog, Mode, Source } from '@recorder/recorderTypes';
import { EventEmitter } from 'events';
import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend';
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';
export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp {
private _recorder: IRecorder;
private _transport: Transport;
static factory(context: BrowserContext): IRecorderAppFactory {
return async (recorder: IRecorder) => {
const transport = new RecorderTransport();
const trace = path.join(context._browser.options.tracesDir, 'trace');
await openApp(trace, { transport });
return new RecorderInTraceViewer(context, recorder, transport);
};
}
constructor(context: BrowserContext, recorder: IRecorder, transport: Transport) {
super();
this._recorder = recorder;
this._transport = transport;
}
async close(): Promise<void> {
this._transport.sendEvent?.('close', {});
}
async setPaused(paused: boolean): Promise<void> {
this._transport.sendEvent?.('setPaused', { paused });
}
async setMode(mode: Mode): Promise<void> {
this._transport.sendEvent?.('setMode', { mode });
}
async setFileIfNeeded(file: string): Promise<void> {
this._transport.sendEvent?.('setFileIfNeeded', { file });
}
async setSelector(selector: string, userGesture?: boolean): Promise<void> {
this._transport.sendEvent?.('setSelector', { selector, userGesture });
}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {
this._transport.sendEvent?.('updateCallLogs', { callLogs });
}
async setSources(sources: Source[]): Promise<void> {
this._transport.sendEvent?.('setSources', { sources });
}
}
async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }) {
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));
}
class RecorderTransport implements Transport {
constructor() {
}
async dispatch(method: string, params: any) {
}
onclose() {
}
sendEvent?: (method: string, params: any) => void;
close?: () => void;
}

View file

@ -0,0 +1,28 @@
<!--
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/playwright-logo.svg" type="image/svg+xml">
<title>Playwright Recorder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/recorder.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,41 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@web/common.css';
import { applyTheme } from '@web/theme';
import '@web/third_party/vscode/codicon.css';
import * as ReactDOM from 'react-dom/client';
import { RecorderView } from './ui/recorderView';
(async () => {
applyTheme();
if (window.location.protocol !== 'file:') {
if (!navigator.serviceWorker)
throw new Error(`Service workers are not supported.\nMake sure to serve the Recorder (${window.location}) via HTTPS or localhost.`);
navigator.serviceWorker.register('sw.bundle.js');
if (!navigator.serviceWorker.controller) {
await new Promise<void>(f => {
navigator.serviceWorker.oncontrollerchange = () => f();
});
}
// Keep SW running.
setInterval(function() { fetch('ping'); }, 10000);
}
ReactDOM.createRoot(document.querySelector('#root')!).render(<RecorderView />);
})();

View file

@ -0,0 +1,15 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

View file

@ -0,0 +1,168 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import './recorderView.css';
import { MultiTraceModel } from './modelUtil';
import type { SourceLocation } from './modelUtil';
import { Workbench } from './workbench';
import type { Mode, Source } from '@recorder/recorderTypes';
import type { ContextEntry } from '../entries';
const searchParams = new URLSearchParams(window.location.search);
const guid = searchParams.get('ws');
const trace = searchParams.get('trace') + '.json';
export const RecorderView: React.FunctionComponent = () => {
const [connection, setConnection] = React.useState<Connection | null>(null);
const [sources, setSources] = React.useState<Source[]>([]);
React.useEffect(() => {
const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const webSocket = new WebSocket(wsURL.toString());
setConnection(new Connection(webSocket, { setSources }));
return () => {
webSocket.close();
};
}, []);
React.useEffect(() => {
if (!connection)
return;
connection.setMode('recording');
}, [connection]);
return <div className='vbox workbench-loader'>
<TraceView
traceLocation={trace}
sources={sources} />
</div>;
};
export const TraceView: React.FC<{
traceLocation: string,
sources: Source[],
}> = ({ traceLocation, sources }) => {
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
const model = await loadSingleTraceFile(traceLocation);
setModel({ model, isLive: true });
} catch {
setModel(undefined);
} finally {
setCounter(counter + 1);
}
}, 500);
return () => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [counter, traceLocation]);
const fallbackLocation = React.useMemo(() => {
if (!sources.length)
return undefined;
const fallbackLocation: SourceLocation = {
file: '',
line: 0,
column: 0,
source: {
errors: [],
content: sources[0].text
}
};
return fallbackLocation;
}, [sources]);
return <Workbench
key='workbench'
model={model?.model}
showSourcesFirst={true}
fallbackLocation={fallbackLocation}
isLive={true}
/>;
};
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
}
class Connection {
private _lastId = 0;
private _webSocket: WebSocket;
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
private _options: { setSources: (sources: Source[]) => void; };
constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) {
this._webSocket = webSocket;
this._callbacks = new Map();
this._options = options;
this._webSocket.addEventListener('message', event => {
const message = JSON.parse(event.data);
const { id, result, error, method, params } = message;
if (id) {
const callback = this._callbacks.get(id);
if (!callback)
return;
this._callbacks.delete(id);
if (error)
callback.reject(new Error(error));
else
callback.resolve(result);
} else {
this._dispatchEvent(method, params);
}
});
}
setMode(mode: Mode) {
this._sendMessageNoReply('setMode', { mode });
}
private async _sendMessage(method: string, params?: any): Promise<any> {
const id = ++this._lastId;
const message = { id, method, params };
this._webSocket.send(JSON.stringify(message));
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject });
});
}
private _sendMessageNoReply(method: string, params?: any) {
this._sendMessage(method, params).catch(() => { });
}
private _dispatchEvent(method: string, params?: any) {
if (method === 'setSources') {
const { sources } = params as { sources: Source[] };
this._options.setSources(sources);
}
}
}

View file

@ -55,7 +55,7 @@ export const SourceTab: React.FunctionComponent<{
let source = sources.get(file);
// Fallback location can fall outside the sources model.
if (!source) {
source = { errors: fallbackLocation?.source?.errors || [], content: undefined };
source = { errors: fallbackLocation?.source?.errors || [], content: fallbackLocation?.source?.content };
sources.set(file, source);
}
@ -66,7 +66,9 @@ export const SourceTab: React.FunctionComponent<{
highlight.push({ line: targetLine, type: 'running' });
// After the source update, but before the test run, don't trust the cache.
if (source.content === undefined || shouldUseFallback) {
if (fallbackLocation?.source?.content !== undefined) {
source.content = fallbackLocation.source.content;
} else if (source.content === undefined || shouldUseFallback) {
const sha1 = await calculateSha1(file);
try {
let response = await fetch(`sha1/src@${sha1}.txt`);

View file

@ -45,6 +45,7 @@ export default defineConfig({
index: path.resolve(__dirname, 'index.html'),
uiMode: path.resolve(__dirname, 'uiMode.html'),
embedded: path.resolve(__dirname, 'embedded.html'),
recorder: path.resolve(__dirname, 'recorder.html'),
snapshot: path.resolve(__dirname, 'snapshot.html'),
},
output: {