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