e2e/diffweb_test.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/e2e/diffweb_test.go history · blame · raw

142 lines · 5231 bytes

  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}