Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` / `DAO<T>` / `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
Expand Down
9 changes: 6 additions & 3 deletions docs/develop.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,19 @@ 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`);
`no-restricted-syntax` bans `forwardRef` across `src/pages/**` (use React 19 `function` + ref-prop); and a
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
Expand Down
14 changes: 11 additions & 3 deletions docs/references/develop-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
58 changes: 57 additions & 1 deletion eslint-rules/harness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)", () => {
Expand Down
89 changes: 89 additions & 0 deletions eslint-rules/no-test-large-boundary-fixture.mjs
Original file line number Diff line number Diff line change
@@ -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" });
},
};
},
};
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
{
Expand Down Expand Up @@ -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,
},
},
},
Expand Down Expand Up @@ -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",
},
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 14 additions & 3 deletions src/pages/options/routes/Tools/NetworkRules/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,24 @@ 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 client = clientFor(snapshot(rules));
const current = snapshot(rules);
let resolveState!: (value: NetworkRuleSnapshot) => void;
const stateReady = new Promise<NetworkRuleSnapshot>((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);
await act(async () => {
resolveState(current);
await stateReady;
});
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();
Expand Down
Loading