diff --git a/AGENTS.md b/AGENTS.md index 559613023..c46b70f4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,9 @@ downstream prose does not override it. - **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. + establish a root-cause fix; report the trigger, evidence, and remaining uncertainty. Follow the asynchronous + observation and timing guidance in + [`docs/references/develop-testing.md`](docs/references/develop-testing.md#observation-rules-for-asynchronous-tests). - **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. @@ -74,6 +76,13 @@ downstream prose does not override it. 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). +- **Dnd-kit list rendering must keep the drag boundary cheap.** Keep sensor options, modifiers, callbacks, and the + sortable item-list reference stable when their values are unchanged; render plain rows/cards while dragging is + disabled instead of mounting `DndContext`/`SortableContext`. Stabilize item identity with a collision-safe + representation, never delimiter-join IDs unless the ID contract forbids that delimiter, and use default shallow + memo comparison so rule fields and interaction state cannot be skipped. The Network Rules implementation is the + reference in `src/pages/options/routes/Tools/NetworkRules/RuleTable.tsx` and + `src/pages/options/routes/Tools/NetworkRules/RuleCards.tsx`. - **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/references/develop-testing.md b/docs/references/develop-testing.md index 90c3e7961..fab471b13 100644 --- a/docs/references/develop-testing.md +++ b/docs/references/develop-testing.md @@ -21,6 +21,13 @@ Before modifying a test, shared test helper, or runner configuration, classify t 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. +For a timing cleanup, first classify the cost as contract-required elapsed time, async query polling, fixture/render +work, or worker contention. Preserve `findBy*` when the element's appearance is the boundary; replace it with one +`act` plus a direct assertion only when the test owns the already-resolved Promise or completion signal. For a +production timer, use fake timers to advance the real configured duration and keep the state-transition assertions; +do not shorten the production delay, replace the assertion with a weaker signal, or raise the test timeout to make the +report green. + ### 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 diff --git a/src/app/service/agent/service_worker/dom_cdp.test.ts b/src/app/service/agent/service_worker/dom_cdp.test.ts index 42d4a27e4..f09cfa183 100644 --- a/src/app/service/agent/service_worker/dom_cdp.test.ts +++ b/src/app/service/agent/service_worker/dom_cdp.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest"; // mock chrome.debugger 和 chrome.tabs const mockSendCommand = vi.fn(); @@ -53,11 +53,16 @@ function setupClickMocks(hitTestValue: string) { describe("agent_dom_cdp", () => { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); }); + afterEach(() => vi.useRealTimers()); + it("cdpClick 在元素未被遮挡时正常点击", async () => { setupClickMocks("hit"); - const result = await cdpClick(999, "#btn"); + const pending = cdpClick(999, "#btn"); + await vi.advanceTimersByTimeAsync(500); + const result = await pending; expect(result.success).toBe(true); // 验证 dispatchMouseEvent 被调用(mousePressed + mouseReleased) const mouseEvents = mockSendCommand.mock.calls.filter((c: unknown[]) => c[1] === "Input.dispatchMouseEvent"); diff --git a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts index 782f53c8f..57bc17064 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -246,33 +246,47 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( // 持久化重试退避 200ms + 400ms,放宽超时 { timeout: 3000 }, async () => { - chatRepo.appendMessage.mockRejectedValue(new Error("disk full")); + vi.useFakeTimers(); + try { + chatRepo.appendMessage.mockRejectedValue(new Error("disk full")); + callLLM.mockResolvedValue({ + content: "回复", + contentBlocks: [{ type: "image", attachmentId: "img_lost.png", mimeType: "image/png" }], + usage: { inputTokens: 3, outputTokens: 2 }, + } as LLMCallResult); + + const pending = orchestrator.callLLMWithToolLoop(baseParams()); + await vi.runAllTimersAsync(); + await pending; + + const terminal = sendEvent.mock.calls.map((c) => c[0]).find((e) => e.type === "error"); + expect(terminal?.errorCode).toBe("persist_failed"); + expect(chatRepo.deleteAttachment).toHaveBeenCalledWith("img_lost.png"); + } finally { + vi.useRealTimers(); + } + } + ); + + it("最终回复持久化报错且确认读也失败时,不应删除可能已被消息引用的生成附件", { timeout: 3000 }, async () => { + vi.useFakeTimers(); + try { + chatRepo.appendMessage.mockRejectedValue(new Error("ambiguous close failure")); + chatRepo.getMessageSnapshot.mockRejectedValue(new Error("confirmation read failed")); callLLM.mockResolvedValue({ content: "回复", - contentBlocks: [{ type: "image", attachmentId: "img_lost.png", mimeType: "image/png" }], + contentBlocks: [{ type: "image", attachmentId: "img_maybe_committed.png", mimeType: "image/png" }], usage: { inputTokens: 3, outputTokens: 2 }, } as LLMCallResult); - await orchestrator.callLLMWithToolLoop(baseParams()); + const pending = orchestrator.callLLMWithToolLoop(baseParams()); + await vi.runAllTimersAsync(); + await pending; - const terminal = sendEvent.mock.calls.map((c) => c[0]).find((e) => e.type === "error"); - expect(terminal?.errorCode).toBe("persist_failed"); - expect(chatRepo.deleteAttachment).toHaveBeenCalledWith("img_lost.png"); + expect(chatRepo.deleteAttachment).not.toHaveBeenCalledWith("img_maybe_committed.png"); + } finally { + vi.useRealTimers(); } - ); - - it("最终回复持久化报错且确认读也失败时,不应删除可能已被消息引用的生成附件", { timeout: 3000 }, async () => { - chatRepo.appendMessage.mockRejectedValue(new Error("ambiguous close failure")); - chatRepo.getMessageSnapshot.mockRejectedValue(new Error("confirmation read failed")); - callLLM.mockResolvedValue({ - content: "回复", - contentBlocks: [{ type: "image", attachmentId: "img_maybe_committed.png", mimeType: "image/png" }], - usage: { inputTokens: 3, outputTokens: 2 }, - } as LLMCallResult); - - await orchestrator.callLLMWithToolLoop(baseParams()); - - expect(chatRepo.deleteAttachment).not.toHaveBeenCalledWith("img_maybe_committed.png"); }); it("工具结果持久化期间被取消时应立即终态化,而不是带着已取消的信号进入下一轮", async () => { diff --git a/src/pages/batchupdate/hooks.test.ts b/src/pages/batchupdate/hooks.test.ts index 863f7c268..81a3ef00c 100644 --- a/src/pages/batchupdate/hooks.test.ts +++ b/src/pages/batchupdate/hooks.test.ts @@ -240,13 +240,22 @@ describe("批量更新 Hook useBatchUpdate 行级状态", () => { h.requestBatchUpdateListAction.mockResolvedValueOnce(okItem("a")); const { result } = await setup([mkRecord("a"), mkRecord("b")]); - await act(async () => result.current.onUpdate(result.current.updates[0])); + vi.useFakeTimers(); + try { + await act(async () => result.current.onUpdate(result.current.updates[0])); - expect(result.current.rowStates.a.phase).toBe("success"); - expect(result.current.updates.map((u) => u.uuid)).toEqual(["a", "b"]); + expect(result.current.rowStates.a.phase).toBe("success"); + expect(result.current.updates.map((u) => u.uuid)).toEqual(["a", "b"]); - await waitFor(() => expect(result.current.rowStates.a?.phase).toBe("exiting"), { timeout: 3000 }); - await waitFor(() => expect(result.current.updates.map((u) => u.uuid)).toEqual(["b"]), { timeout: 3000 }); + await act(async () => vi.advanceTimersByTimeAsync(450)); + expect(result.current.rowStates.a?.phase).toBe("exiting"); + + await act(async () => vi.advanceTimersByTimeAsync(220)); + expect(result.current.rowStates.a).toBeUndefined(); + expect(result.current.updates.map((u) => u.uuid)).toEqual(["b"]); + } finally { + vi.useRealTimers(); + } }); }); @@ -294,20 +303,26 @@ describe("批量更新 Hook useBatchUpdate 批量进度", () => { ); const { result } = await setup([mkRecord("a"), mkRecord("b")]); - act(() => result.current.onUpdate(result.current.updates[0])); + vi.useFakeTimers(); + try { + act(() => result.current.onUpdate(result.current.updates[0])); - // 服务端装完即广播刷新;此时页面仍在展示 a 的进行中状态,不能被全量刷新冲掉 - h.record = { checktime: 300, list: [mkRecord("b")] }; - await act(async () => h.handlers.onScriptUpdateCheck({ refreshRecord: true })); + // 服务端装完即广播刷新;此时页面仍在展示 a 的进行中状态,不能被全量刷新冲掉 + h.record = { checktime: 300, list: [mkRecord("b")] }; + await act(async () => h.handlers.onScriptUpdateCheck({ refreshRecord: true })); - expect(result.current.rowStates.a.phase).toBe("working"); - expect(result.current.updates.map((u) => u.uuid)).toEqual(["a", "b"]); + expect(result.current.rowStates.a.phase).toBe("working"); + expect(result.current.updates.map((u) => u.uuid)).toEqual(["a", "b"]); - await act(async () => resolveFirst(okItem("a"))); - expect(result.current.rowStates.a.phase).toBe("success"); + await act(async () => resolveFirst(okItem("a"))); + expect(result.current.rowStates.a.phase).toBe("success"); - // 行退场后才补做那次被推迟的全量刷新 - await waitFor(() => expect(result.current.updates.map((u) => u.uuid)).toEqual(["b"]), { timeout: 3000 }); + // 行退场后才补做那次被推迟的全量刷新 + await act(async () => vi.runAllTimersAsync()); + expect(result.current.updates.map((u) => u.uuid)).toEqual(["b"]); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/src/pages/options/routes/Agent/Chat/MessageToolbar.test.tsx b/src/pages/options/routes/Agent/Chat/MessageToolbar.test.tsx index c9251b4c1..25ab8077d 100644 --- a/src/pages/options/routes/Agent/Chat/MessageToolbar.test.tsx +++ b/src/pages/options/routes/Agent/Chat/MessageToolbar.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; -import { render, cleanup, screen, fireEvent } from "@testing-library/react"; +import { act, render, cleanup, screen, fireEvent } from "@testing-library/react"; import { t } from "@App/locales/locales"; import { initTestLanguage } from "@Tests/initTestLanguage"; import MessageToolbar, { type MessageToolbarProps } from "./MessageToolbar"; @@ -7,6 +7,12 @@ import MessageToolbar, { type MessageToolbarProps } from "./MessageToolbar"; beforeAll(() => initTestLanguage("zh-CN")); afterEach(() => cleanup()); +async function settle() { + await act(async () => { + await Promise.resolve(); + }); +} + const baseProps = (over?: Partial): MessageToolbarProps => ({ toolCallCount: 0, onCopy: vi.fn(), @@ -31,7 +37,10 @@ describe("消息工具栏 MessageToolbar", () => { render(); fireEvent.click(screen.getByTestId("toolbar-delete")); expect(onDelete).not.toHaveBeenCalled(); - fireEvent.click(await screen.findByText(t("common:confirm"), { selector: "button" })); + await settle(); + const confirm = screen.getByTestId("popconfirm-confirm"); + expect(confirm).toHaveTextContent(t("common:confirm")); + fireEvent.click(confirm); expect(onDelete).toHaveBeenCalledOnce(); }); diff --git a/src/pages/options/routes/Agent/Provider/ModelFormDialog.test.tsx b/src/pages/options/routes/Agent/Provider/ModelFormDialog.test.tsx index ef2ee7678..26960f604 100644 --- a/src/pages/options/routes/Agent/Provider/ModelFormDialog.test.tsx +++ b/src/pages/options/routes/Agent/Provider/ModelFormDialog.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; -import { render, cleanup, screen, fireEvent } from "@testing-library/react"; +import { act, render, cleanup, screen, fireEvent } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { ModelFormDialog } from "./ModelFormDialog"; import { getDefaultBaseUrl } from "./provider_api"; @@ -7,6 +7,12 @@ import { getDefaultBaseUrl } from "./provider_api"; beforeAll(() => initTestLanguage("zh-CN")); afterEach(() => cleanup()); +async function settle() { + await act(async () => { + await Promise.resolve(); + }); +} + function setup(props: Record = {}) { const onSubmit = vi.fn(); const onTest = vi.fn(async () => ({ ok: true, latencyMs: 12 })); @@ -33,11 +39,12 @@ describe("ModelFormDialog 模型表单弹窗", () => { // 拉取可用模型列表 -> 填充下拉选项(异步,需等待 state 更新后再展开下拉) fireEvent.click(screen.getByTestId("model-fetch")); expect(onFetchModels).toHaveBeenCalled(); + await settle(); // 用键盘展开 Radix Select(测试环境下 pointerDown 不触发其打开),再选择拉取到的模型 const trigger = screen.getByTestId("model-id"); trigger.focus(); fireEvent.keyDown(trigger, { key: "ArrowDown" }); - fireEvent.click(await screen.findByTestId("model-option-gpt-4o")); + fireEvent.click(screen.getByTestId("model-option-gpt-4o")); fireEvent.click(screen.getByTestId("model-submit")); expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: "My GPT", model: "gpt-4o" })); }); diff --git a/src/pages/options/routes/Tools/NetworkRules/ActionColors.test.tsx b/src/pages/options/routes/Tools/NetworkRules/ActionColors.test.tsx index 4919e4293..662abeb5d 100644 --- a/src/pages/options/routes/Tools/NetworkRules/ActionColors.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/ActionColors.test.tsx @@ -1,9 +1,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; import { Route, Routes } from "react-router-dom"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; -import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter"; +import { renderWithRouter } from "@Tests/renderWithThemeRouter"; import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule"; import type { NetworkRuleClient } from "@App/app/service/service_worker/client"; import type { NetworkRuleSnapshot } from "@App/app/service/service_worker/network_rule"; @@ -39,12 +39,12 @@ function rule(id: string, name: string, action: NetworkRule["action"]): NetworkR }; } -function renderPage(rules: NetworkRule[]) { +async function renderPage(rules: NetworkRule[]) { const snapshot: NetworkRuleSnapshot = { state: { schemaVersion: 1, revision: 3, masterEnabled: true, rules, order: rules.map((r) => r.id) }, apply: { state: "applied", revision: 3, appliedAt: 1 }, }; - renderWithThemeRouter( + renderWithRouter( , { initialEntries: ["/tools/network-rules"] } ); + await act(async () => { + await Promise.resolve(); + }); } /** 模板卡的图标片是按钮的第一个子元素,颜色只落在它身上,标题与描述用的是通用文字色。 */ @@ -76,7 +79,7 @@ describe("网络规则的动作配色", () => { }); it("列表页的动作徽标按动作类型上色,而不是一律中性", async () => { - renderPage([ + await renderPage([ rule("r1", "移除 CSP", cspRemovalAction()), rule("r2", "屏蔽上报", { type: "block" }), rule("r3", "改 UA", { @@ -85,7 +88,7 @@ describe("网络规则的动作配色", () => { }), ]); - expect((await screen.findByText("移除响应头")).className).toContain(ACTION_TONES.removeResponseHeaders); + expect(screen.getByText("移除响应头").className).toContain(ACTION_TONES.removeResponseHeaders); expect(screen.getByText("屏蔽").className).toContain(ACTION_TONES.block); expect(screen.getByText("改请求头").className).toContain(ACTION_TONES.modifyRequestHeaders); expect(screen.getByText("屏蔽").className).not.toContain("bg-secondary"); diff --git a/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx b/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx index 04a95b3fe..d6ae43de3 100644 --- a/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx @@ -1,9 +1,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, 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"; -import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter"; +import { renderWithRouter } from "@Tests/renderWithThemeRouter"; import { notify } from "@App/pages/components/ui/toast"; import { cspRemovalAction, type NetworkRule, type NetworkRuleState } from "@App/app/repo/network_rule"; import type { NetworkRuleClient } from "@App/app/service/service_worker/client"; @@ -71,23 +71,40 @@ function clientFor(rules: NetworkRule[], overrides: Partial = } as unknown as NetworkRuleClient; } -function renderPage(client: NetworkRuleClient) { - return renderWithThemeRouter( +async function settle() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function renderPage(client: NetworkRuleClient) { + renderWithRouter( } /> , { initialEntries: ["/tools/network-rules"] } ); + await settle(); } // 行勾选框与翻页按钮都按 aria-label 取:整页 role 扫描要给每个同角色元素算一遍可访问名, // 20 行的表上单次 *ByRole 就要 20~70ms,够把这一文件顶出 ui 项目 850ms 的预算。 function selectRow(name: string) { - fireEvent.click(screen.getByLabelText(`选择 ${name}`)); + const checkbox = document.querySelector(`[aria-label="选择 ${name}"]`); + expect(checkbox).toBeInTheDocument(); + fireEvent.click(checkbox!); } +const bulkBarSelector = '[role="toolbar"]'; + function bulkBar() { - return screen.getByRole("toolbar", { name: "批量操作" }); + const element = document.querySelector(bulkBarSelector); + expect(element).toBeInTheDocument(); + return element!; +} + +function expectNoBulkBar() { + expect(document.querySelector(bulkBarSelector)).not.toBeInTheDocument(); } function clickBulk(label: string) { @@ -103,44 +120,44 @@ function argsOf(mock: unknown) { describe("网络规则批量操作", () => { it("未选中时没有操作栏,选中两行后批量停用只对这两条发出请求", async () => { const client = clientFor([rule(1), rule(2), rule(3)]); - renderPage(client); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); - expect(screen.queryByRole("toolbar")).not.toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 1")).toBeInTheDocument(); + expectNoBulkBar(); selectRow("规则 1"); selectRow("规则 3"); - expect(within(bulkBar()).getByText("已选 2 条")).toBeInTheDocument(); + const toolbar = bulkBar(); + expect(toolbar).toHaveAttribute("aria-label", "批量操作"); + expect(within(toolbar).getByText("已选 2 条")).toBeInTheDocument(); clickBulk("停用"); + await settle(); - await waitFor(() => { - // 一次用户操作只发一次请求:服务端在同一次写入里改完这两条。 - expect(argsOf(client.setRulesEnabled)).toEqual([{ baseRevision: 3, ids: ["r1", "r3"], enabled: false }]); - expect(screen.queryByRole("toolbar")).not.toBeInTheDocument(); - }); + // 一次用户操作只发一次请求:服务端在同一次写入里改完这两条。 + expect(argsOf(client.setRulesEnabled)).toEqual([{ baseRevision: 3, ids: ["r1", "r3"], enabled: false }]); + expectNoBulkBar(); }); it("批量删除在确认前不动手,确认框提示可以改用停用", async () => { const client = clientFor([rule(1), rule(2), rule(3)]); - renderPage(client); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 1")).toBeInTheDocument(); selectRow("规则 1"); selectRow("规则 3"); clickBulk("删除"); expect(client.deleteRules).not.toHaveBeenCalled(); - const dialog = await screen.findByRole("alertdialog"); + const dialog = screen.getByRole("alertdialog"); expect(within(dialog).getByText("删除选中的 2 条规则?")).toBeInTheDocument(); expect(within(dialog).getByText(/停用/)).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole("button", { name: "删除规则" })); + await settle(); - await waitFor(() => { - expect(argsOf(client.deleteRules)).toEqual([{ baseRevision: 3, ids: ["r1", "r3"] }]); - expect(screen.queryByText("规则 1")).not.toBeInTheDocument(); - expect(screen.queryByRole("toolbar")).not.toBeInTheDocument(); - }); + expect(argsOf(client.deleteRules)).toEqual([{ baseRevision: 3, ids: ["r1", "r3"] }]); + expect(screen.queryByText("规则 1")).not.toBeInTheDocument(); + expectNoBulkBar(); }); it("批量启用只处理当前停用的规则,含「所有网站」时仍需二次确认", async () => { @@ -149,8 +166,8 @@ describe("网络规则批量操作", () => { rule(2, { enabled: false, condition: { urlFilter: "*" } }), rule(3, { enabled: false }), ]); - renderPage(client); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 1")).toBeInTheDocument(); selectRow("规则 1"); selectRow("规则 2"); @@ -158,58 +175,56 @@ describe("网络规则批量操作", () => { clickBulk("启用"); expect(client.setRulesEnabled).not.toHaveBeenCalled(); - const dialog = await screen.findByRole("alertdialog"); + const dialog = screen.getByRole("alertdialog"); fireEvent.click(within(dialog).getByRole("button", { name: "继续" })); + await settle(); - await waitFor(() => - expect(argsOf(client.setRulesEnabled)).toEqual([{ baseRevision: 3, ids: ["r2", "r3"], enabled: true }]) - ); + expect(argsOf(client.setRulesEnabled)).toEqual([{ baseRevision: 3, ids: ["r2", "r3"], enabled: true }]); }); it("批量删除被拒绝时一条都没删,列表与选中项原样保留", async () => { const client = clientFor([rule(1), rule(2), rule(3)], { deleteRules: vi.fn().mockRejectedValue({ code: "revision_conflict" }), } as unknown as Partial); - renderPage(client); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 1")).toBeInTheDocument(); selectRow("规则 1"); selectRow("规则 2"); selectRow("规则 3"); clickBulk("删除"); - const dialog = await screen.findByRole("alertdialog"); + const dialog = screen.getByRole("alertdialog"); fireEvent.click(within(dialog).getByRole("button", { name: "删除规则" })); + await settle(); // 全体或全不:整批被拒绝时不能有任何一条已经消失。 - 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(); - }); + 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 () => { // 刚好多出一条即可翻到第二页,多余的行只会让整表重渲染更贵。 // 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(); + await renderPage(client); + expect(screen.getByText("规则 0")).toBeInTheDocument(); selectRow("规则 0"); expect(bulkBar()).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("下一页")); - expect(screen.queryByRole("toolbar")).not.toBeInTheDocument(); + expectNoBulkBar(); }); it("改筛选会清空选择,操作栏随之消失", async () => { const client = clientFor([rule(1), rule(2), rule(3)]); - renderPage(client); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 1")).toBeInTheDocument(); selectRow("规则 1"); expect(bulkBar()).toBeInTheDocument(); @@ -217,6 +232,6 @@ describe("网络规则批量操作", () => { fireEvent.change(screen.getByRole("searchbox"), { target: { value: "规则 1" } }); // 选中的那行仍在筛选结果里,操作栏照样消失,说明清空来自筛选变化而不是该行被过滤掉。 expect(screen.getByText("规则 1")).toBeInTheDocument(); - expect(screen.queryByRole("toolbar")).not.toBeInTheDocument(); + expectNoBulkBar(); }); }); diff --git a/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx b/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx index 6949ad7c4..bebe7c918 100644 --- a/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx @@ -1,9 +1,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, 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"; -import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter"; +import { renderWithRouter } from "@Tests/renderWithThemeRouter"; import { notify } from "@App/pages/components/ui/toast"; import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule"; import type { NetworkRuleClient } from "@App/app/service/service_worker/client"; @@ -74,13 +74,20 @@ function clientFor(current: NetworkRuleSnapshot, overrides: Partial { + await Promise.resolve(); + }); +} + +async function renderPage(client: NetworkRuleClient) { + renderWithRouter( } /> , { initialEntries: ["/tools/network-rules"] } ); + await settle(); } async function openRowMenu(row: HTMLElement) { @@ -95,17 +102,16 @@ describe("网络规则的成败反馈", () => { const client = clientFor(current, { reorderRules: vi.fn(async ({ order }: { order: string[] }) => applyFailed(current, order)), } as unknown as Partial); - renderPage(client); - expect(await screen.findByText("规则 2")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 2")).toBeInTheDocument(); await openRowMenu(screen.getAllByTestId("network-rule-row")[1]); - fireEvent.click(await screen.findByRole("menuitem", { name: "置顶" })); + fireEvent.click(screen.getByRole("menuitem", { name: "置顶" })); + await settle(); - await waitFor(() => { - expect(client.reorderRules).toHaveBeenCalled(); - expect(notify.error).toHaveBeenCalledWith("规则已保存,但浏览器规则未能更新。"); - expect(notify.success).not.toHaveBeenCalled(); - }); + expect(client.reorderRules).toHaveBeenCalled(); + expect(notify.error).toHaveBeenCalledWith("规则已保存,但浏览器规则未能更新。"); + expect(notify.success).not.toHaveBeenCalled(); }); it("删除保存成功但浏览器未接受时只报失败,不同时弹成功", async () => { @@ -113,25 +119,24 @@ describe("网络规则的成败反馈", () => { const client = clientFor(current, { deleteRules: vi.fn().mockResolvedValue(applyFailed(current)), } as unknown as Partial); - renderPage(client); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 1")).toBeInTheDocument(); await openRowMenu(screen.getAllByTestId("network-rule-row")[0]); - fireEvent.click(await screen.findByRole("menuitem", { name: "删除" })); - fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "删除规则" })); - - await waitFor(() => { - expect(client.deleteRules).toHaveBeenCalled(); - expect(notify.error).toHaveBeenCalledWith("规则已保存,但浏览器规则未能更新。"); - expect(notify.success).not.toHaveBeenCalled(); - }); + fireEvent.click(screen.getByRole("menuitem", { name: "删除" })); + fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "删除规则" })); + await settle(); + + expect(client.deleteRules).toHaveBeenCalled(); + expect(notify.error).toHaveBeenCalledWith("规则已保存,但浏览器规则未能更新。"); + expect(notify.success).not.toHaveBeenCalled(); }); }); describe("网络规则列表页的状态指示", () => { it("总开关关闭时列表页自己说明规则已暂停", async () => { - renderPage(clientFor(snapshot([rule(1)], { masterEnabled: false }))); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(clientFor(snapshot([rule(1)], { masterEnabled: false }))); + expect(screen.getByText("规则 1")).toBeInTheDocument(); expect(screen.getByText("已暂停")).toBeInTheDocument(); expect(screen.queryByText("已生效")).toBeNull(); }); @@ -139,21 +144,21 @@ describe("网络规则列表页的状态指示", () => { it("规则应用失败时状态显示未生效", async () => { const current = snapshot([rule(1)]); current.apply = { state: "error", code: "dnr_apply_failed", desiredRevision: 3, message: "Rule limit exceeded" }; - renderPage(clientFor(current)); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(clientFor(current)); + expect(screen.getByText("规则 1")).toBeInTheDocument(); expect(screen.getByText("未生效")).toBeInTheDocument(); }); it("一切正常时状态显示已生效", async () => { - renderPage(clientFor(snapshot([rule(1)]))); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(clientFor(snapshot([rule(1)]))); + expect(screen.getByText("规则 1")).toBeInTheDocument(); expect(screen.getByText("已生效")).toBeInTheDocument(); }); it("隐身上下文照常渲染规则列表与总开关,且没有隐身提示", async () => { extensionEnv.inIncognitoContext = true; - renderPage(clientFor(snapshot([rule(1)]))); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(clientFor(snapshot([rule(1)]))); + expect(screen.getByText("规则 1")).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "总开关" })).toBeInTheDocument(); expect(screen.queryByText(/请在普通窗口中管理网络规则/)).toBeNull(); }); @@ -161,11 +166,11 @@ describe("网络规则列表页的状态指示", () => { describe("网络规则匹配测试在总开关关闭时", () => { it("照常给出命中,并说明规则当前被总开关暂停", async () => { - renderPage(clientFor(snapshot([rule(1)], { masterEnabled: false }))); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(clientFor(snapshot([rule(1)], { masterEnabled: false }))); + expect(screen.getByText("规则 1")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "测试匹配" })); - fireEvent.change(await screen.findByLabelText("网址"), { target: { value: "https://s1.example.com/page" } }); + fireEvent.change(screen.getByLabelText("网址"), { target: { value: "https://s1.example.com/page" } }); expect(screen.getAllByTestId("match-test-hit")).toHaveLength(1); expect(screen.getByTestId("match-test-outcome")).not.toHaveTextContent("无命中"); diff --git a/src/pages/options/routes/Tools/NetworkRules/RowRenderCost.test.tsx b/src/pages/options/routes/Tools/NetworkRules/RowRenderCost.test.tsx index 0c20310f4..84d06bae9 100644 --- a/src/pages/options/routes/Tools/NetworkRules/RowRenderCost.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/RowRenderCost.test.tsx @@ -1,9 +1,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen } from "@testing-library/react"; import { Route, Routes } from "react-router-dom"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; -import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter"; +import { renderWithRouter } from "@Tests/renderWithThemeRouter"; import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule"; import type { NetworkRuleClient } from "@App/app/service/service_worker/client"; import type { NetworkRuleSnapshot } from "@App/app/service/service_worker/network_rule"; @@ -70,7 +70,7 @@ function clientFor(rules: NetworkRule[]): NetworkRuleClient { } async function renderRows() { - renderWithThemeRouter( + renderWithRouter( , { initialEntries: ["/tools/network-rules"] } ); - expect(await screen.findByText("规则 0")).toBeInTheDocument(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByText("规则 0")).toBeInTheDocument(); expect(screen.getAllByTestId("network-rule-row")).toHaveLength(PAGE_ROWS); } diff --git a/src/pages/options/routes/Tools/NetworkRules/RuleCards.tsx b/src/pages/options/routes/Tools/NetworkRules/RuleCards.tsx index 576be8e37..e41dc704d 100644 --- a/src/pages/options/routes/Tools/NetworkRules/RuleCards.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/RuleCards.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from "react"; +import { memo, useCallback, useMemo } from "react"; import { GripVertical } from "lucide-react"; import type { DragEndEvent } from "@dnd-kit/core"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; @@ -26,6 +26,9 @@ import { // 长按手柄进入拖拽;delay 之内的移动仍按滚动处理。 const LONG_PRESS = { delay: 300, tolerance: 8 }; +const POINTER_SENSOR_OPTIONS = { activationConstraint: LONG_PRESS }; +const KEYBOARD_SENSOR_OPTIONS = { coordinateGetter: sortableKeyboardCoordinates }; +const DRAG_MODIFIERS = [restrictToVerticalAxis]; export type RuleCardsProps = RuleRowActions & { rules: NetworkRule[]; @@ -37,7 +40,7 @@ export type RuleCardsProps = RuleRowActions & { onDragEnd: (activeId: string, overId: string) => void; }; -export default function RuleCards({ +const RuleCards = memo(function RuleCards({ rules, positionOf, total, @@ -48,45 +51,66 @@ export default function RuleCards({ ...moveHandlers }: RuleCardsProps) { const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: LONG_PRESS }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + useSensor(PointerSensor, POINTER_SENSOR_OPTIONS), + useSensor(KeyboardSensor, KEYBOARD_SENSOR_OPTIONS) ); - const ids = useMemo(() => rules.map((rule) => rule.id), [rules]); + // dnd-kit 会把 items 引用传给每一行;规则对象刷新但顺序不变时保留这份引用。 + const idsKey = useMemo(() => JSON.stringify(rules.map((rule) => rule.id)), [rules]); + const ids = useMemo(() => JSON.parse(idsKey) as string[], [idsKey]); const labels = useRuleRowLabels(); const a11y = useDragAccessibility(rules, positionOf, total); - const handleDragEnd = ({ active, over }: DragEndEvent) => { - if (over && active.id !== over.id) onDragEnd(`${active.id}`, `${over.id}`); - }; + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (over && active.id !== over.id) onDragEnd(`${active.id}`, `${over.id}`); + }, + [onDragEnd] + ); return ( - -
- - {rules.map((rule) => ( - - ))} - -
-
+
+ {dragDisabled ? ( + rules.map((rule) => ( + + )) + ) : ( + + + {rules.map((rule) => ( + + ))} + + + )} +
); -} +}); type RuleCardProps = RuleRowActions & { rule: NetworkRule; @@ -99,39 +123,66 @@ type RuleCardProps = RuleRowActions & { }; /** 与 RuleTable 的行同理:只有拖拽接线留在外层,花钱的部分放进 memo 边界内。 */ -function SortableRuleCard({ rule, dragDisabled, labels, ...bodyProps }: RuleCardProps) { +const SortableRuleCard = memo(function SortableRuleCard({ rule, dragDisabled, labels, ...bodyProps }: RuleCardProps) { const { setNodeRef, setActivatorNodeRef, listeners, attributes, transform, transition, isDragging } = useSortable({ id: rule.id, disabled: dragDisabled, }); + return ( + + ); +}); + +type RuleCardDragProps = Pick< + ReturnType, + "setNodeRef" | "setActivatorNodeRef" | "listeners" | "attributes" | "transform" | "transition" | "isDragging" +>; + +const RuleCard = memo(function RuleCard({ + rule, + dragDisabled, + labels, + drag, + ...bodyProps +}: RuleCardProps & { drag?: RuleCardDragProps }) { return (
{/* touch-none 而不是 touch-manipulation:后者仍允许浏览器在拖拽途中把手势收回去做滚动。 */}
); -} +}); const RuleCardBody = memo(function RuleCardBody({ rule, @@ -164,3 +215,5 @@ const RuleCardBody = memo(function RuleCardBody({ ); }); + +export default RuleCards; diff --git a/src/pages/options/routes/Tools/NetworkRules/RuleTable.tsx b/src/pages/options/routes/Tools/NetworkRules/RuleTable.tsx index 6b37ee066..e556d8cf3 100644 --- a/src/pages/options/routes/Tools/NetworkRules/RuleTable.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/RuleTable.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from "react"; +import { memo, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { GripVertical } from "lucide-react"; import type { DragEndEvent } from "@dnd-kit/core"; @@ -37,6 +37,9 @@ const COL = { order: "w-[68px]", menu: "w-11", }; +const POINTER_SENSOR_OPTIONS = { activationConstraint: { distance: 3 } }; +const KEYBOARD_SENSOR_OPTIONS = { coordinateGetter: sortableKeyboardCoordinates }; +const DRAG_MODIFIERS = [restrictToVerticalAxis]; export type RuleTableProps = RuleRowActions & { rules: NetworkRule[]; @@ -51,7 +54,7 @@ export type RuleTableProps = RuleRowActions & { onDragEnd: (activeId: string, overId: string) => void; }; -export default function RuleTable({ +const RuleTable = memo(function RuleTable({ rules, positionOf, total, @@ -66,69 +69,94 @@ export default function RuleTable({ }: RuleTableProps) { const { t } = useTranslation(); const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 3 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + useSensor(PointerSensor, POINTER_SENSOR_OPTIONS), + useSensor(KeyboardSensor, KEYBOARD_SENSOR_OPTIONS) ); - const ids = useMemo(() => rules.map((rule) => rule.id), [rules]); + // dnd-kit 会把 items 引用传给每一行;规则对象刷新但顺序不变时保留这份引用。 + const idsKey = useMemo(() => JSON.stringify(rules.map((rule) => rule.id)), [rules]); + const ids = useMemo(() => JSON.parse(idsKey) as string[], [idsKey]); const labels = useRuleRowLabels(); const a11y = useDragAccessibility(rules, positionOf, total); const allSelected = rules.length > 0 && rules.every((rule) => selected.has(rule.id)); const someSelected = rules.some((rule) => selected.has(rule.id)); - const handleDragEnd = ({ active, over }: DragEndEvent) => { - if (over && active.id !== over.id) onDragEnd(`${active.id}`, `${over.id}`); - }; + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (over && active.id !== over.id) onDragEnd(`${active.id}`, `${over.id}`); + }, + [onDragEnd] + ); return ( - - - - - - - onSelectPage(checked === true)} - /> - - - {t("tools:network_rules_column_name")} - {t("tools:network_rules_column_action")} - {t("tools:network_rules_column_scope")} - {t("tools:network_rules_column_order")} - - - +
+ + + + + onSelectPage(checked === true)} + /> + + + {t("tools:network_rules_column_name")} + {t("tools:network_rules_column_action")} + {t("tools:network_rules_column_scope")} + {t("tools:network_rules_column_order")} + + + + {dragDisabled ? ( - - {rules.map((rule) => ( - - ))} - + {rules.map((rule) => ( + + ))} -
-
+ ) : ( + + + + {rules.map((rule) => ( + + ))} + + + + )} + ); -} +}); type RuleRowProps = RuleRowActions & { rule: NetworkRule; @@ -142,34 +170,56 @@ type RuleRowProps = RuleRowActions & { onToggleEnabled: (rule: NetworkRule, enabled: boolean) => void; }; -/** - * 只有拖拽接线留在外层:useSortable 订阅 dnd-kit 的 context,父级一重渲染 context 就换标识, - * 整行连同 Radix 子树都会跟着重算,React.memo 包在外层也拦不住(已实测)。 - * 把真正花钱的单元格放进 memo 边界内,外层就只剩一个按钮和几个稳定的属性。 - */ -function SortableRuleRow({ rule, dragDisabled, labels, ...cellProps }: RuleRowProps) { +type RuleRowDragProps = Pick< + ReturnType, + "setNodeRef" | "setActivatorNodeRef" | "listeners" | "attributes" | "transform" | "transition" | "isDragging" +>; + +const SortableRuleRow = memo(function SortableRuleRow({ rule, dragDisabled, labels, ...cellProps }: RuleRowProps) { const { setNodeRef, setActivatorNodeRef, listeners, attributes, transform, transition, isDragging } = useSortable({ id: rule.id, disabled: dragDisabled, }); + return ( + + ); +}); + +const RuleRow = memo(function RuleRow({ + rule, + dragDisabled, + labels, + drag, + ...cellProps +}: RuleRowProps & { drag?: RuleRowDragProps }) { return ( @@ -177,7 +227,7 @@ function SortableRuleRow({ rule, dragDisabled, labels, ...cellProps }: RuleRowPr ); -} +}); const RuleRowCells = memo(function RuleRowCells({ rule, @@ -223,3 +273,5 @@ const RuleRowCells = memo(function RuleRowCells({ ); }); + +export default RuleTable; diff --git a/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx b/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx index 56c8940e3..1157e19e1 100644 --- a/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx @@ -1,14 +1,15 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { Route, Routes } from "react-router-dom"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; -import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter"; +import { renderWithRouter } from "@Tests/renderWithThemeRouter"; import { cspRemovalAction, type NetworkRule, type NetworkRuleState } from "@App/app/repo/network_rule"; import type { NetworkRuleClient } from "@App/app/service/service_worker/client"; import type { NetworkRuleMutationResult } from "@App/app/service/service_worker/network_rule"; import NetworkRules from "."; +import RuleSheet from "./RuleSheet"; import { stubNotify } from "./test-helpers"; beforeAll(() => initTestLanguage("zh-CN")); @@ -69,23 +70,42 @@ function clientFor(rules: NetworkRule[], overrides: Partial = } as unknown as NetworkRuleClient; } -function renderPage(client: NetworkRuleClient) { - return renderWithThemeRouter( +async function settle() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function renderPage(client: NetworkRuleClient) { + renderWithRouter( } /> , { initialEntries: ["/tools/network-rules"] } ); + await settle(); +} + +function renderSheet(initialTemplate: "csp" | "block" | "custom") { + render( + {}} + onSave={vi.fn().mockResolvedValue(true)} + /> + ); } describe("网络规则空态", () => { it("给出常用场景入口,点一下直接进到该场景的表单", async () => { - renderPage(clientFor([])); - expect(await screen.findByText("从一个常用场景开始,或自己新建一条")).toBeInTheDocument(); + await renderPage(clientFor([])); + expect(screen.getByText("从一个常用场景开始,或自己新建一条")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "移除 CSP" })); - const sheet = await screen.findByRole("dialog"); + const sheet = screen.getByRole("dialog"); // 直接落在第二步:场景徽标已经是「移除 CSP」,不需要用户再选一次。 expect(within(sheet).getByText("更换类型")).toBeInTheDocument(); expect(within(sheet).getByLabelText("应用范围")).toBeInTheDocument(); @@ -94,8 +114,8 @@ describe("网络规则空态", () => { describe("网络规则筛选横幅", () => { it("说明手柄为什么灰掉,而不只报匹配条数", async () => { - renderPage(clientFor([rule(1), rule(2)])); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(clientFor([rule(1), rule(2)])); + expect(screen.getByText("规则 1")).toBeInTheDocument(); fireEvent.change(screen.getByRole("searchbox"), { target: { value: "规则 1" } }); @@ -106,35 +126,28 @@ describe("网络规则筛选横幅", () => { }); describe("网络规则编辑抽屉的「试一试」", () => { - // 空态的场景入口直接落到第二步,正好省去先选模板这一步。 - async function openTemplate(name: string) { - renderPage(clientFor([])); - fireEvent.click(await screen.findByRole("button", { name })); - await screen.findByRole("dialog"); - } - - it("命中时说明会发生什么,而不只是「匹配」两个字", async () => { - await openTemplate("移除 CSP"); + it("命中时说明会发生什么,而不只是「匹配」两个字", () => { + renderSheet("csp"); fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } }); fireEvent.change(screen.getByLabelText("试一试"), { target: { value: "https://example.com/page" } }); expect(screen.getByText("匹配 · 将移除 4 个响应头")).toBeInTheDocument(); }); - it("屏蔽请求场景说明请求会被屏蔽", async () => { - await openTemplate("屏蔽请求"); + it("屏蔽请求场景说明请求会被屏蔽", () => { + renderSheet("block"); fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } }); fireEvent.change(screen.getByLabelText("试一试"), { target: { value: "https://example.com/page" } }); expect(screen.getByText("匹配 · 将屏蔽该请求")).toBeInTheDocument(); }); - it("范围命中但资源类型不含主文档时,说明这次导航不会被规则作用", async () => { - await openTemplate("屏蔽请求"); + it("范围命中但资源类型不含主文档时,说明这次导航不会被规则作用", () => { + renderSheet("block"); const sheet = screen.getByRole("dialog"); fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } }); fireEvent.click(within(sheet).getByRole("button", { name: "高级选项" })); - fireEvent.click(await within(sheet).findByRole("checkbox", { name: "图片" })); + fireEvent.click(within(sheet).getByRole("checkbox", { name: "图片" })); fireEvent.change(screen.getByLabelText("试一试"), { target: { value: "https://example.com/page" } }); expect( @@ -144,16 +157,15 @@ describe("网络规则编辑抽屉的「试一试」", () => { }); describe("网络规则的请求头黑名单", () => { - it("在输入之前就常驻说明哪些请求头不能改写", async () => { - renderPage(clientFor([])); - fireEvent.click(await screen.findByRole("button", { name: "自定义" })); + it("在输入之前就常驻说明哪些请求头不能改写", () => { + renderSheet("custom"); // 「自定义」默认动作是屏蔽请求,没有请求头可填,说明也就不该出现。 expect(screen.queryByText(/不允许改写/)).not.toBeInTheDocument(); // Radix Select 在 happy-dom 下靠键盘打开,与 index.test.tsx 的 pickOption 一致。 fireEvent.keyDown(screen.getByRole("combobox", { name: "动作类型" }), { key: "Enter" }); - fireEvent.click(await screen.findByRole("option", { name: "改请求头" })); + fireEvent.click(screen.getByRole("option", { name: "改请求头" })); 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 d1179452f..133ab8c30 100644 --- a/src/pages/options/routes/Tools/NetworkRules/index.test.tsx +++ b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx @@ -1,15 +1,16 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { Route, Routes } from "react-router-dom"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; -import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter"; +import { renderWithRouter } from "@Tests/renderWithThemeRouter"; import { notify } from "@App/pages/components/ui/toast"; import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule"; import type { NetworkRuleClient } from "@App/app/service/service_worker/client"; import type { NetworkRuleSnapshot } from "@App/app/service/service_worker/network_rule"; import NetworkRules from "."; +import RuleSheet from "./RuleSheet"; import { NETWORK_RULES_PAGE_SIZE } from "./rules"; import { stubNotify } from "./test-helpers"; @@ -68,13 +69,40 @@ function clientFor(current: NetworkRuleSnapshot, overrides: Partial { + await Promise.resolve(); + }); +} + +async function renderPage(client: NetworkRuleClient) { + renderWithRouter( } /> , { initialEntries: ["/tools/network-rules"] } ); + await settle(); +} + +function renderSheet( + over: { + rule?: NetworkRule; + initialTemplate?: "csp" | "userAgent" | "referer" | "responseHeaders" | "block" | "redirect" | "custom"; + } = {} +) { + const onSave = vi.fn().mockResolvedValue(true); + render( + + ); + return onSave; } function rowNames(): string[] { @@ -87,7 +115,9 @@ function rowNames(): string[] { function stubRowRects() { vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) { const row = this.closest("[data-testid='network-rule-row']") as HTMLElement | null; - const index = row ? screen.getAllByTestId("network-rule-row").indexOf(row) : -1; + // dnd-kit measures many descendants during one keyboard gesture; avoid an accessible-query tree walk per measurement. + const rows = document.querySelectorAll("[data-testid='network-rule-row']"); + const index = row ? Array.from(rows).indexOf(row) : -1; const top = index < 0 ? 0 : index * 60; return { x: 0, @@ -122,16 +152,16 @@ describe("网络规则列表页", () => { it("按列表顺序渲染全部规则,而不是按存储数组顺序", async () => { const rules = [rule(1), rule(2), rule(3)]; const client = clientFor(snapshot(rules, ["r3", "r1", "r2"])); - renderPage(client); + await renderPage(client); - expect(await screen.findByText("规则 3")).toBeInTheDocument(); + expect(screen.getByText("规则 3")).toBeInTheDocument(); expect(rowNames()).toEqual(["规则 3", "规则 1", "规则 2"]); }); it("把第 3 行拖到首位后立即持久化新顺序", async () => { const client = clientFor(snapshot([rule(1), rule(2), rule(3)])); - renderPage(client); - expect(await screen.findByText("规则 3")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 3")).toBeInTheDocument(); stubRowRects(); const handle = within(screen.getAllByTestId("network-rule-row")[2]).getByRole("button", { name: /规则 3/ }); @@ -140,9 +170,8 @@ describe("网络规则列表页", () => { fireEvent.keyDown(handle, { code: "ArrowUp" }); fireEvent.keyDown(handle, { code: "ArrowUp" }); fireEvent.keyDown(handle, { code: "Space" }); - await waitFor(() => - expect(client.reorderRules).toHaveBeenCalledWith({ baseRevision: 3, order: ["r3", "r1", "r2"] }) - ); + await settle(); + expect(client.reorderRules).toHaveBeenCalledWith({ baseRevision: 3, order: ["r3", "r1", "r2"] }); expect(rowNames()).toEqual(["规则 3", "规则 1", "规则 2"]); }); @@ -150,8 +179,8 @@ describe("网络规则列表页", () => { const client = clientFor(snapshot([rule(1), rule(2), rule(3)]), { reorderRules: vi.fn().mockRejectedValue(JSON.stringify({ code: "storage_write_failed" })), }); - renderPage(client); - expect(await screen.findByText("规则 3")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 3")).toBeInTheDocument(); stubRowRects(); const handle = within(screen.getAllByTestId("network-rule-row")[2]).getByRole("button", { name: /规则 3/ }); @@ -160,11 +189,10 @@ describe("网络规则列表页", () => { fireEvent.keyDown(handle, { code: "ArrowUp" }); fireEvent.keyDown(handle, { code: "ArrowUp" }); fireEvent.keyDown(handle, { code: "Space" }); - await waitFor(() => { - expect(client.reorderRules).toHaveBeenCalled(); - expect(rowNames()).toEqual(["规则 1", "规则 2", "规则 3"]); - expect(notify.error).toHaveBeenCalledWith("顺序未能保存,已恢复原顺序。"); - }); + await settle(); + expect(client.reorderRules).toHaveBeenCalled(); + expect(rowNames()).toEqual(["规则 1", "规则 2", "规则 3"]); + expect(notify.error).toHaveBeenCalledWith("顺序未能保存,已恢复原顺序。"); }); it("搜索时手柄置灰,但行菜单的置顶仍能跨页移动规则", async () => { @@ -179,7 +207,7 @@ describe("网络规则列表页", () => { resolveState = resolve; }); const client = clientFor(current, { getState: vi.fn(() => stateReady) }); - renderPage(client); + await renderPage(client); fireEvent.change(screen.getByRole("searchbox"), { target: { value: `规则 ${offPage}` } }); expect(screen.queryAllByTestId("network-rule-row")).toHaveLength(0); @@ -187,15 +215,16 @@ describe("网络规则列表页", () => { resolveState(current); await stateReady; }); - expect(await screen.findByText(`规则 ${offPage}`)).toBeInTheDocument(); + expect(screen.getByText(`规则 ${offPage}`)).toBeInTheDocument(); const row = screen.getAllByTestId("network-rule-row")[0]; expect(rowNames()).toEqual([`规则 ${offPage}`]); expect(within(row).getByRole("button", { name: new RegExp(`规则 ${offPage}`) })).toBeDisabled(); await openRowMenu(row); - fireEvent.click(await screen.findByRole("menuitem", { name: "置顶" })); - await waitFor(() => expect(client.reorderRules).toHaveBeenCalled()); + fireEvent.click(screen.getByRole("menuitem", { name: "置顶" })); + await settle(); + expect(client.reorderRules).toHaveBeenCalled(); const order = vi.mocked(client.reorderRules).mock.calls[0][0].order; expect(order).toHaveLength(total); @@ -204,8 +233,8 @@ describe("网络规则列表页", () => { it("清除筛选后恢复完整列表,手柄重新可用", async () => { // 筛掉一部分再清掉筛选就足以验证恢复,不需要凑满一整页。 - renderPage(clientFor(snapshot([rule(1), rule(2), rule(3)]))); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(clientFor(snapshot([rule(1), rule(2), rule(3)]))); + expect(screen.getByText("规则 1")).toBeInTheDocument(); fireEvent.change(screen.getByRole("searchbox"), { target: { value: "规则 2" } }); expect(rowNames()).toEqual(["规则 2"]); @@ -220,28 +249,29 @@ describe("网络规则列表页", () => { it("行菜单可以把规则移到指定位置", async () => { // 目标位次落在中间,才和「置顶/置底」区分开;四行就够摆出这样一个位次。 const client = clientFor(snapshot([rule(1), rule(2), rule(3), rule(4)])); - renderPage(client); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 1")).toBeInTheDocument(); await openRowMenu(screen.getAllByTestId("network-rule-row")[0]); - fireEvent.click(await screen.findByRole("menuitem", { name: "移到…" })); - fireEvent.change(await screen.findByRole("spinbutton"), { target: { value: "3" } }); + fireEvent.click(screen.getByRole("menuitem", { name: "移到…" })); + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "3" } }); fireEvent.click(screen.getByRole("button", { name: "移动" })); - await waitFor(() => expect(client.reorderRules).toHaveBeenCalled()); + await settle(); + expect(client.reorderRules).toHaveBeenCalled(); const order = vi.mocked(client.reorderRules).mock.calls[0][0].order; expect(order).toEqual(["r2", "r3", "r1", "r4"]); }); it("没有规则时展示空态", async () => { - renderPage(clientFor(snapshot([]))); - expect(await screen.findByText("还没有网络规则")).toBeInTheDocument(); + await renderPage(clientFor(snapshot([]))); + expect(screen.getByText("还没有网络规则")).toBeInTheDocument(); expect(screen.queryAllByTestId("network-rule-row")).toHaveLength(0); }); it("搜索无结果时展示无结果态而不是空态", async () => { - renderPage(clientFor(snapshot([rule(1)]))); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(clientFor(snapshot([rule(1)]))); + expect(screen.getByText("规则 1")).toBeInTheDocument(); fireEvent.change(screen.getByRole("searchbox"), { target: { value: "不存在" } }); expect(screen.getByText("没有匹配的规则")).toBeInTheDocument(); @@ -260,12 +290,15 @@ describe("网络规则列表页", () => { const client = clientFor(current, { retryApply: vi.fn().mockResolvedValue({ ...snapshot([rule(1)]), outcome: "applied" as const }), }); - renderPage(client); + await renderPage(client); - expect(await screen.findByText("规则未能应用到浏览器")).toBeInTheDocument(); + expect(screen.getByText("规则未能应用到浏览器")).toBeInTheDocument(); expect(screen.getByText(/Rule limit exceeded/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "重试" })); - await waitFor(() => expect(client.retryApply).toHaveBeenCalled()); + await settle(); + expect(client.retryApply).toHaveBeenCalledTimes(1); + expect(screen.queryByText("规则未能应用到浏览器")).not.toBeInTheDocument(); + expect(screen.getByText("已生效")).toBeInTheDocument(); }); }); @@ -279,30 +312,30 @@ const TEMPLATE_NAMES = [ "自定义", ]; -async function openCreateSheet() { +function openCreateSheet() { fireEvent.click(screen.getByRole("button", { name: "新建规则" })); } -async function pickTemplate(name: string) { - fireEvent.click(await screen.findByRole("button", { name: new RegExp(name) })); +function pickTemplate(name: string) { + fireEvent.click(screen.getByRole("button", { name: new RegExp(name) })); } -async function pickOption(comboboxName: string, optionText: string) { +function pickOption(comboboxName: string, optionText: string) { fireEvent.keyDown(screen.getByRole("combobox", { name: comboboxName }), { key: "Enter" }); - fireEvent.click(await screen.findByRole("option", { name: optionText })); + fireEvent.click(screen.getByRole("option", { name: optionText })); } async function openRowAction(row: HTMLElement, item: string) { await openRowMenu(row); - fireEvent.click(await screen.findByRole("menuitem", { name: item })); + fireEvent.click(screen.getByRole("menuitem", { name: item })); } describe("网络规则编辑抽屉", () => { it("新建规则先展示七个场景模板", async () => { - renderPage(clientFor(snapshot([]))); - expect(await screen.findByText("还没有网络规则")).toBeInTheDocument(); + await renderPage(clientFor(snapshot([]))); + expect(screen.getByText("还没有网络规则")).toBeInTheDocument(); - await openCreateSheet(); + openCreateSheet(); for (const name of TEMPLATE_NAMES) { expect(screen.getByRole("button", { name: new RegExp(name) })).toBeInTheDocument(); @@ -310,13 +343,8 @@ describe("网络规则编辑抽屉", () => { }); it("改写 Cookie 请求头在输入阶段就被拦下,保存不会发出任何请求", async () => { - const client = clientFor(snapshot([])); - renderPage(client); - expect(await screen.findByText("还没有网络规则")).toBeInTheDocument(); - - await openCreateSheet(); - await pickTemplate("自定义"); - await pickOption("动作类型", "改请求头"); + const onSave = renderSheet({ initialTemplate: "custom" }); + pickOption("动作类型", "改请求头"); fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } }); fireEvent.change(screen.getAllByLabelText("头名称")[0], { target: { value: "Cookie" } }); @@ -325,16 +353,11 @@ describe("网络规则编辑抽屉", () => { const save = screen.getByRole("button", { name: "保存" }); expect(save).toBeDisabled(); fireEvent.click(save); - expect(client.createRule).not.toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); }); it("移除 CSP 模板预填四个 CSP 响应头,X-Frame-Options 可选附带", async () => { - const client = clientFor(snapshot([])); - renderPage(client); - expect(await screen.findByText("还没有网络规则")).toBeInTheDocument(); - - await openCreateSheet(); - await pickTemplate("移除 CSP"); + const onSave = renderSheet({ initialTemplate: "csp" }); for (const header of [ "content-security-policy", "content-security-policy-report-only", @@ -349,31 +372,53 @@ describe("网络规则编辑抽屉", () => { fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "github.com" } }); fireEvent.click(screen.getByRole("button", { name: "保存" })); - 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", - ], - }, - }) - ) + await settle(); + expect(onSave).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", + ], + }, + }) ); }); - it("编辑既有规则直接进入第二步,更换类型退回第一步并保留应用范围", async () => { - const client = clientFor(snapshot([rule(1)])); - renderPage(client); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + it("新建规则通过页面客户端提交 CSP 映射", async () => { + const client = clientFor(snapshot([])); + await renderPage(client); + openCreateSheet(); + pickTemplate("移除 CSP"); + fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "github.com" } }); + fireEvent.click(screen.getByRole("button", { name: "保存" })); - await openRowAction(screen.getAllByTestId("network-rule-row")[0], "编辑"); + await settle(); + expect(client.createRule).toHaveBeenCalledTimes(1); + expect(client.createRule).toHaveBeenCalledWith({ + baseRevision: 3, + enabled: true, + name: "", + condition: { requestDomains: ["github.com"], resourceTypes: ["main_frame", "sub_frame"] }, + action: { + type: "removeResponseHeaders", + headers: [ + "content-security-policy", + "content-security-policy-report-only", + "x-content-security-policy", + "x-webkit-csp", + ], + }, + }); + }); + + it("编辑既有规则直接进入第二步,更换类型退回第一步并保留应用范围", async () => { + const onSave = renderSheet({ rule: rule(1) }); expect(screen.getByLabelText("应用范围")).toHaveValue("s1.example.com"); expect(screen.queryByRole("button", { name: /屏蔽请求/ })).not.toBeInTheDocument(); @@ -382,66 +427,84 @@ describe("网络规则编辑抽屉", () => { expect(screen.getByRole("button", { name: new RegExp(name) })).toBeInTheDocument(); } - await pickTemplate("屏蔽请求"); + pickTemplate("屏蔽请求"); expect(screen.getByLabelText("应用范围")).toHaveValue("s1.example.com"); fireEvent.click(screen.getByRole("button", { name: "保存" })); - await waitFor(() => - expect(client.updateRule).toHaveBeenCalledWith( - expect.objectContaining({ id: "r1", patch: expect.objectContaining({ action: { type: "block" } }) }) - ) + await settle(); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + condition: expect.objectContaining({ requestDomains: ["s1.example.com"] }), + action: { type: "block" }, + }) ); }); it("排除域名超过单条规则上限时挡下保存并说明原因", async () => { - const client = clientFor(snapshot([])); - renderPage(client); - expect(await screen.findByText("还没有网络规则")).toBeInTheDocument(); - - await openCreateSheet(); - await pickTemplate("屏蔽请求"); + const onSave = renderSheet({ initialTemplate: "block" }); fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } }); fireEvent.click(screen.getByRole("button", { name: "高级选项" })); - fireEvent.change(await screen.findByLabelText("排除域名"), { + fireEvent.change(screen.getByLabelText("排除域名"), { target: { value: Array.from({ length: 101 }, (_, index) => `d${index}.example.com`).join("\n") }, }); expect(screen.getByText("每条规则请输入 1 至 100 个域名。")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "保存" })); - expect(client.createRule).not.toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); }); it("保存「所有网站」规则前必须二次确认", async () => { const client = clientFor(snapshot([])); - renderPage(client); - expect(await screen.findByText("还没有网络规则")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("还没有网络规则")).toBeInTheDocument(); - await openCreateSheet(); - await pickTemplate("屏蔽请求"); + openCreateSheet(); + pickTemplate("屏蔽请求"); fireEvent.click(screen.getByRole("checkbox", { name: /所有网站/ })); fireEvent.click(screen.getByRole("button", { name: "保存" })); expect(client.createRule).not.toHaveBeenCalled(); - expect(await screen.findByText("影响所有网站?")).toBeInTheDocument(); + expect(screen.getByText("影响所有网站?")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "继续" })); - await waitFor(() => - expect(client.createRule).toHaveBeenCalledWith( - expect.objectContaining({ condition: expect.objectContaining({ urlFilter: "*" }) }) - ) + await settle(); + expect(client.createRule).toHaveBeenCalledWith( + expect.objectContaining({ condition: expect.objectContaining({ urlFilter: "*" }) }) ); }); + it("编辑规则切换动作后通过页面客户端提交页面中的完整值", async () => { + const client = clientFor(snapshot([rule(1)])); + await renderPage(client); + await openRowAction(screen.getAllByTestId("network-rule-row")[0], "编辑"); + + fireEvent.click(screen.getByRole("button", { name: "更换类型" })); + pickTemplate("屏蔽请求"); + fireEvent.click(screen.getByRole("button", { name: "保存" })); + await settle(); + expect(client.updateRule).toHaveBeenCalledTimes(1); + expect(client.updateRule).toHaveBeenCalledWith({ + baseRevision: 3, + id: "r1", + patch: { + name: "规则 1", + condition: { requestDomains: ["s1.example.com"] }, + action: { type: "block" }, + }, + }); + }); + it("删除规则需确认,并提示可以改用停用", async () => { const client = clientFor(snapshot([rule(1)])); - renderPage(client); - expect(await screen.findByText("规则 1")).toBeInTheDocument(); + await renderPage(client); + expect(screen.getByText("规则 1")).toBeInTheDocument(); await openRowAction(screen.getAllByTestId("network-rule-row")[0], "删除"); expect(client.deleteRules).not.toHaveBeenCalled(); expect(screen.getByText(/停用/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "删除规则" })); - await waitFor(() => expect(client.deleteRules).toHaveBeenCalledWith({ baseRevision: 3, ids: ["r1"] })); + await settle(); + expect(client.deleteRules).toHaveBeenCalledWith({ baseRevision: 3, ids: ["r1"] }); }); }); @@ -455,8 +518,8 @@ describe("网络规则配额提示", () => { ]; it("按动作类型分别提示占用:block 走总池,改头/重定向/放行另占 unsafe 池", async () => { - renderPage(clientFor(snapshot(mixedRules))); - const quota = await screen.findByTestId("network-rules-quota"); + await renderPage(clientFor(snapshot(mixedRules))); + const quota = screen.getByTestId("network-rules-quota"); // 停用的规则不会被编译成 DNR 规则,因此不占配额:总池 4 条,其中 3 条是 unsafe。 expect(quota).toHaveTextContent(`4/${chrome.declarativeNetRequest.MAX_NUMBER_OF_DYNAMIC_RULES}`); @@ -471,8 +534,8 @@ describe("网络规则配额提示", () => { Object.defineProperty(chrome.declarativeNetRequest, name, { value: undefined, configurable: true }); } try { - renderPage(clientFor(snapshot(mixedRules))); - const quota = await screen.findByTestId("network-rules-quota"); + await renderPage(clientFor(snapshot(mixedRules))); + const quota = screen.getByTestId("network-rules-quota"); expect(quota).toHaveTextContent(`4/${chrome.declarativeNetRequest.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES}`); expect(quota.textContent).not.toContain("3/"); } finally { diff --git a/src/pages/options/routes/Tools/sections/NetworkRulesSection.test.tsx b/src/pages/options/routes/Tools/sections/NetworkRulesSection.test.tsx index 5fc54aadf..e449a803f 100644 --- a/src/pages/options/routes/Tools/sections/NetworkRulesSection.test.tsx +++ b/src/pages/options/routes/Tools/sections/NetworkRulesSection.test.tsx @@ -1,9 +1,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, 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"; -import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter"; +import { renderWithRouter } from "@Tests/renderWithThemeRouter"; import { NetworkRuleAmbiguousResponseError, type NetworkRuleClient } from "@App/app/service/service_worker/client"; import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule"; import type { NetworkRuleSnapshot } from "@App/app/service/service_worker/network_rule"; @@ -56,14 +56,22 @@ function clientFor(current: NetworkRuleSnapshot, overrides: Partial { + await Promise.resolve(); + }); +} + +async function renderCard(client: NetworkRuleClient) { + const result = renderWithRouter( () => {}} client={client} />} /> {"规则列表页"}

} />
, { initialEntries: ["/tools"] } ); + await settle(); + return result; } function manyRules(count: number): NetworkRule[] { @@ -72,22 +80,22 @@ function manyRules(count: number): NetworkRule[] { describe("Tools 网络规则摘要卡", () => { it("规则数从 3 条涨到 50 条,卡片结构不变——预览恒为两条,高度不随规则数增长", async () => { - const small = renderCard(clientFor(snapshot(manyRules(3)))); - await screen.findByTestId("network-rules-summary"); + const small = await renderCard(clientFor(snapshot(manyRules(3)))); + expect(screen.getByTestId("network-rules-summary")).toBeInTheDocument(); const smallCard = screen.getByTestId("network-rules-summary"); const smallNodes = smallCard.querySelectorAll("*").length; expect(screen.getAllByTestId("network-rules-preview-row")).toHaveLength(2); small.unmount(); - renderCard(clientFor(snapshot(manyRules(50)))); - await screen.findByTestId("network-rules-summary"); + await renderCard(clientFor(snapshot(manyRules(50)))); + expect(screen.getByTestId("network-rules-summary")).toBeInTheDocument(); expect(screen.getAllByTestId("network-rules-preview-row")).toHaveLength(2); expect(screen.getByTestId("network-rules-summary").querySelectorAll("*").length).toBe(smallNodes); }); it("摘要卡不承载增删改:没有新建/编辑/删除入口,也没有逐条的启用开关", async () => { - renderCard(clientFor(snapshot(manyRules(3)))); - await screen.findByTestId("network-rules-summary"); + await renderCard(clientFor(snapshot(manyRules(3)))); + expect(screen.getByTestId("network-rules-summary")).toBeInTheDocument(); for (const name of ["新建规则", "编辑", "删除", "更多操作"]) { expect(screen.queryByRole("button", { name })).toBeNull(); @@ -97,8 +105,8 @@ describe("Tools 网络规则摘要卡", () => { }); it("预览显示最近修改的两条规则与其余条数", async () => { - renderCard(clientFor(snapshot(manyRules(5)))); - await screen.findByTestId("network-rules-summary"); + await renderCard(clientFor(snapshot(manyRules(5)))); + expect(screen.getByTestId("network-rules-summary")).toBeInTheDocument(); const names = screen.getAllByTestId("network-rules-preview-row").map((row) => row.textContent ?? ""); expect(names[0]).toContain("规则 4"); expect(names[1]).toContain("规则 3"); @@ -106,32 +114,32 @@ describe("Tools 网络规则摘要卡", () => { }); it("「管理规则」是进入 /tools/network-rules 的入口", async () => { - renderCard(clientFor(snapshot(manyRules(3)))); - await screen.findByTestId("network-rules-summary"); + await renderCard(clientFor(snapshot(manyRules(3)))); + expect(screen.getByTestId("network-rules-summary")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "管理规则" })); - expect(await screen.findByText("规则列表页")).toBeInTheDocument(); + expect(screen.getByText("规则列表页")).toBeInTheDocument(); }); it("「测试匹配」打开纯前端模拟对话框", async () => { - renderCard(clientFor(snapshot(manyRules(3)))); - await screen.findByTestId("network-rules-summary"); + await renderCard(clientFor(snapshot(manyRules(3)))); + expect(screen.getByTestId("network-rules-summary")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "测试匹配" })); - expect(await screen.findByLabelText("网址")).toBeInTheDocument(); + expect(screen.getByLabelText("网址")).toBeInTheDocument(); }); - it("加载中显示骨架而不是规则", () => { + it("加载中显示骨架而不是规则", async () => { let resolve: (value: NetworkRuleSnapshot) => void = () => {}; const client = clientFor(snapshot([]), { getState: vi.fn(() => new Promise((r) => (resolve = r))), } as unknown as Partial); - renderCard(client); + await renderCard(client); expect(screen.getByTestId("network-rules-loading")).toBeInTheDocument(); resolve(snapshot([])); }); it("空态说明还没有规则", async () => { - renderCard(clientFor(snapshot([]))); - expect(await screen.findByText("还没有网络规则")).toBeInTheDocument(); + await renderCard(clientFor(snapshot([]))); + expect(screen.getByText("还没有网络规则")).toBeInTheDocument(); expect(screen.getByText("0 条启用中 · 共 0 条规则")).toBeInTheDocument(); }); @@ -140,18 +148,23 @@ describe("Tools 网络规则摘要卡", () => { apply: { state: "error", code: "dnr_apply_failed", desiredRevision: 1, message: "Rule limit exceeded" }, }); const retryApply = vi.fn().mockResolvedValue({ ...snapshot(manyRules(3)), outcome: "applied" }); - renderCard(clientFor(failed, { retryApply } as unknown as Partial)); + await renderCard(clientFor(failed, { retryApply } as unknown as Partial)); - expect(await screen.findByText("规则未能应用到浏览器")).toBeInTheDocument(); + expect(screen.getByText("规则未能应用到浏览器")).toBeInTheDocument(); expect(screen.getByText("未生效")).toBeInTheDocument(); expect(screen.getAllByTestId("network-rules-preview-row")).toHaveLength(2); fireEvent.click(screen.getByRole("button", { name: "重试" })); - await waitFor(() => expect(retryApply).toHaveBeenCalled()); + await settle(); + expect(retryApply).toHaveBeenCalledTimes(1); + expect(screen.queryByText("规则未能应用到浏览器")).not.toBeInTheDocument(); + expect(screen.getByText("已生效")).toBeInTheDocument(); }); it("总开关关闭时规则保留并置灰", async () => { - renderCard(clientFor(snapshot(manyRules(3), { state: { ...snapshot(manyRules(3)).state, masterEnabled: false } }))); - expect(await screen.findByText("所有规则已暂停")).toBeInTheDocument(); + await renderCard( + clientFor(snapshot(manyRules(3), { state: { ...snapshot(manyRules(3)).state, masterEnabled: false } })) + ); + expect(screen.getByText("所有规则已暂停")).toBeInTheDocument(); expect(screen.getByText("已暂停")).toBeInTheDocument(); expect(screen.getByTestId("network-rules-preview")).toHaveClass("opacity-60"); expect(screen.getAllByTestId("network-rules-preview-row")).toHaveLength(2); @@ -160,8 +173,8 @@ describe("Tools 网络规则摘要卡", () => { it("隐身上下文与普通窗口一视同仁:照常读取状态、可管理,且没有隐身提示", async () => { extensionEnv.inIncognitoContext = true; const client = clientFor(snapshot(manyRules(3))); - renderCard(client); - expect(await screen.findByTestId("network-rules-summary")).toBeInTheDocument(); + await renderCard(client); + expect(screen.getByTestId("network-rules-summary")).toBeInTheDocument(); expect(client.getState).toHaveBeenCalled(); expect(screen.getByRole("switch", { name: "总开关" })).toBeInTheDocument(); expect(screen.queryByText(/请在普通窗口中管理网络规则/)).toBeNull(); @@ -170,11 +183,11 @@ describe("Tools 网络规则摘要卡", () => { it("总开关关闭时测试匹配照常给出命中,并说明规则被暂停", async () => { const paused = snapshot(manyRules(3)); paused.state.masterEnabled = false; - renderCard(clientFor(paused)); - await screen.findByTestId("network-rules-summary"); + await renderCard(clientFor(paused)); + expect(screen.getByTestId("network-rules-summary")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "测试匹配" })); - fireEvent.change(await screen.findByLabelText("网址"), { target: { value: "https://s1.example.com/page" } }); + fireEvent.change(screen.getByLabelText("网址"), { target: { value: "https://s1.example.com/page" } }); expect(screen.getAllByTestId("match-test-hit")).toHaveLength(1); expect(screen.getByTestId("match-test-outcome")).not.toHaveTextContent("无命中"); @@ -183,10 +196,11 @@ describe("Tools 网络规则摘要卡", () => { it("总开关写入失败时提示且不改动本地状态", async () => { const setMasterEnabled = vi.fn().mockRejectedValue(new NetworkRuleAmbiguousResponseError()); - renderCard(clientFor(snapshot(manyRules(3)), { setMasterEnabled } as unknown as Partial)); - await screen.findByTestId("network-rules-summary"); + await renderCard(clientFor(snapshot(manyRules(3)), { setMasterEnabled } as unknown as Partial)); + expect(screen.getByTestId("network-rules-summary")).toBeInTheDocument(); fireEvent.click(screen.getByRole("switch")); - await waitFor(() => expect(setMasterEnabled).toHaveBeenCalled()); + await settle(); + expect(setMasterEnabled).toHaveBeenCalled(); expect(screen.getByRole("switch")).toBeChecked(); }); });