detached 5278df2

diff_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"strings"
8	"testing"
9
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12
13	"bnema.dev/ermite/internal/domain"
14)
15
16// Trojan Source (ADR 0017): direction controls, zero-width and control
17// characters are shown as markers; ordinary text, tabs and non-Latin letters
18// are not touched.
19func TestSegmentsShowInvisibleCharacters(t *testing.T) {
20	t.Parallel()
21	tests := []struct {
22		name, in string
23		want     []Seg
24	}{
25		{"plain", "return x", []Seg{{Text: "return x"}}},
26		{"tab and unicode", "\tπ := “é”", []Seg{{Text: "\tπ := “é”"}}},
27		{"empty", "", []Seg{{Text: ""}}},
28		{"crlf", "a\r", []Seg{{Text: "a"}}},
29		{"rlo", "if x { // \u202eadmin", []Seg{{Text: "if x { // "}, {Text: "<U+202E>", Mark: true}, {Text: "admin"}}},
30		{"isolates", "\u2066a\u2069", []Seg{{Text: "<U+2066>", Mark: true}, {Text: "a"}, {Text: "<U+2069>", Mark: true}}},
31		{"zero width", "a\u200bb\ufeff", []Seg{{Text: "a"}, {Text: "<U+200B>", Mark: true}, {Text: "b"}, {Text: "<U+FEFF>", Mark: true}}},
32		{"control", "a\x00\x1bb", []Seg{{Text: "a"}, {Text: "<U+0000>", Mark: true}, {Text: "<U+001B>", Mark: true}, {Text: "b"}}},
33		{"tag character", "a\U000E0041", []Seg{{Text: "a"}, {Text: "<U+E0041>", Mark: true}}},
34		{"hangul filler", "\u3164", []Seg{{Text: "<U+3164>", Mark: true}}},
35		{"replacement is visible already", "a\ufffdb", []Seg{{Text: "a\ufffdb"}}},
36	}
37	for _, tt := range tests {
38		t.Run(tt.name, func(t *testing.T) {
39			t.Parallel()
40			assert.Equal(t, tt.want, segments(tt.in))
41		})
42	}
43	assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go"))
44	assert.Equal(t, "plain", visibleText("plain"))
45	assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n"))
46}
47
48func line(kind domain.LineKind, oldN, newN int, text string) domain.Line {
49	return domain.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text}
50}
51
52// The split view lines a run of deletions up with the additions after it;
53// context fills both sides; the extra lines of the longer run have an empty
54// opposite side.
55func TestPairLines(t *testing.T) {
56	t.Parallel()
57	ctx, add, del := domain.LineContext, domain.LineAddition, domain.LineDeletion
58	var ls []DiffLine
59	for _, l := range []domain.Line{
60		line(ctx, 1, 1, "c"),
61		line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"),
62		line(add, 0, 2, "a1"),
63		line(ctx, 5, 3, "c2"),
64		line(add, 0, 4, "a2"), line(add, 0, 5, "a3"),
65	} {
66		ls = append(ls, diffLine(l))
67	}
68	rows := pairLines(ls)
69	type pair struct{ l, r string }
70	var got []pair
71	for _, r := range rows {
72		p := pair{}
73		if r.L != nil {
74			p.l = r.L.Mark + r.L.Segs[0].Text
75		}
76		if r.R != nil {
77			p.r = r.R.Mark + r.R.Segs[0].Text
78		}
79		got = append(got, p)
80	}
81	assert.Equal(t, []pair{
82		{" c", " c"}, {"-d1", "+a1"}, {"-d2", ""}, {"-d3", ""}, {" c2", " c2"}, {"", "+a2"}, {"", "+a3"},
83	}, got)
84	assert.Equal(t, DiffLine{Kind: "del", Mark: "-", Old: "2", Segs: []Seg{{Text: "d1"}}, oldN: 2}, ls[1])
85	assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4])
86}
87
88func TestFileNotes(t *testing.T) {
89	t.Parallel()
90	tests := []struct {
91		name string
92		file domain.FileDiff
93		want string // a substring; empty means no note at all
94		eq   bool   // want is the whole note
95	}{
96		{"binary", domain.FileDiff{Binary: true, Status: domain.FileModified}, "binary file: no text diff", true},
97		{"mode change", domain.FileDiff{Status: domain.FileModified, OldMode: 0o100644, NewMode: 0o100755}, "mode 100644 → 100755", true},
98		{"an added file has no mode change", domain.FileDiff{Status: domain.FileAdded, NewMode: 0o100644}, "", true},
99		{"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100		{"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101		{"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102		{"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103		{"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104		{"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105		{"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
106	}
107	for _, tt := range tests {
108		t.Run(tt.name, func(t *testing.T) {
109			t.Parallel()
110			got := fileNote(tt.file)
111			if tt.eq {
112				assert.Equal(t, tt.want, got)
113				return
114			}
115			assert.Contains(t, got, tt.want)
116		})
117	}
118}
119
120func TestDiffFilesPaths(t *testing.T) {
121	t.Parallel()
122	fs := diffFiles([]domain.FileDiff{
123		{Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
124		{Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
125		{Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
126		{Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000},
127	})
128	assert.Equal(t, "a.go", fs[0].Path)
129	assert.True(t, fs[0].Open)
130	assert.Equal(t, "b.go", fs[1].Path)
131	assert.False(t, fs[1].Open, "nothing to read: stays closed")
132	assert.Equal(t, "c<U+202E>.go → d.go", fs[2].Path)
133	assert.False(t, fs[3].Open, "a truncated file is collapsed")
134	assert.Equal(t, []string{"f1", "f2", "f3", "f4"}, []string{fs[0].ID, fs[1].ID, fs[2].ID, fs[3].ID})
135}
136
137// A code cell holds exactly the text: escaped, with markers, and nothing
138// added between the pieces (the cell keeps its white space).
139func TestCodeLine(t *testing.T) {
140	t.Parallel()
141	var b strings.Builder
142	ln := diffLine(domain.Line{Kind: domain.LineAddition, NewNumber: 1, Text: "a <b> \u202e c  d", NoNewlineAtEOF: true})
143	require.NoError(t, codeLine(ln).Render(context.Background(), &b))
144	assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c  d`+
145		`<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String())
146}
147
148// The gaps of a file: before a hunk the unchanged lines it does not show
149// (the same number on each side), after the last hunk the rest of the file;
150// none for a file whose lines are all in the diff.
151func TestContextGaps(t *testing.T) {
152	t.Parallel()
153	ctx := func(o, n int) domain.Line { return line(domain.LineContext, o, n, "x") }
154	files := []domain.FileDiff{
155		{Status: domain.FileModified, NewPath: "a b.go", Hunks: []domain.Hunk{
156			{OldStart: 1, OldLines: 2, NewStart: 1, NewLines: 2, Lines: []domain.Line{ctx(1, 1), ctx(2, 2)}},
157			{OldStart: 10, OldLines: 3, NewStart: 10, NewLines: 3, Lines: []domain.Line{ctx(10, 10), ctx(11, 11), ctx(12, 12)}},
158		}},
159		{Status: domain.FileModified, NewPath: "b.go", Hunks: []domain.Hunk{
160			// a pure deletion: the new side has no line, "+4,0" names line 4
161			{OldStart: 5, OldLines: 1, NewStart: 4, NewLines: 0, Lines: []domain.Line{line(domain.LineDeletion, 5, 0, "x")}},
162		}},
163		{Status: domain.FileAdded, NewPath: "c.go", Hunks: []domain.Hunk{{OldStart: 0, NewStart: 1, NewLines: 1, Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
164		{Status: domain.FileModified, NewPath: "d.go", Hunks: []domain.Hunk{
165			{OldStart: 3, OldLines: 1, NewStart: 3, NewLines: 1, Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 3, Text: "end", NoNewlineAtEOF: true}}},
166		}},
167	}
168	out := diffFiles(files)
169	contextGaps(out, files, "/r/context/c")
170
171	a := out[0]
172	assert.Nil(t, a.Hunks[0].Gap, "the first hunk starts at line 1")
173	require.NotNil(t, a.Hunks[1].Gap)
174	assert.Equal(t, 7, a.Hunks[1].Gap.Lines, "lines 3 to 9 on both sides")
175	assert.Equal(t, "/r/context/c?key=g1&n=7&new=3&old=3&path=a+b.go", a.Hunks[1].Gap.Src)
176	require.NotNil(t, a.Tail)
177	assert.Equal(t, "/r/context/c?key=tail&n=0&new=13&old=13&path=a+b.go", a.Tail.Src)
178
179	b := out[1]
180	require.NotNil(t, b.Hunks[0].Gap)
181	assert.Equal(t, 4, b.Hunks[0].Gap.Lines, "lines 1 to 4 on both sides before the deleted line 5")
182	assert.Nil(t, b.Tail, "a hunk that ends on a change ends the file")
183
184	assert.Nil(t, out[2].Tail, "an added file shows every line")
185	assert.Nil(t, out[3].Tail, "the last hunk holds the end of the file")
186	assert.NotNil(t, a.Tail, "three unchanged lines after the change: the file may go on")
187	assert.NotNil(t, out[3].Hunks[0].Gap)
188
189	// Sides that do not move together (a parse the page cannot trust): no gap.
190	odd := []domain.FileDiff{{Status: domain.FileModified, NewPath: "e.go", Hunks: []domain.Hunk{
191		{OldStart: 10, OldLines: 1, NewStart: 12, NewLines: 1, Lines: []domain.Line{ctx(10, 12)}},
192	}}}
193	o := diffFiles(odd)
194	contextGaps(o, odd, "/r/context/c")
195	assert.Nil(t, o[0].Hunks[0].Gap)
196}
197
198func TestDiffNote(t *testing.T) {
199	t.Parallel()
200	files := func(w ...domain.FileOmission) domain.Diff {
201		var d domain.Diff
202		for _, o := range w {
203			d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204		}
205		return d
206	}
207	assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
208	assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
209		diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
210}
211
212func TestCollapsedFileMarkup(t *testing.T) {
213	t.Parallel()
214	view := DiffView{ShowUnified: true}
215	src := "/demo/commit/abc1234/files"
216	for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
217		raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
218		fs := diffFiles(raw)
219		collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
220		out := renderToString(t, DiffFileView(fs[0], view))
221		assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222		assert.Regexp(t, `<details `, out, "the file is a details element")
223		assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
224		assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
225		assert.NotContains(t, out, "<table")
226	}
227	raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
228	fs := diffFiles(raw)
229	collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
230	out := renderToString(t, DiffFileView(fs[0], view))
231	assert.Contains(t, out, "too large to show in the browser")
232	assert.NotContains(t, out, "load diff")
233	assert.NotContains(t, out, "data-manual")
234	assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
235
236	// Past the first window of a merge request the link keeps the window.
237	raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
238	fs = diffFiles(raw)
239	collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
240	assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
241	assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
242}

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