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// The behaviours of the commit pages, asserted on their own.
45func TestWebCommit(t *testing.T) {
46	h := newHarness(t, harnessOpts{})
47	f := h.diffRepo("diffs")
48	get := func(path string, status int) string {
49		t.Helper()
50		st, _, body := h.get(path)
51		require.Equal(t, status, st, path)
52		return body
53	}
54
55	t.Run("commit", func(t *testing.T) {
56		body := get("/diffs/commit/"+f.modify, 200)
57		assert.Contains(t, body, "<h1>modify a file and make a script executable</h1>")
58		assert.Contains(t, body, "The body has a second paragraph.")
59		assert.Contains(t, body, "And &lt;b&gt;html&lt;/b&gt; &amp;", "the message is text")
60		assert.NotContains(t, body, "<b>html</b>")
61		assert.Contains(t, body, "mode 100644 → 100755")
62		assert.Contains(t, body, `<td class="m">+</td>`, "added lines keep their marker")
63		assert.Contains(t, body, `<td class="m">-</td>`, "deleted lines keep their marker")
64		assert.Contains(t, body, `data-diff="unified"`)
65		assert.Contains(t, body, `hx-live="this.dataset.diff = sessionStorage`, "the browser remembers the layout when the URL does not choose")
66		assert.Contains(t, body, `<table class="dt uni"`)
67		assert.Contains(t, body, `<table class="dt spl"`, "both layouts are sent, the toggle needs no request")
68		assert.Contains(t, body, `href="/diffs/commit/`+f.modify+`?view=split"`, "the toggle works without scripts")
69		// The abbreviated id opens the same commit.
70		assert.Equal(t, normalise(body), normalise(get("/diffs/commit/"+f.modify[:12], 200)))
71	})
72
73	t.Run("split without scripts", func(t *testing.T) {
74		body := get("/diffs/commit/"+f.modify+"?view=split", 200)
75		assert.Contains(t, body, `data-diff="split"`)
76		assert.NotContains(t, body, `hx-live="this.dataset.diff`, "the URL decides, not the browser memory")
77		assert.Contains(t, body, `class="ln del"`)
78		assert.Contains(t, body, `class="ln add"`)
79	})
80
81	t.Run("root commit", func(t *testing.T) {
82		body := get("/diffs/commit/"+f.root, 200)
83		assert.Contains(t, body, "none (root commit)")
84		assert.NotContains(t, body, `class="m del"`)
85	})
86
87	t.Run("rename delete binary", func(t *testing.T) {
88		body := get("/diffs/commit/"+f.rename, 200)
89		assert.Contains(t, body, "old.txt → docs-old.txt")
90		assert.Contains(t, body, "renamed, 100% similar")
91		assert.Contains(t, body, "gone.txt")
92		bin := get("/diffs/commit/"+f.binary, 200)
93		assert.Contains(t, bin, "binary file: no text diff")
94		assert.NotContains(t, bin, `<table class="dt`)
95	})
96
97	t.Run("merge", func(t *testing.T) {
98		body := get("/diffs/commit/"+f.merge, 200)
99		assert.Contains(t, body, "merge of")
100		assert.Contains(t, body, "diff against the first parent")
101		assert.Equal(t, 2, strings.Count(body, `href="/diffs/commit/`)-strings.Count(body, `href="/diffs/commit/`+f.merge), "two parent links")
102	})
103
104	t.Run("trojan source", func(t *testing.T) {
105		body := get("/diffs/commit/"+f.trojan, 200)
106		for _, bad := range []string{"\u202e", "\u2066", "\u2069", "\u200b", "\u202c"} {
107			assert.NotContains(t, body, bad, "no invisible character reaches the page: %U", []rune(bad))
108		}
109		for _, mark := range []string{"&lt;U+202E&gt;", "&lt;U+2066&gt;", "&lt;U+2069&gt;", "&lt;U+200B&gt;", "&lt;U+202C&gt;"} {
110			assert.Contains(t, body, mark)
111		}
112		assert.Contains(t, body, "holds invisible characters")
113	})
114
115	t.Run("truncated", func(t *testing.T) {
116		body := get("/diffs/commit/"+f.big, 200)
117		assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")
118		assert.Contains(t, body, "this diff is incomplete")
119		assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")
120		assert.NotContains(t, body, "line 2000", "no lines of a truncated file")
121	})
122
123	t.Run("not found", func(t *testing.T) {
124		_, _, missing := h.get("/diffs/commit/0123456789abcdef0123456789abcdef01234567")
125		for _, p := range []string{
126			"/diffs/commit/0123456789abcdef0123456789abcdef01234567", // no such commit
127			"/diffs/commit/main", "/diffs/commit/abc", "/diffs/commit/ABCDEF0", "/diffs/commit/v1.0.0", // not a commit id
128			"/nope/commit/" + f.root, "/diffs/commit/" + f.root + "/x",
129		} {
130			st, _, body := h.get(p)
131			assert.Equal(t, 404, st, p)
132			assert.NotContains(t, body, "<h1>root", p)
133		}
134		st, _, body := h.get("/diffs/commit/0123456789abcdef0123456789abcdef01234567")
135		assert.Equal(t, 404, st)
136		assert.Equal(t, normalise(missing), normalise(body))
137	})
138}
139
140// Over 4,000 lines only one layout is sent, the one the URL asks for.
141func TestWebCommitLargeDiffView(t *testing.T) {
142	h := newHarness(t, harnessOpts{})
143	h.seed("large", domain.VisibilityPublic)
144	wt := h.workTree("main", "README")
145	var lines strings.Builder
146	for i := range 1500 {
147		fmt.Fprintf(&lines, "line %d\n", i)
148	}
149	for _, name := range []string{"a.txt", "b.txt", "c.txt"} {
150		require.NoError(t, os.WriteFile(filepath.Join(wt, name), []byte(lines.String()), 0o644))
151	}
152	h.mustGit(wt, "add", ".")
153	h.mustGit(wt, "commit", "-q", "-m", "three big files")
154	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/large.git"), "main")
155	sha := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
156
157	_, _, unified := h.get("/large/commit/" + sha)
158	assert.True(t, strings.Contains(unified, `<table class="dt uni"`), "unified by default")
159	assert.False(t, strings.Contains(unified, `<table class="dt spl"`), "one layout only")
160
161	_, _, split := h.get("/large/commit/" + sha + "?view=split")
162	assert.True(t, strings.Contains(split, `<table class="dt spl"`), "the split layout is sent")
163	assert.False(t, strings.Contains(split, `<table class="dt uni"`), "and only it")
164	assert.True(t, strings.Contains(split, `<div id="diffs" data-view="split">`))
165}
166
167// AC2: the commit pages of a private repository are the 404 page of a
168// repository that does not exist.
169func TestWebCommitPrivate(t *testing.T) {
170	h := newHarness(t, harnessOpts{})
171	wt := h.workTree("main", "secret.txt")
172	h.createRepo("hidden-one", domain.VisibilityPrivate)
173	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main")
174	sha := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
175
176	_, _, missing := h.get("/never-existed")
177	for _, p := range []string{
178		"/hidden-one/commit/" + sha, "/hidden-one/commit/" + sha + "?view=split",
179	} {
180		st, hdr, body := h.get(p)
181		assert.Equal(t, 404, st, p)
182		assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
183		assert.NotContains(t, body, "hidden-one", p)
184		assert.NotContains(t, body, "secret.txt", p)
185		assert.Equal(t, normalise(missing), normalise(body), p)
186	}
187}

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 2

Tags 44

↑↓ moveenter openesc close