diff --git a/src/cli/traceViewer/snapshotServer.ts b/src/cli/traceViewer/snapshotServer.ts index 4d93a8fda9ea2..8d8b67caa9731 100644 --- a/src/cli/traceViewer/snapshotServer.ts +++ b/src/cli/traceViewer/snapshotServer.ts @@ -19,6 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { TraceModel, trace } from './traceModel'; import { TraceServer } from './traceServer'; +import { NodeSnapshot } from '../../trace/traceTypes'; export class SnapshotServer { private _resourcesDir: string | undefined; @@ -185,6 +186,69 @@ export class SnapshotServer { } } + const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); + + function snapshotNodes(snapshot: trace.FrameSnapshot): NodeSnapshot[] { + if (!(snapshot as any)._nodes) { + const nodes: NodeSnapshot[] = []; + const visit = (n: trace.NodeSnapshot) => { + if (typeof n === 'string') { + nodes.push(n); + } else if (typeof n[0] === 'string') { + nodes.push(n); + for (let i = 2; i < n.length; i++) + visit(n[i]); + } + }; + visit(snapshot.html); + (snapshot as any)._nodes = nodes; + } + return (snapshot as any)._nodes; + } + + function serializeSnapshot(snapshots: trace.FrameSnapshotTraceEvent[], initialSnapshotIndex: number): string { + const visit = (n: trace.NodeSnapshot, snapshotIndex: number): string => { + // Text node. + if (typeof n === 'string') + return n; + + if (!(n as any)._string) { + if (Array.isArray(n[0])) { + // Node reference. + const referenceIndex = snapshotIndex - n[0][0]; + if (referenceIndex >= 0 && referenceIndex < snapshotIndex) { + const nodes = snapshotNodes(snapshots[referenceIndex].snapshot); + const nodeIndex = n[0][1]; + if (nodeIndex >= 0 && nodeIndex < nodes.length) + (n as any)._string = visit(nodes[nodeIndex], referenceIndex); + } + } else if (typeof n[0] === 'string') { + // Element node. + const builder: string[] = []; + builder.push('<', n[0]); + for (const [attr, value] of Object.entries(n[1] || {})) + builder.push(' ', attr, '="', value, '"'); + builder.push('>'); + for (let i = 2; i < n.length; i++) + builder.push(visit(n[i], snapshotIndex)); + if (!autoClosing.has(n[0])) + builder.push(''); + (n as any)._string = builder.join(''); + } else { + // Why are we here? Let's not throw, just in case. + (n as any)._string = ''; + } + } + return (n as any)._string; + }; + + const snapshot = snapshots[initialSnapshotIndex].snapshot; + let html = visit(snapshot.html, initialSnapshotIndex); + if (snapshot.doctype) + html = `` + html; + return html; + } + async function doFetch(event: any /* FetchEvent */): Promise { try { const pathname = new URL(event.request.url).pathname; @@ -215,26 +279,29 @@ export class SnapshotServer { if (!contextEntry || !pageEntry) return request.mode === 'navigate' ? respondNotAvailable() : respond404(); - const lastSnapshotEvent = new Map(); - for (const [frameId, snapshots] of Object.entries(pageEntry.snapshotsByFrameId)) { - for (const snapshot of snapshots) { - const current = lastSnapshotEvent.get(frameId); - // Prefer snapshot with exact id. - const exactMatch = parsed.snapshotId && snapshot.snapshotId === parsed.snapshotId; - const currentExactMatch = current && parsed.snapshotId && current.snapshotId === parsed.snapshotId; - // If not available, prefer the latest snapshot before the timestamp. - const timestampMatch = parsed.timestamp && snapshot.timestamp <= parsed.timestamp; - if (exactMatch || (timestampMatch && !currentExactMatch)) - lastSnapshotEvent.set(frameId, snapshot); - } + const frameSnapshots = pageEntry.snapshotsByFrameId[parsed.frameId] || []; + let snapshotIndex = -1; + for (let index = 0; index < frameSnapshots.length; index++) { + const current = snapshotIndex === -1 ? undefined : frameSnapshots[snapshotIndex]; + const snapshot = frameSnapshots[index]; + // Prefer snapshot with exact id. + const exactMatch = parsed.snapshotId && snapshot.snapshotId === parsed.snapshotId; + const currentExactMatch = current && parsed.snapshotId && current.snapshotId === parsed.snapshotId; + // If not available, prefer the latest snapshot before the timestamp. + const timestampMatch = parsed.timestamp && snapshot.timestamp <= parsed.timestamp; + if (exactMatch || (timestampMatch && !currentExactMatch)) + snapshotIndex = index; } - - const snapshotEvent = lastSnapshotEvent.get(parsed.frameId); + const snapshotEvent = snapshotIndex === -1 ? undefined : frameSnapshots[snapshotIndex]; if (!snapshotEvent) return request.mode === 'navigate' ? respondNotAvailable() : respond404(); - if (request.mode === 'navigate') - return new Response(snapshotEvent.snapshot.html, { status: 200, headers: { 'Content-Type': 'text/html' } }); + if (request.mode === 'navigate') { + let html = serializeSnapshot(frameSnapshots, snapshotIndex); + html += ``; + const response = new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } }); + return response; + } let resource: trace.NetworkResourceTraceEvent | null = null; const resourcesWithUrl = contextEntry.resourcesByUrl.get(removeHash(request.url)) || []; diff --git a/src/cli/traceViewer/traceViewer.ts b/src/cli/traceViewer/traceViewer.ts index d43619f7c7b33..4f6a1adcdff92 100644 --- a/src/cli/traceViewer/traceViewer.ts +++ b/src/cli/traceViewer/traceViewer.ts @@ -44,6 +44,7 @@ const emptyModel: TraceModel = { deviceScaleFactor: 1, isMobile: false, viewportSize: { width: 800, height: 600 }, + snapshotScript: '', }, destroyed: { timestamp: Date.now(), diff --git a/src/trace/snapshotterInjected.ts b/src/trace/snapshotterInjected.ts index 2cf212bc08c49..789f102c765ce 100644 --- a/src/trace/snapshotterInjected.ts +++ b/src/trace/snapshotterInjected.ts @@ -14,8 +14,21 @@ * limitations under the License. */ +export type NodeSnapshot = + // Text node. + string | + // Subtree reference, "x snapshots ago, node #y". Could point to a text node. + // Only nodes that are not references are counted, starting from zero. + [ [number, number] ] | + // Just node name. + [ string ] | + // Node name, attributes, child nodes. + // Unfortunately, we cannot make this type definition recursive, therefore "any". + [ string, { [attr: string]: string }, ...any ]; + export type SnapshotData = { - html: string, + doctype?: string, + html: NodeSnapshot, resourceOverrides: { url: string, content: string }[], viewport: { width: number, height: number }, url: string, @@ -23,47 +36,92 @@ export type SnapshotData = { }; export const kSnapshotStreamer = '__playwright_snapshot_streamer_'; -export const kSnapshotFrameIdAttribute = '__playwright_snapshot_frameid_'; export const kSnapshotBinding = '__playwright_snapshot_binding_'; export function frameSnapshotStreamer() { + // Communication with Playwright. const kSnapshotStreamer = '__playwright_snapshot_streamer_'; - const kSnapshotFrameIdAttribute = '__playwright_snapshot_frameid_'; const kSnapshotBinding = '__playwright_snapshot_binding_'; + + // Attributes present in the snapshot. const kShadowAttribute = '__playwright_shadow_root_'; const kScrollTopAttribute = '__playwright_scroll_top_'; const kScrollLeftAttribute = '__playwright_scroll_left_'; + // Symbols for our own info on Nodes. + const kSnapshotFrameId = Symbol('__playwright_snapshot_frameid_'); + const kCachedData = Symbol('__playwright_snapshot_cache_'); + type CachedData = { + ref?: [number, number], // Previous snapshotNumber and nodeIndex. + value?: string, // Value for input/textarea elements. + }; + function ensureCachedData(node: Node): CachedData { + if (!(node as any)[kCachedData]) + (node as any)[kCachedData] = {}; + return (node as any)[kCachedData]; + } + const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; - const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); + function escapeAttribute(s: string): string { + return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); + } + function escapeText(s: string): string { + return s.replace(/[&<]/ug, char => (escaped as any)[char]); + } class Streamer { private _removeNoScript = true; private _needStyleOverrides = false; private _timer: NodeJS.Timeout | undefined; + private _lastSnapshotNumber = 0; + private _observer: MutationObserver; constructor() { - 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._interceptNative(window.CSSStyleSheet.prototype, 'insertRule', () => this._needStyleOverrides = true); + this._interceptNative(window.CSSStyleSheet.prototype, 'deleteRule', () => this._needStyleOverrides = true); + this._interceptNative(window.CSSStyleSheet.prototype, 'addRule', () => this._needStyleOverrides = true); + this._interceptNative(window.CSSStyleSheet.prototype, 'removeRule', () => this._needStyleOverrides = true); + + this._observer = new MutationObserver(list => this._handleMutations(list)); + const observerConfig = { attributes: true, childList: true, subtree: true, characterData: true }; + this._observer.observe(document, observerConfig); + this._interceptNative(window.Element.prototype, 'attachShadow', (node: Node, shadowRoot: ShadowRoot) => { + this._invalidateCache(node); + this._observer.observe(shadowRoot, observerConfig); + }); + this._streamSnapshot(); } - private _interceptCSSOM(obj: any, method: string) { - const self = this; + private _interceptNative(obj: any, method: string, cb: (thisObj: any, result: any) => void) { const native = obj[method] as Function; if (!native) return; obj[method] = function(...args: any[]) { - self._needStyleOverrides = true; - native.call(this, ...args); + const result = native.call(this, ...args); + cb(this, result); + return result; }; } + private _invalidateCache(node: Node | null) { + while (node) { + ensureCachedData(node).ref = undefined; + if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (node as ShadowRoot).host) + node = (node as ShadowRoot).host; + else + node = node.parentNode; + } + } + + private _handleMutations(list: MutationRecord[]) { + for (const mutation of list) + this._invalidateCache(mutation.target); + } + markIframe(iframeElement: HTMLIFrameElement | HTMLFrameElement, frameId: string) { - iframeElement.setAttribute(kSnapshotFrameIdAttribute, frameId); + (iframeElement as any)[kSnapshotFrameId] = frameId; } forceSnapshot(snapshotId: string) { @@ -75,19 +133,14 @@ export function frameSnapshotStreamer() { clearTimeout(this._timer); this._timer = undefined; } - const snapshot = this._captureSnapshot(snapshotId); - (window as any)[kSnapshotBinding](snapshot).catch((e: any) => {}); + try { + const snapshot = this._captureSnapshot(snapshotId); + (window as any)[kSnapshotBinding](snapshot).catch((e: any) => {}); + } catch (e) { + } this._timer = setTimeout(() => this._streamSnapshot(), 100); } - private _escapeAttribute(s: string): string { - return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); - } - - private _escapeText(s: string): string { - return s.replace(/[&<]/ug, char => (escaped as any)[char]); - } - private _sanitizeUrl(url: string): string { if (url.startsWith('javascript:')) return ''; @@ -131,10 +184,19 @@ export function frameSnapshotStreamer() { } private _captureSnapshot(snapshotId?: string): SnapshotData { + const snapshotNumber = ++this._lastSnapshotNumber; const win = window; const doc = win.document; - let needScript = false; + // Ensure we are up-to-date. + this._handleMutations(this._observer.takeRecords()); + for (const input of doc.querySelectorAll('input, textarea')) { + const value = (input as HTMLInputElement | HTMLTextAreaElement).value; + const data = ensureCachedData(input); + if (data.value !== value) + this._invalidateCache(input); + } + const styleNodeToStyleSheetText = new Map(); const styleSheetUrlToContentOverride = new Map(); @@ -164,57 +226,52 @@ export function frameSnapshotStreamer() { } }; - const visit = (node: Node | ShadowRoot, builder: string[]) => { - const nodeName = node.nodeName; - const nodeType = node.nodeType; + let nodeCounter = 0; - if (nodeType === Node.DOCUMENT_TYPE_NODE) { - const docType = node as DocumentType; - builder.push(``); - return; - } - - if (nodeType === Node.TEXT_NODE) { - builder.push(this._escapeText(node.nodeValue || '')); - return; - } + const visit = (node: Node | ShadowRoot): NodeSnapshot | undefined => { + const nodeType = node.nodeType; + const nodeName = nodeType === Node.DOCUMENT_FRAGMENT_NODE ? 'template' : node.nodeName; if (nodeType !== Node.ELEMENT_NODE && - nodeType !== Node.DOCUMENT_NODE && - nodeType !== Node.DOCUMENT_FRAGMENT_NODE) + nodeType !== Node.DOCUMENT_FRAGMENT_NODE && + nodeType !== Node.TEXT_NODE) return; - - if (nodeType === Node.DOCUMENT_NODE || nodeType === Node.DOCUMENT_FRAGMENT_NODE) { - const documentOrShadowRoot = node as DocumentOrShadowRoot; - for (const sheet of documentOrShadowRoot.styleSheets) - visitStyleSheet(sheet); - } - if (nodeName === 'SCRIPT' || nodeName === 'BASE') return; - if (this._removeNoScript && nodeName === 'NOSCRIPT') return; + const data = ensureCachedData(node); + if (data.ref) + return [[ snapshotNumber - data.ref[0], data.ref[1] ]]; + nodeCounter++; + data.ref = [snapshotNumber, nodeCounter - 1]; + // ---------- No returns without the data after this point ----------- + // ---------- Otherwise nodeCounter is wrong ----------- + + if (nodeType === Node.TEXT_NODE) + return escapeText(node.nodeValue || ''); + if (nodeName === 'STYLE') { const cssText = styleNodeToStyleSheetText.get(node) || node.textContent || ''; - builder.push(''); - return; + return ['style', {}, escapeText(cssText)]; + } + + const attrs: { [attr: string]: string } = {}; + const result: NodeSnapshot = [nodeName, attrs]; + + if (nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + for (const sheet of (node as ShadowRoot).styleSheets) + visitStyleSheet(sheet); + attrs[kShadowAttribute] = 'open'; } if (nodeType === Node.ELEMENT_NODE) { const element = node as Element; - builder.push('<'); - builder.push(nodeName); // if (node === target) - // builder.push(' __playwright_target__="true"'); + // attrs[' __playwright_target__] = ''; for (let i = 0; i < element.attributes.length; i++) { const name = element.attributes[i].name; - if (name === kSnapshotFrameIdAttribute) - continue; - let value = element.attributes[i].value; if (name === 'value' && (nodeName === 'INPUT' || nodeName === 'TEXTAREA')) continue; @@ -224,13 +281,8 @@ export function frameSnapshotStreamer() { continue; if (name === 'src' && (nodeName === 'IFRAME' || nodeName === 'FRAME')) { // TODO: handle srcdoc? - const frameId = element.getAttribute(kSnapshotFrameIdAttribute); - if (frameId) { - needScript = true; - value = frameId; - } else { - value = 'data:text/html,Snapshot is not available'; - } + const frameId = (element as any)[kSnapshotFrameId]; + value = frameId || 'data:text/html,Snapshot is not available'; } else if (name === 'src' && (nodeName === 'IMG')) { value = this._sanitizeUrl(value); } else if (name === 'srcset' && (nodeName === 'IMG')) { @@ -242,137 +294,68 @@ export function frameSnapshotStreamer() { } else if (name.startsWith('on')) { value = ''; } - builder.push(' '); - builder.push(name); - builder.push('="'); - builder.push(this._escapeAttribute(value)); - builder.push('"'); + attrs[name] = escapeAttribute(value); } if (nodeName === 'INPUT') { - builder.push(' value="'); - builder.push(this._escapeAttribute((element as HTMLInputElement).value)); - builder.push('"'); + const value = (element as HTMLInputElement).value; + data.value = value; + attrs['value'] = escapeAttribute(value); } if ((element as any).checked) - builder.push(' checked'); + attrs['checked'] = ''; if ((element as any).disabled) - builder.push(' disabled'); + attrs['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('>'); + attrs['readonly'] = ''; + if (element.scrollTop) + attrs[kScrollTopAttribute] = '' + element.scrollTop; + if (element.scrollLeft) + attrs[kScrollLeftAttribute] = '' + element.scrollLeft; if (element.shadowRoot) { - needScript = true; - const b: string[] = []; - visit(element.shadowRoot, b); - builder.push(''); + const child = visit(element.shadowRoot); + if (child) + result.push(child); } } + if (nodeName === 'HEAD') { - let baseHref = document.baseURI; - let baseTarget: string | undefined; + const base: NodeSnapshot = ['base', { 'href': document.baseURI }]; for (let child = node.firstChild; child; child = child.nextSibling) { if (child.nodeName === 'BASE') { - baseHref = (child as HTMLBaseElement).href; - baseTarget = (child as HTMLBaseElement).target; + base[1]['href'] = escapeAttribute((child as HTMLBaseElement).href); + base[1]['target'] = escapeAttribute((child as HTMLBaseElement).target); } } - builder.push(''); + nodeCounter++; // Compensate for the extra 'base' node in the list. + result.push(base); } + if (nodeName === 'TEXTAREA') { - builder.push(this._escapeText((node as HTMLTextAreaElement).value)); + nodeCounter++; // Compensate for the extra text node in the list. + const value = (node as HTMLTextAreaElement).value; + data.value = value; + result.push(escapeText(value)); } else { - for (let child = node.firstChild; child; child = child.nextSibling) - visit(child, builder); - } - if (node.nodeName === 'BODY' && needScript) { - builder.push(''); - } - if (nodeType === Node.ELEMENT_NODE && !autoClosing.has(nodeName)) { - builder.push(''); + for (let child = node.firstChild; child; child = child.nextSibling) { + const snapshotted = visit(child); + if (snapshotted) + result.push(snapshotted); + } } - }; - function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string) { - const scrollTops: Element[] = []; - const scrollLefts: Element[] = []; - - const visit = (root: Document | ShadowRoot) => { - for (const e of root.querySelectorAll(`[${scrollTopAttribute}]`)) - scrollTops.push(e); - for (const e of root.querySelectorAll(`[${scrollLeftAttribute}]`)) - scrollLefts.push(e); - - for (const iframe of root.querySelectorAll('iframe')) { - const src = iframe.getAttribute('src') || ''; - if (src.startsWith('data:text/html')) - continue; - const index = location.pathname.lastIndexOf('/'); - if (index === -1) - continue; - const pathname = location.pathname.substring(0, index + 1) + src; - const href = location.href.substring(0, location.href.indexOf(location.pathname)) + pathname; - iframe.setAttribute('src', href); - } + if (result.length === 2 && !Object.keys(attrs).length) + result.pop(); // Remove empty attrs when there are no children. + return result; + }; - for (const element of root.querySelectorAll(`template[${shadowAttribute}]`)) { - const template = element as HTMLTemplateElement; - const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' }); - shadowRoot.appendChild(template.content); - template.remove(); - visit(shadowRoot); - } - }; - visit(document); - - for (const element of scrollTops) - element.scrollTop = +element.getAttribute(scrollTopAttribute)!; - for (const element of scrollLefts) - element.scrollLeft = +element.getAttribute(scrollLeftAttribute)!; - - 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); - } - }; - window.addEventListener('load', onLoad); - } + for (const sheet of doc.styleSheets) + visitStyleSheet(sheet); + const html = doc.documentElement ? visit(doc.documentElement)! : (['html', {}] as NodeSnapshot); - const root: string[] = []; - visit(doc, root); return { - html: root.join(''), + html, + doctype: doc.doctype ? doc.doctype.name : undefined, resourceOverrides: Array.from(styleSheetUrlToContentOverride).map(([url, content]) => ({ url, content })), viewport: { width: Math.max(doc.body ? doc.body.offsetWidth : 0, doc.documentElement ? doc.documentElement.offsetWidth : 0), @@ -386,3 +369,59 @@ export function frameSnapshotStreamer() { (window as any)[kSnapshotStreamer] = new Streamer(); } + +export function snapshotScript() { + function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string) { + const scrollTops: Element[] = []; + const scrollLefts: Element[] = []; + + const visit = (root: Document | ShadowRoot) => { + // Collect all scrolled elements for later use. + for (const e of root.querySelectorAll(`[${scrollTopAttribute}]`)) + scrollTops.push(e); + for (const e of root.querySelectorAll(`[${scrollLeftAttribute}]`)) + scrollLefts.push(e); + + for (const iframe of root.querySelectorAll('iframe')) { + const src = iframe.getAttribute('src') || ''; + if (src.startsWith('data:text/html')) + continue; + // Rewrite iframes to use snapshot url (relative to window.location) + // instead of begin relative to the tag. + const index = location.pathname.lastIndexOf('/'); + if (index === -1) + continue; + const pathname = location.pathname.substring(0, index + 1) + src; + const href = location.href.substring(0, location.href.indexOf(location.pathname)) + pathname; + iframe.setAttribute('src', href); + } + + for (const element of root.querySelectorAll(`template[${shadowAttribute}]`)) { + const template = element as HTMLTemplateElement; + const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' }); + shadowRoot.appendChild(template.content); + template.remove(); + visit(shadowRoot); + } + }; + visit(document); + + 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); + } + }; + window.addEventListener('load', onLoad); + } + + const kShadowAttribute = '__playwright_shadow_root_'; + const kScrollTopAttribute = '__playwright_scroll_top_'; + const kScrollLeftAttribute = '__playwright_scroll_left_'; + return `\n(${applyPlaywrightAttributes.toString()})('${kShadowAttribute}', '${kScrollTopAttribute}', '${kScrollLeftAttribute}')`; +} diff --git a/src/trace/traceTypes.ts b/src/trace/traceTypes.ts index 5bd46a5d1d9eb..1f484477e2d54 100644 --- a/src/trace/traceTypes.ts +++ b/src/trace/traceTypes.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +import { NodeSnapshot } from './snapshotterInjected'; +export { NodeSnapshot } from './snapshotterInjected'; + export type ContextCreatedTraceEvent = { timestamp: number, type: 'context-created', @@ -23,6 +26,7 @@ export type ContextCreatedTraceEvent = { isMobile: boolean, viewportSize?: { width: number, height: number }, debugName?: string, + snapshotScript: string, }; export type ContextDestroyedTraceEvent = { @@ -145,9 +149,9 @@ export type TraceEvent = LoadEvent | FrameSnapshotTraceEvent; - export type FrameSnapshot = { - html: string, + doctype?: string, + html: NodeSnapshot, resourceOverrides: { url: string, sha1: string }[], viewport: { width: number, height: number }, }; diff --git a/src/trace/tracer.ts b/src/trace/tracer.ts index e72db046446c3..8483834e1f0ad 100644 --- a/src/trace/tracer.ts +++ b/src/trace/tracer.ts @@ -27,6 +27,7 @@ import { helper, RegisteredListener } from '../server/helper'; import { ProgressResult } from '../server/progress'; import { Dialog } from '../server/dialog'; import { Frame, NavigationEvent } from '../server/frames'; +import { snapshotScript } from './snapshotterInjected'; const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs)); @@ -98,6 +99,7 @@ class ContextTracer implements SnapshotterDelegate, ActionListener { deviceScaleFactor: context._options.deviceScaleFactor || 1, viewportSize: context._options.viewport || undefined, debugName: context._options._debugName, + snapshotScript: snapshotScript(), }; this._appendTraceEvent(event); this._snapshotter = new Snapshotter(context, this);