Skip to content

Commit ba2a491

Browse files
test(css): hold mergeDistantRules to the cascade every element computes
Each stylesheet is printed with and without the merge, and every element its selectors reach is read under both, over a seeded corpus, configCases and the benchmark stylesheets.
1 parent 30cf650 commit ba2a491

2 files changed

Lines changed: 597 additions & 0 deletions

File tree

‎test/helpers/syntaxEquivalence.js‎

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1980,11 +1980,361 @@ const compareRules = (before, after, signatures) => {
19801980
return "";
19811981
};
19821982

1983+
/**
1984+
* One stylesheet printed two ways that must style every element alike.
1985+
* @typedef {{ name: string, before: string, after: string }} CascadePair
1986+
*/
1987+
1988+
/**
1989+
* What one pair styled differently, per element, and how much was compared.
1990+
* @typedef {{ name: string, elements: number, properties: number, differences: string[] }} CascadeReport
1991+
*/
1992+
1993+
/**
1994+
* Runs in the page. Builds, per pair, elements its selectors reach — a chain per
1995+
* selector, and one element per type carrying every class and attribute named,
1996+
* so rules meet in one cascade — then reads each under `before` and `after`.
1997+
* Only what a rule held by one sheet alone declares can differ, so only that is read.
1998+
* @param {{ pairs: CascadePair[], types: string[] }} input the pairs, and element types every pair nests in each other
1999+
* @returns {CascadeReport[]} one report per pair, in order
2000+
*/
2001+
const compareCascades = ({ pairs, types }) => {
2002+
const REPORTED = 5;
2003+
2004+
/**
2005+
* @param {string} text an identifier as the CSSOM serializes it
2006+
* @returns {string} the name it spells
2007+
*/
2008+
const unescapeIdentifier = (text) =>
2009+
text.replace(/\\(?:([0-9a-fA-F]{1,6}) ?|([\s\S]))/g, (_, hex, other) =>
2010+
hex === undefined ? other : String.fromCodePoint(Number.parseInt(hex, 16))
2011+
);
2012+
2013+
/**
2014+
* @param {string} list a selector list
2015+
* @returns {string[]} its selectors
2016+
*/
2017+
const splitList = (list) => {
2018+
/** @type {string[]} */
2019+
const out = [];
2020+
let depth = 0;
2021+
let quote = "";
2022+
let start = 0;
2023+
for (let i = 0; i < list.length; i++) {
2024+
const char = list[i];
2025+
if (char === "\\") {
2026+
i++;
2027+
} else if (quote !== "") {
2028+
if (char === quote) quote = "";
2029+
} else if (char === '"' || char === "'") {
2030+
quote = char;
2031+
} else if (char === "(" || char === "[") {
2032+
depth++;
2033+
} else if (char === ")" || char === "]") {
2034+
depth--;
2035+
} else if (char === "," && depth === 0) {
2036+
out.push(list.slice(start, i).trim());
2037+
start = i + 1;
2038+
}
2039+
}
2040+
out.push(list.slice(start).trim());
2041+
return out;
2042+
};
2043+
2044+
/** @typedef {{ combinator: string, type: string, classes: string[], id: string, attributes: [string, string][] }} Compound */
2045+
2046+
/**
2047+
* A selector as the compounds an element chain has to carry, pseudos left out:
2048+
* the element is still built, whether or not one matches it.
2049+
* @param {string} selector one selector
2050+
* @returns {Compound[] | null} the chain, or null for a shape not read here
2051+
*/
2052+
const parseSelector = (selector) => {
2053+
const IDENTIFIER =
2054+
/^(?:[-\w\u00A0-\uFFFF]|\\(?:[0-9a-fA-F]{1,6} ?|[\s\S]))+/;
2055+
const ATTRIBUTE =
2056+
/^\[\s*([-\w]+)\s*(?:[~|^$*]?=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s\]]+)\s*[iIsS]?\s*)?\]/;
2057+
/** @type {Compound[]} */
2058+
const chain = [];
2059+
/** @type {Compound} */
2060+
let current = {
2061+
combinator: "",
2062+
type: "",
2063+
classes: [],
2064+
id: "",
2065+
attributes: []
2066+
};
2067+
let started = false;
2068+
let at = 0;
2069+
while (at < selector.length) {
2070+
const rest = selector.slice(at);
2071+
const combinator = /^[\s>+~]+/.exec(rest);
2072+
if (combinator !== null) {
2073+
if (started) {
2074+
chain.push(current);
2075+
const named = combinator[0].replace(/\s/g, "");
2076+
current = {
2077+
combinator: named === "" ? " " : named,
2078+
type: "",
2079+
classes: [],
2080+
id: "",
2081+
attributes: []
2082+
};
2083+
started = false;
2084+
}
2085+
at += combinator[0].length;
2086+
continue;
2087+
}
2088+
started = true;
2089+
const char = rest[0];
2090+
if (char === "*") {
2091+
at++;
2092+
} else if (char === "." || char === "#") {
2093+
const name = IDENTIFIER.exec(rest.slice(1));
2094+
if (name === null) return null;
2095+
if (char === ".") current.classes.push(unescapeIdentifier(name[0]));
2096+
else current.id = unescapeIdentifier(name[0]);
2097+
at += 1 + name[0].length;
2098+
} else if (char === "[") {
2099+
const attribute = ATTRIBUTE.exec(rest);
2100+
if (attribute === null) return null;
2101+
const value = attribute[2] === undefined ? "" : attribute[2];
2102+
current.attributes.push([
2103+
attribute[1],
2104+
unescapeIdentifier(/^["']/.test(value) ? value.slice(1, -1) : value)
2105+
]);
2106+
at += attribute[0].length;
2107+
} else if (char === ":") {
2108+
const name = /^::?[-\w]+/.exec(rest);
2109+
if (name === null) return null;
2110+
at += name[0].length;
2111+
if (selector[at] === "(") {
2112+
for (let depth = 0; at < selector.length; at++) {
2113+
if (selector[at] === "(") depth++;
2114+
else if (selector[at] === ")" && --depth === 0) break;
2115+
}
2116+
at++;
2117+
}
2118+
} else {
2119+
const name = IDENTIFIER.exec(rest);
2120+
// A namespace, a nesting selector: the chain would not say what it matches.
2121+
if (name === null) return null;
2122+
current.type = unescapeIdentifier(name[0]).toLowerCase();
2123+
at += name[0].length;
2124+
}
2125+
}
2126+
chain.push(current);
2127+
return chain;
2128+
};
2129+
2130+
/**
2131+
* @param {Compound} compound what the element carries
2132+
* @returns {Element} the element
2133+
*/
2134+
const build = (compound) => {
2135+
/** @type {Element} */
2136+
let element;
2137+
try {
2138+
element = document.createElement(compound.type || "div");
2139+
} catch (_err) {
2140+
element = document.createElement("div");
2141+
}
2142+
if (compound.classes.length > 0) {
2143+
element.setAttribute("class", compound.classes.join(" "));
2144+
}
2145+
if (compound.id !== "") element.id = compound.id;
2146+
for (const [name, value] of compound.attributes) {
2147+
try {
2148+
element.setAttribute(name, value);
2149+
} catch (_err) {
2150+
// A name the DOM will not take is one no element carries either.
2151+
}
2152+
}
2153+
return element;
2154+
};
2155+
2156+
/**
2157+
* Every style rule a sheet holds, keyed by the text it reads as in its
2158+
* context and counted, so the rules only one of two sheets holds stand out.
2159+
* @param {CSSRuleList} rules the rules
2160+
* @param {string} context the conditions and layers around them
2161+
* @param {Map<string, { count: number, rule: CSSStyleRule }>} out what was found
2162+
* @returns {Map<string, { count: number, rule: CSSStyleRule }>} the same map
2163+
*/
2164+
const inventory = (rules, context, out) => {
2165+
for (const rule of rules) {
2166+
const grouping = /** @type {CSSConditionRule & CSSLayerBlockRule} */ (
2167+
/** @type {unknown} */ (rule)
2168+
);
2169+
const inside =
2170+
grouping.conditionText !== undefined
2171+
? `${context}@${grouping.conditionText}`
2172+
: grouping.name !== undefined
2173+
? `${context}@layer ${grouping.name}`
2174+
: context;
2175+
if (rule instanceof CSSStyleRule) {
2176+
const key = `${context}|${rule.cssText}`;
2177+
const found = out.get(key);
2178+
if (found === undefined) out.set(key, { count: 1, rule });
2179+
else found.count++;
2180+
}
2181+
const nested = /** @type {CSSGroupingRule} */ (
2182+
/** @type {unknown} */ (rule)
2183+
).cssRules;
2184+
if (nested !== undefined) inventory(nested, inside, out);
2185+
}
2186+
return out;
2187+
};
2188+
2189+
const style = document.createElement("style");
2190+
document.head.append(style);
2191+
const root = document.createElement("div");
2192+
document.body.append(root);
2193+
/** @type {CascadeReport[]} */
2194+
const reports = [];
2195+
for (const pair of pairs) {
2196+
root.textContent = "";
2197+
style.textContent = pair.after;
2198+
const afterRules = inventory(
2199+
/** @type {CSSStyleSheet} */ (style.sheet).cssRules,
2200+
"",
2201+
new Map()
2202+
);
2203+
style.textContent = pair.before;
2204+
const beforeRules = inventory(
2205+
/** @type {CSSStyleSheet} */ (style.sheet).cssRules,
2206+
"",
2207+
new Map()
2208+
);
2209+
/** @type {Set<string>} */
2210+
const properties = new Set();
2211+
for (const [one, other] of [
2212+
[beforeRules, afterRules],
2213+
[afterRules, beforeRules]
2214+
]) {
2215+
for (const [key, { count, rule }] of one) {
2216+
const there = other.get(key);
2217+
if (there !== undefined && there.count === count) continue;
2218+
for (let i = 0; i < rule.style.length; i++) {
2219+
properties.add(rule.style.item(i));
2220+
}
2221+
}
2222+
}
2223+
/** @type {Set<string>} */
2224+
const named = new Set(["div", ...types]);
2225+
/** @type {Set<string>} */
2226+
const classes = new Set();
2227+
/** @type {Map<string, string>} */
2228+
const attributes = new Map();
2229+
for (const { rule } of beforeRules.values()) {
2230+
for (const selector of splitList(rule.selectorText)) {
2231+
const chain = parseSelector(selector);
2232+
if (chain === null) continue;
2233+
/** @type {Element | null} */
2234+
let last = null;
2235+
for (const compound of chain) {
2236+
if (compound.type !== "") named.add(compound.type);
2237+
for (const name of compound.classes) classes.add(name);
2238+
for (const [name, value] of compound.attributes) {
2239+
attributes.set(name, value);
2240+
}
2241+
const element = build(compound);
2242+
if (
2243+
last !== null &&
2244+
(compound.combinator === "+" || compound.combinator === "~")
2245+
) {
2246+
last.after(element);
2247+
} else {
2248+
(last === null ? root : last).append(element);
2249+
}
2250+
last = element;
2251+
}
2252+
}
2253+
}
2254+
/** @type {Compound} */
2255+
const everything = {
2256+
combinator: "",
2257+
type: "",
2258+
classes: [...classes],
2259+
id: "",
2260+
attributes: [...attributes]
2261+
};
2262+
for (const type of named) {
2263+
const outer = build({ ...everything, type });
2264+
outer.append(build({ ...everything, type: "div" }));
2265+
root.append(outer);
2266+
for (const inner of types) {
2267+
const parent = build({ ...everything, type, classes: [] });
2268+
parent.append(build({ ...everything, type: inner, classes: [] }));
2269+
parent.append(build({ ...everything, type: inner }));
2270+
root.append(parent);
2271+
}
2272+
}
2273+
const tracked = [...properties];
2274+
const elements = [
2275+
document.documentElement,
2276+
document.body,
2277+
root,
2278+
...root.querySelectorAll("*")
2279+
];
2280+
const PSEUDOS = [null, "::before", "::after"];
2281+
/**
2282+
* @returns {string[][]} every tracked value of every element and pseudo
2283+
*/
2284+
const read = () => {
2285+
// Swapping the sheet restarts every animation, which a read would
2286+
// otherwise catch part way through interpolating.
2287+
for (const running of document.getAnimations()) running.cancel();
2288+
/** @type {string[][]} */
2289+
const out = [];
2290+
for (const element of elements) {
2291+
for (const pseudo of PSEUDOS) {
2292+
const computed = getComputedStyle(element, pseudo);
2293+
out.push(tracked.map((name) => computed.getPropertyValue(name)));
2294+
}
2295+
}
2296+
return out;
2297+
};
2298+
const before = read();
2299+
style.textContent = pair.after;
2300+
const after = read();
2301+
/** @type {string[]} */
2302+
const differences = [];
2303+
for (let i = 0; i < before.length && differences.length < REPORTED; i++) {
2304+
/** @type {string[]} */
2305+
const moved = [];
2306+
for (let at = 0; at < tracked.length; at++) {
2307+
if (before[i][at] !== after[i][at]) {
2308+
moved.push(`${tracked[at]}: ${before[i][at]} -> ${after[i][at]}`);
2309+
}
2310+
}
2311+
if (moved.length === 0) continue;
2312+
const element = elements[Math.floor(i / PSEUDOS.length)];
2313+
const pseudo = PSEUDOS[i % PSEUDOS.length] || "";
2314+
const tag = element.outerHTML.slice(
2315+
0,
2316+
element.outerHTML.indexOf(">") + 1
2317+
);
2318+
differences.push(`${tag.slice(0, 160)}${pseudo} ${moved.join("; ")}`);
2319+
}
2320+
reports.push({
2321+
name: pair.name,
2322+
elements: elements.length,
2323+
properties: tracked.length,
2324+
differences
2325+
});
2326+
}
2327+
style.remove();
2328+
root.remove();
2329+
return reports;
2330+
};
2331+
19832332
module.exports = {
19842333
benchmarkDocuments,
19852334
benchmarkStylesheets,
19862335
buildCorpus,
19872336
collectFixtures,
2337+
compareCascades,
19882338
compareRules,
19892339
conditionSignatures,
19902340
installHelpers,

0 commit comments

Comments
 (0)