| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "image/color" |
| 7 | "image/png" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "reflect" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "testing" |
| 14 | "time" |
| 15 | ) |
| 16 | |
| 17 | func testExternalOpener(id, name, kind string) externalOpenerSpec { |
| 18 | return externalOpenerSpec{View: ExternalOpenerView{ID: id, Name: name, Kind: kind}, Target: id} |
| 19 | } |
| 20 | |
| 21 | func TestResolveExternalOpenerPrefersInstalledSelection(t *testing.T) { |
| 22 | specs := []externalOpenerSpec{ |
| 23 | testExternalOpener("files", "Files", externalOpenerFileManager), |
| 24 | testExternalOpener("code", "Code", externalOpenerEditor), |
| 25 | } |
| 26 | got, ok := resolveExternalOpener(specs, " CODE ") |
| 27 | if !ok || got.View.ID != "code" { |
| 28 | t.Fatalf("resolveExternalOpener = (%+v, %v), want installed code", got, ok) |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | func TestResolveExternalOpenerFallsBackAcrossOperatingSystems(t *testing.T) { |
| 33 | specs := []externalOpenerSpec{ |
| 34 | testExternalOpener("files", "Files", externalOpenerFileManager), |
| 35 | testExternalOpener("code", "Code", externalOpenerEditor), |
| 36 | } |
| 37 | got, ok := resolveExternalOpener(specs, "finder") |
| 38 | if !ok || got.View.ID != "files" { |
| 39 | t.Fatalf("resolveExternalOpener unavailable preference = (%+v, %v), want file manager fallback", got, ok) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestExternalOpenerViewsAreStableAndDeduplicated(t *testing.T) { |
| 44 | specs := []externalOpenerSpec{ |
| 45 | testExternalOpener("code", "VS Code", externalOpenerEditor), |
| 46 | testExternalOpener("CODE", "Duplicate", externalOpenerEditor), |
| 47 | testExternalOpener("", "Invalid", externalOpenerEditor), |
| 48 | } |
| 49 | want := []ExternalOpenerView{{ID: "code", Name: "VS Code", Kind: externalOpenerEditor}} |
| 50 | if got := externalOpenerViews(specs); !reflect.DeepEqual(got, want) { |
| 51 | t.Fatalf("externalOpenerViews = %+v, want %+v", got, want) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func TestExternalOpenerCatalogCachesUntilTTLExpires(t *testing.T) { |
| 56 | now := time.Unix(100, 0) |
| 57 | discoveryCalls := 0 |
| 58 | cache := newExternalOpenerCatalogCache(15*time.Second, func() []externalOpenerSpec { |
| 59 | discoveryCalls++ |
| 60 | return []externalOpenerSpec{testExternalOpener("code", "Code", externalOpenerEditor)} |
| 61 | }) |
| 62 | cache.now = func() time.Time { return now } |
| 63 | |
| 64 | first := cache.get() |
| 65 | first[0].View.Name = "mutated" |
| 66 | if got := cache.get(); discoveryCalls != 1 || got[0].View.Name != "Code" { |
| 67 | t.Fatalf("fresh cache = (%d calls, %+v), want one isolated discovery result", discoveryCalls, got) |
| 68 | } |
| 69 | |
| 70 | now = now.Add(15 * time.Second) |
| 71 | if got := cache.get(); discoveryCalls != 2 || got[0].View.Name != "Code" { |
| 72 | t.Fatalf("expired cache = (%d calls, %+v), want a refreshed result", discoveryCalls, got) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestExternalOpenerCatalogCoalescesConcurrentRefreshes(t *testing.T) { |
| 77 | started := make(chan struct{}) |
| 78 | release := make(chan struct{}) |
| 79 | var callsMu sync.Mutex |
| 80 | discoveryCalls := 0 |
| 81 | cache := newExternalOpenerCatalogCache(time.Minute, func() []externalOpenerSpec { |
| 82 | callsMu.Lock() |
| 83 | discoveryCalls++ |
| 84 | callsMu.Unlock() |
| 85 | close(started) |
| 86 | <-release |
| 87 | return []externalOpenerSpec{testExternalOpener("code", "Code", externalOpenerEditor)} |
| 88 | }) |
| 89 | |
| 90 | const callers = 8 |
| 91 | results := make(chan []externalOpenerSpec, callers) |
| 92 | for range callers { |
| 93 | go func() { results <- cache.get() }() |
| 94 | } |
| 95 | <-started |
| 96 | close(release) |
| 97 | for range callers { |
| 98 | if got := <-results; len(got) != 1 || got[0].View.ID != "code" { |
| 99 | t.Fatalf("coalesced cache result = %+v, want code", got) |
| 100 | } |
| 101 | } |
| 102 | callsMu.Lock() |
| 103 | defer callsMu.Unlock() |
| 104 | if discoveryCalls != 1 { |
| 105 | t.Fatalf("concurrent discovery calls = %d, want 1", discoveryCalls) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func BenchmarkExternalOpenerCatalogCacheHit(b *testing.B) { |
| 110 | cache := newExternalOpenerCatalogCache(time.Minute, func() []externalOpenerSpec { |
| 111 | return []externalOpenerSpec{testExternalOpener("code", "Code", externalOpenerEditor)} |
| 112 | }) |
| 113 | cache.get() |
| 114 | b.ResetTimer() |
| 115 | for b.Loop() { |
| 116 | cache.get() |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestPlatformExternalOpenersHaveUniqueSafeIds(t *testing.T) { |
| 121 | specs := platformExternalOpenerSpecs() |
| 122 | if len(specs) == 0 { |
| 123 | t.Fatal("platformExternalOpenerSpecs returned no fallback opener") |
| 124 | } |
| 125 | views := externalOpenerViews(specs) |
| 126 | if len(views) != len(specs) { |
| 127 | t.Fatalf("platform opener ids are invalid or duplicated: specs=%+v views=%+v", specs, views) |
| 128 | } |
| 129 | if _, ok := resolveExternalOpener(specs, "definitely-not-installed"); !ok { |
| 130 | t.Fatal("platform opener list has no usable fallback") |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestSetPreferredExternalOpenerRejectsRendererCommands(t *testing.T) { |
| 135 | app := NewApp() |
| 136 | for _, id := range []string{"", "../../bin/sh", "vscode; rm -rf /"} { |
| 137 | if err := app.SetPreferredExternalOpener(id); err == nil { |
| 138 | t.Fatalf("SetPreferredExternalOpener(%q) unexpectedly succeeded", id) |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestExternalOpenerWorkspaceCapabilityUsesTheTabDirectoryNotScope(t *testing.T) { |
| 144 | projectRoot := t.TempDir() |
| 145 | globalRoot := t.TempDir() |
| 146 | missingRoot := filepath.Join(t.TempDir(), "missing") |
| 147 | fileRoot := filepath.Join(t.TempDir(), "not-a-directory") |
| 148 | if err := os.WriteFile(fileRoot, []byte("file"), 0o600); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | |
| 152 | app := NewApp() |
| 153 | app.tabs = map[string]*WorkspaceTab{ |
| 154 | "project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot}, |
| 155 | "global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot}, |
| 156 | "missing": {ID: "missing", Scope: "project", WorkspaceRoot: missingRoot}, |
| 157 | "file": {ID: "file", Scope: "global", WorkspaceRoot: fileRoot}, |
| 158 | "empty": {ID: "empty", Scope: "global"}, |
| 159 | } |
| 160 | |
| 161 | for _, tabID := range []string{"project", "global"} { |
| 162 | if _, err := app.externalOpenerWorkspacePathForTab(tabID); err != nil { |
| 163 | t.Errorf("externalOpenerWorkspacePathForTab(%q) = %v, want available", tabID, err) |
| 164 | } |
| 165 | } |
| 166 | for _, tabID := range []string{"missing", "file", "empty", "unknown"} { |
| 167 | if _, err := app.externalOpenerWorkspacePathForTab(tabID); err == nil { |
| 168 | t.Errorf("externalOpenerWorkspacePathForTab(%q) succeeded, want unavailable", tabID) |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | func TestExternalOpenersForGlobalTabReportsWorkspaceCapability(t *testing.T) { |
| 174 | app := NewApp() |
| 175 | app.tabs = map[string]*WorkspaceTab{ |
| 176 | "global": {ID: "global", Scope: "global", WorkspaceRoot: t.TempDir()}, |
| 177 | } |
| 178 | view := app.ExternalOpenersForTab("global") |
| 179 | if !view.WorkspaceOpenable { |
| 180 | t.Fatal("ExternalOpenersForTab(global) workspaceOpenable = false, want true") |
| 181 | } |
| 182 | if view.Openers == nil { |
| 183 | t.Fatal("ExternalOpenersForTab(global) openers = nil, want a bridge-safe array") |
| 184 | } |
| 185 | unavailable := app.ExternalOpenersForTab("unknown") |
| 186 | if unavailable.WorkspaceOpenable || unavailable.Openers == nil { |
| 187 | t.Fatalf("ExternalOpenersForTab(unknown) = %+v, want unavailable with an empty bridge-safe array", unavailable) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestLocalSaveDestinationIsSourceDetectsFilesystemAliases(t *testing.T) { |
| 192 | dir := t.TempDir() |
| 193 | source := filepath.Join(dir, "Readme.md") |
| 194 | if err := os.WriteFile(source, []byte("content"), 0o600); err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | info, err := os.Stat(source) |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | |
| 202 | hardLink := filepath.Join(dir, "readme-hardlink.md") |
| 203 | if err := os.Link(source, hardLink); err != nil { |
| 204 | t.Fatalf("create hard link: %v", err) |
| 205 | } |
| 206 | if same, err := localSaveDestinationIsSource(info, hardLink); err != nil || !same { |
| 207 | t.Fatalf("hard-link alias = (%v, %v), want (true, nil)", same, err) |
| 208 | } |
| 209 | |
| 210 | symlink := filepath.Join(dir, "readme-symlink.md") |
| 211 | if err := os.Symlink(source, symlink); err == nil { |
| 212 | if same, err := localSaveDestinationIsSource(info, symlink); err != nil || !same { |
| 213 | t.Fatalf("symlink alias = (%v, %v), want (true, nil)", same, err) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | missing := filepath.Join(dir, "new-copy.md") |
| 218 | if same, err := localSaveDestinationIsSource(info, missing); err != nil || same { |
| 219 | t.Fatalf("missing destination = (%v, %v), want (false, nil)", same, err) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func TestCopyLocalPathAsRejectsAliasWithoutChangingSource(t *testing.T) { |
| 224 | dir := t.TempDir() |
| 225 | source := filepath.Join(dir, "source.md") |
| 226 | alias := filepath.Join(dir, "alias.md") |
| 227 | content := []byte("source content") |
| 228 | if err := os.WriteFile(source, content, 0o600); err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | if err := os.Link(source, alias); err != nil { |
| 232 | t.Fatalf("create hard link: %v", err) |
| 233 | } |
| 234 | if err := copyLocalPathAs(source, alias); err == nil { |
| 235 | t.Fatal("copyLocalPathAs(alias) succeeded, want same-source error") |
| 236 | } |
| 237 | if got, err := os.ReadFile(source); err != nil || !reflect.DeepEqual(got, content) { |
| 238 | t.Fatalf("source after rejected alias copy = (%q, %v), want original content", got, err) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | func TestCopyLocalPathAsReplacesDestinationWithoutChangingSource(t *testing.T) { |
| 243 | dir := t.TempDir() |
| 244 | source := filepath.Join(dir, "source.md") |
| 245 | target := filepath.Join(dir, "target.md") |
| 246 | sourceContent := []byte("source content") |
| 247 | if err := os.WriteFile(source, sourceContent, 0o640); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | if err := os.WriteFile(target, []byte("old destination"), 0o600); err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if err := copyLocalPathAs(source, target); err != nil { |
| 254 | t.Fatalf("copyLocalPathAs = %v", err) |
| 255 | } |
| 256 | if got, err := os.ReadFile(target); err != nil || !reflect.DeepEqual(got, sourceContent) { |
| 257 | t.Fatalf("destination after copy = (%q, %v), want source content", got, err) |
| 258 | } |
| 259 | if got, err := os.ReadFile(source); err != nil || !reflect.DeepEqual(got, sourceContent) { |
| 260 | t.Fatalf("source after copy = (%q, %v), want unchanged source", got, err) |
| 261 | } |
| 262 | entries, err := os.ReadDir(dir) |
| 263 | if err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | for _, entry := range entries { |
| 267 | if strings.HasPrefix(entry.Name(), ".target.md.reasonix-copy-") { |
| 268 | t.Fatalf("temporary copy was not cleaned up: %s", entry.Name()) |
| 269 | } |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | func TestExternalOpenerLaunchPathUsesParentForTerminalFiles(t *testing.T) { |
| 274 | dir := t.TempDir() |
| 275 | path := filepath.Join(dir, "report.md") |
| 276 | if err := os.WriteFile(path, []byte("report"), 0o600); err != nil { |
| 277 | t.Fatal(err) |
| 278 | } |
| 279 | |
| 280 | terminal := externalOpenerSpec{View: ExternalOpenerView{Kind: externalOpenerTerminal}, LaunchMode: "application"} |
| 281 | if got := externalOpenerLaunchPath(terminal, path); got != dir { |
| 282 | t.Fatalf("terminal launch path = %q, want parent directory %q", got, dir) |
| 283 | } |
| 284 | editor := externalOpenerSpec{View: ExternalOpenerView{Kind: externalOpenerEditor}, LaunchMode: "application"} |
| 285 | if got := externalOpenerLaunchPath(editor, path); got != path { |
| 286 | t.Fatalf("editor launch path = %q, want file %q", got, path) |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | func TestExternalOpenerIconFileDataURLAcceptsBoundedImages(t *testing.T) { |
| 291 | path := filepath.Join(t.TempDir(), "icon.png") |
| 292 | if err := os.WriteFile(path, []byte("png-data"), 0o600); err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | got := externalOpenerIconFileDataURL(path) |
| 296 | if !strings.HasPrefix(got, "data:image/png;base64,") { |
| 297 | t.Fatalf("externalOpenerIconFileDataURL = %q, want PNG data URL", got) |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | func TestExternalOpenerPNGRestoresAlphaFromBlackAndWhiteComposites(t *testing.T) { |
| 302 | black := []byte{ |
| 303 | 0, 0, 0, 255, |
| 304 | 0, 0, 0, 255, |
| 305 | 0, 0, 128, 255, |
| 306 | } |
| 307 | white := []byte{ |
| 308 | 0, 0, 0, 255, |
| 309 | 255, 255, 255, 255, |
| 310 | 127, 127, 255, 255, |
| 311 | } |
| 312 | encoded := externalOpenerPNGFromBGRAComposites(black, white, 3, 1) |
| 313 | decoded, err := png.Decode(bytes.NewReader(encoded)) |
| 314 | if err != nil { |
| 315 | t.Fatal(err) |
| 316 | } |
| 317 | want := []color.NRGBA{ |
| 318 | {R: 0, G: 0, B: 0, A: 255}, |
| 319 | {0, 0, 0, 0}, |
| 320 | {R: 255, G: 0, B: 0, A: 128}, |
| 321 | } |
| 322 | for x, expected := range want { |
| 323 | got := color.NRGBAModel.Convert(decoded.At(x, 0)).(color.NRGBA) |
| 324 | if got != expected { |
| 325 | t.Fatalf("pixel %d = %#v, want %#v", x, got, expected) |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | func TestExternalOpenerViewIconIsBackwardCompatible(t *testing.T) { |
| 331 | withoutIcon, err := json.Marshal(ExternalOpenerView{ID: "vscode", Name: "VS Code", Kind: externalOpenerEditor}) |
| 332 | if err != nil { |
| 333 | t.Fatal(err) |
| 334 | } |
| 335 | if strings.Contains(string(withoutIcon), "iconDataUrl") { |
| 336 | t.Fatalf("empty optional icon should be omitted: %s", withoutIcon) |
| 337 | } |
| 338 | withIcon, err := json.Marshal(ExternalOpenerView{ID: "vscode", Name: "VS Code", Kind: externalOpenerEditor, IconDataURL: "data:image/png;base64,AA=="}) |
| 339 | if err != nil { |
| 340 | t.Fatal(err) |
| 341 | } |
| 342 | if !strings.Contains(string(withIcon), `"iconDataUrl":"data:image/png;base64,AA=="`) { |
| 343 | t.Fatalf("native icon missing from JSON contract: %s", withIcon) |
| 344 | } |
| 345 | |
| 346 | withoutCapability, err := json.Marshal(ExternalOpenersView{Openers: []ExternalOpenerView{}, Preferred: "finder"}) |
| 347 | if err != nil { |
| 348 | t.Fatal(err) |
| 349 | } |
| 350 | if strings.Contains(string(withoutCapability), "workspaceOpenable") { |
| 351 | t.Fatalf("false workspace capability should be omitted for old readers: %s", withoutCapability) |
| 352 | } |
| 353 | withCapability, err := json.Marshal(ExternalOpenersView{ |
| 354 | Openers: []ExternalOpenerView{}, |
| 355 | Preferred: "finder", |
| 356 | WorkspaceOpenable: true, |
| 357 | }) |
| 358 | if err != nil { |
| 359 | t.Fatal(err) |
| 360 | } |
| 361 | if !strings.Contains(string(withCapability), `"workspaceOpenable":true`) { |
| 362 | t.Fatalf("workspace capability missing from JSON contract: %s", withCapability) |
| 363 | } |
| 364 | var oldReader struct { |
| 365 | Openers []ExternalOpenerView `json:"openers"` |
| 366 | Preferred string `json:"preferred"` |
| 367 | } |
| 368 | if err := json.Unmarshal(withCapability, &oldReader); err != nil || oldReader.Preferred != "finder" || oldReader.Openers == nil { |
| 369 | t.Fatalf("old reader rejected additive workspace capability: reader=%+v err=%v", oldReader, err) |
| 370 | } |
| 371 | } |
| 372 |