feat(snapshots): incremental snapshots (#5213)

- Switch from html to json ml format.
- Allow node reuse between snapshots with `[nSnapshotsBefore, nodeWithIndexM]`.
- Service worker now lazily serializes snapshot chunks into a single html.

This decreases total snapshot size on random scripts ~10x.
This also decreases snapshot collecting time on mostly static pages to ~0.3ms.

Unfortunate downside for now is that we have to intercept
`Element.prototype.attachShadow` to invalidate nodes. This
also temporary breaks scroll restoration. Needs more research.
This commit is contained in:
Dmitry Gozman 2021-01-29 06:57:57 -08:00 committed by GitHub
parent 21041bc331
commit 69ca30834e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 303 additions and 190 deletions

View file

@ -19,6 +19,7 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import type { TraceModel, trace } from './traceModel'; import type { TraceModel, trace } from './traceModel';
import { TraceServer } from './traceServer'; import { TraceServer } from './traceServer';
import { NodeSnapshot } from '../../trace/traceTypes';
export class SnapshotServer { export class SnapshotServer {
private _resourcesDir: string | undefined; 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[0], '>');
(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 = `<!DOCTYPE ${snapshot.doctype}>` + html;
return html;
}
async function doFetch(event: any /* FetchEvent */): Promise<Response> { async function doFetch(event: any /* FetchEvent */): Promise<Response> {
try { try {
const pathname = new URL(event.request.url).pathname; const pathname = new URL(event.request.url).pathname;
@ -215,26 +279,29 @@ export class SnapshotServer {
if (!contextEntry || !pageEntry) if (!contextEntry || !pageEntry)
return request.mode === 'navigate' ? respondNotAvailable() : respond404(); return request.mode === 'navigate' ? respondNotAvailable() : respond404();
const lastSnapshotEvent = new Map<string, trace.FrameSnapshotTraceEvent>(); const frameSnapshots = pageEntry.snapshotsByFrameId[parsed.frameId] || [];
for (const [frameId, snapshots] of Object.entries(pageEntry.snapshotsByFrameId)) { let snapshotIndex = -1;
for (const snapshot of snapshots) { for (let index = 0; index < frameSnapshots.length; index++) {
const current = lastSnapshotEvent.get(frameId); const current = snapshotIndex === -1 ? undefined : frameSnapshots[snapshotIndex];
// Prefer snapshot with exact id. const snapshot = frameSnapshots[index];
const exactMatch = parsed.snapshotId && snapshot.snapshotId === parsed.snapshotId; // Prefer snapshot with exact id.
const currentExactMatch = current && parsed.snapshotId && current.snapshotId === parsed.snapshotId; const exactMatch = parsed.snapshotId && snapshot.snapshotId === parsed.snapshotId;
// If not available, prefer the latest snapshot before the timestamp. const currentExactMatch = current && parsed.snapshotId && current.snapshotId === parsed.snapshotId;
const timestampMatch = parsed.timestamp && snapshot.timestamp <= parsed.timestamp; // If not available, prefer the latest snapshot before the timestamp.
if (exactMatch || (timestampMatch && !currentExactMatch)) const timestampMatch = parsed.timestamp && snapshot.timestamp <= parsed.timestamp;
lastSnapshotEvent.set(frameId, snapshot); if (exactMatch || (timestampMatch && !currentExactMatch))
} snapshotIndex = index;
} }
const snapshotEvent = snapshotIndex === -1 ? undefined : frameSnapshots[snapshotIndex];
const snapshotEvent = lastSnapshotEvent.get(parsed.frameId);
if (!snapshotEvent) if (!snapshotEvent)
return request.mode === 'navigate' ? respondNotAvailable() : respond404(); return request.mode === 'navigate' ? respondNotAvailable() : respond404();
if (request.mode === 'navigate') if (request.mode === 'navigate') {
return new Response(snapshotEvent.snapshot.html, { status: 200, headers: { 'Content-Type': 'text/html' } }); let html = serializeSnapshot(frameSnapshots, snapshotIndex);
html += `<script>${contextEntry.created.snapshotScript}</script>`;
const response = new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } });
return response;
}
let resource: trace.NetworkResourceTraceEvent | null = null; let resource: trace.NetworkResourceTraceEvent | null = null;
const resourcesWithUrl = contextEntry.resourcesByUrl.get(removeHash(request.url)) || []; const resourcesWithUrl = contextEntry.resourcesByUrl.get(removeHash(request.url)) || [];

View file

@ -44,6 +44,7 @@ const emptyModel: TraceModel = {
deviceScaleFactor: 1, deviceScaleFactor: 1,
isMobile: false, isMobile: false,
viewportSize: { width: 800, height: 600 }, viewportSize: { width: 800, height: 600 },
snapshotScript: '',
}, },
destroyed: { destroyed: {
timestamp: Date.now(), timestamp: Date.now(),

View file

@ -14,8 +14,21 @@
* limitations under the License. * 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 = { export type SnapshotData = {
html: string, doctype?: string,
html: NodeSnapshot,
resourceOverrides: { url: string, content: string }[], resourceOverrides: { url: string, content: string }[],
viewport: { width: number, height: number }, viewport: { width: number, height: number },
url: string, url: string,
@ -23,47 +36,92 @@ export type SnapshotData = {
}; };
export const kSnapshotStreamer = '__playwright_snapshot_streamer_'; export const kSnapshotStreamer = '__playwright_snapshot_streamer_';
export const kSnapshotFrameIdAttribute = '__playwright_snapshot_frameid_';
export const kSnapshotBinding = '__playwright_snapshot_binding_'; export const kSnapshotBinding = '__playwright_snapshot_binding_';
export function frameSnapshotStreamer() { export function frameSnapshotStreamer() {
// Communication with Playwright.
const kSnapshotStreamer = '__playwright_snapshot_streamer_'; const kSnapshotStreamer = '__playwright_snapshot_streamer_';
const kSnapshotFrameIdAttribute = '__playwright_snapshot_frameid_';
const kSnapshotBinding = '__playwright_snapshot_binding_'; const kSnapshotBinding = '__playwright_snapshot_binding_';
// Attributes present in the snapshot.
const kShadowAttribute = '__playwright_shadow_root_'; const kShadowAttribute = '__playwright_shadow_root_';
const kScrollTopAttribute = '__playwright_scroll_top_'; const kScrollTopAttribute = '__playwright_scroll_top_';
const kScrollLeftAttribute = '__playwright_scroll_left_'; 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' }; 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']); 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 { class Streamer {
private _removeNoScript = true; private _removeNoScript = true;
private _needStyleOverrides = false; private _needStyleOverrides = false;
private _timer: NodeJS.Timeout | undefined; private _timer: NodeJS.Timeout | undefined;
private _lastSnapshotNumber = 0;
private _observer: MutationObserver;
constructor() { 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? // 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(); this._streamSnapshot();
} }
private _interceptCSSOM(obj: any, method: string) { private _interceptNative(obj: any, method: string, cb: (thisObj: any, result: any) => void) {
const self = this;
const native = obj[method] as Function; const native = obj[method] as Function;
if (!native) if (!native)
return; return;
obj[method] = function(...args: any[]) { obj[method] = function(...args: any[]) {
self._needStyleOverrides = true; const result = native.call(this, ...args);
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) { markIframe(iframeElement: HTMLIFrameElement | HTMLFrameElement, frameId: string) {
iframeElement.setAttribute(kSnapshotFrameIdAttribute, frameId); (iframeElement as any)[kSnapshotFrameId] = frameId;
} }
forceSnapshot(snapshotId: string) { forceSnapshot(snapshotId: string) {
@ -75,19 +133,14 @@ export function frameSnapshotStreamer() {
clearTimeout(this._timer); clearTimeout(this._timer);
this._timer = undefined; this._timer = undefined;
} }
const snapshot = this._captureSnapshot(snapshotId); try {
(window as any)[kSnapshotBinding](snapshot).catch((e: any) => {}); const snapshot = this._captureSnapshot(snapshotId);
(window as any)[kSnapshotBinding](snapshot).catch((e: any) => {});
} catch (e) {
}
this._timer = setTimeout(() => this._streamSnapshot(), 100); 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 { private _sanitizeUrl(url: string): string {
if (url.startsWith('javascript:')) if (url.startsWith('javascript:'))
return ''; return '';
@ -131,10 +184,19 @@ export function frameSnapshotStreamer() {
} }
private _captureSnapshot(snapshotId?: string): SnapshotData { private _captureSnapshot(snapshotId?: string): SnapshotData {
const snapshotNumber = ++this._lastSnapshotNumber;
const win = window; const win = window;
const doc = win.document; 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<Node, string>(); const styleNodeToStyleSheetText = new Map<Node, string>();
const styleSheetUrlToContentOverride = new Map<string, string>(); const styleSheetUrlToContentOverride = new Map<string, string>();
@ -164,57 +226,52 @@ export function frameSnapshotStreamer() {
} }
}; };
const visit = (node: Node | ShadowRoot, builder: string[]) => { let nodeCounter = 0;
const nodeName = node.nodeName;
const visit = (node: Node | ShadowRoot): NodeSnapshot | undefined => {
const nodeType = node.nodeType; const nodeType = node.nodeType;
const nodeName = nodeType === Node.DOCUMENT_FRAGMENT_NODE ? 'template' : node.nodeName;
if (nodeType === Node.DOCUMENT_TYPE_NODE) {
const docType = node as DocumentType;
builder.push(`<!DOCTYPE ${docType.name}>`);
return;
}
if (nodeType === Node.TEXT_NODE) {
builder.push(this._escapeText(node.nodeValue || ''));
return;
}
if (nodeType !== Node.ELEMENT_NODE && if (nodeType !== Node.ELEMENT_NODE &&
nodeType !== Node.DOCUMENT_NODE && nodeType !== Node.DOCUMENT_FRAGMENT_NODE &&
nodeType !== Node.DOCUMENT_FRAGMENT_NODE) nodeType !== Node.TEXT_NODE)
return; 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') if (nodeName === 'SCRIPT' || nodeName === 'BASE')
return; return;
if (this._removeNoScript && nodeName === 'NOSCRIPT') if (this._removeNoScript && nodeName === 'NOSCRIPT')
return; 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') { if (nodeName === 'STYLE') {
const cssText = styleNodeToStyleSheetText.get(node) || node.textContent || ''; const cssText = styleNodeToStyleSheetText.get(node) || node.textContent || '';
builder.push('<style>'); return ['style', {}, escapeText(cssText)];
builder.push(cssText); }
builder.push('</style>');
return; 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) { if (nodeType === Node.ELEMENT_NODE) {
const element = node as Element; const element = node as Element;
builder.push('<');
builder.push(nodeName);
// if (node === target) // if (node === target)
// builder.push(' __playwright_target__="true"'); // attrs[' __playwright_target__] = '';
for (let i = 0; i < element.attributes.length; i++) { for (let i = 0; i < element.attributes.length; i++) {
const name = element.attributes[i].name; const name = element.attributes[i].name;
if (name === kSnapshotFrameIdAttribute)
continue;
let value = element.attributes[i].value; let value = element.attributes[i].value;
if (name === 'value' && (nodeName === 'INPUT' || nodeName === 'TEXTAREA')) if (name === 'value' && (nodeName === 'INPUT' || nodeName === 'TEXTAREA'))
continue; continue;
@ -224,13 +281,8 @@ export function frameSnapshotStreamer() {
continue; continue;
if (name === 'src' && (nodeName === 'IFRAME' || nodeName === 'FRAME')) { if (name === 'src' && (nodeName === 'IFRAME' || nodeName === 'FRAME')) {
// TODO: handle srcdoc? // TODO: handle srcdoc?
const frameId = element.getAttribute(kSnapshotFrameIdAttribute); const frameId = (element as any)[kSnapshotFrameId];
if (frameId) { value = frameId || 'data:text/html,<body>Snapshot is not available</body>';
needScript = true;
value = frameId;
} else {
value = 'data:text/html,<body>Snapshot is not available</body>';
}
} else if (name === 'src' && (nodeName === 'IMG')) { } else if (name === 'src' && (nodeName === 'IMG')) {
value = this._sanitizeUrl(value); value = this._sanitizeUrl(value);
} else if (name === 'srcset' && (nodeName === 'IMG')) { } else if (name === 'srcset' && (nodeName === 'IMG')) {
@ -242,137 +294,68 @@ export function frameSnapshotStreamer() {
} else if (name.startsWith('on')) { } else if (name.startsWith('on')) {
value = ''; value = '';
} }
builder.push(' '); attrs[name] = escapeAttribute(value);
builder.push(name);
builder.push('="');
builder.push(this._escapeAttribute(value));
builder.push('"');
} }
if (nodeName === 'INPUT') { if (nodeName === 'INPUT') {
builder.push(' value="'); const value = (element as HTMLInputElement).value;
builder.push(this._escapeAttribute((element as HTMLInputElement).value)); data.value = value;
builder.push('"'); attrs['value'] = escapeAttribute(value);
} }
if ((element as any).checked) if ((element as any).checked)
builder.push(' checked'); attrs['checked'] = '';
if ((element as any).disabled) if ((element as any).disabled)
builder.push(' disabled'); attrs['disabled'] = '';
if ((element as any).readOnly) if ((element as any).readOnly)
builder.push(' readonly'); attrs['readonly'] = '';
if (element.scrollTop) { if (element.scrollTop)
needScript = true; attrs[kScrollTopAttribute] = '' + element.scrollTop;
builder.push(` ${kScrollTopAttribute}="${element.scrollTop}"`); if (element.scrollLeft)
} attrs[kScrollLeftAttribute] = '' + element.scrollLeft;
if (element.scrollLeft) {
needScript = true;
builder.push(` ${kScrollLeftAttribute}="${element.scrollLeft}"`);
}
builder.push('>');
if (element.shadowRoot) { if (element.shadowRoot) {
needScript = true; const child = visit(element.shadowRoot);
const b: string[] = []; if (child)
visit(element.shadowRoot, b); result.push(child);
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; const base: NodeSnapshot = ['base', { 'href': document.baseURI }];
let baseTarget: string | undefined;
for (let child = node.firstChild; child; child = child.nextSibling) { for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.nodeName === 'BASE') { if (child.nodeName === 'BASE') {
baseHref = (child as HTMLBaseElement).href; base[1]['href'] = escapeAttribute((child as HTMLBaseElement).href);
baseTarget = (child as HTMLBaseElement).target; base[1]['target'] = escapeAttribute((child as HTMLBaseElement).target);
} }
} }
builder.push('<base href="'); nodeCounter++; // Compensate for the extra 'base' node in the list.
builder.push(this._escapeAttribute(baseHref)); result.push(base);
builder.push('"');
if (baseTarget) {
builder.push(' target="');
builder.push(this._escapeAttribute(baseTarget));
builder.push('"');
}
builder.push('>');
} }
if (nodeName === 'TEXTAREA') { 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 { } else {
for (let child = node.firstChild; child; child = child.nextSibling) for (let child = node.firstChild; child; child = child.nextSibling) {
visit(child, builder); const snapshotted = visit(child);
} if (snapshotted)
if (node.nodeName === 'BODY' && needScript) { result.push(snapshotted);
builder.push('<script>'); }
const scriptContent = `\n(${applyPlaywrightAttributes.toString()})('${kShadowAttribute}', '${kScrollTopAttribute}', '${kScrollLeftAttribute}')`;
builder.push(scriptContent);
builder.push('</script>');
}
if (nodeType === Node.ELEMENT_NODE && !autoClosing.has(nodeName)) {
builder.push('</');
builder.push(nodeName);
builder.push('>');
} }
if (result.length === 2 && !Object.keys(attrs).length)
result.pop(); // Remove empty attrs when there are no children.
return result;
}; };
function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string) { for (const sheet of doc.styleSheets)
const scrollTops: Element[] = []; visitStyleSheet(sheet);
const scrollLefts: Element[] = []; const html = doc.documentElement ? visit(doc.documentElement)! : (['html', {}] as NodeSnapshot);
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);
}
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);
}
const root: string[] = [];
visit(doc, root);
return { return {
html: root.join(''), html,
doctype: doc.doctype ? doc.doctype.name : undefined,
resourceOverrides: Array.from(styleSheetUrlToContentOverride).map(([url, content]) => ({ url, content })), resourceOverrides: Array.from(styleSheetUrlToContentOverride).map(([url, content]) => ({ url, content })),
viewport: { viewport: {
width: Math.max(doc.body ? doc.body.offsetWidth : 0, doc.documentElement ? doc.documentElement.offsetWidth : 0), 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(); (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 <base> 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}')`;
}

View file

@ -14,6 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import { NodeSnapshot } from './snapshotterInjected';
export { NodeSnapshot } from './snapshotterInjected';
export type ContextCreatedTraceEvent = { export type ContextCreatedTraceEvent = {
timestamp: number, timestamp: number,
type: 'context-created', type: 'context-created',
@ -23,6 +26,7 @@ export type ContextCreatedTraceEvent = {
isMobile: boolean, isMobile: boolean,
viewportSize?: { width: number, height: number }, viewportSize?: { width: number, height: number },
debugName?: string, debugName?: string,
snapshotScript: string,
}; };
export type ContextDestroyedTraceEvent = { export type ContextDestroyedTraceEvent = {
@ -145,9 +149,9 @@ export type TraceEvent =
LoadEvent | LoadEvent |
FrameSnapshotTraceEvent; FrameSnapshotTraceEvent;
export type FrameSnapshot = { export type FrameSnapshot = {
html: string, doctype?: string,
html: NodeSnapshot,
resourceOverrides: { url: string, sha1: string }[], resourceOverrides: { url: string, sha1: string }[],
viewport: { width: number, height: number }, viewport: { width: number, height: number },
}; };

View file

@ -27,6 +27,7 @@ import { helper, RegisteredListener } from '../server/helper';
import { ProgressResult } from '../server/progress'; import { ProgressResult } from '../server/progress';
import { Dialog } from '../server/dialog'; import { Dialog } from '../server/dialog';
import { Frame, NavigationEvent } from '../server/frames'; import { Frame, NavigationEvent } from '../server/frames';
import { snapshotScript } from './snapshotterInjected';
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs)); const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs));
@ -98,6 +99,7 @@ class ContextTracer implements SnapshotterDelegate, ActionListener {
deviceScaleFactor: context._options.deviceScaleFactor || 1, deviceScaleFactor: context._options.deviceScaleFactor || 1,
viewportSize: context._options.viewport || undefined, viewportSize: context._options.viewport || undefined,
debugName: context._options._debugName, debugName: context._options._debugName,
snapshotScript: snapshotScript(),
}; };
this._appendTraceEvent(event); this._appendTraceEvent(event);
this._snapshotter = new Snapshotter(context, this); this._snapshotter = new Snapshotter(context, this);