From 6a739341fb8d05cc7c7f59f84cccf6bda9d3114d Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Mon, 9 Nov 2026 21:22:43 +0000 Subject: [PATCH] test: add property tests over generated selectors and values Seeded generators build valid selectors from a small grammar, arbitrary fragment soup, and arbitrary strings for the setters and constructors. Five properties are checked against them: - invalid input throws the parser's own error, not a TypeError - lossless output reproduces the input - lossy output is stable when parsed and printed again - `sourceIndex` points at the node's own text in the input - a value set through the API reads back the same after printing and parsing They run with `npm run test:property`, outside `npm test`. PROPERTY_RUNS and PROPERTY_SEED set the size and seed. Failures that are already understood are listed in `known.mjs` with what tracks them. Each run reports how many failures every entry explains, and an entry that no longer matches anything is reported so it can go. --- package.json | 2 + src/__tests__/property/generate.mjs | 172 +++++++++++++++++++ src/__tests__/property/known.mjs | 54 ++++++ src/__tests__/property/properties.test.mjs | 191 +++++++++++++++++++++ 4 files changed, 419 insertions(+) create mode 100644 src/__tests__/property/generate.mjs create mode 100644 src/__tests__/property/known.mjs create mode 100644 src/__tests__/property/properties.test.mjs diff --git a/package.json b/package.json index 0d1c1a2..9975bd7 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "format:check": "oxfmt --check src", "pretests": "npm run build", "tests": "node --test src/__tests__/*.mjs", + "pretest:property": "npm run build", + "test:property": "node --test src/__tests__/property/*.test.mjs", "precoverage": "npm run build", "coverage": "node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude=\"**/__tests__/**\" --test-coverage-lines=94 --test-coverage-branches=94 --test-coverage-functions=96 --test-reporter=spec src/__tests__/*.mjs", "test": "npm run lint && npm run typecheck && npm run coverage", diff --git a/src/__tests__/property/generate.mjs b/src/__tests__/property/generate.mjs new file mode 100644 index 0000000..71065d5 --- /dev/null +++ b/src/__tests__/property/generate.mjs @@ -0,0 +1,172 @@ +// Seeded generators for the property tests. Everything here is deterministic +// for a given seed, so a failure can be replayed exactly. + +/** + * mulberry32, a small seedable PRNG. + * @param {number} seed + */ +export function random(seed) { + let a = seed >>> 0; + const next = () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + return { + next, + int: (n) => Math.floor(next() * n), + chance: (p) => next() < p, + pick: (list) => list[Math.floor(next() * list.length)], + }; +} + +const IDENTS = ["a", "div", "foo-bar", "_x", "x1", "é", "\\31 a", "a\\:b", "a\\ b", "--v"]; +const VALUES = ["v", "foo-bar", '"x y"', "'x y'", '"a\\"b"', "'it\\'s'", '""', "a\\ b"]; +const OPERATORS = ["=", "~=", "|=", "^=", "$=", "*="]; +const PSEUDOS = [":hover", "::before", ":first-child", ":nth-child(2n+1)", "::slotted(a)"]; +const COMBINATORS = [" ", " > ", ">", " + ", "+", " ~ ", "~"]; +const NAMESPACES = ["ns|", "*|", "|"]; + +/** + * A selector list built from the grammar: compounds that start with an + * optional type or universal selector, then classes, ids, attributes and + * pseudos, joined by combinators, comments and commas. Spacing varies so the + * lossless paths get exercised. + */ +export function grammarSelector(rng, depth = 0) { + const sp = () => rng.pick(["", "", "", " ", " ", "\n"]); + const ns = () => (rng.chance(0.2) ? rng.pick(NAMESPACES) : ""); + + const attribute = () => { + let s = "[" + sp() + ns() + rng.pick(IDENTS) + sp(); + if (rng.chance(0.7)) { + s += rng.pick(OPERATORS) + sp() + rng.pick(VALUES) + sp(); + if (rng.chance(0.2)) { + s += rng.pick(["i", "s", "I"]) + sp(); + } + } + return s + "]"; + }; + + const pseudo = () => { + if (depth < 2 && rng.chance(0.2)) { + return ( + rng.pick([":not(", ":is(", ":where(", ":has("]) + grammarSelector(rng, depth + 1) + ")" + ); + } + return rng.pick(PSEUDOS); + }; + + const compound = () => { + let s = ""; + if (rng.chance(0.1)) { + s += "&"; + } else if (rng.chance(0.6)) { + s += ns() + (rng.chance(0.2) ? "*" : rng.pick(IDENTS)); + } + const parts = s ? rng.int(3) : 1 + rng.int(3); + for (let i = 0; i < parts; i++) { + s += rng.pick([ + () => "." + rng.pick(IDENTS), + () => "#" + rng.pick(IDENTS), + attribute, + pseudo, + ])(); + } + return s; + }; + + const complex = () => { + let s = compound(); + const n = rng.int(3); + for (let i = 0; i < n; i++) { + if (rng.chance(0.1)) { + s += " /*c*/"; + } + s += rng.pick(COMBINATORS) + compound(); + } + return s; + }; + + let s = sp() + complex(); + const n = rng.int(3); + for (let i = 0; i < n; i++) { + s += rng.pick([",", ", ", " , "]) + sp() + complex(); + } + return s + sp(); +} + +const FRAGMENTS = [ + ...IDENTS, + "*", + "|", + "ns|a", + "*|*", + ".c", + "#i", + "[x]", + "[x=y]", + '[x="y z"]', + ":hover", + "::before", + ":not(.a, .b)", + ":is( a , b )", + "&", + " ", + "\n", + " > ", + "+", + "~", + ",", + "/*c*/", + "\\", + "\\\\", + "#{$x}", + "@{v}", + "(", + ")", + "[", + "]", + ":", + '"', + "'", +]; + +/** + * Fragments concatenated with no grammar at all. Most results do not parse + * and are skipped; the ones that do reach parser paths the grammar never + * produces, such as a `|` straight after a pseudo. + */ +export function fragmentSelector(rng) { + let s = ""; + const n = 1 + rng.int(6); + for (let i = 0; i < n; i++) { + s += rng.pick(FRAGMENTS); + } + return s; +} + +const CHARS = [ + ..."abZ09-_.#:[]()\"'\\ \n\t*|$=~^>+,/{}@%!&;", + "é", + "\u{1F600}", + "\u0001", + "\u007f", + " ", +]; + +/** + * An arbitrary value for the setters and constructors. NUL and lone + * surrogates are left out: CSS has no way to spell them, so they come back + * as U+FFFD by design. + */ +export function anyValue(rng) { + let s = ""; + const n = 1 + rng.int(6); + for (let i = 0; i < n; i++) { + s += rng.pick(CHARS); + } + return s; +} diff --git a/src/__tests__/property/known.mjs b/src/__tests__/property/known.mjs new file mode 100644 index 0000000..3f7a129 --- /dev/null +++ b/src/__tests__/property/known.mjs @@ -0,0 +1,54 @@ +// Failures the property tests already know about. Each entry names what +// tracks it and matches only the failures it explains, so anything new still +// fails the run. Remove an entry when its fix lands; a stale entry is harmless +// but hides nothing either, since it no longer matches. +const PR = "https://github.com/postcss/postcss-selector-parser/pull/"; +const ISSUE = "https://github.com/postcss/postcss-selector-parser/issues/"; + +export const KNOWN = [ + { + property: "sourceIndex points at the node", + tracking: `${PR}359`, + note: "a namespaced tag or universal starts at its name instead of its namespace", + match: (f) => (f.type === "tag" || f.type === "universal") && f.text.includes("|"), + }, + { + property: "sourceIndex points at the node", + tracking: `${ISSUE}357`, + note: "#189: a comment before a combinator is kept in the combinator's raws", + match: (f) => f.type === "combinator" && f.text.includes("/*"), + }, + { + property: "lossy output is stable", + tracking: "not filed yet", + note: "lossy output drops the whitespace before a `|`, so `a |b` becomes `a|b`", + match: (f) => /\s\|(?!=)|\*\/\|/.test(f.input), + }, + { + property: "values set through the API read back the same", + tracking: `${ISSUE}357`, + note: + "in 7.x the constructors and the id value setter write the value as given (see API.md); " + + "escaping there is parked for the next major (#360, #361)", + match: (f) => + [ + "className constructor", + "id constructor", + "id value setter", + "attribute constructor value", + "attribute constructor name", + ].includes(f.case), + }, + { + property: "values set through the API read back the same", + tracking: "not filed yet", + note: "an attribute namespace of `&` prints as `[&|x]`, which does not parse", + match: (f) => f.case === "attribute namespace setter" && f.value === "&", + }, + { + property: "invalid input throws a parser error", + tracking: "not filed yet", + note: "an unclosed `(` at the start or after a pseudo throws a TypeError", + match: (f) => f.error.startsWith("TypeError") && f.input.includes("("), + }, +]; diff --git a/src/__tests__/property/properties.test.mjs b/src/__tests__/property/properties.test.mjs new file mode 100644 index 0000000..2255f94 --- /dev/null +++ b/src/__tests__/property/properties.test.mjs @@ -0,0 +1,191 @@ +// Property tests: generated selectors and values checked against invariants +// that should hold for any input. They are slower than the example tests and +// run on their own, with `npm run test:property`. +// +// PROPERTY_RUNS inputs per property (default 2000) +// PROPERTY_SEED seed for the generators (default 1) +// +// A failure prints the offending inputs, so it can be turned into an example +// test directly. Failures that are already understood are listed in +// `known.mjs` with the issue or pull request that tracks them. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import parser from "../../../dist/index.js"; +import { random, grammarSelector, fragmentSelector, anyValue } from "./generate.mjs"; +import { KNOWN } from "./known.mjs"; + +const RUNS = Number(process.env.PROPERTY_RUNS || 2000); +const SEED = Number(process.env.PROPERTY_SEED || 1); + +const parse = (input, options) => parser().processSync(input, options); + +/** + * Valid selectors from the grammar generator. They all have to parse, so a + * parse error here fails the run. + */ +function* selectors() { + const rng = random(SEED); + for (let i = 0; i < RUNS; i++) { + yield grammarSelector(rng); + } +} + +/** + * Collects failures for one property, sets aside the ones a `KNOWN` entry + * explains, and fails on the rest. + */ +function check(property, run) { + test(`${property} (seed ${SEED}, ${RUNS} runs)`, (t) => { + const known = KNOWN.filter((k) => k.property === property); + const matched = new Map(); + const unexpected = []; + run((failure) => { + const entry = known.find((k) => k.match(failure)); + if (entry) { + matched.set(entry, (matched.get(entry) || 0) + 1); + } else { + unexpected.push(failure); + } + }); + for (const entry of known) { + const count = matched.get(entry); + t.diagnostic( + count + ? `${count} known (${entry.tracking}): ${entry.note}` + : `known failure no longer seen, remove it from known.mjs: ${entry.note}`, + ); + } + assert.deepEqual(unexpected.slice(0, 10), [], `${unexpected.length} unexpected failures`); + }); +} + +check("invalid input throws a parser error", (fail) => { + const rng = random(SEED); + for (let i = 0; i < RUNS * 4; i++) { + const input = fragmentSelector(rng); + try { + parser().astSync(input); + } catch (error) { + // The parser reports its own errors as plain `Error`s; anything else, + // such as a TypeError, means it crashed instead of rejecting the input. + if (error.constructor !== Error) { + fail({ input, error: `${error.name}: ${error.message.split("\n")[0]}` }); + } + } + } +}); + +check("lossless round trip", (fail) => { + for (const input of selectors()) { + const output = parse(input); + if (output !== input) { + fail({ input, output }); + } + } +}); + +check("lossy output is stable", (fail) => { + for (const input of selectors()) { + const once = parse(input, { lossless: false }); + let twice; + try { + twice = parse(once, { lossless: false }); + } catch (error) { + twice = `throws: ${error.message.split("\n")[0]}`; + } + if (twice !== once) { + fail({ input, once, twice }); + } + } +}); + +check("sourceIndex points at the node", (fail) => { + for (const input of selectors()) { + parser() + .astSync(input) + .walk((node) => { + if (node.type === "selector") { + return; + } + const text = node.toString().trim(); + if (text && !input.startsWith(text, node.sourceIndex)) { + fail({ input, type: node.type, text, sourceIndex: node.sourceIndex }); + } + }); + } +}); + +/** + * Each case stores a value through the public API and returns the value read + * back after printing and parsing again. + */ +const API_CASES = { + "className value setter": (value) => { + const root = parser().astSync(".x"); + root.first.first.value = value; + return [root.toString(), (s) => parser().astSync(s).first.first.value]; + }, + "className constructor": (value) => [ + parser.className({ value }).toString(), + (s) => parser().astSync(s).first.first.value, + ], + "id value setter": (value) => { + const root = parser().astSync("#x"); + root.first.first.value = value; + return [root.toString(), (s) => parser().astSync(s).first.first.value]; + }, + "id constructor": (value) => [ + parser.id({ value }).toString(), + (s) => parser().astSync(s).first.first.value, + ], + "attribute setValue": (value, quoteMark) => { + const root = parser().astSync("[x=y]"); + root.first.first.setValue(value, { quoteMark }); + return [root.toString(), (s) => parser().astSync(s).first.first.value]; + }, + "attribute constructor value": (value, quoteMark) => [ + parser.attribute({ attribute: "x", operator: "=", value, quoteMark }).toString(), + (s) => parser().astSync(s).first.first.value, + ], + "attribute name setter": (value) => { + const root = parser().astSync("[x=y]"); + root.first.first.attribute = value; + return [root.toString(), (s) => parser().astSync(s).first.first.attribute]; + }, + "attribute constructor name": (value) => [ + parser.attribute({ attribute: value }).toString(), + (s) => parser().astSync(s).first.first.attribute, + ], + "tag namespace setter": (value) => { + const root = parser().astSync("x"); + root.first.first.namespace = value; + return [root.toString(), (s) => parser().astSync(s).first.first.namespace]; + }, + "attribute namespace setter": (value) => { + const root = parser().astSync("[x]"); + root.first.first.namespace = value; + return [root.toString(), (s) => parser().astSync(s).first.first.namespace]; + }, +}; + +check("values set through the API read back the same", (fail) => { + const rng = random(SEED); + for (let i = 0; i < RUNS; i++) { + const value = anyValue(rng); + const quoteMark = rng.pick(['"', "'", null]); + for (const [name, run] of Object.entries(API_CASES)) { + let output; + let got; + try { + let read; + [output, read] = run(value, quoteMark); + got = read(output); + } catch (error) { + got = `throws: ${error.message.split("\n")[0]}`; + } + if (got !== value) { + fail({ case: name, value, quoteMark, output, got }); + } + } + } +});