返回 DeepSeek-Reasonix
heartbeat_sidecar_test.go
根目录 / desktop / heartbeat_sidecar_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "reasonix/internal/fileutil"
11 )
12
13 func TestHeartbeatRunHistoryLegacySidecarReadable(t *testing.T) {
14 isolateDesktopUserDirs(t)
15 engine := &HeartbeatEngine{}
16 if err := os.MkdirAll(filepath.Dir(engine.configPath()), 0o755); err != nil {
17 t.Fatal(err)
18 }
19 config := []byte(`{"schemaVersion":2,"revision":7,"tasks":[{"id":"t1","title":"task"}]}`)
20 legacySidecar := []byte(`{"runs":{"t1":[{"at":100,"topicId":"legacy"}]}}`)
21 if err := os.WriteFile(engine.configPath(), config, 0o644); err != nil {
22 t.Fatal(err)
23 }
24 if err := os.WriteFile(engine.runHistoryPath(), legacySidecar, 0o644); err != nil {
25 t.Fatal(err)
26 }
27 got := engine.ReloadTasks()
28 if len(got) != 1 || len(got[0].RunHistory) != 1 || got[0].RunHistory[0].TopicID != "legacy" {
29 t.Fatalf("legacy sidecar was not loaded: %+v", got)
30 }
31 }
32
33 func TestHeartbeatRunHistorySidecarSurvivesOlderFullTableSave(t *testing.T) {
34 isolateDesktopUserDirs(t)
35 engine := &HeartbeatEngine{}
36 if err := engine.ReplaceTasks([]HeartbeatTask{{
37 ID: "t1",
38 Title: "task",
39 RunHistory: []HeartbeatRun{{At: 100, TopicID: "a"}, {At: 200, TopicID: "b"}},
40 }}); err != nil {
41 t.Fatalf("seed: %v", err)
42 }
43 legacySave := `{"schemaVersion":1,"tasks":[{"id":"t1","title":"task"}]}`
44 if err := os.WriteFile(engine.configPath(), []byte(legacySave), 0o644); err != nil {
45 t.Fatal(err)
46 }
47 reloaded := &HeartbeatEngine{}
48 snap, err := reloaded.readConfigSnapshot()
49 if err != nil {
50 t.Fatalf("read snapshot: %v", err)
51 }
52 if len(snap.cfg.Tasks) != 1 || len(snap.cfg.Tasks[0].RunHistory) != 2 {
53 t.Fatalf("sidecar did not survive older full-table save: %+v", snap.cfg.Tasks)
54 }
55 }
56
57 func TestHeartbeatRunHistorySidecarTrimmedOnSave(t *testing.T) {
58 isolateDesktopUserDirs(t)
59 engine := &HeartbeatEngine{}
60 if err := engine.ReplaceTasks([]HeartbeatTask{{
61 ID: "t1",
62 Title: "task",
63 RunHistory: []HeartbeatRun{{At: 100, TopicID: "a"}},
64 }}); err != nil {
65 t.Fatalf("seed: %v", err)
66 }
67 data, err := os.ReadFile(engine.configPath())
68 if err != nil {
69 t.Fatal(err)
70 }
71 var cfg heartbeatConfig
72 if err := json.Unmarshal(data, &cfg); err != nil {
73 t.Fatal(err)
74 }
75 if len(cfg.Tasks) != 1 || cfg.Tasks[0].RunHistory != nil {
76 t.Fatalf("main config retained sidecar-owned history: %+v", cfg.Tasks)
77 }
78 raw, err := os.ReadFile(engine.runHistoryPath())
79 if err != nil {
80 t.Fatal(err)
81 }
82 var previousReader struct {
83 Runs map[string][]HeartbeatRun `json:"runs"`
84 }
85 if err := json.Unmarshal(raw, &previousReader); err != nil || len(previousReader.Runs["t1"]) != 1 {
86 t.Fatalf("previous sidecar reader lost top-level runs: runs=%+v err=%v", previousReader.Runs, err)
87 }
88 }
89
90 func TestHeartbeatRunHistorySidecarClearsAfterLastTaskDeletion(t *testing.T) {
91 isolateDesktopUserDirs(t)
92 engine := &HeartbeatEngine{}
93 if err := engine.ReplaceTasks([]HeartbeatTask{{
94 ID: "reused",
95 Title: "old task",
96 RunHistory: []HeartbeatRun{{At: 100, TopicID: "old-topic"}},
97 }}); err != nil {
98 t.Fatalf("seed: %v", err)
99 }
100 if err := engine.ReplaceTasks([]HeartbeatTask{}); err != nil {
101 t.Fatalf("delete last task: %v", err)
102 }
103 raw, err := os.ReadFile(engine.runHistoryPath())
104 if err != nil {
105 t.Fatalf("read empty sidecar: %v", err)
106 }
107 var sidecar heartbeatRunHistorySidecar
108 if err := json.Unmarshal(raw, &sidecar); err != nil {
109 t.Fatalf("decode empty sidecar: %v", err)
110 }
111 if len(sidecar.Runs) != 0 {
112 t.Fatalf("sidecar runs=%v, want empty", sidecar.Runs)
113 }
114 if err := engine.ReplaceTasks([]HeartbeatTask{{ID: "reused", Title: "new task"}}); err != nil {
115 t.Fatalf("reuse task ID: %v", err)
116 }
117 got := engine.ListTasks()
118 if len(got) != 1 || len(got[0].RunHistory) != 0 {
119 t.Fatalf("deleted task history resurrected after ID reuse: %+v", got)
120 }
121 }
122
123 func TestHeartbeatRunHistorySidecarSurvivesCrashBeforeConfigCommit(t *testing.T) {
124 isolateDesktopUserDirs(t)
125 engine := &HeartbeatEngine{}
126 if err := engine.ReplaceTasks([]HeartbeatTask{{
127 ID: "reused",
128 Title: "old task",
129 RunHistory: []HeartbeatRun{{At: 100, TopicID: "old-topic"}},
130 }}); err != nil {
131 t.Fatalf("seed: %v", err)
132 }
133 mainBefore, err := os.ReadFile(engine.configPath())
134 if err != nil {
135 t.Fatal(err)
136 }
137
138 previousCrashPoint := fileutil.CrashPoint
139 defer func() { fileutil.CrashPoint = previousCrashPoint }()
140 const crashMarker = "simulated process crash before config commit"
141 crashed := false
142 func() {
143 defer func() {
144 if recovered := recover(); recovered != nil {
145 if recovered != crashMarker {
146 panic(recovered)
147 }
148 crashed = true
149 }
150 }()
151 fileutil.CrashPoint = func(op, path string) {
152 if op == "atomic-write" && path == engine.configPath() {
153 panic(crashMarker)
154 }
155 }
156 _ = engine.ReplaceTasks([]HeartbeatTask{})
157 }()
158 fileutil.CrashPoint = previousCrashPoint
159 if !crashed {
160 t.Fatal("delete did not reach the injected crash point")
161 }
162 mainAfter, err := os.ReadFile(engine.configPath())
163 if err != nil {
164 t.Fatal(err)
165 }
166 if !bytes.Equal(mainAfter, mainBefore) {
167 t.Fatal("main config changed before its commit point")
168 }
169
170 rawSidecar, err := os.ReadFile(engine.runHistoryPath())
171 if err != nil {
172 t.Fatal(err)
173 }
174 var staged heartbeatRunHistorySidecar
175 if err := json.Unmarshal(rawSidecar, &staged); err != nil {
176 t.Fatal(err)
177 }
178 if staged.Revision != 2 || len(staged.Runs) != 0 || staged.Previous == nil || staged.Previous.Revision != 1 {
179 t.Fatalf("staged sidecar = %+v, want empty revision 2 with revision 1 fallback", staged)
180 }
181
182 restarted := &HeartbeatEngine{}
183 restored := restarted.ReloadTasks()
184 if len(restored) != 1 || restored[0].ID != "reused" || len(restored[0].RunHistory) != 1 || restored[0].RunHistory[0].TopicID != "old-topic" {
185 t.Fatalf("restart loaded inconsistent config/sidecar pair: %+v", restored)
186 }
187 if err := restarted.ReplaceTasks([]HeartbeatTask{}); err != nil {
188 t.Fatalf("retry delete after restart: %v", err)
189 }
190 if err := restarted.ReplaceTasks([]HeartbeatTask{{ID: "reused", Title: "new task"}}); err != nil {
191 t.Fatalf("reuse task ID: %v", err)
192 }
193 got := restarted.ReloadTasks()
194 if len(got) != 1 || len(got[0].RunHistory) != 0 {
195 t.Fatalf("deleted task history resurrected after committed delete and ID reuse: %+v", got)
196 }
197 }
198
199 func TestHeartbeatRunHistorySidecarForwardProtection(t *testing.T) {
200 isolateDesktopUserDirs(t)
201 engine := &HeartbeatEngine{}
202 if err := engine.ReplaceTasks([]HeartbeatTask{{ID: "t1", Title: "task"}}); err != nil {
203 t.Fatalf("seed: %v", err)
204 }
205 future := []byte(`{"schemaVersion":99,"revision":1,"runs":{"t1":[{"at":100,"topicId":"future"}]}}`)
206 if err := os.WriteFile(engine.runHistoryPath(), future, 0o644); err != nil {
207 t.Fatal(err)
208 }
209 if _, err := engine.readConfigSnapshot(); err == nil {
210 t.Fatal("read should reject a future run-history sidecar schemaVersion")
211 }
212 // A running older process may still have the task in memory. Manual runs
213 // must revalidate the persisted schemas before touching the app runtime.
214 engine.TriggerNow("t1")
215 restarted := newHeartbeatEngine(nil)
216 restarted.Start()
217 t.Cleanup(restarted.Stop)
218 if tasks := restarted.ListTasks(); len(tasks) != 0 {
219 t.Fatalf("startup loaded tasks beside a future sidecar schema: %+v", tasks)
220 }
221 if err := engine.ReplaceTasks([]HeartbeatTask{{ID: "t1", Title: "edited"}}); err == nil {
222 t.Fatal("write should reject a future run-history sidecar schemaVersion")
223 }
224 got, err := os.ReadFile(engine.runHistoryPath())
225 if err != nil {
226 t.Fatal(err)
227 }
228 if !bytes.Equal(got, future) {
229 t.Fatalf("future run-history sidecar was overwritten: %s", got)
230 }
231 }
232
232 lines GO