rebrowser-playwright-core
Version:
A drop-in replacement for playwright-core patched with rebrowser-patches. It allows to pass modern automation detection tests.
511 lines (501 loc) • 21.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.frameSnapshotStreamer = frameSnapshotStreamer;
/**
* 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.
*/
function frameSnapshotStreamer(snapshotStreamer, removeNoScript) {
// Communication with Playwright.
if (window[snapshotStreamer]) return;
// Attributes present in the snapshot.
const kShadowAttribute = '__playwright_shadow_root_';
const kValueAttribute = '__playwright_value_';
const kCheckedAttribute = '__playwright_checked_';
const kSelectedAttribute = '__playwright_selected_';
const kScrollTopAttribute = '__playwright_scroll_top_';
const kScrollLeftAttribute = '__playwright_scroll_left_';
const kStyleSheetAttribute = '__playwright_style_sheet_';
const kTargetAttribute = '__playwright_target__';
const kCustomElementsAttribute = '__playwright_custom_elements__';
const kCurrentSrcAttribute = '__playwright_current_src__';
const kBoundingRectAttribute = '__playwright_bounding_rect__';
// Symbols for our own info on Nodes/StyleSheets.
const kSnapshotFrameId = Symbol('__playwright_snapshot_frameid_');
const kCachedData = Symbol('__playwright_snapshot_cache_');
const kEndOfList = Symbol('__playwright_end_of_list_');
function resetCachedData(obj) {
delete obj[kCachedData];
}
function ensureCachedData(obj) {
if (!obj[kCachedData]) obj[kCachedData] = {};
return obj[kCachedData];
}
function removeHash(url) {
try {
const u = new URL(url);
u.hash = '';
return u.toString();
} catch (e) {
return url;
}
}
class Streamer {
constructor() {
this._lastSnapshotNumber = 0;
this._staleStyleSheets = new Set();
this._readingStyleSheet = false;
// To avoid invalidating due to our own reads.
this._fakeBase = void 0;
this._observer = void 0;
const invalidateCSSGroupingRule = rule => {
if (rule.parentStyleSheet) this._invalidateStyleSheet(rule.parentStyleSheet);
};
this._interceptNativeMethod(window.CSSStyleSheet.prototype, 'insertRule', sheet => this._invalidateStyleSheet(sheet));
this._interceptNativeMethod(window.CSSStyleSheet.prototype, 'deleteRule', sheet => this._invalidateStyleSheet(sheet));
this._interceptNativeMethod(window.CSSStyleSheet.prototype, 'addRule', sheet => this._invalidateStyleSheet(sheet));
this._interceptNativeMethod(window.CSSStyleSheet.prototype, 'removeRule', sheet => this._invalidateStyleSheet(sheet));
this._interceptNativeGetter(window.CSSStyleSheet.prototype, 'rules', sheet => this._invalidateStyleSheet(sheet));
this._interceptNativeGetter(window.CSSStyleSheet.prototype, 'cssRules', sheet => this._invalidateStyleSheet(sheet));
this._interceptNativeMethod(window.CSSStyleSheet.prototype, 'replaceSync', sheet => this._invalidateStyleSheet(sheet));
this._interceptNativeMethod(window.CSSGroupingRule.prototype, 'insertRule', invalidateCSSGroupingRule);
this._interceptNativeMethod(window.CSSGroupingRule.prototype, 'deleteRule', invalidateCSSGroupingRule);
this._interceptNativeGetter(window.CSSGroupingRule.prototype, 'cssRules', invalidateCSSGroupingRule);
this._interceptNativeAsyncMethod(window.CSSStyleSheet.prototype, 'replace', sheet => this._invalidateStyleSheet(sheet));
this._fakeBase = document.createElement('base');
this._observer = new MutationObserver(list => this._handleMutations(list));
const observerConfig = {
attributes: true,
subtree: true
};
this._observer.observe(document, observerConfig);
this._refreshListenersWhenNeeded();
}
_refreshListenersWhenNeeded() {
this._refreshListeners();
const customEventName = '__playwright_snapshotter_global_listeners_check__';
let seenEvent = false;
const handleCustomEvent = () => seenEvent = true;
window.addEventListener(customEventName, handleCustomEvent);
const observer = new MutationObserver(entries => {
// Check for new documentElement in case we need to reinstall document listeners.
const newDocumentElement = entries.some(entry => Array.from(entry.addedNodes).includes(document.documentElement));
if (newDocumentElement) {
// New documentElement - let's check whether listeners are still here.
seenEvent = false;
window.dispatchEvent(new CustomEvent(customEventName));
if (!seenEvent) {
// Listener did not fire. Reattach the listener and notify.
window.addEventListener(customEventName, handleCustomEvent);
this._refreshListeners();
}
}
});
observer.observe(document, {
childList: true
});
}
_refreshListeners() {
document.addEventListener('__playwright_mark_target__', event => {
if (!event.detail) return;
const callId = event.detail;
event.composedPath()[0].__playwright_target__ = callId;
});
document.addEventListener('__playwright_unmark_target__', event => {
if (!event.detail) return;
const callId = event.detail;
if (event.composedPath()[0].__playwright_target__ === callId) delete event.composedPath()[0].__playwright_target__;
});
}
_interceptNativeMethod(obj, method, cb) {
const native = obj[method];
if (!native) return;
obj[method] = function (...args) {
const result = native.call(this, ...args);
cb(this, result);
return result;
};
}
_interceptNativeAsyncMethod(obj, method, cb) {
const native = obj[method];
if (!native) return;
obj[method] = async function (...args) {
const result = await native.call(this, ...args);
cb(this, result);
return result;
};
}
_interceptNativeGetter(obj, prop, cb) {
const descriptor = Object.getOwnPropertyDescriptor(obj, prop);
Object.defineProperty(obj, prop, {
...descriptor,
get: function () {
const result = descriptor.get.call(this);
cb(this, result);
return result;
}
});
}
_handleMutations(list) {
for (const mutation of list) ensureCachedData(mutation.target).attributesCached = undefined;
}
_invalidateStyleSheet(sheet) {
if (this._readingStyleSheet) return;
this._staleStyleSheets.add(sheet);
}
_updateStyleElementStyleSheetTextIfNeeded(sheet, forceText) {
const data = ensureCachedData(sheet);
if (this._staleStyleSheets.has(sheet) || forceText && data.cssText === undefined) {
this._staleStyleSheets.delete(sheet);
try {
data.cssText = this._getSheetText(sheet);
} catch (e) {
// Sometimes we cannot access cross-origin stylesheets.
data.cssText = '';
}
}
return data.cssText;
}
// Returns either content, ref, or no override.
_updateLinkStyleSheetTextIfNeeded(sheet, snapshotNumber) {
const data = ensureCachedData(sheet);
if (this._staleStyleSheets.has(sheet)) {
this._staleStyleSheets.delete(sheet);
try {
data.cssText = this._getSheetText(sheet);
data.cssRef = snapshotNumber;
return data.cssText;
} catch (e) {
// Sometimes we cannot access cross-origin stylesheets.
}
}
return data.cssRef === undefined ? undefined : snapshotNumber - data.cssRef;
}
markIframe(iframeElement, frameId) {
iframeElement[kSnapshotFrameId] = frameId;
}
reset() {
this._staleStyleSheets.clear();
const visitNode = node => {
resetCachedData(node);
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node;
if (element.shadowRoot) visitNode(element.shadowRoot);
}
for (let child = node.firstChild; child; child = child.nextSibling) visitNode(child);
};
visitNode(document.documentElement);
visitNode(this._fakeBase);
}
__sanitizeMetaAttribute(name, value, httpEquiv) {
if (name === 'charset') return 'utf-8';
if (httpEquiv.toLowerCase() !== 'content-type' || name !== 'content') return value;
const [type, ...params] = value.split(';');
if (type !== 'text/html' || params.length <= 0) return value;
const charsetParamIdx = params.findIndex(param => param.trim().startsWith('charset='));
if (charsetParamIdx > -1) params[charsetParamIdx] = 'charset=utf-8';
return `${type}; ${params.join('; ')}`;
}
_sanitizeUrl(url) {
if (url.startsWith('javascript:') || url.startsWith('vbscript:')) return '';
return url;
}
_sanitizeSrcSet(srcset) {
return srcset.split(',').map(src => {
src = src.trim();
const spaceIndex = src.lastIndexOf(' ');
if (spaceIndex === -1) return this._sanitizeUrl(src);
return this._sanitizeUrl(src.substring(0, spaceIndex).trim()) + src.substring(spaceIndex);
}).join(', ');
}
_resolveUrl(base, url) {
if (url === '') return '';
try {
return new URL(url, base).href;
} catch (e) {
return url;
}
}
_getSheetBase(sheet) {
let rootSheet = sheet;
while (rootSheet.parentStyleSheet) rootSheet = rootSheet.parentStyleSheet;
if (rootSheet.ownerNode) return rootSheet.ownerNode.baseURI;
return document.baseURI;
}
_getSheetText(sheet) {
this._readingStyleSheet = true;
try {
const rules = [];
for (const rule of sheet.cssRules) rules.push(rule.cssText);
return rules.join('\n');
} finally {
this._readingStyleSheet = false;
}
}
captureSnapshot() {
const timestamp = performance.now();
const snapshotNumber = ++this._lastSnapshotNumber;
let nodeCounter = 0;
let shadowDomNesting = 0;
let headNesting = 0;
// Ensure we are up to date.
this._handleMutations(this._observer.takeRecords());
const definedCustomElements = new Set();
const visitNode = node => {
const nodeType = node.nodeType;
const nodeName = nodeType === Node.DOCUMENT_FRAGMENT_NODE ? 'template' : node.nodeName;
if (nodeType !== Node.ELEMENT_NODE && nodeType !== Node.DOCUMENT_FRAGMENT_NODE && nodeType !== Node.TEXT_NODE) return;
if (nodeName === 'SCRIPT') return;
// Don't preload resources.
if (nodeName === 'LINK' && nodeType === Node.ELEMENT_NODE) {
var _getAttribute;
const rel = (_getAttribute = node.getAttribute('rel')) === null || _getAttribute === void 0 ? void 0 : _getAttribute.toLowerCase();
if (rel === 'preload' || rel === 'prefetch') return;
}
if (removeNoScript && nodeName === 'NOSCRIPT') return;
if (nodeName === 'META' && node.httpEquiv.toLowerCase() === 'content-security-policy') return;
// Skip iframes which are inside document's head as they are not visible.
// See https://github.com/microsoft/playwright/issues/12005.
if ((nodeName === 'IFRAME' || nodeName === 'FRAME') && headNesting) return;
const data = ensureCachedData(node);
const values = [];
let equals = !!data.cached;
let extraNodes = 0;
const expectValue = value => {
equals = equals && data.cached[values.length] === value;
values.push(value);
};
const checkAndReturn = n => {
data.attributesCached = true;
if (equals) return {
equals: true,
n: [[snapshotNumber - data.ref[0], data.ref[1]]]
};
nodeCounter += extraNodes;
data.ref = [snapshotNumber, nodeCounter++];
data.cached = values;
return {
equals: false,
n
};
};
if (nodeType === Node.TEXT_NODE) {
const value = node.nodeValue || '';
expectValue(value);
return checkAndReturn(value);
}
if (nodeName === 'STYLE') {
const sheet = node.sheet;
let cssText;
if (sheet) cssText = this._updateStyleElementStyleSheetTextIfNeeded(sheet);
cssText = cssText || node.textContent || '';
expectValue(cssText);
// Compensate for the extra 'cssText' text node.
extraNodes++;
return checkAndReturn([nodeName, {}, cssText]);
}
const attrs = {};
const result = [nodeName, attrs];
const visitChild = child => {
const snapshot = visitNode(child);
if (snapshot) {
result.push(snapshot.n);
expectValue(child);
equals = equals && snapshot.equals;
}
};
const visitChildStyleSheet = child => {
const snapshot = visitStyleSheet(child);
if (snapshot) {
result.push(snapshot.n);
expectValue(child);
equals = equals && snapshot.equals;
}
};
if (nodeType === Node.DOCUMENT_FRAGMENT_NODE) attrs[kShadowAttribute] = 'open';
if (nodeType === Node.ELEMENT_NODE) {
var _window$customElement;
const element = node;
if (element.localName.includes('-') && (_window$customElement = window.customElements) !== null && _window$customElement !== void 0 && _window$customElement.get(element.localName)) definedCustomElements.add(element.localName);
if (nodeName === 'INPUT' || nodeName === 'TEXTAREA') {
const value = element.value;
expectValue(kValueAttribute);
expectValue(value);
attrs[kValueAttribute] = value;
}
if (nodeName === 'INPUT' && ['checkbox', 'radio'].includes(element.type)) {
const value = element.checked ? 'true' : 'false';
expectValue(kCheckedAttribute);
expectValue(value);
attrs[kCheckedAttribute] = value;
}
if (nodeName === 'OPTION') {
const value = element.selected ? 'true' : 'false';
expectValue(kSelectedAttribute);
expectValue(value);
attrs[kSelectedAttribute] = value;
}
if (nodeName === 'CANVAS') {
const boundingRect = element.getBoundingClientRect();
const value = JSON.stringify({
left: boundingRect.left / window.innerWidth,
top: boundingRect.top / window.innerHeight,
right: boundingRect.right / window.innerWidth,
bottom: boundingRect.bottom / window.innerHeight
});
expectValue(kBoundingRectAttribute);
expectValue(value);
attrs[kBoundingRectAttribute] = value;
}
if (element.scrollTop) {
expectValue(kScrollTopAttribute);
expectValue(element.scrollTop);
attrs[kScrollTopAttribute] = '' + element.scrollTop;
}
if (element.scrollLeft) {
expectValue(kScrollLeftAttribute);
expectValue(element.scrollLeft);
attrs[kScrollLeftAttribute] = '' + element.scrollLeft;
}
if (element.shadowRoot) {
++shadowDomNesting;
visitChild(element.shadowRoot);
--shadowDomNesting;
}
if ('__playwright_target__' in element) {
expectValue(kTargetAttribute);
expectValue(element['__playwright_target__']);
attrs[kTargetAttribute] = element['__playwright_target__'];
}
}
if (nodeName === 'HEAD') {
++headNesting;
// Insert fake <base> first, to ensure all <link> elements use the proper base uri.
this._fakeBase.setAttribute('href', document.baseURI);
visitChild(this._fakeBase);
}
for (let child = node.firstChild; child; child = child.nextSibling) visitChild(child);
if (nodeName === 'HEAD') --headNesting;
expectValue(kEndOfList);
let documentOrShadowRoot = null;
if (node.ownerDocument.documentElement === node) documentOrShadowRoot = node.ownerDocument;else if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) documentOrShadowRoot = node;
if (documentOrShadowRoot) {
for (const sheet of documentOrShadowRoot.adoptedStyleSheets || []) visitChildStyleSheet(sheet);
expectValue(kEndOfList);
}
// Process iframe src attribute before bailing out since it depends on a symbol, not the DOM.
if (nodeName === 'IFRAME' || nodeName === 'FRAME') {
const element = node;
const frameId = element[kSnapshotFrameId];
const name = 'src';
const value = frameId ? `/snapshot/${frameId}` : '';
expectValue(name);
expectValue(value);
attrs[name] = value;
}
// Process custom elements before bailing out since they depend on JS, not the DOM.
if (nodeName === 'BODY' && definedCustomElements.size) {
const value = [...definedCustomElements].join(',');
expectValue(kCustomElementsAttribute);
expectValue(value);
attrs[kCustomElementsAttribute] = value;
}
// Process currentSrc before bailing out since it depends on JS, not the DOM.
if (nodeName === 'IMG' || nodeName === 'PICTURE') {
const value = nodeName === 'PICTURE' ? '' : this._sanitizeUrl(node.currentSrc);
expectValue(kCurrentSrcAttribute);
expectValue(value);
attrs[kCurrentSrcAttribute] = value;
}
// We can skip attributes comparison because nothing else has changed,
// and mutation observer didn't tell us about the attributes.
if (equals && data.attributesCached && !shadowDomNesting) return checkAndReturn(result);
if (nodeType === Node.ELEMENT_NODE) {
const element = node;
for (let i = 0; i < element.attributes.length; i++) {
const name = element.attributes[i].name;
if (nodeName === 'LINK' && name === 'integrity') continue;
if (nodeName === 'IFRAME' && (name === 'src' || name === 'srcdoc' || name === 'sandbox')) continue;
if (nodeName === 'FRAME' && name === 'src') continue;
let value = element.attributes[i].value;
if (nodeName === 'META') value = this.__sanitizeMetaAttribute(name, value, node.httpEquiv);else if (name === 'src' && nodeName === 'IMG') value = this._sanitizeUrl(value);else if (name === 'srcset' && nodeName === 'IMG') value = this._sanitizeSrcSet(value);else if (name === 'srcset' && nodeName === 'SOURCE') value = this._sanitizeSrcSet(value);else if (name === 'href' && nodeName === 'LINK') value = this._sanitizeUrl(value);else if (name.startsWith('on')) value = '';
expectValue(name);
expectValue(value);
attrs[name] = value;
}
expectValue(kEndOfList);
}
if (result.length === 2 && !Object.keys(attrs).length) result.pop(); // Remove empty attrs when there are no children.
return checkAndReturn(result);
};
const visitStyleSheet = sheet => {
const data = ensureCachedData(sheet);
const oldCSSText = data.cssText;
const cssText = this._updateStyleElementStyleSheetTextIfNeeded(sheet, true /* forceText */);
if (cssText === oldCSSText) return {
equals: true,
n: [[snapshotNumber - data.ref[0], data.ref[1]]]
};
data.ref = [snapshotNumber, nodeCounter++];
return {
equals: false,
n: ['template', {
[kStyleSheetAttribute]: cssText
}]
};
};
let html;
if (document.documentElement) {
const {
n
} = visitNode(document.documentElement);
html = n;
} else {
html = ['html'];
}
const result = {
html,
doctype: document.doctype ? document.doctype.name : undefined,
resourceOverrides: [],
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
url: location.href,
wallTime: Date.now(),
collectionTime: 0
};
for (const sheet of this._staleStyleSheets) {
if (sheet.href === null) continue;
const content = this._updateLinkStyleSheetTextIfNeeded(sheet, snapshotNumber);
if (content === undefined) {
// Unable to capture stylesheet contents.
continue;
}
const base = this._getSheetBase(sheet);
const url = removeHash(this._resolveUrl(base, sheet.href));
result.resourceOverrides.push({
url,
content,
contentType: 'text/css'
});
}
result.collectionTime = performance.now() - timestamp;
return result;
}
}
window[snapshotStreamer] = new Streamer();
}