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();
});
});