diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 8016a903..ca7265f1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 +- 修复版本:本地未提交候选 diff --git a/frontend/tests/css-contract-test-support.test.ts b/frontend/tests/css-contract-test-support.test.ts new file mode 100644 index 00000000..0efb012c --- /dev/null +++ b/frontend/tests/css-contract-test-support.test.ts @@ -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/); +}); diff --git a/frontend/tests/css-contract-test-support.ts b/frontend/tests/css-contract-test-support.ts new file mode 100644 index 00000000..9ae9af9a --- /dev/null +++ b/frontend/tests/css-contract-test-support.ts @@ -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(" "); +} diff --git a/frontend/tests/membership-page.test.ts b/frontend/tests/membership-page.test.ts index 003837af..ed031681 100644 --- a/frontend/tests/membership-page.test.ts +++ b/frontend/tests/membership-page.test.ts @@ -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); diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index 9bf4cd0d..f00a2ea7 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -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);