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())
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,

View file

@ -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) {

View file

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

View file

@ -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 {

View file

@ -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);
}

View file

@ -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() {

View file

@ -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) {

View file

@ -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 |

View file

@ -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;

View file

@ -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 & {

View file

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

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 { 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' : '')}

View file

@ -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',

View file

@ -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)}
/>

View file

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

View file

@ -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>');