返回 DeepSeek-Reasonix
startup_health_test.go
根目录 / desktop / startup_health_test.go
1 package main
2
3 import (
4 "context"
5 "path/filepath"
6 "testing"
7 "time"
8
9 "reasonix/internal/control"
10 "reasonix/internal/repair"
11 )
12
13 type shutdownSnapshotController struct {
14 control.SessionAPI
15 calls []string
16 normalSnapshots int
17 sessionPath string
18 shutdown func() error
19 }
20
21 func (c *shutdownSnapshotController) Snapshot() error {
22 c.normalSnapshots++
23 return nil
24 }
25
26 func (c *shutdownSnapshotController) SnapshotForShutdown() error {
27 c.calls = append(c.calls, "shutdown-snapshot")
28 if c.shutdown != nil {
29 return c.shutdown()
30 }
31 return nil
32 }
33
34 func (c *shutdownSnapshotController) SessionPath() string {
35 if c.sessionPath != "" {
36 return c.sessionPath
37 }
38 if c.SessionAPI != nil {
39 return c.SessionAPI.SessionPath()
40 }
41 return ""
42 }
43
44 func (c *shutdownSnapshotController) Close() {
45 c.calls = append(c.calls, "close")
46 if c.SessionAPI != nil {
47 c.SessionAPI.Close()
48 }
49 }
50
51 func TestShutdownWaitsForRuntimeLifecycleMutation(t *testing.T) {
52 isolateDesktopUserDirs(t)
53 app := NewApp()
54 app.runtimeAdmissionMu.Lock()
55 admissionHeld := true
56 defer func() {
57 if admissionHeld {
58 app.runtimeAdmissionMu.Unlock()
59 }
60 }()
61
62 done := make(chan struct{})
63 go func() {
64 app.shutdown(context.Background())
65 close(done)
66 }()
67 deadline := time.Now().Add(5 * time.Second)
68 for app.runtimeRebuildMu.TryLock() {
69 app.runtimeRebuildMu.Unlock()
70 if time.Now().After(deadline) {
71 t.Fatal("shutdown did not enter the runtime lifecycle barrier")
72 }
73 time.Sleep(time.Millisecond)
74 }
75 select {
76 case <-done:
77 t.Fatal("shutdown bypassed an in-flight runtime lifecycle mutation")
78 default:
79 }
80
81 app.runtimeAdmissionMu.Unlock()
82 admissionHeld = false
83 select {
84 case <-done:
85 case <-time.After(5 * time.Second):
86 t.Fatal("shutdown did not resume after the runtime lifecycle mutation completed")
87 }
88 }
89
90 // TestShutdownRecordsLKGOnlyAfterReady pins that last-known-good config is only
91 // written after the window reached domReady. A quit before paint must not
92 // rewrite the LKG snapshot from an incomplete boot.
93 func TestShutdownRecordsLKGOnlyAfterReady(t *testing.T) {
94 isolateDesktopUserDirs(t)
95 a := NewApp()
96 // Pre-ready shutdown is a no-op for LKG (startupReady is false).
97 a.shutdown(context.Background())
98 a.startupReady.Store(true)
99 // Post-ready shutdown attempts RecordHealthyConfig; missing user config is fine.
100 a.shutdown(context.Background())
101 }
102
103 func TestCaptureAndCommitPendingUpdateHealthUsesExactStartupIdentity(t *testing.T) {
104 originalRead := readPendingUpdateForHealth
105 originalMark := markPendingUpdateHealthyAfterReady
106 t.Cleanup(func() {
107 readPendingUpdateForHealth = originalRead
108 markPendingUpdateHealthyAfterReady = originalMark
109 })
110 tx := &repair.UpdateTransaction{
111 SchemaVersion: 1,
112 ToVersion: version,
113 CreatedAt: "2026-08-05T00:00:00Z",
114 Platform: "darwin/arm64",
115 TargetKind: "app-bundle",
116 TargetPath: "/Applications/Reasonix.app",
117 BackupPath: "/Applications/Reasonix.app.reasonix-update-backup",
118 }
119 readPendingUpdateForHealth = func() (*repair.UpdateTransaction, error) { return tx, nil }
120 app := NewApp()
121 capturePendingUpdateHealthIdentity(app)
122 wantID := repair.UpdateTransactionID(tx)
123 if app.healthyUpdateCreatedAt != tx.CreatedAt || app.healthyUpdateTransactionID != wantID {
124 t.Fatalf("captured health identity=(%q,%q), want (%q,%q)", app.healthyUpdateCreatedAt, app.healthyUpdateTransactionID, tx.CreatedAt, wantID)
125 }
126 called := false
127 markPendingUpdateHealthyAfterReady = func(running, createdAt, transactionID string) error {
128 called = true
129 if running != version || createdAt != tx.CreatedAt || transactionID != wantID {
130 t.Fatalf("health commit=(%q,%q,%q)", running, createdAt, transactionID)
131 }
132 return nil
133 }
134 if err := app.commitPendingUpdateHealth(); err != nil {
135 t.Fatal(err)
136 }
137 if !called {
138 t.Fatal("exact startup transaction was not committed")
139 }
140 }
141
142 func TestCapturePendingUpdateHealthRejectsDifferentTargetVersion(t *testing.T) {
143 originalRead := readPendingUpdateForHealth
144 t.Cleanup(func() { readPendingUpdateForHealth = originalRead })
145 readPendingUpdateForHealth = func() (*repair.UpdateTransaction, error) {
146 return &repair.UpdateTransaction{ToVersion: version + "-other", CreatedAt: "2026-08-05T00:00:00Z"}, nil
147 }
148 app := NewApp()
149 capturePendingUpdateHealthIdentity(app)
150 if app.healthyUpdateCreatedAt != "" || app.healthyUpdateTransactionID != "" {
151 t.Fatalf("captured unrelated transaction: %+v", app)
152 }
153 }
154
155 func TestShutdownUsesDurableSnapshotBeforeClosingController(t *testing.T) {
156 isolateDesktopUserDirs(t)
157 ctrl := &shutdownSnapshotController{SessionAPI: control.New(control.Options{Label: "shutdown"})}
158 a := NewApp()
159 a.tabs["tab"] = &WorkspaceTab{ID: "tab", Ctrl: ctrl}
160 a.tabOrder = []string{"tab"}
161
162 a.shutdown(context.Background())
163
164 if ctrl.normalSnapshots != 0 {
165 t.Fatalf("ordinary Snapshot calls = %d, want shutdown-specific persistence", ctrl.normalSnapshots)
166 }
167 if len(ctrl.calls) != 2 || ctrl.calls[0] != "shutdown-snapshot" || ctrl.calls[1] != "close" {
168 t.Fatalf("shutdown call order = %v, want [shutdown-snapshot close]", ctrl.calls)
169 }
170 }
171
172 func TestShutdownPersistsRecoveryPathCommittedAfterCallback(t *testing.T) {
173 isolateDesktopUserDirs(t)
174 dir := t.TempDir()
175 originalPath := filepath.Join(dir, "original.jsonl")
176 recoveryPath := filepath.Join(dir, "original-recovery.jsonl")
177 a := NewApp()
178 ctrl := &shutdownSnapshotController{
179 SessionAPI: control.New(control.Options{Label: "shutdown", SessionPath: originalPath}),
180 sessionPath: originalPath,
181 }
182 tab := &WorkspaceTab{ID: "tab", Ctrl: ctrl, SessionPath: originalPath}
183 a.tabs[tab.ID] = tab
184 a.tabOrder = []string{tab.ID}
185 a.activeTabID = tab.ID
186 ctrl.shutdown = func() error {
187 err := a.handleTabSessionRecovered(tab)(control.SessionRecoveryInfo{
188 OriginalPath: originalPath,
189 RecoveryPath: recoveryPath,
190 })
191 if err == nil {
192 // Force a newer ordinary layout write while Controller still exposes
193 // the old path. The recovery lease must keep this write anchored to
194 // recovery instead of undoing the callback's first save.
195 a.mu.Lock()
196 a.saveTabsLocked()
197 a.mu.Unlock()
198 // Controller.commitRecoveredSession updates its path only after the
199 // callback succeeds. Mirror that ordering exactly.
200 ctrl.sessionPath = recoveryPath
201 }
202 return err
203 }
204
205 a.shutdown(context.Background())
206
207 saved := loadTabsFile()
208 if len(saved.Tabs) != 1 || saved.Tabs[0].ID != tab.ID {
209 t.Fatalf("saved tabs = %+v, want recovered tab %q", saved.Tabs, tab.ID)
210 }
211 if got := saved.Tabs[0].SessionPath; got != recoveryPath {
212 t.Fatalf("saved shutdown session path = %q, want recovery path %q", got, recoveryPath)
213 }
214 if len(ctrl.calls) != 2 || ctrl.calls[0] != "shutdown-snapshot" || ctrl.calls[1] != "close" {
215 t.Fatalf("shutdown call order = %v, want [shutdown-snapshot close]", ctrl.calls)
216 }
217 }
218
218 lines GO