chore: recorder is trace viewer experiment (#32598)
This commit is contained in:
parent
de08e729ae
commit
7e3348eb0e
|
|
@ -348,10 +348,10 @@ type CaptureOptions = {
|
||||||
fullPage: boolean;
|
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);
|
validateOptions(options);
|
||||||
const browserType = lookupBrowserType(options);
|
const browserType = lookupBrowserType(options);
|
||||||
const launchOptions: LaunchOptions = { headless, executablePath };
|
const launchOptions: LaunchOptions = extraOptions;
|
||||||
if (options.channel)
|
if (options.channel)
|
||||||
launchOptions.channel = options.channel as any;
|
launchOptions.channel = options.channel as any;
|
||||||
launchOptions.handleSIGINT = false;
|
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 headful mode, use host device scale factor for things to look nice.
|
||||||
// In headless, keep things the way it works in Playwright by default.
|
// In headless, keep things the way it works in Playwright by default.
|
||||||
// Assume high-dpi on MacOS. TODO: this is not perfect.
|
// Assume high-dpi on MacOS. TODO: this is not perfect.
|
||||||
if (!headless)
|
if (!extraOptions.headless)
|
||||||
contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1;
|
contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1;
|
||||||
|
|
||||||
// Work around the WebKit GTK scrolling issue.
|
// 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) {
|
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({
|
await context._enableRecorder({
|
||||||
language,
|
language,
|
||||||
launchOptions,
|
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) {
|
async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) {
|
||||||
const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options;
|
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' });
|
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({
|
await context._enableRecorder({
|
||||||
language,
|
language,
|
||||||
launchOptions,
|
launchOptions,
|
||||||
|
|
@ -587,7 +596,7 @@ async function waitForPage(page: Page, captureOptions: CaptureOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function screenshot(options: Options, captureOptions: CaptureOptions, url: string, path: string) {
|
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);
|
console.log('Navigating to ' + url);
|
||||||
const page = await openPage(context, url);
|
const page = await openPage(context, url);
|
||||||
await waitForPage(page, captureOptions);
|
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) {
|
async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) {
|
||||||
if (options.browser !== 'chromium')
|
if (options.browser !== 'chromium')
|
||||||
throw new Error('PDF creation is only working with 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);
|
console.log('Navigating to ' + url);
|
||||||
const page = await openPage(context, url);
|
const page = await openPage(context, url);
|
||||||
await waitForPage(page, captureOptions);
|
await waitForPage(page, captureOptions);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import type { Dialog } from '../dialog';
|
||||||
import type { ConsoleMessage } from '../console';
|
import type { ConsoleMessage } from '../console';
|
||||||
import { serializeError } from '../errors';
|
import { serializeError } from '../errors';
|
||||||
import { ElementHandleDispatcher } from './elementHandlerDispatcher';
|
import { ElementHandleDispatcher } from './elementHandlerDispatcher';
|
||||||
|
import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer';
|
||||||
import { RecorderApp } from '../recorder/recorderApp';
|
import { RecorderApp } from '../recorder/recorderApp';
|
||||||
|
|
||||||
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
|
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> {
|
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) {
|
async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) {
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,12 @@ import { type Language } from './codegen/types';
|
||||||
import { Debugger } from './debugger';
|
import { Debugger } from './debugger';
|
||||||
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
|
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
|
||||||
import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder';
|
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';
|
import { buildFullSelector, metadataToCallLog } from './recorder/recorderUtils';
|
||||||
|
|
||||||
const recorderSymbol = Symbol('recorderSymbol');
|
const recorderSymbol = Symbol('recorderSymbol');
|
||||||
|
|
||||||
export type RecorderAppFactory = (recorder: Recorder) => Promise<IRecorderApp>;
|
export class Recorder implements InstrumentationListener, IRecorder {
|
||||||
|
|
||||||
export class Recorder implements InstrumentationListener {
|
|
||||||
private _context: BrowserContext;
|
private _context: BrowserContext;
|
||||||
private _mode: Mode;
|
private _mode: Mode;
|
||||||
private _highlightedSelector = '';
|
private _highlightedSelector = '';
|
||||||
|
|
@ -47,14 +45,14 @@ export class Recorder implements InstrumentationListener {
|
||||||
private _omitCallTracking = false;
|
private _omitCallTracking = false;
|
||||||
private _currentLanguage: Language;
|
private _currentLanguage: Language;
|
||||||
|
|
||||||
static showInspector(context: BrowserContext, recorderAppFactory: RecorderAppFactory) {
|
static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) {
|
||||||
const params: channels.BrowserContextRecorderSupplementEnableParams = {};
|
const params: channels.BrowserContextRecorderSupplementEnableParams = {};
|
||||||
if (isUnderTest())
|
if (isUnderTest())
|
||||||
params.language = process.env.TEST_INSPECTOR_LANGUAGE;
|
params.language = process.env.TEST_INSPECTOR_LANGUAGE;
|
||||||
Recorder.show(context, recorderAppFactory, params).catch(() => {});
|
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>;
|
let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
|
||||||
if (!recorderPromise) {
|
if (!recorderPromise) {
|
||||||
recorderPromise = Recorder._create(context, recorderAppFactory, params);
|
recorderPromise = Recorder._create(context, recorderAppFactory, params);
|
||||||
|
|
@ -63,7 +61,7 @@ export class Recorder implements InstrumentationListener {
|
||||||
return recorderPromise;
|
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 recorder = new Recorder(context, params);
|
||||||
const recorderApp = await recorderAppFactory(recorder);
|
const recorderApp = await recorderAppFactory(recorder);
|
||||||
await recorder._install(recorderApp);
|
await recorder._install(recorderApp);
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,6 @@
|
||||||
../../utils/**
|
../../utils/**
|
||||||
../../utilsBundle.ts
|
../../utilsBundle.ts
|
||||||
../../zipBundle.ts
|
../../zipBundle.ts
|
||||||
|
|
||||||
|
[recorderInTraceViewer.ts]
|
||||||
|
../trace/viewer/traceViewer.ts
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export class ContextRecorder extends EventEmitter {
|
||||||
// Make a copy of options to modify them later.
|
// Make a copy of options to modify them later.
|
||||||
const languageGeneratorOptions: LanguageGeneratorOptions = {
|
const languageGeneratorOptions: LanguageGeneratorOptions = {
|
||||||
browserName: context._browser.options.name,
|
browserName: context._browser.options.name,
|
||||||
launchOptions: { headless: false, ...params.launchOptions },
|
launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined },
|
||||||
contextOptions: { ...params.contextOptions },
|
contextOptions: { ...params.contextOptions },
|
||||||
deviceName: params.device,
|
deviceName: params.device,
|
||||||
saveStorage: params.saveStorage,
|
saveStorage: params.saveStorage,
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ import type { CallLog, EventData, Mode, Source } from '@recorder/recorderTypes';
|
||||||
import { isUnderTest } from '../../utils';
|
import { isUnderTest } from '../../utils';
|
||||||
import { mime } from '../../utilsBundle';
|
import { mime } from '../../utilsBundle';
|
||||||
import { syncLocalStorageWithSettings } from '../launchApp';
|
import { syncLocalStorageWithSettings } from '../launchApp';
|
||||||
import type { Recorder, RecorderAppFactory } from '../recorder';
|
|
||||||
import type { BrowserContext } from '../browserContext';
|
import type { BrowserContext } from '../browserContext';
|
||||||
import { launchApp } from '../launchApp';
|
import { launchApp } from '../launchApp';
|
||||||
|
import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
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 {
|
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
|
||||||
async close(): Promise<void> {}
|
async close(): Promise<void> {}
|
||||||
async setPaused(paused: boolean): 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 {
|
export class RecorderApp extends EventEmitter implements IRecorderApp {
|
||||||
private _page: Page;
|
private _page: Page;
|
||||||
readonly wsEndpoint: string | undefined;
|
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();
|
super();
|
||||||
this.setMaxListeners(0);
|
this.setMaxListeners(0);
|
||||||
this._recorder = recorder;
|
this._recorder = recorder;
|
||||||
|
|
@ -113,7 +103,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
|
||||||
await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html');
|
await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
static factory(context: BrowserContext): RecorderAppFactory {
|
static factory(context: BrowserContext): IRecorderAppFactory {
|
||||||
return async recorder => {
|
return async recorder => {
|
||||||
if (process.env.PW_CODEGEN_NO_INSPECTOR)
|
if (process.env.PW_CODEGEN_NO_INSPECTOR)
|
||||||
return new EmptyRecorderApp();
|
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 sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage;
|
||||||
const headed = !!inspectedContext._browser.options.headful;
|
const headed = !!inspectedContext._browser.options.headful;
|
||||||
const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true });
|
const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true });
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
28
packages/trace-viewer/recorder.html
Normal file
28
packages/trace-viewer/recorder.html
Normal 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>
|
||||||
41
packages/trace-viewer/src/recorder.tsx
Normal file
41
packages/trace-viewer/src/recorder.tsx
Normal 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 />);
|
||||||
|
})();
|
||||||
15
packages/trace-viewer/src/ui/recorderView.css
Normal file
15
packages/trace-viewer/src/ui/recorderView.css
Normal 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.
|
||||||
|
*/
|
||||||
168
packages/trace-viewer/src/ui/recorderView.tsx
Normal file
168
packages/trace-viewer/src/ui/recorderView.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ export const SourceTab: React.FunctionComponent<{
|
||||||
let source = sources.get(file);
|
let source = sources.get(file);
|
||||||
// Fallback location can fall outside the sources model.
|
// Fallback location can fall outside the sources model.
|
||||||
if (!source) {
|
if (!source) {
|
||||||
source = { errors: fallbackLocation?.source?.errors || [], content: undefined };
|
source = { errors: fallbackLocation?.source?.errors || [], content: fallbackLocation?.source?.content };
|
||||||
sources.set(file, source);
|
sources.set(file, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +66,9 @@ export const SourceTab: React.FunctionComponent<{
|
||||||
highlight.push({ line: targetLine, type: 'running' });
|
highlight.push({ line: targetLine, type: 'running' });
|
||||||
|
|
||||||
// After the source update, but before the test run, don't trust the cache.
|
// 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);
|
const sha1 = await calculateSha1(file);
|
||||||
try {
|
try {
|
||||||
let response = await fetch(`sha1/src@${sha1}.txt`);
|
let response = await fetch(`sha1/src@${sha1}.txt`);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export default defineConfig({
|
||||||
index: path.resolve(__dirname, 'index.html'),
|
index: path.resolve(__dirname, 'index.html'),
|
||||||
uiMode: path.resolve(__dirname, 'uiMode.html'),
|
uiMode: path.resolve(__dirname, 'uiMode.html'),
|
||||||
embedded: path.resolve(__dirname, 'embedded.html'),
|
embedded: path.resolve(__dirname, 'embedded.html'),
|
||||||
|
recorder: path.resolve(__dirname, 'recorder.html'),
|
||||||
snapshot: path.resolve(__dirname, 'snapshot.html'),
|
snapshot: path.resolve(__dirname, 'snapshot.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue