返回 DeepSeek-Reasonix
heartbeat-editor.test.tsx
根目录 / desktop / frontend / src / __tests__ / heartbeat-editor.test.tsx
1 // Run: node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/heartbeat-editor.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import { HeartbeatView, TaskEditor } from "../custom/features/heartbeat/HeartbeatPanel";
7 import type { HeartbeatTask } from "../custom/features/heartbeat/heartbeat.types";
8 import { LocaleProvider } from "../lib/i18n";
9 import { installDesktopHostStub } from "./desktopHostStub";
10
11 let passed = 0;
12 let failed = 0;
13
14 function ok(value: unknown, label: string) {
15 if (value) {
16 process.stdout.write(` PASS ${label}\n`);
17 passed += 1;
18 } else {
19 process.stdout.write(` FAIL ${label}\n`);
20 failed += 1;
21 }
22 }
23
24 function flush(): Promise<void> {
25 return new Promise((resolve) => setTimeout(resolve, 0));
26 }
27
28 function button(label: string): HTMLButtonElement | undefined {
29 return Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.trim() === label);
30 }
31
32 class NoopResizeObserver {
33 observe() {}
34 unobserve() {}
35 disconnect() {}
36 }
37
38 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
39 pretendToBeVisual: true,
40 url: "http://localhost/",
41 });
42 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
43 globalThis.window = dom.window as unknown as Window & typeof globalThis;
44 globalThis.document = dom.window.document;
45 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
46 globalThis.Node = dom.window.Node;
47 globalThis.Element = dom.window.Element;
48 globalThis.HTMLElement = dom.window.HTMLElement;
49 globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
50 globalThis.HTMLInputElement = dom.window.HTMLInputElement;
51 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
52 globalThis.Event = dom.window.Event;
53 globalThis.MouseEvent = dom.window.MouseEvent;
54 globalThis.ResizeObserver = NoopResizeObserver as unknown as typeof ResizeObserver;
55 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
56 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
57
58 let nextID = 0;
59 let savedUpdate: { tasks?: HeartbeatTask[] } | null = null;
60 let backendTasks: HeartbeatTask[] = [];
61 let saveShouldFail = false;
62 installDesktopHostStub({
63 async HeartbeatReloadConfig() { return { revision: 1, etag: "test", tasks: backendTasks }; },
64 async HeartbeatSaveConfig(update: { tasks?: HeartbeatTask[] }) {
65 if (saveShouldFail) throw new Error("conflict");
66 savedUpdate = update;
67 backendTasks = update.tasks ?? [];
68 return { revision: 2, etag: "saved", tasks: backendTasks };
69 },
70 async HeartbeatTriggerNow() {},
71 async HeartbeatGenerateID() { nextID += 1; return `draft-${nextID}`; },
72 async ListWorkspaces() { return [{ name: "Project One", path: "/project-one", current: true }]; },
73 });
74
75 const rootElement = document.getElementById("root");
76 if (!rootElement) throw new Error("missing root");
77 const root = createRoot(rootElement);
78 const noopDelete = async () => true;
79
80 function renderEditor(task: HeartbeatTask, onSave: (task: HeartbeatTask) => Promise<boolean>, key: string) {
81 root.render(
82 <LocaleProvider>
83 <TaskEditor
84 key={key}
85 task={task}
86 onSave={onSave}
87 onDelete={noopDelete}
88 onCloseDetail={() => {}}
89 />
90 </LocaleProvider>,
91 );
92 }
93
94 console.log("\nheartbeat editor state ownership");
95
96 const originalTask: HeartbeatTask = {
97 id: "existing",
98 title: "Saved title",
99 prompt: "Saved prompt",
100 interval: "30m",
101 enabled: true,
102 createdAt: 1,
103 topicId: "old-topic",
104 lastRunAt: 100,
105 };
106 let submitted: HeartbeatTask | null = null;
107 await act(async () => {
108 renderEditor(originalTask, async (task) => { submitted = task; return true; }, "run-state");
109 await flush();
110 });
111 await act(async () => {
112 button("Daily")?.click();
113 await flush();
114 });
115 await act(async () => {
116 renderEditor({
117 ...originalTask,
118 topicId: "fresh-topic",
119 lastRunAt: 200,
120 runHistory: [{ at: 200, topicId: "fresh-topic" }],
121 }, async (task) => { submitted = task; return true; }, "run-state");
122 await flush();
123 });
124 await act(async () => {
125 button("Save")?.click();
126 await flush();
127 });
128 ok(submitted?.interval.startsWith("24h|daily") === true, "trigger completion preserves the user's edited schedule");
129 ok(submitted?.topicId === "fresh-topic" && submitted.lastRunAt === 200, "save carries the latest engine-owned run state");
130 ok(submitted?.runHistory?.[0]?.topicId === "fresh-topic", "save carries run history added while the editor was open");
131
132 console.log("\nheartbeat editor failed save");
133
134 let resolveSave: ((saved: boolean) => void) | undefined;
135 const rejectedSave = () => new Promise<boolean>((resolve) => { resolveSave = resolve; });
136 await act(async () => {
137 renderEditor({ ...originalTask, id: "conflict" }, rejectedSave, "conflict");
138 await flush();
139 });
140 await act(async () => {
141 button("Weekly")?.click();
142 await flush();
143 });
144 await act(async () => {
145 button("Save")?.click();
146 await flush();
147 });
148 ok(button("Save")?.disabled === true, "save stays pending until persistence resolves");
149 await act(async () => {
150 resolveSave?.(false);
151 await flush();
152 });
153 ok(button("Weekly")?.classList.contains("set-seg__btn--on") === true, "failed save preserves the local draft");
154 ok(document.querySelector('[role="alert"]')?.textContent?.includes("Your draft is still here") === true, "failed save reports an actionable error");
155 ok(button("Save") != null, "failed save remains dirty and retryable");
156
157 console.log("\nheartbeat editor frequency conversion");
158
159 await act(async () => {
160 renderEditor({ ...originalTask, id: "weekly-cron", interval: "0 9 * * 1" }, async () => true, "weekly-cron");
161 await flush();
162 });
163 await act(async () => {
164 button("Interval")?.click();
165 await flush();
166 });
167 ok(button("Custom")?.classList.contains("set-seg__btn--on") === true, "lossy cron conversion keeps the Custom frequency selected");
168 ok(document.querySelector<HTMLInputElement>('.heartbeat-editor__freq-input--cron')?.value === "0 9 * * 1", "lossy conversion keeps the original cron expression");
169 ok(document.querySelector('.heartbeat-editor__inline-error')?.textContent?.includes("cannot be converted") === true, "lossy conversion explains why the editor did not switch");
170
171 console.log("\nheartbeat recommendation draft");
172
173 await act(async () => {
174 root.render(<LocaleProvider><HeartbeatView /></LocaleProvider>);
175 await flush();
176 await flush();
177 });
178 await act(async () => {
179 document.querySelector<HTMLButtonElement>(".heartbeat-suggestion")?.click();
180 await flush();
181 await flush();
182 });
183 ok(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value === "Daily review", "recommendation keeps its prefilled editor open");
184 ok(document.body.textContent?.includes("Select a task to view details") !== true, "recommendation is not cleared by the missing-task cleanup effect");
185 ok(button("Read only")?.classList.contains("set-seg__btn--on") === true, "recommendation defaults to read-only approval");
186 ok(button("Global") != null, "recommendation remains a new draft with editable scope");
187 await act(async () => {
188 button("Save")?.click();
189 await flush();
190 });
191 ok(savedUpdate?.tasks?.[0]?.enabled === false, "recommendation stays disabled until the user explicitly enables it");
192 ok(savedUpdate?.tasks?.[0]?.approvalMode === "read-only", "recommendation persists the read-only approval default");
193
194 saveShouldFail = true;
195 await act(async () => {
196 const productSuggestion = Array.from(document.querySelectorAll<HTMLButtonElement>(".heartbeat-suggestion"))
197 .find((item) => item.textContent?.includes("Product update"));
198 productSuggestion?.click();
199 await flush();
200 });
201 await act(async () => {
202 button("Save")?.click();
203 await flush();
204 await flush();
205 });
206 ok(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value === "Product update digest", "parent save conflict keeps the unsaved recommendation draft open");
207 ok(document.querySelector('.heartbeat-editor__save-error')?.textContent?.includes("Your draft is still here") === true, "parent save conflict is reported instead of marking the draft clean");
208
209 await act(async () => root.unmount());
210 dom.window.close();
211
212 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
213 if (failed > 0) process.exit(1);
214
214 lines Plain Text