Files
AI/참고/playwright-main/packages/isomorphic/trace/snapshotRenderer.ts

684 lines
31 KiB
TypeScript
Raw Normal View History

2026-05-12 19:40:31 +09: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.
*/
import { escapeHTMLAttribute, escapeHTML } from '../stringUtils';
import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot';
import type { PageEntry } from './entries';
import type { LRUCache } from '../lruCache';
function findClosest<T>(items: T[], metric: (v: T) => number, target: number) {
return items.find((item, index) => {
if (index === items.length - 1)
return true;
const next = items[index + 1];
return Math.abs(metric(item) - target) < Math.abs(metric(next) - target);
});
}
function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot {
return Array.isArray(n) && typeof n[0] === 'string';
}
function isSubtreeReferenceSnapshot(n: NodeSnapshot): n is SubtreeReferenceSnapshot {
return Array.isArray(n) && Array.isArray(n[0]);
}
export class SnapshotRenderer {
private _htmlCache: LRUCache<SnapshotRenderer, string>;
private _snapshots: FrameSnapshot[];
private _index: number;
readonly snapshotName: string | undefined;
private _resources: ResourceSnapshot[];
private _snapshot: FrameSnapshot;
private _callId: string;
private _screencastFrames: PageEntry['screencastFrames'];
constructor(htmlCache: LRUCache<SnapshotRenderer, string>, resources: ResourceSnapshot[], snapshots: FrameSnapshot[], screencastFrames: PageEntry['screencastFrames'], index: number) {
this._htmlCache = htmlCache;
this._resources = resources;
this._snapshots = snapshots;
this._index = index;
this._snapshot = snapshots[index];
this._callId = snapshots[index].callId;
this._screencastFrames = screencastFrames;
this.snapshotName = snapshots[index].snapshotName;
}
snapshot(): FrameSnapshot {
return this._snapshots[this._index];
}
viewport(): { width: number, height: number } {
return this._snapshots[this._index].viewport;
}
closestScreenshot(): string | undefined {
const { wallTime, timestamp } = this.snapshot();
const closestFrame = (wallTime && this._screencastFrames[0]?.frameSwapWallTime)
? findClosest(this._screencastFrames, frame => frame.frameSwapWallTime!, wallTime)
: findClosest(this._screencastFrames, frame => frame.timestamp, timestamp);
return closestFrame?.sha1;
}
render(): RenderedFrameSnapshot {
const result: string[] = [];
const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined, parentAttrs: [string, string][] | undefined) => {
// Text node.
if (typeof n === 'string') {
// Best-effort Electron support: rewrite custom protocol in url() links in stylesheets.
// Old snapshotter was sending lower-case.
if (parentTag === 'STYLE' || parentTag === 'style')
result.push(escapeURLsInStyleSheet(rewriteURLsInStyleSheetForCustomProtocol(n)));
else
result.push(escapeHTML(n));
return;
}
if (isSubtreeReferenceSnapshot(n)) {
// Node reference.
const referenceIndex = snapshotIndex - n[0][0];
if (referenceIndex >= 0 && referenceIndex <= snapshotIndex) {
const nodes = snapshotNodes(this._snapshots[referenceIndex]);
const nodeIndex = n[0][1];
if (nodeIndex >= 0 && nodeIndex < nodes.length)
return visit(nodes[nodeIndex], referenceIndex, parentTag, parentAttrs);
}
} else if (isNodeNameAttributesChildNodesSnapshot(n)) {
const [name, nodeAttrs, ...children] = n;
// Filter SCRIPT elements. The capture side already strips these, but a
// crafted trace file could include them to achieve XSS.
if (name.toUpperCase() === 'SCRIPT')
return;
// Element node.
// Note that <noscript> will not be rendered by default in the trace viewer, because
// JS is enabled. So rename it to <x-noscript>.
const upperName = name.toUpperCase();
const nodeName = upperName === 'NOSCRIPT' ? 'X-NOSCRIPT' : name;
const attrs = Object.entries(nodeAttrs || {});
result.push('<', nodeName);
const kCurrentSrcAttribute = '__playwright_current_src__';
const isFrame = upperName === 'IFRAME' || upperName === 'FRAME';
const isAnchor = upperName === 'A';
const isImg = upperName === 'IMG';
const isMeta = upperName === 'META';
const isImgWithCurrentSrc = isImg && attrs.some(a => a[0] === kCurrentSrcAttribute);
const isSourceInsidePictureWithCurrentSrc = upperName === 'SOURCE' && parentTag === 'PICTURE' && parentAttrs?.some(a => a[0] === kCurrentSrcAttribute);
// For META, only allow a small whitelist of http-equiv directives so a malicious snapshot
// cannot navigate the snapshot iframe via e.g. <meta http-equiv="refresh"> or otherwise
// affect the trace viewer.
const hasUnsafeHttpEquiv = isMeta && attrs.some(a => a[0].toLowerCase() === 'http-equiv' && !kAllowedMetaHttpEquivs.has(a[1].trim().toLowerCase()));
for (const [attr, value] of attrs) {
let attrName = attr;
// Strip event handler attributes. The capture side already empties these,
// but a crafted trace file could include live handlers (e.g. onerror, onclick).
// escapeHTMLAttribute does not help because payloads like "alert(1)" contain
// no characters that need escaping.
if (attr.toLowerCase().startsWith('on'))
continue;
if (isFrame && attr.toLowerCase() === 'src') {
// Never set relative URLs as <iframe src> - they start fetching frames immediately.
attrName = '__playwright_src__';
}
if (isFrame && (attr.toLowerCase() === 'srcdoc' || attr.toLowerCase() === 'sandbox')) {
// Neutralize srcdoc (could contain arbitrary HTML/script that executes
// automatically) and sandbox (attacker-controlled values could alter
// iframe security policy). The capture side already skips these, but a
// crafted trace could include them.
attrName = '__playwright_' + attr.toLowerCase() + '__';
}
if (upperName === 'OBJECT' && attr.toLowerCase() === 'data')
attrName = '__playwright_data__';
if (upperName === 'EMBED' && attr.toLowerCase() === 'src')
attrName = '__playwright_src__';
if (isImg && attr === kCurrentSrcAttribute) {
// Render currentSrc for images, so that trace viewer does not accidentally
// resolve srcset to a different source.
attrName = 'src';
}
if (['src', 'srcset'].includes(attr.toLowerCase()) && (isImgWithCurrentSrc || isSourceInsidePictureWithCurrentSrc)) {
// Disable actual <img src>, <img srcset>, <source src> and <source srcset> if
// we will be using the currentSrc instead.
attrName = '_' + attrName;
}
if (hasUnsafeHttpEquiv && (attr.toLowerCase() === 'http-equiv' || attr.toLowerCase() === 'content')) {
// Neutralize the META directive by renaming the attribute so the browser ignores it.
attrName = '_' + attr;
}
let attrValue = value;
if (!isAnchor && (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute))
attrValue = rewriteURLForCustomProtocol(value);
result.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"');
}
result.push('>');
for (const child of children)
visit(child, snapshotIndex, nodeName, attrs);
if (!autoClosing.has(nodeName))
result.push('</', nodeName, '>');
return;
} else {
// Why are we here? Let's not throw, just in case.
return;
}
};
const snapshot = this._snapshot;
const html = this._htmlCache.getOrCompute(this, () => {
visit(snapshot.html, this._index, undefined, undefined);
// Sanitize doctype to prevent injection from crafted trace files.
// Valid doctype names (from document.doctype.name) only contain alphanumeric characters.
const safeDoctype = snapshot.doctype?.replace(/[^a-zA-Z0-9]/g, '');
const prefix = safeDoctype ? `<!DOCTYPE ${safeDoctype}>` : '';
const html = prefix + [
// Hide the document in order to prevent flickering. We will unhide once script has processed shadow.
'<style>*,*::before,*::after { visibility: hidden }</style>',
`<script>${snapshotScript(this.viewport(), this._callId, this.snapshotName)}</script>`
].join('') + result.join('');
return { value: html, size: html.length };
});
return { html, pageId: snapshot.pageId, frameId: snapshot.frameId, index: this._index };
}
resourceByUrl(url: string, method: string): ResourceSnapshot | undefined {
const snapshot = this._snapshot;
let sameFrameResource: ResourceSnapshot | undefined;
let otherFrameResource: ResourceSnapshot | undefined;
for (const resource of this._resources) {
// Only use resources that received response before the snapshot.
// Note that both snapshot time and request time are taken in the same Node process.
if (typeof resource._monotonicTime === 'number' && resource._monotonicTime >= snapshot.timestamp)
break;
if (resource.response.status === 304) {
// "Not Modified" responses are issued when browser requests the same resource
// multiple times, meanwhile indicating that it has the response cached.
//
// When rendering the snapshot, browser most likely will not have the resource cached,
// so we should respond with the real content instead, picking the last response that
// is not 304.
continue;
}
if (resource.request.url === url && resource.request.method === method) {
// Pick the last resource with matching url - most likely it was used
// at the time of snapshot, not the earlier aborted resource with the same url.
if (resource._frameref === snapshot.frameId)
sameFrameResource = resource;
else
otherFrameResource = resource;
}
}
// First try locating exact resource belonging to this frame,
// then fall back to resource with this URL to account for memory cache.
let result = sameFrameResource ?? otherFrameResource;
if (result && method.toUpperCase() === 'GET') {
// Patch override if necessary.
let override = snapshot.resourceOverrides.find(o => o.url === url);
if (override?.ref) {
// "ref" means use the same content as "ref" snapshots ago.
const index = this._index - override.ref;
if (index >= 0 && index < this._snapshots.length)
override = this._snapshots[index].resourceOverrides.find(o => o.url === url);
}
if (override?.sha1) {
result = {
...result,
response: {
...result.response,
content: {
...result.response.content,
_sha1: override.sha1,
}
},
};
}
}
return result;
}
}
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
// Whitelist of META http-equiv directives that are safe to render in the trace viewer.
// Notably excludes 'refresh' (auto-navigation), 'set-cookie' and 'content-security-policy'.
const kAllowedMetaHttpEquivs = new Set(['content-type', 'content-language', 'default-style', 'x-ua-compatible']);
function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
if (!(snapshot as any)._nodes) {
const nodes: NodeSnapshot[] = [];
const visit = (n: NodeSnapshot) => {
if (typeof n === 'string') {
nodes.push(n);
} else if (isNodeNameAttributesChildNodesSnapshot(n)) {
const [,, ...children] = n;
for (const child of children)
visit(child);
nodes.push(n);
}
};
visit(snapshot.html);
(snapshot as any)._nodes = nodes;
}
return (snapshot as any)._nodes;
}
type ViewportSize = { width: number, height: number };
type BoundingRect = { left: number, top: number, right: number, bottom: number };
type FrameBoundingRectsInfo = {
viewport: ViewportSize;
frames: WeakMap<Element, {
boundingRect: BoundingRect;
scrollLeft: number;
scrollTop: number;
}>;
};
declare global {
interface Window {
__playwright_frame_bounding_rects__: FrameBoundingRectsInfo;
}
}
function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
function applyPlaywrightAttributes(blankSnapshotUrl: string, viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
// eslint-disable-next-line no-restricted-globals
const win = window;
const searchParams = new URLSearchParams(win.location.search);
const shouldPopulateCanvasFromScreenshot = searchParams.has('shouldPopulateCanvasFromScreenshot');
const isUnderTest = searchParams.has('isUnderTest');
// info to recursively compute canvas position relative to the top snapshot frame.
// Before rendering each iframe, its parent extracts the '__playwright_canvas_render_info__' attribute
// value and keeps in this variable. It can then remove the attribute and render the element,
// which will eventually trigger the same process inside the iframe recursively.
// When there's a canvas to render, we iterate over its ancestor frames to compute
// its position relative to the top snapshot frame.
const frameBoundingRectsInfo: FrameBoundingRectsInfo = {
viewport,
frames: new WeakMap(),
};
win['__playwright_frame_bounding_rects__'] = frameBoundingRectsInfo;
const kPointerWarningTitle = 'Recorded click position in absolute coordinates did not' +
' match the center of the clicked element. This is either due to the use of provided offset,' +
' or due to a difference between the test runner and the trace viewer operating systems.';
const scrollTops: Element[] = [];
const scrollLefts: Element[] = [];
const targetElements: Element[] = [];
const canvasElements: HTMLCanvasElement[] = [];
let topSnapshotWindow: Window = win;
while (topSnapshotWindow !== topSnapshotWindow.parent && !topSnapshotWindow.location.pathname.match(/\/page@[a-z0-9]+$/))
topSnapshotWindow = topSnapshotWindow.parent;
const visit = (root: Document | ShadowRoot) => {
// Collect all scrolled elements for later use.
for (const e of root.querySelectorAll(`[__playwright_scroll_top_]`))
scrollTops.push(e);
for (const e of root.querySelectorAll(`[__playwright_scroll_left_]`))
scrollLefts.push(e);
for (const element of root.querySelectorAll(`[__playwright_value_]`)) {
const inputElement = element as HTMLInputElement | HTMLTextAreaElement;
if (inputElement.type !== 'file')
inputElement.value = inputElement.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_');
}
for (const element of root.querySelectorAll(`[__playwright_popover_open_]`)) {
try {
(element as HTMLElement).showPopover();
} catch {
}
element.removeAttribute('__playwright_popover_open_');
}
for (const element of root.querySelectorAll(`[__playwright_dialog_open_]`)) {
try {
if (element.getAttribute('__playwright_dialog_open_') === 'modal')
(element as HTMLDialogElement).showModal();
else
(element as HTMLDialogElement).show();
} catch {
}
element.removeAttribute('__playwright_dialog_open_');
}
// Highlight targets marked by the current snapshotter, which sets `__playwright_target__`
// to an empty string on the active target elements. For traces produced by older versions,
// also match by callId/snapshotName, which used to be stored as the attribute value.
const highlightTarget = (target: Element) => {
const style = (target as HTMLElement).style;
style.outline = '2px solid #006ab1';
style.backgroundColor = '#6fa8dc7f';
targetElements.push(target);
};
for (const target of root.querySelectorAll(`[__playwright_target__=""]`))
highlightTarget(target);
for (const targetId of targetIds) {
if (!targetId)
continue;
for (const target of root.querySelectorAll(`[__playwright_target__="${targetId}"]`))
highlightTarget(target);
}
for (const iframe of root.querySelectorAll('iframe, frame')) {
const boundingRectJson = iframe.getAttribute('__playwright_bounding_rect__');
iframe.removeAttribute('__playwright_bounding_rect__');
const boundingRect = boundingRectJson ? JSON.parse(boundingRectJson) : undefined;
if (boundingRect)
frameBoundingRectsInfo.frames.set(iframe, { boundingRect, scrollLeft: 0, scrollTop: 0 });
const src = iframe.getAttribute('__playwright_src__');
if (!src) {
iframe.setAttribute('src', blankSnapshotUrl);
} else {
// Retain query parameters to inherit name=, time=, pointX=, pointY= and other values from parent.
const url = new URL(win.location.href);
// 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);
iframe.setAttribute('src', url.toString());
}
}
{
const body = root.querySelector(`body[__playwright_custom_elements__]`);
if (body && win.customElements) {
const customElements = (body.getAttribute('__playwright_custom_elements__') || '').split(',');
for (const elementName of customElements)
win.customElements.define(elementName, class extends HTMLElement {});
}
}
for (const element of root.querySelectorAll(`template[__playwright_shadow_root_]`)) {
const template = element as HTMLTemplateElement;
const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' });
shadowRoot.appendChild(template.content);
template.remove();
visit(shadowRoot);
}
for (const element of root.querySelectorAll('a'))
element.addEventListener('click', event => { event.preventDefault(); });
if ('adoptedStyleSheets' in (root as any)) {
const adoptedSheets: CSSStyleSheet[] = [...(root as any).adoptedStyleSheets];
for (const element of root.querySelectorAll(`template[__playwright_style_sheet_]`)) {
const template = element as HTMLTemplateElement;
const sheet = new CSSStyleSheet();
(sheet as any).replaceSync(template.getAttribute('__playwright_style_sheet_'));
adoptedSheets.push(sheet);
}
(root as any).adoptedStyleSheets = adoptedSheets;
}
canvasElements.push(...root.querySelectorAll('canvas'));
};
const onLoad = () => {
win.removeEventListener('load', onLoad);
for (const element of scrollTops) {
element.scrollTop = +element.getAttribute('__playwright_scroll_top_')!;
element.removeAttribute('__playwright_scroll_top_');
if (frameBoundingRectsInfo.frames.has(element))
frameBoundingRectsInfo.frames.get(element)!.scrollTop = element.scrollTop;
}
for (const element of scrollLefts) {
element.scrollLeft = +element.getAttribute('__playwright_scroll_left_')!;
element.removeAttribute('__playwright_scroll_left_');
if (frameBoundingRectsInfo.frames.has(element))
frameBoundingRectsInfo.frames.get(element)!.scrollLeft = element.scrollLeft;
}
win.document.styleSheets[0].disabled = true;
const search = new URL(win.location.href).searchParams;
const isTopFrame = win === topSnapshotWindow;
if (isTopFrame && search.get('pointX') && search.get('pointY')) {
const pointX = +search.get('pointX')!;
const pointY = +search.get('pointY')!;
const pointElement = win.document.createElement('x-pw-pointer');
pointElement.style.position = 'fixed';
pointElement.style.backgroundColor = '#f44336';
pointElement.style.width = '20px';
pointElement.style.height = '20px';
pointElement.style.borderRadius = '10px';
pointElement.style.margin = '-10px 0 0 -10px';
pointElement.style.zIndex = '2147483646';
pointElement.style.display = 'flex';
pointElement.style.alignItems = 'center';
pointElement.style.justifyContent = 'center';
// Sometimes there are layout discrepancies between recording and rendering, e.g. fonts,
// that may place the point at the wrong place. To avoid confusion, we just show the
// point in the middle of the target element.
const target = targetElements[0];
const targetBox = target?.getBoundingClientRect();
const targetCenter = target ? { x: targetBox.left + targetBox.width / 2, y: targetBox.top + targetBox.height / 2 } : null;
pointElement.style.left = (targetCenter?.x ?? pointX) + 'px';
pointElement.style.top = (targetCenter?.y ?? pointY) + 'px';
const isAligned = !targetCenter || (Math.abs(targetCenter.x - pointX) <= 10 && Math.abs(targetCenter.y - pointY) <= 10);
if (!isAligned) {
const warningElement = win.document.createElement('x-pw-pointer-warning');
warningElement.textContent = '⚠';
warningElement.style.fontSize = '19px';
warningElement.style.color = 'white';
warningElement.style.marginTop = '-3.5px';
warningElement.style.userSelect = 'none';
pointElement.appendChild(warningElement);
pointElement.setAttribute('title', kPointerWarningTitle);
}
win.document.documentElement.appendChild(pointElement);
}
if (canvasElements.length > 0) {
function drawCheckerboard(context: CanvasRenderingContext2D, canvas: HTMLCanvasElement) {
function createCheckerboardPattern() {
const pattern = win.document.createElement('canvas');
pattern.width = pattern.width / Math.floor(pattern.width / 24);
pattern.height = pattern.height / Math.floor(pattern.height / 24);
const context = pattern.getContext('2d')!;
context.fillStyle = 'lightgray';
context.fillRect(0, 0, pattern.width, pattern.height);
context.fillStyle = 'white';
context.fillRect(0, 0, pattern.width / 2, pattern.height / 2);
context.fillRect(pattern.width / 2, pattern.height / 2, pattern.width, pattern.height);
return context.createPattern(pattern, 'repeat')!;
}
context.fillStyle = createCheckerboardPattern();
context.fillRect(0, 0, canvas.width, canvas.height);
}
const img = new Image();
img.onload = () => {
for (const canvas of canvasElements) {
const context = canvas.getContext('2d')!;
const boundingRectAttribute = canvas.getAttribute('__playwright_bounding_rect__');
canvas.removeAttribute('__playwright_bounding_rect__');
if (!boundingRectAttribute)
continue;
let boundingRect: { left: number, top: number, right: number, bottom: number };
try {
boundingRect = JSON.parse(boundingRectAttribute);
} catch (e) {
continue;
}
let currWindow: Window = win;
while (currWindow !== topSnapshotWindow) {
const iframe = currWindow.frameElement!;
currWindow = currWindow.parent;
const iframeInfo = currWindow['__playwright_frame_bounding_rects__']?.frames.get(iframe);
if (!iframeInfo?.boundingRect)
break;
const leftOffset = iframeInfo.boundingRect.left - iframeInfo.scrollLeft;
const topOffset = iframeInfo.boundingRect.top - iframeInfo.scrollTop;
boundingRect.left += leftOffset;
boundingRect.top += topOffset;
boundingRect.right += leftOffset;
boundingRect.bottom += topOffset;
}
const { width, height } = topSnapshotWindow['__playwright_frame_bounding_rects__'].viewport;
boundingRect.left = boundingRect.left / width;
boundingRect.top = boundingRect.top / height;
boundingRect.right = boundingRect.right / width;
boundingRect.bottom = boundingRect.bottom / height;
const partiallyUncaptured = boundingRect.right > 1 || boundingRect.bottom > 1;
const fullyUncaptured = boundingRect.left > 1 || boundingRect.top > 1;
if (fullyUncaptured) {
canvas.title = `Playwright couldn't capture canvas contents because it's located outside the viewport.`;
continue;
}
drawCheckerboard(context, canvas);
if (shouldPopulateCanvasFromScreenshot) {
context.drawImage(img, boundingRect.left * img.width, boundingRect.top * img.height, (boundingRect.right - boundingRect.left) * img.width, (boundingRect.bottom - boundingRect.top) * img.height, 0, 0, canvas.width, canvas.height);
if (partiallyUncaptured)
canvas.title = `Playwright couldn't capture full canvas contents because it's located partially outside the viewport.`;
else
canvas.title = `Canvas contents are displayed on a best-effort basis based on viewport screenshots taken during test execution.`;
} else {
canvas.title = 'Canvas content display is disabled.';
}
if (isUnderTest)
// eslint-disable-next-line no-console
console.log(`canvas drawn:`, JSON.stringify([boundingRect.left, boundingRect.top, (boundingRect.right - boundingRect.left), (boundingRect.bottom - boundingRect.top)].map(v => Math.floor(v * 100))));
}
};
img.onerror = () => {
for (const canvas of canvasElements) {
const context = canvas.getContext('2d')!;
drawCheckerboard(context, canvas);
canvas.title = `Playwright couldn't show canvas contents because the screenshot failed to load.`;
}
};
img.src = location.href.replace('/snapshot', '/closest-screenshot');
}
};
const onDOMContentLoaded = () => visit(win.document);
win.addEventListener('load', onLoad);
win.addEventListener('DOMContentLoaded', onDOMContentLoaded);
}
// Trace data is untrusted; escape `<` so attacker-controlled targetIds/viewport
// cannot terminate the surrounding <script> tag with "</script>".
const safe = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
return `\n(${applyPlaywrightAttributes.toString()})(${safe(blankSnapshotUrl)},${safe(viewport)}${targetIds.map(id => `, ${safe(String(id))}`).join('')})`;
}
/**
* 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:'];
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.
if (url.protocol === 'javascript:' || url.protocol === 'vbscript:')
return 'javascript:void(0)';
// Pass through if possible.
const isBlob = url.protocol === 'blob:';
const isFile = url.protocol === 'file:';
if (!isBlob && !isFile && schemas.includes(url.protocol))
return href;
// Rewrite blob, file and custom schemas.
const prefix = 'pw-' + url.protocol.slice(0, url.protocol.length - 1);
if (!isFile)
url.protocol = 'https:';
url.hostname = url.hostname ? `${prefix}--${url.hostname}` : prefix;
if (isFile) {
// File URIs can only have their protocol changed after the hostname
// is set. (For all other URIs, we must set the protocol first.)
url.protocol = 'https:';
}
return url.toString();
} catch {
return href;
}
}
/**
* 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:';
const isFile = protocol === 'file:';
if (!isBlob && !isFile && schemas.includes(protocol))
return match;
return match.replace(protocol + '//', `https://pw-${protocol.slice(0, -1)}--`);
});
}
// url() inside a <style> tag can mess up with html parsing, so we encode some of them.
// As an example, the following url will close the </style> tag:
// url('data:image/svg+xml,<svg><defs><style>.a{fill:none}</style></defs><g class="a"></g></svg>')
const urlToEscapeRegex1 = /url\(\s*'([^']*)'\s*\)/ig;
const urlToEscapeRegex2 = /url\(\s*"([^"]*)"\s*\)/ig;
function escapeURLsInStyleSheet(text: string): string {
const replacer = (match: string, url: string) => {
// Conservatively encode only urls with a closing tag.
if (url.includes('</'))
return match.replace(url, encodeURI(url));
return match;
};
return text.replace(urlToEscapeRegex1, replacer).replace(urlToEscapeRegex2, replacer);
}
export const blankSnapshotUrl = 'data:text/html;base64,' + btoa(`<body></body><style>body { color-scheme: light dark; background: light-dark(white, #333) }</style>`);