e2e/diffweb_test.go
142 lines · 5231 bytes
3 symbols in this file
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// TestDiffRendering covers the shared diff view: per-file folds with stats,
11// line-number gutters, syntax highlighting, and binary files declared
12// rather than dumped.
13func TestDiffRendering(t *testing.T) {
14 t.Parallel()
15 inst := startInstance(t)
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
18 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
19 t.Fatalf("repo create: %s", errOut)
20 }
21
22 work := t.TempDir()
23 env := inst.gitEnv(aliceKey)
24 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
25 dir := filepath.Join(work, "w")
26 write := func(name, body string) {
27 os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644)
28 }
29 write("main.go", "package main\n\nfunc greet() string {\n\treturn \"hello\"\n}\n")
30 write("notes.txt", "old title\n")
31 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
32 mustGit(t, dir, env, "add", ".")
33 mustGit(t, dir, env, "commit", "-q", "-m", "base")
34 mustGit(t, dir, env, "push", "-q", "origin", "main")
35
36 // One commit touching three files in three different ways.
37 write("main.go", "package main\n\nfunc greet() string {\n\t// now with feeling\n\treturn \"HELLO\"\n}\n")
38 os.Rename(filepath.Join(dir, "notes.txt"), filepath.Join(dir, "README.md"))
39 os.WriteFile(filepath.Join(dir, "logo.png"), []byte("\x89PNG\r\n\x1a\n\x00\x00binary"), 0o644)
40 mustGit(t, dir, env, "add", "-A")
41 mustGit(t, dir, env, "commit", "-q", "-m", "rework")
42 mustGit(t, dir, env, "push", "-q", "origin", "main")
43
44 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/app", "--json")
45 sha := jsonField(out, "sha")
46 if sha == "" {
47 t.Fatalf("no sha in log: %s", out)
48 }
49
50 status, body := inst.get(t, "/alice/app/commit/"+sha)
51 if status != 200 {
52 t.Fatalf("commit page: %d", status)
53 }
54
55 // A fold per file, each naming its path.
56 for _, path := range []string{"main.go", "logo.png"} {
57 if !strings.Contains(body, ">"+path+"<") {
58 t.Errorf("no section for %s", path)
59 }
60 }
61 if strings.Count(body, `<details class="difffold"`) != 3 {
62 t.Errorf("want 3 file sections, got %d", strings.Count(body, `<details class="difffold"`))
63 }
64 // The rename is shown as one, not as an add plus a delete.
65 if !strings.Contains(body, "notes.txt") || !strings.Contains(body, "renamed") {
66 t.Error("rename not shown as a rename")
67 }
68 // Binary content is declared, never dumped into the page.
69 if !strings.Contains(body, "Binary file not shown") {
70 t.Error("binary file not declared")
71 }
72 if strings.Contains(body, "\x89PNG") {
73 t.Error("binary content leaked into the diff")
74 }
75 // Line-number gutters and per-file stats.
76 if !strings.Contains(body, `<td class="ln">`) {
77 t.Error("no line-number gutter")
78 }
79 if !strings.Contains(body, `<span class="add">+`) || !strings.Contains(body, `<span class="del">−`) {
80 t.Error("no per-file stat")
81 }
82 // Go is a type chroma knows, so the added line carries token markup.
83 if !strings.Contains(body, "class=\"k\"") && !strings.Contains(body, "class=\"kd\"") {
84 t.Error("diff content is not syntax highlighted")
85 }
86 // Token classes alone prove nothing: chroma scopes every colour rule
87 // under .chroma, and the per-line markup has no wrapper of its own, so
88 // the cell has to be that wrapper or the classes are inert.
89 if !strings.Contains(body, `<td class="src chroma">`) {
90 t.Error("diff cell is not a chroma wrapper, so token classes go unstyled")
91 }
92 css := stylesheet(t, inst)
93 if !strings.Contains(css, ".chroma .k") {
94 t.Error("stylesheet has no chroma token rules to match")
95 }
96 // Each palette is scoped to its own scheme. Unscoped, the light one
97 // leaks into dark mode for every token the dark palette does not name.
98 if !strings.Contains(css, "@media (prefers-color-scheme: light)") ||
99 !strings.Contains(css, "@media (prefers-color-scheme: dark)") {
100 t.Error("chroma palettes are not both scheme-scoped")
101 }
102 if i := strings.Index(css, ".chroma .na"); i >= 0 {
103 if j := strings.LastIndex(css[:i], "prefers-color-scheme"); j < 0 {
104 t.Error("a chroma token rule sits outside any scheme scope")
105 }
106 }
107 // The +/- markers are CSS, so a copied selection is real source.
108 if strings.Contains(body, `<td class="src">+`) {
109 t.Error("diff markers are in the markup, not the stylesheet")
110 }
111 // The cell must not use .code: that class carries its own background,
112 // which would paint over the add/delete row tint.
113 if strings.Contains(body, `<td class="code">`) {
114 t.Error("diff cells use the blob code class, whose background hides the row tint")
115 }
116}
117
118// jsonField pulls the first "name":"value" string out of a JSON blob.
119func jsonField(blob, name string) string {
120 key := `"` + name + `":"`
121 i := strings.Index(blob, key)
122 if i < 0 {
123 return ""
124 }
125 rest := blob[i+len(key):]
126 j := strings.IndexByte(rest, '"')
127 if j < 0 {
128 return ""
129 }
130 return rest[:j]
131}
132
133// stylesheet fetches the served CSS, so a test can check that a rule the
134// markup depends on actually exists.
135func stylesheet(t *testing.T, inst *instance) string {
136 t.Helper()
137 status, body := inst.get(t, "/static/style.css")
138 if status != 200 {
139 t.Fatalf("stylesheet: %d", status)
140 }
141 return body
142}