返回 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 // React's legacy input-event fallback expects these IE hooks when JSDOM does
56 // not advertise native InputEvent support.
57 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
58 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
59 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
60 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
61
62 let nextID = 0;
63 let savedUpdate: { tasks?: HeartbeatTask[] } | null = null;
64 let backendTasks: HeartbeatTask[] = [];
65 let saveShouldFail = false;
66 installDesktopHostStub({
67 async HeartbeatReloadConfig() { return { revision: 1, etag: "test", tasks: backendTasks }; },
68 async HeartbeatSaveConfig(update: { tasks?: HeartbeatTask[] }) {
69 if (saveShouldFail) throw new Error("conflict");
70 savedUpdate = update;
71 backendTasks = update.tasks ?? [];
72 return { revision: 2, etag: "saved", tasks: backendTasks };
73 },
74 async HeartbeatTriggerNow() {},
75 async HeartbeatGenerateID() { nextID += 1; return `draft-${nextID}`; },
76 async ListWorkspaces() { return [{ name: "Project One", path: "/project-one", current: true }]; },
77 });
78
79 const rootElement = document.getElementById("root");
80 if (!rootElement) throw new Error("missing root");
81 const root = createRoot(rootElement);
82 const noopDelete = async () => true;
83
84 function renderEditor(task: HeartbeatTask, onSave: (task: HeartbeatTask) => Promise<boolean>, key: string) {
85 root.render(
86 <LocaleProvider>
87 <TaskEditor
88 key={key}
89 task={task}
90 onSave={onSave}
91 onDelete={noopDelete}
92 onCloseDetail={() => {}}
93 />
94 </LocaleProvider>,
95 );
96 }
97
98 console.log("\nheartbeat editor state ownership");
99
100 const originalTask: HeartbeatTask = {
101 id: "existing",
102 title: "Saved title",
103 prompt: "Saved prompt",
104 interval: "30m",
105 enabled: true,
106 createdAt: 1,
107 topicId: "old-topic",
108 lastRunAt: 100,
109 };
110 let submitted: HeartbeatTask | null = null;
111 await act(async () => {
112 renderEditor(originalTask, async (task) => { submitted = task; return true; }, "run-state");
113 await flush();
114 });
115 await act(async () => {
116 button("Daily")?.click();
117 await flush();
118 });
119 await act(async () => {
120 renderEditor({
121 ...originalTask,
122 topicId: "fresh-topic",
123 lastRunAt: 200,
124 runHistory: [{ at: 200, topicId: "fresh-topic" }],
125 }, async (task) => { submitted = task; return true; }, "run-state");
126 await flush();
127 });
128 await act(async () => {
129 button("Save")?.click();
130 await flush();
131 });
132 ok(submitted?.interval.startsWith("24h|daily") === true, "trigger completion preserves the user's edited schedule");
133 ok(submitted?.topicId === "fresh-topic" && submitted.lastRunAt === 200, "save carries the latest engine-owned run state");
134 ok(submitted?.runHistory?.[0]?.topicId === "fresh-topic", "save carries run history added while the editor was open");
135
136 console.log("\nheartbeat editor failed save");
137
138 let resolveSave: ((saved: boolean) => void) | undefined;
139 const rejectedSave = () => new Promise<boolean>((resolve) => { resolveSave = resolve; });
140 await act(async () => {
141 renderEditor({ ...originalTask, id: "conflict" }, rejectedSave, "conflict");
142 await flush();
143 });
144 await act(async () => {
145 button("Weekly")?.click();
146 await flush();
147 });
148 await act(async () => {
149 button("Save")?.click();
150 await flush();
151 });
152 ok(button("Save")?.disabled === true, "save stays pending until persistence resolves");
153 await act(async () => {
154 resolveSave?.(false);
155 await flush();
156 });
157 ok(button("Weekly")?.classList.contains("set-seg__btn--on") === true, "failed save preserves the local draft");
158 ok(document.querySelector('[role="alert"]')?.textContent?.includes("Your draft is still here") === true, "failed save reports an actionable error");
159 ok(button("Save") != null, "failed save remains dirty and retryable");
160
161 console.log("\nheartbeat editor frequency conversion");
162
163 await act(async () => {
164 renderEditor({ ...originalTask, id: "weekly-cron", interval: "0 9 * * 1" }, async () => true, "weekly-cron");
165 await flush();
166 });
167 await act(async () => {
168 button("Interval")?.click();
169 await flush();
170 });
171 ok(button("Custom")?.classList.contains("set-seg__btn--on") === true, "lossy cron conversion keeps the Custom frequency selected");
172 ok(document.querySelector<HTMLInputElement>('.heartbeat-editor__freq-input--cron')?.value === "0 9 * * 1", "lossy conversion keeps the original cron expression");
173 ok(document.querySelector('.heartbeat-editor__inline-error')?.textContent?.includes("cannot be converted") === true, "lossy conversion explains why the editor did not switch");
174
175 console.log("\nheartbeat editor required fields");
176
177 let untitledSaves = 0;
178 await act(async () => {
179 renderEditor({ id: "untitled", title: "", prompt: "Check the build", interval: "1h", enabled: false }, async () => { untitledSaves += 1; return true; }, "untitled");
180 await flush();
181 });
182 await act(async () => {
183 button("Save")?.click();
184 await flush();
185 });
186 const titleInput = document.querySelector<HTMLInputElement>('[aria-label="Title"]');
187 const titleError = titleInput?.getAttribute("aria-describedby")
188 ? document.getElementById(titleInput.getAttribute("aria-describedby") ?? "")
189 : null;
190 ok(untitledSaves === 0, "an untitled task is not persisted");
191 ok(titleInput?.getAttribute("aria-invalid") === "true", "save marks the missing title invalid");
192 ok(document.activeElement === titleInput, "save moves focus to the missing title");
193 ok(titleError?.textContent?.includes("title") === true, "save explains that the task needs a title");
194 ok(document.querySelector('.heartbeat-editor__textarea')?.getAttribute("aria-invalid") !== "true", "a filled prompt is not reported as missing");
195
196 console.log("\nheartbeat recommendation draft");
197
198 await act(async () => {
199 root.render(<LocaleProvider><HeartbeatView /></LocaleProvider>);
200 await flush();
201 await flush();
202 });
203 await act(async () => {
204 document.querySelector<HTMLButtonElement>(".heartbeat-suggestion")?.click();
205 await flush();
206 await flush();
207 });
208 ok(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value === "Daily review", "recommendation keeps its prefilled editor open");
209 ok(document.body.textContent?.includes("Select a task to view details") !== true, "recommendation is not cleared by the missing-task cleanup effect");
210 ok(button("Read only")?.classList.contains("set-seg__btn--on") === true, "recommendation defaults to read-only approval");
211 ok(button("Global") != null, "recommendation remains a new draft with editable scope");
212 await act(async () => {
213 button("Save")?.click();
214 await flush();
215 });
216 ok(savedUpdate?.tasks?.[0]?.enabled === false, "recommendation stays disabled until the user explicitly enables it");
217 ok(savedUpdate?.tasks?.[0]?.approvalMode === "read-only", "recommendation persists the read-only approval default");
218
219 saveShouldFail = true;
220 await act(async () => {
221 const productSuggestion = Array.from(document.querySelectorAll<HTMLButtonElement>(".heartbeat-suggestion"))
222 .find((item) => item.textContent?.includes("Product update"));
223 productSuggestion?.click();
224 await flush();
225 });
226 await act(async () => {
227 button("Save")?.click();
228 await flush();
229 await flush();
230 });
231 ok(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value === "Product update digest", "parent save conflict keeps the unsaved recommendation draft open");
232 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");
233
234 await act(async () => root.unmount());
235 dom.window.close();
236
237 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
238 if (failed > 0) process.exit(1);
239
239 lines Plain Text