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 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[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> {
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<string, trace.FrameSnapshotTraceEvent>();
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 += `<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;
const resourcesWithUrl = contextEntry.resourcesByUrl.get(removeHash(request.url)) || [];

View file

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

View file

@ -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 = { '&': '&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 {
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<Node, string>();
const styleSheetUrlToContentOverride = new Map<string, string>();
@ -164,57 +226,52 @@ export function frameSnapshotStreamer() {
}
};
const visit = (node: Node | ShadowRoot, builder: string[]) => {
const nodeName = node.nodeName;
let nodeCounter = 0;
const visit = (node: Node | ShadowRoot): NodeSnapshot | undefined => {
const nodeType = node.nodeType;
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;
}
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('<style>');
builder.push(cssText);
builder.push('</style>');
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,<body>Snapshot is not available</body>';
}
const frameId = (element as any)[kSnapshotFrameId];
value = frameId || 'data:text/html,<body>Snapshot is not available</body>';
} 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('<template ');
builder.push(kShadowAttribute);
builder.push('="open">');
builder.push(b.join(''));
builder.push('</template>');
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('<base href="');
builder.push(this._escapeAttribute(baseHref));
builder.push('"');
if (baseTarget) {
builder.push(' target="');
builder.push(this._escapeAttribute(baseTarget));
builder.push('"');
}
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('<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('>');
for (let child = node.firstChild; child; child = child.nextSibling) {
const snapshotted = visit(child);
if (snapshotted)
result.push(snapshotted);
}
}
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) {
const scrollTops: Element[] = [];
const scrollLefts: Element[] = [];
for (const sheet of doc.styleSheets)
visitStyleSheet(sheet);
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 {
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 <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.
*/
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 },
};

View file

@ -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);