/
githubmirror
/
webpack
Обзор
Документация
Войти
/
githubmirror
/
webpack
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
test/helpers/FakeDocument.js
839 строк
21 KB
Alexander Akait
fix(css): terminate a string or url() the tokenizer closed at EOF (#21624)
07 авг 2026, 16:56
Не верифицирован
07 авг 2026, 16:56
21af4f5
Код
Авторство
О чём код?
"use strict"; const fs = require("fs"); const path = require("path"); /** @typedef {import("../../lib/css/syntax").MutableToken} MutableToken */ /** @typedef {Record<string, unknown> & { getPropertyValue: (property: string) => unknown }} StyleDeclaration */ /** @typedef {{ selectorText: string | undefined, style: StyleDeclaration, cssText: string }} CssRule */ /** @typedef {{ type: string, target?: FakeElement }} FakeEvent */ /** @typedef {(event: FakeEvent) => void} EventHandler */ /** * @this {StyleDeclaration} * @param {string} property property * @returns {unknown} value */ function getPropertyValue(property) { return this[property]; } /** @type {typeof import("../../lib/html/syntax") | undefined} */ let htmlSyntax; // Serialized without a closing tag, so `outerHTML` can round-trip them. const VOID_ELEMENTS = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr" ]); /** * The top-level elements of an HTML fragment, each with its attributes and text. * Parsing goes through webpack's own HTML parser so this fake models no HTML * itself; a nested element is skipped by its range falling inside the last one * kept, and the implied `<html>` / `<head>` wrappers carry an empty range. * @param {string} html fragment source * @returns {{ tag: string, attributes: { name: string, value: string }[], text: string }[]} the fragment's top-level elements */ const parseFragment = (html) => { if (htmlSyntax === undefined) htmlSyntax = require("../../lib/html/syntax"); const { SourceProcessor, NodeType } = htmlSyntax; /** @type {{ tag: string, attributes: { name: string, value: string }[], text: string }[]} */ const elements = []; let lastEnd = 0; /** @type {{ tag: string, attributes: { name: string, value: string }[], text: string } | undefined} */ let open; new SourceProcessor() .use({ [NodeType.Element]: { enter: (path) => { const end = path.end(); if (end === 0 || path.start() < lastEnd) return; lastEnd = end; open = { tag: path.tagName(), attributes: path.attributes().map((attribute) => ({ name: attribute.name, value: attribute.value })), text: "" }; elements.push(open); }, exit: () => { open = undefined; } }, [NodeType.Text]: (path) => { if (open !== undefined) open.text += path.data(); } }) .process(html, {}); return elements; }; class FakeDocument { /** * @param {string} basePath base path */ constructor(basePath) { this.head = this.createElement("head"); this.body = this.createElement("body"); this.baseURI = "https://test.cases/path/index.html"; this.title = ""; /** @type {Map<string, FakeElement[]>} */ this._elementsByTagName = new Map([ ["head", [this.head]], ["body", [this.body]] ]); this._basePath = basePath; /** @type {undefined | ((src: string | undefined) => void)} */ this.onScript = undefined; } /** * @param {string} type element type * @returns {FakeElement} element */ createElement(type) { return new FakeElement(this, type, this._basePath); } /** * @param {FakeElement} element element * @returns {void} */ _onElementAttached(element) { const type = element._type; let list = this._elementsByTagName.get(type); if (list === undefined) { list = []; this._elementsByTagName.set(type, list); } list.push(element); } /** * @param {FakeElement} element element * @returns {void} */ _onElementRemoved(element) { const type = element._type; const list = this._elementsByTagName.get(type); if (list === undefined) return; const idx = list.indexOf(element); if (idx >= 0) list.splice(idx, 1); } /** * @param {string} name tag name * @returns {FakeElement[]} elements */ getElementsByTagName(name) { return this._elementsByTagName.get(name) || []; } /** * @param {string} selector selector * @returns {FakeElement[]} matching elements */ querySelectorAll(selector) { // Simple selector support for common cases // Tag selector: "link", "script", etc. if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(selector)) { return this.getElementsByTagName(selector); } // Class selector: ".class" if (selector.startsWith(".")) { const className = selector.slice(1); /** @type {FakeElement[]} */ const allElements = []; for (const elements of this._elementsByTagName.values()) { for (const element of elements) { if (element.getAttribute("class") === className) { allElements.push(element); } } } return allElements; } // ID selector: "#id" if (selector.startsWith("#")) { const id = selector.slice(1); for (const elements of this._elementsByTagName.values()) { for (const element of elements) { if (element.getAttribute("id") === id) { return [element]; } } } return []; } // Attribute selector: "[attr]", "[attr=value]" if (selector.startsWith("[") && selector.endsWith("]")) { const attrSelector = selector.slice(1, -1); /** @type {FakeElement[]} */ const allElements = []; if (attrSelector.includes("=")) { const [attr, value] = attrSelector .split("=") .map((s) => s.trim().replace(/^["']|["']$/g, "")); for (const elements of this._elementsByTagName.values()) { for (const element of elements) { if (element.getAttribute(attr) === value) { allElements.push(element); } } } } else { for (const elements of this._elementsByTagName.values()) { for (const element of elements) { if (element.getAttribute(attrSelector) !== undefined) { allElements.push(element); } } } } return allElements; } // Default: return empty array for unsupported selectors return []; } /** * @param {FakeElement} element element * @returns {StyleDeclaration} computed style */ getComputedStyle(element) { /** @type {StyleDeclaration} */ const style = { getPropertyValue }; const links = this.getElementsByTagName("link"); for (const link of links) { if (!link.sheet) continue; for (const rule of link.sheet.cssRules) { if (rule.selectorText === element._type) { Object.assign(style, rule.style); } } } return style; } } class FakeElement { /** * @param {FakeDocument} document owning document * @param {string} type element type * @param {string} basePath base path */ constructor(document, type, basePath) { this._document = document; this._type = type; /** @type {FakeElement[]} */ this._children = []; /** @type {Record<string, unknown>} */ this._attributes = Object.create(null); /** @type {string | undefined} */ this._src = undefined; /** @type {string | undefined} */ this._href = undefined; /** @type {FakeElement | undefined} */ this.parentNode = undefined; this.sheet = type === "link" ? new FakeSheet(this, basePath) : undefined; this._textContent = ""; this._innerHTML = ""; /** @type {Map<string, EventHandler[]> | undefined} */ this._eventListeners = undefined; /** @type {EventHandler | undefined} */ this.onload = undefined; } get nodeName() { return this._type.toUpperCase(); } get textContent() { return this._textContent; } set textContent(value) { this._textContent = value || ""; } get innerHTML() { return this._innerHTML; } set innerHTML(value) { this._innerHTML = value === undefined || value === null ? "" : String(value); // Kept verbatim above (tests read the raw string back), and parsed into // children here — the HMR head reconciliation walks them. Parsing alone // registers nothing document-wide: a container built by `createElement` is // detached, so `getElementsByTagName` must not start finding what is in it. for (const child of this._children) { this._document._onElementRemoved(child); child.parentNode = undefined; } this._children = []; for (const { tag, attributes, text } of parseFragment(this._innerHTML)) { const element = this._document.createElement(tag); for (const { name, value } of attributes) { element.setAttribute(name, value); } element.textContent = text; element.parentNode = this; this._children.push(element); } } get children() { return this._children; } get attributes() { return Object.keys(this._attributes).map((name) => ({ name, value: this._attributes[name] })); } get outerHTML() { let html = `<${this._type}`; for (const { name, value } of this.attributes) { html += value === "" ? ` ${name}` : ` ${name}="${value}"`; } html += ">"; if (VOID_ELEMENTS.has(this._type)) return html; return `${html}${this._textContent}</${this._type}>`; } /** * @param {FakeElement} node node to attach * @returns {void} */ _attach(node) { this._document._onElementAttached(node); this._children.push(node); node.parentNode = this; } /** * @param {FakeElement} node node to load * @returns {void} */ _load(node) { if (node._type === "link") { const timer = setTimeout(() => { const loadEvent = { type: "load", target: node }; if (node.onload) node.onload(loadEvent); node._dispatchEvent(loadEvent); }, 100); // Don't let this cosmetic load timer keep the worker's event loop alive // past teardown (Deno's setTimeout returns a number, hence the guard). if (typeof timer.unref === "function") timer.unref(); } else if ( node._type === "script" && node.src !== undefined && this._document.onScript ) { Promise.resolve().then(() => { /** @type {(src: string | undefined) => void} */ (this._document.onScript)(node.src); }); } } /** * @param {FakeElement} node node to insert * @param {FakeElement=} reference child to insert before (appends without one) * @returns {void} */ insertBefore(node, reference) { const index = reference ? this._children.indexOf(reference) : -1; if (index === -1) { this._attach(node); } else { this._document._onElementAttached(node); this._children.splice(index, 0, node); node.parentNode = this; } this._load(node); } /** * @param {FakeElement} node node to append * @returns {void} */ appendChild(node) { this._attach(node); this._load(node); } /** * @param {FakeElement} node node to remove * @returns {void} */ removeChild(node) { const idx = this._children.indexOf(node); if (idx >= 0) { this._children.splice(idx, 1); this._document._onElementRemoved(node); node.parentNode = undefined; } } /** * @param {string} name attribute name * @param {string} value attribute value * @returns {void} */ setAttribute(name, value) { // Recorded raw for `outerHTML`; `href` / `src` additionally go through their // setters, which resolve the URL and no-op on the wrong element type. this._attributes[name] = value; if (name === "href") this.href = value; else if (name === "src") this.src = value; } /** * @param {string} name attribute name * @returns {void} */ removeAttribute(name) { delete this._attributes[name]; } /** * @param {string} name attribute name * @returns {unknown} attribute value */ getAttribute(name) { if (this._type === "link" && name === "href") { return this.href; } return this._attributes[name]; } /** * @param {string} value raw url value * @returns {string} resolved url */ _toRealUrl(value) { if (/^\//.test(value)) { return `https://test.cases${value}`; } else if (/^\.\.\//.test(value)) { return `https://test.cases${value.slice(2)}`; } else if (/^\.\//.test(value)) { return `https://test.cases/path${value.slice(1)}`; } else if (/^\w+:\/\//.test(value)) { return value; } else if (/^\/\//.test(value)) { return `https:${value}`; } return `https://test.cases/path/${value}`; } set src(value) { if (this._type === "script") { this._src = this._toRealUrl(/** @type {string} */ (value)); } } get src() { return this._src; } set href(value) { if (this._type === "link") { this._href = this._toRealUrl(/** @type {string} */ (value)); try { if (this.sheet) { this.sheet._css = this.sheet.css; this.sheet._cssRules = this.sheet.cssRules; } } catch (_error) { // Ignore error } } } get href() { return this._href; } get rel() { if (this._type === "link") { return this._attributes.rel || "stylesheet"; } return this._attributes.rel; } set rel(value) { this._attributes.rel = value; } /** * @param {string} event event name * @param {EventHandler} handler handler * @returns {void} */ addEventListener(event, handler) { if (!this._eventListeners) { this._eventListeners = new Map(); } let handlers = this._eventListeners.get(event); if (handlers === undefined) { handlers = []; this._eventListeners.set(event, handlers); } handlers.push(handler); } /** * @param {string} event event name * @param {EventHandler} handler handler * @returns {void} */ removeEventListener(event, handler) { if (!this._eventListeners) return; const handlers = this._eventListeners.get(event); if (!handlers) return; const index = handlers.indexOf(handler); if (index >= 0) { handlers.splice(index, 1); } } /** * @param {FakeEvent} event event * @returns {void} */ _dispatchEvent(event) { if (!this._eventListeners) return; const handlers = this._eventListeners.get(event.type); if (handlers) { for (const handler of handlers) { handler(event); } } } /** * @param {boolean=} deep whether to deep-clone children * @returns {FakeElement} cloned element */ cloneNode(deep = false) { const cloned = new FakeElement( this._document, this._type, this._document._basePath ); // Copy attributes cloned._attributes = { ...this._attributes }; // Copy src and href cloned._src = this._src; cloned._href = this._href; // For link elements, create a new sheet with the same href if (this._type === "link" && this.sheet) { cloned.sheet = new FakeSheet(cloned, this._document._basePath); if (this._href) { cloned.href = this._href; } } // Copy event handlers if they exist if (this.onload) { cloned.onload = this.onload; } // Copy event listeners if (this._eventListeners) { cloned._eventListeners = new Map(); for (const [event, handlers] of this._eventListeners.entries()) { cloned._eventListeners.set(event, [...handlers]); } } // Deep clone children if requested if (deep) { for (const child of this._children) { const clonedChild = child.cloneNode(true); cloned.appendChild(clonedChild); } } return cloned; } } class FakeSheet { /** * @param {FakeElement} element owning element * @param {string} basePath base path */ constructor(element, basePath) { this._element = element; this._basePath = basePath; // We cannot lazily load file content in getter because in HMR scenarios, // the file path will have ?hmr=timestamp appended. If we load lazily, // we can only get the latest file content, and the previous content will be lost. /** @type {string | undefined} */ this._css = undefined; /** @type {CssRule[] | undefined} */ this._cssRules = undefined; } get css() { if (this._css) return this._css; let css = fs.readFileSync( path.resolve( this._basePath, /** @type {string} */ (this._element.href) .replace(/^https:\/\/test\.cases\/path\//, "") .replace(/^https:\/\/example\.com\//, "") .split("?")[0] // Remove query parameters (e.g., ?hmr=timestamp) ), "utf8" ); css = css.replace(/@import url\("([^"]+)"\);/g, (match, url) => { if (!/^https:\/\/test\.cases\/path\//.test(url)) { return `@import url("${url}");`; } if (url.startsWith("#")) { return url; } return fs.readFileSync( path.resolve( this._basePath, url.replace(/^https:\/\/test\.cases\/path\//, "") ), "utf8" ); }); return css; } get cssRules() { if (this._cssRules) return this._cssRules; const { TT_LEFT_CURLY_BRACKET, TT_RIGHT_CURLY_BRACKET, TT_SEMICOLON, readToken } = require("../../lib/css/syntax"); /** @type {CssRule[]} */ const rules = []; /** @type {StyleDeclaration} */ let currentRule = { getPropertyValue }; /** @type {string | undefined} */ let selector; let last = 0; let ruleStart = 0; // Track the start of the current rule const processDeclaration = (/** @type {string} */ str) => { const colon = str.indexOf(":"); if (colon > 0) { const property = str.slice(0, colon).trim(); const value = str.slice(colon + 1); currentRule[property] = value; } }; const href = /** @type {string} */ (this._element.href).split("?")[0]; // Remove query parameters (e.g., ?hmr=timestamp) const filepath = /file:\/\//.test(href) ? new URL(href) : path.resolve( this._basePath, href .replace(/^https:\/\/test\.cases\/path\//, "") .replace(/^https:\/\/example\.com\/public\/path\//, "") .replace(/^https:\/\/example\.com\//, "") ); let css = fs.readFileSync(filepath, "utf8"); css = css // Remove comments // @ts-expect-error we use es2018 for such tests .replace(/\/\*.*?\*\//gs, "") .replace(/@import url\("([^"]+)"\);/g, (match, url) => { if (!/^https:\/\/test\.cases\/path\//.test(url)) { return url; } if (url.startsWith("#")) { return url; } return fs.readFileSync( path.resolve( this._basePath, url.replace(/^https:\/\/test\.cases\/path\//, "") ), "utf8" ); }); for (let pos = 0; ;) { const t = readToken(css, pos, /** @type {MutableToken} */ ({})); if (t === undefined) break; pos = t.end; if (t.type === TT_LEFT_CURLY_BRACKET) { if (selector === undefined) { ruleStart = last; // Record the start of the rule (before the selector) selector = css.slice(last, t.start).trim(); last = t.end; } } else if (t.type === TT_RIGHT_CURLY_BRACKET) { processDeclaration(css.slice(last, t.start)); rules.push({ selectorText: selector, style: currentRule, cssText: css.slice(ruleStart, t.end).trim() }); selector = undefined; currentRule = { getPropertyValue }; last = t.end; } else if (t.type === TT_SEMICOLON) { processDeclaration(css.slice(last, t.start)); last = t.end; } } return rules; } } /** * Fake CSSStyleSheet implementation for testing * Supports the Constructable Stylesheets API */ class CSSStyleSheet { constructor() { this._cssText = ""; /** @type {CssRule[]} */ this._cssRules = []; } /** * Synchronously replace the stylesheet content * @param {string} cssText CSS text content */ replaceSync(cssText) { this._cssText = cssText; this._parseCssRules(cssText); } /** * Asynchronously replace the stylesheet content * @param {string} cssText CSS text content * @returns {Promise<CSSStyleSheet>} Promise that resolves to this stylesheet */ replace(cssText) { return Promise.resolve().then(() => { this.replaceSync(cssText); return this; }); } /** * Get the parsed CSS rules * @returns {CssRule[]} Array of CSS rules */ get cssRules() { return this._cssRules; } /** * Parse CSS text into rules * @param {string} cssText CSS text to parse */ _parseCssRules(cssText) { const { TT_LEFT_CURLY_BRACKET, TT_RIGHT_CURLY_BRACKET, TT_SEMICOLON, readToken } = require("../../lib/css/syntax"); /** @type {CssRule[]} */ const rules = []; /** @type {StyleDeclaration} */ let currentRule = { getPropertyValue }; /** @type {string | undefined} */ let selector; let last = 0; const processDeclaration = (/** @type {string} */ str) => { const colon = str.indexOf(":"); if (colon > 0) { const property = str.slice(0, colon).trim(); const value = str.slice(colon + 1).trim(); currentRule[property] = value; } }; // Remove comments const cleanCss = cssText // @ts-expect-error we use es2018 for such tests .replace(/\/\*.*?\*\//gs, ""); let ruleStart = 0; for (let pos = 0; ;) { const t = readToken(cleanCss, pos, /** @type {MutableToken} */ ({})); if (t === undefined) break; pos = t.end; if (t.type === TT_LEFT_CURLY_BRACKET) { if (selector === undefined) { selector = cleanCss.slice(last, t.start).trim(); ruleStart = last; last = t.end; } } else if (t.type === TT_RIGHT_CURLY_BRACKET) { processDeclaration(cleanCss.slice(last, t.start)); last = t.end; // Generate cssText for the entire rule const ruleText = cleanCss.slice(ruleStart, t.end).trim(); const cssText = `${selector} ${ruleText.slice(ruleText.indexOf("{"))}`; rules.push({ selectorText: selector, style: currentRule, cssText }); selector = undefined; currentRule = { getPropertyValue }; } else if (t.type === TT_SEMICOLON) { processDeclaration(cleanCss.slice(last, t.start)); last = t.end; } } this._cssRules = rules; } } FakeDocument.FakeSheet = FakeSheet; FakeDocument.FakeElement = FakeDocument; FakeDocument.CSSStyleSheet = CSSStyleSheet; module.exports = FakeDocument;