返回 DeepSeek-Reasonix
external_opener_test.go
根目录 / desktop / external_opener_test.go
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
372 lines GO