feat: include screencast in trace (#6128)
This commit is contained in:
parent
0c00891b80
commit
d0db4f6737
|
|
@ -157,7 +157,7 @@ export class CRPage implements PageDelegate {
|
|||
for (const session of this._sessions.values())
|
||||
session.dispose();
|
||||
this._page._didClose();
|
||||
this._mainFrameSession._stopScreencast().catch(() => {});
|
||||
this._mainFrameSession._stopVideoRecording().catch(() => {});
|
||||
}
|
||||
|
||||
async navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise<frames.GotoResult> {
|
||||
|
|
@ -290,6 +290,19 @@ export class CRPage implements PageDelegate {
|
|||
return this._sessionForHandle(handle)._scrollRectIntoViewIfNeeded(handle, rect);
|
||||
}
|
||||
|
||||
async setScreencastEnabled(enabled: boolean): Promise<void> {
|
||||
if (enabled) {
|
||||
await this._mainFrameSession._startScreencast(this, {
|
||||
format: 'jpeg',
|
||||
quality: 90,
|
||||
maxWidth: 800,
|
||||
maxHeight: 600,
|
||||
});
|
||||
} else {
|
||||
await this._mainFrameSession._stopScreencast(this);
|
||||
}
|
||||
}
|
||||
|
||||
rafCountForStablePosition(): number {
|
||||
return 1;
|
||||
}
|
||||
|
|
@ -357,6 +370,7 @@ class FrameSession {
|
|||
private _swappedIn = false;
|
||||
private _videoRecorder: VideoRecorder | null = null;
|
||||
private _screencastId: string | null = null;
|
||||
private _screencastClients = new Set<any>();
|
||||
|
||||
constructor(crPage: CRPage, client: CRSession, targetId: string, parentSession: FrameSession | null) {
|
||||
this._client = client;
|
||||
|
|
@ -429,7 +443,7 @@ class FrameSession {
|
|||
await this._crPage._browserContext._ensureVideosPath();
|
||||
// Note: it is important to start video recorder before sending Page.startScreencast,
|
||||
// and it is equally important to send Page.startScreencast before sending Runtime.runIfWaitingForDebugger.
|
||||
await this._startVideoRecorder(screencastId, screencastOptions);
|
||||
await this._createVideoRecorder(screencastId, screencastOptions);
|
||||
}
|
||||
|
||||
let lifecycleEventsEnabled: Promise<any>;
|
||||
|
|
@ -511,7 +525,7 @@ class FrameSession {
|
|||
for (const source of this._crPage._page._evaluateOnNewDocumentSources)
|
||||
promises.push(this._evaluateOnNewDocument(source, 'main'));
|
||||
if (screencastOptions)
|
||||
promises.push(this._startScreencast(screencastOptions));
|
||||
promises.push(this._startVideoRecording(screencastOptions));
|
||||
promises.push(this._client.send('Runtime.runIfWaitingForDebugger'));
|
||||
promises.push(this._firstNonInitialNavigationCommittedPromise);
|
||||
await Promise.all(promises);
|
||||
|
|
@ -824,15 +838,12 @@ class FrameSession {
|
|||
}
|
||||
|
||||
_onScreencastFrame(payload: Protocol.Page.screencastFramePayload) {
|
||||
if (!this._videoRecorder)
|
||||
return;
|
||||
const buffer = Buffer.from(payload.data, 'base64');
|
||||
this._videoRecorder.writeFrame(buffer, payload.metadata.timestamp!);
|
||||
// The target may be closed before receiving the ack.
|
||||
this._client.send('Page.screencastFrameAck', {sessionId: payload.sessionId}).catch(() => {});
|
||||
const buffer = Buffer.from(payload.data, 'base64');
|
||||
this._page.emit(Page.Events.ScreencastFrame, { buffer, timestamp: payload.metadata.timestamp });
|
||||
}
|
||||
|
||||
async _startVideoRecorder(screencastId: string, options: types.PageScreencastOptions): Promise<void> {
|
||||
async _createVideoRecorder(screencastId: string, options: types.PageScreencastOptions): Promise<void> {
|
||||
assert(!this._screencastId);
|
||||
const ffmpegPath = this._crPage._browserContext._browser.options.registry.executablePath('ffmpeg');
|
||||
if (!ffmpegPath)
|
||||
|
|
@ -857,11 +868,11 @@ class FrameSession {
|
|||
this._screencastId = screencastId;
|
||||
}
|
||||
|
||||
async _startScreencast(options: types.PageScreencastOptions) {
|
||||
async _startVideoRecording(options: types.PageScreencastOptions) {
|
||||
const screencastId = this._screencastId;
|
||||
assert(screencastId);
|
||||
const gotFirstFrame = new Promise(f => this._client.once('Page.screencastFrame', f));
|
||||
await this._client.send('Page.startScreencast', {
|
||||
await this._startScreencast(this._videoRecorder, {
|
||||
format: 'jpeg',
|
||||
quality: 90,
|
||||
maxWidth: options.width,
|
||||
|
|
@ -873,11 +884,11 @@ class FrameSession {
|
|||
});
|
||||
}
|
||||
|
||||
async _stopScreencast(): Promise<void> {
|
||||
async _stopVideoRecording(): Promise<void> {
|
||||
if (!this._screencastId)
|
||||
return;
|
||||
await this._client._sendMayFail('Page.stopScreencast');
|
||||
const recorder = this._videoRecorder!;
|
||||
await this._stopScreencast(recorder);
|
||||
const screencastId = this._screencastId;
|
||||
this._videoRecorder = null;
|
||||
this._screencastId = null;
|
||||
|
|
@ -885,6 +896,18 @@ class FrameSession {
|
|||
this._crPage._browserContext._browser._videoFinished(screencastId);
|
||||
}
|
||||
|
||||
async _startScreencast(client: any, options: Protocol.Page.startScreencastParameters = {}) {
|
||||
this._screencastClients.add(client);
|
||||
if (this._screencastClients.size === 1)
|
||||
await this._client.send('Page.startScreencast', options);
|
||||
}
|
||||
|
||||
async _stopScreencast(client: any) {
|
||||
this._screencastClients.delete(client);
|
||||
if (!this._screencastClients.size)
|
||||
await this._client._sendMayFail('Page.stopScreencast');
|
||||
}
|
||||
|
||||
async _updateExtraHTTPHeaders(initial: boolean): Promise<void> {
|
||||
const headers = network.mergeHeaders([
|
||||
this._crPage._browserContext._options.extraHTTPHeaders,
|
||||
|
|
|
|||
|
|
@ -43,15 +43,16 @@ export class VideoRecorder {
|
|||
const controller = new ProgressController(internalCallMetadata(), page);
|
||||
controller.setLogName('browser');
|
||||
return await controller.run(async progress => {
|
||||
const recorder = new VideoRecorder(ffmpegPath, progress);
|
||||
const recorder = new VideoRecorder(page, ffmpegPath, progress);
|
||||
await recorder._launch(options);
|
||||
return recorder;
|
||||
});
|
||||
}
|
||||
|
||||
private constructor(ffmpegPath: string, progress: Progress) {
|
||||
private constructor(page: Page, ffmpegPath: string, progress: Progress) {
|
||||
this._progress = progress;
|
||||
this._ffmpegPath = ffmpegPath;
|
||||
page.on(Page.Events.ScreencastFrame, frame => this.writeFrame(frame.buffer, frame.timestamp));
|
||||
}
|
||||
|
||||
private async _launch(options: types.PageScreencastOptions) {
|
||||
|
|
|
|||
|
|
@ -472,6 +472,10 @@ export class FFPage implements PageDelegate {
|
|||
});
|
||||
}
|
||||
|
||||
async setScreencastEnabled(enabled: boolean): Promise<void> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
rafCountForStablePosition(): number {
|
||||
return 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export interface PageDelegate {
|
|||
getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>;
|
||||
getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle>;
|
||||
scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'>;
|
||||
setScreencastEnabled(enabled: boolean): Promise<void>;
|
||||
|
||||
getAccessibilityTree(needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}>;
|
||||
pdf?: (options?: types.PDFOptions) => Promise<Buffer>;
|
||||
|
|
@ -111,6 +112,7 @@ export class Page extends SdkObject {
|
|||
FrameDetached: 'framedetached',
|
||||
InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument',
|
||||
Load: 'load',
|
||||
ScreencastFrame: 'screencastframe',
|
||||
Video: 'video',
|
||||
WebSocket: 'websocket',
|
||||
Worker: 'worker',
|
||||
|
|
@ -500,6 +502,10 @@ export class Page extends SdkObject {
|
|||
const identifier = PageBinding.identifier(name, world);
|
||||
return this._pageBindings.get(identifier) || this._browserContext._pageBindings.get(identifier);
|
||||
}
|
||||
|
||||
setScreencastEnabled(enabled: boolean) {
|
||||
this._delegate.setScreencastEnabled(enabled).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export class Worker extends SdkObject {
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ import { BaseSnapshotStorage } from './snapshotStorage';
|
|||
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
|
||||
import { ElementHandle } from '../dom';
|
||||
|
||||
const kSnapshotInterval = 25;
|
||||
|
||||
export class InMemorySnapshotter extends BaseSnapshotStorage implements SnapshotterDelegate {
|
||||
private _blobs = new Map<string, Buffer>();
|
||||
private _server: HttpServer;
|
||||
|
|
@ -44,10 +42,6 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot
|
|||
return await this._server.start();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval);
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this._snapshotter.dispose();
|
||||
await this._server.stop();
|
||||
|
|
@ -68,7 +62,7 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot
|
|||
});
|
||||
}
|
||||
|
||||
async setAutoSnapshotInterval(interval: number): Promise<void> {
|
||||
async setAutoSnapshotIntervalForTest(interval: number): Promise<void> {
|
||||
await this._snapshotter.setAutoSnapshotInterval(interval);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,12 +46,13 @@ export class PersistentSnapshotter extends EventEmitter implements SnapshotterDe
|
|||
this._snapshotter = new Snapshotter(context, this);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
async start(autoSnapshots: boolean): Promise<void> {
|
||||
await fsMkdirAsync(this._resourcesDir, {recursive: true}).catch(() => {});
|
||||
await fsAppendFileAsync(this._networkTrace, Buffer.from([]));
|
||||
await fsAppendFileAsync(this._snapshotTrace, Buffer.from([]));
|
||||
await this._snapshotter.initialize();
|
||||
await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval);
|
||||
if (autoSnapshots)
|
||||
await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval);
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ export class Snapshotter {
|
|||
this._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => {
|
||||
this._saveResource(page, response).catch(e => debugLogger.log('error', e));
|
||||
}));
|
||||
page.setScreencastEnabled(true);
|
||||
}
|
||||
|
||||
private async _saveResource(page: Page, response: network.Response) {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,15 @@ export type PageDestroyedTraceEvent = {
|
|||
pageId: string,
|
||||
};
|
||||
|
||||
export type ScreencastFrameTraceEvent = {
|
||||
timestamp: number,
|
||||
type: 'page-screencast-frame',
|
||||
contextId: string,
|
||||
pageId: string,
|
||||
pageTimestamp: number,
|
||||
sha1: string
|
||||
};
|
||||
|
||||
export type ActionTraceEvent = {
|
||||
timestamp: number,
|
||||
type: 'action',
|
||||
|
|
@ -93,6 +102,7 @@ export type TraceEvent =
|
|||
ContextDestroyedTraceEvent |
|
||||
PageCreatedTraceEvent |
|
||||
PageDestroyedTraceEvent |
|
||||
ScreencastFrameTraceEvent |
|
||||
ActionTraceEvent |
|
||||
DialogOpenedEvent |
|
||||
DialogClosedEvent |
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import * as util from 'util';
|
||||
import { createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../../../utils/utils';
|
||||
import { calculateSha1, createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../../../utils/utils';
|
||||
import { BrowserContext } from '../../browserContext';
|
||||
import { Dialog } from '../../dialog';
|
||||
import { ElementHandle } from '../../dom';
|
||||
|
|
@ -105,7 +105,7 @@ class ContextTracer {
|
|||
}
|
||||
|
||||
async start() {
|
||||
await this._snapshotter.start();
|
||||
await this._snapshotter.start(false);
|
||||
}
|
||||
|
||||
async _captureSnapshot(name: 'before' | 'after' | 'action', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle): Promise<void> {
|
||||
|
|
@ -193,6 +193,20 @@ class ContextTracer {
|
|||
this._appendTraceEvent(event);
|
||||
});
|
||||
|
||||
page.on(Page.Events.ScreencastFrame, params => {
|
||||
const sha1 = calculateSha1(params.buffer);
|
||||
const event: trace.ScreencastFrameTraceEvent = {
|
||||
type: 'page-screencast-frame',
|
||||
pageId: page.uniqueId,
|
||||
contextId: this._contextId,
|
||||
sha1,
|
||||
pageTimestamp: params.timestamp,
|
||||
timestamp: monotonicTime()
|
||||
};
|
||||
this._appendTraceEvent(event);
|
||||
this._snapshotter.onBlob({ sha1, buffer: params.buffer });
|
||||
});
|
||||
|
||||
page.once(Page.Events.Close, () => {
|
||||
if (this._disposed)
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export class TraceModel {
|
|||
destroyed: undefined as any,
|
||||
actions: [],
|
||||
interestingEvents: [],
|
||||
screencastFrames: [],
|
||||
};
|
||||
const contextEntry = this.contextEntries.get(event.contextId)!;
|
||||
this.pageEntries.set(event.pageId, { pageEntry, contextEntry });
|
||||
|
|
@ -78,6 +79,10 @@ export class TraceModel {
|
|||
this.pageEntries.get(event.pageId)!.pageEntry.destroyed = event;
|
||||
break;
|
||||
}
|
||||
case 'page-screencast-frame': {
|
||||
this.pageEntries.get(event.pageId)!.pageEntry.screencastFrames.push(event);
|
||||
break;
|
||||
}
|
||||
case 'action': {
|
||||
const metadata = event.metadata;
|
||||
if (metadata.method === 'waitForEventInfo')
|
||||
|
|
@ -145,6 +150,7 @@ export type PageEntry = {
|
|||
destroyed: trace.PageDestroyedTraceEvent;
|
||||
actions: ActionEntry[];
|
||||
interestingEvents: InterestingPageEvent[];
|
||||
screencastFrames: { sha1: string, timestamp: number }[]
|
||||
}
|
||||
|
||||
export type ActionEntry = trace.ActionTraceEvent & {
|
||||
|
|
|
|||
|
|
@ -819,6 +819,10 @@ export class WKPage implements PageDelegate {
|
|||
});
|
||||
}
|
||||
|
||||
async setScreencastEnabled(enabled: boolean): Promise<void> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
rafCountForStablePosition(): number {
|
||||
return process.platform === 'win32' ? 5 : 1;
|
||||
}
|
||||
|
|
|
|||
45
src/web/traceViewer/ui/filmStrip.css
Normal file
45
src/web/traceViewer/ui/filmStrip.css
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.film-strip {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.film-strip-lane {
|
||||
flex: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.film-strip-frame {
|
||||
flex: none;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.film-strip-hover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: white;
|
||||
box-shadow: rgba(0, 0, 0, 0.133) 0px 1.6px 10px 0px, rgba(0, 0, 0, 0.11) 0px 0.3px 10px 0px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
111
src/web/traceViewer/ui/filmStrip.tsx
Normal file
111
src/web/traceViewer/ui/filmStrip.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
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 './filmStrip.css';
|
||||
import { Boundaries, Size } from '../geometry';
|
||||
import * as React from 'react';
|
||||
import { useMeasure } from './helpers';
|
||||
import { lowerBound } from '../../uiUtils';
|
||||
import { ContextEntry, PageEntry } from '../../../server/trace/viewer/traceModel';
|
||||
|
||||
export const FilmStrip: React.FunctionComponent<{
|
||||
context: ContextEntry,
|
||||
boundaries: Boundaries,
|
||||
previewX?: number,
|
||||
}> = ({ context, boundaries, previewX }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const screencastFrames = context.pages[0]?.screencastFrames;
|
||||
// TODO: pick file from the Y position.
|
||||
let previewImage = undefined;
|
||||
if (previewX !== undefined && context.pages.length) {
|
||||
const previewTime = boundaries.minimum + (boundaries.maximum - boundaries.minimum) * previewX / measure.width;
|
||||
previewImage = screencastFrames[lowerBound(screencastFrames, previewTime, timeComparator)];
|
||||
}
|
||||
const previewSize = inscribe(context.created.viewportSize!, { width: 600, height: 600 });
|
||||
console.log(previewSize);
|
||||
|
||||
return <div className='film-strip' ref={ref}>{
|
||||
context.pages.filter(p => p.screencastFrames.length).map((page, index) => <FilmStripLane
|
||||
boundaries={boundaries}
|
||||
viewportSize={context.created.viewportSize!}
|
||||
page={page}
|
||||
width={measure.width}
|
||||
key={index}
|
||||
/>)
|
||||
}
|
||||
{previewImage && previewX !== undefined &&
|
||||
<div className='film-strip-hover' style={{
|
||||
width: previewSize.width,
|
||||
height: previewSize.height,
|
||||
top: measure.bottom + 5,
|
||||
left: Math.min(previewX, measure.width - previewSize.width - 10),
|
||||
}}>
|
||||
<img src={`/sha1/${previewImage.sha1}`} width={previewSize.width} height={previewSize.height} />
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
};
|
||||
|
||||
const FilmStripLane: React.FunctionComponent<{
|
||||
boundaries: Boundaries,
|
||||
viewportSize: Size,
|
||||
page: PageEntry,
|
||||
width: number,
|
||||
}> = ({ boundaries, viewportSize, page, width }) => {
|
||||
const frameSize = inscribe(viewportSize!, { width: 200, height: 45 });
|
||||
const frameMargin = 2.5;
|
||||
const screencastFrames = page.screencastFrames;
|
||||
const startTime = screencastFrames[0].timestamp;
|
||||
const endTime = screencastFrames[screencastFrames.length - 1].timestamp;
|
||||
|
||||
const boundariesDuration = boundaries.maximum - boundaries.minimum;
|
||||
const gapLeft = (startTime - boundaries.minimum) / boundariesDuration * width;
|
||||
const gapRight = (boundaries.maximum - endTime) / boundariesDuration * width;
|
||||
const effectiveWidth = (endTime - startTime) / boundariesDuration * width;
|
||||
const frameCount = effectiveWidth / (frameSize.width + 2 * frameMargin) | 0;
|
||||
const frameDuration = (endTime - startTime) / frameCount;
|
||||
|
||||
const frames: JSX.Element[] = [];
|
||||
for (let time = startTime, i = 0; time <= endTime; time += frameDuration, ++i) {
|
||||
const index = lowerBound(screencastFrames, time, timeComparator);
|
||||
frames.push(<div className='film-strip-frame' key={i} style={{
|
||||
width: frameSize.width,
|
||||
height: frameSize.height,
|
||||
backgroundImage: `url(/sha1/${screencastFrames[index].sha1})`,
|
||||
backgroundSize: `${frameSize.width}px ${frameSize.height}px`,
|
||||
margin: frameMargin,
|
||||
marginRight: frameMargin,
|
||||
}} />);
|
||||
}
|
||||
|
||||
return <div className='film-strip-lane' style={{
|
||||
marginLeft: gapLeft + 'px',
|
||||
marginRight: gapRight + 'px',
|
||||
}}>{frames}</div>;
|
||||
};
|
||||
|
||||
function timeComparator(time: number, frame: { timestamp: number }): number {
|
||||
return time - frame.timestamp;
|
||||
}
|
||||
|
||||
function inscribe(object: Size, area: Size): Size {
|
||||
const scale = Math.max(object.width / area.width, object.height / area.height);
|
||||
return {
|
||||
width: object.width / scale | 0,
|
||||
height: object.height / scale | 0
|
||||
};
|
||||
}
|
||||
|
|
@ -15,35 +15,28 @@
|
|||
*/
|
||||
|
||||
import { ActionEntry } from '../../../server/trace/viewer/traceModel';
|
||||
import { Boundaries, Size } from '../geometry';
|
||||
import { Size } from '../geometry';
|
||||
import './snapshotTab.css';
|
||||
import * as React from 'react';
|
||||
import { useMeasure } from './helpers';
|
||||
import { msToString } from '../../uiUtils';
|
||||
import type { Point } from '../../../common/types';
|
||||
|
||||
export const SnapshotTab: React.FunctionComponent<{
|
||||
actionEntry: ActionEntry | undefined,
|
||||
snapshotSize: Size,
|
||||
selection: { pageId: string, time: number } | undefined,
|
||||
boundaries: Boundaries,
|
||||
}> = ({ actionEntry, snapshotSize, selection, boundaries }) => {
|
||||
}> = ({ actionEntry, snapshotSize }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
|
||||
|
||||
const snapshots = actionEntry ? (actionEntry.snapshots || []) : [];
|
||||
const { pageId, time } = selection || { pageId: undefined, time: 0 };
|
||||
|
||||
const iframeRef = React.createRef<HTMLIFrameElement>();
|
||||
React.useEffect(() => {
|
||||
if (!iframeRef.current)
|
||||
return;
|
||||
|
||||
let snapshotUri = undefined;
|
||||
let point: Point | undefined = undefined;
|
||||
if (pageId) {
|
||||
snapshotUri = `${pageId}?time=${time}`;
|
||||
} else if (actionEntry) {
|
||||
if (actionEntry) {
|
||||
const snapshot = snapshots[snapshotIndex];
|
||||
if (snapshot && snapshot.snapshotName) {
|
||||
snapshotUri = `${actionEntry.metadata.pageId}?name=${snapshot.snapshotName}`;
|
||||
|
|
@ -56,7 +49,7 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
(iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl, { point });
|
||||
} catch (e) {
|
||||
}
|
||||
}, [actionEntry, snapshotIndex, pageId, time]);
|
||||
}, [actionEntry, snapshotIndex]);
|
||||
|
||||
const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height);
|
||||
const scaledSize = {
|
||||
|
|
@ -64,11 +57,8 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
height: snapshotSize.height * scale,
|
||||
};
|
||||
return <div className='snapshot-tab'>
|
||||
<div className='snapshot-controls'>{
|
||||
selection && <div key='selectedTime' className='snapshot-toggle'>
|
||||
{msToString(selection.time - boundaries.minimum)}
|
||||
</div>
|
||||
}{!selection && snapshots.map((snapshot, index) => {
|
||||
<div className='snapshot-controls'>
|
||||
{snapshots.map((snapshot, index) => {
|
||||
return <div
|
||||
key={snapshot.title}
|
||||
className={'snapshot-toggle' + (snapshotIndex === index ? ' toggled' : '')}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { Boundaries } from '../geometry';
|
|||
import * as React from 'react';
|
||||
import { useMeasure } from './helpers';
|
||||
import { msToString } from '../../uiUtils';
|
||||
import { FilmStrip } from './filmStrip';
|
||||
|
||||
type TimelineBar = {
|
||||
entry?: ActionEntry;
|
||||
|
|
@ -40,8 +41,7 @@ export const Timeline: React.FunctionComponent<{
|
|||
selectedAction: ActionEntry | undefined,
|
||||
highlightedAction: ActionEntry | undefined,
|
||||
onSelected: (action: ActionEntry) => void,
|
||||
onTimeSelected: (time: number | undefined) => void,
|
||||
}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onTimeSelected }) => {
|
||||
}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
const [previewX, setPreviewX] = React.useState<number | undefined>();
|
||||
const [hoveredBarIndex, setHoveredBarIndex] = React.useState<number | undefined>();
|
||||
|
|
@ -143,12 +143,10 @@ export const Timeline: React.FunctionComponent<{
|
|||
return;
|
||||
const x = event.clientX - ref.current.getBoundingClientRect().left;
|
||||
setPreviewX(x);
|
||||
onTimeSelected(positionToTime(measure.width, boundaries, x));
|
||||
setHoveredBarIndex(findHoveredBarIndex(x));
|
||||
};
|
||||
const onMouseLeave = () => {
|
||||
setPreviewX(undefined);
|
||||
onTimeSelected(undefined);
|
||||
};
|
||||
const onClick = (event: React.MouseEvent) => {
|
||||
if (!ref.current)
|
||||
|
|
@ -194,6 +192,7 @@ export const Timeline: React.FunctionComponent<{
|
|||
></div>;
|
||||
})
|
||||
}</div>
|
||||
<FilmStrip context={context} boundaries={boundaries} previewX={previewX} />
|
||||
<div className='timeline-marker timeline-marker-hover' style={{
|
||||
display: (previewX !== undefined) ? 'block' : 'none',
|
||||
left: (previewX || 0) + 'px',
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export const Workbench: React.FunctionComponent<{
|
|||
const [context, setContext] = React.useState(contexts[0]);
|
||||
const [selectedAction, setSelectedAction] = React.useState<ActionEntry | undefined>();
|
||||
const [highlightedAction, setHighlightedAction] = React.useState<ActionEntry | undefined>();
|
||||
const [selectedTime, setSelectedTime] = React.useState<number | undefined>();
|
||||
|
||||
const actions = React.useMemo(() => {
|
||||
const actions: ActionEntry[] = [];
|
||||
|
|
@ -45,7 +44,6 @@ export const Workbench: React.FunctionComponent<{
|
|||
|
||||
const snapshotSize = context.created.viewportSize || { width: 1280, height: 720 };
|
||||
const boundaries = { minimum: context.startTime, maximum: context.endTime };
|
||||
const snapshotSelection = context.pages.length && selectedTime !== undefined ? { pageId: context.pages[0].created.pageId, time: selectedTime } : undefined;
|
||||
|
||||
return <div className='vbox workbench'>
|
||||
<div className='hbox header'>
|
||||
|
|
@ -58,7 +56,6 @@ export const Workbench: React.FunctionComponent<{
|
|||
onChange={context => {
|
||||
setContext(context);
|
||||
setSelectedAction(undefined);
|
||||
setSelectedTime(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -69,12 +66,11 @@ export const Workbench: React.FunctionComponent<{
|
|||
selectedAction={selectedAction}
|
||||
highlightedAction={highlightedAction}
|
||||
onSelected={action => setSelectedAction(action)}
|
||||
onTimeSelected={time => setSelectedTime(time)}
|
||||
/>
|
||||
</div>
|
||||
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
|
||||
<SplitView sidebarSize={250}>
|
||||
<SnapshotTab actionEntry={selectedAction} snapshotSize={snapshotSize} selection={snapshotSelection} boundaries={boundaries} />
|
||||
<SnapshotTab actionEntry={selectedAction} snapshotSize={snapshotSize} />
|
||||
<TabbedPane tabs={[
|
||||
{ id: 'logs', title: 'Log', render: () => <LogsTab actionEntry={selectedAction} /> },
|
||||
{ id: 'source', title: 'Source', render: () => <SourceTab actionEntry={selectedAction} /> },
|
||||
|
|
@ -87,7 +83,6 @@ export const Workbench: React.FunctionComponent<{
|
|||
highlightedAction={highlightedAction}
|
||||
onSelected={action => {
|
||||
setSelectedAction(action);
|
||||
setSelectedTime(undefined);
|
||||
}}
|
||||
onHighlighted={action => setHighlightedAction(action)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -39,3 +39,29 @@ export function msToString(ms: number): string {
|
|||
const days = hours / 24;
|
||||
return days.toFixed(1) + 'd';
|
||||
}
|
||||
|
||||
export function lowerBound<S, T>(array: S[], object: T, comparator: (object: T, b: S) => number, left?: number, right?: number): number {
|
||||
let l = left || 0;
|
||||
let r = right !== undefined ? right : array.length;
|
||||
while (l < r) {
|
||||
const m = (l + r) >> 1;
|
||||
if (comparator(object, array[m]) > 0)
|
||||
l = m + 1;
|
||||
else
|
||||
r = m;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export function upperBound<S, T>(array: S[], object: T, comparator: (object: T, b: S) => number, left?: number, right?: number): number {
|
||||
let l = left || 0;
|
||||
let r = right !== undefined ? right : array.length;
|
||||
while (l < r) {
|
||||
const m = (l + r) >> 1;
|
||||
if (comparator(object, array[m]) >= 0)
|
||||
l = m + 1;
|
||||
else
|
||||
r = m;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ it.describe('snapshots', () => {
|
|||
snapshotter.on('snapshot', snapshot => snapshots.push(snapshot));
|
||||
await Promise.all([
|
||||
new Promise(f => snapshotter.once('snapshot', f)),
|
||||
snapshotter.setAutoSnapshotInterval(25),
|
||||
snapshotter.setAutoSnapshotIntervalForTest(25),
|
||||
]);
|
||||
await Promise.all([
|
||||
new Promise(f => snapshotter.once('snapshot', f)),
|
||||
|
|
@ -88,7 +88,7 @@ it.describe('snapshots', () => {
|
|||
snapshotter.on('snapshot', snapshot => snapshots.push(snapshot));
|
||||
await Promise.all([
|
||||
new Promise(f => snapshotter.once('snapshot', f)),
|
||||
snapshotter.setAutoSnapshotInterval(25),
|
||||
snapshotter.setAutoSnapshotIntervalForTest(25),
|
||||
]);
|
||||
expect(distillSnapshot(snapshots[0])).toBe('<style>button { color: red; }</style><BUTTON>Hello</BUTTON>');
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ it.describe('snapshots', () => {
|
|||
snapshotter.on('snapshot', snapshot => snapshots.push(snapshot));
|
||||
await Promise.all([
|
||||
new Promise(f => snapshotter.once('snapshot', f)),
|
||||
snapshotter.setAutoSnapshotInterval(25),
|
||||
snapshotter.setAutoSnapshotIntervalForTest(25),
|
||||
]);
|
||||
expect(distillSnapshot(snapshots[0])).toBe('<LINK rel=\"stylesheet\" href=\"style.css\"><BUTTON>Hello</BUTTON>');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue