From d0db4f67371ebf19366f5cd8ec36f54c3ae57bef Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 8 Apr 2021 05:32:12 +0800 Subject: [PATCH] feat: include screencast in trace (#6128) --- src/server/chromium/crPage.ts | 49 +++++--- src/server/chromium/videoRecorder.ts | 5 +- src/server/firefox/ffPage.ts | 4 + src/server/page.ts | 6 + src/server/snapshot/inMemorySnapshotter.ts | 8 +- src/server/snapshot/persistentSnapshotter.ts | 5 +- src/server/snapshot/snapshotter.ts | 1 + src/server/trace/common/traceEvents.ts | 10 ++ src/server/trace/recorder/tracer.ts | 18 ++- src/server/trace/viewer/traceModel.ts | 6 + src/server/webkit/wkPage.ts | 4 + src/web/traceViewer/ui/filmStrip.css | 45 ++++++++ src/web/traceViewer/ui/filmStrip.tsx | 111 +++++++++++++++++++ src/web/traceViewer/ui/snapshotTab.tsx | 22 +--- src/web/traceViewer/ui/timeline.tsx | 7 +- src/web/traceViewer/ui/workbench.tsx | 7 +- src/web/uiUtils.ts | 26 +++++ tests/snapshotter.spec.ts | 6 +- 18 files changed, 285 insertions(+), 55 deletions(-) create mode 100644 src/web/traceViewer/ui/filmStrip.css create mode 100644 src/web/traceViewer/ui/filmStrip.tsx diff --git a/src/server/chromium/crPage.ts b/src/server/chromium/crPage.ts index e3ec3267ba..7705721d4d 100644 --- a/src/server/chromium/crPage.ts +++ b/src/server/chromium/crPage.ts @@ -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 { @@ -290,6 +290,19 @@ export class CRPage implements PageDelegate { return this._sessionForHandle(handle)._scrollRectIntoViewIfNeeded(handle, rect); } + async setScreencastEnabled(enabled: boolean): Promise { + 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(); 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; @@ -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 { + async _createVideoRecorder(screencastId: string, options: types.PageScreencastOptions): Promise { 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 { + async _stopVideoRecording(): Promise { 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 { const headers = network.mergeHeaders([ this._crPage._browserContext._options.extraHTTPHeaders, diff --git a/src/server/chromium/videoRecorder.ts b/src/server/chromium/videoRecorder.ts index dba5e27f91..301a7418fb 100644 --- a/src/server/chromium/videoRecorder.ts +++ b/src/server/chromium/videoRecorder.ts @@ -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) { diff --git a/src/server/firefox/ffPage.ts b/src/server/firefox/ffPage.ts index 9418986898..394ecd5cca 100644 --- a/src/server/firefox/ffPage.ts +++ b/src/server/firefox/ffPage.ts @@ -472,6 +472,10 @@ export class FFPage implements PageDelegate { }); } + async setScreencastEnabled(enabled: boolean): Promise { + throw new Error('Not implemented'); + } + rafCountForStablePosition(): number { return 1; } diff --git a/src/server/page.ts b/src/server/page.ts index eea3c70c57..9ef7c45a16 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -70,6 +70,7 @@ export interface PageDelegate { getBoundingBox(handle: dom.ElementHandle): Promise; getFrameElement(frame: frames.Frame): Promise; scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'>; + setScreencastEnabled(enabled: boolean): Promise; getAccessibilityTree(needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}>; pdf?: (options?: types.PDFOptions) => Promise; @@ -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 { diff --git a/src/server/snapshot/inMemorySnapshotter.ts b/src/server/snapshot/inMemorySnapshotter.ts index c28bf03508..f7aa1b81c0 100644 --- a/src/server/snapshot/inMemorySnapshotter.ts +++ b/src/server/snapshot/inMemorySnapshotter.ts @@ -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(); private _server: HttpServer; @@ -44,10 +42,6 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot return await this._server.start(); } - async start(): Promise { - 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 { + async setAutoSnapshotIntervalForTest(interval: number): Promise { await this._snapshotter.setAutoSnapshotInterval(interval); } diff --git a/src/server/snapshot/persistentSnapshotter.ts b/src/server/snapshot/persistentSnapshotter.ts index c5619a1665..419f1be8ca 100644 --- a/src/server/snapshot/persistentSnapshotter.ts +++ b/src/server/snapshot/persistentSnapshotter.ts @@ -46,12 +46,13 @@ export class PersistentSnapshotter extends EventEmitter implements SnapshotterDe this._snapshotter = new Snapshotter(context, this); } - async start(): Promise { + async start(autoSnapshots: boolean): Promise { 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() { diff --git a/src/server/snapshot/snapshotter.ts b/src/server/snapshot/snapshotter.ts index c87d75e379..0a5850d32c 100644 --- a/src/server/snapshot/snapshotter.ts +++ b/src/server/snapshot/snapshotter.ts @@ -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) { diff --git a/src/server/trace/common/traceEvents.ts b/src/server/trace/common/traceEvents.ts index 14ebcc9f88..2c725a26f6 100644 --- a/src/server/trace/common/traceEvents.ts +++ b/src/server/trace/common/traceEvents.ts @@ -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 | diff --git a/src/server/trace/recorder/tracer.ts b/src/server/trace/recorder/tracer.ts index a179f11c6c..c954833d80 100644 --- a/src/server/trace/recorder/tracer.ts +++ b/src/server/trace/recorder/tracer.ts @@ -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 { @@ -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; diff --git a/src/server/trace/viewer/traceModel.ts b/src/server/trace/viewer/traceModel.ts index 4fcaed8c66..32b8fd8db3 100644 --- a/src/server/trace/viewer/traceModel.ts +++ b/src/server/trace/viewer/traceModel.ts @@ -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 & { diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index 5e01876395..4664127e10 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -819,6 +819,10 @@ export class WKPage implements PageDelegate { }); } + async setScreencastEnabled(enabled: boolean): Promise { + throw new Error('Not implemented'); + } + rafCountForStablePosition(): number { return process.platform === 'win32' ? 5 : 1; } diff --git a/src/web/traceViewer/ui/filmStrip.css b/src/web/traceViewer/ui/filmStrip.css new file mode 100644 index 0000000000..e6db636fae --- /dev/null +++ b/src/web/traceViewer/ui/filmStrip.css @@ -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; +} diff --git a/src/web/traceViewer/ui/filmStrip.tsx b/src/web/traceViewer/ui/filmStrip.tsx new file mode 100644 index 0000000000..7c2dd28add --- /dev/null +++ b/src/web/traceViewer/ui/filmStrip.tsx @@ -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(); + + 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
{ + context.pages.filter(p => p.screencastFrames.length).map((page, index) => ) + } + {previewImage && previewX !== undefined && +
+ +
+ } +
; +}; + +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(
); + } + + return
{frames}
; +}; + +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 + }; +} diff --git a/src/web/traceViewer/ui/snapshotTab.tsx b/src/web/traceViewer/ui/snapshotTab.tsx index 490f00a78b..8b361a93c5 100644 --- a/src/web/traceViewer/ui/snapshotTab.tsx +++ b/src/web/traceViewer/ui/snapshotTab.tsx @@ -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(); const [snapshotIndex, setSnapshotIndex] = React.useState(0); const snapshots = actionEntry ? (actionEntry.snapshots || []) : []; - const { pageId, time } = selection || { pageId: undefined, time: 0 }; const iframeRef = React.createRef(); 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
-
{ - selection &&
- {msToString(selection.time - boundaries.minimum)} -
- }{!selection && snapshots.map((snapshot, index) => { +
+ {snapshots.map((snapshot, index) => { return
void, - onTimeSelected: (time: number | undefined) => void, -}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onTimeSelected }) => { +}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected }) => { const [measure, ref] = useMeasure(); const [previewX, setPreviewX] = React.useState(); const [hoveredBarIndex, setHoveredBarIndex] = React.useState(); @@ -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<{ >
; }) }
+
(); const [highlightedAction, setHighlightedAction] = React.useState(); - const [selectedTime, setSelectedTime] = React.useState(); 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
@@ -58,7 +56,6 @@ export const Workbench: React.FunctionComponent<{ onChange={context => { setContext(context); setSelectedAction(undefined); - setSelectedTime(undefined); }} />
@@ -69,12 +66,11 @@ export const Workbench: React.FunctionComponent<{ selectedAction={selectedAction} highlightedAction={highlightedAction} onSelected={action => setSelectedAction(action)} - onTimeSelected={time => setSelectedTime(time)} />
- + }, { id: 'source', title: 'Source', render: () => }, @@ -87,7 +83,6 @@ export const Workbench: React.FunctionComponent<{ highlightedAction={highlightedAction} onSelected={action => { setSelectedAction(action); - setSelectedTime(undefined); }} onHighlighted={action => setHighlightedAction(action)} /> diff --git a/src/web/uiUtils.ts b/src/web/uiUtils.ts index 34ad57731f..9af12be9cd 100644 --- a/src/web/uiUtils.ts +++ b/src/web/uiUtils.ts @@ -39,3 +39,29 @@ export function msToString(ms: number): string { const days = hours / 24; return days.toFixed(1) + 'd'; } + +export function lowerBound(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(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; +} diff --git a/tests/snapshotter.spec.ts b/tests/snapshotter.spec.ts index c724e0b43f..c5baa8c04b 100644 --- a/tests/snapshotter.spec.ts +++ b/tests/snapshotter.spec.ts @@ -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(''); @@ -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('');