Css batching (#3326)
* tracker: initial css inlining functionality * tracker: add tests, adjust sheet id, stagger rule sending * ui: rereoute custom html component fragments * removed sorting --------- Co-authored-by: nick-delirium <nikita@openreplay.com>
This commit is contained in:
parent
ee71625499
commit
a26411f2a6
8 changed files with 421 additions and 39 deletions
|
|
@ -352,25 +352,25 @@ const DOMMessages = [
|
|||
function brokenDomSorter(m1: PlayerMsg, m2: PlayerMsg) {
|
||||
if (m1.time !== m2.time) return m1.time - m2.time;
|
||||
|
||||
if (m1.tp === MType.CreateDocument && m2.tp !== MType.CreateDocument)
|
||||
return -1;
|
||||
if (m1.tp !== MType.CreateDocument && m2.tp === MType.CreateDocument)
|
||||
return 1;
|
||||
// if (m1.tp === MType.CreateDocument && m2.tp !== MType.CreateDocument)
|
||||
// return -1;
|
||||
// if (m1.tp !== MType.CreateDocument && m2.tp === MType.CreateDocument)
|
||||
// return 1;
|
||||
|
||||
if (m1.tp === MType.RemoveNode)
|
||||
return 1;
|
||||
if (m2.tp === MType.RemoveNode)
|
||||
return -1;
|
||||
// if (m1.tp === MType.RemoveNode)
|
||||
// return 1;
|
||||
// if (m2.tp === MType.RemoveNode)
|
||||
// return -1;
|
||||
|
||||
const m1IsDOM = DOMMessages.includes(m1.tp);
|
||||
const m2IsDOM = DOMMessages.includes(m2.tp);
|
||||
// const m1IsDOM = DOMMessages.includes(m1.tp);
|
||||
// const m2IsDOM = DOMMessages.includes(m2.tp);
|
||||
// if (m1IsDOM && m2IsDOM) {
|
||||
// // @ts-ignore DOM msg has id but checking for 'id' in m is expensive
|
||||
// return m1.id - m2.id;
|
||||
// }
|
||||
|
||||
if (m1IsDOM && !m2IsDOM) return -1;
|
||||
if (!m1IsDOM && m2IsDOM) return 1;
|
||||
// if (m1IsDOM && !m2IsDOM) return -1;
|
||||
// if (!m1IsDOM && m2IsDOM) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
/** required to keep track of iframes, frameId : vnodeId */
|
||||
private readonly iframeRoots: Record<number, number> = {};
|
||||
|
||||
private shadowRootParentMap: Map<number, number> = new Map();
|
||||
|
||||
/** Constructed StyleSheets https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
|
||||
* as well as <style> tag owned StyleSheets
|
||||
*/
|
||||
|
|
@ -163,6 +165,11 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
}
|
||||
|
||||
public getNode(id: number) {
|
||||
const mappedId = this.shadowRootParentMap.get(id);
|
||||
if (mappedId !== undefined) {
|
||||
// If this is a shadow root ID, return the parent element instead
|
||||
return this.vElements.get(mappedId);
|
||||
}
|
||||
return this.vElements.get(id) || this.vTexts.get(id);
|
||||
}
|
||||
|
||||
|
|
@ -171,24 +178,21 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
id: number;
|
||||
index: number;
|
||||
}): void {
|
||||
const { parentID, id, index } = msg;
|
||||
let { parentID, id, index } = msg;
|
||||
|
||||
// Check if parentID is a shadow root, and get the real parent element if so
|
||||
const actualParentID = this.shadowRootParentMap.get(parentID);
|
||||
if (actualParentID !== undefined) {
|
||||
parentID = actualParentID;
|
||||
}
|
||||
|
||||
const child = this.vElements.get(id) || this.vTexts.get(id);
|
||||
if (!child) {
|
||||
logger.error('Insert error. Node not found', id);
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = this.vElements.get(parentID) || this.olVRoots.get(parentID);
|
||||
if ('tagName' in child && child.tagName === 'BODY') {
|
||||
const spriteMap = new VSpriteMap(
|
||||
'svg',
|
||||
true,
|
||||
Number.MAX_SAFE_INTEGER - 100,
|
||||
Number.MAX_SAFE_INTEGER - 100,
|
||||
);
|
||||
spriteMap.node.setAttribute('id', 'OPENREPLAY_SPRITES_MAP');
|
||||
spriteMap.node.setAttribute('style', 'display: none;');
|
||||
child.insertChildAt(spriteMap, Number.MAX_SAFE_INTEGER - 100);
|
||||
}
|
||||
if (!parent) {
|
||||
logger.error(
|
||||
`${id} Insert error. Parent vNode ${parentID} not found`,
|
||||
|
|
@ -305,9 +309,14 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
this.removeAutocomplete(vElem);
|
||||
return;
|
||||
}
|
||||
case MType.MoveNode:
|
||||
case MType.MoveNode: {
|
||||
// if the parent ID is in shadow root map -> custom elements case
|
||||
if (this.shadowRootParentMap.has(msg.parentID)) {
|
||||
msg.parentID = this.shadowRootParentMap.get(msg.parentID)!;
|
||||
}
|
||||
this.insertNode(msg);
|
||||
return;
|
||||
}
|
||||
case MType.RemoveNode: {
|
||||
const vChild = this.vElements.get(msg.id) || this.vTexts.get(msg.id);
|
||||
if (!vChild) {
|
||||
|
|
@ -440,6 +449,19 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
logger.error('CreateIFrameDocument: Node not found', msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// shadow DOM for a custom element
|
||||
const isCustomElement = vElem.tagName.includes('-');
|
||||
const isNotActualIframe = !["IFRAME", "FRAME"].includes(vElem.tagName.toUpperCase());
|
||||
const isLikelyShadowRoot = isCustomElement && isNotActualIframe;
|
||||
|
||||
if (isLikelyShadowRoot) {
|
||||
// Store the mapping but don't create the actual shadow root
|
||||
this.shadowRootParentMap.set(msg.id, msg.frameID);
|
||||
return;
|
||||
}
|
||||
|
||||
// Real iframes
|
||||
if (this.iframeRoots[msg.frameID] && !this.olVRoots.has(msg.id)) {
|
||||
this.olVRoots.delete(this.iframeRoots[msg.frameID]);
|
||||
}
|
||||
|
|
@ -452,7 +474,7 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
case MType.AdoptedSsInsertRule: {
|
||||
const styleSheet = this.olStyleSheets.get(msg.sheetID);
|
||||
if (!styleSheet) {
|
||||
logger.warn('No stylesheet was created for ', msg);
|
||||
logger.warn('No stylesheet was created for ', msg, this.olStyleSheets);
|
||||
return;
|
||||
}
|
||||
insertRule(styleSheet, msg);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "16.1.4",
|
||||
"version": "16.2.0",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
|
|||
|
|
@ -319,13 +319,14 @@ export default class App {
|
|||
__save_canvas_locally: false,
|
||||
localStorage: null,
|
||||
sessionStorage: null,
|
||||
disableStringDict: false,
|
||||
disableStringDict: true,
|
||||
forceSingleTab: false,
|
||||
assistSocketHost: '',
|
||||
fixedCanvasScaling: false,
|
||||
disableCanvas: false,
|
||||
captureIFrames: true,
|
||||
disableSprites: false,
|
||||
inlineRemoteCss: true,
|
||||
obscureTextEmails: true,
|
||||
obscureTextNumbers: false,
|
||||
crossdomain: {
|
||||
|
|
@ -820,7 +821,7 @@ export default class App {
|
|||
this.debug.error('OpenReplay error: ', context, e)
|
||||
}
|
||||
|
||||
send(message: Message, urgent = false): void {
|
||||
send = (message: Message, urgent = false): void => {
|
||||
if (this.activityState === ActivityState.NotActive) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
87
tracker/tracker/src/main/app/observer/cssInliner.ts
Normal file
87
tracker/tracker/src/main/app/observer/cssInliner.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
export function inlineRemoteCss(
|
||||
node: HTMLLinkElement,
|
||||
id: number,
|
||||
baseHref: string,
|
||||
getNextID: () => number,
|
||||
insertRule: (id: number, cssText: string, index: number, baseHref: string) => any[],
|
||||
addOwner: (sheetId: number, ownerId: number) => any[],
|
||||
) {
|
||||
const sheet = node.sheet;
|
||||
const sheetId = getNextID()
|
||||
addOwner(sheetId, id);
|
||||
|
||||
const processRules = (rules: CSSRuleList) => {
|
||||
if (rules.length) {
|
||||
setTimeout(() => {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i];
|
||||
insertRule(sheetId, rule.cssText, i, baseHref);
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
};
|
||||
|
||||
const processCssText = (cssText: string) => {
|
||||
cssText = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
const ruleTexts: string[] = [];
|
||||
let depth = 0;
|
||||
let currentRule = '';
|
||||
|
||||
for (let i = 0; i < cssText.length; i++) {
|
||||
const char = cssText[i];
|
||||
|
||||
if (char === '{') {
|
||||
depth++;
|
||||
} else if (char === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
currentRule += char;
|
||||
ruleTexts.push(currentRule.trim());
|
||||
currentRule = '';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
currentRule += char;
|
||||
}
|
||||
|
||||
for (let i = 0; i < ruleTexts.length; i++) {
|
||||
const ruleText = ruleTexts[i];
|
||||
insertRule(sheetId, ruleText, i, baseHref);
|
||||
}
|
||||
};
|
||||
|
||||
if (sheet) {
|
||||
try {
|
||||
const rules = sheet.cssRules;
|
||||
processRules(rules);
|
||||
} catch (e) {
|
||||
const href = node.href;
|
||||
if (href) {
|
||||
fetch(href)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch CSS: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(cssText => {
|
||||
processCssText(cssText);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Failed to fetch or process CSS from ${href}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (node.href) {
|
||||
fetch(node.href)
|
||||
.then(response => response.text())
|
||||
.then(cssText => {
|
||||
processCssText(cssText);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Failed to fetch CSS from ${node.href}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import {
|
|||
RemoveNode,
|
||||
UnbindNodes,
|
||||
SetNodeAttribute,
|
||||
AdoptedSSInsertRuleURLBased,
|
||||
AdoptedSSAddOwner
|
||||
} from '../messages.gen.js'
|
||||
import App from '../index.js'
|
||||
import {
|
||||
|
|
@ -21,6 +23,8 @@ import {
|
|||
hasTag,
|
||||
isCommentNode,
|
||||
} from '../guards.js'
|
||||
import { inlineRemoteCss } from './cssInliner.js'
|
||||
import { nextID } from "../../modules/constructedStyleSheets.js";
|
||||
|
||||
const iconCache = {}
|
||||
const svgUrlCache = {}
|
||||
|
|
@ -47,7 +51,7 @@ async function parseUseEl(
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${symbol.getAttribute('viewBox') || '0 0 24 24'}">
|
||||
${symbol.innerHTML}
|
||||
</svg>
|
||||
`.trim()
|
||||
`
|
||||
|
||||
iconCache[symbolId] = inlineSvg
|
||||
|
||||
|
|
@ -115,7 +119,7 @@ async function parseUseEl(
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${symbol.getAttribute('viewBox') || '0 0 24 24'}">
|
||||
${symbol.innerHTML}
|
||||
</svg>
|
||||
`.trim()
|
||||
`
|
||||
|
||||
iconCache[symbolId] = inlineSvg
|
||||
|
||||
|
|
@ -188,13 +192,20 @@ export default abstract class Observer {
|
|||
private readonly attributesMap: Map<number, Set<string>> = new Map()
|
||||
private readonly textSet: Set<number> = new Set()
|
||||
private readonly disableSprites: boolean = false
|
||||
/**
|
||||
* this option means that, instead of using link element with href to load css,
|
||||
* we will try to parse the css text instead and send it as css rules set
|
||||
* can (and will) affect performance
|
||||
* */
|
||||
private readonly inlineRemoteCss: boolean = false
|
||||
private readonly domParser = new DOMParser()
|
||||
constructor(
|
||||
protected readonly app: App,
|
||||
protected readonly isTopContext = false,
|
||||
options: { disableSprites: boolean } = { disableSprites: false },
|
||||
options: { disableSprites: boolean, inlineRemoteCss: boolean } = { disableSprites: false, inlineRemoteCss: false },
|
||||
) {
|
||||
this.disableSprites = options.disableSprites
|
||||
this.inlineRemoteCss = options.inlineRemoteCss
|
||||
this.observer = createMutationObserver(
|
||||
this.app.safe((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
|
|
@ -280,10 +291,12 @@ export default abstract class Observer {
|
|||
let removed = 0
|
||||
const totalBeforeRemove = this.app.nodes.getNodeCount()
|
||||
|
||||
const contentDocument = iframe.contentDocument;
|
||||
const nodesUnregister = this.app.nodes.unregisterNode.bind(this.app.nodes);
|
||||
while (walker.nextNode()) {
|
||||
if (!iframe.contentDocument.contains(walker.currentNode)) {
|
||||
if (!contentDocument.contains(walker.currentNode)) {
|
||||
removed += 1
|
||||
this.app.nodes.unregisterNode(walker.currentNode)
|
||||
nodesUnregister(walker.currentNode)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,7 +310,7 @@ export default abstract class Observer {
|
|||
|
||||
private sendNodeAttribute(id: number, node: Element, name: string, value: string | null): void {
|
||||
if (isSVGElement(node)) {
|
||||
if (name.substring(0, 6) === 'xlink:') {
|
||||
if (name.startsWith('xlink:')) {
|
||||
name = name.substring(6)
|
||||
}
|
||||
if (value === null) {
|
||||
|
|
@ -351,6 +364,24 @@ export default abstract class Observer {
|
|||
return
|
||||
}
|
||||
if (name === 'style' || (name === 'href' && hasTag(node, 'link'))) {
|
||||
if ('rel' in node && node.rel === 'stylesheet' && this.inlineRemoteCss) {
|
||||
setTimeout(() => {
|
||||
inlineRemoteCss(
|
||||
// @ts-ignore
|
||||
node,
|
||||
id,
|
||||
this.app.getBaseHref(),
|
||||
nextID,
|
||||
(id: number, cssText: string, index: number, baseHref: string) => {
|
||||
this.app.send(AdoptedSSInsertRuleURLBased(id, cssText, index, baseHref))
|
||||
},
|
||||
(sheetId: number, ownerId: number) => {
|
||||
this.app.send(AdoptedSSAddOwner(sheetId, ownerId))
|
||||
}
|
||||
)
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()))
|
||||
return
|
||||
}
|
||||
|
|
@ -506,8 +537,11 @@ export default abstract class Observer {
|
|||
;(el as HTMLElement | SVGElement).style.width = `${width}px`
|
||||
;(el as HTMLElement | SVGElement).style.height = `${height}px`
|
||||
}
|
||||
|
||||
this.app.send(CreateElementNode(id, parentID, index, el.tagName, isSVGElement(node)))
|
||||
if ('rel' in el && el.rel === 'stylesheet' && this.inlineRemoteCss) {
|
||||
this.app.send(CreateElementNode(id, parentID, index, 'STYLE', false))
|
||||
} else {
|
||||
this.app.send(CreateElementNode(id, parentID, index, el.tagName, isSVGElement(node)))
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < el.attributes.length; i++) {
|
||||
const attr = el.attributes[i]
|
||||
|
|
@ -554,12 +588,12 @@ export default abstract class Observer {
|
|||
}
|
||||
private commitNodes(isStart = false): void {
|
||||
let node
|
||||
this.recents.forEach((type, id) => {
|
||||
for (const [id, type] of this.recents.entries()) {
|
||||
this.commitNode(id)
|
||||
if (type === RecentsType.New && (node = this.app.nodes.getNode(id))) {
|
||||
this.app.nodes.callNodeCallbacks(node, isStart)
|
||||
}
|
||||
})
|
||||
}
|
||||
this.clear()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ import { IN_BROWSER, hasOpenreplayAttribute, canAccessIframe } from '../../utils
|
|||
export interface Options {
|
||||
captureIFrames: boolean
|
||||
disableSprites: boolean
|
||||
/**
|
||||
* with this option instead of using link element with href to load css,
|
||||
* we will try to parse the css text instead and send it as css rules set
|
||||
* can (and probably will) affect performance to certain degree,
|
||||
* especially if the css itself is crossdomain
|
||||
* @default false
|
||||
* */
|
||||
inlineRemoteCss: boolean
|
||||
}
|
||||
|
||||
type Context = Window & typeof globalThis
|
||||
|
|
@ -29,6 +37,7 @@ export default class TopObserver extends Observer {
|
|||
{
|
||||
captureIFrames: true,
|
||||
disableSprites: false,
|
||||
inlineRemoteCss: false,
|
||||
},
|
||||
params.options,
|
||||
)
|
||||
|
|
|
|||
229
tracker/tracker/src/tests/cssInliner.test.ts
Normal file
229
tracker/tracker/src/tests/cssInliner.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// @ts-nocheck
|
||||
|
||||
const mockNextID = jest.fn().mockReturnValue(123);
|
||||
const mockAdoptedSSInsertRuleURLBased = jest.fn();
|
||||
const mockAdoptedSSAddOwner = jest.fn();
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
import { inlineRemoteCss } from '../main/app/observer/cssInliner';
|
||||
import { describe, test, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
describe('inlineRemoteCss', () => {
|
||||
let mockNode;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAdoptedSSInsertRuleURLBased.mockImplementation((id, cssText, index, baseHref) => ({
|
||||
type: "AdoptedSSInsertRuleURLBased",
|
||||
id,
|
||||
cssText,
|
||||
index,
|
||||
baseHref
|
||||
}));
|
||||
|
||||
mockAdoptedSSAddOwner.mockImplementation((sheetId, ownerId) => ({
|
||||
type: "AdoptedSSAddOwner",
|
||||
sheetId,
|
||||
ownerId
|
||||
}));
|
||||
mockNode = document.createElement('link');
|
||||
Object.defineProperty(mockNode, 'sheet', {
|
||||
configurable: true,
|
||||
value: null,
|
||||
writable: true
|
||||
});
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('body { color: red; }')
|
||||
});
|
||||
});
|
||||
|
||||
test('should process rules directly if node has a sheet with accessible rules', () => {
|
||||
jest.useFakeTimers();
|
||||
const mockRule = { cssText: 'body { color: red; }' };
|
||||
const mockRules = [mockRule];
|
||||
Object.defineProperty(mockRules, 'length', { value: 1 });
|
||||
|
||||
const mockSheet = { cssRules: mockRules };
|
||||
Object.defineProperty(mockNode, 'sheet', {
|
||||
get: () => mockSheet
|
||||
});
|
||||
inlineRemoteCss(mockNode, 456, 'http://example.com', mockNextID,mockAdoptedSSInsertRuleURLBased,mockAdoptedSSAddOwner);
|
||||
jest.runAllTimers();
|
||||
expect(mockNextID).toHaveBeenCalled();
|
||||
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
|
||||
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(456, 'body { color: red; }', 0, 'http://example.com');
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should fetch CSS if accessing rules throws an error', () => {
|
||||
mockNode.href = 'http://example.com/style.css';
|
||||
const mockSheet = {};
|
||||
Object.defineProperty(mockSheet, 'cssRules', {
|
||||
get: () => { throw new Error('CORS error'); }
|
||||
});
|
||||
Object.defineProperty(mockNode, 'sheet', {
|
||||
get: () => mockSheet
|
||||
});
|
||||
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||
expect(global.fetch).toHaveBeenCalledWith('http://example.com/style.css');
|
||||
});
|
||||
|
||||
test('should handle successful fetch and process CSS text', async () => {
|
||||
mockNode.href = 'http://example.com/style.css';
|
||||
const mockSheet = {};
|
||||
Object.defineProperty(mockSheet, 'cssRules', {
|
||||
get: () => { throw new Error('CORS error'); }
|
||||
});
|
||||
Object.defineProperty(mockNode, 'sheet', {
|
||||
get: () => mockSheet
|
||||
});
|
||||
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||
await new Promise(process.nextTick);
|
||||
expect(mockNextID).toHaveBeenCalled();
|
||||
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
|
||||
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(123, 'body { color: red; }', 0, 'http://example.com');
|
||||
});
|
||||
|
||||
test('should fetch CSS if node has no sheet but has href', () => {
|
||||
Object.defineProperty(mockNode, 'sheet', {
|
||||
get: () => null
|
||||
});
|
||||
mockNode.href = 'http://example.com/style.css';
|
||||
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||
expect(global.fetch).toHaveBeenCalledWith('http://example.com/style.css');
|
||||
});
|
||||
|
||||
test('should handle complex CSS with multiple rules', async () => {
|
||||
Object.defineProperty(mockNode, 'sheet', {
|
||||
get: () => null
|
||||
});
|
||||
mockNode.href = 'http://example.com/style.css';
|
||||
const complexCss = `
|
||||
body { color: red; }
|
||||
.class { background: blue; }
|
||||
@media (max-width: 600px) { body { font-size: 14px; } }
|
||||
`;
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(complexCss)
|
||||
});
|
||||
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||
await new Promise(process.nextTick);
|
||||
expect(mockNextID).toHaveBeenCalled();
|
||||
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
|
||||
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||
123, 'body { color: red; }', 0, 'http://example.com'
|
||||
);
|
||||
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||
123, '.class { background: blue; }', 1, 'http://example.com'
|
||||
);
|
||||
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||
123, '@media (max-width: 600px) { body { font-size: 14px; } }', 2, 'http://example.com'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle CSS with comments', async () => {
|
||||
Object.defineProperty(mockNode, 'sheet', {
|
||||
get: () => null
|
||||
});
|
||||
mockNode.href = 'http://example.com/style.css';
|
||||
const cssWithComments = `
|
||||
/* This is a comment */
|
||||
body { color: red; }
|
||||
/* Another comment */
|
||||
.class { background: blue; }
|
||||
`;
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(cssWithComments)
|
||||
});
|
||||
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||
await new Promise(process.nextTick);
|
||||
expect(mockNextID).toHaveBeenCalled();
|
||||
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
|
||||
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||
123, 'body { color: red; }', 0, 'http://example.com'
|
||||
);
|
||||
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||
123, '.class { background: blue; }', 1, 'http://example.com'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle failed fetch', async () => {
|
||||
Object.defineProperty(mockNode, 'sheet', {
|
||||
get: () => null
|
||||
});
|
||||
mockNode.href = 'http://example.com/style.css';
|
||||
global.fetch.mockRejectedValue(new Error('Network error'));
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||
await new Promise(process.nextTick);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to fetch CSS from http://example.com/style.css:'),
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should handle non-OK response from fetch', async () => {
|
||||
Object.defineProperty(mockNode, 'sheet', {
|
||||
get: () => null
|
||||
});
|
||||
mockNode.href = 'http://example.com/style.css';
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: () => Promise.resolve('')
|
||||
});
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
expect.any(Error)
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should handle nested CSS rules correctly', async () => {
|
||||
Object.defineProperty(mockNode, 'sheet', {
|
||||
get: () => null
|
||||
});
|
||||
mockNode.href = 'http://example.com/style.css';
|
||||
const nestedCss = `
|
||||
@media (max-width: 600px) {
|
||||
.header {
|
||||
font-size: 14px;
|
||||
}
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
`;
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(nestedCss)
|
||||
});
|
||||
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||
await new Promise(process.nextTick);
|
||||
expect(mockNextID).toHaveBeenCalled();
|
||||
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
|
||||
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||
123,
|
||||
'@media (max-width: 600px) {\n .header {\n font-size: 14px;\n }\n .footer {\n font-size: 12px;\n }\n }',
|
||||
0,
|
||||
'http://example.com'
|
||||
);
|
||||
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||
123,
|
||||
'.container {\n padding: 20px;\n }',
|
||||
1,
|
||||
'http://example.com'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue