| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/control" |
| 9 | "reasonix/internal/jobs" |
| 10 | "reasonix/internal/workspacelease" |
| 11 | ) |
| 12 | |
| 13 | type backgroundRuntimeController struct { |
| 14 | stubSessionAPI |
| 15 | status control.RuntimeStatus |
| 16 | jobs []jobs.View |
| 17 | lease workspacelease.State |
| 18 | leaseKeys []string |
| 19 | cancelled []string |
| 20 | turnCancel int |
| 21 | } |
| 22 | |
| 23 | func (c *backgroundRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status } |
| 24 | func (c *backgroundRuntimeController) Jobs() []jobs.View { |
| 25 | return append([]jobs.View(nil), c.jobs...) |
| 26 | } |
| 27 | func (c *backgroundRuntimeController) CancelJob(id string) bool { |
| 28 | for _, job := range c.jobs { |
| 29 | if job.ID == id { |
| 30 | c.cancelled = append(c.cancelled, id) |
| 31 | return true |
| 32 | } |
| 33 | } |
| 34 | return false |
| 35 | } |
| 36 | func (c *backgroundRuntimeController) Cancel() { c.turnCancel++ } |
| 37 | func (c *backgroundRuntimeController) WorkspaceLeaseState() workspacelease.State { |
| 38 | state := c.lease |
| 39 | state.HeldKeys = append([]string(nil), c.leaseKeys...) |
| 40 | return state |
| 41 | } |
| 42 | func (c *backgroundRuntimeController) WorkspaceLeaseHeldKeys() []string { |
| 43 | return append([]string(nil), c.leaseKeys...) |
| 44 | } |
| 45 | |
| 46 | func TestBackgroundRuntimeAPIsKeepDetachedJobsActionable(t *testing.T) { |
| 47 | targetCtrl := &backgroundRuntimeController{ |
| 48 | status: control.RuntimeStatus{BackgroundJobs: 1}, |
| 49 | jobs: []jobs.View{{ID: "job-target", Kind: "bash", Label: "target tests", Status: "running", StartedAt: 1}}, |
| 50 | } |
| 51 | focusedCtrl := &backgroundRuntimeController{ |
| 52 | status: control.RuntimeStatus{BackgroundJobs: 1}, |
| 53 | jobs: []jobs.View{{ID: "job-focused", Kind: "bash", Label: "focused tests", Status: "running", StartedAt: 2}}, |
| 54 | } |
| 55 | target := &WorkspaceTab{ID: "target", TopicTitle: "Detached task", Ctrl: targetCtrl} |
| 56 | focused := &WorkspaceTab{ID: "focused", TopicTitle: "Focused task", Ctrl: focusedCtrl} |
| 57 | app := &App{ |
| 58 | tabs: map[string]*WorkspaceTab{"focused": focused}, |
| 59 | tabOrder: []string{"focused"}, |
| 60 | activeTabID: "focused", |
| 61 | detachedSessions: map[string]*WorkspaceTab{"session-target": target}, |
| 62 | } |
| 63 | |
| 64 | work := app.ActiveWorkForTab("target") |
| 65 | if len(work.Jobs) != 1 || work.Jobs[0].ID != "job-target" { |
| 66 | t.Fatalf("ActiveWorkForTab(target) = %+v, want target job", work) |
| 67 | } |
| 68 | result, err := app.CancelJobsForTab("target", []string{"job-target", "job-target", "missing"}) |
| 69 | if err != nil { |
| 70 | t.Fatalf("CancelJobsForTab: %v", err) |
| 71 | } |
| 72 | if len(result.Cancelled) != 1 || result.Cancelled[0] != "job-target" || len(result.NotRunning) != 1 || result.NotRunning[0] != "missing" { |
| 73 | t.Fatalf("CancelJobsForTab result = %+v", result) |
| 74 | } |
| 75 | if len(targetCtrl.cancelled) != 1 || len(focusedCtrl.cancelled) != 0 { |
| 76 | t.Fatalf("cancellation routing target=%v focused=%v", targetCtrl.cancelled, focusedCtrl.cancelled) |
| 77 | } |
| 78 | |
| 79 | runtimes := app.BackgroundRuntimes() |
| 80 | if len(runtimes) != 2 { |
| 81 | t.Fatalf("BackgroundRuntimes = %+v, want visible and detached", runtimes) |
| 82 | } |
| 83 | var foundDetached bool |
| 84 | for _, runtime := range runtimes { |
| 85 | if runtime.TabID == "target" { |
| 86 | foundDetached = runtime.Detached && runtime.Title == "Detached task" && len(runtime.Jobs) == 1 |
| 87 | } |
| 88 | } |
| 89 | if !foundDetached { |
| 90 | t.Fatalf("detached runtime missing from %+v", runtimes) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | func TestCancelJobForLocalRuntimeByExplicitTab(t *testing.T) { |
| 95 | localCtrl := &backgroundRuntimeController{ |
| 96 | status: control.RuntimeStatus{BackgroundJobs: 1}, |
| 97 | jobs: []jobs.View{{ID: "job-local", Kind: "bash", Status: "running"}}, |
| 98 | } |
| 99 | app := &App{ |
| 100 | tabs: map[string]*WorkspaceTab{"local": {ID: "local", Ctrl: localCtrl}}, |
| 101 | tabOrder: []string{"local"}, |
| 102 | activeTabID: "local", |
| 103 | } |
| 104 | |
| 105 | listed := app.JobsForTab("local") |
| 106 | if len(listed) != 1 || listed[0].ID != "job-local" { |
| 107 | t.Fatalf("JobsForTab(local) = %+v, want local job", listed) |
| 108 | } |
| 109 | cancelled, err := app.CancelJobForTab("local", "job-local") |
| 110 | if err != nil { |
| 111 | t.Fatalf("CancelJobForTab(local): %v", err) |
| 112 | } |
| 113 | if !cancelled || len(localCtrl.cancelled) != 1 || localCtrl.cancelled[0] != "job-local" { |
| 114 | t.Fatalf("local cancellation = %t, calls=%v; want local job cancelled", cancelled, localCtrl.cancelled) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestWorkspaceConflictIdentifiesExactLocalWriterWithoutIdentity(t *testing.T) { |
| 119 | root := t.TempDir() |
| 120 | waiterCtrl := &backgroundRuntimeController{ |
| 121 | status: control.RuntimeStatus{Running: true}, |
| 122 | lease: workspacelease.State{Waiting: true}, |
| 123 | } |
| 124 | ownerCtrl := &backgroundRuntimeController{ |
| 125 | status: control.RuntimeStatus{BackgroundJobs: 1}, |
| 126 | jobs: []jobs.View{{ID: "owner-job", Kind: "bash", Status: "running"}}, |
| 127 | lease: workspacelease.State{Acquired: true}, |
| 128 | } |
| 129 | app := &App{ |
| 130 | tabs: map[string]*WorkspaceTab{ |
| 131 | "waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl}, |
| 132 | "owner": {ID: "owner", TopicTitle: "Writing task", WorkspaceRoot: root, Ctrl: ownerCtrl}, |
| 133 | }, |
| 134 | tabOrder: []string{"waiter", "owner"}, |
| 135 | activeTabID: "waiter", |
| 136 | } |
| 137 | |
| 138 | conflict := app.WorkspaceConflictForTab("waiter") |
| 139 | if conflict.State != "local" || conflict.OwnerTabID != "owner" || conflict.OwnerTitle != "Writing task" || !conflict.CanReveal { |
| 140 | t.Fatalf("WorkspaceConflictForTab = %+v, want exact local owner", conflict) |
| 141 | } |
| 142 | if len(conflict.OwnerWork.Jobs) != 1 || conflict.OwnerWork.Jobs[0].ID != "owner-job" { |
| 143 | t.Fatalf("owner work = %+v, want sanitized active work", conflict.OwnerWork) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | func TestWorkspaceConflictMatchesTheWaitingFile(t *testing.T) { |
| 148 | root := t.TempDir() |
| 149 | aPath := filepath.Join(root, "a.go") |
| 150 | bPath := filepath.Join(root, "b.go") |
| 151 | waiterCtrl := &backgroundRuntimeController{lease: workspacelease.State{ |
| 152 | Waiting: true, Scope: "file", Label: "a.go", WaitingKeys: []string{aPath}, |
| 153 | }} |
| 154 | wrongCtrl := &backgroundRuntimeController{ |
| 155 | lease: workspacelease.State{Acquired: true, Scope: "file", Label: "b.go"}, leaseKeys: []string{bPath}, |
| 156 | } |
| 157 | rightCtrl := &backgroundRuntimeController{ |
| 158 | lease: workspacelease.State{Acquired: true, Scope: "file", Label: "a.go"}, leaseKeys: []string{aPath}, |
| 159 | } |
| 160 | app := &App{ |
| 161 | tabs: map[string]*WorkspaceTab{ |
| 162 | "waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl}, |
| 163 | "wrong": {ID: "wrong", WorkspaceRoot: root, Ctrl: wrongCtrl}, |
| 164 | "right": {ID: "right", WorkspaceRoot: root, Ctrl: rightCtrl}, |
| 165 | }, |
| 166 | tabOrder: []string{"waiter", "wrong", "right"}, activeTabID: "waiter", |
| 167 | } |
| 168 | |
| 169 | conflict := app.WorkspaceConflictForTab("waiter") |
| 170 | if conflict.State != "local" || conflict.OwnerTabID != "right" { |
| 171 | t.Fatalf("WorkspaceConflictForTab = %+v, want right file owner", conflict) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | func TestWorkspaceConflictMatchesWaitingFileAcrossNestedWorkspaceRoots(t *testing.T) { |
| 176 | parent := t.TempDir() |
| 177 | nested := filepath.Join(parent, "nested") |
| 178 | if err := os.MkdirAll(filepath.Join(nested, ".git"), 0o700); err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | path := filepath.Join(nested, "shared.go") |
| 182 | waiterCtrl := &backgroundRuntimeController{lease: workspacelease.State{ |
| 183 | Waiting: true, Scope: "file", Label: "shared.go", WaitingKeys: []string{path}, |
| 184 | }} |
| 185 | ownerCtrl := &backgroundRuntimeController{ |
| 186 | lease: workspacelease.State{Acquired: true, Scope: "file", Label: "shared.go"}, |
| 187 | leaseKeys: []string{path}, |
| 188 | } |
| 189 | app := &App{ |
| 190 | tabs: map[string]*WorkspaceTab{ |
| 191 | "waiter": {ID: "waiter", WorkspaceRoot: parent, Ctrl: waiterCtrl}, |
| 192 | "owner": {ID: "owner", WorkspaceRoot: nested, Ctrl: ownerCtrl}, |
| 193 | }, |
| 194 | tabOrder: []string{"waiter", "owner"}, activeTabID: "waiter", |
| 195 | } |
| 196 | |
| 197 | conflict := app.WorkspaceConflictForTab("waiter") |
| 198 | if conflict.State != "local" || conflict.OwnerTabID != "owner" { |
| 199 | t.Fatalf("WorkspaceConflictForTab = %+v, want nested local owner", conflict) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | func TestWorkspaceConflictAllowsAcquiredOwnerToWaitForAnotherFile(t *testing.T) { |
| 204 | root := t.TempDir() |
| 205 | aPath := filepath.Join(root, "a.go") |
| 206 | waiterCtrl := &backgroundRuntimeController{lease: workspacelease.State{ |
| 207 | Acquired: true, Waiting: true, Scope: "file", Label: "a.go", WaitingKeys: []string{aPath}, |
| 208 | }} |
| 209 | ownerCtrl := &backgroundRuntimeController{ |
| 210 | lease: workspacelease.State{Acquired: true, Scope: "file", Label: "a.go"}, leaseKeys: []string{aPath}, |
| 211 | } |
| 212 | app := &App{ |
| 213 | tabs: map[string]*WorkspaceTab{ |
| 214 | "waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl}, |
| 215 | "owner": {ID: "owner", WorkspaceRoot: root, Ctrl: ownerCtrl}, |
| 216 | }, |
| 217 | tabOrder: []string{"waiter", "owner"}, activeTabID: "waiter", |
| 218 | } |
| 219 | |
| 220 | conflict := app.WorkspaceConflictForTab("waiter") |
| 221 | if conflict.State != "local" || conflict.OwnerTabID != "owner" { |
| 222 | t.Fatalf("WorkspaceConflictForTab = %+v, want local owner", conflict) |
| 223 | } |
| 224 | } |
| 225 |