fix: harden cron session automation flows

This commit is contained in:
chengyongru
2026-06-12 18:13:25 +08:00
parent b5f9d51b5b
commit a50b3ac0f2
30 changed files with 267 additions and 56 deletions
+27 -21
View File
@@ -1,12 +1,14 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types";
import i18n from "@/i18n";
import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
const connectSpy = vi.fn();
const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomationJob[]>>();
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
@@ -146,6 +148,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
refresh: refreshSpy,
createChat: createChatSpy,
forkChat: async () => "fork-chat",
getSessionAutomations: getSessionAutomationsSpy,
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
if (options === undefined) await deleteChatSpy(key);
else await deleteChatSpy(key, options);
@@ -212,13 +215,15 @@ import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import App from "@/App";
describe("App layout", () => {
beforeEach(() => {
beforeEach(async () => {
await i18n.changeLanguage("en");
mockSessions = [];
connectSpy.mockClear();
updateUrlSpy.mockClear();
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset();
attachSpy.mockReset();
runStatusHandlers.clear();
@@ -435,7 +440,7 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
it("shows bound automations in the first delete confirmation", async () => {
it("shows localized bound automations in the first delete confirmation", async () => {
mockSessions = [
{
key: "websocket:chat-a",
@@ -454,44 +459,45 @@ describe("App layout", () => {
preview: "Second chat",
},
];
mockFetchRoutes({
"/api/sessions/websocket%3Achat-a/automations": {
jobs: [
{
id: "job-1",
name: "Daily repo check",
enabled: true,
schedule: { kind: "every", every_ms: 86_400_000 },
payload: { message: "Check the repo" },
state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) },
},
],
getSessionAutomationsSpy.mockResolvedValue([
{
id: "job-1",
name: "Daily repo check",
enabled: true,
schedule: { kind: "every", every_ms: 86_400_000 },
payload: { message: "Check the repo" },
state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) },
},
});
]);
await i18n.changeLanguage("zh-CN");
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const sidebar = screen.getByRole("navigation", { name: "侧边栏导航" });
await waitFor(() =>
expect(
within(sidebar).getByRole("button", { name: /^First chat$/ }),
).toBeInTheDocument(),
);
fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
fireEvent.pointerDown(screen.getByLabelText(/First chat.*会话操作/), {
button: 0,
});
fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "删除" }));
await waitFor(() =>
expect(screen.getByText("Daily repo check")).toBeInTheDocument(),
);
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
expect(
screen.getByText("This chat has scheduled automations. Deleting it will also delete them."),
screen.getByText("这个对话有关联的自动任务。删除对话也会删除这些自动任务。"),
).toBeInTheDocument();
expect(
screen.queryByText("This chat has scheduled automations. Deleting it will also delete them."),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Delete chat and automations" }));
fireEvent.click(screen.getByRole("button", { name: "删除对话和自动任务" }));
await waitFor(() =>
expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a", {
+30 -3
View File
@@ -5,14 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { setAppLanguage } from "@/i18n";
function automationJob(nextRunAt = Date.now() + 3_600_000) {
function automationJob(
nextRunAt = Date.now() + 3_600_000,
state: Record<string, unknown> = {},
) {
return {
id: "job-1",
name: "Morning check",
enabled: true,
schedule: { kind: "every", every_ms: 3_600_000 },
payload: { message: "Check the project status" },
state: { next_run_at_ms: nextRunAt },
state: { next_run_at_ms: nextRunAt, ...state },
};
}
@@ -27,7 +30,8 @@ function automationsResponse(jobs: unknown[]) {
}
describe("SessionInfoPopover", () => {
beforeEach(() => {
beforeEach(async () => {
await setAppLanguage("en");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(automationsResponse([automationJob()])),
@@ -86,6 +90,29 @@ describe("SessionInfoPopover", () => {
expect(screen.queryByText("Automations")).not.toBeInTheDocument();
});
it("shows a short pending label for deferred automations", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
automationsResponse([automationJob(Date.now() - 1000, { pending: true })]),
),
);
const user = userEvent.setup();
render(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="Release work"
/>,
);
await user.click(screen.getByRole("button", { name: "Session details" }));
expect(await screen.findByText("Runs shortly")).toBeInTheDocument();
expect(screen.queryByText(/ago/i)).not.toBeInTheDocument();
});
it("refreshes while open so completed one-shot automations disappear", async () => {
vi.stubGlobal(
"fetch",