diff --git a/AGENTS.md b/AGENTS.md index 929d15163..21c79c729 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ duplicating its rules. | Before you… | Read | | --- | --- | | write code | [`docs/develop.md`](docs/develop.md) | +| modify tests, test helpers, or test runner configuration | [`docs/references/develop-testing.md`](docs/references/develop-testing.md) — apply the test-boundary, observation, and harness rules before editing | | review or report a branch/PR, or create/update a PR or publish its branch | [`docs/develop.md#revision-scope-and-publication-binding`](docs/develop.md#revision-scope-and-publication-binding) + [`docs/pull-request.md`](docs/pull-request.md) | | change a process/message/service/persistence boundary or add a subsystem | [`docs/architecture.md`](docs/architecture.md) + the relevant `docs/references/architecture-*.md` | | build or modify a page, dialog, or block | [`docs/design.md`](docs/design.md) — Core Constraints apply to every UI change | @@ -48,6 +49,13 @@ downstream prose does not override it. Chinese or English titles. The two narrow, non-blanket exceptions are in [`docs/references/develop-testing.md`](docs/references/develop-testing.md#when-tdd-doesnt-apply); runner, mocks, and how to run tests are in [`docs/develop.md`](docs/develop.md). +- **Test changes must follow the test route.** Before changing a test, shared test helper, or runner configuration, + identify the observable contract and test boundary, search existing coverage, capture a baseline or reproduction, + then make the smallest correction and rerun focused and relevant broader checks. A single passing run does not + establish a root-cause fix; report the trigger, evidence, and remaining uncertainty. +- **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. - **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/architecture.md b/docs/architecture.md index bac32e350..06436d77f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -288,7 +288,8 @@ premature abstraction. test-first principle and [develop-testing.md § When TDD doesn't apply](./references/develop-testing.md#when-tdd-doesnt-apply) for the narrow exceptions — this section only covers architecture-specific test mechanics, not the policy itself. -- **E2E (Playwright).** `e2e/*.spec.ts`, one worker, real Chromium. `pnpm run test:e2e` (first run: +- **E2E (Playwright).** `e2e/*.spec.ts`, real Chromium; worker count and retries come from + [`playwright.config.ts`](../playwright.config.ts). `pnpm run test:e2e` (first run: `pnpm run test:e2e:install`). - **Before a PR:** lint + the relevant suite — owned by [references/develop-testing.md](./references/develop-testing.md) → *Testing*. diff --git a/docs/develop.md b/docs/develop.md index 7cd946773..71b1412d5 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -21,7 +21,7 @@ pnpm run coverage pnpm run typecheck # tsc --noEmit pnpm run test:e2e:install # install Playwright Chromium (first run only) -pnpm run test:e2e # Playwright (e2e/*.spec.ts, 1 worker) +pnpm run test:e2e # Playwright (e2e/*.spec.ts; worker count comes from playwright.config.ts) pnpm run lint # prettier --check + tsc --noEmit + check:i18n + check:issue-templates, then eslint pnpm run lint-fix # prettier --write + tsc --noEmit + eslint --fix diff --git a/docs/references/develop-testing.md b/docs/references/develop-testing.md index 4ca055188..8e559bff6 100644 --- a/docs/references/develop-testing.md +++ b/docs/references/develop-testing.md @@ -7,6 +7,57 @@ This guide owns how contributors design, write, review, clean up, and run automa merely because it raises coverage: it must protect an observable contract, fail for a relevant regression, and cost less to understand and maintain than the confidence it provides. +## Test-change route and evidence + +Before modifying a test, shared test helper, or runner configuration, classify the contract and boundary first: + +1. State the trigger, observable outcome, and plausible regression. +2. Search nearby unit, component, service, E2E, and lint coverage before adding or deleting a case. +3. Run the narrowest baseline or reproduce the failure under the same runner, reporter, coverage, shard, and worker + conditions that exposed it. +4. Change one cause at a time, then run the focused test and the relevant broader combination. +5. Report exact commands and distinguish a passed assertion from an unobserved channel or unverified negative. + +One passing run is evidence for that run only. Do not treat a timeout increase, retry, deleted assertion, or arbitrary +sleep as a root-cause repair. + +### Observation rules for asynchronous tests + +The test must observe completion of the contract under test. A request being called proves that work started; it does +not prove that state, persistence, rendering, or the user-visible result completed. Use the narrowest primitive that +matches the boundary: + +- Use direct assertions for synchronous effects and one `act` for a Promise-driven React update. +- Use `findBy*` for a single element that appears asynchronously. Do not wrap `getBy*` in `waitFor` for a lone + `toBeInTheDocument` assertion. +- Use `waitFor` for genuinely open-ended async state, multiple related assertions, or a non-DOM boundary that has no + dedicated completion signal. Keep the callback observational: do not fire events or call `userEvent` inside it, + because retries repeat the interaction. +- Use a real timer only when elapsed time is the contract or the only bounded closure window proves a negative result + (for example, an observer timeout, a runaway retry check, a browser event-loop yield, or a library timer). Add a + 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. + +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 +Playwright `waitForTimeout` and timer-backed `new Promise` forms, while finite observer timeouts remain valid only with +a line-level disable comment that names the timeout contract. Scratch files remain excluded by the committed E2E +configuration; inspect the effective ESLint configuration when a helper moves between tracks. + +### UI and Playwright examples + +For a UI mutation, assert the returned state, rendered result, or persisted collaborator result after completion; +`expect(client.update).toHaveBeenCalled()` alone only proves dispatch. For Playwright, a helper that saves an editor +must take an explicit success or failure expectation and wait for the matching, operation-specific signal. A negative +case must request the failure contract; a helper that always waits for success turns a valid rejection into a harness +failure. An arbitrary toast, an existing toast from an earlier action, or a page-shell anchor is not proof that the +save completed. Keep real browser API, cross-context, and permission flows in E2E; do not replace them with mocks just +to avoid waiting. + ## Applicability gate — read this first Not every section below applies to every change. Before designing or reviewing tests, check which of these the @@ -246,7 +297,9 @@ before/after in one environment with the JSON-report method below. - Co-locate `*.test.ts`/`*.test.tsx` next to source (or place in `tests`). - Use `describe.concurrent()` / `it.concurrent()` where independent. - Single file: `pnpm test -- --run path/to/file.test.ts`. -- Playwright tests are `*.spec.ts` files in `e2e`; they run with one worker and retain failure artifacts. Run targeted tests while iterating, then run `pnpm run lint` plus the relevant full suite before a PR. +- Playwright tests are `*.spec.ts` files in `e2e`; worker count, retries, and artifact settings come from + [`playwright.config.ts`](../../playwright.config.ts) and the CI matrix. Run targeted tests while iterating, then + run `pnpm run lint` plus the relevant full suite before a PR. ## Vitest Performance Hygiene diff --git a/e2e/README.md b/e2e/README.md index 6b5a38e5d..7620a40d8 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -75,7 +75,10 @@ playwright config → fixture (launchPersistentContext, loads dist/ext) [`utils.ts`](./utils.ts) carries the page openers and script installer used by every track: `openOptionsPage`, `openPopupPage`, `openEditorPage`, `openAgentChatPage`, `openAgentProviderPage`, -`saveCurrentEditor`, `installScriptByCode`, `runInlineTestScript`, and `autoApprovePermissions`. +`saveCurrentEditor`, `installScriptByCode`, `runInlineTestScript`, and `autoApprovePermissions`. Save helpers +default to the successful outcome; a test that intentionally rejects a script must pass +`{ saveOutcome: "failure" }` so the helper waits for the matching save failure signal rather than accepting an +unrelated notification. ### The two-phase launch diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 7532c9df0..866b1228f 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -606,6 +606,8 @@ test.describe("GM API", () => { const script = document.createElement("script"); script.textContent = `window["${key}"] = true;`; document.head.appendChild(script); + // 页面脚本执行需要一次真实事件循环让步,才能观察 CSP 阻止后的最终状态。 + // eslint-disable-next-line scriptcat/no-test-fixed-sleep -- page event-loop observation contract await new Promise((resolve) => setTimeout(resolve, 0)); return Boolean((window as Record)[key]); }); diff --git a/e2e/options-pages-smoke.spec.ts b/e2e/options-pages-smoke.spec.ts index 64d650513..4dc93e5b3 100644 --- a/e2e/options-pages-smoke.spec.ts +++ b/e2e/options-pages-smoke.spec.ts @@ -61,7 +61,8 @@ test.describe("Options 各页加载冒烟", () => { await expect(route.anchor(page), `${route.name} (${route.path}) 未渲染稳定锚点`).toBeVisible({ timeout: 20_000, }); - // 给页面挂载副作用(数据加载/消息往返)一点时间触发可能的异常。 + // 页面冒烟契约包含挂载后副作用的有限观察窗口;没有统一完成事件可等待。 + // eslint-disable-next-line scriptcat/no-test-fixed-sleep -- finite post-mount error observation window await page.waitForTimeout(500); } diff --git a/e2e/user-config-yaml.spec.ts b/e2e/user-config-yaml.spec.ts index 71c951367..fd0f66290 100644 --- a/e2e/user-config-yaml.spec.ts +++ b/e2e/user-config-yaml.spec.ts @@ -99,7 +99,7 @@ test.describe("UserConfig YAML prototype pollution (#1494)", () => { expect(before).toBeUndefined(); // 尝试安装恶意脚本(parseUserConfig 应抛错并阻止安装) - await installScriptByCode(context, extensionId, evilCfg); + await installScriptByCode(context, extensionId, evilCfg, { saveOutcome: "failure" }); const list = await openOptionsPage(context, extensionId); const evilInfo = await getScriptInfo(list, "UC Evil E2E"); diff --git a/e2e/utils.ts b/e2e/utils.ts index e7a50fdc7..fc54e4c46 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -144,38 +144,47 @@ async function focusMonacoEditor(page: Page): Promise { await page.locator(".monaco-editor textarea.inputarea").focus(); } -async function waitForSavedScriptInList(context: BrowserContext, extensionId: string): Promise { - const listPage = await openOptionsPage(context, extensionId); - try { - // new-ui 列表页加载完成的稳定信号(桌面工具栏搜索框 / 移动搜索栏) - await listPage - .getByTestId("script-search") - .or(listPage.getByTestId("mobile-search")) - .first() - .waitFor({ state: "visible", timeout: 30_000 }); - } finally { - await listPage.close(); - } -} - -export async function saveCurrentEditor(context: BrowserContext, extensionId: string, page: Page): Promise { +export type SaveOutcome = "success" | "failure"; + +const saveSuccessMessage = + /Saved successfully|successfully created|保存成功|新建成功|儲存成功|保存しました|作成に成功しました|저장되었습니다|새 스크립트가 생성되었습니다|Salvo com sucesso|Novo script criado com sucesso|Успешно сохранено|Создание успешно|Erfolgreich gespeichert|Erstellung erfolgreich|Başarıyla kaydedildi|Yeni betik başarıyla oluşturuldu|Đã lưu thành công|Script mới được tạo thành công/i; +const saveFailureMessage = + /Save Failed|Speichern fehlgeschlagen|保存に失敗しました|저장에 실패했습니다|Falha ao salvar|Ошибка сохранения|Kaydetme Başarısız|Lưu thất bại|保存失败|儲存失敗/i; + +export async function saveCurrentEditor( + _context: BrowserContext, + _extensionId: string, + page: Page, + outcome: SaveOutcome = "success" +): Promise { await focusMonacoEditor(page); + const saveToast = page + .locator(`[data-sonner-toast][data-type="${outcome === "success" ? "success" : "error"}"]`) + .filter({ + hasText: outcome === "success" ? saveSuccessMessage : saveFailureMessage, + }); + // 先关闭同类旧通知的观察窗口,避免它在保存期间自动卸载后与本次通知共用计数。 + await expect.poll(() => saveToast.count(), { timeout: 5_000 }).toBe(0); await page.keyboard.press("ControlOrMeta+s"); - // new-ui 保存成功为 sonner toast - const toastAppeared = await page - .locator("[data-sonner-toast]") - .first() - .waitFor({ timeout: 10_000 }) - .then(() => true) - .catch(() => false); - if (toastAppeared) return; - - await waitForSavedScriptInList(context, extensionId); + // 只有保存后新出现且与结果匹配的通知能证明保存完成;任意 toast 和列表页挂载都不能替代它。 + await expect + .poll(() => saveToast.count(), { + timeout: 10_000, + intervals: [100, 250, 500, 1_000], + message: + outcome === "success" ? "保存操作未产生成功通知,可能被错误通知或未完成状态掩盖" : "保存操作未产生失败通知", + }) + .toBeGreaterThan(0); } /** Install a script by injecting code into the Monaco editor and saving */ -export async function installScriptByCode(context: BrowserContext, extensionId: string, code: string): Promise { +export async function installScriptByCode( + context: BrowserContext, + extensionId: string, + code: string, + options: { saveOutcome?: SaveOutcome } = {} +): Promise { const page = await openEditorPage(context, extensionId); // Wait for Monaco editor DOM and default template content to be ready await focusMonacoEditor(page); @@ -190,7 +199,7 @@ export async function installScriptByCode(context: BrowserContext, extensionId: timeout: 5_000, }); // Save - await saveCurrentEditor(context, extensionId, page); + await saveCurrentEditor(context, extensionId, page, options.saveOutcome); await page.close(); } diff --git a/e2e/vscode-connect.spec.ts b/e2e/vscode-connect.spec.ts index 4f309d0d8..5b3e51c40 100644 --- a/e2e/vscode-connect.spec.ts +++ b/e2e/vscode-connect.spec.ts @@ -67,6 +67,8 @@ function createMockWSServer(): Promise<{ } }, waitForAction: (action: string, timeout = 10_000) => + // 有限观察窗口:WebSocket action 可能永远不回传,超时用于清理监听器并传播失败。 + // eslint-disable-next-line scriptcat/no-test-fixed-sleep -- WebSocket action observation timeout new Promise((resolveAction, rejectAction) => { const timer = setTimeout(() => { const idx = messageListeners.indexOf(handler); diff --git a/eslint-rules/harness.test.mjs b/eslint-rules/harness.test.mjs index d14a8e33c..c16b81677 100644 --- a/eslint-rules/harness.test.mjs +++ b/eslint-rules/harness.test.mjs @@ -134,7 +134,133 @@ describe("harness lint 规则", () => { }); }); - describe("④ no-restricted-syntax:src/pages 禁用 forwardRef", () => { + describe("④ scriptcat/no-test-waitfor-interaction:waitFor 回调不得重复交互", () => { + const RULE = "scriptcat/no-test-waitfor-interaction"; + + it("拦截 fireEvent 及其导入别名", () => { + expect( + ruleIdsAt( + `import { fireEvent as fe, waitFor } from "@testing-library/react"; waitFor(() => fe.click(button));`, + "src/pages/example.test.tsx" + ) + ).toContain(RULE); + }); + + it("拦截 userEvent 实例交互", () => { + expect( + ruleIdsAt( + `import userEvent from "@testing-library/user-event"; import { waitFor } from "@testing-library/react"; const user = userEvent.setup(); waitFor(() => user.click(button));`, + "src/pages/example.test.tsx" + ) + ).toContain(RULE); + }); + + it("拦截 userEvent 导入别名实例交互", () => { + expect( + ruleIdsAt( + `import { userEvent as ue } from "@testing-library/user-event"; import { waitFor } from "@testing-library/react"; const u = ue.setup(); waitFor(() => u.type(input, "x"));`, + "src/pages/example.test.tsx" + ) + ).toContain(RULE); + }); + + it("拦截 waitFor 导入别名", () => { + expect( + ruleIdsAt( + `import { fireEvent, waitFor as until } from "@testing-library/react"; until(() => fireEvent.click(button));`, + "src/pages/example.test.tsx" + ) + ).toContain(RULE); + }); + + it("放行同名普通函数和被局部变量遮蔽的导入名", () => { + expect( + ruleIdsAt( + `function waitFor(callback) { callback(); } const fireEvent = { click() {} }; waitFor(() => fireEvent.click(button));`, + "src/pages/example.test.tsx" + ) + ).not.toContain(RULE); + expect( + ruleIdsAt( + `import { fireEvent as fe, waitFor } from "@testing-library/react"; function run(fe) { waitFor(() => fe.click(button)); }`, + "src/pages/example.test.tsx" + ) + ).not.toContain(RULE); + + expect( + ruleIdsAt( + `import { fireEvent, waitFor } from "@testing-library/react"; fireEvent.click(button); waitFor(() => expect(screen.getByText("done")).toBeInTheDocument());`, + "src/pages/example.test.tsx" + ) + ).not.toContain(RULE); + }); + }); + + describe("⑤ scriptcat/no-test-waitfor-query:存在性查询用 findBy", () => { + const RULE = "scriptcat/no-test-waitfor-query"; + + it("拦截仅包装 getBy 存在性断言的 waitFor", () => { + expect( + ruleIdsAt( + `import { waitFor } from "@testing-library/react"; waitFor(() => expect(screen.getByText("done")).toBeInTheDocument());`, + "src/pages/example.test.tsx" + ) + ).toContain(RULE); + }); + + it("拦截 waitFor 导入别名并放行同名普通函数", () => { + expect( + ruleIdsAt( + `import { waitFor as until } from "@testing-library/react"; until(() => expect(screen.getByText("done")).toBeInTheDocument());`, + "src/pages/example.test.tsx" + ) + ).toContain(RULE); + expect( + ruleIdsAt( + `function waitFor(callback) { callback(); } waitFor(() => expect(screen.getByText("done")).toBeInTheDocument());`, + "src/pages/example.test.tsx" + ) + ).not.toContain(RULE); + }); + + it("放行多断言和非存在性断言", () => { + expect( + ruleIdsAt( + `waitFor(() => { expect(screen.getByText("done")).toBeInTheDocument(); expect(api).toHaveBeenCalled(); });`, + "src/pages/example.test.tsx" + ) + ).not.toContain(RULE); + expect( + ruleIdsAt(`waitFor(() => expect(screen.getByRole("button")).toBeEnabled());`, "src/pages/example.test.tsx") + ).not.toContain(RULE); + }); + }); + + describe("⑥ scriptcat/no-test-fixed-sleep:禁止无契约固定休眠", () => { + const RULE = "scriptcat/no-test-fixed-sleep"; + + it("拦截 Playwright waitForTimeout 和 timer Promise", () => { + expect(ruleIdsAt(`await page.waitForTimeout(500);`, "e2e/example.spec.ts")).toContain(RULE); + expect( + ruleIdsAt(`await new Promise((resolve) => setTimeout(resolve, 0));`, "src/pages/example.test.tsx") + ).toContain(RULE); + expect( + ruleIdsAt(`await new Promise((resolve) => setTimeout(() => resolve(), 0));`, "src/pages/example.test.tsx") + ).toContain(RULE); + }); + + it("放行带有逐处豁免注释的时序让步和其他 setTimeout", () => { + expect( + ruleIdsAt( + `// eslint-disable-next-line scriptcat/no-test-fixed-sleep -- listener registration contract\nawait new Promise((resolve) => setTimeout(resolve, 0));`, + "src/pages/example.test.tsx" + ) + ).not.toContain(RULE); + expect(ruleIdsAt(`setTimeout(tick, 10);`, "e2e/example.spec.ts")).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-fixed-sleep.mjs b/eslint-rules/no-test-fixed-sleep.mjs new file mode 100644 index 000000000..f9b3d1db4 --- /dev/null +++ b/eslint-rules/no-test-fixed-sleep.mjs @@ -0,0 +1,74 @@ +// 固定休眠无法证明目标状态;仅允许通过逐处 eslint-disable 注释保留真实时序契约。 + +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; +} + +export default { + meta: { + type: "problem", + docs: { description: "禁止测试用固定休眠代替可观察完成信号" }, + schema: [], + messages: { + sleep: "测试不得用固定休眠代替完成信号;请等待目标状态,或为真实时序契约添加逐处豁免说明。", + }, + }, + create(context) { + function report(node) { + context.report({ node, messageId: "sleep" }); + } + + return { + CallExpression(node) { + if (node.callee?.type !== "MemberExpression") return; + if (propertyName(node.callee.property) === "waitForTimeout") report(node); + }, + NewExpression(node) { + if (node.callee?.type !== "Identifier" || node.callee.name !== "Promise") return; + const executor = node.arguments[0]; + if (!executor || (executor.type !== "ArrowFunctionExpression" && executor.type !== "FunctionExpression")) + return; + const resolveNames = new Set(); + for (const param of executor.params) if (param.type === "Identifier") resolveNames.add(param.name); + let hasTimer = false; + + const callsResolver = (child) => { + if (!child || typeof child !== "object") return false; + if (child.type === "Identifier" && resolveNames.has(child.name)) return true; + if ( + child.type === "CallExpression" && + child.callee?.type === "Identifier" && + resolveNames.has(child.callee.name) + ) + return true; + return Object.entries(child).some(([key, value]) => { + if (key === "parent" || key === "loc" || key === "range" || key === "tokens" || key === "comments") + return false; + if (Array.isArray(value)) return value.some((item) => callsResolver(item)); + return value && typeof value === "object" && value.type ? callsResolver(value) : false; + }); + }; + + const visit = (child, isRoot = false) => { + if (!child || typeof child !== "object") return; + if (child.type === "CallExpression" && propertyName(child.callee) === "setTimeout") { + const callback = child.arguments[0]; + if (callsResolver(callback)) hasTimer = true; + } + if (!isRoot && (child.type === "ArrowFunctionExpression" || child.type === "FunctionExpression")) return; + for (const [key, value] of Object.entries(child)) { + if (key === "parent" || key === "loc" || key === "range" || key === "tokens" || key === "comments") + continue; + if (Array.isArray(value)) value.forEach((item) => visit(item)); + else if (value && typeof value === "object" && value.type) visit(value); + } + }; + visit(executor.body, true); + if (hasTimer) report(node); + }, + }; + }, +}; diff --git a/eslint-rules/no-test-waitfor-interaction.mjs b/eslint-rules/no-test-waitfor-interaction.mjs new file mode 100644 index 000000000..8f1b8b1d1 --- /dev/null +++ b/eslint-rules/no-test-waitfor-interaction.mjs @@ -0,0 +1,112 @@ +// waitFor 只负责观察异步结果;把交互放进轮询回调会在每次重试时重复触发副作用。 + +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 unwrap(node) { + return node?.type === "ChainExpression" ? node.expression : node; +} + +export default { + meta: { + type: "problem", + docs: { description: "禁止在 Testing Library waitFor 回调中重复触发交互" }, + schema: [], + messages: { + interaction: + "waitFor 回调只能观察结果;请把 fireEvent/userEvent 交互移到轮询回调之外,避免重试时重复触发副作用。", + }, + }, + create(context) { + const importedNames = new Set(); + const waitForNames = new Set(); + const userInstanceVariables = new WeakSet(); + const sourceCode = context.sourceCode; + + 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 isImportedBinding(node, name, names = importedNames) { + const variable = bindingFor(node, name); + return names.has(name) && variable?.defs.some((definition) => definition.type === "ImportBinding"); + } + + function isInteractionCall(node) { + const callee = unwrap(node.callee); + if (!callee || callee.type !== "MemberExpression") return false; + const object = unwrap(callee.object); + if (object?.type !== "Identifier") return false; + return isImportedBinding(node, object.name) || userInstanceVariables.has(bindingFor(node, object.name)); + } + + function walk(node, callback) { + if (!node || typeof node !== "object") return; + if (node.type === "CallExpression" && isInteractionCall(node)) callback(node); + for (const [key, value] of Object.entries(node)) { + if (key === "parent" || key === "loc" || key === "range" || key === "tokens" || key === "comments") continue; + if (Array.isArray(value)) value.forEach((child) => walk(child, callback)); + else if (value && typeof value === "object" && value.type) walk(value, callback); + } + } + + return { + ImportDeclaration(node) { + const source = node.source.value; + for (const specifier of node.specifiers) { + if (source === "@testing-library/react") { + if (specifier.type === "ImportSpecifier" && propertyName(specifier.imported) === "fireEvent") { + importedNames.add(specifier.local.name); + } + if (specifier.type === "ImportSpecifier" && propertyName(specifier.imported) === "waitFor") { + waitForNames.add(specifier.local.name); + } + } + if ( + source === "@testing-library/user-event" && + (specifier.type === "ImportDefaultSpecifier" || + (specifier.type === "ImportSpecifier" && propertyName(specifier.imported) === "userEvent")) + ) { + importedNames.add(specifier.local.name); + } + } + }, + VariableDeclarator(node) { + const init = unwrap(node.init); + if ( + node.id?.type === "Identifier" && + init?.type === "CallExpression" && + init.callee?.type === "MemberExpression" && + propertyName(init.callee.property) === "setup" && + init.callee.object?.type === "Identifier" && + isImportedBinding(node, init.callee.object.name) + ) { + const variable = bindingFor(node, node.id.name); + if (variable) userInstanceVariables.add(variable); + } + }, + CallExpression(node) { + if ( + node.callee?.type !== "Identifier" || + !waitForNames.has(node.callee.name) || + !isImportedBinding(node, node.callee.name, waitForNames) + ) + return; + const callback = node.arguments[0]; + if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) + return; + walk(callback.body, (interaction) => context.report({ node: interaction, messageId: "interaction" })); + }, + }; + }, +}; diff --git a/eslint-rules/no-test-waitfor-query.mjs b/eslint-rules/no-test-waitfor-query.mjs new file mode 100644 index 000000000..6cf47805b --- /dev/null +++ b/eslint-rules/no-test-waitfor-query.mjs @@ -0,0 +1,83 @@ +// 单一的 getBy + toBeInTheDocument 断言应使用 findBy,避免把同步查询包进轮询器。 + +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 unwrap(node) { + return node?.type === "ChainExpression" ? node.expression : node; +} + +function isGetByCall(node) { + const call = unwrap(node); + if (!call || call.type !== "CallExpression") return false; + const callee = unwrap(call.callee); + return callee?.type === "MemberExpression" && /^getBy/.test(propertyName(callee.property) ?? ""); +} + +function isExistenceAssertion(node) { + const assertion = unwrap(node); + if (!assertion || assertion.type !== "CallExpression") return false; + const matcher = unwrap(assertion.callee); + if (matcher?.type !== "MemberExpression" || propertyName(matcher.property) !== "toBeInTheDocument") return false; + const expectCall = unwrap(matcher.object); + return ( + expectCall?.type === "CallExpression" && + propertyName(expectCall.callee) === "expect" && + isGetByCall(expectCall.arguments[0]) + ); +} + +export default { + meta: { + type: "suggestion", + docs: { description: "存在性查询使用 findBy,不要用 waitFor 包装 getBy" }, + schema: [], + messages: { + query: "单一存在性断言请使用 findBy*;waitFor 应保留给多断言或非标准异步边界。", + }, + }, + create(context) { + const waitForNames = new Set(); + const sourceCode = context.sourceCode; + + function isImportedWaitFor(node, name) { + let scope = sourceCode.getScope(node); + while (scope) { + const variable = scope.set.get(name); + if (variable) + return waitForNames.has(name) && variable.defs.some((definition) => definition.type === "ImportBinding"); + scope = scope.upper; + } + return false; + } + + return { + ImportDeclaration(node) { + if (node.source.value !== "@testing-library/react") return; + for (const specifier of node.specifiers) { + if (specifier.type === "ImportSpecifier" && propertyName(specifier.imported) === "waitFor") { + waitForNames.add(specifier.local.name); + } + } + }, + CallExpression(node) { + if (node.callee?.type !== "Identifier" || !isImportedWaitFor(node, node.callee.name)) return; + const callback = node.arguments[0]; + if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) + return; + const body = callback.body; + const expression = + body.type === "BlockStatement" + ? body.body.length === 1 && body.body[0].type === "ExpressionStatement" + ? body.body[0].expression + : null + : body; + if (isExistenceAssertion(expression)) context.report({ node, messageId: "query" }); + }, + }; + }, +}; diff --git a/eslint.config.mjs b/eslint.config.mjs index 8663fd547..6ab084444 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,9 @@ import globals from "globals"; import requireLastErrorCheck from "./eslint-rules/require-last-error-check.mjs"; import noI18nDefaultValue from "./eslint-rules/no-i18n-default-value.mjs"; 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"; export default [ { @@ -44,6 +47,9 @@ export default [ rules: { "no-i18n-default-value": noI18nDefaultValue, "no-raw-color-classname": noRawColorClassname, + "no-test-waitfor-interaction": noTestWaitForInteraction, + "no-test-waitfor-query": noTestWaitForQuery, + "no-test-fixed-sleep": noTestFixedSleep, }, }, }, @@ -91,6 +97,15 @@ export default [ files: ["e2e/**/*.ts"], rules: { "react-hooks/rules-of-hooks": "off", + "scriptcat/no-test-fixed-sleep": "error", + }, + }, + { + files: ["src/pages/**/*.test.{ts,tsx}"], + rules: { + "scriptcat/no-test-waitfor-interaction": "error", + "scriptcat/no-test-waitfor-query": "error", + "scriptcat/no-test-fixed-sleep": "error", }, }, { diff --git a/src/pages/options/onboarding/observe-target.test.ts b/src/pages/options/onboarding/observe-target.test.ts index a6ccc1f98..865c3296d 100644 --- a/src/pages/options/onboarding/observe-target.test.ts +++ b/src/pages/options/onboarding/observe-target.test.ts @@ -28,6 +28,8 @@ describe("observeTarget", () => { const el = document.createElement("div"); el.setAttribute("data-tour", "late"); document.body.appendChild(el); + // 该测试验证 MutationObserver 超时窗口内的异步回调,而非任意 UI 状态。 + // eslint-disable-next-line scriptcat/no-test-fixed-sleep -- observer timing contract await new Promise((r) => setTimeout(r, 50)); expect(cb).toHaveBeenCalledWith(el); }); @@ -39,6 +41,8 @@ describe("observeTarget", () => { const el = document.createElement("div"); el.setAttribute("data-tour", "cancelMe"); document.body.appendChild(el); + // 该测试验证 stop 后观察窗口关闭,必须经过真实窗口才能证明没有回调。 + // eslint-disable-next-line scriptcat/no-test-fixed-sleep -- observer cleanup timing contract await new Promise((r) => setTimeout(r, 50)); expect(cb).not.toHaveBeenCalled(); }); @@ -46,6 +50,8 @@ describe("observeTarget", () => { it("超时后应回调 null", async () => { const cb = vi.fn(); observeTarget("never", cb, { timeout: 30 }); + // 超时行为的契约就是在 timeout 后回调 null。 + // eslint-disable-next-line scriptcat/no-test-fixed-sleep -- observer timeout contract await new Promise((r) => setTimeout(r, 80)); expect(cb).toHaveBeenCalledWith(null); }); diff --git a/src/pages/options/routes/Agent/OPFS/index.test.tsx b/src/pages/options/routes/Agent/OPFS/index.test.tsx index 63dfe1b63..a0bc266f8 100644 --- a/src/pages/options/routes/Agent/OPFS/index.test.tsx +++ b/src/pages/options/routes/Agent/OPFS/index.test.tsx @@ -331,7 +331,7 @@ describe("AgentOPFS 页面", () => { expect(screen.queryByTestId("opfs-loading")).not.toBeInTheDocument(); expect(screen.getByText("file1.txt")).toBeInTheDocument(); releaseSecondList(); - await waitFor(() => expect(screen.getByText("file1.txt")).toBeInTheDocument()); + expect(await screen.findByText("file1.txt")).toBeInTheDocument(); }); it("移动端:页内工具行为图标按钮(无可见文案标签)+ 标题作为页内标题", async () => { diff --git a/src/pages/options/routes/ScriptList/components.test.tsx b/src/pages/options/routes/ScriptList/components.test.tsx index ab63b7937..3905f8c25 100644 --- a/src/pages/options/routes/ScriptList/components.test.tsx +++ b/src/pages/options/routes/ScriptList/components.test.tsx @@ -407,6 +407,8 @@ describe("ScheduleNextRun 定时脚本下次运行时间", () => { const trigger = screen.getByText("2026-06-25 08:00:00").closest('[data-slot="tooltip-trigger"]')!; await act(async () => { fireEvent.pointerMove(trigger, { pointerType: "mouse" }); + // Radix Tooltip 的 delayDuration=0 仍通过真实 timer 刷新 Portal;这是该时序契约本身。 + // eslint-disable-next-line scriptcat/no-test-fixed-sleep -- Radix Tooltip timer contract await new Promise((r) => setTimeout(r, 20)); }); // Tooltip 内容经 Portal 渲染,含完整文案与 cron 表达式(Radix 会额外渲染一份无障碍副本,故用 getAllByText) diff --git a/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx b/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx index aae70394e..6afdbeded 100644 --- a/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { Route, Routes } from "react-router-dom"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; @@ -80,12 +80,6 @@ function renderPage(client: NetworkRuleClient) { ); } -async function flush() { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); -} - // 行勾选框与翻页按钮都按 aria-label 取:整页 role 扫描要给每个同角色元素算一遍可访问名, // 20 行的表上单次 *ByRole 就要 20~70ms,够把这一文件顶出 ui 项目 850ms 的预算。 function selectRow(name: string) { @@ -107,10 +101,7 @@ function argsOf(mock: unknown) { } describe("网络规则批量操作", () => { - // 整页挂载 + 两次勾选 + 一次批量往返:本地 solo 覆盖率下 320ms,而 GitHub runner 约慢 2.5 倍, - // 本文件首例还要多付一次冷启动,850ms 的 ui 预算在 CI 上连挂两轮。给这一条单独放宽, - // 预算对其余用例保持不变。 - it("未选中时没有操作栏,选中两行后批量停用只对这两条发出请求", { timeout: 1500 }, async () => { + it("未选中时没有操作栏,选中两行后批量停用只对这两条发出请求", async () => { const client = clientFor([rule(1), rule(2), rule(3)]); renderPage(client); expect(await screen.findByText("规则 1")).toBeInTheDocument(); @@ -121,11 +112,12 @@ describe("网络规则批量操作", () => { expect(within(bulkBar()).getByText("已选 2 条")).toBeInTheDocument(); clickBulk("停用"); - await flush(); - // 一次用户操作只发一次请求:服务端在同一次写入里改完这两条。 - expect(argsOf(client.setRulesEnabled)).toEqual([{ baseRevision: 3, ids: ["r1", "r3"], enabled: false }]); - expect(screen.queryByRole("toolbar")).not.toBeInTheDocument(); + await waitFor(() => { + // 一次用户操作只发一次请求:服务端在同一次写入里改完这两条。 + expect(argsOf(client.setRulesEnabled)).toEqual([{ baseRevision: 3, ids: ["r1", "r3"], enabled: false }]); + expect(screen.queryByRole("toolbar")).not.toBeInTheDocument(); + }); }); it("批量删除在确认前不动手,确认框提示可以改用停用", async () => { @@ -136,19 +128,19 @@ describe("网络规则批量操作", () => { selectRow("规则 1"); selectRow("规则 3"); clickBulk("删除"); - await flush(); expect(client.deleteRules).not.toHaveBeenCalled(); - const dialog = screen.getByRole("alertdialog"); + const dialog = await screen.findByRole("alertdialog"); expect(within(dialog).getByText("删除选中的 2 条规则?")).toBeInTheDocument(); expect(within(dialog).getByText(/停用/)).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole("button", { name: "删除规则" })); - await flush(); - expect(argsOf(client.deleteRules)).toEqual([{ baseRevision: 3, ids: ["r1", "r3"] }]); - expect(screen.queryByText("规则 1")).not.toBeInTheDocument(); - expect(screen.queryByRole("toolbar")).not.toBeInTheDocument(); + await waitFor(() => { + expect(argsOf(client.deleteRules)).toEqual([{ baseRevision: 3, ids: ["r1", "r3"] }]); + expect(screen.queryByText("规则 1")).not.toBeInTheDocument(); + expect(screen.queryByRole("toolbar")).not.toBeInTheDocument(); + }); }); it("批量启用只处理当前停用的规则,含「所有网站」时仍需二次确认", async () => { @@ -164,13 +156,14 @@ describe("网络规则批量操作", () => { selectRow("规则 2"); selectRow("规则 3"); clickBulk("启用"); - await flush(); expect(client.setRulesEnabled).not.toHaveBeenCalled(); - fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "继续" })); - await flush(); + const dialog = await screen.findByRole("alertdialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "继续" })); - expect(argsOf(client.setRulesEnabled)).toEqual([{ baseRevision: 3, ids: ["r2", "r3"], enabled: true }]); + await waitFor(() => + expect(argsOf(client.setRulesEnabled)).toEqual([{ baseRevision: 3, ids: ["r2", "r3"], enabled: true }]) + ); }); it("批量删除被拒绝时一条都没删,列表与选中项原样保留", async () => { @@ -184,18 +177,19 @@ describe("网络规则批量操作", () => { selectRow("规则 2"); selectRow("规则 3"); clickBulk("删除"); - await flush(); - fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "删除规则" })); - await flush(); + const dialog = await screen.findByRole("alertdialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "删除规则" })); // 全体或全不:整批被拒绝时不能有任何一条已经消失。 - expect(client.deleteRules).toHaveBeenCalledTimes(1); - for (const name of ["规则 1", "规则 2", "规则 3"]) { - expect(screen.getByText(name)).toBeInTheDocument(); - } - expect(within(bulkBar()).getByText("已选 3 条")).toBeInTheDocument(); - expect(notify.error).toHaveBeenCalledTimes(1); - expect(notify.success).not.toHaveBeenCalled(); + await waitFor(() => { + expect(client.deleteRules).toHaveBeenCalledTimes(1); + for (const name of ["规则 1", "规则 2", "规则 3"]) { + expect(screen.getByText(name)).toBeInTheDocument(); + } + expect(within(bulkBar()).getByText("已选 3 条")).toBeInTheDocument(); + expect(notify.error).toHaveBeenCalledTimes(1); + expect(notify.success).not.toHaveBeenCalled(); + }); }); it("翻页会清空选择,操作栏随之消失", async () => { diff --git a/src/pages/options/routes/Tools/NetworkRules/DragAccessibility.test.tsx b/src/pages/options/routes/Tools/NetworkRules/DragAccessibility.test.tsx index 2e2653149..621a4b921 100644 --- a/src/pages/options/routes/Tools/NetworkRules/DragAccessibility.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/DragAccessibility.test.tsx @@ -128,6 +128,7 @@ describe("网络规则拖拽可达性", () => { // KeyboardSensor 的 keydown 监听器在 setTimeout 里挂载,激活后必须让出一次事件循环。 await act(async () => { + // eslint-disable-next-line scriptcat/no-test-fixed-sleep -- dnd-kit listener registration contract await new Promise((resolve) => setTimeout(resolve, 0)); }); fireEvent.keyDown(handle, { code: "ArrowUp" }); diff --git a/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx b/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx index c6c8659d4..6949ad7c4 100644 --- a/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { Route, Routes } from "react-router-dom"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; @@ -83,17 +83,10 @@ function renderPage(client: NetworkRuleClient) { ); } -async function flush() { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); -} - async function openRowMenu(row: HTMLElement) { const trigger = within(row).getByRole("button", { name: "更多操作" }); fireEvent.pointerDown(trigger, { button: 0 }); fireEvent.click(trigger); - await flush(); } describe("网络规则的成败反馈", () => { @@ -107,11 +100,12 @@ describe("网络规则的成败反馈", () => { await openRowMenu(screen.getAllByTestId("network-rule-row")[1]); fireEvent.click(await screen.findByRole("menuitem", { name: "置顶" })); - await flush(); - expect(client.reorderRules).toHaveBeenCalled(); - expect(notify.error).toHaveBeenCalledWith("规则已保存,但浏览器规则未能更新。"); - expect(notify.success).not.toHaveBeenCalled(); + await waitFor(() => { + expect(client.reorderRules).toHaveBeenCalled(); + expect(notify.error).toHaveBeenCalledWith("规则已保存,但浏览器规则未能更新。"); + expect(notify.success).not.toHaveBeenCalled(); + }); }); it("删除保存成功但浏览器未接受时只报失败,不同时弹成功", async () => { @@ -124,13 +118,13 @@ describe("网络规则的成败反馈", () => { await openRowMenu(screen.getAllByTestId("network-rule-row")[0]); fireEvent.click(await screen.findByRole("menuitem", { name: "删除" })); - await flush(); - fireEvent.click(screen.getByRole("button", { name: "删除规则" })); - await flush(); + fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "删除规则" })); - expect(client.deleteRules).toHaveBeenCalled(); - expect(notify.error).toHaveBeenCalledWith("规则已保存,但浏览器规则未能更新。"); - expect(notify.success).not.toHaveBeenCalled(); + await waitFor(() => { + expect(client.deleteRules).toHaveBeenCalled(); + expect(notify.error).toHaveBeenCalledWith("规则已保存,但浏览器规则未能更新。"); + expect(notify.success).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx b/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx index 159eddf1e..56c8940e3 100644 --- a/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, screen, within } from "@testing-library/react"; import { Route, Routes } from "react-router-dom"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; @@ -78,21 +78,14 @@ function renderPage(client: NetworkRuleClient) { ); } -async function flush() { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); -} - describe("网络规则空态", () => { it("给出常用场景入口,点一下直接进到该场景的表单", async () => { renderPage(clientFor([])); expect(await screen.findByText("从一个常用场景开始,或自己新建一条")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "移除 CSP" })); - await flush(); - const sheet = screen.getByRole("dialog"); + const sheet = await screen.findByRole("dialog"); // 直接落在第二步:场景徽标已经是「移除 CSP」,不需要用户再选一次。 expect(within(sheet).getByText("更换类型")).toBeInTheDocument(); expect(within(sheet).getByLabelText("应用范围")).toBeInTheDocument(); @@ -117,7 +110,7 @@ describe("网络规则编辑抽屉的「试一试」", () => { async function openTemplate(name: string) { renderPage(clientFor([])); fireEvent.click(await screen.findByRole("button", { name })); - await flush(); + await screen.findByRole("dialog"); } it("命中时说明会发生什么,而不只是「匹配」两个字", async () => { @@ -141,8 +134,7 @@ describe("网络规则编辑抽屉的「试一试」", () => { const sheet = screen.getByRole("dialog"); fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } }); fireEvent.click(within(sheet).getByRole("button", { name: "高级选项" })); - await flush(); - fireEvent.click(within(sheet).getByRole("checkbox", { name: "图片" })); + fireEvent.click(await within(sheet).findByRole("checkbox", { name: "图片" })); fireEvent.change(screen.getByLabelText("试一试"), { target: { value: "https://example.com/page" } }); expect( @@ -155,16 +147,13 @@ describe("网络规则的请求头黑名单", () => { it("在输入之前就常驻说明哪些请求头不能改写", async () => { renderPage(clientFor([])); fireEvent.click(await screen.findByRole("button", { name: "自定义" })); - await flush(); // 「自定义」默认动作是屏蔽请求,没有请求头可填,说明也就不该出现。 expect(screen.queryByText(/不允许改写/)).not.toBeInTheDocument(); // Radix Select 在 happy-dom 下靠键盘打开,与 index.test.tsx 的 pickOption 一致。 fireEvent.keyDown(screen.getByRole("combobox", { name: "动作类型" }), { key: "Enter" }); - await flush(); fireEvent.click(await screen.findByRole("option", { name: "改请求头" })); - await flush(); expect(screen.getByText("Cookie、Authorization、Host、Origin 不允许改写。")).toBeInTheDocument(); }); diff --git a/src/pages/options/routes/Tools/NetworkRules/index.test.tsx b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx index da9f1a8a8..6367385d8 100644 --- a/src/pages/options/routes/Tools/NetworkRules/index.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { Route, Routes } from "react-router-dom"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; @@ -106,6 +106,8 @@ function stubRowRects() { /** KeyboardSensor 的 keydown 监听器在 setTimeout 里挂载,激活后必须让出一次事件循环。 */ async function flush() { await act(async () => { + // dnd-kit KeyboardSensor 在 setTimeout 中注册监听器,必须让出一次事件循环才能继续驱动键盘序列。 + // eslint-disable-next-line scriptcat/no-test-fixed-sleep -- dnd-kit listener registration contract await new Promise((resolve) => setTimeout(resolve, 0)); }); } @@ -114,7 +116,6 @@ async function openRowMenu(row: HTMLElement) { const trigger = within(row).getByRole("button", { name: "更多操作" }); fireEvent.pointerDown(trigger, { button: 0 }); fireEvent.click(trigger); - await flush(); } describe("网络规则列表页", () => { @@ -139,9 +140,9 @@ describe("网络规则列表页", () => { fireEvent.keyDown(handle, { code: "ArrowUp" }); fireEvent.keyDown(handle, { code: "ArrowUp" }); fireEvent.keyDown(handle, { code: "Space" }); - await flush(); - - expect(client.reorderRules).toHaveBeenCalledWith({ baseRevision: 3, order: ["r3", "r1", "r2"] }); + await waitFor(() => + expect(client.reorderRules).toHaveBeenCalledWith({ baseRevision: 3, order: ["r3", "r1", "r2"] }) + ); expect(rowNames()).toEqual(["规则 3", "规则 1", "规则 2"]); }); @@ -159,11 +160,11 @@ describe("网络规则列表页", () => { fireEvent.keyDown(handle, { code: "ArrowUp" }); fireEvent.keyDown(handle, { code: "ArrowUp" }); fireEvent.keyDown(handle, { code: "Space" }); - await flush(); - - expect(client.reorderRules).toHaveBeenCalled(); - expect(rowNames()).toEqual(["规则 1", "规则 2", "规则 3"]); - expect(notify.error).toHaveBeenCalledWith("顺序未能保存,已恢复原顺序。"); + await waitFor(() => { + expect(client.reorderRules).toHaveBeenCalled(); + expect(rowNames()).toEqual(["规则 1", "规则 2", "规则 3"]); + expect(notify.error).toHaveBeenCalledWith("顺序未能保存,已恢复原顺序。"); + }); }); it("搜索时手柄置灰,但行菜单的置顶仍能跨页移动规则", async () => { @@ -183,7 +184,7 @@ describe("网络规则列表页", () => { await openRowMenu(row); fireEvent.click(await screen.findByRole("menuitem", { name: "置顶" })); - await flush(); + await waitFor(() => expect(client.reorderRules).toHaveBeenCalled()); const order = vi.mocked(client.reorderRules).mock.calls[0][0].order; expect(order).toHaveLength(total); @@ -215,7 +216,7 @@ describe("网络规则列表页", () => { fireEvent.click(await screen.findByRole("menuitem", { name: "移到…" })); fireEvent.change(await screen.findByRole("spinbutton"), { target: { value: "3" } }); fireEvent.click(screen.getByRole("button", { name: "移动" })); - await flush(); + await waitFor(() => expect(client.reorderRules).toHaveBeenCalled()); const order = vi.mocked(client.reorderRules).mock.calls[0][0].order; expect(order).toEqual(["r2", "r3", "r1", "r4"]); @@ -253,8 +254,7 @@ describe("网络规则列表页", () => { expect(await screen.findByText("规则未能应用到浏览器")).toBeInTheDocument(); expect(screen.getByText(/Rule limit exceeded/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "重试" })); - await flush(); - expect(client.retryApply).toHaveBeenCalled(); + await waitFor(() => expect(client.retryApply).toHaveBeenCalled()); }); }); @@ -270,25 +270,20 @@ const TEMPLATE_NAMES = [ async function openCreateSheet() { fireEvent.click(screen.getByRole("button", { name: "新建规则" })); - await flush(); } async function pickTemplate(name: string) { fireEvent.click(await screen.findByRole("button", { name: new RegExp(name) })); - await flush(); } async function pickOption(comboboxName: string, optionText: string) { fireEvent.keyDown(screen.getByRole("combobox", { name: comboboxName }), { key: "Enter" }); - await flush(); fireEvent.click(await screen.findByRole("option", { name: optionText })); - await flush(); } async function openRowAction(row: HTMLElement, item: string) { await openRowMenu(row); fireEvent.click(await screen.findByRole("menuitem", { name: item })); - await flush(); } describe("网络规则编辑抽屉", () => { @@ -316,8 +311,9 @@ describe("网络规则编辑抽屉", () => { // 黑名单说明常驻在这一栏,所以这里要认的是报错本身,而不是页面上出现了这几个字。 expect(screen.getByRole("alert")).toHaveTextContent("会暴露或伪造调用者身份"); - fireEvent.click(screen.getByRole("button", { name: "保存" })); - await flush(); + const save = screen.getByRole("button", { name: "保存" }); + expect(save).toBeDisabled(); + fireEvent.click(save); expect(client.createRule).not.toHaveBeenCalled(); }); @@ -341,22 +337,23 @@ describe("网络规则编辑抽屉", () => { fireEvent.click(screen.getByRole("checkbox", { name: "同时移除 X-Frame-Options" })); fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "github.com" } }); fireEvent.click(screen.getByRole("button", { name: "保存" })); - await flush(); - expect(client.createRule).toHaveBeenCalledWith( - expect.objectContaining({ - condition: expect.objectContaining({ requestDomains: ["github.com"] }), - action: { - type: "removeResponseHeaders", - headers: [ - "content-security-policy", - "content-security-policy-report-only", - "x-content-security-policy", - "x-webkit-csp", - "x-frame-options", - ], - }, - }) + await waitFor(() => + expect(client.createRule).toHaveBeenCalledWith( + expect.objectContaining({ + condition: expect.objectContaining({ requestDomains: ["github.com"] }), + action: { + type: "removeResponseHeaders", + headers: [ + "content-security-policy", + "content-security-policy-report-only", + "x-content-security-policy", + "x-webkit-csp", + "x-frame-options", + ], + }, + }) + ) ); }); @@ -370,7 +367,6 @@ describe("网络规则编辑抽屉", () => { expect(screen.queryByRole("button", { name: /屏蔽请求/ })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "更换类型" })); - await flush(); for (const name of TEMPLATE_NAMES) { expect(screen.getByRole("button", { name: new RegExp(name) })).toBeInTheDocument(); } @@ -379,9 +375,10 @@ describe("网络规则编辑抽屉", () => { expect(screen.getByLabelText("应用范围")).toHaveValue("s1.example.com"); fireEvent.click(screen.getByRole("button", { name: "保存" })); - await flush(); - expect(client.updateRule).toHaveBeenCalledWith( - expect.objectContaining({ id: "r1", patch: expect.objectContaining({ action: { type: "block" } }) }) + await waitFor(() => + expect(client.updateRule).toHaveBeenCalledWith( + expect.objectContaining({ id: "r1", patch: expect.objectContaining({ action: { type: "block" } }) }) + ) ); }); @@ -400,7 +397,6 @@ describe("网络规则编辑抽屉", () => { expect(screen.getByText("每条规则请输入 1 至 100 个域名。")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "保存" })); - await flush(); expect(client.createRule).not.toHaveBeenCalled(); }); @@ -413,14 +409,14 @@ describe("网络规则编辑抽屉", () => { await pickTemplate("屏蔽请求"); fireEvent.click(screen.getByRole("checkbox", { name: /所有网站/ })); fireEvent.click(screen.getByRole("button", { name: "保存" })); - await flush(); expect(client.createRule).not.toHaveBeenCalled(); - expect(screen.getByText("影响所有网站?")).toBeInTheDocument(); + expect(await screen.findByText("影响所有网站?")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "继续" })); - await flush(); - expect(client.createRule).toHaveBeenCalledWith( - expect.objectContaining({ condition: expect.objectContaining({ urlFilter: "*" }) }) + await waitFor(() => + expect(client.createRule).toHaveBeenCalledWith( + expect.objectContaining({ condition: expect.objectContaining({ urlFilter: "*" }) }) + ) ); }); @@ -434,8 +430,7 @@ describe("网络规则编辑抽屉", () => { expect(screen.getByText(/停用/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "删除规则" })); - await flush(); - expect(client.deleteRules).toHaveBeenCalledWith({ baseRevision: 3, ids: ["r1"] }); + await waitFor(() => expect(client.deleteRules).toHaveBeenCalledWith({ baseRevision: 3, ids: ["r1"] })); }); }); diff --git a/src/pages/preloadable-query.test.tsx b/src/pages/preloadable-query.test.tsx index 2afc9d316..7cedec310 100644 --- a/src/pages/preloadable-query.test.tsx +++ b/src/pages/preloadable-query.test.tsx @@ -16,7 +16,9 @@ describe("useQuery error behavior", () => { const { result } = renderHook(() => query.useQuery("resources")); await waitFor(() => expect(result.current.isError).toBe(true)); - await new Promise((r) => setTimeout(r, 100)); // give any runaway loop time to spin + // 负向回归需要让潜在的重试循环运行一个有限窗口;没有可等待的完成事件。 + // eslint-disable-next-line scriptcat/no-test-fixed-sleep -- runaway retry observation window + await new Promise((r) => setTimeout(r, 100)); expect(load.mock.calls.length).toBeLessThanOrEqual(2); expect(result.current.status).toBe("error");