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 (!lastSnapshotEvent.get(''))
|
||||||
if (!mainFrameSnapshot)
|
|
||||||
return 'data:text/html,Snapshot is not available';
|
return 'data:text/html,Snapshot is not available';
|
||||||
|
this._pageUrl = 'http://playwright.snapshot/?cachebusting=' + Date.now();
|
||||||
if (!mainFrameSnapshot.frameUrl.startsWith('http'))
|
|
||||||
this._pageUrl = 'http://playwright.snapshot/';
|
|
||||||
else
|
|
||||||
this._pageUrl = mainFrameSnapshot.frameUrl;
|
|
||||||
return this._pageUrl;
|
return this._pageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,15 +98,14 @@ class TraceViewer {
|
||||||
|
|
||||||
return fs.readFileSync(path.join(this._document.resourcesDir, sha1)).toString('base64');
|
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;
|
const { action, snapshot } = arg;
|
||||||
if (!this._document)
|
if (!this._document)
|
||||||
return;
|
return;
|
||||||
try {
|
try {
|
||||||
const contextEntry = this._document.model.contexts.find(entry => entry.created.contextId === action.contextId)!;
|
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 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, snapshot.snapshotTime);
|
||||||
const pageUrl = await this._document.snapshotRouter.selectSnapshot(contextEntry, pageEntry, snapshot.snapshotId, snapshotTime);
|
|
||||||
|
|
||||||
// TODO: fix Playwright bug where frame.name is lost (empty).
|
// TODO: fix Playwright bug where frame.name is lost (empty).
|
||||||
const snapshotFrame = uiPage.frames()[1];
|
const snapshotFrame = uiPage.frames()[1];
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ declare global {
|
||||||
getTraceModel(): Promise<TraceModel>;
|
getTraceModel(): Promise<TraceModel>;
|
||||||
readFile(filePath: string): Promise<string>;
|
readFile(filePath: string): Promise<string>;
|
||||||
readResource(sha1: 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> }
|
{ expanded && <div style={{ display: 'flex', flex: 'auto', margin: '5px 0 5px 20px' }}>{body}</div> }
|
||||||
</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 { ActionEntry } from '../../traceModel';
|
||||||
import { Size } from '../geometry';
|
import { Boundaries, Size } from '../geometry';
|
||||||
import { NetworkTab } from './networkTab';
|
import { NetworkTab } from './networkTab';
|
||||||
import { SourceTab } from './sourceTab';
|
import { SourceTab } from './sourceTab';
|
||||||
import './propertiesTabbedPane.css';
|
import './propertiesTabbedPane.css';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useMeasure } from './helpers';
|
import { msToString, useMeasure } from './helpers';
|
||||||
import { LogsTab } from './logsTab';
|
import { LogsTab } from './logsTab';
|
||||||
|
|
||||||
export const PropertiesTabbedPane: React.FunctionComponent<{
|
export const PropertiesTabbedPane: React.FunctionComponent<{
|
||||||
actionEntry: ActionEntry | undefined,
|
actionEntry: ActionEntry | undefined,
|
||||||
snapshotSize: Size,
|
snapshotSize: Size,
|
||||||
}> = ({ actionEntry, snapshotSize }) => {
|
selectedTime: number | undefined,
|
||||||
|
boundaries: Boundaries,
|
||||||
|
}> = ({ actionEntry, snapshotSize, selectedTime, boundaries }) => {
|
||||||
const [selected, setSelected] = React.useState<'snapshot' | 'source' | 'network' | 'logs'>('snapshot');
|
const [selected, setSelected] = React.useState<'snapshot' | 'source' | 'network' | 'logs'>('snapshot');
|
||||||
return <div className='properties-tabbed-pane'>
|
return <div className='properties-tabbed-pane'>
|
||||||
<div className='vbox'>
|
<div className='vbox'>
|
||||||
|
|
@ -51,7 +53,7 @@ export const PropertiesTabbedPane: React.FunctionComponent<{
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='properties-tab-content' style={{ display: selected === 'snapshot' ? 'flex' : 'none' }}>
|
<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>
|
||||||
<div className='properties-tab-content' style={{ display: selected === 'source' ? 'flex' : 'none' }}>
|
<div className='properties-tab-content' style={{ display: selected === 'source' ? 'flex' : 'none' }}>
|
||||||
<SourceTab actionEntry={actionEntry} />
|
<SourceTab actionEntry={actionEntry} />
|
||||||
|
|
@ -69,18 +71,24 @@ export const PropertiesTabbedPane: React.FunctionComponent<{
|
||||||
const SnapshotTab: React.FunctionComponent<{
|
const SnapshotTab: React.FunctionComponent<{
|
||||||
actionEntry: ActionEntry | undefined,
|
actionEntry: ActionEntry | undefined,
|
||||||
snapshotSize: Size,
|
snapshotSize: Size,
|
||||||
}> = ({ actionEntry, snapshotSize }) => {
|
selectedTime: number | undefined,
|
||||||
|
boundaries: Boundaries,
|
||||||
|
}> = ({ actionEntry, snapshotSize, selectedTime, boundaries }) => {
|
||||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
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();
|
snapshots = (actionEntry ? (actionEntry.action.snapshots || []) : []).slice();
|
||||||
if (!snapshots.length || snapshots[0].name !== 'before')
|
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')
|
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);
|
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSnapshotIndex(0);
|
||||||
|
}, [selectedTime]);
|
||||||
|
|
||||||
const iframeRef = React.createRef<HTMLIFrameElement>();
|
const iframeRef = React.createRef<HTMLIFrameElement>();
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -89,9 +97,9 @@ const SnapshotTab: React.FunctionComponent<{
|
||||||
}, [actionEntry, iframeRef]);
|
}, [actionEntry, iframeRef]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (actionEntry)
|
if (actionEntry && snapshots[snapshotIndex])
|
||||||
(window as any).renderSnapshot({ action: actionEntry.action, snapshot: 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);
|
const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height);
|
||||||
return <div className='snapshot-tab'>
|
return <div className='snapshot-tab'>
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-lane.timeline-bars {
|
.timeline-lane.timeline-bars {
|
||||||
|
pointer-events: auto;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { ContextEntry, InterestingPageEvent, ActionEntry, trace } from '../../tr
|
||||||
import './timeline.css';
|
import './timeline.css';
|
||||||
import { Boundaries } from '../geometry';
|
import { Boundaries } from '../geometry';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useMeasure } from './helpers';
|
import { msToString, useMeasure } from './helpers';
|
||||||
|
|
||||||
type TimelineBar = {
|
type TimelineBar = {
|
||||||
entry?: ActionEntry;
|
entry?: ActionEntry;
|
||||||
|
|
@ -40,7 +40,8 @@ export const Timeline: React.FunctionComponent<{
|
||||||
highlightedAction: ActionEntry | undefined,
|
highlightedAction: ActionEntry | undefined,
|
||||||
onSelected: (action: ActionEntry) => void,
|
onSelected: (action: ActionEntry) => void,
|
||||||
onHighlighted: (action: ActionEntry | undefined) => 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 [measure, ref] = useMeasure<HTMLDivElement>();
|
||||||
const [previewX, setPreviewX] = React.useState<number | undefined>();
|
const [previewX, setPreviewX] = React.useState<number | undefined>();
|
||||||
const [hoveredBar, setHoveredBar] = React.useState<TimelineBar | undefined>();
|
const [hoveredBar, setHoveredBar] = React.useState<TimelineBar | undefined>();
|
||||||
|
|
@ -147,16 +148,25 @@ export const Timeline: React.FunctionComponent<{
|
||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
setPreviewX(undefined);
|
setPreviewX(undefined);
|
||||||
};
|
};
|
||||||
const onClick = (event: React.MouseEvent) => {
|
const onActionClick = (event: React.MouseEvent) => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
const x = event.clientX - ref.current.getBoundingClientRect().left;
|
const x = event.clientX - ref.current.getBoundingClientRect().left;
|
||||||
const bar = findHoveredBar(x);
|
const bar = findHoveredBar(x);
|
||||||
if (bar && bar.entry)
|
if (bar && bar.entry)
|
||||||
onSelected(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'>{
|
<div className='timeline-grid'>{
|
||||||
offsets.map((offset, index) => {
|
offsets.map((offset, index) => {
|
||||||
return <div key={index} className='timeline-divider' style={{ left: offset.position + 'px' }}>
|
return <div key={index} className='timeline-divider' style={{ left: offset.position + 'px' }}>
|
||||||
|
|
@ -177,7 +187,7 @@ export const Timeline: React.FunctionComponent<{
|
||||||
</div>;
|
</div>;
|
||||||
})
|
})
|
||||||
}</div>
|
}</div>
|
||||||
<div className='timeline-lane timeline-bars'>{
|
<div className='timeline-lane timeline-bars' onClick={onActionClick}>{
|
||||||
bars.map((bar, index) => {
|
bars.map((bar, index) => {
|
||||||
return <div key={index}
|
return <div key={index}
|
||||||
className={'timeline-bar ' + bar.type + (targetBar === bar ? ' selected' : '')}
|
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;
|
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 [context, setContext] = React.useState(traceModel.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[] = [];
|
||||||
|
|
@ -38,6 +39,7 @@ export const Workbench: React.FunctionComponent<{
|
||||||
}, [context]);
|
}, [context]);
|
||||||
|
|
||||||
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 };
|
||||||
|
|
||||||
return <div className='vbox workbench'>
|
return <div className='vbox workbench'>
|
||||||
<GlobalStyles />
|
<GlobalStyles />
|
||||||
|
|
@ -51,17 +53,22 @@ export const Workbench: React.FunctionComponent<{
|
||||||
onChange={context => {
|
onChange={context => {
|
||||||
setContext(context);
|
setContext(context);
|
||||||
setSelectedAction(undefined);
|
setSelectedAction(undefined);
|
||||||
|
setSelectedTime(undefined);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ background: 'white', paddingLeft: '20px', flex: 'none' }}>
|
<div style={{ background: 'white', paddingLeft: '20px', flex: 'none' }}>
|
||||||
<Timeline
|
<Timeline
|
||||||
context={context}
|
context={context}
|
||||||
boundaries={{ minimum: context.startTime, maximum: context.endTime }}
|
boundaries={boundaries}
|
||||||
selectedAction={selectedAction}
|
selectedAction={selectedAction}
|
||||||
highlightedAction={highlightedAction}
|
highlightedAction={highlightedAction}
|
||||||
onSelected={action => setSelectedAction(action)}
|
onSelected={action => {
|
||||||
|
setSelectedAction(action);
|
||||||
|
setSelectedTime(undefined);
|
||||||
|
}}
|
||||||
onHighlighted={action => setHighlightedAction(action)}
|
onHighlighted={action => setHighlightedAction(action)}
|
||||||
|
onTimeSelected={time => setSelectedTime(time)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='hbox'>
|
<div className='hbox'>
|
||||||
|
|
@ -70,11 +77,19 @@ export const Workbench: React.FunctionComponent<{
|
||||||
actions={actions}
|
actions={actions}
|
||||||
selectedAction={selectedAction}
|
selectedAction={selectedAction}
|
||||||
highlightedAction={highlightedAction}
|
highlightedAction={highlightedAction}
|
||||||
onSelected={action => setSelectedAction(action)}
|
onSelected={action => {
|
||||||
|
setSelectedAction(action);
|
||||||
|
setSelectedTime(undefined);
|
||||||
|
}}
|
||||||
onHighlighted={action => setHighlightedAction(action)}
|
onHighlighted={action => setHighlightedAction(action)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PropertiesTabbedPane actionEntry={selectedAction} snapshotSize={snapshotSize} />
|
<PropertiesTabbedPane
|
||||||
|
actionEntry={selectedAction}
|
||||||
|
snapshotSize={snapshotSize}
|
||||||
|
selectedTime={selectedTime}
|
||||||
|
boundaries={boundaries}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ export function frameSnapshotStreamer() {
|
||||||
const kSnapshotFrameIdAttribute = '__playwright_snapshot_frameid_';
|
const kSnapshotFrameIdAttribute = '__playwright_snapshot_frameid_';
|
||||||
const kSnapshotBinding = '__playwright_snapshot_binding_';
|
const kSnapshotBinding = '__playwright_snapshot_binding_';
|
||||||
const kShadowAttribute = '__playwright_shadow_root_';
|
const kShadowAttribute = '__playwright_shadow_root_';
|
||||||
|
const kScrollTopAttribute = '__playwright_scroll_top_';
|
||||||
|
const kScrollLeftAttribute = '__playwright_scroll_left_';
|
||||||
|
|
||||||
const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
||||||
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
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;
|
private _timer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._streamSnapshot();
|
|
||||||
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'insertRule');
|
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'insertRule');
|
||||||
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'deleteRule');
|
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'deleteRule');
|
||||||
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'addRule');
|
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'addRule');
|
||||||
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'removeRule');
|
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'removeRule');
|
||||||
// TODO: should we also intercept setters like CSSRule.cssText and CSSStyleRule.selectorText?
|
// TODO: should we also intercept setters like CSSRule.cssText and CSSStyleRule.selectorText?
|
||||||
|
this._streamSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _interceptCSSOM(obj: any, method: string) {
|
private _interceptCSSOM(obj: any, method: string) {
|
||||||
|
|
@ -132,7 +134,7 @@ export function frameSnapshotStreamer() {
|
||||||
const win = window;
|
const win = window;
|
||||||
const doc = win.document;
|
const doc = win.document;
|
||||||
|
|
||||||
const shadowChunks: string[] = [];
|
let needScript = false;
|
||||||
const styleNodeToStyleSheetText = new Map<Node, string>();
|
const styleNodeToStyleSheetText = new Map<Node, string>();
|
||||||
const styleSheetUrlToContentOverride = new Map<string, string>();
|
const styleSheetUrlToContentOverride = new Map<string, string>();
|
||||||
|
|
||||||
|
|
@ -259,18 +261,26 @@ export function frameSnapshotStreamer() {
|
||||||
builder.push(' disabled');
|
builder.push(' disabled');
|
||||||
if ((element as any).readOnly)
|
if ((element as any).readOnly)
|
||||||
builder.push(' readonly');
|
builder.push(' readonly');
|
||||||
if (element.shadowRoot) {
|
if (element.scrollTop) {
|
||||||
const b: string[] = [];
|
needScript = true;
|
||||||
visit(element.shadowRoot, b);
|
builder.push(` ${kScrollTopAttribute}="${element.scrollTop}"`);
|
||||||
const chunkId = shadowChunks.length;
|
}
|
||||||
shadowChunks.push(b.join(''));
|
if (element.scrollLeft) {
|
||||||
builder.push(' ');
|
needScript = true;
|
||||||
builder.push(kShadowAttribute);
|
builder.push(` ${kScrollLeftAttribute}="${element.scrollLeft}"`);
|
||||||
builder.push('="');
|
|
||||||
builder.push('' + chunkId);
|
|
||||||
builder.push('"');
|
|
||||||
}
|
}
|
||||||
builder.push('>');
|
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') {
|
if (nodeName === 'HEAD') {
|
||||||
let baseHref = document.baseURI;
|
let baseHref = document.baseURI;
|
||||||
|
|
@ -297,12 +307,9 @@ export function frameSnapshotStreamer() {
|
||||||
for (let child = node.firstChild; child; child = child.nextSibling)
|
for (let child = node.firstChild; child; child = child.nextSibling)
|
||||||
visit(child, builder);
|
visit(child, builder);
|
||||||
}
|
}
|
||||||
if (node.nodeName === 'BODY' && shadowChunks.length) {
|
if (node.nodeName === 'BODY' && needScript) {
|
||||||
builder.push('<script>');
|
builder.push('<script>');
|
||||||
const chunks = shadowChunks.map(html => {
|
const scriptContent = `\n(${applyPlaywrightAttributes.toString()})('${kShadowAttribute}', '${kScrollTopAttribute}', '${kScrollLeftAttribute}')`;
|
||||||
return '`' + html.replace(/`/g, '\\\`') + '`';
|
|
||||||
}).join(',\n');
|
|
||||||
const scriptContent = `\n(${applyShadowsInPage.toString()})('${kShadowAttribute}', [\n${chunks}\n])\n`;
|
|
||||||
builder.push(scriptContent);
|
builder.push(scriptContent);
|
||||||
builder.push('</script>');
|
builder.push('</script>');
|
||||||
}
|
}
|
||||||
|
|
@ -313,22 +320,35 @@ export function frameSnapshotStreamer() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyShadowsInPage(shadowAttribute: string, shadowContent: string[]) {
|
function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string) {
|
||||||
const visitShadows = (root: Document | ShadowRoot) => {
|
const scrollTops = document.querySelectorAll(`[${scrollTopAttribute}]`);
|
||||||
const elements = root.querySelectorAll(`[${shadowAttribute}]`);
|
const scrollLefts = document.querySelectorAll(`[${scrollLeftAttribute}]`);
|
||||||
for (let i = 0; i < elements.length; i++) {
|
for (const element of document.querySelectorAll(`template[${shadowAttribute}]`)) {
|
||||||
const host = elements[i];
|
const template = element as HTMLTemplateElement;
|
||||||
const chunkId = host.getAttribute(shadowAttribute)!;
|
const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' });
|
||||||
host.removeAttribute(shadowAttribute);
|
shadowRoot.appendChild(template.content);
|
||||||
const shadow = host.attachShadow({ mode: 'open' });
|
template.remove();
|
||||||
const html = shadowContent[+chunkId];
|
}
|
||||||
if (html) {
|
const onDOMContentLoaded = () => {
|
||||||
shadow.innerHTML = html;
|
window.removeEventListener('DOMContentLoaded', onDOMContentLoaded);
|
||||||
visitShadows(shadow);
|
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[] = [];
|
const root: string[] = [];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue