返回 DeepSeek-Reasonix
fork_targets_test.go
根目录 / desktop / fork_targets_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "testing"
8
9 "reasonix/internal/control"
10 "reasonix/internal/event"
11 "reasonix/internal/session"
12 )
13
14 // forkTargetsStubController is a control.SessionAPI fake that also satisfies
15 // forkTargetsController and records the creation request it received.
16 type forkTargetsStubController struct {
17 *tabScopedActionController
18 running bool
19 set session.ForkTargetSet
20 setErr error
21 childID string
22 createErr error
23 createTurn string
24 createBoundary uint64
25 createName string
26 createOp string
27 creates int
28 service *session.Service
29 cwd string
30 }
31
32 // RuntimeStatus reports a turn in flight when running is set, so a scenario can
33 // assert the binding answers while the source is busy.
34 func (c *forkTargetsStubController) RuntimeStatus() control.RuntimeStatus {
35 if !c.running {
36 return c.tabScopedActionController.RuntimeStatus()
37 }
38 return control.RuntimeStatus{Running: true, Status: event.TurnInProgress}
39 }
40
41 func (c *forkTargetsStubController) ForkTargets() (session.ForkTargetSet, error) {
42 return c.set, c.setErr
43 }
44
45 func (c *forkTargetsStubController) CreateForkSession(request session.ForkRequest, name string) (string, error) {
46 c.creates++
47 c.createTurn, c.createBoundary, c.createName, c.createOp = request.TurnID, request.BoundarySequence, name, request.OperationID
48 if c.createErr == nil && c.service != nil {
49 var runtime *session.Runtime
50 runtime, c.createErr = c.service.Create(context.Background(), session.CreateOptions{
51 SessionID: c.childID, CWD: c.cwd, ParentSessionID: request.Source.SessionID, Origin: session.SessionOriginFork,
52 })
53 if c.createErr == nil {
54 c.createErr = c.service.Close(context.Background(), runtime.Ref())
55 }
56 }
57 return c.childID, c.createErr
58 }
59
60 func enableForkTargetPersistence(t *testing.T, app *App, ctrl *forkTargetsStubController) {
61 t.Helper()
62 pinDesktopSessionRoot(t, app)
63 ctrl.service = app.desktopSessionService("")
64 ctrl.cwd = globalWorkspaceRoot()
65 }
66
67 func (c *forkTargetsStubController) UsesExclusiveSession() bool { return true }
68 func (c *forkTargetsStubController) SessionRef() (session.SessionRef, bool) {
69 return session.SessionRef{HostID: "host-1", SessionID: "source-1"}, true
70 }
71 func (c *forkTargetsStubController) SessionService() *session.Service { return nil }
72 func (c *forkTargetsStubController) BindFreshSession(context.Context, string) (session.SessionRef, error) {
73 return session.SessionRef{}, errors.New("not implemented")
74 }
75 func (c *forkTargetsStubController) OpenSession(context.Context, session.SessionRef) (session.SessionRef, error) {
76 return session.SessionRef{}, errors.New("not implemented")
77 }
78 func (c *forkTargetsStubController) ContinueLegacySession(context.Context, string, string) (session.SessionRef, error) {
79 return session.SessionRef{}, errors.New("not implemented")
80 }
81 func (c *forkTargetsStubController) ContinuePrototypeSession(context.Context, string) (session.SessionRef, error) {
82 return session.SessionRef{}, errors.New("not implemented")
83 }
84
85 // assertEmptyForkTargets checks the shared empty result: no error, a non-nil
86 // slice, and a "targets" field that JSON-marshals to [] instead of null.
87 func assertEmptyForkTargets(t *testing.T, name string, view ForkTargetSetView, err error) {
88 t.Helper()
89 if err != nil {
90 t.Fatalf("%s: err = %v, want nil", name, err)
91 }
92 if view.Targets == nil {
93 t.Fatalf("%s: Targets is nil; the renderer expects []", name)
94 }
95 if len(view.Targets) != 0 || view.Verifiable {
96 t.Fatalf("%s: view = %+v, want an empty unverifiable set", name, view)
97 }
98 raw, marshalErr := json.Marshal(view)
99 if marshalErr != nil {
100 t.Fatalf("%s: marshal: %v", name, marshalErr)
101 }
102 var decoded struct {
103 Targets *[]ForkTargetView `json:"targets"`
104 }
105 if unmarshalErr := json.Unmarshal(raw, &decoded); unmarshalErr != nil {
106 t.Fatalf("%s: unmarshal %s: %v", name, raw, unmarshalErr)
107 }
108 if decoded.Targets == nil || len(*decoded.Targets) != 0 {
109 t.Fatalf("%s: JSON = %s, want \"targets\":[]", name, raw)
110 }
111 }
112
113 func TestForkTargetsForTabReturnsEmptyNonNilTargets(t *testing.T) {
114 isolateDesktopUserDirs(t)
115
116 app := NewApp()
117 missing, missingErr := app.ForkTargetsForTab("missing")
118 assertEmptyForkTargets(t, "missing tab", missing, missingErr)
119 app.setTestCtrl(newTabScopedActionController(), "")
120 stubbed, stubbedErr := app.ForkTargetsForTab("test")
121 assertEmptyForkTargets(t, "controller without fork targets", stubbed, stubbedErr)
122 active, activeErr := app.ForkTargetsForTab("")
123 assertEmptyForkTargets(t, "active tab", active, activeErr)
124 }
125
126 func TestForkedSessionLocatorRejectsCatalogPseudoPaths(t *testing.T) {
127 source := &WorkspaceTab{ID: "source"}
128 for _, path := range []string{"", ".", "bare-session-id"} {
129 if _, err := normalizeForkedSessionLocator(source, forkedSessionLocator{SessionPath: path}); err == nil {
130 t.Fatalf("session path %q was accepted", path)
131 }
132 }
133 if got, err := normalizeForkedSessionLocator(source, forkedSessionLocator{SessionID: "child-session"}); err != nil || got.SessionID != "child-session" {
134 t.Fatalf("canonical session id = %+v, err=%v", got, err)
135 }
136 for _, path := range []string{"", ".", "child-session"} {
137 if got := sessionDirectoryForPath(path); got != "" {
138 t.Fatalf("sessionDirectoryForPath(%q) = %q, want no catalog target", path, got)
139 }
140 }
141 }
142
143 func TestForkTargetsForTabMapsTargetsForReadOnlyTab(t *testing.T) {
144 isolateDesktopUserDirs(t)
145
146 ctrl := &forkTargetsStubController{
147 tabScopedActionController: newTabScopedActionController(),
148 // A read-only channel tab whose turn is running still lists targets.
149 running: true,
150 set: session.ForkTargetSet{
151 Source: session.SessionRef{HostID: "host-1", SessionID: "source-1"},
152 Targets: []session.ForkTarget{
153 {TurnID: "turn-1", BoundarySequence: 7, TurnNumber: 1, Status: event.TurnCompleted, MessageID: "msg-1", Available: true},
154 {TurnID: "turn-2", TurnNumber: 2, Status: event.TurnInProgress, Reason: session.ForkTurnOpen},
155 },
156 Verifiable: true,
157 },
158 }
159 app := NewApp()
160 app.setTestCtrl(ctrl, "")
161 app.tabs["test"].SessionID = "source-1"
162 app.tabs["test"].Scope = "global"
163 app.tabs["test"].ReadOnly = true
164
165 view, err := app.ForkTargetsForTab("test")
166 if err != nil {
167 t.Fatalf("ForkTargetsForTab: %v", err)
168 }
169 if !view.Verifiable {
170 t.Fatal("Verifiable = false, want the controller's value")
171 }
172 want := []ForkTargetView{
173 {SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-1", BoundarySequence: 7, TurnNumber: 1, Status: string(event.TurnCompleted), MessageID: "msg-1", Available: true},
174 {SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-2", TurnNumber: 2, Status: string(event.TurnInProgress), Reason: string(session.ForkTurnOpen)},
175 }
176 if len(view.Targets) != len(want) {
177 t.Fatalf("targets = %+v, want %+v", view.Targets, want)
178 }
179 for i, target := range view.Targets {
180 if target != want[i] {
181 t.Fatalf("target[%d] = %+v, want %+v", i, target, want[i])
182 }
183 }
184 raw, marshalErr := json.Marshal(view.Targets[1])
185 if marshalErr != nil {
186 t.Fatalf("marshal target: %v", marshalErr)
187 }
188 var fields map[string]any
189 if unmarshalErr := json.Unmarshal(raw, &fields); unmarshalErr != nil {
190 t.Fatalf("unmarshal target: %v", unmarshalErr)
191 }
192 if _, present := fields["messageId"]; present {
193 t.Fatalf("target without a message id = %s, want messageId omitted", raw)
194 }
195 }
196
197 func TestForkTargetsForTabReturnsControllerError(t *testing.T) {
198 isolateDesktopUserDirs(t)
199
200 ctrl := &forkTargetsStubController{
201 tabScopedActionController: newTabScopedActionController(),
202 setErr: errors.New("fork targets unavailable"),
203 }
204 app := NewApp()
205 app.setTestCtrl(ctrl, "")
206
207 view, err := app.ForkTargetsForTab("test")
208 if err == nil {
209 t.Fatal("ForkTargetsForTab: err = nil, want the controller's failure")
210 }
211 // The error is asserted above; the view still satisfies the empty-set contract.
212 assertEmptyForkTargets(t, "failed targets", view, nil)
213 }
214
215 func TestCreateForkForTabOpensChildInNewTab(t *testing.T) {
216 isolateDesktopUserDirs(t)
217
218 childID := "created-fork"
219 ctrl := &forkTargetsStubController{
220 tabScopedActionController: newTabScopedActionController(),
221 // Creating a child neither stops the running turn nor takes a rotation gate.
222 running: true,
223 childID: childID,
224 }
225 app := NewApp()
226 app.setTestCtrl(ctrl, "")
227 enableForkTargetPersistence(t, app, ctrl)
228 app.tabs["test"].SessionID = "source-1"
229 app.tabs["test"].Scope = "global"
230 app.tabs["test"].TopicTitle = "Source topic"
231 // A read-only channel tab is a legitimate fork source: the child is written
232 // from the source, never into it.
233 app.tabs["test"].ReadOnly = true
234
235 view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-7", BoundarySequence: 9})
236 if err != nil {
237 t.Fatalf("CreateForkForTab: %v", err)
238 }
239 if !view.Opened || view.Error != "" {
240 t.Fatalf("view = %+v, want an opened tab without an error", view)
241 }
242 if view.SessionID != childID {
243 t.Fatalf("sessionId = %q, want %q", view.SessionID, childID)
244 }
245 if view.TabID == "" || view.TabID == "test" {
246 t.Fatalf("tabId = %q, want a fresh tab", view.TabID)
247 }
248 if ctrl.createTurn != "turn-7" || ctrl.createBoundary != 9 || ctrl.createName != "" || ctrl.createOp == "" || view.OperationID != ctrl.createOp {
249 t.Fatalf("create request = (%q, %d, %q, %q), want the anchored turn and host operation",
250 ctrl.createTurn, ctrl.createBoundary, ctrl.createName, ctrl.createOp)
251 }
252 if app.tabs["test"] == nil || app.tabs["test"].Ctrl != ctrl {
253 t.Fatal("source tab lost its controller")
254 }
255 if app.activeTabID != view.TabID {
256 t.Fatalf("active tab = %q, want the focused source's child %q", app.activeTabID, view.TabID)
257 }
258 child := app.tabs[view.TabID]
259 if child == nil {
260 t.Fatalf("child tab %q is missing", view.TabID)
261 }
262 if child.TopicID == "" || child.SessionID != childID || child.SessionPath != "" {
263 t.Fatalf("child tab = %+v, want canonical session id %q", child, childID)
264 }
265 }
266
267 func TestCreateForkForTabKeepsChildWhenTabAttachFails(t *testing.T) {
268 isolateDesktopUserDirs(t)
269
270 childID := "orphan-fork"
271 ctrl := &forkTargetsStubController{
272 tabScopedActionController: newTabScopedActionController(),
273 childID: childID,
274 }
275 app := NewApp()
276 app.setTestCtrl(ctrl, "")
277 enableForkTargetPersistence(t, app, ctrl)
278 app.tabs["test"].SessionID = "source-1"
279 app.tabs["test"].Scope = "global"
280 app.tabs["test"].TopicTitle = "Source topic"
281 t.Cleanup(func() { forkTabBeforePublishHookForTest.Store(nil) })
282 // Closing the source tab mid-flight makes the attach a no-op, which is the
283 // same outcome as an attach that fails outright.
284 hook := func() {
285 app.mu.Lock()
286 delete(app.tabs, "test")
287 app.removeTabOrderLocked("test")
288 if app.activeTabID == "test" {
289 app.activeTabID = ""
290 }
291 app.mu.Unlock()
292 }
293 forkTabBeforePublishHookForTest.Store(&hook)
294
295 view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-7", BoundarySequence: 9})
296 if err != nil {
297 t.Fatalf("CreateForkForTab: %v", err)
298 }
299 if view.Opened {
300 t.Fatal("opened = true, want false when the new tab was not created")
301 }
302 if view.TabID != "" {
303 t.Fatalf("tabId = %q, want empty", view.TabID)
304 }
305 if view.SessionID != childID {
306 t.Fatalf("sessionId = %q, want the created child %q", view.SessionID, childID)
307 }
308 if view.Error == "" {
309 t.Fatal("error is empty; the caller cannot offer a recovery entry")
310 }
311 if ctrl.creates != 1 {
312 t.Fatalf("creates = %d, want exactly one child", ctrl.creates)
313 }
314 if ctrl.createOp == "" || view.OperationID != ctrl.createOp {
315 t.Fatalf("operationId = %q view=%q, want one host-owned id", ctrl.createOp, view.OperationID)
316 }
317 }
318
319 func TestCreateForkForTabReturnsCreateFailure(t *testing.T) {
320 isolateDesktopUserDirs(t)
321
322 ctrl := &forkTargetsStubController{
323 tabScopedActionController: newTabScopedActionController(),
324 createErr: errors.New("turn is not forkable"),
325 }
326 app := NewApp()
327 app.setTestCtrl(ctrl, "")
328 app.tabs["test"].SessionID = "source-1"
329 app.tabs["test"].Scope = "global"
330
331 view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-7", BoundarySequence: 9})
332 if err == nil {
333 t.Fatal("CreateForkForTab: err = nil, want the controller's failure")
334 }
335 if view.SessionID != "" || view.TabID != "" || view.Opened || view.Error != "" {
336 t.Fatalf("view = %+v, want the zero view alongside the error", view)
337 }
338 if len(app.tabs) != 1 || app.tabs["test"] == nil || app.activeTabID != "test" {
339 t.Fatalf("tabs = %d, active = %q, want only the unchanged source tab", len(app.tabs), app.activeTabID)
340 }
341 journal, loadErr := loadForkOperations(forkOperationsPath())
342 if loadErr != nil || len(journal.Operations) != 1 || journal.Operations[0].State != "pending" {
343 t.Fatalf("uncertain failure journal = %+v, err=%v", journal, loadErr)
344 }
345 }
346
347 func TestCreateForkForTabDiscardsExplicitRefusal(t *testing.T) {
348 isolateDesktopUserDirs(t)
349 ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(),
350 createErr: &session.ForkUnavailableError{TurnID: "turn-7", Reason: session.ForkActiveAuthority}}
351 app := NewApp()
352 app.setTestCtrl(ctrl, "")
353 app.tabs["test"].SessionID = "source-1"
354 view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1",
355 TurnID: "turn-7", BoundarySequence: 9})
356 if err != nil || view.Reason != string(session.ForkActiveAuthority) {
357 t.Fatalf("explicit refusal = %+v, err=%v", view, err)
358 }
359 journal, loadErr := loadForkOperations(forkOperationsPath())
360 if loadErr != nil || len(journal.Operations) != 0 {
361 t.Fatalf("explicit refusal journal = %+v, err=%v", journal, loadErr)
362 }
363 }
364
365 func TestCreateForkForTabRejectsStaleSourceIdentity(t *testing.T) {
366 isolateDesktopUserDirs(t)
367 ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(), childID: "must-not-exist"}
368 app := NewApp()
369 app.setTestCtrl(ctrl, "")
370 app.tabs["test"].Scope = "global"
371 app.tabs["test"].SessionID = "source-b"
372
373 view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-a",
374 TurnID: "shared-turn", BoundarySequence: 9})
375 if err != nil || view.Reason != string(session.ForkStaleSource) || ctrl.creates != 0 {
376 t.Fatalf("stale create = %+v, err=%v creates=%d", view, err, ctrl.creates)
377 }
378 journal, loadErr := loadForkOperations(forkOperationsPath())
379 if loadErr != nil || len(journal.Operations) != 0 {
380 t.Fatalf("stale create journal = %+v, err=%v", journal, loadErr)
381 }
382 }
383
384 func TestForkOperationJournalSurvivesRestartAndAcknowledgement(t *testing.T) {
385 isolateDesktopUserDirs(t)
386 template := forkOperation{Surface: "remote", TabID: "tab", SourceHostID: "host", SourceSessionID: "source",
387 TurnID: "turn", BoundarySequence: 12}
388 first, err := (&App{}).beginForkOperation(template)
389 if err != nil {
390 t.Fatal(err)
391 }
392 if err := (&App{}).AcknowledgeForkOperation("tab", first.OperationID); err != nil {
393 t.Fatal(err)
394 }
395 second, err := (&App{}).beginForkOperation(template)
396 if err != nil || second.OperationID != first.OperationID || second.State != "pending" {
397 t.Fatalf("reloaded pending = %+v, err=%v; want %+v", second, err, first)
398 }
399 if err := (&App{}).completeForkOperation(first.OperationID, "child"); err != nil {
400 t.Fatal(err)
401 }
402 restartedTemplate := template
403 restartedTemplate.TabID = "tab-after-restart"
404 completed, err := (&App{}).beginForkOperation(restartedTemplate)
405 if err != nil || completed.OperationID != first.OperationID || completed.State != "completed" || completed.ChildSessionID != "child" {
406 t.Fatalf("reloaded completion = %+v, err=%v", completed, err)
407 }
408 if err := (&App{}).AcknowledgeForkOperation("tab-after-restart", first.OperationID); err != nil {
409 t.Fatal(err)
410 }
411 fresh, err := (&App{}).beginForkOperation(template)
412 if err != nil || fresh.OperationID == first.OperationID {
413 t.Fatalf("fresh operation after acknowledgement = %+v, err=%v", fresh, err)
414 }
415 }
416
417 func TestCreateForkForTabReopensCompletedOperationAfterAttachFailure(t *testing.T) {
418 isolateDesktopUserDirs(t)
419 ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(), childID: "recovered-child"}
420 app := NewApp()
421 app.setTestCtrl(ctrl, "")
422 enableForkTargetPersistence(t, app, ctrl)
423 app.tabs["test"].Scope = "global"
424 app.tabs["test"].SessionID = "source-1"
425 anchor := ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn", BoundarySequence: 9}
426 t.Cleanup(func() { forkTabBeforePublishHookForTest.Store(nil) })
427 hook := func() {
428 app.mu.Lock()
429 app.tabs["test"] = &WorkspaceTab{ID: "test", Scope: "global", TopicTitle: "Source",
430 SessionID: "source-1", Ctrl: ctrl}
431 app.mu.Unlock()
432 forkTabBeforePublishHookForTest.Store(nil)
433 }
434 forkTabBeforePublishHookForTest.Store(&hook)
435 first, err := app.CreateForkForTab("test", anchor)
436 if err != nil || first.Opened || first.SessionID != "recovered-child" || ctrl.creates != 1 {
437 t.Fatalf("first attach = %+v err=%v creates=%d", first, err, ctrl.creates)
438 }
439 second, err := app.CreateForkForTab("test", anchor)
440 if err != nil || !second.Opened || second.SessionID != first.SessionID || second.OperationID != first.OperationID || ctrl.creates != 1 {
441 t.Fatalf("recovered attach = %+v err=%v creates=%d; first=%+v", second, err, ctrl.creates, first)
442 }
443 }
444
444 lines GO