v0.33.2 57f21d6

adapter_test.go RawHistoryFolder

1package desktop
2
3import (
4	"context"
5	"errors"
6	"os"
7	"os/exec"
8	"path/filepath"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15
16	"github.com/bnema/dumber/internal/domain/entity"
17)
18
19const testCaptureEnvFile = "DUMBER_TEST_CAPTURE_ENV_FILE"
20
21const testRootCacheEnvVar = "DUMBER_TEST_ROOT_CACHE_PATH"
22
23type testSessionSpawnEnvironment struct {
24	rootDir string
25}
26
27func (testSessionSpawnEnvironment) RootCacheEnvVar() string {
28	return testRootCacheEnvVar
29}
30
31func (e testSessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {
32	return filepath.Join(e.rootDir, "sessions", string(sessionID))
33}
34
35func TestMain(m *testing.M) {
36	if envFile := os.Getenv(testCaptureEnvFile); envFile != "" && len(os.Args) >= 2 && os.Args[1] == "browse" {
37		if err := writeFileAtomically(envFile, []byte(strings.Join(os.Environ(), "\n")), 0o644); err != nil {
38			os.Exit(2)
39		}
40		os.Exit(0)
41	}
42
43	os.Exit(m.Run())
44}
45
46func writeFileAtomically(path string, data []byte, perm os.FileMode) error {
47	tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*")
48	if err != nil {
49		return err
50	}
51
52	tmpPath := tmpFile.Name()
53	defer func() {
54		_ = os.Remove(tmpPath)
55	}()
56
57	if _, err := tmpFile.Write(data); err != nil {
58		_ = tmpFile.Close()
59		return err
60	}
61	if err := tmpFile.Chmod(perm); err != nil {
62		_ = tmpFile.Close()
63		return err
64	}
65	if err := tmpFile.Close(); err != nil {
66		return err
67	}
68	if err := os.Rename(tmpPath, path); err != nil {
69		return err
70	}
71
72	return nil
73}
74
75func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) {
76	env := sanitizedChildEnv([]string{
77		"PATH=/usr/bin",
78		"LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0 /tmp/keep.so",
79	})
80
81	if len(env) != 2 {
82		t.Fatalf("expected two env entries, got %#v", env)
83	}
84	if env[0] != "PATH=/usr/bin" {
85		t.Fatalf("expected PATH to be preserved, got %#v", env)
86	}
87	if env[1] != "LD_PRELOAD=/tmp/keep.so" {
88		t.Fatalf("expected non-layer-shell preload to remain, got %#v", env)
89	}
90}
91
92func TestSanitizedChildEnv_DropsEmptyLDPreload(t *testing.T) {
93	env := sanitizedChildEnv([]string{"LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0"})
94	if len(env) != 0 {
95		t.Fatalf("expected layer-shell-only preload to be removed, got %#v", env)
96	}
97}
98
99func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) {
100	env := sanitizedChildEnv([]string{"LD_PRELOAD=a.so:/tmp/libgtk4-layer-shell.so.0:b.so"})
101	if len(env) != 1 || env[0] != "LD_PRELOAD=a.so b.so" {
102		t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env)
103	}
104}
105
106func TestWithoutEnvKeys_RemovesExplicitOverrides(t *testing.T) {
107	env := withoutEnvKeys([]string{
108		"PATH=/usr/bin",
109		RestoreSessionEnvVar + "=old-session",
110		testRootCacheEnvVar + "=/tmp/old-root",
111	}, RestoreSessionEnvVar, testRootCacheEnvVar)
112
113	if len(env) != 1 || env[0] != "PATH=/usr/bin" {
114		t.Fatalf("expected override keys to be removed, got %#v", env)
115	}
116}
117
118func TestSessionSpawner_SpawnWithSession_PassesRestoreSessionAndEngineOverride(t *testing.T) {
119	rootDir := filepath.Join(t.TempDir(), "cef-root")
120	spawner := NewSessionSpawner(t.Context(), testSessionSpawnEnvironment{rootDir: rootDir})
121	childEnvFile := filepath.Join(t.TempDir(), "child-env.txt")
122	t.Setenv(testCaptureEnvFile, childEnvFile)
123	t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "data"))
124	t.Setenv("ENV", "")
125
126	sessionID := entity.SessionID("session-123")
127	requireNoError(t, spawner.SpawnWithSession(sessionID))
128
129	env := readEnvFile(t, childEnvFile)
130	wantRoot := filepath.Join(rootDir, "sessions", string(sessionID))
131	if got := env[RestoreSessionEnvVar]; got != string(sessionID) {
132		t.Fatalf("restore session env = %q, want %q", got, string(sessionID))
133	}
134	if got := env[testRootCacheEnvVar]; got != wantRoot {
135		t.Fatalf("engine state root env = %q, want %q", got, wantRoot)
136	}
137}
138
139func readEnvFile(t *testing.T, path string) map[string]string {
140	t.Helper()
141
142	deadline := time.Now().Add(2 * time.Second)
143	for time.Now().Before(deadline) {
144		data, err := os.ReadFile(path)
145		if err == nil {
146			env := make(map[string]string)
147			for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") {
148				if line == "" {
149					continue
150				}
151				key, value, ok := strings.Cut(line, "=")
152				if ok {
153					env[key] = value
154				}
155			}
156			return env
157		}
158		time.Sleep(10 * time.Millisecond)
159	}
160
161	t.Fatalf("timed out waiting for child env file %q", path)
162	return nil
163}
164
165func TestSessionSpawner_SpawnWithSession_OmitsEngineOverrideWhenNotConfigured(t *testing.T) {
166	spawner := NewSessionSpawner(t.Context(), nil)
167	childEnvFile := filepath.Join(t.TempDir(), "child-env.txt")
168	t.Setenv(testCaptureEnvFile, childEnvFile)
169	t.Setenv("ENV", "")
170
171	sessionID := entity.SessionID("session-plain")
172	requireNoError(t, spawner.SpawnWithSession(sessionID))
173
174	env := readEnvFile(t, childEnvFile)
175	if got := env[RestoreSessionEnvVar]; got != string(sessionID) {
176		t.Fatalf("restore session env = %q, want %q", got, string(sessionID))
177	}
178	if got := env[testRootCacheEnvVar]; got != "" {
179		t.Fatalf("engine state root env = %q, want empty", got)
180	}
181}
182
183func TestLaunchBrowserBrowseURL_ResolveErrorDoesNotLeakURL(t *testing.T) {
184	wantErr := errors.New("resolve failed")
185
186	err := launchBrowserBrowseURL(
187		"https://example.com/private?token=secret",
188		func() (string, error) { return "", wantErr },
189		nil,
190	)
191
192	if err == nil {
193		t.Fatal("expected resolve error")
194	}
195	if !errors.Is(err, wantErr) {
196		t.Fatalf("expected error %v, got %v", wantErr, err)
197	}
198	if strings.Contains(err.Error(), "token=secret") || strings.Contains(err.Error(), "https://example.com") {
199		t.Fatalf("expected error to redact URL, got %q", err)
200	}
201}
202
203func TestLaunchBrowserBrowseURL_SpawnErrorDoesNotLeakURL(t *testing.T) {
204	wantErr := errors.New("spawn failed")
205
206	err := launchBrowserBrowseURL(
207		"https://example.com/private?token=secret",
208		func() (string, error) { return "/usr/bin/dumber", nil },
209		func(*exec.Cmd) error { return wantErr },
210	)
211
212	if err == nil {
213		t.Fatal("expected spawn error")
214	}
215	if !errors.Is(err, wantErr) {
216		t.Fatalf("expected error %v, got %v", wantErr, err)
217	}
218	if strings.Contains(err.Error(), "token=secret") || strings.Contains(err.Error(), "https://example.com") {
219		t.Fatalf("expected error to redact URL, got %q", err)
220	}
221}
222
223func TestLaunchBrowserBrowseURL_ClearsFreshWindowMarker(t *testing.T) {
224	t.Setenv(FreshWindowLaunchEnvVar, "1")
225
226	err := launchBrowserBrowseURL(
227		"https://example.com",
228		func() (string, error) { return "/usr/bin/dumber", nil },
229		func(cmd *exec.Cmd) error {
230			assert.NotContains(t, cmd.Env, FreshWindowLaunchEnvVar+"=1")
231			return nil
232		},
233	)
234
235	require.NoError(t, err)
236}
237
238func TestSetAsDefaultBrowserAlsoUpdatesMimeHandlers(t *testing.T) {
239	t.Setenv("HOME", t.TempDir())
240	t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share"))
241
242	desktopPath, err := getDesktopFilePath()
243	require.NoError(t, err)
244	require.NoError(t, os.MkdirAll(filepath.Dir(desktopPath), 0o755))
245	require.NoError(t, os.WriteFile(desktopPath, []byte(desktopFileTemplate), 0o644))
246
247	binDir := t.TempDir()
248	logPath := filepath.Join(binDir, "commands.log")
249
250	xdgSettingsPath := writeExecutable(t, filepath.Join(binDir, "xdg-settings"), "#!/bin/sh\necho \"$@\" >> \""+logPath+"\"\n")
251	xdgMimePath := writeExecutable(t, filepath.Join(binDir, "xdg-mime"), "#!/bin/sh\necho \"$@\" >> \""+logPath+"\"\n")
252
253	a := &Adapter{xdgSettingsPath: xdgSettingsPath, xdgMimePath: xdgMimePath}
254	desktopFile := a.findDesktopFile(context.Background())
255	require.NotEmpty(t, desktopFile)
256
257	err = a.SetAsDefaultBrowser(context.Background())
258	require.NoError(t, err)
259
260	logged, err := os.ReadFile(logPath)
261	require.NoError(t, err)
262	commands := strings.Split(strings.TrimSpace(string(logged)), "\n")
263
264	assert.Contains(t, commands, "set default-web-browser "+desktopFile)
265	assert.Contains(t, commands, "default "+desktopFile+" x-scheme-handler/http x-scheme-handler/https text/html text/xml application/xhtml+xml")
266}
267
268func TestGetStatusRequiresBrowserLinkMimeHandlersToMatch(t *testing.T) {
269	t.Setenv("HOME", t.TempDir())
270	t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share"))
271
272	binDir := t.TempDir()
273	xdgSettingsPath := writeExecutable(t, filepath.Join(binDir, "xdg-settings"), "#!/bin/sh\nif [ \"$1\" = \"get\" ]; then\n  echo dumber.desktop\nfi\n")
274	xdgMimePath := writeExecutable(t, filepath.Join(binDir, "xdg-mime"), "#!/bin/sh\nif [ \"$1\" = \"query\" ]; then\n  if [ \"$3\" = \"x-scheme-handler/http\" ]; then\n    echo zen.desktop\n  else\n    echo dumber.desktop\n  fi\nfi\n")
275
276	a := &Adapter{xdgSettingsPath: xdgSettingsPath, xdgMimePath: xdgMimePath}
277
278	status, err := a.GetStatus(context.Background())
279	require.NoError(t, err)
280	assert.False(t, status.IsDefaultBrowser)
281}
282
283func writeExecutable(t *testing.T, path, content string) string {
284	t.Helper()
285	require.NoError(t, os.WriteFile(path, []byte(content), 0o755))
286	return path
287}
288
289func requireNoError(t *testing.T, err error) {
290	t.Helper()
291	if err != nil {
292		t.Fatal(err)
293	}
294}
295
296func TestLaunchBrowserBrowseURL_ChildEnvMergesRuntimeSafety(t *testing.T) {
297	t.Setenv("GODEBUG", "gctrace=1")
298
299	err := launchBrowserBrowseURL(
300		"https://example.com",
301		func() (string, error) { return "/usr/bin/dumber", nil },
302		func(cmd *exec.Cmd) error {
303			found := false
304			for _, entry := range cmd.Env {
305				if entry == "GODEBUG=gctrace=1,gcshrinkstackoff=1" {
306					found = true
307				}
308			}
309			assert.True(t, found, "child env must merge safety after sanitization: %#v", cmd.Env)
310			return nil
311		},
312	)
313
314	require.NoError(t, err)
315}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close