v0.31.0 bf0756b

normalize_test.go RawHistoryFolder

1package url
2
3import (
4	"strings"
5	"testing"
6)
7
8func TestNormalize(t *testing.T) {
9	tests := []struct {
10		name  string
11		input string
12		want  string
13	}{
14		{
15			name:  "empty string",
16			input: "",
17			want:  "",
18		},
19		{
20			name:  "http scheme unchanged",
21			input: "http://example.com",
22			want:  "http://example.com",
23		},
24		{
25			name:  "https scheme unchanged",
26			input: "https://example.com",
27			want:  "https://example.com",
28		},
29		{
30			name:  "file scheme unchanged",
31			input: "file:///path/to/file.html",
32			want:  "file:///path/to/file.html",
33		},
34		{
35			name:  "dumb scheme unchanged",
36			input: "dumb://history",
37			want:  "dumb://history",
38		},
39		{
40			name:  "about scheme unchanged",
41			input: "about:blank",
42			want:  "about:blank",
43		},
44		{
45			name:  "mailto scheme unchanged",
46			input: "mailto:foo@example.com",
47			want:  "mailto:foo@example.com",
48		},
49		{
50			name:  "magnet scheme unchanged",
51			input: "magnet:?xt=urn:btih:123",
52			want:  "magnet:?xt=urn:btih:123",
53		},
54		{
55			name:  "obsidian scheme unchanged",
56			input: "obsidian://open?vault=foo.bar",
57			want:  "obsidian://open?vault=foo.bar",
58		},
59		{
60			name:  "domain gets https",
61			input: "example.com",
62			want:  "https://example.com",
63		},
64		{
65			name:  "domain with path gets https",
66			input: "example.com/path",
67			want:  "https://example.com/path",
68		},
69		{
70			name:  "search query unchanged",
71			input: "hello world",
72			want:  "hello world",
73		},
74		{
75			name:  "single word unchanged",
76			input: "hello",
77			want:  "hello",
78		},
79		{
80			name:  "localhost",
81			input: "localhost",
82			want:  "http://localhost",
83		},
84		{
85			name:  "localhost with port",
86			input: "localhost:5173",
87			want:  "http://localhost:5173",
88		},
89		{
90			name:  "localhost with alt port",
91			input: "localhost:8080",
92			want:  "http://localhost:8080",
93		},
94		{
95			name:  "localhost with path",
96			input: "localhost/api/v1",
97			want:  "http://localhost/api/v1",
98		},
99		{
100			name:  "localhost.com is a domain not localhost",
101			input: "localhost.com",
102			want:  "https://localhost.com",
103		},
104		{
105			name:  "localhostevil.com is a domain not localhost",
106			input: "localhostevil.com",
107			want:  "https://localhostevil.com",
108		},
109	}
110
111	for _, tt := range tests {
112		t.Run(tt.name, func(t *testing.T) {
113			got := Normalize(tt.input)
114			if got != tt.want {
115				t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
116			}
117		})
118	}
119}
120
121func TestNormalize_PathLikeInputsArePure(t *testing.T) {
122	tests := []struct {
123		name  string
124		input string
125	}{
126		{name: "absolute path", input: "/tmp/example.html"},
127		{name: "relative path with dot", input: "./example.html"},
128		{name: "relative parent path", input: "../example.html"},
129		{name: "home path", input: "~/example.html"},
130	}
131
132	for _, tt := range tests {
133		t.Run(tt.name, func(t *testing.T) {
134			if got := Normalize(tt.input); got != tt.input {
135				t.Errorf("Normalize(%q) = %q, want unchanged", tt.input, got)
136			}
137		})
138	}
139}
140
141func TestTrimLeadingSpacesIfURL(t *testing.T) {
142	tests := []struct {
143		name  string
144		input string
145		want  string
146	}{
147		{
148			name:  "single space before https URL",
149			input: " https://example.com",
150			want:  "https://example.com",
151		},
152		{
153			name:  "multiple spaces before https URL",
154			input: "   https://example.com",
155			want:  "https://example.com",
156		},
157		{
158			name:  "tab before URL",
159			input: "\thttps://example.com",
160			want:  "https://example.com",
161		},
162		{
163			name:  "mixed whitespace before URL",
164			input: " \t https://example.com",
165			want:  "https://example.com",
166		},
167		{
168			name:  "space before domain",
169			input: " example.com",
170			want:  "example.com",
171		},
172		{
173			name:  "spaces before localhost",
174			input: "  localhost:8080",
175			want:  "localhost:8080",
176		},
177		{
178			name:  "no leading space URL unchanged",
179			input: "https://example.com",
180			want:  "https://example.com",
181		},
182		{
183			name:  "search query with leading space unchanged",
184			input: " hello world",
185			want:  " hello world",
186		},
187		{
188			name:  "single word with leading space unchanged",
189			input: " hello",
190			want:  " hello",
191		},
192		{
193			name:  "empty string unchanged",
194			input: "",
195			want:  "",
196		},
197		{
198			name:  "only spaces unchanged",
199			input: "   ",
200			want:  "   ",
201		},
202	}
203
204	for _, tt := range tests {
205		t.Run(tt.name, func(t *testing.T) {
206			got := TrimLeadingSpacesIfURL(tt.input)
207			if got != tt.want {
208				t.Errorf("TrimLeadingSpacesIfURL(%q) = %q, want %q", tt.input, got, tt.want)
209			}
210		})
211	}
212}
213
214func TestLooksLikeURL(t *testing.T) {
215	tests := []struct {
216		name  string
217		input string
218		want  bool
219	}{
220		{
221			name:  "empty string",
222			input: "",
223			want:  false,
224		},
225		{
226			name:  "http URL",
227			input: "http://example.com",
228			want:  true,
229		},
230		{
231			name:  "https URL",
232			input: "https://example.com",
233			want:  true,
234		},
235		{
236			name:  "file URL",
237			input: "file:///path/to/file",
238			want:  true,
239		},
240		{
241			name:  "dumb URL",
242			input: "dumb://history",
243			want:  true,
244		},
245		{
246			name:  "obsidian URL",
247			input: "obsidian://open?vault=foo.bar",
248			want:  true,
249		},
250		{
251			name:  "about URL",
252			input: "about:blank",
253			want:  true,
254		},
255		{
256			name:  "spotify URL",
257			input: "spotify://track/123",
258			want:  true,
259		},
260		{
261			name:  "mailto URL",
262			input: "mailto:foo@example.com",
263			want:  true,
264		},
265		{
266			name:  "magnet URL",
267			input: "magnet:?xt=urn:btih:123",
268			want:  true,
269		},
270		{
271			name:  "vscode URL",
272			input: "vscode://file/tmp/demo",
273			want:  true,
274		},
275		{
276			name:  "domain",
277			input: "example.com",
278			want:  true,
279		},
280		{
281			name:  "domain with path",
282			input: "example.com/path",
283			want:  true,
284		},
285		{
286			name:  "search query",
287			input: "hello world",
288			want:  false,
289		},
290		{
291			name:  "single word",
292			input: "hello",
293			want:  false,
294		},
295		{
296			name:  "localhost",
297			input: "localhost",
298			want:  true,
299		},
300		{
301			name:  "localhost with port",
302			input: "localhost:5173",
303			want:  true,
304		},
305		{
306			name:  "localhost with alt port",
307			input: "localhost:8080",
308			want:  true,
309		},
310		{
311			name:  "localhost with path",
312			input: "localhost/api/v1",
313			want:  true,
314		},
315		{
316			name:  "localhost.com is a domain not localhost",
317			input: "localhost.com",
318			want:  true,
319		},
320		{
321			name:  "localhostevil.com is a domain not localhost",
322			input: "localhostevil.com",
323			want:  true,
324		},
325		{
326			name:  "ipv4 with port",
327			input: "100.64.0.10:3000",
328			want:  true,
329		},
330		{
331			name:  "ipv4",
332			input: "127.0.0.1",
333			want:  true,
334		},
335		{
336			name:  "ipv6 bracketed",
337			input: "[::1]",
338			want:  true,
339		},
340	}
341
342	for _, tt := range tests {
343		t.Run(tt.name, func(t *testing.T) {
344			got := LooksLikeURL(tt.input)
345			if got != tt.want {
346				t.Errorf("LooksLikeURL(%q) = %v, want %v", tt.input, got, tt.want)
347			}
348		})
349	}
350}
351
352func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) {
353	tests := []struct {
354		name  string
355		input string
356		want  string
357	}{
358		{name: "tailnet ipv4", input: "100.64.0.10", want: "http://100.64.0.10"},
359		{name: "tailnet ipv4 with port", input: "100.64.0.10:8080", want: "http://100.64.0.10:8080"},
360		{name: "ipv4 with path", input: "100.64.0.10/api", want: "http://100.64.0.10/api"},
361	}
362
363	for _, tt := range tests {
364		t.Run(tt.name, func(t *testing.T) {
365			got := Normalize(tt.input)
366			if got != tt.want {
367				t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
368			}
369		})
370	}
371}
372
373func TestSanitizeDomainForPNGPathKeysDoNotCollide(t *testing.T) {
374	pathKey := SanitizeDomainForPNG("example.com/a/b")
375	underscoreKey := SanitizeDomainForPNG("example.com/a_b")
376	if pathKey == underscoreKey {
377		t.Fatalf("path key filename collision: %q", pathKey)
378	}
379	if pathKey != "example.com%2Fa%2Fb.png" {
380		t.Fatalf("path key filename = %q, want example.com%%2Fa%%2Fb.png", pathKey)
381	}
382	if underscoreKey != "example.com%2Fa_b.png" {
383		t.Fatalf("underscore key filename = %q, want example.com%%2Fa_b.png", underscoreKey)
384	}
385}
386
387func TestSanitizeDomainForLongPathKeysUsesBoundedDeterministicFilenames(t *testing.T) {
388	longKeyA := "example.com/" + strings.Repeat("very-long-segment/", 24) + "alpha"
389	longKeyB := "example.com/" + strings.Repeat("very-long-segment/", 24) + "beta"
390
391	pngA := SanitizeDomainForPNG(longKeyA)
392	pngB := SanitizeDomainForPNG(longKeyB)
393	if len(pngA) > 240 {
394		t.Fatalf("png filename too long: len=%d name=%q", len(pngA), pngA)
395	}
396	if pngA == pngB {
397		t.Fatalf("distinct long keys collided: %q", pngA)
398	}
399	if !strings.HasSuffix(pngA, ".png") {
400		t.Fatalf("png filename missing suffix: %q", pngA)
401	}
402
403	ico := SanitizeDomainForFilename(longKeyA)
404	if len(ico) > 240 {
405		t.Fatalf("ico filename too long: len=%d name=%q", len(ico), ico)
406	}
407	if !strings.HasSuffix(ico, ".ico") {
408		t.Fatalf("ico filename missing suffix: %q", ico)
409	}
410
411	sized := SanitizeDomainForPNGSized(longKeyA, 32)
412	if len(sized) > 240 {
413		t.Fatalf("sized png filename too long: len=%d name=%q", len(sized), sized)
414	}
415	if !strings.HasSuffix(sized, ".32.png") {
416		t.Fatalf("sized png filename missing suffix: %q", sized)
417	}
418}
419
420func TestIsExternalScheme(t *testing.T) {
421	tests := []struct {
422		name  string
423		input string
424		want  bool
425	}{
426		{
427			name:  "empty string",
428			input: "",
429			want:  false,
430		},
431		{
432			name:  "http scheme",
433			input: "http://example.com",
434			want:  false,
435		},
436		{
437			name:  "https scheme",
438			input: "https://example.com",
439			want:  false,
440		},
441		{
442			name:  "file scheme",
443			input: "file:///path/to/file.html",
444			want:  false,
445		},
446		{
447			name:  "dumb scheme",
448			input: "dumb://history",
449			want:  false,
450		},
451		{
452			name:  "about scheme",
453			input: "about:blank",
454			want:  false,
455		},
456		{
457			name:  "data scheme",
458			input: "data:text/plain;base64,SGVsbG8gV29ybGQ==",
459			want:  false,
460		},
461		{
462			name:  "blob scheme",
463			input: "blob:https://example.com/blob-id",
464			want:  false,
465		},
466		{
467			name:  "javascript scheme",
468			input: "javascript:alert('hello')",
469			want:  false,
470		},
471		{
472			name:  "vscode scheme",
473			input: "vscode://open",
474			want:  true,
475		},
476		{
477			name:  "spotify scheme",
478			input: "spotify:track:123",
479			want:  true,
480		},
481		{
482			name:  "steam scheme",
483			input: "steam://run/123",
484			want:  true,
485		},
486		{
487			name:  "uppercase HTTP scheme",
488			input: "HTTP://example.com",
489			want:  false,
490		},
491		{
492			name:  "uppercase ABOUT scheme",
493			input: "ABOUT:blank",
494			want:  false,
495		},
496		{
497			name:  "uppercase VSCODE scheme",
498			input: "VSCODE://open",
499			want:  true,
500		},
501		{
502			name:  "geo scheme",
503			input: "geo:37.786971,-122.399677",
504			want:  true,
505		},
506		{
507			name:  "xmpp scheme",
508			input: "xmpp:user@example.com",
509			want:  true,
510		},
511		{
512			name:  "malformed URL",
513			input: "://invalid",
514			want:  false,
515		},
516		{
517			name:  "no scheme",
518			input: "example.com",
519			want:  false,
520		},
521		{
522			name:  "localhost port is not external scheme",
523			input: "localhost:5173",
524			want:  false,
525		},
526		{
527			name:  "scheme only",
528			input: "custom://",
529			want:  true,
530		},
531	}
532
533	for _, tt := range tests {
534		t.Run(tt.name, func(t *testing.T) {
535			got := IsExternalScheme(tt.input)
536			if got != tt.want {
537				t.Errorf("IsExternalScheme(%q) = %v, want %v", tt.input, got, tt.want)
538			}
539		})
540	}
541}
542
543func TestExtractOrigin_ValidURI(t *testing.T) {
544	tests := []struct {
545		name     string
546		uri      string
547		expected string
548	}{
549		{
550			name:     "https with port",
551			uri:      "https://example.com:8443/path?query=1",
552			expected: "https://example.com:8443",
553		},
554		{
555			name:     "https without port",
556			uri:      "https://example.com/path",
557			expected: "https://example.com",
558		},
559		{
560			name:     "http",
561			uri:      "http://localhost:8080/app",
562			expected: "http://localhost:8080",
563		},
564		{
565			name:     "uppercase host",
566			uri:      "https://EXAMPLE.COM/path",
567			expected: "https://example.com",
568		},
569		{
570			name:     "uppercase scheme",
571			uri:      "HTTPS://example.com/path",
572			expected: "https://example.com",
573		},
574		{
575			name:     "https default port omitted",
576			uri:      "https://example.com:443/path",
577			expected: "https://example.com",
578		},
579		{
580			name:     "http default port omitted",
581			uri:      "http://example.com:80/path",
582			expected: "http://example.com",
583		},
584		{
585			name:     "https explicit non-default port kept",
586			uri:      "https://example.com:8080/path",
587			expected: "https://example.com:8080",
588		},
589		{
590			name:     "mixed case with default port",
591			uri:      "HTTPS://EXAMPLE.COM:443/path",
592			expected: "https://example.com",
593		},
594		{
595			name:     "ipv6 with explicit port",
596			uri:      "https://[::1]:8443/path",
597			expected: "https://[::1]:8443",
598		},
599	}
600
601	for _, tt := range tests {
602		t.Run(tt.name, func(t *testing.T) {
603			origin, err := ExtractOrigin(tt.uri)
604			if err != nil {
605				t.Errorf("ExtractOrigin(%q) returned error: %v", tt.uri, err)
606				return
607			}
608			if origin != tt.expected {
609				t.Errorf("ExtractOrigin(%q) = %q, want %q", tt.uri, origin, tt.expected)
610			}
611		})
612	}
613}
614
615func TestExtractOrigin_InvalidURI(t *testing.T) {
616	tests := []struct {
617		name string
618		uri  string
619	}{
620		{name: "empty", uri: ""},
621		{name: "no scheme", uri: "example.com"},
622		{name: "no host", uri: "https://"},
623	}
624
625	for _, tt := range tests {
626		t.Run(tt.name, func(t *testing.T) {
627			origin, err := ExtractOrigin(tt.uri)
628			if err == nil {
629				t.Errorf("ExtractOrigin(%q) should return error, got origin: %q", tt.uri, origin)
630			}
631		})
632	}
633}
634
635func TestCanonicalDomain(t *testing.T) {
636	tests := []struct {
637		name  string
638		input string
639		want  string
640	}{
641		{name: "empty", input: "", want: ""},
642		{name: "whitespace only", input: "   ", want: ""},
643		{name: "url", input: "https://www.example.com/path", want: "example.com"},
644		{name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"},
645		{name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
646		{name: "url with userinfo and port", input: "https://user:pass@example.com:8443/path", want: "example.com:8443"},
647		{name: "domain", input: "www.example.com", want: "example.com"},
648		{name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"},
649	}
650
651	for _, tt := range tests {
652		t.Run(tt.name, func(t *testing.T) {
653			if got := CanonicalDomain(tt.input); got != tt.want {
654				t.Errorf("CanonicalDomain(%q) = %q, want %q", tt.input, got, tt.want)
655			}
656		})
657	}
658}
659
660func TestDisplayDomain(t *testing.T) {
661	tests := []struct {
662		name  string
663		input string
664		want  string
665	}{
666		{name: "empty", input: "", want: ""},
667		{name: "whitespace only", input: "   ", want: ""},
668		{name: "url with port", input: "https://www.example.com:8443/path", want: "example.com"},
669		{name: "url without port", input: "https://www.example.com/path", want: "example.com"},
670		{name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
671		{name: "no www prefix", input: "https://docs.example.com/path", want: "docs.example.com"},
672		{name: "host only", input: "www.example.org:9443/path?q=1", want: "example.org"},
673		{name: "invalid url", input: "%zz", want: "%zz"},
674		{name: "non-http scheme", input: "ftp://www.example.net:21/file", want: "example.net"},
675		{name: "ipv6 url", input: "https://[2001:db8::1]:8443/path", want: "2001:db8::1"},
676		{name: "uppercase host", input: "https://WWW.EXAMPLE.COM/path", want: "example.com"},
677	}
678
679	for _, tt := range tests {
680		t.Run(tt.name, func(t *testing.T) {
681			if got := DisplayDomain(tt.input); got != tt.want {
682				t.Errorf("DisplayDomain(%q) = %q, want %q", tt.input, got, tt.want)
683			}
684		})
685	}
686}

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