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:
parent
a3af0829ff
commit
0108d2d41f
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
}
|
||||
|
||||
.timeline-lane.timeline-bars {
|
||||
pointer-events: auto;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
||||
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[] = [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue