feat: include screencast in trace (#6128)

This commit is contained in:
Pavel Feldman 2021-04-08 05:32:12 +08:00 committed by GitHub
parent 0c00891b80
commit d0db4f6737
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 285 additions and 55 deletions

View file

@ -157,7 +157,7 @@ export class CRPage implements PageDelegate {
for (const session of this._sessions.values()) for (const session of this._sessions.values())
session.dispose(); session.dispose();
this._page._didClose(); this._page._didClose();
this._mainFrameSession._stopScreencast().catch(() => {}); this._mainFrameSession._stopVideoRecording().catch(() => {});
} }
async navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise<frames.GotoResult> { 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); 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 { rafCountForStablePosition(): number {
return 1; return 1;
} }
@ -357,6 +370,7 @@ class FrameSession {
private _swappedIn = false; private _swappedIn = false;
private _videoRecorder: VideoRecorder | null = null; private _videoRecorder: VideoRecorder | null = null;
private _screencastId: string | null = null; private _screencastId: string | null = null;
private _screencastClients = new Set<any>();
constructor(crPage: CRPage, client: CRSession, targetId: string, parentSession: FrameSession | null) { constructor(crPage: CRPage, client: CRSession, targetId: string, parentSession: FrameSession | null) {
this._client = client; this._client = client;
@ -429,7 +443,7 @@ class FrameSession {
await this._crPage._browserContext._ensureVideosPath(); await this._crPage._browserContext._ensureVideosPath();
// Note: it is important to start video recorder before sending Page.startScreencast, // 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. // 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>; let lifecycleEventsEnabled: Promise<any>;
@ -511,7 +525,7 @@ class FrameSession {
for (const source of this._crPage._page._evaluateOnNewDocumentSources) for (const source of this._crPage._page._evaluateOnNewDocumentSources)
promises.push(this._evaluateOnNewDocument(source, 'main')); promises.push(this._evaluateOnNewDocument(source, 'main'));
if (screencastOptions) if (screencastOptions)
promises.push(this._startScreencast(screencastOptions)); promises.push(this._startVideoRecording(screencastOptions));
promises.push(this._client.send('Runtime.runIfWaitingForDebugger')); promises.push(this._client.send('Runtime.runIfWaitingForDebugger'));
promises.push(this._firstNonInitialNavigationCommittedPromise); promises.push(this._firstNonInitialNavigationCommittedPromise);
await Promise.all(promises); await Promise.all(promises);
@ -824,15 +838,12 @@ class FrameSession {
} }
_onScreencastFrame(payload: Protocol.Page.screencastFramePayload) { _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(() => {}); 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); assert(!this._screencastId);
const ffmpegPath = this._crPage._browserContext._browser.options.registry.executablePath('ffmpeg'); const ffmpegPath = this._crPage._browserContext._browser.options.registry.executablePath('ffmpeg');
if (!ffmpegPath) if (!ffmpegPath)
@ -857,11 +868,11 @@ class FrameSession {
this._screencastId = screencastId; this._screencastId = screencastId;
} }
async _startScreencast(options: types.PageScreencastOptions) { async _startVideoRecording(options: types.PageScreencastOptions) {
const screencastId = this._screencastId; const screencastId = this._screencastId;
assert(screencastId); assert(screencastId);
const gotFirstFrame = new Promise(f => this._client.once('Page.screencastFrame', f)); const gotFirstFrame = new Promise(f => this._client.once('Page.screencastFrame', f));
await this._client.send('Page.startScreencast', { await this._startScreencast(this._videoRecorder, {
format: 'jpeg', format: 'jpeg',
quality: 90, quality: 90,
maxWidth: options.width, maxWidth: options.width,
@ -873,11 +884,11 @@ class FrameSession {
}); });
} }
async _stopScreencast(): Promise<void> { async _stopVideoRecording(): Promise<void> {
if (!this._screencastId) if (!this._screencastId)
return; return;
await this._client._sendMayFail('Page.stopScreencast');
const recorder = this._videoRecorder!; const recorder = this._videoRecorder!;
await this._stopScreencast(recorder);
const screencastId = this._screencastId; const screencastId = this._screencastId;
this._videoRecorder = null; this._videoRecorder = null;
this._screencastId = null; this._screencastId = null;
@ -885,6 +896,18 @@ class FrameSession {
this._crPage._browserContext._browser._videoFinished(screencastId); 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> { async _updateExtraHTTPHeaders(initial: boolean): Promise<void> {
const headers = network.mergeHeaders([ const headers = network.mergeHeaders([
this._crPage._browserContext._options.extraHTTPHeaders, this._crPage._browserContext._options.extraHTTPHeaders,

View file

@ -43,15 +43,16 @@ export class VideoRecorder {
const controller = new ProgressController(internalCallMetadata(), page); const controller = new ProgressController(internalCallMetadata(), page);
controller.setLogName('browser'); controller.setLogName('browser');
return await controller.run(async progress => { return await controller.run(async progress => {
const recorder = new VideoRecorder(ffmpegPath, progress); const recorder = new VideoRecorder(page, ffmpegPath, progress);
await recorder._launch(options); await recorder._launch(options);
return recorder; return recorder;
}); });
} }
private constructor(ffmpegPath: string, progress: Progress) { private constructor(page: Page, ffmpegPath: string, progress: Progress) {
this._progress = progress; this._progress = progress;
this._ffmpegPath = ffmpegPath; this._ffmpegPath = ffmpegPath;
page.on(Page.Events.ScreencastFrame, frame => this.writeFrame(frame.buffer, frame.timestamp));
} }
private async _launch(options: types.PageScreencastOptions) { private async _launch(options: types.PageScreencastOptions) {

View file

@ -472,6 +472,10 @@ export class FFPage implements PageDelegate {
}); });
} }
async setScreencastEnabled(enabled: boolean): Promise<void> {
throw new Error('Not implemented');
}
rafCountForStablePosition(): number { rafCountForStablePosition(): number {
return 1; return 1;
} }

View file

@ -70,6 +70,7 @@ export interface PageDelegate {
getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>; getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>;
getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle>; getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle>;
scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'>; 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}>; getAccessibilityTree(needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}>;
pdf?: (options?: types.PDFOptions) => Promise<Buffer>; pdf?: (options?: types.PDFOptions) => Promise<Buffer>;
@ -111,6 +112,7 @@ export class Page extends SdkObject {
FrameDetached: 'framedetached', FrameDetached: 'framedetached',
InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument', InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument',
Load: 'load', Load: 'load',
ScreencastFrame: 'screencastframe',
Video: 'video', Video: 'video',
WebSocket: 'websocket', WebSocket: 'websocket',
Worker: 'worker', Worker: 'worker',
@ -500,6 +502,10 @@ export class Page extends SdkObject {
const identifier = PageBinding.identifier(name, world); const identifier = PageBinding.identifier(name, world);
return this._pageBindings.get(identifier) || this._browserContext._pageBindings.get(identifier); return this._pageBindings.get(identifier) || this._browserContext._pageBindings.get(identifier);
} }
setScreencastEnabled(enabled: boolean) {
this._delegate.setScreencastEnabled(enabled).catch(() => {});
}
} }
export class Worker extends SdkObject { export class Worker extends SdkObject {

View file

@ -25,8 +25,6 @@ import { BaseSnapshotStorage } from './snapshotStorage';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter'; import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
import { ElementHandle } from '../dom'; import { ElementHandle } from '../dom';
const kSnapshotInterval = 25;
export class InMemorySnapshotter extends BaseSnapshotStorage implements SnapshotterDelegate { export class InMemorySnapshotter extends BaseSnapshotStorage implements SnapshotterDelegate {
private _blobs = new Map<string, Buffer>(); private _blobs = new Map<string, Buffer>();
private _server: HttpServer; private _server: HttpServer;
@ -44,10 +42,6 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot
return await this._server.start(); return await this._server.start();
} }
async start(): Promise<void> {
await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval);
}
async dispose() { async dispose() {
this._snapshotter.dispose(); this._snapshotter.dispose();
await this._server.stop(); 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); await this._snapshotter.setAutoSnapshotInterval(interval);
} }

View file

@ -46,12 +46,13 @@ export class PersistentSnapshotter extends EventEmitter implements SnapshotterDe
this._snapshotter = new Snapshotter(context, this); this._snapshotter = new Snapshotter(context, this);
} }
async start(): Promise<void> { async start(autoSnapshots: boolean): Promise<void> {
await fsMkdirAsync(this._resourcesDir, {recursive: true}).catch(() => {}); await fsMkdirAsync(this._resourcesDir, {recursive: true}).catch(() => {});
await fsAppendFileAsync(this._networkTrace, Buffer.from([])); await fsAppendFileAsync(this._networkTrace, Buffer.from([]));
await fsAppendFileAsync(this._snapshotTrace, Buffer.from([])); await fsAppendFileAsync(this._snapshotTrace, Buffer.from([]));
await this._snapshotter.initialize(); await this._snapshotter.initialize();
await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval); if (autoSnapshots)
await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval);
} }
async dispose() { async dispose() {

View file

@ -135,6 +135,7 @@ export class Snapshotter {
this._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => { this._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => {
this._saveResource(page, response).catch(e => debugLogger.log('error', e)); this._saveResource(page, response).catch(e => debugLogger.log('error', e));
})); }));
page.setScreencastEnabled(true);
} }
private async _saveResource(page: Page, response: network.Response) { private async _saveResource(page: Page, response: network.Response) {

View file

@ -47,6 +47,15 @@ export type PageDestroyedTraceEvent = {
pageId: string, pageId: string,
}; };
export type ScreencastFrameTraceEvent = {
timestamp: number,
type: 'page-screencast-frame',
contextId: string,
pageId: string,
pageTimestamp: number,
sha1: string
};
export type ActionTraceEvent = { export type ActionTraceEvent = {
timestamp: number, timestamp: number,
type: 'action', type: 'action',
@ -93,6 +102,7 @@ export type TraceEvent =
ContextDestroyedTraceEvent | ContextDestroyedTraceEvent |
PageCreatedTraceEvent | PageCreatedTraceEvent |
PageDestroyedTraceEvent | PageDestroyedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent | ActionTraceEvent |
DialogOpenedEvent | DialogOpenedEvent |
DialogClosedEvent | DialogClosedEvent |

View file

@ -17,7 +17,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import * as util from 'util'; 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 { BrowserContext } from '../../browserContext';
import { Dialog } from '../../dialog'; import { Dialog } from '../../dialog';
import { ElementHandle } from '../../dom'; import { ElementHandle } from '../../dom';
@ -105,7 +105,7 @@ class ContextTracer {
} }
async start() { 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> { async _captureSnapshot(name: 'before' | 'after' | 'action', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle): Promise<void> {
@ -193,6 +193,20 @@ class ContextTracer {
this._appendTraceEvent(event); 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, () => { page.once(Page.Events.Close, () => {
if (this._disposed) if (this._disposed)
return; return;

View file

@ -68,6 +68,7 @@ export class TraceModel {
destroyed: undefined as any, destroyed: undefined as any,
actions: [], actions: [],
interestingEvents: [], interestingEvents: [],
screencastFrames: [],
}; };
const contextEntry = this.contextEntries.get(event.contextId)!; const contextEntry = this.contextEntries.get(event.contextId)!;
this.pageEntries.set(event.pageId, { pageEntry, contextEntry }); this.pageEntries.set(event.pageId, { pageEntry, contextEntry });
@ -78,6 +79,10 @@ export class TraceModel {
this.pageEntries.get(event.pageId)!.pageEntry.destroyed = event; this.pageEntries.get(event.pageId)!.pageEntry.destroyed = event;
break; break;
} }
case 'page-screencast-frame': {
this.pageEntries.get(event.pageId)!.pageEntry.screencastFrames.push(event);
break;
}
case 'action': { case 'action': {
const metadata = event.metadata; const metadata = event.metadata;
if (metadata.method === 'waitForEventInfo') if (metadata.method === 'waitForEventInfo')
@ -145,6 +150,7 @@ export type PageEntry = {
destroyed: trace.PageDestroyedTraceEvent; destroyed: trace.PageDestroyedTraceEvent;
actions: ActionEntry[]; actions: ActionEntry[];
interestingEvents: InterestingPageEvent[]; interestingEvents: InterestingPageEvent[];
screencastFrames: { sha1: string, timestamp: number }[]
} }
export type ActionEntry = trace.ActionTraceEvent & { export type ActionEntry = trace.ActionTraceEvent & {

View file

@ -819,6 +819,10 @@ export class WKPage implements PageDelegate {
}); });
} }
async setScreencastEnabled(enabled: boolean): Promise<void> {
throw new Error('Not implemented');
}
rafCountForStablePosition(): number { rafCountForStablePosition(): number {
return process.platform === 'win32' ? 5 : 1; return process.platform === 'win32' ? 5 : 1;
} }

View 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;
}

View 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
};
}

View file

@ -15,35 +15,28 @@
*/ */
import { ActionEntry } from '../../../server/trace/viewer/traceModel'; import { ActionEntry } from '../../../server/trace/viewer/traceModel';
import { Boundaries, Size } from '../geometry'; import { Size } from '../geometry';
import './snapshotTab.css'; import './snapshotTab.css';
import * as React from 'react'; import * as React from 'react';
import { useMeasure } from './helpers'; import { useMeasure } from './helpers';
import { msToString } from '../../uiUtils';
import type { Point } from '../../../common/types'; import type { Point } from '../../../common/types';
export const SnapshotTab: React.FunctionComponent<{ export const SnapshotTab: React.FunctionComponent<{
actionEntry: ActionEntry | undefined, actionEntry: ActionEntry | undefined,
snapshotSize: Size, snapshotSize: Size,
selection: { pageId: string, time: number } | undefined, }> = ({ actionEntry, snapshotSize }) => {
boundaries: Boundaries,
}> = ({ actionEntry, snapshotSize, selection, boundaries }) => {
const [measure, ref] = useMeasure<HTMLDivElement>(); const [measure, ref] = useMeasure<HTMLDivElement>();
const [snapshotIndex, setSnapshotIndex] = React.useState(0); const [snapshotIndex, setSnapshotIndex] = React.useState(0);
const snapshots = actionEntry ? (actionEntry.snapshots || []) : []; const snapshots = actionEntry ? (actionEntry.snapshots || []) : [];
const { pageId, time } = selection || { pageId: undefined, time: 0 };
const iframeRef = React.createRef<HTMLIFrameElement>(); const iframeRef = React.createRef<HTMLIFrameElement>();
React.useEffect(() => { React.useEffect(() => {
if (!iframeRef.current) if (!iframeRef.current)
return; return;
let snapshotUri = undefined; let snapshotUri = undefined;
let point: Point | undefined = undefined; let point: Point | undefined = undefined;
if (pageId) { if (actionEntry) {
snapshotUri = `${pageId}?time=${time}`;
} else if (actionEntry) {
const snapshot = snapshots[snapshotIndex]; const snapshot = snapshots[snapshotIndex];
if (snapshot && snapshot.snapshotName) { if (snapshot && snapshot.snapshotName) {
snapshotUri = `${actionEntry.metadata.pageId}?name=${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 }); (iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl, { point });
} catch (e) { } catch (e) {
} }
}, [actionEntry, snapshotIndex, pageId, time]); }, [actionEntry, snapshotIndex]);
const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height); const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height);
const scaledSize = { const scaledSize = {
@ -64,11 +57,8 @@ export const SnapshotTab: React.FunctionComponent<{
height: snapshotSize.height * scale, height: snapshotSize.height * scale,
}; };
return <div className='snapshot-tab'> return <div className='snapshot-tab'>
<div className='snapshot-controls'>{ <div className='snapshot-controls'>
selection && <div key='selectedTime' className='snapshot-toggle'> {snapshots.map((snapshot, index) => {
{msToString(selection.time - boundaries.minimum)}
</div>
}{!selection && snapshots.map((snapshot, index) => {
return <div return <div
key={snapshot.title} key={snapshot.title}
className={'snapshot-toggle' + (snapshotIndex === index ? ' toggled' : '')} className={'snapshot-toggle' + (snapshotIndex === index ? ' toggled' : '')}

View file

@ -21,6 +21,7 @@ import { Boundaries } from '../geometry';
import * as React from 'react'; import * as React from 'react';
import { useMeasure } from './helpers'; import { useMeasure } from './helpers';
import { msToString } from '../../uiUtils'; import { msToString } from '../../uiUtils';
import { FilmStrip } from './filmStrip';
type TimelineBar = { type TimelineBar = {
entry?: ActionEntry; entry?: ActionEntry;
@ -40,8 +41,7 @@ export const Timeline: React.FunctionComponent<{
selectedAction: ActionEntry | undefined, selectedAction: ActionEntry | undefined,
highlightedAction: ActionEntry | undefined, highlightedAction: ActionEntry | undefined,
onSelected: (action: ActionEntry) => void, onSelected: (action: ActionEntry) => void,
onTimeSelected: (time: number | undefined) => void, }> = ({ context, boundaries, selectedAction, highlightedAction, onSelected }) => {
}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onTimeSelected }) => {
const [measure, ref] = useMeasure<HTMLDivElement>(); const [measure, ref] = useMeasure<HTMLDivElement>();
const [previewX, setPreviewX] = React.useState<number | undefined>(); const [previewX, setPreviewX] = React.useState<number | undefined>();
const [hoveredBarIndex, setHoveredBarIndex] = React.useState<number | undefined>(); const [hoveredBarIndex, setHoveredBarIndex] = React.useState<number | undefined>();
@ -143,12 +143,10 @@ export const Timeline: React.FunctionComponent<{
return; return;
const x = event.clientX - ref.current.getBoundingClientRect().left; const x = event.clientX - ref.current.getBoundingClientRect().left;
setPreviewX(x); setPreviewX(x);
onTimeSelected(positionToTime(measure.width, boundaries, x));
setHoveredBarIndex(findHoveredBarIndex(x)); setHoveredBarIndex(findHoveredBarIndex(x));
}; };
const onMouseLeave = () => { const onMouseLeave = () => {
setPreviewX(undefined); setPreviewX(undefined);
onTimeSelected(undefined);
}; };
const onClick = (event: React.MouseEvent) => { const onClick = (event: React.MouseEvent) => {
if (!ref.current) if (!ref.current)
@ -194,6 +192,7 @@ export const Timeline: React.FunctionComponent<{
></div>; ></div>;
}) })
}</div> }</div>
<FilmStrip context={context} boundaries={boundaries} previewX={previewX} />
<div className='timeline-marker timeline-marker-hover' style={{ <div className='timeline-marker timeline-marker-hover' style={{
display: (previewX !== undefined) ? 'block' : 'none', display: (previewX !== undefined) ? 'block' : 'none',
left: (previewX || 0) + 'px', left: (previewX || 0) + 'px',

View file

@ -34,7 +34,6 @@ export const Workbench: React.FunctionComponent<{
const [context, setContext] = React.useState(contexts[0]); const [context, setContext] = React.useState(contexts[0]);
const [selectedAction, setSelectedAction] = React.useState<ActionEntry | undefined>(); const [selectedAction, setSelectedAction] = React.useState<ActionEntry | undefined>();
const [highlightedAction, setHighlightedAction] = React.useState<ActionEntry | undefined>(); const [highlightedAction, setHighlightedAction] = React.useState<ActionEntry | undefined>();
const [selectedTime, setSelectedTime] = React.useState<number | undefined>();
const actions = React.useMemo(() => { const actions = React.useMemo(() => {
const actions: ActionEntry[] = []; const actions: ActionEntry[] = [];
@ -45,7 +44,6 @@ export const Workbench: React.FunctionComponent<{
const snapshotSize = context.created.viewportSize || { width: 1280, height: 720 }; const snapshotSize = context.created.viewportSize || { width: 1280, height: 720 };
const boundaries = { minimum: context.startTime, maximum: context.endTime }; 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'> return <div className='vbox workbench'>
<div className='hbox header'> <div className='hbox header'>
@ -58,7 +56,6 @@ export const Workbench: React.FunctionComponent<{
onChange={context => { onChange={context => {
setContext(context); setContext(context);
setSelectedAction(undefined); setSelectedAction(undefined);
setSelectedTime(undefined);
}} }}
/> />
</div> </div>
@ -69,12 +66,11 @@ export const Workbench: React.FunctionComponent<{
selectedAction={selectedAction} selectedAction={selectedAction}
highlightedAction={highlightedAction} highlightedAction={highlightedAction}
onSelected={action => setSelectedAction(action)} onSelected={action => setSelectedAction(action)}
onTimeSelected={time => setSelectedTime(time)}
/> />
</div> </div>
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}> <SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<SplitView sidebarSize={250}> <SplitView sidebarSize={250}>
<SnapshotTab actionEntry={selectedAction} snapshotSize={snapshotSize} selection={snapshotSelection} boundaries={boundaries} /> <SnapshotTab actionEntry={selectedAction} snapshotSize={snapshotSize} />
<TabbedPane tabs={[ <TabbedPane tabs={[
{ id: 'logs', title: 'Log', render: () => <LogsTab actionEntry={selectedAction} /> }, { id: 'logs', title: 'Log', render: () => <LogsTab actionEntry={selectedAction} /> },
{ id: 'source', title: 'Source', render: () => <SourceTab actionEntry={selectedAction} /> }, { id: 'source', title: 'Source', render: () => <SourceTab actionEntry={selectedAction} /> },
@ -87,7 +83,6 @@ export const Workbench: React.FunctionComponent<{
highlightedAction={highlightedAction} highlightedAction={highlightedAction}
onSelected={action => { onSelected={action => {
setSelectedAction(action); setSelectedAction(action);
setSelectedTime(undefined);
}} }}
onHighlighted={action => setHighlightedAction(action)} onHighlighted={action => setHighlightedAction(action)}
/> />

View file

@ -39,3 +39,29 @@ export function msToString(ms: number): string {
const days = hours / 24; const days = hours / 24;
return days.toFixed(1) + 'd'; 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;
}

View file

@ -73,7 +73,7 @@ it.describe('snapshots', () => {
snapshotter.on('snapshot', snapshot => snapshots.push(snapshot)); snapshotter.on('snapshot', snapshot => snapshots.push(snapshot));
await Promise.all([ await Promise.all([
new Promise(f => snapshotter.once('snapshot', f)), new Promise(f => snapshotter.once('snapshot', f)),
snapshotter.setAutoSnapshotInterval(25), snapshotter.setAutoSnapshotIntervalForTest(25),
]); ]);
await Promise.all([ await Promise.all([
new Promise(f => snapshotter.once('snapshot', f)), new Promise(f => snapshotter.once('snapshot', f)),
@ -88,7 +88,7 @@ it.describe('snapshots', () => {
snapshotter.on('snapshot', snapshot => snapshots.push(snapshot)); snapshotter.on('snapshot', snapshot => snapshots.push(snapshot));
await Promise.all([ await Promise.all([
new Promise(f => snapshotter.once('snapshot', f)), 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>'); 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)); snapshotter.on('snapshot', snapshot => snapshots.push(snapshot));
await Promise.all([ await Promise.all([
new Promise(f => snapshotter.once('snapshot', f)), 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>'); expect(distillSnapshot(snapshots[0])).toBe('<LINK rel=\"stylesheet\" href=\"style.css\"><BUTTON>Hello</BUTTON>');