diff --git a/src/cli/traceViewer/snapshotRouter.ts b/src/cli/traceViewer/snapshotRouter.ts index f8e4ee1eb4dec..f1da1f90cda02 100644 --- a/src/cli/traceViewer/snapshotRouter.ts +++ b/src/cli/traceViewer/snapshotRouter.ts @@ -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; } diff --git a/src/cli/traceViewer/traceViewer.ts b/src/cli/traceViewer/traceViewer.ts index bcac6955538bf..3d896501be386 100644 --- a/src/cli/traceViewer/traceViewer.ts +++ b/src/cli/traceViewer/traceViewer.ts @@ -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]; diff --git a/src/cli/traceViewer/web/index.tsx b/src/cli/traceViewer/web/index.tsx index fae79c76f2613..0590c72be0424 100644 --- a/src/cli/traceViewer/web/index.tsx +++ b/src/cli/traceViewer/web/index.tsx @@ -26,7 +26,7 @@ declare global { getTraceModel(): Promise; readFile(filePath: string): Promise; readResource(sha1: string): Promise; - renderSnapshot(arg: { action: trace.ActionTraceEvent, snapshot: { name: string, snapshotId?: string } }): void; + renderSnapshot(arg: { action: trace.ActionTraceEvent, snapshot: { snapshotId?: string, snapshotTime?: number } }): void; } } diff --git a/src/cli/traceViewer/web/ui/helpers.tsx b/src/cli/traceViewer/web/ui/helpers.tsx index d152c0d411aea..0891083a0c718 100644 --- a/src/cli/traceViewer/web/ui/helpers.tsx +++ b/src/cli/traceViewer/web/ui/helpers.tsx @@ -72,3 +72,29 @@ export const Expandable: React.FunctionComponent<{ { expanded &&
{body}
} ; }; + +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'; +} diff --git a/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx b/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx index 1c5f76c7a11c6..0d954439cfc7b 100644 --- a/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx +++ b/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx @@ -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
@@ -51,7 +53,7 @@ export const PropertiesTabbedPane: React.FunctionComponent<{
- +
@@ -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(); - 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(); 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
diff --git a/src/cli/traceViewer/web/ui/timeline.css b/src/cli/traceViewer/web/ui/timeline.css index f8d0f717746eb..9ce247409e165 100644 --- a/src/cli/traceViewer/web/ui/timeline.css +++ b/src/cli/traceViewer/web/ui/timeline.css @@ -63,6 +63,7 @@ } .timeline-lane.timeline-bars { + pointer-events: auto; margin-bottom: 10px; overflow: visible; } diff --git a/src/cli/traceViewer/web/ui/timeline.tsx b/src/cli/traceViewer/web/ui/timeline.tsx index 1e580ee7f53dc..22a54b971c733 100644 --- a/src/cli/traceViewer/web/ui/timeline.tsx +++ b/src/cli/traceViewer/web/ui/timeline.tsx @@ -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(); const [previewX, setPreviewX] = React.useState(); const [hoveredBar, setHoveredBar] = React.useState(); @@ -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
+ return
{ offsets.map((offset, index) => { return
@@ -177,7 +187,7 @@ export const Timeline: React.FunctionComponent<{
; }) }
-
{ +
{ bars.map((bar, index) => { return
(); const [highlightedAction, setHighlightedAction] = React.useState(); + const [selectedTime, setSelectedTime] = React.useState(); 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
@@ -51,17 +53,22 @@ export const Workbench: React.FunctionComponent<{ onChange={context => { setContext(context); setSelectedAction(undefined); + setSelectedTime(undefined); }} />
setSelectedAction(action)} + onSelected={action => { + setSelectedAction(action); + setSelectedTime(undefined); + }} onHighlighted={action => setHighlightedAction(action)} + onTimeSelected={time => setSelectedTime(time)} />
@@ -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)} />
- +
; }; diff --git a/src/trace/snapshotterInjected.ts b/src/trace/snapshotterInjected.ts index 27de50bbcb107..6aa15570ed78e 100644 --- a/src/trace/snapshotterInjected.ts +++ b/src/trace/snapshotterInjected.ts @@ -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(); const styleSheetUrlToContentOverride = new Map(); @@ -259,18 +261,26 @@ export function frameSnapshotStreamer() { builder.push(' disabled'); if ((element as any).readOnly) builder.push(' readonly'); + 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); - const chunkId = shadowChunks.length; - shadowChunks.push(b.join('')); - builder.push(' '); + builder.push(''); } - builder.push('>'); } 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(''); } @@ -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[] = [];