mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(inspector): wire snapshots to inspector (#5628)
This commit is contained in:
parent
c652794b5a
commit
aeb2b2f605
@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { TimeoutSettings } from '../utils/timeoutSettings';
|
import { TimeoutSettings } from '../utils/timeoutSettings';
|
||||||
import { mkdirIfNeeded } from '../utils/utils';
|
import { isDebugMode, mkdirIfNeeded } from '../utils/utils';
|
||||||
import { Browser, BrowserOptions } from './browser';
|
import { Browser, BrowserOptions } from './browser';
|
||||||
import { Download } from './download';
|
import { Download } from './download';
|
||||||
import * as frames from './frames';
|
import * as frames from './frames';
|
||||||
@ -396,6 +396,8 @@ export function validateBrowserContextOptions(options: types.BrowserContextOptio
|
|||||||
throw new Error(`Browser needs to be launched with the global proxy. If all contexts override the proxy, global proxy will be never used and can be any string, for example "launch({ proxy: { server: 'per-context' } })"`);
|
throw new Error(`Browser needs to be launched with the global proxy. If all contexts override the proxy, global proxy will be never used and can be any string, for example "launch({ proxy: { server: 'per-context' } })"`);
|
||||||
options.proxy = normalizeProxySettings(options.proxy);
|
options.proxy = normalizeProxySettings(options.proxy);
|
||||||
}
|
}
|
||||||
|
if (isDebugMode())
|
||||||
|
options.bypassCSP = true;
|
||||||
verifyGeolocation(options.geolocation);
|
verifyGeolocation(options.geolocation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,10 +14,12 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { HttpServer } from '../../utils/httpServer';
|
||||||
import { BrowserContext } from '../browserContext';
|
import { BrowserContext } from '../browserContext';
|
||||||
|
import { Page } from '../page';
|
||||||
import { ContextResources, FrameSnapshot } from './snapshot';
|
import { ContextResources, FrameSnapshot } from './snapshot';
|
||||||
import { SnapshotRenderer } from './snapshotRenderer';
|
import { SnapshotRenderer } from './snapshotRenderer';
|
||||||
import { NetworkResponse, SnapshotStorage } from './snapshotServer';
|
import { NetworkResponse, SnapshotServer, SnapshotStorage } from './snapshotServer';
|
||||||
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate, SnapshotterResource } from './snapshotter';
|
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate, SnapshotterResource } from './snapshotter';
|
||||||
|
|
||||||
export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate {
|
export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate {
|
||||||
@ -26,12 +28,29 @@ export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate
|
|||||||
private _frameSnapshots = new Map<string, FrameSnapshot[]>();
|
private _frameSnapshots = new Map<string, FrameSnapshot[]>();
|
||||||
private _snapshots = new Map<string, SnapshotRenderer>();
|
private _snapshots = new Map<string, SnapshotRenderer>();
|
||||||
private _contextResources: ContextResources = new Map();
|
private _contextResources: ContextResources = new Map();
|
||||||
|
private _server: HttpServer;
|
||||||
private _snapshotter: Snapshotter;
|
private _snapshotter: Snapshotter;
|
||||||
|
|
||||||
constructor(context: BrowserContext) {
|
constructor(context: BrowserContext) {
|
||||||
|
this._server = new HttpServer();
|
||||||
|
new SnapshotServer(this._server, this);
|
||||||
this._snapshotter = new Snapshotter(context, this);
|
this._snapshotter = new Snapshotter(context, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async start(): Promise<string> {
|
||||||
|
await this._snapshotter.start();
|
||||||
|
return await this._server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this._snapshotter.dispose();
|
||||||
|
this._server.stop().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async forceSnapshot(page: Page, snapshotId: string) {
|
||||||
|
await this._snapshotter.forceSnapshot(page, snapshotId);
|
||||||
|
}
|
||||||
|
|
||||||
onBlob(blob: SnapshotterBlob): void {
|
onBlob(blob: SnapshotterBlob): void {
|
||||||
this._blobs.set(blob.sha1, blob.buffer);
|
this._blobs.set(blob.sha1, blob.buffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,32 +33,17 @@ export interface SnapshotStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SnapshotServer {
|
export class SnapshotServer {
|
||||||
private _urlPrefix: string;
|
|
||||||
private _snapshotStorage: SnapshotStorage;
|
private _snapshotStorage: SnapshotStorage;
|
||||||
|
|
||||||
constructor(server: HttpServer, snapshotStorage: SnapshotStorage) {
|
constructor(server: HttpServer, snapshotStorage: SnapshotStorage) {
|
||||||
this._urlPrefix = server.urlPrefix();
|
|
||||||
this._snapshotStorage = snapshotStorage;
|
this._snapshotStorage = snapshotStorage;
|
||||||
|
|
||||||
server.routePath('/snapshot/', this._serveSnapshotRoot.bind(this), true);
|
server.routePath('/snapshot/', this._serveSnapshotRoot.bind(this));
|
||||||
server.routePath('/snapshot/service-worker.js', this._serveServiceWorker.bind(this));
|
server.routePath('/snapshot/service-worker.js', this._serveServiceWorker.bind(this));
|
||||||
server.routePath('/snapshot-data', this._serveSnapshot.bind(this));
|
server.routePath('/snapshot-data', this._serveSnapshot.bind(this));
|
||||||
server.routePrefix('/resources/', this._serveResource.bind(this));
|
server.routePrefix('/resources/', this._serveResource.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotRootUrl() {
|
|
||||||
return this._urlPrefix + '/snapshot/';
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshotUrl(pageId: string, snapshotId?: string, timestamp?: number) {
|
|
||||||
// Prefer snapshotId over timestamp.
|
|
||||||
if (snapshotId)
|
|
||||||
return this._urlPrefix + `/snapshot/pageId/${pageId}/snapshotId/${snapshotId}/main`;
|
|
||||||
if (timestamp)
|
|
||||||
return this._urlPrefix + `/snapshot/pageId/${pageId}/timestamp/${timestamp}/main`;
|
|
||||||
return 'data:text/html,Snapshot is not available';
|
|
||||||
}
|
|
||||||
|
|
||||||
private _serveSnapshotRoot(request: http.IncomingMessage, response: http.ServerResponse): boolean {
|
private _serveSnapshotRoot(request: http.IncomingMessage, response: http.ServerResponse): boolean {
|
||||||
response.statusCode = 200;
|
response.statusCode = 200;
|
||||||
response.setHeader('Cache-Control', 'public, max-age=31536000');
|
response.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||||
@ -80,8 +65,8 @@ export class SnapshotServer {
|
|||||||
</style>
|
</style>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
|
if (navigator.serviceWorker) {
|
||||||
navigator.serviceWorker.register('./service-worker.js');
|
navigator.serviceWorker.register('./service-worker.js');
|
||||||
|
|
||||||
let showPromise = Promise.resolve();
|
let showPromise = Promise.resolve();
|
||||||
if (!navigator.serviceWorker.controller)
|
if (!navigator.serviceWorker.controller)
|
||||||
showPromise = new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve);
|
showPromise = new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve);
|
||||||
@ -105,6 +90,10 @@ export class SnapshotServer {
|
|||||||
await showPromise;
|
await showPromise;
|
||||||
next.src = url;
|
next.src = url;
|
||||||
};
|
};
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
window.showSnapshot(window.location.href + event.data.snapshotId);
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
`);
|
`);
|
||||||
@ -148,13 +137,13 @@ export class SnapshotServer {
|
|||||||
|
|
||||||
let snapshotId: string;
|
let snapshotId: string;
|
||||||
if (request.mode === 'navigate') {
|
if (request.mode === 'navigate') {
|
||||||
snapshotId = pathname;
|
snapshotId = pathname.substring('/snapshot/'.length);
|
||||||
} else {
|
} else {
|
||||||
const client = (await self.clients.get(event.clientId))!;
|
const client = (await self.clients.get(event.clientId))!;
|
||||||
snapshotId = new URL(client.url).pathname;
|
snapshotId = new URL(client.url).pathname.substring('/snapshot/'.length);
|
||||||
}
|
}
|
||||||
if (request.mode === 'navigate') {
|
if (request.mode === 'navigate') {
|
||||||
const htmlResponse = await fetch(`/snapshot-data?snapshotName=${snapshotId}`);
|
const htmlResponse = await fetch(`/snapshot-data?snapshotId=${snapshotId}`);
|
||||||
const { html, resources }: RenderedFrameSnapshot = await htmlResponse.json();
|
const { html, resources }: RenderedFrameSnapshot = await htmlResponse.json();
|
||||||
if (!html)
|
if (!html)
|
||||||
return respondNotAvailable();
|
return respondNotAvailable();
|
||||||
@ -208,7 +197,7 @@ export class SnapshotServer {
|
|||||||
response.setHeader('Cache-Control', 'public, max-age=31536000');
|
response.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||||
response.setHeader('Content-Type', 'application/json');
|
response.setHeader('Content-Type', 'application/json');
|
||||||
const parsed: any = querystring.parse(request.url!.substring(request.url!.indexOf('?') + 1));
|
const parsed: any = querystring.parse(request.url!.substring(request.url!.indexOf('?') + 1));
|
||||||
const snapshot = this._snapshotStorage.snapshotById(parsed.snapshotName);
|
const snapshot = this._snapshotStorage.snapshotById(parsed.snapshotId);
|
||||||
const snapshotData: any = snapshot ? snapshot.render() : { html: '' };
|
const snapshotData: any = snapshot ? snapshot.render() : { html: '' };
|
||||||
response.end(JSON.stringify(snapshotData));
|
response.end(JSON.stringify(snapshotData));
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -52,15 +52,18 @@ export interface SnapshotterDelegate {
|
|||||||
export class Snapshotter {
|
export class Snapshotter {
|
||||||
private _context: BrowserContext;
|
private _context: BrowserContext;
|
||||||
private _delegate: SnapshotterDelegate;
|
private _delegate: SnapshotterDelegate;
|
||||||
private _eventListeners: RegisteredListener[];
|
private _eventListeners: RegisteredListener[] = [];
|
||||||
|
|
||||||
constructor(context: BrowserContext, delegate: SnapshotterDelegate) {
|
constructor(context: BrowserContext, delegate: SnapshotterDelegate) {
|
||||||
this._context = context;
|
this._context = context;
|
||||||
this._delegate = delegate;
|
this._delegate = delegate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
this._eventListeners = [
|
this._eventListeners = [
|
||||||
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
|
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
|
||||||
];
|
];
|
||||||
this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => {
|
await this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => {
|
||||||
const snapshot: FrameSnapshot = {
|
const snapshot: FrameSnapshot = {
|
||||||
snapshotId: data.snapshotId,
|
snapshotId: data.snapshotId,
|
||||||
pageId: source.page.idInSnapshot,
|
pageId: source.page.idInSnapshot,
|
||||||
@ -83,7 +86,10 @@ export class Snapshotter {
|
|||||||
}
|
}
|
||||||
this._delegate.onFrameSnapshot(snapshot);
|
this._delegate.onFrameSnapshot(snapshot);
|
||||||
});
|
});
|
||||||
this._context._doAddInitScript('(' + frameSnapshotStreamer.toString() + ')()');
|
const initScript = '(' + frameSnapshotStreamer.toString() + ')()';
|
||||||
|
await this._context._doAddInitScript(initScript);
|
||||||
|
for (const page of this._context.pages())
|
||||||
|
await page.mainFrame()._evaluateExpression(initScript, false, undefined, 'main');
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
|
|||||||
@ -37,6 +37,9 @@ export function frameSnapshotStreamer() {
|
|||||||
const kSnapshotStreamer = '__playwright_snapshot_streamer_';
|
const kSnapshotStreamer = '__playwright_snapshot_streamer_';
|
||||||
const kSnapshotBinding = '__playwright_snapshot_binding_';
|
const kSnapshotBinding = '__playwright_snapshot_binding_';
|
||||||
|
|
||||||
|
if ((window as any)[kSnapshotStreamer])
|
||||||
|
return;
|
||||||
|
|
||||||
// Attributes present in the snapshot.
|
// Attributes present in the snapshot.
|
||||||
const kShadowAttribute = '__playwright_shadow_root_';
|
const kShadowAttribute = '__playwright_shadow_root_';
|
||||||
const kScrollTopAttribute = '__playwright_scroll_top_';
|
const kScrollTopAttribute = '__playwright_scroll_top_';
|
||||||
|
|||||||
@ -28,6 +28,7 @@ declare global {
|
|||||||
_playwrightRecorderState: () => Promise<UIState>;
|
_playwrightRecorderState: () => Promise<UIState>;
|
||||||
_playwrightResume: () => Promise<void>;
|
_playwrightResume: () => Promise<void>;
|
||||||
_playwrightRecorderSetSelector: (selector: string) => Promise<void>;
|
_playwrightRecorderSetSelector: (selector: string) => Promise<void>;
|
||||||
|
_playwrightRefreshOverlay: () => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,8 +53,11 @@ export class Recorder {
|
|||||||
private _actionPoint: Point | undefined;
|
private _actionPoint: Point | undefined;
|
||||||
private _actionSelector: string | undefined;
|
private _actionSelector: string | undefined;
|
||||||
private _params: { isUnderTest: boolean; };
|
private _params: { isUnderTest: boolean; };
|
||||||
|
private _snapshotIframe: HTMLIFrameElement | undefined;
|
||||||
|
private _snapshotId: string | undefined;
|
||||||
|
private _snapshotBaseUrl: string;
|
||||||
|
|
||||||
constructor(injectedScript: InjectedScript, params: { isUnderTest: boolean }) {
|
constructor(injectedScript: InjectedScript, params: { isUnderTest: boolean, snapshotBaseUrl: string }) {
|
||||||
this._params = params;
|
this._params = params;
|
||||||
this._injectedScript = injectedScript;
|
this._injectedScript = injectedScript;
|
||||||
this._outerGlassPaneElement = document.createElement('x-pw-glass');
|
this._outerGlassPaneElement = document.createElement('x-pw-glass');
|
||||||
@ -65,6 +69,7 @@ export class Recorder {
|
|||||||
this._outerGlassPaneElement.style.zIndex = '2147483647';
|
this._outerGlassPaneElement.style.zIndex = '2147483647';
|
||||||
this._outerGlassPaneElement.style.pointerEvents = 'none';
|
this._outerGlassPaneElement.style.pointerEvents = 'none';
|
||||||
this._outerGlassPaneElement.style.display = 'flex';
|
this._outerGlassPaneElement.style.display = 'flex';
|
||||||
|
this._snapshotBaseUrl = params.snapshotBaseUrl;
|
||||||
|
|
||||||
this._tooltipElement = document.createElement('x-pw-tooltip');
|
this._tooltipElement = document.createElement('x-pw-tooltip');
|
||||||
this._actionPointElement = document.createElement('x-pw-action-point');
|
this._actionPointElement = document.createElement('x-pw-action-point');
|
||||||
@ -122,10 +127,15 @@ export class Recorder {
|
|||||||
this._refreshListenersIfNeeded();
|
this._refreshListenersIfNeeded();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this._refreshListenersIfNeeded();
|
this._refreshListenersIfNeeded();
|
||||||
if ((window as any)._recorderScriptReadyForTest)
|
if ((window as any)._recorderScriptReadyForTest) {
|
||||||
(window as any)._recorderScriptReadyForTest();
|
(window as any)._recorderScriptReadyForTest();
|
||||||
|
delete (window as any)._recorderScriptReadyForTest;
|
||||||
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
|
window._playwrightRefreshOverlay = () => {
|
||||||
|
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
|
||||||
|
};
|
||||||
|
window._playwrightRefreshOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _refreshListenersIfNeeded() {
|
private _refreshListenersIfNeeded() {
|
||||||
@ -152,8 +162,29 @@ export class Recorder {
|
|||||||
document.documentElement.appendChild(this._outerGlassPaneElement);
|
document.documentElement.appendChild(this._outerGlassPaneElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _createSnapshotIframeIfNeeded(): HTMLIFrameElement | undefined {
|
||||||
|
if (this._snapshotIframe)
|
||||||
|
return this._snapshotIframe;
|
||||||
|
if (window.top === window) {
|
||||||
|
this._snapshotIframe = document.createElement('iframe');
|
||||||
|
this._snapshotIframe.src = this._snapshotBaseUrl;
|
||||||
|
this._snapshotIframe.style.position = 'fixed';
|
||||||
|
this._snapshotIframe.style.top = '0';
|
||||||
|
this._snapshotIframe.style.right = '0';
|
||||||
|
this._snapshotIframe.style.bottom = '0';
|
||||||
|
this._snapshotIframe.style.left = '0';
|
||||||
|
this._snapshotIframe.style.border = 'none';
|
||||||
|
this._snapshotIframe.style.width = '100%';
|
||||||
|
this._snapshotIframe.style.height = '100%';
|
||||||
|
this._snapshotIframe.style.zIndex = '2147483647';
|
||||||
|
this._snapshotIframe.style.visibility = 'hidden';
|
||||||
|
document.documentElement.appendChild(this._snapshotIframe);
|
||||||
|
}
|
||||||
|
return this._snapshotIframe;
|
||||||
|
}
|
||||||
|
|
||||||
private async _pollRecorderMode() {
|
private async _pollRecorderMode() {
|
||||||
const pollPeriod = 250;
|
const pollPeriod = 1000;
|
||||||
if (this._pollRecorderModeTimer)
|
if (this._pollRecorderModeTimer)
|
||||||
clearTimeout(this._pollRecorderModeTimer);
|
clearTimeout(this._pollRecorderModeTimer);
|
||||||
const state = await window._playwrightRecorderState().catch(e => null);
|
const state = await window._playwrightRecorderState().catch(e => null);
|
||||||
@ -162,7 +193,7 @@ export class Recorder {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { mode, actionPoint, actionSelector } = state;
|
const { mode, actionPoint, actionSelector, snapshotId } = state;
|
||||||
if (mode !== this._mode) {
|
if (mode !== this._mode) {
|
||||||
this._mode = mode;
|
this._mode = mode;
|
||||||
this._clearHighlight();
|
this._clearHighlight();
|
||||||
@ -191,6 +222,18 @@ export class Recorder {
|
|||||||
this._updateHighlight();
|
this._updateHighlight();
|
||||||
this._actionSelector = actionSelector;
|
this._actionSelector = actionSelector;
|
||||||
}
|
}
|
||||||
|
if (snapshotId !== this._snapshotId) {
|
||||||
|
this._snapshotId = snapshotId;
|
||||||
|
const snapshotIframe = this._createSnapshotIframeIfNeeded();
|
||||||
|
if (snapshotIframe) {
|
||||||
|
if (!snapshotId) {
|
||||||
|
snapshotIframe.style.visibility = 'hidden';
|
||||||
|
} else {
|
||||||
|
snapshotIframe.style.visibility = 'visible';
|
||||||
|
snapshotIframe.contentWindow?.postMessage({ snapshotId }, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
|
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -83,14 +83,14 @@ export class InspectorController implements InstrumentationListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context);
|
const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context);
|
||||||
await recorder?.onAfterCall(metadata);
|
await recorder?.onAfterCall(sdkObject, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
||||||
if (!sdkObject.attribution.context)
|
if (!sdkObject.attribution.context)
|
||||||
return;
|
return;
|
||||||
const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context);
|
const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context);
|
||||||
await recorder?.onBeforeInputAction(metadata);
|
await recorder?.onBeforeInputAction(sdkObject, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { Point } from '../../../common/types';
|
|||||||
export type Mode = 'inspecting' | 'recording' | 'none';
|
export type Mode = 'inspecting' | 'recording' | 'none';
|
||||||
|
|
||||||
export type EventData = {
|
export type EventData = {
|
||||||
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated';
|
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated' | 'callLogHovered';
|
||||||
params: any;
|
params: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -27,6 +27,7 @@ export type UIState = {
|
|||||||
mode: Mode;
|
mode: Mode;
|
||||||
actionPoint?: Point;
|
actionPoint?: Point;
|
||||||
actionSelector?: string;
|
actionSelector?: string;
|
||||||
|
snapshotId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CallLog = {
|
export type CallLog = {
|
||||||
@ -41,6 +42,11 @@ export type CallLog = {
|
|||||||
url?: string,
|
url?: string,
|
||||||
selector?: string,
|
selector?: string,
|
||||||
};
|
};
|
||||||
|
snapshots: {
|
||||||
|
before: boolean,
|
||||||
|
in: boolean,
|
||||||
|
after: boolean,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SourceHighlight = {
|
export type SourceHighlight = {
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import { CallMetadata, internalCallMetadata, SdkObject } from '../instrumentatio
|
|||||||
import { Point } from '../../common/types';
|
import { Point } from '../../common/types';
|
||||||
import { CallLog, EventData, Mode, Source, UIState } from './recorder/recorderTypes';
|
import { CallLog, EventData, Mode, Source, UIState } from './recorder/recorderTypes';
|
||||||
import { isUnderTest, monotonicTime } from '../../utils/utils';
|
import { isUnderTest, monotonicTime } from '../../utils/utils';
|
||||||
|
import { InMemorySnapshotter } from '../snapshot/inMemorySnapshotter';
|
||||||
|
|
||||||
type BindingSource = { frame: Frame, page: Page };
|
type BindingSource = { frame: Frame, page: Page };
|
||||||
|
|
||||||
@ -53,6 +54,10 @@ export class RecorderSupplement {
|
|||||||
private _pauseOnNextStatement: boolean;
|
private _pauseOnNextStatement: boolean;
|
||||||
private _recorderSources: Source[];
|
private _recorderSources: Source[];
|
||||||
private _userSources = new Map<string, Source>();
|
private _userSources = new Map<string, Source>();
|
||||||
|
private _snapshotter: InMemorySnapshotter;
|
||||||
|
private _hoveredSnapshot: { callLogId: number, phase: 'before' | 'after' | 'in' } | undefined;
|
||||||
|
private _snapshots = new Set<string>();
|
||||||
|
private _allMetadatas = new Map<number, CallMetadata>();
|
||||||
|
|
||||||
static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<RecorderSupplement> {
|
static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<RecorderSupplement> {
|
||||||
let recorderPromise = (context as any)[symbol] as Promise<RecorderSupplement>;
|
let recorderPromise = (context as any)[symbol] as Promise<RecorderSupplement>;
|
||||||
@ -119,21 +124,32 @@ export class RecorderSupplement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this._generator = generator;
|
this._generator = generator;
|
||||||
|
this._snapshotter = new InMemorySnapshotter(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
async install() {
|
async install() {
|
||||||
const recorderApp = await RecorderApp.open(this._context);
|
const recorderApp = await RecorderApp.open(this._context);
|
||||||
this._recorderApp = recorderApp;
|
this._recorderApp = recorderApp;
|
||||||
recorderApp.once('close', () => {
|
recorderApp.once('close', () => {
|
||||||
|
this._snapshotter.stop();
|
||||||
this._recorderApp = null;
|
this._recorderApp = null;
|
||||||
});
|
});
|
||||||
recorderApp.on('event', (data: EventData) => {
|
recorderApp.on('event', (data: EventData) => {
|
||||||
if (data.event === 'setMode') {
|
if (data.event === 'setMode') {
|
||||||
this._setMode(data.params.mode);
|
this._setMode(data.params.mode);
|
||||||
|
this._refreshOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.event === 'selectorUpdated') {
|
if (data.event === 'selectorUpdated') {
|
||||||
this._highlightedSelector = data.params.selector;
|
this._highlightedSelector = data.params.selector;
|
||||||
|
this._refreshOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.event === 'callLogHovered') {
|
||||||
|
this._hoveredSnapshot = undefined;
|
||||||
|
if (this._isPaused())
|
||||||
|
this._hoveredSnapshot = data.params;
|
||||||
|
this._refreshOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.event === 'step') {
|
if (data.event === 'step') {
|
||||||
@ -185,15 +201,27 @@ export class RecorderSupplement {
|
|||||||
(source: BindingSource, action: actions.Action) => this._generator.commitLastAction());
|
(source: BindingSource, action: actions.Action) => this._generator.commitLastAction());
|
||||||
|
|
||||||
await this._context.exposeBinding('_playwrightRecorderState', false, source => {
|
await this._context.exposeBinding('_playwrightRecorderState', false, source => {
|
||||||
let actionPoint: Point | undefined = undefined;
|
let snapshotId: string | undefined;
|
||||||
let actionSelector: string | undefined = undefined;
|
let actionSelector: string | undefined;
|
||||||
for (const [metadata, sdkObject] of this._currentCallsMetadata) {
|
let actionPoint: Point | undefined;
|
||||||
if (source.page === sdkObject.attribution.page) {
|
if (this._hoveredSnapshot) {
|
||||||
actionPoint = metadata.point || actionPoint;
|
snapshotId = this._hoveredSnapshot.phase + '@' + this._hoveredSnapshot.callLogId;
|
||||||
actionSelector = metadata.params.selector || actionSelector;
|
const metadata = this._allMetadatas.get(this._hoveredSnapshot.callLogId);
|
||||||
|
actionPoint = this._hoveredSnapshot.phase === 'in' ? metadata?.point : undefined;
|
||||||
|
} else {
|
||||||
|
for (const [metadata, sdkObject] of this._currentCallsMetadata) {
|
||||||
|
if (source.page === sdkObject.attribution.page) {
|
||||||
|
actionPoint = metadata.point || actionPoint;
|
||||||
|
actionSelector = metadata.params.selector || actionSelector;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const uiState: UIState = { mode: this._mode, actionPoint, actionSelector: this._highlightedSelector || actionSelector };
|
const uiState: UIState = {
|
||||||
|
mode: this._mode,
|
||||||
|
actionPoint,
|
||||||
|
actionSelector,
|
||||||
|
snapshotId,
|
||||||
|
};
|
||||||
return uiState;
|
return uiState;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -207,9 +235,9 @@ export class RecorderSupplement {
|
|||||||
this._resume(false).catch(() => {});
|
this._resume(false).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
await this._context.extendInjectedScript(recorderSource.source, { isUnderTest: isUnderTest() });
|
const snapshotBaseUrl = await this._snapshotter.start() + '/snapshot/';
|
||||||
|
await this._context.extendInjectedScript(recorderSource.source, { isUnderTest: isUnderTest(), snapshotBaseUrl });
|
||||||
await this._context.extendInjectedScript(consoleApiSource.source);
|
await this._context.extendInjectedScript(consoleApiSource.source);
|
||||||
|
|
||||||
(this._context as any).recorderAppForTest = recorderApp;
|
(this._context as any).recorderAppForTest = recorderApp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,6 +252,10 @@ export class RecorderSupplement {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isPaused(): boolean {
|
||||||
|
return !!this._pausedCallsMetadata.size;
|
||||||
|
}
|
||||||
|
|
||||||
private _setMode(mode: Mode) {
|
private _setMode(mode: Mode) {
|
||||||
this._mode = mode;
|
this._mode = mode;
|
||||||
this._recorderApp?.setMode(this._mode);
|
this._recorderApp?.setMode(this._mode);
|
||||||
@ -247,6 +279,11 @@ export class RecorderSupplement {
|
|||||||
this.updateCallLog([...this._currentCallsMetadata.keys()]);
|
this.updateCallLog([...this._currentCallsMetadata.keys()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _refreshOverlay() {
|
||||||
|
for (const page of this._context.pages())
|
||||||
|
page.mainFrame()._evaluateExpression('window._playwrightRefreshOverlay', false, undefined, 'main').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
private async _onPage(page: Page) {
|
private async _onPage(page: Page) {
|
||||||
// First page is called page, others are called popup1, popup2, etc.
|
// First page is called page, others are called popup1, popup2, etc.
|
||||||
const frame = page.mainFrame();
|
const frame = page.mainFrame();
|
||||||
@ -362,10 +399,20 @@ export class RecorderSupplement {
|
|||||||
this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: String(++this._lastDialogOrdinal) });
|
this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: String(++this._lastDialogOrdinal) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _captureSnapshot(sdkObject: SdkObject, metadata: CallMetadata, phase: 'before' | 'after' | 'in') {
|
||||||
|
if (sdkObject.attribution.page) {
|
||||||
|
const snapshotId = `${phase}@${metadata.id}`;
|
||||||
|
this._snapshots.add(snapshotId);
|
||||||
|
await this._snapshotter.forceSnapshot(sdkObject.attribution.page, snapshotId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
||||||
if (this._mode === 'recording')
|
if (this._mode === 'recording')
|
||||||
return;
|
return;
|
||||||
|
await this._captureSnapshot(sdkObject, metadata, 'before');
|
||||||
this._currentCallsMetadata.set(metadata, sdkObject);
|
this._currentCallsMetadata.set(metadata, sdkObject);
|
||||||
|
this._allMetadatas.set(metadata.id, metadata);
|
||||||
this._updateUserSources();
|
this._updateUserSources();
|
||||||
this.updateCallLog([metadata]);
|
this.updateCallLog([metadata]);
|
||||||
if (shouldPauseOnCall(sdkObject, metadata) || (this._pauseOnNextStatement && shouldPauseOnStep(sdkObject, metadata)))
|
if (shouldPauseOnCall(sdkObject, metadata) || (this._pauseOnNextStatement && shouldPauseOnStep(sdkObject, metadata)))
|
||||||
@ -376,9 +423,10 @@ export class RecorderSupplement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onAfterCall(metadata: CallMetadata): Promise<void> {
|
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
||||||
if (this._mode === 'recording')
|
if (this._mode === 'recording')
|
||||||
return;
|
return;
|
||||||
|
await this._captureSnapshot(sdkObject, metadata, 'after');
|
||||||
if (!metadata.error)
|
if (!metadata.error)
|
||||||
this._currentCallsMetadata.delete(metadata);
|
this._currentCallsMetadata.delete(metadata);
|
||||||
this._pausedCallsMetadata.delete(metadata);
|
this._pausedCallsMetadata.delete(metadata);
|
||||||
@ -420,9 +468,10 @@ export class RecorderSupplement {
|
|||||||
this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()]);
|
this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onBeforeInputAction(metadata: CallMetadata): Promise<void> {
|
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
||||||
if (this._mode === 'recording')
|
if (this._mode === 'recording')
|
||||||
return;
|
return;
|
||||||
|
await this._captureSnapshot(sdkObject, metadata, 'in');
|
||||||
if (this._pauseOnNextStatement)
|
if (this._pauseOnNextStatement)
|
||||||
await this.pause(metadata);
|
await this.pause(metadata);
|
||||||
}
|
}
|
||||||
@ -458,7 +507,12 @@ export class RecorderSupplement {
|
|||||||
status,
|
status,
|
||||||
error: metadata.error,
|
error: metadata.error,
|
||||||
params,
|
params,
|
||||||
duration
|
duration,
|
||||||
|
snapshots: {
|
||||||
|
before: showBeforeSnapshot(metadata) && this._snapshots.has(`before@${metadata.id}`),
|
||||||
|
in: showInSnapshot(metadata) && this._snapshots.has(`in@${metadata.id}`),
|
||||||
|
after: showAfterSnapshot(metadata) && this._snapshots.has(`after@${metadata.id}`),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this._recorderApp?.updateCallLogs(logs);
|
this._recorderApp?.updateCallLogs(logs);
|
||||||
@ -492,3 +546,15 @@ function shouldPauseOnCall(sdkObject: SdkObject, metadata: CallMetadata): boolea
|
|||||||
function shouldPauseOnStep(sdkObject: SdkObject, metadata: CallMetadata): boolean {
|
function shouldPauseOnStep(sdkObject: SdkObject, metadata: CallMetadata): boolean {
|
||||||
return metadata.method === 'goto' || metadata.method === 'close';
|
return metadata.method === 'goto' || metadata.method === 'close';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showBeforeSnapshot(metadata: CallMetadata): boolean {
|
||||||
|
return metadata.method === 'close';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInSnapshot(metadata: CallMetadata): boolean {
|
||||||
|
return ['click', 'dblclick', 'check', 'uncheck', 'fill', 'press'].includes(metadata.method);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAfterSnapshot(metadata: CallMetadata): boolean {
|
||||||
|
return ['goto', 'click', 'dblclick', 'dblclick', 'check', 'uncheck', 'fill', 'press'].includes(metadata.method);
|
||||||
|
}
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export class Tracer implements InstrumentationListener {
|
|||||||
const traceStorageDir = path.join(traceDir, 'resources');
|
const traceStorageDir = path.join(traceDir, 'resources');
|
||||||
const tracePath = path.join(traceDir, createGuid() + '.trace');
|
const tracePath = path.join(traceDir, createGuid() + '.trace');
|
||||||
const contextTracer = new ContextTracer(context, traceStorageDir, tracePath);
|
const contextTracer = new ContextTracer(context, traceStorageDir, tracePath);
|
||||||
|
await contextTracer.start();
|
||||||
this._contextTracers.set(context, contextTracer);
|
this._contextTracers.set(context, contextTracer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,6 +111,10 @@ class ContextTracer implements SnapshotterDelegate {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
await this._snapshotter.start();
|
||||||
|
}
|
||||||
|
|
||||||
onBlob(blob: SnapshotterBlob): void {
|
onBlob(blob: SnapshotterBlob): void {
|
||||||
this._writeArtifact(blob.sha1, blob.buffer);
|
this._writeArtifact(blob.sha1, blob.buffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,7 +86,7 @@ class TraceViewer implements SnapshotStorage {
|
|||||||
const absolutePath = path.join(__dirname, '..', '..', '..', 'web', ...relativePath.split('/'));
|
const absolutePath = path.join(__dirname, '..', '..', '..', 'web', ...relativePath.split('/'));
|
||||||
return server.serveFile(response, absolutePath);
|
return server.serveFile(response, absolutePath);
|
||||||
};
|
};
|
||||||
server.routePrefix('/traceviewer/', traceViewerHandler, true);
|
server.routePrefix('/traceviewer/', traceViewerHandler);
|
||||||
|
|
||||||
const fileHandler: ServerRouteHandler = (request, response) => {
|
const fileHandler: ServerRouteHandler = (request, response) => {
|
||||||
try {
|
try {
|
||||||
@ -122,9 +122,9 @@ class TraceViewer implements SnapshotStorage {
|
|||||||
return traceModel.resourceById.get(resourceId)!;
|
return traceModel.resourceById.get(resourceId)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotById(snapshotName: string): SnapshotRenderer | undefined {
|
snapshotById(snapshotId: string): SnapshotRenderer | undefined {
|
||||||
const traceModel = this._document!.model;
|
const traceModel = this._document!.model;
|
||||||
const parsed = parseSnapshotName(snapshotName);
|
const parsed = parseSnapshotName(snapshotId);
|
||||||
const snapshot = parsed.snapshotId ? traceModel.findSnapshotById(parsed.pageId, parsed.frameId, parsed.snapshotId) : traceModel.findSnapshotByTime(parsed.pageId, parsed.frameId, parsed.timestamp!);
|
const snapshot = parsed.snapshotId ? traceModel.findSnapshotById(parsed.pageId, parsed.frameId, parsed.snapshotId) : traceModel.findSnapshotByTime(parsed.pageId, parsed.frameId, parsed.timestamp!);
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
@ -143,18 +143,14 @@ export async function showTraceViewer(traceDir: string) {
|
|||||||
|
|
||||||
function parseSnapshotName(pathname: string): { pageId: string, frameId: string, timestamp?: number, snapshotId?: string } {
|
function parseSnapshotName(pathname: string): { pageId: string, frameId: string, timestamp?: number, snapshotId?: string } {
|
||||||
const parts = pathname.split('/');
|
const parts = pathname.split('/');
|
||||||
if (!parts[0])
|
// - pageId/<pageId>/snapshotId/<snapshotId>/<frameId>
|
||||||
parts.shift();
|
// - pageId/<pageId>/timestamp/<timestamp>/<frameId>
|
||||||
if (!parts[parts.length - 1])
|
if (parts.length !== 5 || parts[0] !== 'pageId' || (parts[2] !== 'snapshotId' && parts[2] !== 'timestamp'))
|
||||||
parts.pop();
|
|
||||||
// - /snapshot/pageId/<pageId>/snapshotId/<snapshotId>/<frameId>
|
|
||||||
// - /snapshot/pageId/<pageId>/timestamp/<timestamp>/<frameId>
|
|
||||||
if (parts.length !== 6 || parts[0] !== 'snapshot' || parts[1] !== 'pageId' || (parts[3] !== 'snapshotId' && parts[3] !== 'timestamp'))
|
|
||||||
throw new Error(`Unexpected path "${pathname}"`);
|
throw new Error(`Unexpected path "${pathname}"`);
|
||||||
return {
|
return {
|
||||||
pageId: parts[2],
|
pageId: parts[1],
|
||||||
frameId: parts[5] === 'main' ? parts[2] : parts[5],
|
frameId: parts[4] === 'main' ? parts[1] : parts[4],
|
||||||
snapshotId: (parts[3] === 'snapshotId' ? parts[4] : undefined),
|
snapshotId: (parts[2] === 'snapshotId' ? parts[3] : undefined),
|
||||||
timestamp: (parts[3] === 'timestamp' ? +parts[4] : undefined),
|
timestamp: (parts[2] === 'timestamp' ? +parts[3] : undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,23 +23,23 @@ export type ServerRouteHandler = (request: http.IncomingMessage, response: http.
|
|||||||
export class HttpServer {
|
export class HttpServer {
|
||||||
private _server: http.Server | undefined;
|
private _server: http.Server | undefined;
|
||||||
private _urlPrefix: string;
|
private _urlPrefix: string;
|
||||||
private _routes: { prefix?: string, exact?: string, needsReferrer: boolean, handler: ServerRouteHandler }[] = [];
|
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._urlPrefix = '';
|
this._urlPrefix = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
routePrefix(prefix: string, handler: ServerRouteHandler, skipReferrerCheck?: boolean) {
|
routePrefix(prefix: string, handler: ServerRouteHandler) {
|
||||||
this._routes.push({ prefix, handler, needsReferrer: !skipReferrerCheck });
|
this._routes.push({ prefix, handler });
|
||||||
}
|
}
|
||||||
|
|
||||||
routePath(path: string, handler: ServerRouteHandler, skipReferrerCheck?: boolean) {
|
routePath(path: string, handler: ServerRouteHandler) {
|
||||||
this._routes.push({ exact: path, handler, needsReferrer: !skipReferrerCheck });
|
this._routes.push({ exact: path, handler });
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<string> {
|
async start(port?: number): Promise<string> {
|
||||||
this._server = http.createServer(this._onRequest.bind(this));
|
this._server = http.createServer(this._onRequest.bind(this));
|
||||||
this._server.listen();
|
this._server.listen(port);
|
||||||
await new Promise(cb => this._server!.once('listening', cb));
|
await new Promise(cb => this._server!.once('listening', cb));
|
||||||
const address = this._server.address();
|
const address = this._server.address();
|
||||||
this._urlPrefix = typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`;
|
this._urlPrefix = typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`;
|
||||||
@ -78,10 +78,7 @@ export class HttpServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = new URL('http://localhost' + request.url);
|
const url = new URL('http://localhost' + request.url);
|
||||||
const hasReferrer = request.headers['referer'] && request.headers['referer'].startsWith(this._urlPrefix);
|
|
||||||
for (const route of this._routes) {
|
for (const route of this._routes) {
|
||||||
if (route.needsReferrer && !hasReferrer)
|
|
||||||
continue;
|
|
||||||
if (route.exact && url.pathname === route.exact && route.handler(request, response))
|
if (route.exact && url.pathname === route.exact && route.handler(request, response))
|
||||||
return;
|
return;
|
||||||
if (route.prefix && url.pathname.startsWith(route.prefix) && route.handler(request, response))
|
if (route.prefix && url.pathname.startsWith(route.prefix) && route.handler(request, response))
|
||||||
|
|||||||
@ -81,6 +81,10 @@ body {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invisible {
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,3 +78,17 @@
|
|||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
color: var(--gray);
|
color: var(--gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.call-log-call .codicon.preview {
|
||||||
|
visibility: hidden;
|
||||||
|
color: var(--toolbar-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-log-call .codicon.preview:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-log-call:hover .codicon.preview {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|||||||
@ -24,7 +24,12 @@ export function exampleCallLog(): CallLog[] {
|
|||||||
'title': 'newPage',
|
'title': 'newPage',
|
||||||
'status': 'done',
|
'status': 'done',
|
||||||
'duration': 100,
|
'duration': 100,
|
||||||
'params': {}
|
'params': {},
|
||||||
|
'snapshots': {
|
||||||
|
'before': true,
|
||||||
|
'in': false,
|
||||||
|
'after': true,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 4,
|
'id': 4,
|
||||||
@ -36,6 +41,11 @@ export function exampleCallLog(): CallLog[] {
|
|||||||
'params': {
|
'params': {
|
||||||
'url': 'https://github.com/microsoft'
|
'url': 'https://github.com/microsoft'
|
||||||
},
|
},
|
||||||
|
'snapshots': {
|
||||||
|
'before': true,
|
||||||
|
'in': false,
|
||||||
|
'after': true,
|
||||||
|
},
|
||||||
'duration': 1100,
|
'duration': 1100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -57,6 +67,11 @@ export function exampleCallLog(): CallLog[] {
|
|||||||
'params': {
|
'params': {
|
||||||
'selector': 'input[aria-label="Find a repository…"]'
|
'selector': 'input[aria-label="Find a repository…"]'
|
||||||
},
|
},
|
||||||
|
'snapshots': {
|
||||||
|
'before': true,
|
||||||
|
'in': true,
|
||||||
|
'after': false,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 6,
|
'id': 6,
|
||||||
@ -68,6 +83,11 @@ export function exampleCallLog(): CallLog[] {
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'params': {
|
'params': {
|
||||||
},
|
},
|
||||||
|
'snapshots': {
|
||||||
|
'before': false,
|
||||||
|
'in': false,
|
||||||
|
'after': false,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,11 +20,13 @@ import type { CallLog } from '../../server/supplements/recorder/recorderTypes';
|
|||||||
import { msToString } from '../uiUtils';
|
import { msToString } from '../uiUtils';
|
||||||
|
|
||||||
export interface CallLogProps {
|
export interface CallLogProps {
|
||||||
log: CallLog[]
|
log: CallLog[],
|
||||||
|
onHover: (callLogId: number | undefined, phase?: 'before' | 'after' | 'in') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CallLogView: React.FC<CallLogProps> = ({
|
export const CallLogView: React.FC<CallLogProps> = ({
|
||||||
log,
|
log,
|
||||||
|
onHover,
|
||||||
}) => {
|
}) => {
|
||||||
const messagesEndRef = React.createRef<HTMLDivElement>();
|
const messagesEndRef = React.createRef<HTMLDivElement>();
|
||||||
const [expandOverrides, setExpandOverrides] = React.useState<Map<number, boolean>>(new Map());
|
const [expandOverrides, setExpandOverrides] = React.useState<Map<number, boolean>>(new Map());
|
||||||
@ -49,6 +51,10 @@ export const CallLogView: React.FC<CallLogProps> = ({
|
|||||||
{ callLog.params.selector ? <span>(<span className='call-log-selector'>{callLog.params.selector}</span>)</span> : undefined }
|
{ callLog.params.selector ? <span>(<span className='call-log-selector'>{callLog.params.selector}</span>)</span> : undefined }
|
||||||
<span className={'codicon ' + iconClass(callLog)}></span>
|
<span className={'codicon ' + iconClass(callLog)}></span>
|
||||||
{ typeof callLog.duration === 'number' ? <span className='call-log-time'>— {msToString(callLog.duration)}</span> : undefined}
|
{ typeof callLog.duration === 'number' ? <span className='call-log-time'>— {msToString(callLog.duration)}</span> : undefined}
|
||||||
|
{ <div style={{flex: 'auto'}}></div> }
|
||||||
|
<span className={'codicon codicon-vm-outline preview' + (callLog.snapshots.before ? '' : ' invisible')} onMouseEnter={() => onHover(callLog.id, 'before')} onMouseLeave={() => onHover(undefined)}></span>
|
||||||
|
<span className={'codicon codicon-vm-running preview' + (callLog.snapshots.in ? '' : ' invisible')} onMouseEnter={() => onHover(callLog.id, 'in')} onMouseLeave={() => onHover(undefined)}></span>
|
||||||
|
<span className={'codicon codicon-vm-active preview' + (callLog.snapshots.after ? '' : ' invisible')} onMouseEnter={() => onHover(callLog.id, 'after')} onMouseLeave={() => onHover(undefined)}></span>
|
||||||
</div>
|
</div>
|
||||||
{ (isExpanded ? callLog.messages : []).map((message, i) => {
|
{ (isExpanded ? callLog.messages : []).map((message, i) => {
|
||||||
return <div className='call-log-message' key={i}>
|
return <div className='call-log-message' key={i}>
|
||||||
|
|||||||
@ -35,7 +35,6 @@ export const Main: React.FC = ({
|
|||||||
const [paused, setPaused] = React.useState(false);
|
const [paused, setPaused] = React.useState(false);
|
||||||
const [log, setLog] = React.useState(new Map<number, CallLog>());
|
const [log, setLog] = React.useState(new Map<number, CallLog>());
|
||||||
const [mode, setMode] = React.useState<Mode>('none');
|
const [mode, setMode] = React.useState<Mode>('none');
|
||||||
const [selector, setSelector] = React.useState('');
|
|
||||||
|
|
||||||
window.playwrightSetMode = setMode;
|
window.playwrightSetMode = setMode;
|
||||||
window.playwrightSetSources = setSources;
|
window.playwrightSetSources = setSources;
|
||||||
|
|||||||
@ -121,7 +121,9 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||||||
window.dispatch({ event: 'selectorUpdated', params: { selector: event.target.value } });
|
window.dispatch({ event: 'selectorUpdated', params: { selector: event.target.value } });
|
||||||
}} />
|
}} />
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<CallLogView log={[...log.values()]}/>
|
<CallLogView log={Array.from(log.values())} onHover={(callLogId, phase) => {
|
||||||
|
window.dispatch({ event: 'callLogHovered', params: { callLogId, phase } }).catch(() => {});
|
||||||
|
}}/>
|
||||||
</div>
|
</div>
|
||||||
</SplitView>
|
</SplitView>
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
@ -281,3 +281,11 @@ it('exposeBinding(handle) should work with element handles', async ({ page}) =>
|
|||||||
await page.click('#a1');
|
await page.click('#a1');
|
||||||
expect(await promise).toBe('Click me');
|
expect(await promise).toBe('Click me');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should work with setContent', async ({page, server}) => {
|
||||||
|
await page.exposeFunction('compute', function(a, b) {
|
||||||
|
return Promise.resolve(a * b);
|
||||||
|
});
|
||||||
|
await page.setContent('<script>window.result = compute(3, 2)</script>');
|
||||||
|
expect(await page.evaluate('window.result')).toBe(6);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user