Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
172 changes: 172 additions & 0 deletions src/__tests__/property/generate.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
54 changes: 54 additions & 0 deletions src/__tests__/property/known.mjs
Original file line number Diff line number Diff line change
@@ -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("("),
},
];
Loading
Loading