| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "mime" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "path/filepath" |
| 11 | "runtime" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "golang.org/x/text/encoding/simplifiedchinese" |
| 17 | "reasonix/internal/agent" |
| 18 | "reasonix/internal/checkpoint" |
| 19 | "reasonix/internal/config" |
| 20 | "reasonix/internal/control" |
| 21 | ) |
| 22 | |
| 23 | // --- workspaceStatePath --- |
| 24 | |
| 25 | func TestWorkspaceStatePath(t *testing.T) { |
| 26 | // workspaceStatePath depends on config.MemoryUserDir() which needs a |
| 27 | // config dir. We just verify it returns a consistent path. |
| 28 | p1 := workspaceStatePath() |
| 29 | p2 := workspaceStatePath() |
| 30 | if p1 != p2 { |
| 31 | t.Errorf("workspaceStatePath not stable: %q vs %q", p1, p2) |
| 32 | } |
| 33 | if p1 != "" && filepath.Base(p1) != "desktop-workspace" { |
| 34 | t.Errorf("workspaceStatePath should end with desktop-workspace, got %q", p1) |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // --- saveWorkspace / loadWorkspace round-trip --- |
| 39 | |
| 40 | func TestSaveLoadWorkspaceRoundTrip(t *testing.T) { |
| 41 | // workspaceStatePath() resolves via os.UserConfigDir() (HOME on unix, |
| 42 | // %AppData% on Windows); isolate both so the round-trip exercises real |
| 43 | // persistence instead of no-opping or leaking into the dev config dir. |
| 44 | isolateDesktopUserDirs(t) |
| 45 | if workspaceStatePath() == "" { |
| 46 | t.Fatal("workspaceStatePath() is empty after isolating the user config dir") |
| 47 | } |
| 48 | |
| 49 | dir := t.TempDir() |
| 50 | saveWorkspace(dir) |
| 51 | if got := loadWorkspace(); got != dir { |
| 52 | t.Errorf("loadWorkspace = %q, want %q", got, dir) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestSaveWorkspaceOnlyRemembersLastWorkspace(t *testing.T) { |
| 57 | isolateDesktopUserDirs(t) |
| 58 | first := t.TempDir() |
| 59 | second := t.TempDir() |
| 60 | |
| 61 | saveWorkspace(first) |
| 62 | saveWorkspace(second) |
| 63 | saveWorkspace(first) |
| 64 | |
| 65 | if got := loadWorkspace(); got != first { |
| 66 | t.Fatalf("loadWorkspace = %q, want %q", got, first) |
| 67 | } |
| 68 | if got := loadWorkspaces(); len(got) != 0 { |
| 69 | t.Fatalf("saveWorkspace should not maintain legacy workspace list, got %v", got) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func TestDesktopMCPMigrationRootsIncludesLegacyWorkspaces(t *testing.T) { |
| 74 | isolateDesktopUserDirs(t) |
| 75 | active := t.TempDir() |
| 76 | legacy := t.TempDir() |
| 77 | tabRoot := t.TempDir() |
| 78 | projectRoot := t.TempDir() |
| 79 | |
| 80 | saveWorkspace(active) |
| 81 | if err := os.MkdirAll(filepath.Dir(workspaceListPath()), 0o755); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | b, err := json.Marshal([]string{legacy, active, legacy}) |
| 85 | if err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | if err := os.WriteFile(workspaceListPath(), b, 0o644); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: projectRoot}}}); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | |
| 95 | roots := desktopMCPMigrationRoots(desktopTabsFile{ |
| 96 | Tabs: []desktopTabEntry{{Scope: "project", WorkspaceRoot: tabRoot}}, |
| 97 | }) |
| 98 | want := []string{ |
| 99 | normalizeProjectRoot(active), |
| 100 | normalizeProjectRoot(legacy), |
| 101 | normalizeProjectRoot(tabRoot), |
| 102 | normalizeProjectRoot(projectRoot), |
| 103 | } |
| 104 | if len(roots) != len(want) { |
| 105 | t.Fatalf("roots len = %d, want %d: %+v", len(roots), len(want), roots) |
| 106 | } |
| 107 | for i, root := range want { |
| 108 | if roots[i] != root { |
| 109 | t.Fatalf("roots[%d] = %q, want %q; roots=%+v", i, roots[i], root, roots) |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestRecoverLegacyProjectSidebarRootsPreservesUpgradeProjects(t *testing.T) { |
| 115 | isolateDesktopUserDirs(t) |
| 116 | existing := t.TempDir() |
| 117 | active := t.TempDir() |
| 118 | legacy := t.TempDir() |
| 119 | tabRoot := t.TempDir() |
| 120 | missing := filepath.Join(t.TempDir(), "missing") |
| 121 | |
| 122 | if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: existing, Title: "Existing"}}}); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | saveWorkspace(active) |
| 126 | if err := os.MkdirAll(filepath.Dir(workspaceListPath()), 0o755); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | b, err := json.Marshal([]string{legacy, active, missing, legacy}) |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if err := os.WriteFile(workspaceListPath(), b, 0o644); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | |
| 137 | tabs := desktopTabsFile{ |
| 138 | Tabs: []desktopTabEntry{ |
| 139 | {Scope: "project", WorkspaceRoot: tabRoot}, |
| 140 | {Scope: "project", WorkspaceRoot: missing}, |
| 141 | {Scope: "global"}, |
| 142 | }, |
| 143 | } |
| 144 | changed, err := recoverLegacyProjectSidebarRoots(tabs) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | if !changed { |
| 149 | t.Fatal("recoverLegacyProjectSidebarRoots should add missing legacy projects") |
| 150 | } |
| 151 | |
| 152 | projects := loadProjectsFile().Projects |
| 153 | want := []string{ |
| 154 | normalizeProjectRoot(existing), |
| 155 | normalizeProjectRoot(active), |
| 156 | normalizeProjectRoot(legacy), |
| 157 | normalizeProjectRoot(tabRoot), |
| 158 | } |
| 159 | if len(projects) != len(want) { |
| 160 | t.Fatalf("project count = %d, want %d: %+v", len(projects), len(want), projects) |
| 161 | } |
| 162 | for i, root := range want { |
| 163 | if projects[i].Root != root { |
| 164 | t.Fatalf("projects[%d].Root = %q, want %q; projects=%+v", i, projects[i].Root, root, projects) |
| 165 | } |
| 166 | } |
| 167 | if _, err := os.Stat(filepath.Join(desktopConfigDir(), legacyProjectSidebarRecoveryMarker)); err != nil { |
| 168 | t.Fatalf("recovery marker was not written: %v", err) |
| 169 | } |
| 170 | |
| 171 | if err := removeProject(legacy); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | changed, err = recoverLegacyProjectSidebarRoots(tabs) |
| 175 | if err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | if changed { |
| 179 | t.Fatal("recovery should be one-shot after the marker is written") |
| 180 | } |
| 181 | for _, project := range loadProjectsFile().Projects { |
| 182 | if project.Root == normalizeProjectRoot(legacy) { |
| 183 | t.Fatalf("removed legacy project was restored after marker: %+v", loadProjectsFile().Projects) |
| 184 | } |
| 185 | if project.Root == normalizeProjectRoot(missing) { |
| 186 | t.Fatalf("missing legacy project should not be restored: %+v", loadProjectsFile().Projects) |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestProjectFileUpdatesSerializeReadModifyWrite(t *testing.T) { |
| 192 | isolateDesktopUserDirs(t) |
| 193 | active := t.TempDir() |
| 194 | added := t.TempDir() |
| 195 | |
| 196 | if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: active, Title: "Active"}}}); err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | |
| 200 | entered := make(chan struct{}) |
| 201 | release := make(chan struct{}) |
| 202 | updateErr := make(chan error, 1) |
| 203 | go func() { |
| 204 | updateErr <- updateProjectsFile(func(f *desktopProjectFile) (bool, error) { |
| 205 | close(entered) |
| 206 | <-release |
| 207 | for i, project := range f.Projects { |
| 208 | if project.Root == normalizeProjectRoot(active) { |
| 209 | f.Projects[i].Title = "Active edited" |
| 210 | return true, nil |
| 211 | } |
| 212 | } |
| 213 | return false, nil |
| 214 | }) |
| 215 | }() |
| 216 | <-entered |
| 217 | |
| 218 | addErr := make(chan error, 1) |
| 219 | go func() { |
| 220 | addErr <- addProject(added, "Added") |
| 221 | }() |
| 222 | select { |
| 223 | case err := <-addErr: |
| 224 | t.Fatalf("addProject completed while another project update was in progress: %v", err) |
| 225 | case <-time.After(10 * time.Millisecond): |
| 226 | } |
| 227 | close(release) |
| 228 | if err := <-updateErr; err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | if err := <-addErr; err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | |
| 235 | projects := loadProjectsFile().Projects |
| 236 | if len(projects) != 2 { |
| 237 | t.Fatalf("project count = %d, want 2: %+v", len(projects), projects) |
| 238 | } |
| 239 | if projects[0].Root != normalizeProjectRoot(active) || projects[0].Title != "Active edited" { |
| 240 | t.Fatalf("active project was not preserved with edited title: %+v", projects) |
| 241 | } |
| 242 | if projects[1].Root != normalizeProjectRoot(added) || projects[1].Title != "Added" { |
| 243 | t.Fatalf("concurrent project add was lost: %+v", projects) |
| 244 | } |
| 245 | |
| 246 | if err := addProject(active, ""); err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | projects = loadProjectsFile().Projects |
| 250 | if len(projects) != 2 || projects[1].Root != normalizeProjectRoot(added) { |
| 251 | t.Fatalf("no-op addProject overwrote the added project: %+v", projects) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | func TestNormalizeProjectsFileMergesEquivalentProjectRoots(t *testing.T) { |
| 256 | isolateDesktopUserDirs(t) |
| 257 | projectRoot := t.TempDir() |
| 258 | // A textually different spelling of the same folder. filepath.Join would |
| 259 | // clean the dot segment away and hand back the identical string, so build |
| 260 | // the spelling by hand. |
| 261 | equivalentRoot := projectRoot + string(filepath.Separator) + "." |
| 262 | |
| 263 | f := normalizeProjectsFile(desktopProjectFile{ |
| 264 | Projects: []desktopProject{ |
| 265 | {Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}}, |
| 266 | {Root: equivalentRoot, Color: "blue", Topics: []string{"topic_b"}, PinnedTopics: []string{"topic_b"}}, |
| 267 | }, |
| 268 | PinnedProjects: []string{equivalentRoot}, |
| 269 | SidebarOrder: []string{equivalentRoot, projectRoot}, |
| 270 | }) |
| 271 | |
| 272 | if len(f.Projects) != 1 { |
| 273 | t.Fatalf("projects = %+v, want one merged project", f.Projects) |
| 274 | } |
| 275 | if f.Projects[0].Root != normalizeProjectRoot(projectRoot) { |
| 276 | t.Fatalf("merged root = %q, want %q", f.Projects[0].Root, normalizeProjectRoot(projectRoot)) |
| 277 | } |
| 278 | if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" { |
| 279 | t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0]) |
| 280 | } |
| 281 | if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" { |
| 282 | t.Fatalf("merged topics = %v, want [topic_a topic_b]", got) |
| 283 | } |
| 284 | if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != f.Projects[0].Root { |
| 285 | t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, f.Projects[0].Root) |
| 286 | } |
| 287 | if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != f.Projects[0].Root { |
| 288 | t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, f.Projects[0].Root) |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func TestSwitchWorkspaceReaddsRemovedProject(t *testing.T) { |
| 293 | isolateDesktopUserDirs(t) |
| 294 | projectRoot := t.TempDir() |
| 295 | |
| 296 | if err := addProject(projectRoot, "Project"); err != nil { |
| 297 | t.Fatalf("add project: %v", err) |
| 298 | } |
| 299 | if err := removeProject(projectRoot); err != nil { |
| 300 | t.Fatalf("remove project: %v", err) |
| 301 | } |
| 302 | if got := loadProjectsFile().Projects; len(got) != 0 { |
| 303 | t.Fatalf("projects after remove = %+v, want none", got) |
| 304 | } |
| 305 | |
| 306 | app := NewApp() |
| 307 | installNoopRuntimeEvents(app) |
| 308 | if got, err := app.SwitchWorkspace(projectRoot + string(filepath.Separator) + "."); err != nil { |
| 309 | t.Fatalf("switch workspace: %v", err) |
| 310 | } else if got != normalizeProjectRoot(projectRoot) { |
| 311 | t.Fatalf("SwitchWorkspace root = %q, want %q", got, normalizeProjectRoot(projectRoot)) |
| 312 | } |
| 313 | |
| 314 | projects := loadProjectsFile().Projects |
| 315 | if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(projectRoot) { |
| 316 | t.Fatalf("projects after re-add = %+v, want %q", projects, normalizeProjectRoot(projectRoot)) |
| 317 | } |
| 318 | if got := loadWorkspace(); got != normalizeProjectRoot(projectRoot) { |
| 319 | t.Fatalf("active workspace = %q, want %q", got, normalizeProjectRoot(projectRoot)) |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | // flipPathASCIICase returns the path with the case of every ASCII letter |
| 324 | // swapped — on Windows an equivalent spelling of the same folder that |
| 325 | // normalizeProjectRoot cannot fold away. |
| 326 | func flipPathASCIICase(t *testing.T, path string) string { |
| 327 | t.Helper() |
| 328 | flipped := strings.Map(func(r rune) rune { |
| 329 | switch { |
| 330 | case r >= 'a' && r <= 'z': |
| 331 | return r - 'a' + 'A' |
| 332 | case r >= 'A' && r <= 'Z': |
| 333 | return r - 'A' + 'a' |
| 334 | } |
| 335 | return r |
| 336 | }, path) |
| 337 | if flipped == path { |
| 338 | t.Skipf("path %q contains no ASCII letters to flip", path) |
| 339 | } |
| 340 | return flipped |
| 341 | } |
| 342 | |
| 343 | func TestNormalizeProjectsFileFoldsRootCaseOnWindows(t *testing.T) { |
| 344 | if runtime.GOOS != "windows" { |
| 345 | t.Skip("case-insensitive root matching only applies to Windows paths") |
| 346 | } |
| 347 | isolateDesktopUserDirs(t) |
| 348 | projectRoot := t.TempDir() |
| 349 | flipped := flipPathASCIICase(t, projectRoot) |
| 350 | |
| 351 | f := normalizeProjectsFile(desktopProjectFile{ |
| 352 | Projects: []desktopProject{ |
| 353 | {Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}}, |
| 354 | {Root: flipped, Color: "blue", Topics: []string{"topic_b"}}, |
| 355 | }, |
| 356 | PinnedProjects: []string{flipped}, |
| 357 | SidebarOrder: []string{flipped, projectRoot}, |
| 358 | }) |
| 359 | |
| 360 | if len(f.Projects) != 1 { |
| 361 | t.Fatalf("projects = %+v, want case-equivalent roots merged", f.Projects) |
| 362 | } |
| 363 | canonical := f.Projects[0].Root |
| 364 | if canonical != normalizeProjectRoot(projectRoot) { |
| 365 | t.Fatalf("merged root = %q, want first spelling %q", canonical, normalizeProjectRoot(projectRoot)) |
| 366 | } |
| 367 | if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" { |
| 368 | t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0]) |
| 369 | } |
| 370 | if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" { |
| 371 | t.Fatalf("merged topics = %v, want [topic_a topic_b]", got) |
| 372 | } |
| 373 | if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != canonical { |
| 374 | t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, canonical) |
| 375 | } |
| 376 | if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != canonical { |
| 377 | t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, canonical) |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | func TestProjectRootOpsFoldCaseOnWindows(t *testing.T) { |
| 382 | if runtime.GOOS != "windows" { |
| 383 | t.Skip("case-insensitive root matching only applies to Windows paths") |
| 384 | } |
| 385 | isolateDesktopUserDirs(t) |
| 386 | projectRoot := t.TempDir() |
| 387 | flipped := flipPathASCIICase(t, projectRoot) |
| 388 | |
| 389 | if err := addProject(projectRoot, "Project"); err != nil { |
| 390 | t.Fatalf("add project: %v", err) |
| 391 | } |
| 392 | if err := addProject(flipped, ""); err != nil { |
| 393 | t.Fatalf("re-add project under flipped case: %v", err) |
| 394 | } |
| 395 | projects := loadProjectsFile().Projects |
| 396 | if len(projects) != 1 { |
| 397 | t.Fatalf("projects = %+v, want re-add under equivalent spelling to update in place", projects) |
| 398 | } |
| 399 | if projects[0].Root != normalizeProjectRoot(flipped) { |
| 400 | t.Fatalf("root = %q, want self-healed to latest spelling %q", projects[0].Root, normalizeProjectRoot(flipped)) |
| 401 | } |
| 402 | if projects[0].Title != "Project" { |
| 403 | t.Fatalf("title = %q, want preserved across re-add", projects[0].Title) |
| 404 | } |
| 405 | |
| 406 | if err := prependTopicInProjectsFile(projectRoot, "topic_a", true); err != nil { |
| 407 | t.Fatalf("prepend topic: %v", err) |
| 408 | } |
| 409 | projects = loadProjectsFile().Projects |
| 410 | if len(projects) != 1 { |
| 411 | t.Fatalf("projects = %+v, want topic prepend to reuse the case-equivalent entry", projects) |
| 412 | } |
| 413 | if got := projects[0].Topics; len(got) != 1 || got[0] != "topic_a" { |
| 414 | t.Fatalf("topics = %v, want [topic_a] on the merged entry", got) |
| 415 | } |
| 416 | |
| 417 | if err := removeProject(projectRoot); err != nil { |
| 418 | t.Fatalf("remove project via original spelling: %v", err) |
| 419 | } |
| 420 | if got := loadProjectsFile().Projects; len(got) != 0 { |
| 421 | t.Fatalf("projects after remove = %+v, want none", got) |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | func TestSyncTabWorkspaceRootSpellingsOnWindows(t *testing.T) { |
| 426 | if runtime.GOOS != "windows" { |
| 427 | t.Skip("case-insensitive root matching only applies to Windows paths") |
| 428 | } |
| 429 | isolateDesktopUserDirs(t) |
| 430 | projectRoot := t.TempDir() |
| 431 | flipped := flipPathASCIICase(t, projectRoot) |
| 432 | |
| 433 | if err := addProject(projectRoot, "Project"); err != nil { |
| 434 | t.Fatalf("add project: %v", err) |
| 435 | } |
| 436 | |
| 437 | app := NewApp() |
| 438 | installNoopRuntimeEvents(app) |
| 439 | app.tabs["tab_case"] = &WorkspaceTab{ |
| 440 | ID: "tab_case", |
| 441 | Scope: "project", |
| 442 | WorkspaceRoot: normalizeProjectRoot(projectRoot), |
| 443 | Ready: true, |
| 444 | disabledMCP: map[string]ServerView{}, |
| 445 | } |
| 446 | app.tabOrder = []string{"tab_case"} |
| 447 | |
| 448 | // Re-registering under the flipped spelling self-heals the registry root; |
| 449 | // open tabs must follow so the frontend keeps comparing one string form. |
| 450 | app.registerProjectRoot(flipped) |
| 451 | |
| 452 | projects := loadProjectsFile().Projects |
| 453 | if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(flipped) { |
| 454 | t.Fatalf("registry projects = %+v, want single root %q", projects, normalizeProjectRoot(flipped)) |
| 455 | } |
| 456 | if got := app.tabs["tab_case"].WorkspaceRoot; got != projects[0].Root { |
| 457 | t.Fatalf("tab root = %q, want registry spelling %q", got, projects[0].Root) |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | func TestFindTopicSessionAfterCaseFlippedReaddOnWindows(t *testing.T) { |
| 462 | if runtime.GOOS != "windows" { |
| 463 | t.Skip("case-insensitive root matching only applies to Windows paths") |
| 464 | } |
| 465 | isolateDesktopUserDirs(t) |
| 466 | projectRoot := t.TempDir() |
| 467 | flipped := flipPathASCIICase(t, projectRoot) |
| 468 | |
| 469 | // Register under original spelling. |
| 470 | if err := addProject(projectRoot, "Project"); err != nil { |
| 471 | t.Fatalf("add project: %v", err) |
| 472 | } |
| 473 | if err := prependTopicInProjectsFile(projectRoot, "topic_case", true); err != nil { |
| 474 | t.Fatalf("prepend topic: %v", err) |
| 475 | } |
| 476 | |
| 477 | // Write a session file with the original root spelling in its meta. |
| 478 | sessionDir := desktopSessionDir(projectRoot) |
| 479 | if err := os.MkdirAll(sessionDir, 0o755); err != nil { |
| 480 | t.Fatalf("mkdir session dir: %v", err) |
| 481 | } |
| 482 | sessionPath := filepath.Join(sessionDir, "topic-case.jsonl") |
| 483 | if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil { |
| 484 | t.Fatalf("write session file: %v", err) |
| 485 | } |
| 486 | if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{ |
| 487 | TopicID: "topic_case", |
| 488 | Scope: "project", |
| 489 | WorkspaceRoot: projectRoot, |
| 490 | CreatedAt: time.Now(), |
| 491 | UpdatedAt: time.Now(), |
| 492 | }); err != nil { |
| 493 | t.Fatalf("save branch meta: %v", err) |
| 494 | } |
| 495 | |
| 496 | // Re-add under the flipped-case spelling — simulates Windows Explorer |
| 497 | // or a different shell returning the same folder with different case. |
| 498 | app := NewApp() |
| 499 | installNoopRuntimeEvents(app) |
| 500 | app.registerProjectRoot(flipped) |
| 501 | |
| 502 | // findTopicSessionForTarget must match the session whose meta carries |
| 503 | // the original case spelling against the registry's new (flipped) root. |
| 504 | path, _ := app.findTopicSessionForTarget("project", normalizeProjectRoot(flipped), "topic_case") |
| 505 | if path == "" { |
| 506 | t.Fatal("findTopicSessionForTarget returned empty path; session with old-case root should still match") |
| 507 | } |
| 508 | if path != sessionPath { |
| 509 | t.Fatalf("findTopicSessionForTarget = %q, want %q", path, sessionPath) |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | func TestDialogDefaultDirectoryFallsBackFromMissingWorkspace(t *testing.T) { |
| 514 | parent := t.TempDir() |
| 515 | missing := filepath.Join(parent, "deleted", "project") |
| 516 | |
| 517 | if got := dialogDefaultDirectory(missing); got != parent { |
| 518 | t.Fatalf("dialogDefaultDirectory(%q) = %q, want %q", missing, got, parent) |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | func TestDialogDefaultDirectoryUsesFileParent(t *testing.T) { |
| 523 | dir := t.TempDir() |
| 524 | file := filepath.Join(dir, "reasonix.toml") |
| 525 | if err := os.WriteFile(file, []byte("default_model = \"x\"\n"), 0o644); err != nil { |
| 526 | t.Fatal(err) |
| 527 | } |
| 528 | |
| 529 | if got := dialogDefaultDirectory(file); got != dir { |
| 530 | t.Fatalf("dialogDefaultDirectory(file) = %q, want %q", got, dir) |
| 531 | } |
| 532 | } |
| 533 | |
| 534 | func TestDesktopSessionDirIsScopedByWorkspace(t *testing.T) { |
| 535 | t.Setenv("HOME", t.TempDir()) |
| 536 | t.Setenv("USERPROFILE", t.TempDir()) |
| 537 | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) |
| 538 | |
| 539 | rootA := filepath.Join(t.TempDir(), "project-a") |
| 540 | rootB := filepath.Join(t.TempDir(), "project-b") |
| 541 | if err := os.MkdirAll(rootA, 0o755); err != nil { |
| 542 | t.Fatal(err) |
| 543 | } |
| 544 | if err := os.MkdirAll(rootB, 0o755); err != nil { |
| 545 | t.Fatal(err) |
| 546 | } |
| 547 | |
| 548 | dirA := desktopSessionDir(rootA) |
| 549 | dirB := desktopSessionDir(rootB) |
| 550 | if dirA == "" || dirB == "" { |
| 551 | t.Fatalf("desktop session dirs should resolve: A=%q B=%q", dirA, dirB) |
| 552 | } |
| 553 | if dirA == dirB { |
| 554 | t.Fatalf("different workspaces must not share a desktop session dir: %q", dirA) |
| 555 | } |
| 556 | if dirA == config.SessionDir() || dirB == config.SessionDir() { |
| 557 | t.Fatalf("desktop workspace sessions should not use the global CLI session dir: A=%q B=%q global=%q", dirA, dirB, config.SessionDir()) |
| 558 | } |
| 559 | wantPrefix := filepath.Join(config.MemoryUserDir(), "projects") + string(filepath.Separator) |
| 560 | if !strings.HasPrefix(dirA, wantPrefix) || filepath.Base(dirA) != "sessions" { |
| 561 | t.Fatalf("workspace session dir should live under the project state tree, got %q", dirA) |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | func BenchmarkDesktopSessionDir(b *testing.B) { |
| 566 | root := filepath.Join(b.TempDir(), "project") |
| 567 | if err := os.MkdirAll(root, 0o755); err != nil { |
| 568 | b.Fatal(err) |
| 569 | } |
| 570 | b.ReportAllocs() |
| 571 | for i := 0; i < b.N; i++ { |
| 572 | if desktopSessionDir(root) == "" { |
| 573 | b.Fatal("empty session dir") |
| 574 | } |
| 575 | } |
| 576 | } |
| 577 | |
| 578 | // --- cwdWritable --- |
| 579 | |
| 580 | func TestCwdWritable(t *testing.T) { |
| 581 | // In a normal test environment, cwd should be writable. |
| 582 | if !cwdWritable() { |
| 583 | t.Error("cwd should be writable in test environment") |
| 584 | } |
| 585 | } |
| 586 | |
| 587 | func TestCwdWritableInTempDir(t *testing.T) { |
| 588 | orig, _ := os.Getwd() |
| 589 | defer os.Chdir(orig) |
| 590 | |
| 591 | dir := t.TempDir() |
| 592 | os.Chdir(dir) |
| 593 | if !cwdWritable() { |
| 594 | t.Error("temp dir should be writable") |
| 595 | } |
| 596 | } |
| 597 | |
| 598 | func TestReadFileTrimsPartialUTF8RuneAtPreviewBoundary(t *testing.T) { |
| 599 | orig, _ := os.Getwd() |
| 600 | defer os.Chdir(orig) |
| 601 | |
| 602 | dir := t.TempDir() |
| 603 | if err := os.Chdir(dir); err != nil { |
| 604 | t.Fatal(err) |
| 605 | } |
| 606 | |
| 607 | prefix := strings.Repeat("a", filePreviewLimit-1) |
| 608 | if err := os.WriteFile("large.md", []byte(prefix+"你tail"), 0o644); err != nil { |
| 609 | t.Fatal(err) |
| 610 | } |
| 611 | |
| 612 | preview := (&App{}).ReadFile("large.md") |
| 613 | if preview.Err != "" { |
| 614 | t.Fatalf("ReadFile err = %q", preview.Err) |
| 615 | } |
| 616 | if preview.Binary { |
| 617 | t.Fatal("ReadFile marked valid truncated UTF-8 text as binary") |
| 618 | } |
| 619 | if !preview.Truncated { |
| 620 | t.Fatal("ReadFile did not mark oversized file as truncated") |
| 621 | } |
| 622 | if preview.Body != prefix { |
| 623 | t.Fatalf("ReadFile body len = %d, want %d", len(preview.Body), len(prefix)) |
| 624 | } |
| 625 | } |
| 626 | |
| 627 | func TestReadFilePreviewBinaryClassification(t *testing.T) { |
| 628 | orig, _ := os.Getwd() |
| 629 | defer os.Chdir(orig) |
| 630 | |
| 631 | dir := t.TempDir() |
| 632 | if err := os.Chdir(dir); err != nil { |
| 633 | t.Fatal(err) |
| 634 | } |
| 635 | |
| 636 | // NUL is the binary signal, matching the CLI read_file tool once GB18030 |
| 637 | // decoding meant invalid UTF-8 alone no longer implies binary. |
| 638 | if err := os.WriteFile("binary.bin", append([]byte("data"), 0x00, 0x01, 0x02), 0o644); err != nil { |
| 639 | t.Fatal(err) |
| 640 | } |
| 641 | if p := (&App{}).ReadFile("binary.bin"); !p.Binary { |
| 642 | t.Errorf("NUL-containing file should be binary, got Body=%q", p.Body) |
| 643 | } |
| 644 | |
| 645 | // Invalid UTF-8 without a NUL is decoded leniently and shown as text, with |
| 646 | // U+FFFD where bytes don't map — not hidden behind a binary classification. |
| 647 | if err := os.WriteFile("invalid.txt", append([]byte("hello"), 0xff, 'x', 'y'), 0o644); err != nil { |
| 648 | t.Fatal(err) |
| 649 | } |
| 650 | p := (&App{}).ReadFile("invalid.txt") |
| 651 | if p.Binary { |
| 652 | t.Error("invalid-but-NUL-free file should render as lossy text, not binary") |
| 653 | } |
| 654 | if !strings.ContainsRune(p.Body, '�') { |
| 655 | t.Errorf("lossy decode should mark undecodable bytes with U+FFFD, got %q", p.Body) |
| 656 | } |
| 657 | } |
| 658 | |
| 659 | func TestReadFileMediaPreview(t *testing.T) { |
| 660 | orig, _ := os.Getwd() |
| 661 | defer os.Chdir(orig) |
| 662 | |
| 663 | dir := t.TempDir() |
| 664 | if err := os.Chdir(dir); err != nil { |
| 665 | t.Fatal(err) |
| 666 | } |
| 667 | |
| 668 | png := []byte("\x89PNG\r\n\x1a\npreview") |
| 669 | if err := os.WriteFile("shot.PNG", png, 0o644); err != nil { |
| 670 | t.Fatal(err) |
| 671 | } |
| 672 | var app App |
| 673 | image := app.ReadFile("shot.PNG") |
| 674 | if image.Err != "" { |
| 675 | t.Fatalf("ReadFile png err = %q", image.Err) |
| 676 | } |
| 677 | if image.Binary || image.Kind != "image" || image.Mime != "image/png" { |
| 678 | t.Fatalf("ReadFile png = %+v, want image preview", image) |
| 679 | } |
| 680 | if image.Body != "" { |
| 681 | t.Fatalf("media preview should have empty body, got %q", image.Body) |
| 682 | } |
| 683 | if !strings.HasPrefix(image.URL, "/__reasonix_workspace_media/") || !strings.HasSuffix(image.URL, "/shot.PNG") { |
| 684 | t.Fatalf("unexpected media URL: %q", image.URL) |
| 685 | } |
| 686 | |
| 687 | if err := os.WriteFile("report.pdf", []byte("%PDF-1.4\npreview"), 0o644); err != nil { |
| 688 | t.Fatal(err) |
| 689 | } |
| 690 | pdf := app.ReadFile("report.pdf") |
| 691 | if pdf.Err != "" { |
| 692 | t.Fatalf("ReadFile pdf err = %q", pdf.Err) |
| 693 | } |
| 694 | if pdf.Binary || pdf.Kind != "pdf" || pdf.Mime != "application/pdf" { |
| 695 | t.Fatalf("ReadFile pdf = %+v, want pdf preview", pdf) |
| 696 | } |
| 697 | if pdf.Body != "" { |
| 698 | t.Fatalf("media preview should have empty body, got %q", pdf.Body) |
| 699 | } |
| 700 | if !strings.HasPrefix(pdf.URL, "/__reasonix_workspace_media/") || !strings.HasSuffix(pdf.URL, "/report.pdf") { |
| 701 | t.Fatalf("unexpected media URL: %q", pdf.URL) |
| 702 | } |
| 703 | } |
| 704 | |
| 705 | func TestMediaTokenHandlerServesFile(t *testing.T) { |
| 706 | orig, _ := os.Getwd() |
| 707 | defer os.Chdir(orig) |
| 708 | |
| 709 | dir := t.TempDir() |
| 710 | if err := os.Chdir(dir); err != nil { |
| 711 | t.Fatal(err) |
| 712 | } |
| 713 | |
| 714 | png := []byte("\x89PNG\r\n\x1a\npreview-data") |
| 715 | if err := os.WriteFile("shot.PNG", png, 0o644); err != nil { |
| 716 | t.Fatal(err) |
| 717 | } |
| 718 | |
| 719 | app := NewApp() |
| 720 | preview := app.ReadFile("shot.PNG") |
| 721 | if preview.URL == "" { |
| 722 | t.Fatal("expected URL in media preview") |
| 723 | } |
| 724 | |
| 725 | mw := app.workspaceMediaMiddleware() |
| 726 | handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 727 | t.Error("fallback handler should not be called for media URLs") |
| 728 | })) |
| 729 | |
| 730 | req := httptest.NewRequest(http.MethodGet, preview.URL, nil) |
| 731 | rec := httptest.NewRecorder() |
| 732 | handler.ServeHTTP(rec, req) |
| 733 | |
| 734 | if rec.Code != http.StatusOK { |
| 735 | t.Fatalf("expected 200, got %d", rec.Code) |
| 736 | } |
| 737 | if ct := rec.Header().Get("Content-Type"); ct != "image/png" { |
| 738 | t.Fatalf("expected Content-Type image/png, got %q", ct) |
| 739 | } |
| 740 | if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "inline") { |
| 741 | t.Fatalf("expected inline Content-Disposition, got %q", cd) |
| 742 | } |
| 743 | if rec.Body.String() != string(png) { |
| 744 | t.Fatalf("body mismatch, got %q", rec.Body.String()) |
| 745 | } |
| 746 | } |
| 747 | |
| 748 | func TestMediaTokenHandlerEscapedFilename(t *testing.T) { |
| 749 | orig, _ := os.Getwd() |
| 750 | defer os.Chdir(orig) |
| 751 | |
| 752 | dir := t.TempDir() |
| 753 | if err := os.Chdir(dir); err != nil { |
| 754 | t.Fatal(err) |
| 755 | } |
| 756 | |
| 757 | name := `weird "file" name.png` |
| 758 | rawURLChars := []string{" ", `"`} |
| 759 | if runtime.GOOS == "windows" { |
| 760 | name = "weird #file name.png" |
| 761 | rawURLChars = []string{" ", "#"} |
| 762 | } |
| 763 | if err := os.WriteFile(name, []byte("png"), 0o644); err != nil { |
| 764 | t.Fatal(err) |
| 765 | } |
| 766 | |
| 767 | app := NewApp() |
| 768 | preview := app.ReadFile(name) |
| 769 | for _, raw := range rawURLChars { |
| 770 | if strings.Contains(preview.URL, raw) { |
| 771 | t.Fatalf("media URL should path-escape %q in filename, got %q", raw, preview.URL) |
| 772 | } |
| 773 | } |
| 774 | |
| 775 | handler := app.workspaceMediaMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 776 | t.Error("fallback handler should not be called") |
| 777 | })) |
| 778 | req := httptest.NewRequest(http.MethodGet, preview.URL, nil) |
| 779 | rec := httptest.NewRecorder() |
| 780 | handler.ServeHTTP(rec, req) |
| 781 | |
| 782 | if rec.Code != http.StatusOK { |
| 783 | t.Fatalf("expected 200, got %d", rec.Code) |
| 784 | } |
| 785 | disposition, params, err := mime.ParseMediaType(rec.Header().Get("Content-Disposition")) |
| 786 | if err != nil { |
| 787 | t.Fatalf("Content-Disposition should parse: %v", err) |
| 788 | } |
| 789 | if disposition != "inline" || params["filename"] != name { |
| 790 | t.Fatalf("Content-Disposition = %q %#v, want inline filename %q", disposition, params, name) |
| 791 | } |
| 792 | } |
| 793 | |
| 794 | func TestMediaTokenHandlerHead(t *testing.T) { |
| 795 | orig, _ := os.Getwd() |
| 796 | defer os.Chdir(orig) |
| 797 | |
| 798 | dir := t.TempDir() |
| 799 | if err := os.Chdir(dir); err != nil { |
| 800 | t.Fatal(err) |
| 801 | } |
| 802 | |
| 803 | if err := os.WriteFile("doc.pdf", []byte("%PDF-test"), 0o644); err != nil { |
| 804 | t.Fatal(err) |
| 805 | } |
| 806 | |
| 807 | app := NewApp() |
| 808 | preview := app.ReadFile("doc.pdf") |
| 809 | |
| 810 | mw := app.workspaceMediaMiddleware() |
| 811 | handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 812 | t.Error("fallback handler should not be called") |
| 813 | })) |
| 814 | |
| 815 | req := httptest.NewRequest(http.MethodHead, preview.URL, nil) |
| 816 | rec := httptest.NewRecorder() |
| 817 | handler.ServeHTTP(rec, req) |
| 818 | |
| 819 | if rec.Code != http.StatusOK { |
| 820 | t.Fatalf("expected 200, got %d", rec.Code) |
| 821 | } |
| 822 | if ct := rec.Header().Get("Content-Type"); ct != "application/pdf" { |
| 823 | t.Fatalf("expected Content-Type application/pdf, got %q", ct) |
| 824 | } |
| 825 | } |
| 826 | |
| 827 | func TestMediaTokenHandlerRangeRequest(t *testing.T) { |
| 828 | orig, _ := os.Getwd() |
| 829 | defer os.Chdir(orig) |
| 830 | |
| 831 | dir := t.TempDir() |
| 832 | if err := os.Chdir(dir); err != nil { |
| 833 | t.Fatal(err) |
| 834 | } |
| 835 | |
| 836 | data := []byte("0123456789ABCDEF") |
| 837 | if err := os.WriteFile("data.png", data, 0o644); err != nil { |
| 838 | t.Fatal(err) |
| 839 | } |
| 840 | |
| 841 | app := NewApp() |
| 842 | preview := app.ReadFile("data.png") |
| 843 | |
| 844 | mw := app.workspaceMediaMiddleware() |
| 845 | handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 846 | t.Error("fallback should not be called") |
| 847 | })) |
| 848 | |
| 849 | req := httptest.NewRequest(http.MethodGet, preview.URL, nil) |
| 850 | req.Header.Set("Range", "bytes=0-4") |
| 851 | rec := httptest.NewRecorder() |
| 852 | handler.ServeHTTP(rec, req) |
| 853 | |
| 854 | if rec.Code != http.StatusPartialContent { |
| 855 | t.Fatalf("expected 206, got %d", rec.Code) |
| 856 | } |
| 857 | if rec.Body.String() != "01234" { |
| 858 | t.Fatalf("expected '01234', got %q", rec.Body.String()) |
| 859 | } |
| 860 | } |
| 861 | |
| 862 | func TestMediaTokenHandlerBadToken(t *testing.T) { |
| 863 | orig, _ := os.Getwd() |
| 864 | defer os.Chdir(orig) |
| 865 | |
| 866 | dir := t.TempDir() |
| 867 | if err := os.Chdir(dir); err != nil { |
| 868 | t.Fatal(err) |
| 869 | } |
| 870 | |
| 871 | app := NewApp() |
| 872 | mw := app.workspaceMediaMiddleware() |
| 873 | handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 874 | w.WriteHeader(http.StatusOK) |
| 875 | })) |
| 876 | |
| 877 | req := httptest.NewRequest(http.MethodGet, "/__reasonix_workspace_media/deadbeef/fake.png", nil) |
| 878 | rec := httptest.NewRecorder() |
| 879 | handler.ServeHTTP(rec, req) |
| 880 | |
| 881 | if rec.Code != http.StatusNotFound { |
| 882 | t.Fatalf("expected 404 for bad token, got %d", rec.Code) |
| 883 | } |
| 884 | } |
| 885 | |
| 886 | func TestMediaTokenHandlerNonGetHead(t *testing.T) { |
| 887 | orig, _ := os.Getwd() |
| 888 | defer os.Chdir(orig) |
| 889 | |
| 890 | dir := t.TempDir() |
| 891 | if err := os.Chdir(dir); err != nil { |
| 892 | t.Fatal(err) |
| 893 | } |
| 894 | |
| 895 | if err := os.WriteFile("test.png", []byte("png"), 0o644); err != nil { |
| 896 | t.Fatal(err) |
| 897 | } |
| 898 | |
| 899 | app := NewApp() |
| 900 | preview := app.ReadFile("test.png") |
| 901 | |
| 902 | mw := app.workspaceMediaMiddleware() |
| 903 | handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 904 | t.Error("fallback should not be called") |
| 905 | })) |
| 906 | |
| 907 | req := httptest.NewRequest(http.MethodPost, preview.URL, nil) |
| 908 | rec := httptest.NewRecorder() |
| 909 | handler.ServeHTTP(rec, req) |
| 910 | |
| 911 | if rec.Code != http.StatusMethodNotAllowed { |
| 912 | t.Fatalf("expected 405 for POST, got %d", rec.Code) |
| 913 | } |
| 914 | } |
| 915 | |
| 916 | func TestMediaTokenHandlerPassesUnrelatedPaths(t *testing.T) { |
| 917 | app := NewApp() |
| 918 | mw := app.workspaceMediaMiddleware() |
| 919 | |
| 920 | fallbackCalled := false |
| 921 | handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 922 | fallbackCalled = true |
| 923 | w.WriteHeader(http.StatusOK) |
| 924 | })) |
| 925 | |
| 926 | req := httptest.NewRequest(http.MethodGet, "/index.html", nil) |
| 927 | rec := httptest.NewRecorder() |
| 928 | handler.ServeHTTP(rec, req) |
| 929 | |
| 930 | if !fallbackCalled { |
| 931 | t.Fatal("expected fallback handler for non-media path") |
| 932 | } |
| 933 | } |
| 934 | |
| 935 | func TestMediaTokenMaxEviction(t *testing.T) { |
| 936 | orig, _ := os.Getwd() |
| 937 | defer os.Chdir(orig) |
| 938 | |
| 939 | dir := t.TempDir() |
| 940 | if err := os.Chdir(dir); err != nil { |
| 941 | t.Fatal(err) |
| 942 | } |
| 943 | |
| 944 | if err := os.WriteFile("test.png", []byte("data"), 0o644); err != nil { |
| 945 | t.Fatal(err) |
| 946 | } |
| 947 | |
| 948 | app := NewApp() |
| 949 | store := app.mediaTokens |
| 950 | |
| 951 | // Fill beyond max to trigger eviction of oldest. |
| 952 | var oldestToken string |
| 953 | for i := 0; i < mediaTokenMax+1; i++ { |
| 954 | tok := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{}) |
| 955 | if i == 0 { |
| 956 | oldestToken = tok |
| 957 | } |
| 958 | } |
| 959 | |
| 960 | if store.get(oldestToken) != nil { |
| 961 | t.Fatal("oldest token should have been evicted") |
| 962 | } |
| 963 | if len(store.order) != mediaTokenMax { |
| 964 | t.Fatalf("expected %d tokens, got %d", mediaTokenMax, len(store.order)) |
| 965 | } |
| 966 | } |
| 967 | |
| 968 | func TestMediaTokenExpiry(t *testing.T) { |
| 969 | orig, _ := os.Getwd() |
| 970 | defer os.Chdir(orig) |
| 971 | |
| 972 | dir := t.TempDir() |
| 973 | if err := os.Chdir(dir); err != nil { |
| 974 | t.Fatal(err) |
| 975 | } |
| 976 | |
| 977 | if err := os.WriteFile("test.png", []byte("data"), 0o644); err != nil { |
| 978 | t.Fatal(err) |
| 979 | } |
| 980 | |
| 981 | app := NewApp() |
| 982 | store := app.mediaTokens |
| 983 | tok := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{}) |
| 984 | |
| 985 | // Force expiry by rolling back the clock on the entry. |
| 986 | store.mu.Lock() |
| 987 | store.byTok[tok].expiresAt = time.Now().Add(-1 * time.Second) |
| 988 | store.mu.Unlock() |
| 989 | |
| 990 | if e := store.get(tok); e != nil { |
| 991 | t.Fatal("expired token should return nil") |
| 992 | } |
| 993 | next := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{}) |
| 994 | if next == "" || store.get(next) == nil { |
| 995 | t.Fatal("store should create and read a fresh token after expired get cleanup") |
| 996 | } |
| 997 | } |
| 998 | |
| 999 | func TestReadFileTextUnchanged(t *testing.T) { |
| 1000 | orig, _ := os.Getwd() |
| 1001 | defer os.Chdir(orig) |
| 1002 | |
| 1003 | dir := t.TempDir() |
| 1004 | if err := os.Chdir(dir); err != nil { |
| 1005 | t.Fatal(err) |
| 1006 | } |
| 1007 | |
| 1008 | if err := os.WriteFile("hello.txt", []byte("hello world"), 0o644); err != nil { |
| 1009 | t.Fatal(err) |
| 1010 | } |
| 1011 | |
| 1012 | app := NewApp() |
| 1013 | preview := app.ReadFile("hello.txt") |
| 1014 | if preview.Err != "" { |
| 1015 | t.Fatalf("ReadFile text err = %q", preview.Err) |
| 1016 | } |
| 1017 | if preview.Body != "hello world" { |
| 1018 | t.Fatalf("expected text body, got %q", preview.Body) |
| 1019 | } |
| 1020 | if preview.Kind != "" || preview.Mime != "" || preview.URL != "" { |
| 1021 | t.Fatalf("text preview should not have media fields") |
| 1022 | } |
| 1023 | } |
| 1024 | |
| 1025 | func TestReadFileGB18030(t *testing.T) { |
| 1026 | orig, _ := os.Getwd() |
| 1027 | defer os.Chdir(orig) |
| 1028 | |
| 1029 | dir := t.TempDir() |
| 1030 | if err := os.Chdir(dir); err != nil { |
| 1031 | t.Fatal(err) |
| 1032 | } |
| 1033 | |
| 1034 | gb, _ := simplifiedchinese.GB18030.NewEncoder().String("你好世界") |
| 1035 | if err := os.WriteFile("gbk.txt", []byte(gb), 0o644); err != nil { |
| 1036 | t.Fatal(err) |
| 1037 | } |
| 1038 | |
| 1039 | preview := (&App{}).ReadFile("gbk.txt") |
| 1040 | if preview.Err != "" { |
| 1041 | t.Fatalf("ReadFile err = %q", preview.Err) |
| 1042 | } |
| 1043 | if preview.Binary { |
| 1044 | t.Fatal("ReadFile should decode GB18030, not mark as binary") |
| 1045 | } |
| 1046 | if !strings.Contains(preview.Body, "你好世界") { |
| 1047 | t.Errorf("expected decoded Chinese text, got %q", preview.Body) |
| 1048 | } |
| 1049 | } |
| 1050 | |
| 1051 | // --- RemoveWorkspace cleanup of active pointer --- |
| 1052 | |
| 1053 | func TestRemoveWorkspaceClearsActivePointerWhenRemovingCurrentWorkspace(t *testing.T) { |
| 1054 | isolateDesktopUserDirs(t) |
| 1055 | if workspaceStatePath() == "" { |
| 1056 | t.Fatal("workspaceStatePath() is empty after isolating") |
| 1057 | } |
| 1058 | |
| 1059 | dir := t.TempDir() |
| 1060 | saveWorkspace(dir) |
| 1061 | if got := loadWorkspace(); got != dir { |
| 1062 | t.Fatalf("precondition: loadWorkspace = %q, want %q", got, dir) |
| 1063 | } |
| 1064 | |
| 1065 | // Simulate RemoveWorkspace's cleanup logic: |
| 1066 | // When the removed workspace equals the active one, clearWorkspace should fire. |
| 1067 | if loadWorkspace() == dir { |
| 1068 | clearWorkspace() |
| 1069 | } |
| 1070 | |
| 1071 | if got := loadWorkspace(); got != "" { |
| 1072 | t.Errorf("loadWorkspace = %q after clearWorkspace, want empty", got) |
| 1073 | } |
| 1074 | } |
| 1075 | |
| 1076 | func TestRemoveWorkspaceFallsBackToRemainingProject(t *testing.T) { |
| 1077 | isolateDesktopUserDirs(t) |
| 1078 | |
| 1079 | // Set up two projects and make the first one active. |
| 1080 | first := t.TempDir() |
| 1081 | second := t.TempDir() |
| 1082 | saveWorkspace(first) |
| 1083 | |
| 1084 | // Simulate: remove the active workspace, fall back to the other. |
| 1085 | if loadWorkspace() == first { |
| 1086 | // In the real code, loadProjectsFile() would return remaining projects. |
| 1087 | // Here we simulate falling back to the second project. |
| 1088 | saveWorkspace(second) |
| 1089 | } |
| 1090 | |
| 1091 | if got := loadWorkspace(); got != second { |
| 1092 | t.Errorf("loadWorkspace = %q, want fallback to %q", got, second) |
| 1093 | } |
| 1094 | } |
| 1095 | |
| 1096 | func TestClearWorkspace(t *testing.T) { |
| 1097 | isolateDesktopUserDirs(t) |
| 1098 | if workspaceStatePath() == "" { |
| 1099 | t.Fatal("workspaceStatePath() is empty after isolating") |
| 1100 | } |
| 1101 | |
| 1102 | dir := t.TempDir() |
| 1103 | saveWorkspace(dir) |
| 1104 | if got := loadWorkspace(); got != dir { |
| 1105 | t.Fatalf("precondition failed: loadWorkspace = %q, want %q", got, dir) |
| 1106 | } |
| 1107 | |
| 1108 | clearWorkspace() |
| 1109 | if got := loadWorkspace(); got != "" { |
| 1110 | t.Errorf("loadWorkspace after clearWorkspace = %q, want empty", got) |
| 1111 | } |
| 1112 | // Also verify the file is actually removed. |
| 1113 | if _, err := os.Stat(workspaceStatePath()); !os.IsNotExist(err) { |
| 1114 | t.Errorf("desktop-workspace file should be removed, stat err = %v", err) |
| 1115 | } |
| 1116 | } |
| 1117 | |
| 1118 | // --- OpenProjectTab updates active workspace pointer --- |
| 1119 | |
| 1120 | func TestOpenProjectTabUpdatesActiveWorkspacePointer(t *testing.T) { |
| 1121 | isolateDesktopUserDirs(t) |
| 1122 | if workspaceStatePath() == "" { |
| 1123 | t.Fatal("workspaceStatePath() is empty after isolating") |
| 1124 | } |
| 1125 | |
| 1126 | projectRoot := t.TempDir() |
| 1127 | app := NewApp() |
| 1128 | topic, err := app.CreateTopic("project", projectRoot, "") |
| 1129 | if err != nil { |
| 1130 | t.Fatalf("create topic: %v", err) |
| 1131 | } |
| 1132 | |
| 1133 | if _, err := app.OpenProjectTab(projectRoot, topic.ID); err != nil { |
| 1134 | t.Fatalf("open project tab: %v", err) |
| 1135 | } |
| 1136 | |
| 1137 | if got := loadWorkspace(); got != projectRoot { |
| 1138 | t.Errorf("loadWorkspace = %q after OpenProjectTab, want %q", got, projectRoot) |
| 1139 | } |
| 1140 | } |
| 1141 | |
| 1142 | func TestWindowsOpenWorkspacePathAvoidsCmdShell(t *testing.T) { |
| 1143 | src, err := os.ReadFile("open_workspace_windows.go") |
| 1144 | if err != nil { |
| 1145 | t.Fatal(err) |
| 1146 | } |
| 1147 | body := string(src) |
| 1148 | if !strings.Contains(body, "ShellExecute") { |
| 1149 | t.Fatal("Windows workspace opener should use ShellExecute") |
| 1150 | } |
| 1151 | if strings.Contains(body, "cmd") || strings.Contains(body, "/c") { |
| 1152 | t.Fatal("Windows workspace opener must not route paths through cmd.exe") |
| 1153 | } |
| 1154 | } |
| 1155 | |
| 1156 | func TestParseGitStatusPorcelainZ(t *testing.T) { |
| 1157 | raw := []byte(" M changed.go\x00?? new.txt\x00R renamed.go\x00old.go\x00") |
| 1158 | got := parseGitStatusPorcelainZ(raw) |
| 1159 | if len(got) != 3 { |
| 1160 | t.Fatalf("entries = %d, want 3: %+v", len(got), got) |
| 1161 | } |
| 1162 | if got[0].Path != "changed.go" || got[0].Status != "M" { |
| 1163 | t.Fatalf("modified entry = %+v", got[0]) |
| 1164 | } |
| 1165 | if got[1].Path != "new.txt" || got[1].Status != "??" { |
| 1166 | t.Fatalf("untracked entry = %+v", got[1]) |
| 1167 | } |
| 1168 | if got[2].Path != "renamed.go" || got[2].OldPath != "old.go" || got[2].Status != "R" { |
| 1169 | t.Fatalf("rename entry = %+v", got[2]) |
| 1170 | } |
| 1171 | } |
| 1172 | |
| 1173 | func TestWorkspaceChangesNonGitDirectory(t *testing.T) { |
| 1174 | orig, _ := os.Getwd() |
| 1175 | defer os.Chdir(orig) |
| 1176 | |
| 1177 | dir := t.TempDir() |
| 1178 | if err := os.Chdir(dir); err != nil { |
| 1179 | t.Fatal(err) |
| 1180 | } |
| 1181 | |
| 1182 | got := (&App{}).WorkspaceChanges("") |
| 1183 | if got.GitAvailable { |
| 1184 | t.Fatal("non-git directory should mark git unavailable") |
| 1185 | } |
| 1186 | if len(got.Files) != 0 { |
| 1187 | t.Fatalf("files = %+v, want none", got.Files) |
| 1188 | } |
| 1189 | } |
| 1190 | |
| 1191 | func TestWorkspaceChangesUsesRequestedTabCheckpoints(t *testing.T) { |
| 1192 | workspace := t.TempDir() |
| 1193 | sessionDir := t.TempDir() |
| 1194 | sessionA := filepath.Join(sessionDir, "a.jsonl") |
| 1195 | sessionB := filepath.Join(sessionDir, "b.jsonl") |
| 1196 | content := "old" |
| 1197 | afterExists := true |
| 1198 | now := time.Now() |
| 1199 | |
| 1200 | for _, tc := range []struct { |
| 1201 | session string |
| 1202 | path string |
| 1203 | prompt string |
| 1204 | schemaVersion int |
| 1205 | afterExisted *bool |
| 1206 | afterSHA256 string |
| 1207 | }{ |
| 1208 | {sessionA, "a.txt", "edit a", checkpoint.SchemaV2, &afterExists, checkpoint.Digest([]byte("new"))}, |
| 1209 | {sessionB, "b.txt", "edit b", 0, nil, ""}, |
| 1210 | } { |
| 1211 | ckptDir := strings.TrimSuffix(tc.session, ".jsonl") + ".ckpt" |
| 1212 | if err := os.MkdirAll(ckptDir, 0o755); err != nil { |
| 1213 | t.Fatal(err) |
| 1214 | } |
| 1215 | seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{ |
| 1216 | SchemaVersion: tc.schemaVersion, |
| 1217 | Turn: 0, |
| 1218 | Time: now, |
| 1219 | Prompt: tc.prompt, |
| 1220 | Files: []checkpoint.FileSnap{{ |
| 1221 | Path: tc.path, Content: &content, |
| 1222 | AfterExisted: tc.afterExisted, AfterSHA256: tc.afterSHA256, |
| 1223 | }}, |
| 1224 | }) |
| 1225 | } |
| 1226 | |
| 1227 | ctrlA := control.New(control.Options{SessionDir: sessionDir, SessionPath: sessionA, Label: "a"}) |
| 1228 | ctrlB := control.New(control.Options{SessionDir: sessionDir, SessionPath: sessionB, Label: "b"}) |
| 1229 | app := &App{ |
| 1230 | tabs: map[string]*WorkspaceTab{ |
| 1231 | "a": {ID: "a", Scope: "project", WorkspaceRoot: workspace, Ctrl: ctrlA, Ready: true}, |
| 1232 | "b": {ID: "b", Scope: "project", WorkspaceRoot: workspace, Ctrl: ctrlB, Ready: true}, |
| 1233 | }, |
| 1234 | activeTabID: "a", |
| 1235 | } |
| 1236 | |
| 1237 | got := app.WorkspaceChanges("b") |
| 1238 | byPath := map[string]WorkspaceChangeView{} |
| 1239 | for _, file := range got.Files { |
| 1240 | byPath[file.Path] = file |
| 1241 | } |
| 1242 | if _, ok := byPath["a.txt"]; ok { |
| 1243 | t.Fatalf("requested tab b included active tab a changes: %+v", got.Files) |
| 1244 | } |
| 1245 | if byPath["b.txt"].LatestPrompt != "edit b" { |
| 1246 | t.Fatalf("requested tab b changes = %+v, want b.txt from tab b", got.Files) |
| 1247 | } |
| 1248 | if byPath["b.txt"].CanSessionRevert { |
| 1249 | t.Fatalf("legacy checkpoint must not enable destructive one-click revert: %+v", byPath["b.txt"]) |
| 1250 | } |
| 1251 | gotA := app.WorkspaceChanges("a") |
| 1252 | if len(gotA.Files) != 1 || !gotA.Files[0].CanSessionRevert { |
| 1253 | t.Fatalf("verified v2 checkpoint should enable session revert: %+v", gotA.Files) |
| 1254 | } |
| 1255 | } |
| 1256 | |
| 1257 | func TestWorkspaceChangesGitStatus(t *testing.T) { |
| 1258 | if _, err := exec.LookPath("git"); err != nil { |
| 1259 | t.Skip("git not installed") |
| 1260 | } |
| 1261 | orig, _ := os.Getwd() |
| 1262 | defer os.Chdir(orig) |
| 1263 | |
| 1264 | dir := t.TempDir() |
| 1265 | if err := os.Chdir(dir); err != nil { |
| 1266 | t.Fatal(err) |
| 1267 | } |
| 1268 | runGit(t, "init") |
| 1269 | runGit(t, "checkout", "-b", "feature/test") |
| 1270 | if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil { |
| 1271 | t.Fatal(err) |
| 1272 | } |
| 1273 | runGit(t, "add", "tracked.txt") |
| 1274 | if err := os.WriteFile("tracked.txt", []byte("v2\n"), 0o644); err != nil { |
| 1275 | t.Fatal(err) |
| 1276 | } |
| 1277 | if err := os.WriteFile("untracked.txt", []byte("new\n"), 0o644); err != nil { |
| 1278 | t.Fatal(err) |
| 1279 | } |
| 1280 | |
| 1281 | got := (&App{}).WorkspaceChanges("") |
| 1282 | if !got.GitAvailable { |
| 1283 | t.Fatalf("git unavailable: %s", got.GitErr) |
| 1284 | } |
| 1285 | if got.GitBranch != "feature/test" { |
| 1286 | t.Fatalf("git branch = %q, want feature/test", got.GitBranch) |
| 1287 | } |
| 1288 | byPath := map[string]WorkspaceChangeView{} |
| 1289 | for _, file := range got.Files { |
| 1290 | byPath[file.Path] = file |
| 1291 | } |
| 1292 | if byPath["tracked.txt"].GitStatus == "" { |
| 1293 | t.Fatalf("tracked.txt missing git status: %+v", got.Files) |
| 1294 | } |
| 1295 | if byPath["untracked.txt"].GitStatus != "??" { |
| 1296 | t.Fatalf("untracked.txt = %+v", byPath["untracked.txt"]) |
| 1297 | } |
| 1298 | } |
| 1299 | |
| 1300 | func TestWorkspaceChangesGitStatusFromRepoSubdirectory(t *testing.T) { |
| 1301 | if _, err := exec.LookPath("git"); err != nil { |
| 1302 | t.Skip("git not installed") |
| 1303 | } |
| 1304 | orig, _ := os.Getwd() |
| 1305 | defer os.Chdir(orig) |
| 1306 | |
| 1307 | repo := t.TempDir() |
| 1308 | if err := os.Chdir(repo); err != nil { |
| 1309 | t.Fatal(err) |
| 1310 | } |
| 1311 | runGit(t, "init") |
| 1312 | if err := os.MkdirAll("sub", 0o755); err != nil { |
| 1313 | t.Fatal(err) |
| 1314 | } |
| 1315 | if err := os.WriteFile(filepath.Join("sub", "tracked.txt"), []byte("v1\n"), 0o644); err != nil { |
| 1316 | t.Fatal(err) |
| 1317 | } |
| 1318 | runGit(t, "add", filepath.Join("sub", "tracked.txt")) |
| 1319 | if err := os.WriteFile(filepath.Join("sub", "tracked.txt"), []byte("v2\n"), 0o644); err != nil { |
| 1320 | t.Fatal(err) |
| 1321 | } |
| 1322 | if err := os.WriteFile(filepath.Join("sub", "untracked.txt"), []byte("new\n"), 0o644); err != nil { |
| 1323 | t.Fatal(err) |
| 1324 | } |
| 1325 | if err := os.WriteFile("outside.txt", []byte("outside\n"), 0o644); err != nil { |
| 1326 | t.Fatal(err) |
| 1327 | } |
| 1328 | if err := os.Chdir(filepath.Join(repo, "sub")); err != nil { |
| 1329 | t.Fatal(err) |
| 1330 | } |
| 1331 | |
| 1332 | got := (&App{}).WorkspaceChanges("") |
| 1333 | if !got.GitAvailable { |
| 1334 | t.Fatalf("git unavailable: %s", got.GitErr) |
| 1335 | } |
| 1336 | byPath := map[string]WorkspaceChangeView{} |
| 1337 | for _, file := range got.Files { |
| 1338 | byPath[file.Path] = file |
| 1339 | } |
| 1340 | if byPath["tracked.txt"].GitStatus == "" { |
| 1341 | t.Fatalf("tracked.txt missing git status: %+v", got.Files) |
| 1342 | } |
| 1343 | if byPath["untracked.txt"].GitStatus != "??" { |
| 1344 | t.Fatalf("untracked.txt = %+v", byPath["untracked.txt"]) |
| 1345 | } |
| 1346 | if _, ok := byPath["sub/tracked.txt"]; ok { |
| 1347 | t.Fatalf("git status path should be workspace-relative, got %+v", got.Files) |
| 1348 | } |
| 1349 | if _, ok := byPath["outside.txt"]; ok { |
| 1350 | t.Fatalf("changes outside the opened workspace should be hidden: %+v", got.Files) |
| 1351 | } |
| 1352 | } |
| 1353 | |
| 1354 | func TestWorkspaceChangesUntrackedDirectoryListsFiles(t *testing.T) { |
| 1355 | if _, err := exec.LookPath("git"); err != nil { |
| 1356 | t.Skip("git not installed") |
| 1357 | } |
| 1358 | orig, _ := os.Getwd() |
| 1359 | defer os.Chdir(orig) |
| 1360 | |
| 1361 | dir := t.TempDir() |
| 1362 | if err := os.Chdir(dir); err != nil { |
| 1363 | t.Fatal(err) |
| 1364 | } |
| 1365 | runGit(t, "init") |
| 1366 | if err := os.MkdirAll(filepath.Join("newdir", "nested"), 0o755); err != nil { |
| 1367 | t.Fatal(err) |
| 1368 | } |
| 1369 | if err := os.WriteFile(filepath.Join("newdir", "nested", "file.txt"), []byte("new\n"), 0o644); err != nil { |
| 1370 | t.Fatal(err) |
| 1371 | } |
| 1372 | |
| 1373 | got := (&App{}).WorkspaceChanges("") |
| 1374 | byPath := map[string]WorkspaceChangeView{} |
| 1375 | for _, file := range got.Files { |
| 1376 | byPath[file.Path] = file |
| 1377 | } |
| 1378 | if byPath["newdir/"].GitStatus != "" { |
| 1379 | t.Fatalf("directory should not be listed as a changed file: %+v", got.Files) |
| 1380 | } |
| 1381 | if byPath["newdir/nested/file.txt"].GitStatus != "??" { |
| 1382 | t.Fatalf("untracked file missing from directory: %+v", got.Files) |
| 1383 | } |
| 1384 | } |
| 1385 | |
| 1386 | func TestWorkspaceChangesGitBranchDetachedHead(t *testing.T) { |
| 1387 | if _, err := exec.LookPath("git"); err != nil { |
| 1388 | t.Skip("git not installed") |
| 1389 | } |
| 1390 | orig, _ := os.Getwd() |
| 1391 | defer os.Chdir(orig) |
| 1392 | |
| 1393 | dir := t.TempDir() |
| 1394 | if err := os.Chdir(dir); err != nil { |
| 1395 | t.Fatal(err) |
| 1396 | } |
| 1397 | runGit(t, "init") |
| 1398 | runGit(t, "config", "user.email", "test@example.com") |
| 1399 | runGit(t, "config", "user.name", "Test User") |
| 1400 | if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil { |
| 1401 | t.Fatal(err) |
| 1402 | } |
| 1403 | runGit(t, "add", "tracked.txt") |
| 1404 | runGit(t, "commit", "-m", "init") |
| 1405 | short := gitOutput(t, "rev-parse", "--short", "HEAD") |
| 1406 | runGit(t, "checkout", "--detach", "HEAD") |
| 1407 | |
| 1408 | got := (&App{}).WorkspaceChanges("") |
| 1409 | if !got.GitAvailable { |
| 1410 | t.Fatalf("git unavailable: %s", got.GitErr) |
| 1411 | } |
| 1412 | if got.GitBranch != "@"+short { |
| 1413 | t.Fatalf("git branch = %q, want @%s", got.GitBranch, short) |
| 1414 | } |
| 1415 | } |
| 1416 | |
| 1417 | func TestWorkspaceChangeDetailIncludesStagedAndUnstagedChanges(t *testing.T) { |
| 1418 | if _, err := exec.LookPath("git"); err != nil { |
| 1419 | t.Skip("git not installed") |
| 1420 | } |
| 1421 | repo := t.TempDir() |
| 1422 | runGitIn(t, repo, "init") |
| 1423 | runGitIn(t, repo, "config", "user.email", "test@example.com") |
| 1424 | runGitIn(t, repo, "config", "user.name", "Test User") |
| 1425 | path := filepath.Join(repo, "tracked.txt") |
| 1426 | if err := os.WriteFile(path, []byte("v1\nkeep\n"), 0o644); err != nil { |
| 1427 | t.Fatal(err) |
| 1428 | } |
| 1429 | runGitIn(t, repo, "add", "tracked.txt") |
| 1430 | runGitIn(t, repo, "commit", "-m", "initial") |
| 1431 | if err := os.WriteFile(path, []byte("v2\nkeep\n"), 0o644); err != nil { |
| 1432 | t.Fatal(err) |
| 1433 | } |
| 1434 | runGitIn(t, repo, "add", "tracked.txt") |
| 1435 | if err := os.WriteFile(path, []byte("v3\nkeep\nnew\n"), 0o644); err != nil { |
| 1436 | t.Fatal(err) |
| 1437 | } |
| 1438 | |
| 1439 | app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}} |
| 1440 | detail, err := app.WorkspaceChangeDetail("tab", "tracked.txt") |
| 1441 | if err != nil { |
| 1442 | t.Fatal(err) |
| 1443 | } |
| 1444 | if detail.Source != "git" || detail.Diff == nil { |
| 1445 | t.Fatalf("detail = %+v, want git patch", detail) |
| 1446 | } |
| 1447 | if !strings.Contains(*detail.Diff, "-v1") || !strings.Contains(*detail.Diff, "+v3") || strings.Contains(*detail.Diff, "+v2") { |
| 1448 | t.Fatalf("patch should describe HEAD to current worktree, got:\n%s", *detail.Diff) |
| 1449 | } |
| 1450 | if detail.Added != 2 || detail.Removed != 1 { |
| 1451 | t.Fatalf("tallies = +%d/-%d, want +2/-1", detail.Added, detail.Removed) |
| 1452 | } |
| 1453 | } |
| 1454 | |
| 1455 | func TestWorkspaceChangeDetailSynthesizesUntrackedFile(t *testing.T) { |
| 1456 | if _, err := exec.LookPath("git"); err != nil { |
| 1457 | t.Skip("git not installed") |
| 1458 | } |
| 1459 | repo := t.TempDir() |
| 1460 | runGitIn(t, repo, "init") |
| 1461 | if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("one\ntwo\n"), 0o644); err != nil { |
| 1462 | t.Fatal(err) |
| 1463 | } |
| 1464 | app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}} |
| 1465 | detail, err := app.WorkspaceChangeDetail("tab", "new.txt") |
| 1466 | if err != nil { |
| 1467 | t.Fatal(err) |
| 1468 | } |
| 1469 | if detail.Source != "git" || detail.Diff == nil || !strings.Contains(*detail.Diff, "+one") { |
| 1470 | t.Fatalf("untracked detail = %+v", detail) |
| 1471 | } |
| 1472 | if detail.Added != 2 || detail.Removed != 0 { |
| 1473 | t.Fatalf("untracked tallies = +%d/-%d, want +2/-0", detail.Added, detail.Removed) |
| 1474 | } |
| 1475 | } |
| 1476 | |
| 1477 | func TestWorkspaceChangeDetailBoundsTrackedPatch(t *testing.T) { |
| 1478 | if _, err := exec.LookPath("git"); err != nil { |
| 1479 | t.Skip("git not installed") |
| 1480 | } |
| 1481 | repo := t.TempDir() |
| 1482 | runGitIn(t, repo, "init") |
| 1483 | runGitIn(t, repo, "config", "user.email", "test@example.com") |
| 1484 | runGitIn(t, repo, "config", "user.name", "Test User") |
| 1485 | path := filepath.Join(repo, "large.txt") |
| 1486 | if err := os.WriteFile(path, []byte(strings.Repeat("a", workspaceChangeDetailLimit+128)), 0o644); err != nil { |
| 1487 | t.Fatal(err) |
| 1488 | } |
| 1489 | runGitIn(t, repo, "add", "large.txt") |
| 1490 | runGitIn(t, repo, "commit", "-m", "initial") |
| 1491 | if err := os.WriteFile(path, []byte(strings.Repeat("b", workspaceChangeDetailLimit+128)), 0o644); err != nil { |
| 1492 | t.Fatal(err) |
| 1493 | } |
| 1494 | |
| 1495 | app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}} |
| 1496 | detail, err := app.WorkspaceChangeDetail("tab", "large.txt") |
| 1497 | if err != nil { |
| 1498 | t.Fatal(err) |
| 1499 | } |
| 1500 | if detail.Source != "git" || !detail.Truncated || detail.Diff != nil { |
| 1501 | t.Fatalf("large tracked detail = %+v, want bounded git result", detail) |
| 1502 | } |
| 1503 | } |
| 1504 | |
| 1505 | func TestWorkspaceChangeDetailBoundsUntrackedFile(t *testing.T) { |
| 1506 | if _, err := exec.LookPath("git"); err != nil { |
| 1507 | t.Skip("git not installed") |
| 1508 | } |
| 1509 | repo := t.TempDir() |
| 1510 | runGitIn(t, repo, "init") |
| 1511 | if err := os.WriteFile(filepath.Join(repo, "large.txt"), []byte(strings.Repeat("x", workspaceChangeDetailLimit+1)), 0o644); err != nil { |
| 1512 | t.Fatal(err) |
| 1513 | } |
| 1514 | |
| 1515 | app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}} |
| 1516 | detail, err := app.WorkspaceChangeDetail("tab", "large.txt") |
| 1517 | if err != nil { |
| 1518 | t.Fatal(err) |
| 1519 | } |
| 1520 | if detail.Source != "git" || !detail.Truncated || detail.Diff != nil { |
| 1521 | t.Fatalf("large untracked detail = %+v, want bounded git result", detail) |
| 1522 | } |
| 1523 | } |
| 1524 | |
| 1525 | func TestWorkspaceChangeDetailBoundsCheckpointSnapshot(t *testing.T) { |
| 1526 | workspace := t.TempDir() |
| 1527 | sessionDir := t.TempDir() |
| 1528 | sessionPath := filepath.Join(sessionDir, "session.jsonl") |
| 1529 | checkpointDir := strings.TrimSuffix(sessionPath, ".jsonl") + ".ckpt" |
| 1530 | if err := os.MkdirAll(checkpointDir, 0o755); err != nil { |
| 1531 | t.Fatal(err) |
| 1532 | } |
| 1533 | original := strings.Repeat("before", workspaceChangeDetailLimit/6+1) |
| 1534 | seedCheckpoint(t, checkpointDir, checkpoint.Checkpoint{ |
| 1535 | Turn: 0, |
| 1536 | Time: time.Now(), |
| 1537 | Files: []checkpoint.FileSnap{{Path: filepath.Join(workspace, "large.txt"), Content: &original}}, |
| 1538 | }) |
| 1539 | if err := os.WriteFile(filepath.Join(workspace, "large.txt"), []byte("after\n"), 0o644); err != nil { |
| 1540 | t.Fatal(err) |
| 1541 | } |
| 1542 | ctrl := control.New(control.Options{ |
| 1543 | SessionDir: sessionDir, SessionPath: sessionPath, WorkspaceRoot: workspace, Label: "session", |
| 1544 | }) |
| 1545 | app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: workspace, Ctrl: ctrl}}} |
| 1546 | detail, err := app.WorkspaceChangeDetail("tab", "large.txt") |
| 1547 | if err != nil { |
| 1548 | t.Fatal(err) |
| 1549 | } |
| 1550 | if detail.Source != "session" || !detail.Truncated || detail.Diff != nil { |
| 1551 | t.Fatalf("large checkpoint detail = %+v, want bounded session result", detail) |
| 1552 | } |
| 1553 | } |
| 1554 | |
| 1555 | func TestWorkspaceChangeDetailFallsBackToRequestedTabCheckpoint(t *testing.T) { |
| 1556 | workspace := t.TempDir() |
| 1557 | sessionDir := t.TempDir() |
| 1558 | sessionPath := filepath.Join(sessionDir, "session.jsonl") |
| 1559 | checkpointDir := strings.TrimSuffix(sessionPath, ".jsonl") + ".ckpt" |
| 1560 | if err := os.MkdirAll(checkpointDir, 0o755); err != nil { |
| 1561 | t.Fatal(err) |
| 1562 | } |
| 1563 | original := "before\n" |
| 1564 | seedCheckpoint(t, checkpointDir, checkpoint.Checkpoint{ |
| 1565 | Turn: 0, |
| 1566 | Time: time.Now(), |
| 1567 | Files: []checkpoint.FileSnap{{Path: filepath.Join(workspace, "file.txt"), Content: &original}}, |
| 1568 | }) |
| 1569 | if err := os.WriteFile(filepath.Join(workspace, "file.txt"), []byte("after\n"), 0o644); err != nil { |
| 1570 | t.Fatal(err) |
| 1571 | } |
| 1572 | ctrl := control.New(control.Options{ |
| 1573 | SessionDir: sessionDir, SessionPath: sessionPath, WorkspaceRoot: workspace, Label: "session", |
| 1574 | }) |
| 1575 | app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: workspace, Ctrl: ctrl}}} |
| 1576 | detail, err := app.WorkspaceChangeDetail("tab", "file.txt") |
| 1577 | if err != nil { |
| 1578 | t.Fatal(err) |
| 1579 | } |
| 1580 | if detail.Source != "session" || detail.Diff == nil || !strings.Contains(*detail.Diff, "-before") || !strings.Contains(*detail.Diff, "+after") { |
| 1581 | t.Fatalf("checkpoint detail = %+v", detail) |
| 1582 | } |
| 1583 | if _, err := app.WorkspaceChangeDetail("tab", "../outside.txt"); err == nil { |
| 1584 | t.Fatal("WorkspaceChangeDetail accepted a path outside the workspace") |
| 1585 | } |
| 1586 | } |
| 1587 | |
| 1588 | func TestWorkspaceGitHistory(t *testing.T) { |
| 1589 | if _, err := exec.LookPath("git"); err != nil { |
| 1590 | t.Skip("git not installed") |
| 1591 | } |
| 1592 | orig, _ := os.Getwd() |
| 1593 | defer os.Chdir(orig) |
| 1594 | |
| 1595 | dir := t.TempDir() |
| 1596 | if err := os.Chdir(dir); err != nil { |
| 1597 | t.Fatal(err) |
| 1598 | } |
| 1599 | |
| 1600 | runGit(t, "init") |
| 1601 | runGit(t, "config", "user.email", "test@example.com") |
| 1602 | runGit(t, "config", "user.name", "Test User") |
| 1603 | |
| 1604 | if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil { |
| 1605 | t.Fatal(err) |
| 1606 | } |
| 1607 | runGit(t, "add", "file1.txt") |
| 1608 | runGit(t, "commit", "-m", "init file1") |
| 1609 | |
| 1610 | if err := os.WriteFile("file2.txt", []byte("v1\n"), 0o644); err != nil { |
| 1611 | t.Fatal(err) |
| 1612 | } |
| 1613 | runGit(t, "add", "file2.txt") |
| 1614 | runGit(t, "commit", "-m", "init file2") |
| 1615 | |
| 1616 | app := &App{} |
| 1617 | history, err := app.WorkspaceGitHistory("", "") |
| 1618 | if err != nil { |
| 1619 | t.Fatalf("WorkspaceGitHistory err = %v", err) |
| 1620 | } |
| 1621 | if len(history) != 2 { |
| 1622 | t.Fatalf("expected 2 commits, got %d", len(history)) |
| 1623 | } |
| 1624 | if history[0].Message != "init file2" { |
| 1625 | t.Errorf("expected latest commit message 'init file2', got %q", history[0].Message) |
| 1626 | } |
| 1627 | if history[1].Message != "init file1" { |
| 1628 | t.Errorf("expected older commit message 'init file1', got %q", history[1].Message) |
| 1629 | } |
| 1630 | |
| 1631 | // Test history for specific file |
| 1632 | history, err = app.WorkspaceGitHistory("", "file1.txt") |
| 1633 | if err != nil { |
| 1634 | t.Fatalf("WorkspaceGitHistory err = %v", err) |
| 1635 | } |
| 1636 | if len(history) != 1 { |
| 1637 | t.Fatalf("expected 1 commit for file1.txt, got %d", len(history)) |
| 1638 | } |
| 1639 | if history[0].Message != "init file1" { |
| 1640 | t.Errorf("expected commit message 'init file1', got %q", history[0].Message) |
| 1641 | } |
| 1642 | } |
| 1643 | |
| 1644 | func TestEmptyGitArrayResultsAreNonNil(t *testing.T) { |
| 1645 | if _, err := exec.LookPath("git"); err != nil { |
| 1646 | t.Skip("git not installed") |
| 1647 | } |
| 1648 | orig, _ := os.Getwd() |
| 1649 | defer os.Chdir(orig) |
| 1650 | |
| 1651 | dir := t.TempDir() |
| 1652 | if err := os.Chdir(dir); err != nil { |
| 1653 | t.Fatal(err) |
| 1654 | } |
| 1655 | runGit(t, "init") |
| 1656 | runGit(t, "config", "user.email", "test@example.com") |
| 1657 | runGit(t, "config", "user.name", "Test User") |
| 1658 | |
| 1659 | app := &App{} |
| 1660 | branches, err := app.GitBranches() |
| 1661 | if err != nil { |
| 1662 | t.Fatalf("GitBranches err = %v", err) |
| 1663 | } |
| 1664 | if branches == nil { |
| 1665 | t.Fatal("GitBranches returned nil for an unborn repository; frontend expects []") |
| 1666 | } |
| 1667 | |
| 1668 | if err := os.WriteFile("tracked.txt", []byte("content\n"), 0o644); err != nil { |
| 1669 | t.Fatal(err) |
| 1670 | } |
| 1671 | runGit(t, "add", "tracked.txt") |
| 1672 | runGit(t, "commit", "-m", "initial") |
| 1673 | |
| 1674 | history, err := app.WorkspaceGitHistory("", "never-existed.txt") |
| 1675 | if err != nil { |
| 1676 | t.Fatalf("WorkspaceGitHistory empty path err = %v", err) |
| 1677 | } |
| 1678 | if history == nil { |
| 1679 | t.Fatal("WorkspaceGitHistory returned nil for a path without commits; frontend expects []") |
| 1680 | } |
| 1681 | } |
| 1682 | |
| 1683 | func TestWorkspaceGitHistoryUsesRequestedTabWorkspace(t *testing.T) { |
| 1684 | if _, err := exec.LookPath("git"); err != nil { |
| 1685 | t.Skip("git not installed") |
| 1686 | } |
| 1687 | orig, _ := os.Getwd() |
| 1688 | defer os.Chdir(orig) |
| 1689 | |
| 1690 | makeRepo := func(name, message string) string { |
| 1691 | t.Helper() |
| 1692 | dir := filepath.Join(t.TempDir(), name) |
| 1693 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 1694 | t.Fatal(err) |
| 1695 | } |
| 1696 | if err := os.Chdir(dir); err != nil { |
| 1697 | t.Fatal(err) |
| 1698 | } |
| 1699 | runGit(t, "init") |
| 1700 | runGit(t, "config", "user.email", "test@example.com") |
| 1701 | runGit(t, "config", "user.name", "Test User") |
| 1702 | if err := os.WriteFile("file.txt", []byte(message+"\n"), 0o644); err != nil { |
| 1703 | t.Fatal(err) |
| 1704 | } |
| 1705 | runGit(t, "add", "file.txt") |
| 1706 | runGit(t, "commit", "-m", message) |
| 1707 | return dir |
| 1708 | } |
| 1709 | |
| 1710 | repoA := makeRepo("a", "repo a commit") |
| 1711 | repoB := makeRepo("b", "repo b commit") |
| 1712 | app := &App{ |
| 1713 | tabs: map[string]*WorkspaceTab{ |
| 1714 | "a": {ID: "a", Scope: "project", WorkspaceRoot: repoA, Ready: true}, |
| 1715 | "b": {ID: "b", Scope: "project", WorkspaceRoot: repoB, Ready: true}, |
| 1716 | }, |
| 1717 | activeTabID: "a", |
| 1718 | } |
| 1719 | |
| 1720 | history, err := app.WorkspaceGitHistory("b", "") |
| 1721 | if err != nil { |
| 1722 | t.Fatalf("WorkspaceGitHistory err = %v", err) |
| 1723 | } |
| 1724 | if len(history) != 1 || history[0].Message != "repo b commit" { |
| 1725 | t.Fatalf("history for requested tab = %+v, want repo b commit", history) |
| 1726 | } |
| 1727 | } |
| 1728 | |
| 1729 | func TestWorkspaceGitCommitDetail(t *testing.T) { |
| 1730 | if _, err := exec.LookPath("git"); err != nil { |
| 1731 | t.Skip("git not installed") |
| 1732 | } |
| 1733 | orig, _ := os.Getwd() |
| 1734 | defer os.Chdir(orig) |
| 1735 | |
| 1736 | dir := t.TempDir() |
| 1737 | if err := os.Chdir(dir); err != nil { |
| 1738 | t.Fatal(err) |
| 1739 | } |
| 1740 | |
| 1741 | runGit(t, "init") |
| 1742 | runGit(t, "config", "user.email", "test@example.com") |
| 1743 | runGit(t, "config", "user.name", "Test User") |
| 1744 | |
| 1745 | if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil { |
| 1746 | t.Fatal(err) |
| 1747 | } |
| 1748 | runGit(t, "add", "file1.txt") |
| 1749 | runGit(t, "commit", "-m", "init file1") |
| 1750 | |
| 1751 | if err := os.WriteFile("file1.txt", []byte("v2\n"), 0o644); err != nil { |
| 1752 | t.Fatal(err) |
| 1753 | } |
| 1754 | runGit(t, "add", "file1.txt") |
| 1755 | runGit(t, "commit", "-m", "update file1") |
| 1756 | |
| 1757 | hash := gitOutput(t, "rev-parse", "HEAD") |
| 1758 | |
| 1759 | app := &App{} |
| 1760 | |
| 1761 | // Test project level detail |
| 1762 | detail, err := app.WorkspaceGitCommitDetail("", hash, "") |
| 1763 | if err != nil { |
| 1764 | t.Fatalf("WorkspaceGitCommitDetail err = %v", err) |
| 1765 | } |
| 1766 | if len(detail.Files) != 1 || detail.Files[0] != "file1.txt" { |
| 1767 | t.Fatalf("expected files [file1.txt], got %v", detail.Files) |
| 1768 | } |
| 1769 | if detail.Diff != nil { |
| 1770 | t.Fatal("expected nil diff for project level") |
| 1771 | } |
| 1772 | |
| 1773 | // Test file level detail |
| 1774 | detail, err = app.WorkspaceGitCommitDetail("", hash, "file1.txt") |
| 1775 | if err != nil { |
| 1776 | t.Fatalf("WorkspaceGitCommitDetail err = %v", err) |
| 1777 | } |
| 1778 | if len(detail.Files) != 0 { |
| 1779 | t.Fatalf("expected no files for file level, got %v", detail.Files) |
| 1780 | } |
| 1781 | if detail.Diff == nil || !strings.Contains(*detail.Diff, "+v2") { |
| 1782 | t.Fatalf("expected diff to contain '+v2', got %v", detail.Diff) |
| 1783 | } |
| 1784 | } |
| 1785 | |
| 1786 | func runGit(t *testing.T, args ...string) { |
| 1787 | t.Helper() |
| 1788 | cmd := exec.Command("git", args...) |
| 1789 | out, err := cmd.CombinedOutput() |
| 1790 | if err != nil { |
| 1791 | t.Fatalf("git %v: %v\n%s", args, err, out) |
| 1792 | } |
| 1793 | } |
| 1794 | |
| 1795 | func runGitIn(t *testing.T, dir string, args ...string) { |
| 1796 | t.Helper() |
| 1797 | cmd := exec.Command("git", args...) |
| 1798 | cmd.Dir = dir |
| 1799 | out, err := cmd.CombinedOutput() |
| 1800 | if err != nil { |
| 1801 | t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) |
| 1802 | } |
| 1803 | } |
| 1804 | |
| 1805 | func gitOutput(t *testing.T, args ...string) string { |
| 1806 | t.Helper() |
| 1807 | cmd := exec.Command("git", args...) |
| 1808 | out, err := cmd.CombinedOutput() |
| 1809 | if err != nil { |
| 1810 | t.Fatalf("git %v: %v\n%s", args, err, out) |
| 1811 | } |
| 1812 | return strings.TrimSpace(string(out)) |
| 1813 | } |
| 1814 | |
| 1815 | // --- settings_app.go helpers --- |
| 1816 | // These are unexported but in the same package, so we can test them. |
| 1817 | |
| 1818 | func TestOrDefault(t *testing.T) { |
| 1819 | if orDefault("", "fallback") != "fallback" { |
| 1820 | t.Error("empty should return default") |
| 1821 | } |
| 1822 | if orDefault("value", "fallback") != "value" { |
| 1823 | t.Error("non-empty should return value") |
| 1824 | } |
| 1825 | } |
| 1826 | |
| 1827 | func TestTrimList(t *testing.T) { |
| 1828 | got := trimList([]string{" a ", "", " b ", " "}) |
| 1829 | if len(got) != 2 || got[0] != "a" || got[1] != "b" { |
| 1830 | t.Errorf("trimList = %v", got) |
| 1831 | } |
| 1832 | } |
| 1833 | |
| 1834 | func TestTrimListEmpty(t *testing.T) { |
| 1835 | got := trimList(nil) |
| 1836 | if len(got) != 0 { |
| 1837 | t.Errorf("nil = %v, want empty", got) |
| 1838 | } |
| 1839 | } |
| 1840 | |
| 1841 | func TestNonNil(t *testing.T) { |
| 1842 | if got := nonNil(nil); got == nil || len(got) != 0 { |
| 1843 | t.Errorf("nonNil(nil) = %v, want empty non-nil", got) |
| 1844 | } |
| 1845 | s := []string{"a"} |
| 1846 | if got := nonNil(s); got[0] != "a" { |
| 1847 | t.Errorf("nonNil should pass through") |
| 1848 | } |
| 1849 | } |
| 1850 |