1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "strings"
8 "testing"
9
10 "github.com/alecthomas/chroma/v2"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 pb "bnema.dev/ermite-api/gen/ermite"
15)
16
17func TestHighlightLines(t *testing.T) {
18 v := highlight(context.Background(), "a.go", "package main\n\nfunc main() {}\n")
19 assert.Equal(t, 3, v.lines)
20 assert.Equal(t, "Go", v.lang)
21 h := string(v.html)
22 assert.Contains(t, h, `<span class="line" id="L1"><a class="ln" href="#L1">1</a><span class="kn">package</span>`)
23 assert.Contains(t, h, `<span class="line" id="L2"><a class="ln" href="#L2">2</a></span>`)
24 assert.NotContains(t, h, "style=")
25
26 // No final line feed, CRLF and lone CR are lines too.
27 assert.Equal(t, 2, highlight(context.Background(), "x.txt", "a\nb").lines)
28 assert.Equal(t, 3, highlight(context.Background(), "x.txt", "a\r\nb\rc").lines)
29 assert.Equal(t, 0, highlight(context.Background(), "x.txt", "").lines)
30}
31
32func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) {
33 v := highlight(context.Background(), "unknown.zzz", "<script>alert(1)</script> & \"q\"\n")
34 assert.Equal(t, "", v.lang)
35 h := string(v.html)
36 assert.Contains(t, h, "<script>alert(1)</script> & "q"")
37 assert.NotContains(t, h, "<script>")
38
39 // Invalid UTF-8 is replaced, not passed on.
40 assert.Contains(t, string(highlight(context.Background(), "x.txt", "a\xffb\n").html), "a\uFFFDb")
41}
42
43func TestHighlightMarksInvisibleCharacters(t *testing.T) {
44 for r, cp := range map[rune]string{
45 0x202E: "U+202E", 0x2066: "U+2066", 0x2069: "U+2069", 0x200B: "U+200B", 0xFEFF: "U+FEFF",
46 0x0000: "U+0000", 0x001B: "U+001B", 0x2028: "U+2028", 0x3164: "U+3164", 0x1F: "U+001F",
47 } {
48 h := string(highlight(context.Background(), "x.go", "a"+string(r)+"b\n").html)
49 assert.NotContains(t, h, string(r), cp)
50 assert.Contains(t, h, "<"+cp+"></span>", cp)
51 assert.Contains(t, h, `<span class="ctl" title="`+cp+" ", cp)
52 }
53 // A tab and ordinary non-ASCII text are left alone.
54 h := string(highlight(context.Background(), "x.txt", "\tcafé 日本\n").html)
55 assert.Contains(t, h, "\tcafé 日本")
56 assert.NotContains(t, h, "ctl")
57 // Inside a token of a lexer too.
58 h = string(highlight(context.Background(), "x.go", "x := \"a\u202eb\"\n").html)
59 assert.NotContains(t, h, "\u202e")
60 assert.Contains(t, h, "<U+202E>")
61}
62
63// The lexers do not lose or add characters: the text of the lines is the
64// source, whatever the language.
65func TestHighlightKeepsTheSource(t *testing.T) {
66 srcs := map[string]string{
67 "a.go": "package a\n\nvar s = `multi\nline`\n// end",
68 "b.py": "def f():\n '''doc\n string'''\n return 1\n",
69 "c.md": "# T\n\n```go\nx := 1\n```\n",
70 "d.json": "{\"a\": [1, 2, {\"b\": null}]}\n",
71 "e.sh": "echo \"$(date)\" | tr a b\n",
72 "f.yaml": "a:\n - b\n - c: d\n",
73 "Makefile": "all:\n\t@echo hi\n",
74 "g.rs": "fn main() { let _ = \"x\"; }\n",
75 "h.html": "<p class=\"a\">x & y</p>\n",
76 "i.templ": "templ A() {\n\t<p>x</p>\n}\n",
77 "Dockerfile": "FROM alpine\nRUN echo hi\n",
78 }
79 for name, src := range srcs {
80 v := highlight(context.Background(), name, src)
81 var text strings.Builder
82 for _, l := range strings.Split(string(v.html), "\n") {
83 l = strings.TrimSpace(l)
84 if l == "" {
85 continue
86 }
87 text.WriteString(stripTags(l) + "\n")
88 }
89 want := src
90 assert.Equal(t, strings.Count(strings.TrimSuffix(src, "\n"), "\n")+1, v.lines, name)
91 assert.Equal(t, normSpace(want), normSpace(unescape(text.String())), name)
92 }
93}
94
95func TestFirstLinesAndCount(t *testing.T) {
96 assert.Equal(t, "a\nb\n", firstLines("a\nb\nc\n", 2))
97 assert.Equal(t, "a\nb", firstLines("a\nb", 5))
98 assert.Equal(t, 3, countLines([]byte("a\nb\nc")))
99 assert.Equal(t, 3, countLines([]byte("a\nb\nc\n")))
100 assert.Equal(t, 0, countLines(nil))
101}
102
103func TestHighlightCtxCancelled(t *testing.T) {
104 ctx, cancel := context.WithCancel(context.Background())
105 cancel()
106 v := highlightCtx(ctx, "a.go", "package a\n")
107 assert.Equal(t, 1, v.lines, "a cancelled context gives the plain text")
108 assert.Empty(t, v.lang, "the lexer stopped")
109}
110
111// panicLexer is a lexer that panics while lexing, as chroma lexers can.
112type panicLexer struct{ chroma.Lexer }
113
114func (panicLexer) Tokenise(*chroma.TokeniseOptions, string) (chroma.Iterator, error) {
115 return func() chroma.Token { panic("lexer bug") }, nil
116}
117
118func TestTokeniseRecoversFromALexerPanic(t *testing.T) {
119 toks, ok := tokenise(context.Background(), panicLexer{}, "x\n")
120 assert.False(t, ok)
121 assert.Nil(t, toks)
122}
123
124func TestSniffImage(t *testing.T) {
125 assert.Equal(t, "image/png", sniffImage([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")))
126 assert.Equal(t, "image/gif", sniffImage([]byte("GIF89a....")))
127 assert.Equal(t, "image/jpeg", sniffImage([]byte("\xff\xd8\xff\xe0\x00\x10JFIF")))
128 assert.Equal(t, "", sniffImage([]byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)))
129 assert.Equal(t, "", sniffImage([]byte(`<?xml version="1.0"?><svg/>`)))
130 assert.Equal(t, "", sniffImage([]byte("plain")))
131 assert.Equal(t, "", sniffImage(nil))
132}
133
134func TestLocate(t *testing.T) {
135 b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
136 d := &repoData{
137 branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")},
138 tags: []*pb.Ref{ref(tag, "v1/rc")},
139 }
140 d.head = d.branches[0]
141 for _, tt := range []struct {
142 in, name, path string
143 detached, ok bool
144 }{
145 {"", "main", "", false, true},
146 {"main/a/b.go", "main", "a/b.go", false, true},
147 {"feature/x/dir/f", "feature/x", "dir/f", false, true},
148 {"v1/rc", "v1/rc", "", false, true},
149 {"0123abc/f.txt", "0123abc", "f.txt", true, true},
150 {"feature/y/f", "", "", false, false},
151 {"abc/f", "", "", false, false},
152 {"MAIN/f", "", "", false, false},
153 } {
154 got, ok := d.locate(tt.in)
155 assert.Equal(t, tt.ok, ok, tt.in)
156 if !ok {
157 continue
158 }
159 assert.Equal(t, tt.name, got.name, tt.in)
160 assert.Equal(t, tt.path, got.path, tt.in)
161 assert.Equal(t, tt.detached, got.detached, tt.in)
162 }
163 empty := &repoData{}
164 _, ok := empty.locate("")
165 assert.False(t, ok, "an empty repository has no head")
166 require.True(t, isCommitID(strings.Repeat("a", 40)))
167 assert.False(t, isCommitID(strings.Repeat("a", 41)))
168 assert.False(t, isCommitID("abcdef"))
169}
170
171func TestFileCrumbs(t *testing.T) {
172 cs := pathCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"})
173 require.Len(t, cs, 5)
174 assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href)
175 assert.Equal(t, "/demo/blob/feature/x/src/a.go", cs[4].Href)
176 assert.True(t, cs[4].Current)
177 cs = pathCrumbs("demo", "tree", target{name: "main", path: "src"})
178 assert.Equal(t, "/demo/tree/main/src", cs[3].Href)
179 assert.Equal(t, "a<U+202E>b", pathCrumbs("demo", "tree", target{name: "main", path: "a\u202eb"})[3].Label)
180}
181
182func stripTags(s string) string {
183 var b strings.Builder
184 in := false
185 for _, r := range s {
186 switch {
187 case r == '<':
188 in = true
189 case r == '>':
190 in = false
191 case !in:
192 b.WriteRune(r)
193 }
194 }
195 return b.String()
196}
197
198func unescape(s string) string {
199 return strings.NewReplacer("<", "<", ">", ">", "&", "&", """, `"`, "'", "'").Replace(s)
200}
201
202// normSpace drops the leading numbers of the gutter and the white space, which
203// the tests do not care about.
204func normSpace(s string) string {
205 var out []string
206 for _, l := range strings.Split(s, "\n") {
207 l = strings.TrimSpace(l)
208 if l == "" {
209 continue
210 }
211 i := 0
212 for i < len(l) && l[i] >= '0' && l[i] <= '9' {
213 i++
214 }
215 if l = strings.Join(strings.Fields(l[i:]), ""); l != "" {
216 out = append(out, l)
217 }
218 }
219 return strings.Join(out, "\n")
220}