fix(tests): read every CSS rule for a selector, not the first one
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

The sidebar contract read a selector with indexOf, so it returned whichever
rule appeared earliest in the file. A responsive override added above the base
rule made the gate report a missing min-height that was never removed, which
blocked staging on a change that was correct.

Share one helper that strips comments, parses rule by rule, accepts a whole
group as the query, and returns the declarations of every matching rule. The
union also makes a negative assertion mean no rule may declare the property,
which is what these contracts intend.

The helper was duplicated in two files and had no tests of its own; it now has
regressions for each way it read the wrong block.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 13:03:57 +08:00
parent 8169bd85f7
commit 2d370f2e9d
5 changed files with 110 additions and 14 deletions
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
const source = `
@media (max-width: 640px) {
[data-sidebar="content"] { -webkit-overflow-scrolling: touch; }
}
[data-sidebar="content"] { min-height: 0; overflow-y: auto; }
.session-list { overflow-x: clip; }
.panel [data-sidebar="content"] { color: red; }
.a, .b { gap: 4px; }
/* ---- Documented section ---- */
.documented { padding: 8px; }
.wrapper
.multiline { margin: 0; }
`;
test("reads a base rule that a responsive override precedes", () => {
const declarations = cssDeclarations('[data-sidebar="content"]', source);
assert.match(declarations, /min-height:\s*0/);
assert.match(declarations, /overflow-y:\s*auto/);
});
test("includes every rule for the selector so a negative assertion cannot be evaded", () => {
const declarations = cssDeclarations('[data-sidebar="content"]', source);
assert.match(declarations, /-webkit-overflow-scrolling:\s*touch/);
assert.match(declarations, /color:\s*red/);
});
test("matches a selector inside a comma separated list", () => {
assert.match(cssDeclarations(".b", source), /gap:\s*4px/);
});
test("accepts a whole group as the query", () => {
assert.match(cssDeclarations(".a, .b", source), /gap:\s*4px/);
assert.match(cssDeclarations(".missing-one, .documented", source), /padding:\s*8px/);
});
test("does not match a different selector that shares a prefix", () => {
assert.doesNotMatch(cssDeclarations(".session-list", source), /min-height/);
});
test("reads a rule introduced by a comment", () => {
assert.match(cssDeclarations(".documented", source), /padding:\s*8px/);
});
test("reads a rule whose selector wraps across lines", () => {
assert.match(cssDeclarations(".multiline", source), /margin:\s*0/);
});
test("fails loudly when the selector is absent", () => {
assert.throws(() => cssDeclarations(".missing", source), /missing CSS selector: \.missing/);
});
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const globalStylesUrl = new URL("../src/app/globals.css", import.meta.url);
/**
* Declaration text for every rule that targets `selector`.
*
* A selector is normally declared once at the base layer and again inside
* media queries, so reading only the first occurrence reports whichever rule
* happens to appear earliest in the file. That makes a positive assertion fail
* when a responsive override is added above the base rule, and lets a negative
* assertion pass while an override still declares the forbidden property.
* Joining every matching rule keeps both directions honest.
*/
export function cssDeclarations(selector: string, source = readFileSync(globalStylesUrl, "utf8")) {
// A comment sits between the previous rule and the selector it documents, so
// it lands inside the captured selector text unless it is removed first.
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, " ");
const normalize = (value: string) => value.trim().replace(/\s+/g, " ");
// A caller may ask for a whole group, so any component counts as a hit.
const wanted = selector.split(",").map(normalize).filter(Boolean);
const bodies: string[] = [];
for (const [, selectorList, body] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const targets = selectorList.split(",").map(normalize);
const hit = targets.some((target) => wanted.some(
(value) => target === value || target.endsWith(` ${value}`),
));
if (hit) bodies.push(body.trim());
}
assert.notEqual(bodies.length, 0, `missing CSS selector: ${selector}`);
return bodies.join(" ");
}
+3 -7
View File
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
const projectFile = (path: string) => new URL(`../${path}`, import.meta.url);
const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8");
const globalStyles = readProjectFile("src/app/globals.css");
@@ -12,13 +14,7 @@ const ordersPageSource = readProjectFile("src/app/membership/orders/page.tsx");
const tabsSource = readProjectFile("src/components/ui/tabs.tsx");
const membershipLib = readProjectFile("src/lib/membership.ts");
function cssBlock(selector: string) {
const start = globalStyles.indexOf(`${selector} {`);
assert.notEqual(start, -1, `missing CSS selector: ${selector}`);
const end = globalStyles.indexOf("}", start);
assert.notEqual(end, -1, `unterminated CSS selector: ${selector}`);
return globalStyles.slice(start, end);
}
const cssBlock = (selector: string) => cssDeclarations(selector, globalStyles);
test("membership page exists as a client component with a loading boundary", () => {
assert.equal(existsSync(projectFile("src/app/membership/page.tsx")), true);
+3 -7
View File
@@ -2,17 +2,13 @@ import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
const projectFile = (path: string) => new URL(`../${path}`, import.meta.url);
const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8");
const globalStyles = readProjectFile("src/app/globals.css");
function cssBlock(selector: string) {
const start = globalStyles.indexOf(`${selector} {`);
assert.notEqual(start, -1, `missing CSS selector: ${selector}`);
const end = globalStyles.indexOf("}", start);
assert.notEqual(end, -1, `unterminated CSS selector: ${selector}`);
return globalStyles.slice(start, end);
}
const cssBlock = (selector: string) => cssDeclarations(selector, globalStyles);
test("provides the generic composable sidebar primitive", () => {
assert.equal(existsSync(projectFile("src/components/ui/sidebar.tsx")), true);