

























@@ -1,6 +1,8 @@
11// @vitest-environment node
22import { beforeEach, describe, expect, it, vi } from "vitest";
334+type CronRunsLoadStatus = "ok" | "error" | "skipped";
5+46const mocks = vi.hoisted(() => ({
57refreshChatMock: vi.fn(async () => {}),
68scheduleChatScrollMock: vi.fn(),
@@ -15,8 +17,17 @@ const mocks = vi.hoisted(() => ({
1517loadConfigSchemaMock: vi.fn(async () => {}),
1618loadCronStatusMock: vi.fn(async () => {}),
1719loadCronJobsPageMock: vi.fn(async () => {}),
18-loadCronRunsMock: vi.fn(async () => {}),
20+loadCronRunsMock: vi.fn<() => Promise<CronRunsLoadStatus>>(async () => "ok"),
21+loadDebugMock: vi.fn(async () => {}),
22+loadDevicesMock: vi.fn(async () => {}),
23+loadExecApprovalsMock: vi.fn(async () => {}),
1924loadLogsMock: vi.fn(async () => {}),
25+loadModelAuthStatusStateMock: vi.fn(async () => {}),
26+loadNodesMock: vi.fn(async () => {}),
27+loadPresenceMock: vi.fn(async () => {}),
28+loadSessionsMock: vi.fn(async () => {}),
29+loadSkillsMock: vi.fn(async () => {}),
30+loadUsageMock: vi.fn(async () => {}),
2031}));
21322233vi.mock("./app-chat.ts", () => ({
@@ -51,11 +62,38 @@ vi.mock("./controllers/cron.ts", () => ({
5162loadCronJobsPage: mocks.loadCronJobsPageMock,
5263loadCronRuns: mocks.loadCronRunsMock,
5364}));
65+vi.mock("./controllers/debug.ts", () => ({
66+loadDebug: mocks.loadDebugMock,
67+}));
68+vi.mock("./controllers/devices.ts", () => ({
69+loadDevices: mocks.loadDevicesMock,
70+}));
71+vi.mock("./controllers/exec-approvals.ts", () => ({
72+loadExecApprovals: mocks.loadExecApprovalsMock,
73+}));
5474vi.mock("./controllers/logs.ts", () => ({
5575loadLogs: mocks.loadLogsMock,
5676}));
77+vi.mock("./controllers/model-auth-status.ts", () => ({
78+loadModelAuthStatusState: mocks.loadModelAuthStatusStateMock,
79+}));
80+vi.mock("./controllers/nodes.ts", () => ({
81+loadNodes: mocks.loadNodesMock,
82+}));
83+vi.mock("./controllers/presence.ts", () => ({
84+loadPresence: mocks.loadPresenceMock,
85+}));
86+vi.mock("./controllers/sessions.ts", () => ({
87+loadSessions: mocks.loadSessionsMock,
88+}));
89+vi.mock("./controllers/skills.ts", () => ({
90+loadSkills: mocks.loadSkillsMock,
91+}));
92+vi.mock("./controllers/usage.ts", () => ({
93+loadUsage: mocks.loadUsageMock,
94+}));
579558-import { refreshActiveTab } from "./app-settings.ts";
96+import { refreshActiveTab, setTab } from "./app-settings.ts";
59976098function createHost() {
6199return {
@@ -72,9 +110,13 @@ function createHost() {
72110logsAtBottom: false,
73111eventLog: [],
74112eventLogBuffer: [],
113+requestUpdate: vi.fn(),
114+updateComplete: Promise.resolve(),
75115cronRunsScope: "all",
76116cronRunsJobId: null as string | null,
77117sessionKey: "main",
118+settings: {},
119+basePath: "",
78120};
79121}
80122@@ -150,4 +192,168 @@ describe("refreshActiveTab", () => {
150192expect(mocks.loadLogsMock).toHaveBeenCalledWith(host, { reset: true });
151193expect(mocks.scheduleLogsScrollMock).toHaveBeenCalledWith(host, true);
152194});
195+196+it("records tab visible timing without waiting for the tab refresh RPC", async () => {
197+const host = createHost();
198+host.tab = "chat";
199+let resolveSessions!: () => void;
200+mocks.loadSessionsMock.mockReturnValueOnce(
201+new Promise<void>((resolve) => {
202+resolveSessions = resolve;
203+}),
204+);
205+206+setTab(host as never, "sessions");
207+208+expect(host.requestUpdate).toHaveBeenCalled();
209+await vi.waitFor(() => {
210+expect(host.eventLogBuffer).toEqual(
211+expect.arrayContaining([
212+expect.objectContaining({
213+event: "control-ui.tab.visible",
214+payload: expect.objectContaining({
215+previousTab: "chat",
216+tab: "sessions",
217+durationMs: expect.any(Number),
218+}),
219+}),
220+]),
221+);
222+});
223+224+resolveSessions();
225+});
226+227+it("does not wait for secondary overview refreshes before resolving", async () => {
228+const host = createHost();
229+host.tab = "overview";
230+mocks.loadUsageMock.mockReturnValueOnce(new Promise<void>(() => undefined));
231+232+const refresh = refreshActiveTab(host as never);
233+const outcome = await Promise.race([
234+refresh.then(() => "resolved" as const),
235+new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
236+]);
237+238+expect(outcome).toBe("resolved");
239+expect(mocks.loadChannelsMock).toHaveBeenCalled();
240+expect(mocks.loadSessionsMock).toHaveBeenCalled();
241+expect(mocks.loadUsageMock).toHaveBeenCalled();
242+});
243+244+it("records overview secondary refresh duration and aggregate status", async () => {
245+const host = createHost();
246+host.tab = "overview";
247+let resolveUsage!: () => void;
248+mocks.loadUsageMock.mockReturnValueOnce(
249+new Promise<void>((resolve) => {
250+resolveUsage = resolve;
251+}),
252+);
253+mocks.loadSkillsMock.mockRejectedValueOnce(new Error("skills failed"));
254+255+await refreshActiveTab(host as never);
256+resolveUsage();
257+258+await vi.waitFor(() => {
259+expect(host.eventLogBuffer).toEqual(
260+expect.arrayContaining([
261+expect.objectContaining({
262+event: "control-ui.overview.secondary",
263+payload: expect.objectContaining({
264+phase: "end",
265+status: "error",
266+durationMs: expect.any(Number),
267+}),
268+}),
269+]),
270+);
271+});
272+});
273+274+it("does not wait for cron runs before resolving the cron tab refresh", async () => {
275+const host = createHost();
276+host.tab = "cron";
277+mocks.loadCronRunsMock.mockReturnValueOnce(new Promise<"ok">(() => undefined));
278+279+const refresh = refreshActiveTab(host as never);
280+const outcome = await Promise.race([
281+refresh.then(() => "resolved" as const),
282+new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
283+]);
284+285+expect(outcome).toBe("resolved");
286+expect(mocks.loadChannelsMock).toHaveBeenCalledWith(host, false);
287+expect(mocks.loadCronStatusMock).toHaveBeenCalledOnce();
288+expect(mocks.loadCronJobsPageMock).toHaveBeenCalledOnce();
289+expect(mocks.loadCronRunsMock).toHaveBeenCalledOnce();
290+});
291+292+it("records failed cron runs status from the controller outcome", async () => {
293+const host = createHost();
294+host.tab = "cron";
295+mocks.loadCronRunsMock.mockResolvedValueOnce("error" as const);
296+297+await expect(refreshActiveTab(host as never)).resolves.toBeUndefined();
298+await Promise.resolve();
299+300+expect(host.eventLogBuffer).toEqual(
301+expect.arrayContaining([
302+expect.objectContaining({
303+event: "control-ui.cron.runs",
304+payload: expect.objectContaining({
305+phase: "end",
306+status: "error",
307+durationMs: expect.any(Number),
308+}),
309+}),
310+]),
311+);
312+});
313+314+it("contains rejected cron runs refreshes without failing the primary cron tab refresh", async () => {
315+const host = createHost();
316+host.tab = "cron";
317+mocks.loadCronRunsMock.mockRejectedValueOnce(new Error("cron runs slow path failed"));
318+319+await expect(refreshActiveTab(host as never)).resolves.toBeUndefined();
320+await Promise.resolve();
321+322+expect(host.eventLogBuffer).toEqual(
323+expect.arrayContaining([
324+expect.objectContaining({
325+event: "control-ui.cron.runs",
326+payload: expect.objectContaining({
327+phase: "end",
328+status: "error",
329+durationMs: expect.any(Number),
330+}),
331+}),
332+]),
333+);
334+});
335+336+it("does not record stale cron run timing after leaving the cron tab", async () => {
337+const host = createHost();
338+host.tab = "cron";
339+let resolveRuns!: () => void;
340+mocks.loadCronRunsMock.mockReturnValueOnce(
341+new Promise<"ok">((resolve) => {
342+resolveRuns = () => resolve("ok");
343+}),
344+);
345+346+await refreshActiveTab(host as never);
347+host.tab = "chat";
348+resolveRuns();
349+await Promise.resolve();
350+351+expect(host.eventLogBuffer).not.toEqual(
352+expect.arrayContaining([
353+expect.objectContaining({
354+event: "control-ui.cron.runs",
355+}),
356+]),
357+);
358+});
153359});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。