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:
parent
21041bc331
commit
69ca30834e
|
|
@ -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)) || [];
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const emptyModel: TraceModel = {
|
|||
deviceScaleFactor: 1,
|
||||
isMobile: false,
|
||||
viewportSize: { width: 800, height: 600 },
|
||||
snapshotScript: '',
|
||||
},
|
||||
destroyed: {
|
||||
timestamp: Date.now(),
|
||||
|
|
|
|||
|
|
@ -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<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}')`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue