fix(tests): read every CSS rule for a selector, not the first one
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:
@@ -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(" ");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user