Skip to content
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<T>` / `DAO<T>` / `OPFSRepo` / custom-repo taxonomy, matching an existing entity with the same needs;
messages use `Group.on(...)`; service constructor shapes differ by context and Agent subsystem; depend on
Expand Down
7 changes: 7 additions & 0 deletions docs/references/develop-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/app/service/agent/service_worker/dom_cdp.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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");
Expand Down
54 changes: 34 additions & 20 deletions src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
45 changes: 30 additions & 15 deletions src/pages/batchupdate/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});

Expand Down Expand Up @@ -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();
}
});
});

Expand Down
13 changes: 11 additions & 2 deletions src/pages/options/routes/Agent/Chat/MessageToolbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
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";

beforeAll(() => initTestLanguage("zh-CN"));
afterEach(() => cleanup());

async function settle() {
await act(async () => {
await Promise.resolve();
});
}

const baseProps = (over?: Partial<MessageToolbarProps>): MessageToolbarProps => ({
toolCallCount: 0,
onCopy: vi.fn(),
Expand All @@ -31,7 +37,10 @@ describe("消息工具栏 MessageToolbar", () => {
render(<MessageToolbar {...baseProps({ onDelete })} />);
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();
});

Expand Down
11 changes: 9 additions & 2 deletions src/pages/options/routes/Agent/Provider/ModelFormDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
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";

beforeAll(() => initTestLanguage("zh-CN"));
afterEach(() => cleanup());

async function settle() {
await act(async () => {
await Promise.resolve();
});
}

function setup(props: Record<string, unknown> = {}) {
const onSubmit = vi.fn();
const onTest = vi.fn(async () => ({ ok: true, latencyMs: 12 }));
Expand All @@ -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" }));
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
<Routes>
<Route
path="/tools/network-rules"
Expand All @@ -55,6 +55,9 @@ function renderPage(rules: NetworkRule[]) {
</Routes>,
{ initialEntries: ["/tools/network-rules"] }
);
await act(async () => {
await Promise.resolve();
});
}

/** 模板卡的图标片是按钮的第一个子元素,颜色只落在它身上,标题与描述用的是通用文字色。 */
Expand All @@ -76,7 +79,7 @@ describe("网络规则的动作配色", () => {
});

it("列表页的动作徽标按动作类型上色,而不是一律中性", async () => {
renderPage([
await renderPage([
rule("r1", "移除 CSP", cspRemovalAction()),
rule("r2", "屏蔽上报", { type: "block" }),
rule("r3", "改 UA", {
Expand All @@ -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");
Expand Down
Loading
Loading