2021-02-24 14:22:34 -08:00
|
|
|
/**
|
|
|
|
* Copyright (c) Microsoft Corporation.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2022-03-25 13:12:00 -08:00
|
|
|
import type { FrameSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot } from '@playwright-core/server/trace/common/snapshotTypes';
|
2021-02-24 14:22:34 -08:00
|
|
|
|
2021-02-25 09:33:32 -08:00
|
|
|
export class SnapshotRenderer {
|
|
|
|
private _snapshots: FrameSnapshot[];
|
2021-02-24 14:22:34 -08:00
|
|
|
private _index: number;
|
2021-03-08 19:49:57 -08:00
|
|
|
readonly snapshotName: string | undefined;
|
2021-10-12 13:42:50 -08:00
|
|
|
_resources: ResourceSnapshot[];
|
2021-08-10 21:23:31 -07:00
|
|
|
private _snapshot: FrameSnapshot;
|
2021-02-24 14:22:34 -08:00
|
|
|
|
2021-08-10 12:08:19 -07:00
|
|
|
constructor(resources: ResourceSnapshot[], snapshots: FrameSnapshot[], index: number) {
|
|
|
|
this._resources = resources;
|
2021-02-25 09:33:32 -08:00
|
|
|
this._snapshots = snapshots;
|
2021-02-24 14:22:34 -08:00
|
|
|
this._index = index;
|
2021-08-10 21:23:31 -07:00
|
|
|
this._snapshot = snapshots[index];
|
2021-03-08 19:49:57 -08:00
|
|
|
this.snapshotName = snapshots[index].snapshotName;
|
|
|
|
}
|
|
|
|
|
|
|
|
snapshot(): FrameSnapshot {
|
|
|
|
return this._snapshots[this._index];
|
2021-02-24 14:22:34 -08:00
|
|
|
}
|
|
|
|
|
2021-08-10 17:06:14 -07:00
|
|
|
viewport(): { width: number, height: number } {
|
|
|
|
return this._snapshots[this._index].viewport;
|
|
|
|
}
|
|
|
|
|
2021-02-25 09:33:32 -08:00
|
|
|
render(): RenderedFrameSnapshot {
|
2022-04-05 15:10:12 -08:00
|
|
|
const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined): string => {
|
2021-02-24 14:22:34 -08:00
|
|
|
// Text node.
|
2022-04-05 15:10:12 -08:00
|
|
|
if (typeof n === 'string') {
|
|
|
|
const text = escapeText(n);
|
|
|
|
// Best-effort Electron support: rewrite custom protocol in url() links in stylesheets.
|
|
|
|
// Old snapshotter was sending lower-case.
|
|
|
|
if (parentTag === 'STYLE' || parentTag === 'style')
|
|
|
|
return rewriteURLsInStyleSheetForCustomProtocol(text);
|
|
|
|
return text;
|
|
|
|
}
|
2021-02-24 14:22:34 -08:00
|
|
|
|
|
|
|
if (!(n as any)._string) {
|
|
|
|
if (Array.isArray(n[0])) {
|
|
|
|
// Node reference.
|
|
|
|
const referenceIndex = snapshotIndex - n[0][0];
|
2021-09-16 09:37:38 -04:00
|
|
|
if (referenceIndex >= 0 && referenceIndex <= snapshotIndex) {
|
2021-02-25 09:33:32 -08:00
|
|
|
const nodes = snapshotNodes(this._snapshots[referenceIndex]);
|
2021-02-24 14:22:34 -08:00
|
|
|
const nodeIndex = n[0][1];
|
|
|
|
if (nodeIndex >= 0 && nodeIndex < nodes.length)
|
2022-04-05 15:10:12 -08:00
|
|
|
(n as any)._string = visit(nodes[nodeIndex], referenceIndex, parentTag);
|
2021-02-24 14:22:34 -08:00
|
|
|
}
|
|
|
|
} else if (typeof n[0] === 'string') {
|
|
|
|
// Element node.
|
|
|
|
const builder: string[] = [];
|
|
|
|
builder.push('<', n[0]);
|
2021-11-23 11:36:18 -08:00
|
|
|
// Never set relative URLs as <iframe src> - they start fetching frames immediately.
|
|
|
|
const isFrame = n[0] === 'IFRAME' || n[0] === 'FRAME';
|
|
|
|
for (const [attr, value] of Object.entries(n[1] || {})) {
|
2022-04-04 19:56:04 -08:00
|
|
|
const attrName = isFrame && attr.toLowerCase() === 'src' ? '__playwright_src__' : attr;
|
|
|
|
const attrValue = attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' ? rewriteURLForCustomProtocol(value) : value;
|
|
|
|
builder.push(' ', attrName, '="', escapeAttribute(attrValue as string), '"');
|
2021-11-23 11:36:18 -08:00
|
|
|
}
|
2021-02-24 14:22:34 -08:00
|
|
|
builder.push('>');
|
|
|
|
for (let i = 2; i < n.length; i++)
|
2022-04-05 15:10:12 -08:00
|
|
|
builder.push(visit(n[i], snapshotIndex, n[0]));
|
2021-02-24 14:22:34 -08:00
|
|
|
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;
|
|
|
|
};
|
|
|
|
|
2021-08-10 21:23:31 -07:00
|
|
|
const snapshot = this._snapshot;
|
2022-04-05 15:10:12 -08:00
|
|
|
let html = visit(snapshot.html, this._index, undefined);
|
2021-03-10 11:43:26 -08:00
|
|
|
if (!html)
|
2021-08-10 21:23:31 -07:00
|
|
|
return { html: '', pageId: snapshot.pageId, frameId: snapshot.frameId, index: this._index };
|
2021-03-10 11:43:26 -08:00
|
|
|
|
2021-11-08 18:03:10 -08:00
|
|
|
// Hide the document in order to prevent flickering. We will unhide once script has processed shadow.
|
2021-12-18 03:43:19 +09:00
|
|
|
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
|
|
|
|
html = prefix + [
|
|
|
|
'<style>*,*::before,*::after { visibility: hidden }</style>',
|
|
|
|
`<style>*[__playwright_target__="${this.snapshotName}"] { background-color: #6fa8dc7f; }</style>`,
|
|
|
|
`<script>${snapshotScript()}</script>`
|
|
|
|
].join('') + html;
|
2021-02-24 14:22:34 -08:00
|
|
|
|
2021-08-10 21:23:31 -07:00
|
|
|
return { html, pageId: snapshot.pageId, frameId: snapshot.frameId, index: this._index };
|
|
|
|
}
|
|
|
|
|
|
|
|
resourceByUrl(url: string): ResourceSnapshot | undefined {
|
|
|
|
const snapshot = this._snapshot;
|
|
|
|
let result: ResourceSnapshot | undefined;
|
|
|
|
|
|
|
|
// First try locating exact resource belonging to this frame.
|
2021-08-10 12:08:19 -07:00
|
|
|
for (const resource of this._resources) {
|
2021-08-24 13:17:58 -07:00
|
|
|
if (resource._monotonicTime >= snapshot.timestamp)
|
2021-08-10 12:08:19 -07:00
|
|
|
break;
|
2021-08-24 13:17:58 -07:00
|
|
|
if (resource._frameref !== snapshot.frameId)
|
2021-08-10 12:08:19 -07:00
|
|
|
continue;
|
2021-08-24 13:17:58 -07:00
|
|
|
if (resource.request.url === url) {
|
2021-08-10 21:23:31 -07:00
|
|
|
result = resource;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!result) {
|
|
|
|
// Then fall back to resource with this URL to account for memory cache.
|
|
|
|
for (const resource of this._resources) {
|
2021-08-24 13:17:58 -07:00
|
|
|
if (resource._monotonicTime >= snapshot.timestamp)
|
2021-08-10 21:23:31 -07:00
|
|
|
break;
|
2021-08-24 13:17:58 -07:00
|
|
|
if (resource.request.url === url)
|
2021-08-10 21:23:31 -07:00
|
|
|
return resource;
|
|
|
|
}
|
2021-02-24 18:38:04 -08:00
|
|
|
}
|
2021-08-10 21:23:31 -07:00
|
|
|
|
|
|
|
if (result) {
|
|
|
|
// Patch override if necessary.
|
|
|
|
for (const o of snapshot.resourceOverrides) {
|
|
|
|
if (url === o.url && o.sha1) {
|
2021-08-24 13:17:58 -07:00
|
|
|
result = {
|
|
|
|
...result,
|
|
|
|
response: {
|
|
|
|
...result.response,
|
|
|
|
content: {
|
|
|
|
...result.response.content,
|
|
|
|
_sha1: o.sha1,
|
|
|
|
}
|
|
|
|
},
|
|
|
|
};
|
2021-08-10 21:23:31 -07:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
2021-02-24 18:38:04 -08:00
|
|
|
}
|
2021-08-10 21:23:31 -07:00
|
|
|
|
|
|
|
return result;
|
2021-02-24 14:22:34 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
|
|
|
const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
|
|
|
|
|
|
|
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]);
|
|
|
|
}
|
|
|
|
|
2021-02-25 09:33:32 -08:00
|
|
|
function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
|
2021-02-24 14:22:34 -08:00
|
|
|
if (!(snapshot as any)._nodes) {
|
2021-02-25 09:33:32 -08:00
|
|
|
const nodes: NodeSnapshot[] = [];
|
|
|
|
const visit = (n: NodeSnapshot) => {
|
2021-02-24 14:22:34 -08:00
|
|
|
if (typeof n === 'string') {
|
|
|
|
nodes.push(n);
|
|
|
|
} else if (typeof n[0] === 'string') {
|
|
|
|
for (let i = 2; i < n.length; i++)
|
|
|
|
visit(n[i]);
|
|
|
|
nodes.push(n);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
visit(snapshot.html);
|
|
|
|
(snapshot as any)._nodes = nodes;
|
|
|
|
}
|
|
|
|
return (snapshot as any)._nodes;
|
|
|
|
}
|
2021-02-25 08:25:52 -08:00
|
|
|
|
2021-03-08 19:49:57 -08:00
|
|
|
function snapshotScript() {
|
2022-03-21 18:51:48 -07:00
|
|
|
function applyPlaywrightAttributes() {
|
2021-02-25 08:25:52 -08:00
|
|
|
const scrollTops: Element[] = [];
|
|
|
|
const scrollLefts: Element[] = [];
|
|
|
|
|
|
|
|
const visit = (root: Document | ShadowRoot) => {
|
|
|
|
// Collect all scrolled elements for later use.
|
2022-03-21 18:51:48 -07:00
|
|
|
for (const e of root.querySelectorAll(`[__playwright_scroll_top_]`))
|
2021-02-25 08:25:52 -08:00
|
|
|
scrollTops.push(e);
|
2022-03-21 18:51:48 -07:00
|
|
|
for (const e of root.querySelectorAll(`[__playwright_scroll_left_]`))
|
2021-02-25 08:25:52 -08:00
|
|
|
scrollLefts.push(e);
|
|
|
|
|
2022-03-21 18:51:48 -07:00
|
|
|
for (const element of root.querySelectorAll(`[__playwright_value_]`)) {
|
|
|
|
(element as HTMLInputElement | HTMLTextAreaElement).value = element.getAttribute('__playwright_value_')!;
|
|
|
|
element.removeAttribute('__playwright_value_');
|
|
|
|
}
|
|
|
|
for (const element of root.querySelectorAll(`[__playwright_checked_]`)) {
|
|
|
|
(element as HTMLInputElement).checked = element.getAttribute('__playwright_checked_') === 'true';
|
|
|
|
element.removeAttribute('__playwright_checked_');
|
|
|
|
}
|
|
|
|
for (const element of root.querySelectorAll(`[__playwright_selected_]`)) {
|
|
|
|
(element as HTMLOptionElement).selected = element.getAttribute('__playwright_selected_') === 'true';
|
|
|
|
element.removeAttribute('__playwright_selected_');
|
|
|
|
}
|
|
|
|
|
2021-12-18 03:43:19 +09:00
|
|
|
for (const iframe of root.querySelectorAll('iframe, frame')) {
|
2021-11-23 11:36:18 -08:00
|
|
|
const src = iframe.getAttribute('__playwright_src__');
|
2021-03-08 19:49:57 -08:00
|
|
|
if (!src) {
|
2021-03-11 11:22:59 -08:00
|
|
|
iframe.setAttribute('src', 'data:text/html,<body style="background: #ddd"></body>');
|
2021-03-08 19:49:57 -08:00
|
|
|
} else {
|
|
|
|
// Append query parameters to inherit ?name= or ?time= values from parent.
|
2021-11-23 11:36:18 -08:00
|
|
|
const url = new URL(window.location.href);
|
2021-10-19 14:36:17 -08:00
|
|
|
url.searchParams.delete('pointX');
|
|
|
|
url.searchParams.delete('pointY');
|
2021-11-23 11:36:18 -08:00
|
|
|
// We can be loading iframe from within iframe, reset base to be absolute.
|
|
|
|
const index = url.pathname.lastIndexOf('/snapshot/');
|
|
|
|
if (index !== -1)
|
|
|
|
url.pathname = url.pathname.substring(0, index + 1);
|
|
|
|
url.pathname += src.substring(1);
|
2021-10-19 14:36:17 -08:00
|
|
|
iframe.setAttribute('src', url.toString());
|
2021-03-08 19:49:57 -08:00
|
|
|
}
|
2021-02-25 08:25:52 -08:00
|
|
|
}
|
|
|
|
|
2022-03-21 18:51:48 -07:00
|
|
|
for (const element of root.querySelectorAll(`template[__playwright_shadow_root_]`)) {
|
2021-02-25 08:25:52 -08:00
|
|
|
const template = element as HTMLTemplateElement;
|
|
|
|
const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' });
|
|
|
|
shadowRoot.appendChild(template.content);
|
|
|
|
template.remove();
|
|
|
|
visit(shadowRoot);
|
|
|
|
}
|
2021-06-17 09:41:29 -07:00
|
|
|
|
|
|
|
if ('adoptedStyleSheets' in (root as any)) {
|
|
|
|
const adoptedSheets: CSSStyleSheet[] = [...(root as any).adoptedStyleSheets];
|
2022-03-21 18:51:48 -07:00
|
|
|
for (const element of root.querySelectorAll(`template[__playwright_style_sheet_]`)) {
|
2021-06-17 09:41:29 -07:00
|
|
|
const template = element as HTMLTemplateElement;
|
|
|
|
const sheet = new CSSStyleSheet();
|
2022-03-21 18:51:48 -07:00
|
|
|
(sheet as any).replaceSync(template.getAttribute('__playwright_style_sheet_'));
|
2021-06-17 09:41:29 -07:00
|
|
|
adoptedSheets.push(sheet);
|
|
|
|
}
|
|
|
|
(root as any).adoptedStyleSheets = adoptedSheets;
|
2021-06-23 11:08:35 +02:00
|
|
|
}
|
2021-02-25 08:25:52 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
const onLoad = () => {
|
|
|
|
window.removeEventListener('load', onLoad);
|
|
|
|
for (const element of scrollTops) {
|
2022-03-21 18:51:48 -07:00
|
|
|
element.scrollTop = +element.getAttribute('__playwright_scroll_top_')!;
|
|
|
|
element.removeAttribute('__playwright_scroll_top_');
|
2021-02-25 08:25:52 -08:00
|
|
|
}
|
|
|
|
for (const element of scrollLefts) {
|
2022-03-21 18:51:48 -07:00
|
|
|
element.scrollLeft = +element.getAttribute('__playwright_scroll_left_')!;
|
|
|
|
element.removeAttribute('__playwright_scroll_left_');
|
2021-02-25 08:25:52 -08:00
|
|
|
}
|
2021-10-12 20:21:06 -08:00
|
|
|
|
|
|
|
const search = new URL(window.location.href).searchParams;
|
|
|
|
const pointX = search.get('pointX');
|
|
|
|
const pointY = search.get('pointY');
|
|
|
|
if (pointX) {
|
|
|
|
const pointElement = document.createElement('x-pw-pointer');
|
|
|
|
pointElement.style.position = 'fixed';
|
|
|
|
pointElement.style.backgroundColor = 'red';
|
|
|
|
pointElement.style.width = '20px';
|
|
|
|
pointElement.style.height = '20px';
|
|
|
|
pointElement.style.borderRadius = '10px';
|
|
|
|
pointElement.style.margin = '-10px 0 0 -10px';
|
|
|
|
pointElement.style.zIndex = '2147483647';
|
|
|
|
pointElement.style.left = pointX + 'px';
|
|
|
|
pointElement.style.top = pointY + 'px';
|
|
|
|
document.documentElement.appendChild(pointElement);
|
|
|
|
}
|
2021-11-08 18:03:10 -08:00
|
|
|
document.styleSheets[0].disabled = true;
|
2021-02-25 08:25:52 -08:00
|
|
|
};
|
2021-12-18 03:43:19 +09:00
|
|
|
|
|
|
|
const onDOMContentLoaded = () => visit(document);
|
|
|
|
|
2021-02-25 08:25:52 -08:00
|
|
|
window.addEventListener('load', onLoad);
|
2021-12-18 03:43:19 +09:00
|
|
|
window.addEventListener('DOMContentLoaded', onDOMContentLoaded);
|
2021-02-25 08:25:52 -08:00
|
|
|
}
|
|
|
|
|
2022-03-21 18:51:48 -07:00
|
|
|
return `\n(${applyPlaywrightAttributes.toString()})()`;
|
2021-02-25 08:25:52 -08:00
|
|
|
}
|
2022-04-04 19:56:04 -08:00
|
|
|
|
|
|
|
|
2022-04-05 15:10:12 -08:00
|
|
|
/**
|
|
|
|
* Best-effort Electron support: rewrite custom protocol in DOM.
|
|
|
|
* vscode-file://vscode-app/ -> https://pw-vscode-file--vscode-app/
|
|
|
|
*/
|
|
|
|
const schemas = ['about:', 'blob:', 'data:', 'file:', 'ftp:', 'http:', 'https:', 'mailto:', 'sftp:', 'ws:', 'wss:' ];
|
2022-04-04 19:56:04 -08:00
|
|
|
const kLegacyBlobPrefix = 'http://playwright.bloburl/#';
|
|
|
|
|
|
|
|
export function rewriteURLForCustomProtocol(href: string): string {
|
|
|
|
// Legacy support, we used to prepend this to blobs, strip it away.
|
|
|
|
if (href.startsWith(kLegacyBlobPrefix))
|
|
|
|
href = href.substring(kLegacyBlobPrefix.length);
|
|
|
|
|
|
|
|
try {
|
|
|
|
const url = new URL(href);
|
|
|
|
// Sanitize URL.
|
2022-06-02 12:25:59 -07:00
|
|
|
if (url.protocol === 'javascript:' || url.protocol === 'vbscript:')
|
2022-04-04 19:56:04 -08:00
|
|
|
return 'javascript:void(0)';
|
|
|
|
|
|
|
|
// Pass through if possible.
|
|
|
|
const isBlob = url.protocol === 'blob:';
|
|
|
|
if (!isBlob && schemas.includes(url.protocol))
|
|
|
|
return href;
|
|
|
|
|
|
|
|
// Rewrite blob and custom schemas.
|
|
|
|
const prefix = 'pw-' + url.protocol.slice(0, url.protocol.length - 1);
|
|
|
|
url.protocol = 'https:';
|
2022-04-05 15:10:12 -08:00
|
|
|
url.hostname = url.hostname ? `${prefix}--${url.hostname}` : prefix;
|
2022-04-04 19:56:04 -08:00
|
|
|
return url.toString();
|
|
|
|
} catch {
|
|
|
|
return href;
|
|
|
|
}
|
|
|
|
}
|
2022-04-05 15:10:12 -08:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Best-effort Electron support: rewrite custom protocol in inline stylesheets.
|
|
|
|
* vscode-file://vscode-app/ -> https://pw-vscode-file--vscode-app/
|
|
|
|
*/
|
|
|
|
const urlInCSSRegex = /url\(['"]?([\w-]+:)\/\//ig;
|
|
|
|
|
|
|
|
function rewriteURLsInStyleSheetForCustomProtocol(text: string): string {
|
|
|
|
return text.replace(urlInCSSRegex, (match: string, protocol: string) => {
|
|
|
|
const isBlob = protocol === 'blob:';
|
|
|
|
if (!isBlob && schemas.includes(protocol))
|
|
|
|
return match;
|
|
|
|
return match.replace(protocol + '//', `https://pw-${protocol.slice(0, -1)}--`);
|
|
|
|
});
|
|
|
|
}
|