From dd90b80c6bbf383266e33f10c12597c982c1a2eb Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:31:09 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E9=99=8D=E4=BD=8E?= =?UTF-8?q?=E8=B7=A8=E9=A1=B5=E7=BD=91=E7=BB=9C=E8=A7=84=E5=88=99=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=88=9D=E5=A7=8B=E6=B8=B2=E6=9F=93=E6=88=90=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/Tools/NetworkRules/index.test.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/pages/options/routes/Tools/NetworkRules/index.test.tsx b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx index 6367385d8..8584b5463 100644 --- a/src/pages/options/routes/Tools/NetworkRules/index.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx @@ -172,12 +172,19 @@ describe("网络规则列表页", () => { const total = NETWORK_RULES_PAGE_SIZE + 1; const offPage = total - 1; const rules = Array.from({ length: total }, (_, index) => rule(index)); - const client = clientFor(snapshot(rules)); + const current = snapshot(rules); + let resolveState!: (value: NetworkRuleSnapshot) => void; + const stateReady = new Promise((resolve) => { + resolveState = resolve; + }); + const client = clientFor(current, { getState: vi.fn(() => stateReady) }); renderPage(client); - expect(await screen.findByText("规则 0")).toBeInTheDocument(); - expect(screen.getAllByTestId("network-rule-row")).toHaveLength(NETWORK_RULES_PAGE_SIZE); fireEvent.change(screen.getByRole("searchbox"), { target: { value: `规则 ${offPage}` } }); + expect(screen.queryAllByTestId("network-rule-row")).toHaveLength(0); + resolveState(current); + expect(await screen.findByText(`规则 ${offPage}`)).toBeInTheDocument(); + const row = screen.getAllByTestId("network-rule-row")[0]; expect(rowNames()).toEqual([`规则 ${offPage}`]); expect(within(row).getByRole("button", { name: new RegExp(`规则 ${offPage}`) })).toBeDisabled(); From 853625be50548c3ac1cf7b3a5e54d2cd90ca50e7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:34:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=85=20=E5=B0=87=E8=B7=A8=E9=A0=81?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=E7=9A=84=E5=BF=AB=E7=85=A7=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=8C=85=E5=9C=A8=20act=20=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/options/routes/Tools/NetworkRules/index.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/options/routes/Tools/NetworkRules/index.test.tsx b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx index 8584b5463..4fc5b595f 100644 --- a/src/pages/options/routes/Tools/NetworkRules/index.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx @@ -182,7 +182,10 @@ describe("网络规则列表页", () => { fireEvent.change(screen.getByRole("searchbox"), { target: { value: `规则 ${offPage}` } }); expect(screen.queryAllByTestId("network-rule-row")).toHaveLength(0); - resolveState(current); + await act(async () => { + resolveState(current); + await stateReady; + }); expect(await screen.findByText(`规则 ${offPage}`)).toBeInTheDocument(); const row = screen.getAllByTestId("network-rule-row")[0]; From 650a1781cade3ac51534d28e3c101910a19d1507 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:01:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20=E5=9B=BA=E5=8C=96?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E6=B5=8B=E8=AF=95=E5=A4=B9=E5=85=B7=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 ++ docs/develop.md | 9 +- docs/references/develop-testing.md | 14 ++- eslint-rules/harness.test.mjs | 58 +++++++++++- .../no-test-large-boundary-fixture.mjs | 89 +++++++++++++++++++ eslint.config.mjs | 4 + .../Tools/NetworkRules/BulkActions.test.tsx | 1 + .../routes/Tools/NetworkRules/index.test.tsx | 1 + 8 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 eslint-rules/no-test-large-boundary-fixture.mjs diff --git a/AGENTS.md b/AGENTS.md index 3d994b2d8..559613023 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,11 @@ downstream prose does not override it. - **Shared E2E helpers must model both outcomes.** A helper that drives a save, install, or other mutation must make the expected success or failure explicit and wait for that operation's matching signal. Negative cases must opt into the failure contract; never make them pass by accepting an arbitrary toast, an old notification, or a page shell. +- **Performance-sensitive UI fixtures must stay bounded.** Use the smallest synthetic fixture that crosses the + required boundary; for filtering or pagination, do not eagerly render unrelated rows before the trigger. Obvious + explicit one-page-plus fixtures need a line-level `scriptcat/no-test-large-boundary-fixture` rationale; do not hide + their cost by raising the test timeout. The detailed fixture and measurement rules live in + [`docs/references/develop-testing.md`](docs/references/develop-testing.md#vitest-performance-hygiene). - **SOLID, high cohesion, low coupling.** Match existing extension points: persistence uses the small `Repo` / `DAO` / `OPFSRepo` / custom-repo taxonomy, matching an existing entity with the same needs; messages use `Group.on(...)`; service constructor shapes differ by context and Agent subsystem; depend on diff --git a/docs/develop.md b/docs/develop.md index f5277a119..a3dfaccf1 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -90,6 +90,9 @@ detail matters: - `scriptcat/no-raw-color-classname` (`src/pages/**/*.tsx`) — bans raw palette/hex colors in `className` (`bg-white`, `text-gray-500`, `dark:bg-gray-800`, `bg-[#fff]`); use design tokens (`bg-background`/ `text-foreground`/…) so light & dark both work. +- `scriptcat/no-test-large-boundary-fixture` (`src/pages/**/*.test.{ts,tsx}`) — requires a line-level rationale for + explicit `PAGE_SIZE + 1`/`PAGE_ROWS + 1`-style `Array.from({ length: ... })` fixtures, so pagination and filtering + tests keep their boundary explicit; render cost remains a semantic test-review concern. Three conventions are enforced via built-in rules in `eslint.config.mjs`: `no-restricted-imports` bans `@radix-ui/react-*` single packages (use the merged `radix-ui`) and the `sonner` `toast` export (use `notify`); @@ -97,9 +100,9 @@ Three conventions are enforced via built-in rules in `eslint.config.mjs`: `no-re file-scoped `no-restricted-imports` on `tests/vitest.setup.ts` bans `./utils` / `@App/app/service*` / `@App/pages/store*` so global test setup stays lightweight (as a per-file rule replacement it also drops the sonner/radix restriction there — the file imports neither). -`eslint-rules/harness.test.mjs` covers exactly four of these: `no-i18n-default-value`, `no-raw-color-classname`, -the `radix-ui` pattern of `no-restricted-imports`, and `no-restricted-syntax` — not `require-last-error-check`, -not the `sonner` pattern of `no-restricted-imports`, and not the `tests/vitest.setup.ts` scope. +`eslint-rules/harness.test.mjs` covers every custom rule except `require-last-error-check`, plus the Radix import +pattern and the `forwardRef` restriction — not the `sonner` pattern of `no-restricted-imports`, the +`tests/vitest.setup.ts` scope, or the type-aware rules. `src/pages/components/ui/toast.ts` turns `no-restricted-imports` **entirely off** (`eslint.config.mjs`), but only the `sonner` half of that is intentional: this is the one place in `src/pages/**` allowed to import diff --git a/docs/references/develop-testing.md b/docs/references/develop-testing.md index 8e559bff6..90c3e7961 100644 --- a/docs/references/develop-testing.md +++ b/docs/references/develop-testing.md @@ -38,9 +38,12 @@ matches the boundary: local ESLint disable comment stating that contract. A fixed delay used merely to make a test pass is a defect. The mechanical guards `scriptcat/no-test-waitfor-interaction`, `scriptcat/no-test-waitfor-query`, and -`scriptcat/no-test-fixed-sleep` cover reliably recognizable forms in committed page tests and E2E specs. They do not -prove mock fidelity, the sufficiency of a negative observation window, or that coverage was not weakened; those remain -semantic review duties. Do not disable a whole directory to silence them. +`scriptcat/no-test-fixed-sleep` cover reliably recognizable forms in committed page tests and E2E specs. +`scriptcat/no-test-large-boundary-fixture` marks explicit `PAGE_SIZE + 1`/`PAGE_ROWS + 1`-style `Array.from({ length: +... })` fixtures in page tests so their boundary is explicit. These guards do not prove mock fidelity, the sufficiency +of a negative observation window, that a fixture is cheap, or that coverage was not weakened; those remain semantic +review duties. The boundary guard intentionally does not inspect helper-generated arrays, other constructors, render +order, or actual elapsed time. Do not disable a whole directory to silence them. The interaction and query guards follow actual Testing Library import bindings, including local aliases, and respect lexical shadowing; a same-named ordinary function or object is outside their contract. The sleep guard covers @@ -165,6 +168,11 @@ deterministic while preserving the production path under test. - Fixtures should be small enough that the meaningful difference is visible. Builders are useful when defaults are stable and scenarios override only relevant fields; avoid builders that hide the input responsible for a regression. +- For a paginated or filtered UI, use the smallest fixture that crosses the required page boundary. If the behavior + starts with a filter, do not resolve an oversized initial state just to reach the filter control; gate the state at + the test boundary, trigger the filter, and assert both that irrelevant rows were not eagerly rendered and that the + matching result appears. The `scriptcat/no-test-large-boundary-fixture` lint rule requires a line-level rationale + for explicit one-page-plus synthetic arrays; the rationale does not replace the behavioral assertions. ## When TDD doesn't apply diff --git a/eslint-rules/harness.test.mjs b/eslint-rules/harness.test.mjs index c16b81677..8eca8dc87 100644 --- a/eslint-rules/harness.test.mjs +++ b/eslint-rules/harness.test.mjs @@ -260,7 +260,63 @@ describe("harness lint 规则", () => { }); }); - describe("⑦ no-restricted-syntax:src/pages 禁用 forwardRef", () => { + describe("⑦ scriptcat/no-test-large-boundary-fixture:大边界夹具必须显式说明", () => { + const RULE = "scriptcat/no-test-large-boundary-fixture"; + + it("拦截显式的一页以上分页边界写法及其 const 别名", () => { + expect( + ruleIdsAt( + `const total = NETWORK_RULES_PAGE_SIZE + 1; const rows = Array.from({ length: total }, makeRow);`, + "src/pages/example.test.tsx" + ) + ).toContain(RULE); + expect(ruleIdsAt(`Array.from({ length: PAGE_ROWS + 1 }, makeRow);`, "src/pages/example.test.tsx")).toContain( + RULE + ); + }); + + it("放行非边界夹具和非页面测试", () => { + expect(ruleIdsAt(`Array.from({ length: 20 }, makeRow);`, "src/pages/example.test.tsx")).not.toContain(RULE); + expect(ruleIdsAt(`Array.from({ length: PAGE_SIZE + 2 }, makeRow);`, "src/pages/example.test.tsx")).not.toContain( + RULE + ); + expect(ruleIdsAt(`Array.from({ length: itemCount + 1 }, makeItem);`, "src/pages/example.test.tsx")).not.toContain( + RULE + ); + expect(ruleIdsAt(`Array.from({ length: PAGE_SIZE + 1 }, makeRow);`, "src/pkg/example.test.ts")).not.toContain( + RULE + ); + }); + + it("只追踪 const 的单级别名", () => { + expect( + ruleIdsAt(`let total = PAGE_SIZE + 1; Array.from({ length: total }, makeRow);`, "src/pages/example.test.tsx") + ).not.toContain(RULE); + expect( + ruleIdsAt( + `const total = PAGE_SIZE + 1; const count = total; Array.from({ length: count }, makeRow);`, + "src/pages/example.test.tsx" + ) + ).not.toContain(RULE); + }); + + it("放行词法遮蔽和逐处说明的边界夹具", () => { + expect( + ruleIdsAt( + `const Array = { from() {} }; Array.from({ length: PAGE_SIZE + 1 }, makeRow);`, + "src/pages/example.test.tsx" + ) + ).not.toContain(RULE); + expect( + ruleIdsAt( + `// eslint-disable-next-line scriptcat/no-test-large-boundary-fixture -- pagination boundary\nArray.from({ length: PAGE_SIZE + 1 }, makeRow);`, + "src/pages/example.test.tsx" + ) + ).not.toContain(RULE); + }); + }); + + describe("⑧ no-restricted-syntax:src/pages 禁用 forwardRef", () => { const RULE = "no-restricted-syntax"; it("拦截 ui 组件里的 forwardRef(...)", () => { diff --git a/eslint-rules/no-test-large-boundary-fixture.mjs b/eslint-rules/no-test-large-boundary-fixture.mjs new file mode 100644 index 000000000..1f13873c6 --- /dev/null +++ b/eslint-rules/no-test-large-boundary-fixture.mjs @@ -0,0 +1,89 @@ +// 明确的一页以上边界夹具必须逐处说明边界,避免无意中把整页数据带进 UI 测试。 + +function propertyName(node) { + if (!node) return null; + if (node.type === "Identifier") return node.name; + if (node.type === "Literal" || node.type === "StringLiteral") return node.value; + return null; +} + +function isPageBoundaryLength(node) { + if (!node) return false; + if (node.type !== "BinaryExpression" || node.operator !== "+") return false; + const otherSide = (side) => side?.type === "Identifier" && /(?:^|_)PAGE_(?:SIZE|ROWS|LIMIT)$/.test(side.name); + return ( + (node.left.type === "Literal" && node.left.value === 1 && otherSide(node.right)) || + (node.right.type === "Literal" && node.right.value === 1 && otherSide(node.left)) + ); +} + +function isArrayFrom(node) { + return ( + node?.type === "CallExpression" && + node.callee?.type === "MemberExpression" && + propertyName(node.callee.object) === "Array" && + propertyName(node.callee.property) === "from" + ); +} + +function lengthNode(node) { + const options = node.arguments[0]; + if (options?.type !== "ObjectExpression") return undefined; + const length = options.properties.find( + (property) => property.type === "Property" && !property.computed && propertyName(property.key) === "length" + ); + return length?.value; +} + +export default { + meta: { + type: "problem", + docs: { description: "要求页面测试显式说明一页以上的分页边界夹具" }, + schema: [], + messages: { + fixture: "页面测试的一页以上边界夹具必须逐处说明边界;请缩小夹具,或用 eslint-disable-next-line 标注真实契约。", + }, + }, + create(context) { + const sourceCode = context.sourceCode; + const pageBoundaryBindings = new WeakSet(); + + function bindingFor(node, name) { + let scope = sourceCode.getScope(node); + while (scope) { + const variable = scope.set.get(name); + if (variable) return variable; + scope = scope.upper; + } + return undefined; + } + + function isTrackedLength(node) { + return node?.type === "Identifier" && pageBoundaryBindings.has(bindingFor(node, node.name)); + } + + function isShadowedArray(node) { + const binding = bindingFor(node, "Array"); + return binding?.defs.length > 0; + } + + return { + VariableDeclarator(node) { + if ( + node.parent?.type !== "VariableDeclaration" || + node.parent.kind !== "const" || + node.id?.type !== "Identifier" || + !isPageBoundaryLength(node.init) + ) + return; + const binding = bindingFor(node, node.id.name); + if (binding) pageBoundaryBindings.add(binding); + }, + CallExpression(node) { + if (!isArrayFrom(node) || isShadowedArray(node)) return; + const length = lengthNode(node); + if (isPageBoundaryLength(length) || isTrackedLength(length)) context.report({ node, messageId: "fixture" }); + }, + }; + }, +}; diff --git a/eslint.config.mjs b/eslint.config.mjs index 6ab084444..1647cd0cc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ import noRawColorClassname from "./eslint-rules/no-raw-color-classname.mjs"; import noTestWaitForInteraction from "./eslint-rules/no-test-waitfor-interaction.mjs"; import noTestWaitForQuery from "./eslint-rules/no-test-waitfor-query.mjs"; import noTestFixedSleep from "./eslint-rules/no-test-fixed-sleep.mjs"; +import noTestLargeBoundaryFixture from "./eslint-rules/no-test-large-boundary-fixture.mjs"; export default [ { @@ -50,6 +51,7 @@ export default [ "no-test-waitfor-interaction": noTestWaitForInteraction, "no-test-waitfor-query": noTestWaitForQuery, "no-test-fixed-sleep": noTestFixedSleep, + "no-test-large-boundary-fixture": noTestLargeBoundaryFixture, }, }, }, @@ -106,6 +108,8 @@ export default [ "scriptcat/no-test-waitfor-interaction": "error", "scriptcat/no-test-waitfor-query": "error", "scriptcat/no-test-fixed-sleep": "error", + // 一页以上的合成夹具要逐处说明边界,避免 UI 测试无意中渲染整页数据。 + "scriptcat/no-test-large-boundary-fixture": "error", }, }, { diff --git a/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx b/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx index 6afdbeded..04a95b3fe 100644 --- a/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx @@ -194,6 +194,7 @@ describe("网络规则批量操作", () => { it("翻页会清空选择,操作栏随之消失", async () => { // 刚好多出一条即可翻到第二页,多余的行只会让整表重渲染更贵。 + // eslint-disable-next-line scriptcat/no-test-large-boundary-fixture -- pagination boundary const client = clientFor(Array.from({ length: NETWORK_RULES_PAGE_SIZE + 1 }, (_, index) => rule(index))); renderPage(client); expect(await screen.findByText("规则 0")).toBeInTheDocument(); diff --git a/src/pages/options/routes/Tools/NetworkRules/index.test.tsx b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx index 4fc5b595f..d1179452f 100644 --- a/src/pages/options/routes/Tools/NetworkRules/index.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx @@ -171,6 +171,7 @@ describe("网络规则列表页", () => { // 只有第二页存在时「跨页」才成立,刚好多出一条即可;多余的行只会让整页渲染更贵。 const total = NETWORK_RULES_PAGE_SIZE + 1; const offPage = total - 1; + // eslint-disable-next-line scriptcat/no-test-large-boundary-fixture -- cross-page reorder boundary const rules = Array.from({ length: total }, (_, index) => rule(index)); const current = snapshot(rules); let resolveState!: (value: NetworkRuleSnapshot) => void;