| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/config" |
| 10 | "reasonix/internal/memory" |
| 11 | "reasonix/internal/sessioncatalog" |
| 12 | ) |
| 13 | |
| 14 | func TestNewCollidingProjectGetsOwnStateWithoutMovingExistingProject(t *testing.T) { |
| 15 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 16 | root := t.TempDir() |
| 17 | first := filepath.Join(root, "front-end", "app") |
| 18 | second := filepath.Join(root, "front", "end-app") |
| 19 | plain := filepath.Join(root, "backend") |
| 20 | for _, path := range []string{first, second, plain} { |
| 21 | if err := os.MkdirAll(path, 0o700); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | } |
| 25 | if err := addProject(first, ""); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | firstDir := config.ProjectSessionDir(first) |
| 29 | if err := os.MkdirAll(firstDir, 0o700); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | oldSession := filepath.Join(firstDir, "existing.jsonl") |
| 33 | if err := os.WriteFile(oldSession, []byte("existing session\n"), 0o600); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | if err := addProject(second, ""); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | secondDir := config.ProjectSessionDir(second) |
| 40 | if secondDir == firstDir { |
| 41 | t.Fatalf("new project still shares %q", firstDir) |
| 42 | } |
| 43 | if got := config.ProjectSessionDir(first); got != firstDir { |
| 44 | t.Fatalf("existing project moved to %q", got) |
| 45 | } |
| 46 | if got, err := os.ReadFile(oldSession); err != nil || string(got) != "existing session\n" { |
| 47 | t.Fatalf("existing session changed: %q, %v", got, err) |
| 48 | } |
| 49 | if err := removeProject(first); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if err := addProject(first, ""); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if got := config.ProjectSessionDir(first); got != firstDir { |
| 56 | t.Fatalf("re-added original project moved to %q, want %q", got, firstDir) |
| 57 | } |
| 58 | if _, err := os.Stat(filepath.Join(secondDir, "existing.jsonl")); !os.IsNotExist(err) { |
| 59 | t.Fatalf("new project sees existing session: %v", err) |
| 60 | } |
| 61 | if err := addProject(plain, ""); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | wantPlain := filepath.Join(config.MemoryUserDir(), "projects", config.WorkspaceSlug(plain), "sessions") |
| 65 | if got := config.ProjectSessionDir(plain); got != wantPlain { |
| 66 | t.Fatalf("non-colliding project moved to %q, want %q", got, wantPlain) |
| 67 | } |
| 68 | if err := removeProject(second); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | if err := addProject(second, ""); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | if got := config.ProjectSessionDir(second); got != secondDir { |
| 75 | t.Fatalf("re-added project moved to %q, want %q", got, secondDir) |
| 76 | } |
| 77 | if got := config.ProjectSessionStoreDir(first); got == config.ProjectSessionStoreDir(second) { |
| 78 | t.Fatalf("canonical session stores overlap: %q", got) |
| 79 | } |
| 80 | if got := config.DesktopTopicStatePath(first); got == config.DesktopTopicStatePath(second) { |
| 81 | t.Fatalf("topic stores overlap: %q", got) |
| 82 | } |
| 83 | if got := memory.StoreFor(config.MemoryUserDir(), first).Dir; got == memory.StoreFor(config.MemoryUserDir(), second).Dir { |
| 84 | t.Fatalf("project memory stores overlap: %q", got) |
| 85 | } |
| 86 | if err := os.MkdirAll(secondDir, 0o700); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | firstSession := writeTopicSession(t, firstDir, "first.jsonl", "first-topic", "First", first) |
| 90 | secondSession := writeTopicSession(t, secondDir, "second.jsonl", "second-topic", "Second", second) |
| 91 | app := NewApp() |
| 92 | installSessionCatalogForTest(t, app, firstDir, "project", first) |
| 93 | reconcileSessionCatalogForTest(t, app, secondDir, "project", second) |
| 94 | for _, tc := range []struct{ root, dir, want, other string }{ |
| 95 | {first, firstDir, firstSession, secondSession}, |
| 96 | {second, secondDir, secondSession, firstSession}, |
| 97 | } { |
| 98 | page, err := app.sessionCatalog.Load().ListSessions(context.Background(), sessioncatalog.SessionPageRequest{ |
| 99 | Scope: "project", WorkspaceRoot: tc.root, Directory: tc.dir, Limit: 20, |
| 100 | }) |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | found := false |
| 105 | for _, item := range page.Items { |
| 106 | if item.Path == tc.other { |
| 107 | t.Fatalf("project %q lists another project's session %q", tc.root, tc.other) |
| 108 | } |
| 109 | found = found || item.Path == tc.want |
| 110 | } |
| 111 | if !found { |
| 112 | t.Fatalf("project %q did not list its session %q: %+v", tc.root, tc.want, page.Items) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestLegacyProjectImportsKeepExistingCollisionState(t *testing.T) { |
| 118 | for _, source := range []string{"workspace-list", "sidebar-recovery"} { |
| 119 | t.Run(source, func(t *testing.T) { |
| 120 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 121 | root := t.TempDir() |
| 122 | first := filepath.Join(root, "front-end", "app") |
| 123 | second := filepath.Join(root, "front", "end-app") |
| 124 | for _, path := range []string{first, second} { |
| 125 | if err := os.MkdirAll(path, 0o700); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | } |
| 129 | if err := addProject(first, ""); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | legacyDir := config.ProjectSessionDir(second) |
| 133 | if err := os.MkdirAll(legacyDir, 0o700); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | oldSession := filepath.Join(legacyDir, "legacy.jsonl") |
| 137 | if err := os.WriteFile(oldSession, []byte("legacy session\n"), 0o600); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | switch source { |
| 141 | case "workspace-list": |
| 142 | rememberWorkspace(second) |
| 143 | migrateLegacyWorkspacesIntoProjects() |
| 144 | case "sidebar-recovery": |
| 145 | if _, err := recoverLegacyProjectSidebarRoots(desktopTabsFile{Tabs: []desktopTabEntry{{Scope: "project", WorkspaceRoot: second}}}); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | } |
| 149 | if got := config.ProjectSessionDir(second); got != legacyDir { |
| 150 | t.Fatalf("imported project moved to %q, want %q", got, legacyDir) |
| 151 | } |
| 152 | if got, err := os.ReadFile(oldSession); err != nil || string(got) != "legacy session\n" { |
| 153 | t.Fatalf("imported session changed: %q, %v", got, err) |
| 154 | } |
| 155 | }) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestProjectRegistrationKeepsOpeningOnProjectListLockFailure(t *testing.T) { |
| 160 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 161 | root := t.TempDir() |
| 162 | lockPath := filepath.Join(desktopConfigDir(), desktopProjectsFile+".lock") |
| 163 | if err := os.MkdirAll(lockPath, 0o700); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | app := NewApp() |
| 167 | if err := app.registerProjectRoot(root); err != nil { |
| 168 | t.Fatalf("project list lock failure blocked opening: %v", err) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestCollidingProjectRegistrationFailsBeforeUsingUnownedState(t *testing.T) { |
| 173 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 174 | root := t.TempDir() |
| 175 | first := filepath.Join(root, "front-end", "app") |
| 176 | second := filepath.Join(root, "front", "end-app") |
| 177 | if err := addProject(first, ""); err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | if err := config.AssignProjectStateCollision(config.MemoryUserDir(), second); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | assigned := config.ProjectStateDir(config.MemoryUserDir(), second) |
| 184 | if err := os.Remove(filepath.Join(assigned, ".workspace-root")); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if err := os.WriteFile(filepath.Join(assigned, "orphan"), []byte("keep"), 0o600); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | if err := addProject(second, ""); err == nil { |
| 191 | t.Fatal("registration accepted an unowned, nonempty assigned directory") |
| 192 | } |
| 193 | if len(loadProjectsFile().Projects) != 1 { |
| 194 | t.Fatal("failed registration changed the saved project list") |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | func TestPreviouslyRecordedCollisionKeepsBothLegacyPaths(t *testing.T) { |
| 199 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 200 | root := t.TempDir() |
| 201 | first := filepath.Join(root, "front-end", "app") |
| 202 | second := filepath.Join(root, "front", "end-app") |
| 203 | if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: first}, {Root: second}}}); err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | legacy := config.ProjectSessionDir(first) |
| 207 | for _, path := range []string{first, second} { |
| 208 | if err := addProject(path, ""); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | if got := config.ProjectSessionDir(path); got != legacy { |
| 212 | t.Fatalf("previously recorded project %q moved to %q", path, got) |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 |