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
+14
View File
@@ -3604,3 +3604,17 @@
前两次 `calculation_failed` 的服务端原因尚未定位(工作流超时为 90s,两次失败均在 20s 内,可排除超时)。本条修复只保证瞬时失败可恢复,不替代对失败本身的排查。
排查所需的可观测性已随本批补齐:`calculation_failed``safeToolError()` 的兜底码,除中止与超时外的一切失败都会被压成它,而上游真实错误文本属于 provider payload,按 `agent-observability.ts` 的封闭契约不得进入日志。因此改为按封闭机器码分类:`runConsultationWorkflow` 抛出带 `code``ConsultationWorkflowError`,按 HTTP 状态区分 `workflow_rate_limited`(429)、`workflow_bad_request`(400)、`workflow_queue_full`(503)、`workflow_server_error`(5xx) 等,并单独标识 `workflow_contract_invalid`HTTP 通过但响应未过 `consultationWorkflowResponseSchema`,此种情况 Python 侧日志显示成功,仅凭访问日志无法发现)。该码记入运行步骤的 `failureCode`,经 `agentObservabilityToolCallSchema` 的新增受控可选字段进入观测日志。同时把 `request_id` 透传给 Python API,用于与其访问日志交叉对齐;此前两侧无任何关联标识,只能靠时间戳猜测。公开回执改由 `publicConsultationRuntimeSteps()` 按白名单构建,内部 `failureCode` 不出现在对外契约中——`executionStepSchema` 是 strict,若直接透出会让成功运行在解析回执时报错。
## BUG-215 | CSS 契约测试取错规则块,假阳性阻断 staging 发布
- 状态:resolved(本地修复,未提交、未发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:`backend-quality-gate` 的前端契约测试;间接影响该 gate 上所有等待发布的改动。
- 用户现象:quality gate 以 `makes SidebarContent the only sidebar scroll owner` 失败,报 `[data-sidebar="content"]` 缺少 `min-height: 0`,但该声明实际存在且未被改动。staging 因此停留在 `7050f7ee``bced1b9d` 与其后两批后端修复均无法部署。
- 根因:`cssBlock()``globalStyles.indexOf(selector + " {")` 取**首个**匹配规则。`bced1b9d` 在媒体查询中新增了一条同名规则 `[data-sidebar="content"] { -webkit-overflow-scrolling: touch; }`,位置早于第 333 行的基础规则,helper 因而返回媒体查询块。该 helper 在 `sidebar-contract``membership-page` 两个文件各有一份副本,且自身没有任何测试。修复过程中又暴露两个同源缺陷:CSS 注释写在规则上方时会被计入捕获的选择器文本(`.membership-page` 因此找不到);调用方会把整个选择器组当作 key 传入(`".membership-plan-card, .membership-credit-card"`),旧实现仅靠字面量子串匹配碰巧生效。
- 修复:抽出共享 `tests/css-contract-test-support.ts``cssDeclarations()`,先剥离注释再逐条规则解析,按逗号拆分并归一化空白后做精确或后代组合匹配,支持以整组作为查询,并返回**所有**命中规则声明的并集而非首个。并集使 `assert.match` 语义为“任一规则声明即可”、`assert.doesNotMatch` 为“任何规则都不得声明”,后者比原实现更严格,也更贴近“唯一滚动容器”这类断言的本意。未放宽任何既有断言,未改动 `globals.css`
- 验证:新增 9 个 helper 回归,覆盖媒体查询先于基础规则、注释引入的规则、跨行选择器、选择器组查询、后代组合、以及选择器缺失时必须响亮报错;`sidebar-contract``membership-page` 合计 78/78 通过;全量非数据库套件 1577/1578,唯一失败为需要真实 Postgres 的 v9 迁移测试。
- 防复发:被多处断言复用的测试辅助函数必须有自己的回归,不得以副本形式散落在各测试文件。以字符串匹配近似 CSS 语义时,必须按规则解析并覆盖同名选择器的全部声明;`indexOf` 式首个匹配不可用于可能重复出现的选择器。
- 相关记录:BUG-214
- 修复版本:本地未提交候选
@@ -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);