v0.36.0 1fb2421

web_commit_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"testing"
11
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14
15	"bnema.dev/ermite/internal/domain"
16)
17
18// AC1: the commit pages render for the fixtures with the structure of the
19// mockup (golden HTML, normalised).
20func TestWebCommitGolden(t *testing.T) {
21	h := newHarness(t, harnessOpts{})
22	f := h.diffRepo("diffs")
23
24	for _, tt := range []struct{ name, path string }{
25		{"commit-modify", "/diffs/commit/" + f.modify},
26		{"commit-modify-split", "/diffs/commit/" + f.modify + "?view=split"},
27		{"commit-root", "/diffs/commit/" + f.root},
28		{"commit-rename", "/diffs/commit/" + f.rename},
29		{"commit-binary", "/diffs/commit/" + f.binary},
30		{"commit-merge", "/diffs/commit/" + f.merge},
31		{"commit-trojan", "/diffs/commit/" + f.trojan},
32		{"commit-big", "/diffs/commit/" + f.big},
33	} {
34		t.Run(tt.name, func(t *testing.T) {
35			status, hdr, body := h.get(tt.path)
36			require.Equal(t, 200, status)
37			assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
38			assertNoInline(t, body)
39			golden(t, tt.name, normalise(body))
40		})
41	}
42}
43
44// A commit page reads the hunks of its first 300 files; the others are listed
45// collapsed and load on their own. A binary file or one without changed lines
46// past the window has nothing to load and gets no link.
47func TestWebCommitFileWindow(t *testing.T) {
48	r := newMRRepo(t, domain.VisibilityPublic)
49	b := browser{r.harness}
50	for i := range 305 {
51		require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755))
52		require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644))
53	}
54	require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644))
55	require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644))
56	r.mustGit(r.wt, "add", ".")
57	r.mustGit(r.wt, "commit", "-q", "-m", "many files")
58	r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
59
60	page := b.get("/proj/commit/"+r.headOf("feat/search"), nil)
61	require.Equal(t, 200, page.status)
62	assert.Contains(t, page.body, "line 299</td>", "index 299 is read")
63	assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed")
64	assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light"))
65	assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load")
66	assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`)
67	assert.NotContains(t, page.body, `?file=299#`)
68	assert.NotContains(t, page.body, `?file=305#`)
69	assert.NotContains(t, page.body, `?file=306#`)
70}
71
72// The behaviours of the commit pages, asserted on their own.
73func TestWebCommit(t *testing.T) {
74	h := newHarness(t, harnessOpts{})
75	f := h.diffRepo("diffs")
76	get := func(path string, status int) string {
77		t.Helper()
78		st, _, body := h.get(path)
79		require.Equal(t, status, st, path)
80		return body
81	}
82
83	t.Run("commit", func(t *testing.T) {
84		body := get("/diffs/commit/"+f.modify, 200)
85		assert.Contains(t, body, "<h1>modify a file and make a script executable</h1>")
86		assert.Contains(t, body, "The body has a second paragraph.")
87		assert.Contains(t, body, "And &lt;b&gt;html&lt;/b&gt; &amp;", "the message is text")
88		assert.NotContains(t, body, "<b>html</b>")
89		assert.Contains(t, body, "mode 100644 → 100755")
90		assert.Contains(t, body, `<td class="m">+</td>`, "added lines keep their marker")
91		assert.Contains(t, body, `<td class="m">-</td>`, "deleted lines keep their marker")
92		assert.Contains(t, body, `data-diff="unified"`)
93		assert.Contains(t, body, `hx-live="this.dataset.diff = sessionStorage`, "the browser remembers the layout when the URL does not choose")
94		assert.Contains(t, body, `<table class="dt uni"`)
95		assert.Contains(t, body, `<table class="dt spl"`, "both layouts are sent, the toggle needs no request")
96		assert.Contains(t, body, `href="/diffs/commit/`+f.modify+`?view=split"`, "the toggle works without scripts")
97		// The abbreviated id opens the same commit.
98		assert.Equal(t, normalise(body), normalise(get("/diffs/commit/"+f.modify[:12], 200)))
99	})
100
101	t.Run("split without scripts", func(t *testing.T) {
102		body := get("/diffs/commit/"+f.modify+"?view=split", 200)
103		assert.Contains(t, body, `data-diff="split"`)
104		assert.NotContains(t, body, `hx-live="this.dataset.diff`, "the URL decides, not the browser memory")
105		assert.Contains(t, body, `class="ln del"`)
106		assert.Contains(t, body, `class="ln add"`)
107	})
108
109	t.Run("root commit", func(t *testing.T) {
110		body := get("/diffs/commit/"+f.root, 200)
111		assert.Contains(t, body, "none (root commit)")
112		assert.NotContains(t, body, `class="m del"`)
113	})
114
115	t.Run("rename delete binary", func(t *testing.T) {
116		body := get("/diffs/commit/"+f.rename, 200)
117		assert.Contains(t, body, "old.txt → docs-old.txt")
118		assert.Contains(t, body, "renamed, 100% similar")
119		assert.Contains(t, body, "gone.txt")
120		bin := get("/diffs/commit/"+f.binary, 200)
121		assert.Contains(t, bin, "binary file: no text diff")
122		assert.NotContains(t, bin, `<table class="dt`)
123	})
124
125	t.Run("merge", func(t *testing.T) {
126		body := get("/diffs/commit/"+f.merge, 200)
127		assert.Contains(t, body, "merge of")
128		assert.Contains(t, body, "diff against the first parent")
129		assert.Equal(t, 2, strings.Count(body, `href="/diffs/commit/`)-strings.Count(body, `href="/diffs/commit/`+f.merge), "two parent links")
130	})
131
132	t.Run("trojan source", func(t *testing.T) {
133		body := get("/diffs/commit/"+f.trojan, 200)
134		for _, bad := range []string{"\u202e", "\u2066", "\u2069", "\u200b", "\u202c"} {
135			assert.NotContains(t, body, bad, "no invisible character reaches the page: %U", []rune(bad))
136		}
137		for _, mark := range []string{"&lt;U+202E&gt;", "&lt;U+2066&gt;", "&lt;U+2069&gt;", "&lt;U+200B&gt;", "&lt;U+202C&gt;"} {
138			assert.Contains(t, body, mark)
139		}
140		assert.Contains(t, body, "holds invisible characters")
141	})
142
143	t.Run("large file is collapsed and loads on its own", func(t *testing.T) {
144		body := get("/diffs/commit/"+f.big, 200)
145		assert.Contains(t, body, "large diff (2600 lines changed): collapsed")
146		assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note")
147		assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed")
148		assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&amp;from=0" data-manual>`)
149		assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`)
150		assert.NotContains(t, body, "line 2000", "no rows for a collapsed file")
151
152		// One file, in full, over the 2,000 lines of the page.
153		frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200)
154		assert.NotContains(t, frag, "<html")
155		assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`))
156		assert.Contains(t, frag, "line 2599")
157		assert.NotContains(t, frag, "data-manual")
158		assert.Contains(t, frag, `id="f1"`)
159		both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200)
160		assert.NotContains(t, both, `class="dt uni"`)
161		assert.Contains(t, both, `class="dt spl"`)
162
163		// Without scripts: the page splices the file in.
164		page := get("/diffs/commit/"+f.big+"?file=0", 200)
165		assert.Contains(t, page, "line 2599")
166		assert.NotContains(t, page, "data-manual")
167		assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored")
168	})
169
170	t.Run("one file route", func(t *testing.T) {
171		for _, p := range []string{
172			"/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1",
173		} {
174			st, _, _ := h.get("/diffs/commit/" + f.big + p)
175			assert.Equal(t, 404, st, p)
176		}
177		st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0")
178		assert.Equal(t, 404, st)
179	})
180
181	t.Run("not found", func(t *testing.T) {
182		_, _, missing := h.get("/diffs/commit/0123456789abcdef0123456789abcdef01234567")
183		for _, p := range []string{
184			"/diffs/commit/0123456789abcdef0123456789abcdef01234567", // no such commit
185			"/diffs/commit/main", "/diffs/commit/abc", "/diffs/commit/ABCDEF0", "/diffs/commit/v1.0.0", // not a commit id
186			"/nope/commit/" + f.root, "/diffs/commit/" + f.root + "/x",
187		} {
188			st, _, body := h.get(p)
189			assert.Equal(t, 404, st, p)
190			assert.NotContains(t, body, "<h1>root", p)
191		}
192		st, _, body := h.get("/diffs/commit/0123456789abcdef0123456789abcdef01234567")
193		assert.Equal(t, 404, st)
194		assert.Equal(t, normalise(missing), normalise(body))
195	})
196}
197
198// Over 4,000 lines only one layout is sent, the one the URL asks for.
199func TestWebCommitLargeDiffView(t *testing.T) {
200	h := newHarness(t, harnessOpts{})
201	h.seed("large", domain.VisibilityPublic)
202	wt := h.workTree("main", "README")
203	var lines strings.Builder
204	for i := range 1500 {
205		fmt.Fprintf(&lines, "line %d\n", i)
206	}
207	for _, name := range []string{"a.txt", "b.txt", "c.txt"} {
208		require.NoError(t, os.WriteFile(filepath.Join(wt, name), []byte(lines.String()), 0o644))
209	}
210	h.mustGit(wt, "add", ".")
211	h.mustGit(wt, "commit", "-q", "-m", "three big files")
212	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/large.git"), "main")
213	sha := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
214
215	_, _, unified := h.get("/large/commit/" + sha)
216	assert.True(t, strings.Contains(unified, `<table class="dt uni"`), "unified by default")
217	assert.False(t, strings.Contains(unified, `<table class="dt spl"`), "one layout only")
218
219	_, _, split := h.get("/large/commit/" + sha + "?view=split")
220	assert.True(t, strings.Contains(split, `<table class="dt spl"`), "the split layout is sent")
221	assert.False(t, strings.Contains(split, `<table class="dt uni"`), "and only it")
222	assert.True(t, strings.Contains(split, `<div id="diffs" data-view="split">`))
223}
224
225// AC2: the commit pages of a private repository are the 404 page of a
226// repository that does not exist.
227func TestWebCommitPrivate(t *testing.T) {
228	h := newHarness(t, harnessOpts{})
229	wt := h.workTree("main", "secret.txt")
230	h.createRepo("hidden-one", domain.VisibilityPrivate)
231	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main")
232	sha := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
233
234	_, _, missing := h.get("/never-existed")
235	for _, p := range []string{
236		"/hidden-one/commit/" + sha, "/hidden-one/commit/" + sha + "?view=split",
237	} {
238		st, hdr, body := h.get(p)
239		assert.Equal(t, 404, st, p)
240		assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
241		assert.NotContains(t, body, "hidden-one", p)
242		assert.NotContains(t, body, "secret.txt", p)
243		assert.Equal(t, normalise(missing), normalise(body), p)
244	}
245}
246
247// The syntax colours reach the hidden lines: /context lexes the whole file, so
248// a block comment that opens in the hidden lines and closes below them is
249// coloured as one, with the classes of the code view, and the text stays
250// escaped.
251func TestWebDiffContextHighlight(t *testing.T) {
252	h := newHarness(t, harnessOpts{})
253	wt := t.TempDir()
254	h.mustGit(wt, "init", "-q", "--initial-branch=main")
255	var src strings.Builder
256	src.WriteString("package a\n\n/* open <b>\n")
257	for i := 1; i <= 30; i++ {
258		fmt.Fprintf(&src, "still a comment %d\n", i)
259	}
260	src.WriteString("*/\nvar x = 1\n")
261	for i := 1; i <= 20; i++ {
262		fmt.Fprintf(&src, "var y%d = %d\n", i, i)
263	}
264	h.put(wt, "f.go", []byte(src.String()), 0o644)
265	h.commitAt(wt, "2026-02-01T10:00:00Z", "a file")
266	h.put(wt, "f.go", []byte(strings.Replace(src.String(), "var y15 = 15", "var y15 = 150", 1)), 0o644)
267	sha := h.commitAt(wt, "2026-02-02T10:00:00Z", "change y15")
268	h.createRepo("hl", domain.VisibilityPublic)
269	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hl.git"), "main")
270
271	_, _, page := h.get("/hl/commit/" + sha)
272	assert.Contains(t, page, `<span class="kd">var</span>`, "the hunk is coloured")
273	assertNoInline(t, page)
274
275	st, _, body := h.get("/hl/context/" + sha + "?key=g0&n=20&new=1&old=1&path=f.go")
276	require.Equal(t, 200, st)
277	assert.Contains(t, body, `<span class="kn">package</span> a`)
278	assert.Contains(t, body, `<span class="cm">/* open &lt;b&gt;</span>`)
279	assert.Contains(t, body, `<span class="cm">still a comment 5</span>`, "the comment opened above is still open")
280	assert.NotContains(t, body, "<b>")
281	assertNoInline(t, body)
282}
283
284// The hidden lines of a diff: the page has a gap before a hunk and after the
285// last one, and /context serves their rows for both layouts, numbered on both
286// sides; a private repository's are the 404 page.
287func TestWebDiffContext(t *testing.T) {
288	h := newHarness(t, harnessOpts{})
289	wt := t.TempDir()
290	h.mustGit(wt, "init", "-q", "--initial-branch=main")
291	var src strings.Builder
292	for i := 1; i <= 40; i++ {
293		fmt.Fprintf(&src, "line %d <b>\n", i)
294	}
295	h.put(wt, "f.txt", []byte(src.String()), 0o644)
296	h.commitAt(wt, "2026-02-01T10:00:00Z", "forty lines")
297	h.put(wt, "f.txt", []byte(strings.Replace(src.String(), "line 20 <b>", "line twenty", 1)), 0o644)
298	sha := h.commitAt(wt, "2026-02-02T10:00:00Z", "change line 20")
299	h.createRepo("ctx", domain.VisibilityPublic)
300	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx.git"), "main")
301
302	_, _, page := h.get("/ctx/commit/" + sha)
303	gap := "/ctx/context/" + sha + "?key=g0&amp;n=16&amp;new=1&amp;old=1&amp;path=f.txt"
304	assert.Contains(t, page, gap, "lines 1 to 16 before the hunk (17 to 23)")
305	assert.Contains(t, page, "16 hidden lines")
306	assert.Contains(t, page, "/ctx/context/"+sha+"?key=tail&amp;n=0&amp;new=24&amp;old=24&amp;path=f.txt")
307
308	st, _, body := h.get("/ctx/context/" + sha + "?key=g0&n=16&new=1&old=1&path=f.txt")
309	require.Equal(t, 200, st)
310	assert.Equal(t, 3, strings.Count(body, "line 16 &lt;b&gt;"), "unified once, split on both sides, escaped")
311	assert.NotContains(t, body, "line 17")
312	assert.NotContains(t, body, `class="gap"`, "nothing left")
313
314	st, _, body = h.get("/ctx/context/" + sha + "?key=tail&n=0&new=24&old=24&path=f.txt")
315	require.Equal(t, 200, st)
316	assert.Contains(t, body, "line 40 &lt;b&gt;")
317	assert.NotContains(t, body, "line 23 ")
318
319	for _, bad := range []string{
320		"/ctx/context/" + sha + "?n=1&new=1&old=1",                // no path
321		"/ctx/context/" + sha + "?n=1&new=0&old=1&path=f.txt",     // line 0
322		"/ctx/context/" + sha + "?n=-1&new=1&old=1&path=f.txt",    // negative
323		"/ctx/context/" + sha + "?n=1&new=99&old=99&path=f.txt",   // past the end
324		"/ctx/context/" + sha + "?n=1&new=1&old=1&path=nope.txt",  // no such file
325		"/ctx/context/" + sha[:7] + "?n=1&new=1&old=1&path=f.txt", // not a full id
326		"/ctx/context/main?n=1&new=1&old=1&path=f.txt",            // a ref
327	} {
328		st, _, _ := h.get(bad)
329		assert.Equal(t, 404, st, bad)
330	}
331
332	// Lines as Git numbers them: a lone CR is not a line break; the last line
333	// of a file without a final newline keeps its marker.
334	var cr strings.Builder
335	cr.WriteString("a\rb\n")
336	for i := 2; i <= 30; i++ {
337		fmt.Fprintf(&cr, "row %d\n", i)
338	}
339	h.put(wt, "cr.txt", []byte(cr.String()+"last"), 0o644)
340	h.commitAt(wt, "2026-02-03T10:00:00Z", "cr")
341	h.put(wt, "cr.txt", []byte(strings.Replace(cr.String(), "row 15\n", "row fifteen\n", 1)+"last"), 0o644)
342	crSHA := h.commitAt(wt, "2026-02-04T10:00:00Z", "change row 15")
343	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx.git"), "main")
344	_, _, crPage := h.get("/ctx/commit/" + crSHA)
345	assert.Contains(t, crPage, "11 hidden lines", "lines 1 to 11 before the hunk at 12")
346	st, _, body = h.get("/ctx/context/" + crSHA + "?n=11&new=1&old=1&path=cr.txt")
347	require.Equal(t, 200, st)
348	assert.Contains(t, body, "row 11")
349	assert.NotContains(t, body, "row 12", "a lone CR does not shift the numbers")
350	st, _, body = h.get("/ctx/context/" + crSHA + "?n=0&new=19&old=19&path=cr.txt")
351	require.Equal(t, 200, st)
352	assert.Contains(t, body, "last<span class=\"eol\"", "no newline at the end of the file")
353
354	// A private repository: the 404 page, nothing of the file.
355	h.createRepo("ctx-hidden", domain.VisibilityPrivate)
356	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx-hidden.git"), "main")
357	st, _, body = h.get("/ctx-hidden/context/" + sha + "?n=0&new=1&old=1&path=f.txt")
358	assert.Equal(t, 404, st)
359	assert.NotContains(t, body, "line 1 ")
360}

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 4

Tags 45

↑↓ moveenter openesc close