| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "sync" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | func TestTopicActivationReselectsCancelledStartup(t *testing.T) { |
| 12 | for _, canonical := range []bool{true, false} { |
| 13 | name := "legacy" |
| 14 | if canonical { |
| 15 | name = "canonical" |
| 16 | } |
| 17 | t.Run(name, func(t *testing.T) { |
| 18 | app, source, target, root, _ := canonicalWorkspaceOpenFixture(t) |
| 19 | app.readyHook = func() {} |
| 20 | installNoopRuntimeEvents(app, source.sink) |
| 21 | t.Cleanup(func() { app.shutdown(context.Background()) }) |
| 22 | path := sessionRoute(target.Ref().SessionID) |
| 23 | if !canonical { |
| 24 | saveWorkspace(root) |
| 25 | app.registerProjectRoot(root) |
| 26 | dir := desktopSessionDir(root) |
| 27 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | path = writeTopicSession(t, dir, "reselect.jsonl", "reselect-topic", "Reselect", root) |
| 31 | } |
| 32 | |
| 33 | buildEntered := make(chan struct{}, 4) |
| 34 | releaseBuilds := make(chan struct{}) |
| 35 | unblockBuilds := sync.OnceFunc(func() { close(releaseBuilds) }) |
| 36 | defer unblockBuilds() |
| 37 | app.tabBuildStartHook = func(string) { buildEntered <- struct{}{}; <-releaseBuilds } |
| 38 | events := newActivationEventRecorder(app) |
| 39 | releaseCompletion := make(chan struct{}) |
| 40 | unblockCompletion := sync.OnceFunc(func() { close(releaseCompletion) }) |
| 41 | defer unblockCompletion() |
| 42 | // Keep the cancelled target present until the return click. This is |
| 43 | // the window between a ready event and the previous surface's prune. |
| 44 | app.activationEventHook = func(ev TopicActivationEvent) { |
| 45 | events.ch <- ev |
| 46 | if ev.RequestID == "away" && ev.Phase == "ready" { |
| 47 | <-releaseCompletion |
| 48 | } |
| 49 | } |
| 50 | request := TopicActivationRequest{Scope: "project", WorkspaceRoot: root, TopicID: "reselect-topic", SessionPath: path, RequestID: "first"} |
| 51 | first, err := app.StartTopicActivation(request) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | select { |
| 56 | case <-buildEntered: |
| 57 | case <-time.After(5 * time.Second): |
| 58 | t.Fatal("target startup did not reach the build gate") |
| 59 | } |
| 60 | _, err = app.StartTopicActivation(TopicActivationRequest{ |
| 61 | Scope: source.Scope, WorkspaceRoot: source.WorkspaceRoot, |
| 62 | SessionPath: sessionRoute(source.SessionID), RequestID: "away", |
| 63 | }) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | events.waitFor(t, activationEventFor("away", "ready")) |
| 68 | request.RequestID = "return" |
| 69 | last, err := app.StartTopicActivation(request) |
| 70 | if err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | unblockBuilds() |
| 74 | unblockCompletion() |
| 75 | terminal := events.waitFor(t, func(ev TopicActivationEvent) bool { |
| 76 | return ev.RequestID == "return" && (ev.Phase == "ready" || ev.Phase == "failed") |
| 77 | }) |
| 78 | if terminal.Phase != "ready" { |
| 79 | t.Fatalf("return click reused an abandoned startup: first=%s last=%s event=%+v", first.TabID, last.TabID, terminal) |
| 80 | } |
| 81 | app.mu.RLock() |
| 82 | visible := app.tabs[app.activeTabID] |
| 83 | correct := visible != nil && visible.ID == last.TabID && visible.Ctrl != nil |
| 84 | if canonical { |
| 85 | correct = correct && visible.SessionID == target.Ref().SessionID |
| 86 | } |
| 87 | app.mu.RUnlock() |
| 88 | if !correct { |
| 89 | t.Fatal("reselected startup did not publish the requested session") |
| 90 | } |
| 91 | }) |
| 92 | } |
| 93 | } |
| 94 |