e2e/design_test.go
373 lines · 16711 bytes
1package e2e
2
3import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "net/url"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12)
13
14func TestReadmeRelativeLinks(t *testing.T) {
15 inst := startInstance(t)
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
18 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/site"); code != 0 {
19 t.Fatal("repo create failed")
20 }
21 work := t.TempDir()
22 env := inst.gitEnv(aliceKey)
23 mustGit(t, work, env, "clone", inst.sshURL("alice/site"), "w")
24 dir := filepath.Join(work, "w")
25 os.MkdirAll(filepath.Join(dir, "docs"), 0o755)
26 os.MkdirAll(filepath.Join(dir, "img"), 0o755)
27 os.WriteFile(filepath.Join(dir, "README.md"), []byte(
28 "# site\n\n[guide](docs/guide.md) and [export](docs/paper.html) and "+
29 "[abs](https://example.org/x) here\n\n\n"+
30 "\n\n"+
31 "| flag | effect |\n|------|--------|\n| `-v` | verbose |\n\n"+
32 "```go\nfunc main() {}\n```\n"), 0o644)
33 os.WriteFile(filepath.Join(dir, "docs", "guide.md"), []byte("# guide\n"), 0o644)
34 os.WriteFile(filepath.Join(dir, "docs", "paper.org"), []byte("* paper\n"), 0o644)
35 os.WriteFile(filepath.Join(dir, "img", "logo.png"), []byte{0x89, 0x50}, 0o644)
36 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
37 mustGit(t, dir, env, "add", ".")
38 mustGit(t, dir, env, "commit", "-q", "-m", "base")
39 mustGit(t, dir, env, "push", "-q", "origin", "main")
40
41 status, body := inst.get(t, "/alice/site")
42 if status != 200 {
43 t.Fatalf("tree: %d", status)
44 }
45 for _, want := range []string{
46 `href="/alice/site/blob/main/docs/guide.md"`, // relative link
47 `href="/alice/site/blob/main/docs/paper.org"`, // .html mapped to .org source
48 `src="/alice/site/raw/main/img/logo.png"`, // relative image via raw
49 `src="https://example.org/pic.png"`, // remote image untouched
50 `href="https://example.org/x"`, // absolute untouched
51 "<table>", "<td>verbose</td>", // GFM table renders
52 `<span class="kd">func</span>`, // fenced code highlighted via classes
53 `href="/alice/site/blob/main/README.md">README.md</a>`, // clickable card header
54 `<th scope="col">name</th>`, // file table column headers
55 } {
56 if !strings.Contains(body, want) {
57 t.Errorf("missing %q", want)
58 }
59 }
60 // Directories sort ahead of files, whatever git's own tree order was:
61 // docs/ and img/ precede LICENSE and README.md despite sorting after
62 // them byte-wise.
63 for _, dir := range []string{"docs", "img"} {
64 d := strings.Index(body, `/tree/main/`+dir+`">`)
65 f := strings.Index(body, `/blob/main/README.md">`)
66 if d < 0 || f < 0 {
67 t.Fatalf("listing missing %s/ or README.md", dir)
68 }
69 if d > f {
70 t.Errorf("%s/ listed after README.md; directories should come first", dir)
71 }
72 }
73 // Branch dropdown lists branches.
74 if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">All refs") {
75 t.Error("branch dropdown missing")
76 }
77 // Per-file history: ?path= filters the log; blob pages link to it.
78 os.WriteFile(filepath.Join(dir, "docs", "notes.txt"), []byte("n\n"), 0o644)
79 mustGit(t, dir, env, "add", ".")
80 mustGit(t, dir, env, "commit", "-q", "-m", "touch only the notes")
81 mustGit(t, dir, env, "push", "-q", "origin", "main")
82 if _, body := inst.get(t, "/alice/site/log?path=docs/notes.txt"); !strings.Contains(body, "touch only the notes") ||
83 strings.Contains(body, ">base<") || !strings.Contains(body, "history of") {
84 t.Fatalf("per-file log wrong:\n%s", body)
85 }
86 if _, body := inst.get(t, "/alice/site/log?path=no/such/file"); !strings.Contains(body, "nothing touches") {
87 t.Fatalf("empty per-file log:\n%s", body)
88 }
89 if _, body := inst.get(t, "/alice/site/blob/main/docs/guide.md"); !strings.Contains(body, `log/main?path=docs%2fguide.md">history</a>`) {
90 t.Fatalf("blob history link missing:\n%s", body)
91 }
92 // CLI parity: repo log --path.
93 logOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/site", "--path", "docs/notes.txt", "--json")
94 if !strings.Contains(logOut, "touch only the notes") || strings.Contains(logOut, `"subject":"base"`) {
95 t.Fatalf("repo log --path wrong:\n%s", logOut)
96 }
97 // Raw serves images with their real type (nosniff otherwise blocks
98 // <img>); everything else stays inert text/plain.
99 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/raw/main/img/logo.png", inst.httpPort))
100 if err != nil {
101 t.Fatal(err)
102 }
103 resp.Body.Close()
104 if ct := resp.Header.Get("Content-Type"); ct != "image/png" {
105 t.Errorf("raw png content-type = %q", ct)
106 }
107 resp, err = http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/raw/main/README.md", inst.httpPort))
108 if err != nil {
109 t.Fatal(err)
110 }
111 resp.Body.Close()
112 if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
113 t.Errorf("raw md content-type = %q", ct)
114 }
115 // Blob pages preview images inline.
116 if _, body := inst.get(t, "/alice/site/blob/main/img/logo.png"); !strings.Contains(body, `<img src="/alice/site/raw/main/img/logo.png"`) {
117 t.Errorf("blob image preview missing:\n%s", body)
118 }
119 // Highlighting is class-based so the palette follows the color scheme:
120 // no inline colors on code, and the stylesheet carries both palettes.
121 if _, body := inst.get(t, "/alice/site/blob/main/README.md"); !strings.Contains(body, `class="chroma"`) ||
122 strings.Contains(body, "style=\"color") {
123 t.Errorf("highlighting not class-based:\n%.2000s", body)
124 }
125 if _, css := inst.get(t, "/static/style.css"); strings.Count(css, "/* Background */") < 2 ||
126 !strings.Contains(css, ".chroma, .bg { background: transparent") {
127 t.Error("stylesheet missing dual syntax palettes")
128 }
129 // Explore rows carry topics, license, and updated date.
130 inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/site", "web")
131 // Bare 0BSD grant (no notice-retention clause), wrapped mid-sentence.
132 os.WriteFile(filepath.Join(dir, "LICENSE"), []byte("Permission to use, copy, modify,\nand/or distribute this software for any\npurpose with or without fee is hereby granted.\n"), 0o644)
133 mustGit(t, dir, env, "add", ".")
134 mustGit(t, dir, env, "commit", "-q", "-m", "license")
135 mustGit(t, dir, env, "push", "-q", "origin", "main")
136 _, body = inst.get(t, "/explore")
137 for _, want := range []string{`href="/explore?q=web"`, "0BSD", "updated 20"} {
138 if !strings.Contains(body, want) {
139 t.Errorf("explore row missing %q", want)
140 }
141 }
142 // The description and topics belong to the code tab; task tabs render
143 // only the identity row and the tab bar.
144 for _, p := range []string{"/alice/site/issues", "/alice/site/mrs", "/alice/site/releases"} {
145 if _, body := inst.get(t, p); strings.Contains(body, `class="chip topic"`) {
146 t.Errorf("%s: header still carries topics on a task tab", p)
147 }
148 }
149 if _, body := inst.get(t, "/alice/site"); !strings.Contains(body, `class="chip topic"`) {
150 t.Error("repo home lost its topics")
151 }
152}
153
154// TestLandingRoutes checks the landing page's copy and the two routes.
155func TestLandingRoutes(t *testing.T) {
156 smtp := startFakeSMTP(t)
157 inst := startInstanceWith(t, fmt.Sprintf(
158 "[web]\nmode = \"accounts\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
159 smtp.addr))
160 _, body := inst.get(t, "/")
161 for _, want := range []string{
162 "A git forge you drive from the terminal.",
163 `class="button primary" href="/explore">Explore repositories</a>`,
164 `class="button btn" href="/register">Create an account</a>`,
165 "web login</code>",
166 "/static/img/mr-dark.png",
167 "<picture>",
168 } {
169 if !strings.Contains(body, want) {
170 t.Errorf("landing lacks %q", want)
171 }
172 }
173 if strings.Contains(body, "is the whole onboarding") {
174 t.Error("landing still calls repo create the whole onboarding")
175 }
176 _, reg := inst.get(t, "/register")
177 if !strings.Contains(reg, "Paste the contents of your public key file") || !strings.Contains(reg, "/krz/gitbay/wiki/SSH-keys") {
178 t.Error("register page lacks the key hint or the wiki link")
179 }
180}
181
182func TestWebInteractions(t *testing.T) {
183 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
184 aliceKey := inst.newKey(t, "alice")
185 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
186 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "theorg"); code != 0 {
187 t.Fatal("org create failed")
188 }
189
190 out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
191 if code != 0 {
192 t.Fatal("web login failed")
193 }
194 var env2 struct {
195 Data struct {
196 URL string `json:"url"`
197 } `json:"data"`
198 }
199 json.Unmarshal([]byte(out), &env2)
200 browser := newBrowser(t)
201 browserGet(t, browser, inst.base()+env2.Data.URL[strings.Index(env2.Data.URL, "/login"):])
202
203 // Create a repo under the org through the web form.
204 if status, _ := browserPost(t, browser, inst.base()+"/new",
205 url.Values{"owner": {"theorg"}, "name": {"webborn"}, "visibility": {"public"}}); status != 200 {
206 t.Fatalf("org repo via web: %d", status)
207 }
208 if out, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "theorg/webborn"); code != 0 {
209 t.Fatalf("org repo missing: %s", out)
210 }
211
212 // Pin from the web; the repo header reflects it and the dashboard
213 // lists it; a second toggle unpins.
214 if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{}); status != 200 {
215 t.Fatal("pin toggle failed")
216 }
217 _, body := browserGet(t, browser, inst.base()+"/theorg/webborn")
218 if !strings.Contains(body, "</span> Pinned</button>") {
219 t.Fatal("repo header not pinned")
220 }
221 if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, ">theorg/</span>webborn") {
222 t.Fatal("dashboard missing pinned repo")
223 }
224 browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{})
225 if _, body = browserGet(t, browser, inst.base()+"/theorg/webborn"); !strings.Contains(body, "</span> Pin</button>") {
226 t.Fatal("unpin failed")
227 }
228
229 // New issue with labels through the form; label chips filter.
230 if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/issues/new",
231 url.Values{"title": {"styled"}, "body": {"b"}, "labels": {"bug ui"}}); status != 200 {
232 t.Fatal("issue via web failed")
233 }
234 if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/issues/new",
235 url.Values{"title": {"plain"}}); status != 200 {
236 t.Fatal("second issue failed")
237 }
238 _, body = browserGet(t, browser, inst.base()+"/theorg/webborn/issues?label=bug")
239 if !strings.Contains(body, "styled") || strings.Contains(body, ">plain<") {
240 t.Fatalf("label filter wrong:\n%s", body)
241 }
242 _, body = browserGet(t, browser, inst.base()+"/theorg/webborn/issues/1")
243 if !strings.Contains(body, `href="/theorg/webborn/issues?label=bug"`) ||
244 !strings.Contains(body, `href="/alice">alice</a>`) {
245 t.Fatal("issue page chips/author not linked")
246 }
247}
248
249func TestCommitParentLinks(t *testing.T) {
250 inst := startInstance(t)
251 aliceKey := inst.newKey(t, "alice")
252 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
253 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
254 t.Fatal("repo create failed")
255 }
256 work := t.TempDir()
257 env := inst.gitEnv(aliceKey)
258 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
259 dir := filepath.Join(work, "w")
260 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
261 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
262 mustGit(t, dir, env, "add", ".")
263 mustGit(t, dir, env, "commit", "-q", "-m", "first")
264 first := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
265 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("b\n"), 0o644)
266 mustGit(t, dir, env, "commit", "-qam", "second")
267 head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
268 mustGit(t, dir, env, "push", "-q", "origin", "main")
269
270 _, body := inst.get(t, "/alice/app/commit/"+head)
271 if !strings.Contains(body, `href="/alice/app/commit/`+first+`"`) {
272 t.Fatal("parent commit not linked")
273 }
274 _, body = inst.get(t, "/alice/app/commit/"+first)
275 if strings.Contains(body, ">parent") {
276 t.Fatal("root commit shows a parent")
277 }
278}
279
280// TestAuthorNamesResolve checks that a commit whose author address is
281// verified here displays the account's name rather than whatever git
282// config carried, and that an unknown address keeps its own name.
283func TestAuthorNamesResolve(t *testing.T) {
284 inst := startInstance(t)
285 aliceKey := inst.newKey(t, "alice")
286 inst.admin(t, "admin", "user", "create", "alice",
287 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
288 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
289 t.Fatalf("repo create: %s", errOut)
290 }
291 work := t.TempDir()
292 env := inst.gitEnv(aliceKey)
293 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
294 dir := filepath.Join(work, "w")
295
296 // One commit from the account's verified address under a different
297 // display name, one from an address nobody has proven.
298 known := append(append([]string{}, env...),
299 "GIT_AUTHOR_NAME=Alice Q. Longname", "GIT_AUTHOR_EMAIL=alice@example.test",
300 "GIT_COMMITTER_NAME=Alice Q. Longname", "GIT_COMMITTER_EMAIL=alice@example.test")
301 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
302 mustGit(t, dir, known, "checkout", "-q", "-b", "main")
303 mustGit(t, dir, known, "add", ".")
304 mustGit(t, dir, known, "commit", "-q", "-m", "from the account")
305 stranger := append(append([]string{}, env...),
306 "GIT_AUTHOR_NAME=Outside Person", "GIT_AUTHOR_EMAIL=outside@nowhere.test",
307 "GIT_COMMITTER_NAME=Outside Person", "GIT_COMMITTER_EMAIL=outside@nowhere.test")
308 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
309 mustGit(t, dir, stranger, "add", ".")
310 mustGit(t, dir, stranger, "commit", "-q", "-m", "from a stranger")
311 // The tip is the account's, so the bar above the listing shows a link.
312 os.WriteFile(filepath.Join(dir, "c.txt"), []byte("c\n"), 0o644)
313 mustGit(t, dir, known, "add", ".")
314 mustGit(t, dir, known, "commit", "-q", "-m", "back to the account")
315 mustGit(t, dir, known, "push", "-q", "origin", "main")
316
317 // The log shows the account name for the verified address only.
318 _, body := inst.get(t, "/alice/app/log")
319 if strings.Contains(body, "Alice Q. Longname") {
320 t.Fatalf("log showed the git config name for a known address:\n%s", body)
321 }
322 if !strings.Contains(body, "Outside Person") {
323 t.Fatalf("log lost an unknown author's name:\n%s", body)
324 }
325 if !strings.Contains(body, "alice") {
326 t.Fatalf("log missing the account name:\n%s", body)
327 }
328 // A resolved name links to the profile; an unknown one stays text.
329 if !strings.Contains(body, `class="authorlink" href="/alice"`) {
330 t.Fatalf("account name is not a link:\n%s", body)
331 }
332 if strings.Contains(body, `href="/Outside Person"`) {
333 t.Fatalf("unknown author was linked:\n%s", body)
334 }
335 // The tipbar resolves and links the same way when the tip is an account's.
336 if _, tree := inst.get(t, "/alice/app"); !strings.Contains(tree, `class="authorlink" href="/alice"`) {
337 t.Fatalf("tipbar name is not a link:\n%s", tree)
338 }
339}
340
341// TestTreeSearchCodeAndClone: the overview links "Search code", not
342// "Find file", and shows two labelled clone blocks after the file table.
343func TestTreeSearchCodeAndClone(t *testing.T) {
344 inst := startInstance(t)
345 key := inst.newKey(t, "alice")
346 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
347 inst.ssh(t, key, "", "repo", "create", "alice/app")
348 work := t.TempDir()
349 env := inst.gitEnv(key)
350 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
351 dir := filepath.Join(work, "w")
352 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# app\n"), 0o644)
353 os.MkdirAll(filepath.Join(dir, "docs"), 0o755)
354 os.WriteFile(filepath.Join(dir, "docs", "README.md"), []byte("# docs\n"), 0o644)
355 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
356 mustGit(t, dir, env, "add", ".")
357 mustGit(t, dir, env, "commit", "-q", "-m", "init")
358 mustGit(t, dir, env, "push", "-q", "origin", "main")
359 _, body := inst.get(t, "/alice/app")
360 if strings.Contains(body, ">Find file<") || !strings.Contains(body, ">Search code<") {
361 t.Error("overview still says Find file")
362 }
363 if i, j := strings.Index(body, `<table class="tree">`), strings.Index(body, `<div class="clone">`); i < 0 || j < i {
364 t.Error("clone block does not follow the file table")
365 }
366 if !strings.Contains(body, `<label>SSH</label>`) || !strings.Contains(body, `<label>HTTPS</label>`) {
367 t.Error("clone blocks are not labelled")
368 }
369 // Clone and about belong to the repository root, not a subdirectory.
370 if _, sub := inst.get(t, "/alice/app/tree/main/docs"); strings.Contains(sub, `<div class="facts">`) {
371 t.Error("subdirectory listing still shows the clone/about facts block")
372 }
373}