feat(snapshots): various improvements (#5152)

- Adopt "declarative shadow dom" format for shadow dom snapshots.
- Restore scroll positions.
- Render snapshot at arbitrary timestamp.
This commit is contained in:
Dmitry Gozman 2021-01-26 15:09:17 -08:00 committed by GitHub
parent a3af0829ff
commit 0108d2d41f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 136 additions and 87 deletions

View file

@ -68,14 +68,9 @@ export class SnapshotRouter {
}
}
const mainFrameSnapshot = lastSnapshotEvent.get('');
if (!mainFrameSnapshot)
if (!lastSnapshotEvent.get(''))
return 'data:text/html,Snapshot is not available';
if (!mainFrameSnapshot.frameUrl.startsWith('http'))
this._pageUrl = 'http://playwright.snapshot/';
else
this._pageUrl = mainFrameSnapshot.frameUrl;
this._pageUrl = 'http://playwright.snapshot/?cachebusting=' + Date.now();
return this._pageUrl;
}

View file

@ -98,15 +98,14 @@ class TraceViewer {
return fs.readFileSync(path.join(this._document.resourcesDir, sha1)).toString('base64');
});
await uiPage.exposeBinding('renderSnapshot', async (_, arg: { action: ActionTraceEvent, snapshot: { name: string, snapshotId?: string } }) => {
await uiPage.exposeBinding('renderSnapshot', async (_, arg: { action: ActionTraceEvent, snapshot: { snapshotId?: string, snapshotTime?: number } }) => {
const { action, snapshot } = arg;
if (!this._document)
return;
try {
const contextEntry = this._document.model.contexts.find(entry => entry.created.contextId === action.contextId)!;
const pageEntry = contextEntry.pages.find(entry => entry.created.pageId === action.pageId)!;
const snapshotTime = snapshot.name === 'before' ? action.startTime : (snapshot.name === 'after' ? action.endTime : undefined);
const pageUrl = await this._document.snapshotRouter.selectSnapshot(contextEntry, pageEntry, snapshot.snapshotId, snapshotTime);
const pageUrl = await this._document.snapshotRouter.selectSnapshot(contextEntry, pageEntry, snapshot.snapshotId, snapshot.snapshotTime);
// TODO: fix Playwright bug where frame.name is lost (empty).
const snapshotFrame = uiPage.frames()[1];

View file

@ -26,7 +26,7 @@ declare global {
getTraceModel(): Promise<TraceModel>;
readFile(filePath: string): Promise<string>;
readResource(sha1: string): Promise<string>;
renderSnapshot(arg: { action: trace.ActionTraceEvent, snapshot: { name: string, snapshotId?: string } }): void;
renderSnapshot(arg: { action: trace.ActionTraceEvent, snapshot: { snapshotId?: string, snapshotTime?: number } }): void;
}
}

View file

@ -72,3 +72,29 @@ export const Expandable: React.FunctionComponent<{
{ expanded && <div style={{ display: 'flex', flex: 'auto', margin: '5px 0 5px 20px' }}>{body}</div> }
</div>;
};
export function msToString(ms: number): string {
if (!isFinite(ms))
return '-';
if (ms === 0)
return '0';
if (ms < 1000)
return ms.toFixed(0) + 'ms';
const seconds = ms / 1000;
if (seconds < 60)
return seconds.toFixed(1) + 's';
const minutes = seconds / 60;
if (minutes < 60)
return minutes.toFixed(1) + 'm';
const hours = minutes / 60;
if (hours < 24)
return hours.toFixed(1) + 'h';
const days = hours / 24;
return days.toFixed(1) + 'd';
}

View file

@ -15,18 +15,20 @@
*/
import { ActionEntry } from '../../traceModel';
import { Size } from '../geometry';
import { Boundaries, Size } from '../geometry';
import { NetworkTab } from './networkTab';
import { SourceTab } from './sourceTab';
import './propertiesTabbedPane.css';
import * as React from 'react';
import { useMeasure } from './helpers';
import { msToString, useMeasure } from './helpers';
import { LogsTab } from './logsTab';
export const PropertiesTabbedPane: React.FunctionComponent<{
actionEntry: ActionEntry | undefined,
snapshotSize: Size,
}> = ({ actionEntry, snapshotSize }) => {
selectedTime: number | undefined,
boundaries: Boundaries,
}> = ({ actionEntry, snapshotSize, selectedTime, boundaries }) => {
const [selected, setSelected] = React.useState<'snapshot' | 'source' | 'network' | 'logs'>('snapshot');
return <div className='properties-tabbed-pane'>
<div className='vbox'>
@ -51,7 +53,7 @@ export const PropertiesTabbedPane: React.FunctionComponent<{
</div>
</div>
<div className='properties-tab-content' style={{ display: selected === 'snapshot' ? 'flex' : 'none' }}>
<SnapshotTab actionEntry={actionEntry} snapshotSize={snapshotSize} />
<SnapshotTab actionEntry={actionEntry} snapshotSize={snapshotSize} selectedTime={selectedTime} boundaries={boundaries} />
</div>
<div className='properties-tab-content' style={{ display: selected === 'source' ? 'flex' : 'none' }}>
<SourceTab actionEntry={actionEntry} />
@ -69,18 +71,24 @@ export const PropertiesTabbedPane: React.FunctionComponent<{
const SnapshotTab: React.FunctionComponent<{
actionEntry: ActionEntry | undefined,
snapshotSize: Size,
}> = ({ actionEntry, snapshotSize }) => {
selectedTime: number | undefined,
boundaries: Boundaries,
}> = ({ actionEntry, snapshotSize, selectedTime, boundaries }) => {
const [measure, ref] = useMeasure<HTMLDivElement>();
let snapshots: { name: string, snapshotId?: string }[] = [];
let snapshots: { name: string, snapshotId?: string, snapshotTime?: number }[] = [];
snapshots = (actionEntry ? (actionEntry.action.snapshots || []) : []).slice();
if (!snapshots.length || snapshots[0].name !== 'before')
snapshots.unshift({ name: 'before', snapshotId: undefined });
snapshots.unshift({ name: 'before', snapshotTime: actionEntry ? actionEntry.action.startTime : 0 });
if (snapshots[snapshots.length - 1].name !== 'after')
snapshots.push({ name: 'after', snapshotId: undefined });
snapshots.push({ name: 'after', snapshotTime: actionEntry ? actionEntry.action.endTime : 0 });
if (selectedTime)
snapshots = [{ name: msToString(selectedTime - boundaries.minimum), snapshotTime: selectedTime }];
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
React.useEffect(() => {
setSnapshotIndex(0);
}, [selectedTime]);
const iframeRef = React.createRef<HTMLIFrameElement>();
React.useEffect(() => {
@ -89,9 +97,9 @@ const SnapshotTab: React.FunctionComponent<{
}, [actionEntry, iframeRef]);
React.useEffect(() => {
if (actionEntry)
if (actionEntry && snapshots[snapshotIndex])
(window as any).renderSnapshot({ action: actionEntry.action, snapshot: snapshots[snapshotIndex] });
}, [actionEntry, snapshotIndex]);
}, [actionEntry, snapshotIndex, selectedTime]);
const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height);
return <div className='snapshot-tab'>

View file

@ -63,6 +63,7 @@
}
.timeline-lane.timeline-bars {
pointer-events: auto;
margin-bottom: 10px;
overflow: visible;
}

View file

@ -19,7 +19,7 @@ import { ContextEntry, InterestingPageEvent, ActionEntry, trace } from '../../tr
import './timeline.css';
import { Boundaries } from '../geometry';
import * as React from 'react';
import { useMeasure } from './helpers';
import { msToString, useMeasure } from './helpers';
type TimelineBar = {
entry?: ActionEntry;
@ -40,7 +40,8 @@ export const Timeline: React.FunctionComponent<{
highlightedAction: ActionEntry | undefined,
onSelected: (action: ActionEntry) => void,
onHighlighted: (action: ActionEntry | undefined) => void,
}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onHighlighted }) => {
onTimeSelected: (time: number) => void,
}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onHighlighted, onTimeSelected }) => {
const [measure, ref] = useMeasure<HTMLDivElement>();
const [previewX, setPreviewX] = React.useState<number | undefined>();
const [hoveredBar, setHoveredBar] = React.useState<TimelineBar | undefined>();
@ -147,16 +148,25 @@ export const Timeline: React.FunctionComponent<{
const onMouseLeave = () => {
setPreviewX(undefined);
};
const onClick = (event: React.MouseEvent) => {
const onActionClick = (event: React.MouseEvent) => {
if (ref.current) {
const x = event.clientX - ref.current.getBoundingClientRect().left;
const bar = findHoveredBar(x);
if (bar && bar.entry)
onSelected(bar.entry);
event.stopPropagation();
}
};
const onTimeClick = (event: React.MouseEvent) => {
if (ref.current) {
const x = event.clientX - ref.current.getBoundingClientRect().left;
const time = positionToTime(measure.width, boundaries, x);
onTimeSelected(time);
event.stopPropagation();
}
};
return <div ref={ref} className='timeline-view' onMouseMove={onMouseMove} onMouseOver={onMouseMove} onMouseLeave={onMouseLeave} onClick={onClick}>
return <div ref={ref} className='timeline-view' onMouseMove={onMouseMove} onMouseOver={onMouseMove} onMouseLeave={onMouseLeave} onClick={onTimeClick}>
<div className='timeline-grid'>{
offsets.map((offset, index) => {
return <div key={index} className='timeline-divider' style={{ left: offset.position + 'px' }}>
@ -177,7 +187,7 @@ export const Timeline: React.FunctionComponent<{
</div>;
})
}</div>
<div className='timeline-lane timeline-bars'>{
<div className='timeline-lane timeline-bars' onClick={onActionClick}>{
bars.map((bar, index) => {
return <div key={index}
className={'timeline-bar ' + bar.type + (targetBar === bar ? ' selected' : '')}
@ -233,28 +243,3 @@ function positionToTime(clientWidth: number, boundaries: Boundaries, x: number):
return x / clientWidth * (boundaries.maximum - boundaries.minimum) + boundaries.minimum;
}
function msToString(ms: number): string {
if (!isFinite(ms))
return '-';
if (ms === 0)
return '0';
if (ms < 1000)
return ms.toFixed(0) + 'ms';
const seconds = ms / 1000;
if (seconds < 60)
return seconds.toFixed(1) + 's';
const minutes = seconds / 60;
if (minutes < 60)
return minutes.toFixed(1) + 'm';
const hours = minutes / 60;
if (hours < 24)
return hours.toFixed(1) + 'h';
const days = hours / 24;
return days.toFixed(1) + 'd';
}

View file

@ -29,6 +29,7 @@ export const Workbench: React.FunctionComponent<{
const [context, setContext] = React.useState(traceModel.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[] = [];
@ -38,6 +39,7 @@ export const Workbench: React.FunctionComponent<{
}, [context]);
const snapshotSize = context.created.viewportSize || { width: 1280, height: 720 };
const boundaries = { minimum: context.startTime, maximum: context.endTime };
return <div className='vbox workbench'>
<GlobalStyles />
@ -51,17 +53,22 @@ export const Workbench: React.FunctionComponent<{
onChange={context => {
setContext(context);
setSelectedAction(undefined);
setSelectedTime(undefined);
}}
/>
</div>
<div style={{ background: 'white', paddingLeft: '20px', flex: 'none' }}>
<Timeline
context={context}
boundaries={{ minimum: context.startTime, maximum: context.endTime }}
boundaries={boundaries}
selectedAction={selectedAction}
highlightedAction={highlightedAction}
onSelected={action => setSelectedAction(action)}
onSelected={action => {
setSelectedAction(action);
setSelectedTime(undefined);
}}
onHighlighted={action => setHighlightedAction(action)}
onTimeSelected={time => setSelectedTime(time)}
/>
</div>
<div className='hbox'>
@ -70,11 +77,19 @@ export const Workbench: React.FunctionComponent<{
actions={actions}
selectedAction={selectedAction}
highlightedAction={highlightedAction}
onSelected={action => setSelectedAction(action)}
onSelected={action => {
setSelectedAction(action);
setSelectedTime(undefined);
}}
onHighlighted={action => setHighlightedAction(action)}
/>
</div>
<PropertiesTabbedPane actionEntry={selectedAction} snapshotSize={snapshotSize} />
<PropertiesTabbedPane
actionEntry={selectedAction}
snapshotSize={snapshotSize}
selectedTime={selectedTime}
boundaries={boundaries}
/>
</div>
</div>;
};

View file

@ -31,6 +31,8 @@ export function frameSnapshotStreamer() {
const kSnapshotFrameIdAttribute = '__playwright_snapshot_frameid_';
const kSnapshotBinding = '__playwright_snapshot_binding_';
const kShadowAttribute = '__playwright_shadow_root_';
const kScrollTopAttribute = '__playwright_scroll_top_';
const kScrollLeftAttribute = '__playwright_scroll_left_';
const escaped = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
@ -41,12 +43,12 @@ export function frameSnapshotStreamer() {
private _timer: NodeJS.Timeout | undefined;
constructor() {
this._streamSnapshot();
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'insertRule');
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'deleteRule');
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'addRule');
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'removeRule');
// TODO: should we also intercept setters like CSSRule.cssText and CSSStyleRule.selectorText?
this._streamSnapshot();
}
private _interceptCSSOM(obj: any, method: string) {
@ -132,7 +134,7 @@ export function frameSnapshotStreamer() {
const win = window;
const doc = win.document;
const shadowChunks: string[] = [];
let needScript = false;
const styleNodeToStyleSheetText = new Map<Node, string>();
const styleSheetUrlToContentOverride = new Map<string, string>();
@ -259,18 +261,26 @@ export function frameSnapshotStreamer() {
builder.push(' disabled');
if ((element as any).readOnly)
builder.push(' readonly');
if (element.shadowRoot) {
const b: string[] = [];
visit(element.shadowRoot, b);
const chunkId = shadowChunks.length;
shadowChunks.push(b.join(''));
builder.push(' ');
builder.push(kShadowAttribute);
builder.push('="');
builder.push('' + chunkId);
builder.push('"');
if (element.scrollTop) {
needScript = true;
builder.push(` ${kScrollTopAttribute}="${element.scrollTop}"`);
}
if (element.scrollLeft) {
needScript = true;
builder.push(` ${kScrollLeftAttribute}="${element.scrollLeft}"`);
}
builder.push('>');
if (element.shadowRoot) {
needScript = true;
const b: string[] = [];
visit(element.shadowRoot, b);
builder.push('<template ');
builder.push(kShadowAttribute);
builder.push('="open">');
builder.push(b.join(''));
builder.push('</template>');
}
}
if (nodeName === 'HEAD') {
let baseHref = document.baseURI;
@ -297,12 +307,9 @@ export function frameSnapshotStreamer() {
for (let child = node.firstChild; child; child = child.nextSibling)
visit(child, builder);
}
if (node.nodeName === 'BODY' && shadowChunks.length) {
if (node.nodeName === 'BODY' && needScript) {
builder.push('<script>');
const chunks = shadowChunks.map(html => {
return '`' + html.replace(/`/g, '\\\`') + '`';
}).join(',\n');
const scriptContent = `\n(${applyShadowsInPage.toString()})('${kShadowAttribute}', [\n${chunks}\n])\n`;
const scriptContent = `\n(${applyPlaywrightAttributes.toString()})('${kShadowAttribute}', '${kScrollTopAttribute}', '${kScrollLeftAttribute}')`;
builder.push(scriptContent);
builder.push('</script>');
}
@ -313,22 +320,35 @@ export function frameSnapshotStreamer() {
}
};
function applyShadowsInPage(shadowAttribute: string, shadowContent: string[]) {
const visitShadows = (root: Document | ShadowRoot) => {
const elements = root.querySelectorAll(`[${shadowAttribute}]`);
for (let i = 0; i < elements.length; i++) {
const host = elements[i];
const chunkId = host.getAttribute(shadowAttribute)!;
host.removeAttribute(shadowAttribute);
const shadow = host.attachShadow({ mode: 'open' });
const html = shadowContent[+chunkId];
if (html) {
shadow.innerHTML = html;
visitShadows(shadow);
}
function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string) {
const scrollTops = document.querySelectorAll(`[${scrollTopAttribute}]`);
const scrollLefts = document.querySelectorAll(`[${scrollLeftAttribute}]`);
for (const element of document.querySelectorAll(`template[${shadowAttribute}]`)) {
const template = element as HTMLTemplateElement;
const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' });
shadowRoot.appendChild(template.content);
template.remove();
}
const onDOMContentLoaded = () => {
window.removeEventListener('DOMContentLoaded', onDOMContentLoaded);
for (const element of scrollTops)
element.scrollTop = +element.getAttribute(scrollTopAttribute)!;
for (const element of scrollLefts)
element.scrollLeft = +element.getAttribute(scrollLeftAttribute)!;
};
window.addEventListener('DOMContentLoaded', onDOMContentLoaded);
const onLoad = () => {
window.removeEventListener('load', onLoad);
for (const element of scrollTops) {
element.scrollTop = +element.getAttribute(scrollTopAttribute)!;
element.removeAttribute(scrollTopAttribute);
}
for (const element of scrollLefts) {
element.scrollLeft = +element.getAttribute(scrollLeftAttribute)!;
element.removeAttribute(scrollLeftAttribute);
}
};
visitShadows(document);
window.addEventListener('load', onLoad);
}
const root: string[] = [];