e2e/design_test.go

v1.42.0
gitbay/e2e/design_test.go history · blame · raw

413 lines · 18414 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![logo](img/logo.png)\n"+
 31			"![ext](https://example.org/pic.png)\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/demo.webm",
193		"<video autoplay muted loop playsinline controls",
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, "ssh-keygen -t ed25519") {
204		t.Error("register page lacks the key hint or the disclosure that makes a key")
205	}
206	if strings.Contains(reg, "gitbay.org/krz/gitbay/wiki") {
207		t.Error("register page links out to this instance's wiki")
208	}
209}
210
211func TestWebInteractions(t *testing.T) {
212	t.Parallel()
213	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
214	aliceKey := inst.newKey(t, "alice")
215	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
216	if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "theorg"); code != 0 {
217		t.Fatal("org create failed")
218	}
219
220	out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
221	if code != 0 {
222		t.Fatal("web login failed")
223	}
224	var env2 struct {
225		Data struct {
226			URL string `json:"url"`
227		} `json:"data"`
228	}
229	json.Unmarshal([]byte(out), &env2)
230	browser := newBrowser(t)
231	browserGet(t, browser, inst.base()+env2.Data.URL[strings.Index(env2.Data.URL, "/login"):])
232
233	// Create a repo under the org through the web form.
234	if status, _ := browserPost(t, browser, inst.base()+"/new",
235		url.Values{"owner": {"theorg"}, "name": {"webborn"}, "visibility": {"public"}}); status != 200 {
236		t.Fatalf("org repo via web: %d", status)
237	}
238	if out, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "theorg/webborn"); code != 0 {
239		t.Fatalf("org repo missing: %s", out)
240	}
241
242	// Pin from the web; the repo header reflects it and the dashboard
243	// lists it; a second toggle unpins.
244	if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{}); status != 200 {
245		t.Fatal("pin toggle failed")
246	}
247	_, body := browserGet(t, browser, inst.base()+"/theorg/webborn")
248	if !strings.Contains(body, "</span> Pinned</button>") {
249		t.Fatal("repo header not pinned")
250	}
251	if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, ">theorg/</span>webborn") {
252		t.Fatal("dashboard missing pinned repo")
253	}
254	browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{})
255	if _, body = browserGet(t, browser, inst.base()+"/theorg/webborn"); !strings.Contains(body, "</span> Pin</button>") {
256		t.Fatal("unpin failed")
257	}
258
259	// New issue with labels through the form; label chips filter.
260	if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/issues/new",
261		url.Values{"title": {"styled"}, "body": {"b"}, "labels": {"bug ui"}}); status != 200 {
262		t.Fatal("issue via web failed")
263	}
264	if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/issues/new",
265		url.Values{"title": {"plain"}}); status != 200 {
266		t.Fatal("second issue failed")
267	}
268	_, body = browserGet(t, browser, inst.base()+"/theorg/webborn/issues?label=bug")
269	if !strings.Contains(body, "styled") || strings.Contains(body, ">plain<") {
270		t.Fatalf("label filter wrong:\n%s", body)
271	}
272	_, body = browserGet(t, browser, inst.base()+"/theorg/webborn/issues/1")
273	if !strings.Contains(body, `href="/theorg/webborn/issues?label=bug"`) ||
274		!strings.Contains(body, `href="/alice">alice</a>`) {
275		t.Fatal("issue page chips/author not linked")
276	}
277}
278
279func TestCommitParentLinks(t *testing.T) {
280	t.Parallel()
281	inst := startInstance(t)
282	aliceKey := inst.newKey(t, "alice")
283	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
284	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
285		t.Fatal("repo create failed")
286	}
287	work := t.TempDir()
288	env := inst.gitEnv(aliceKey)
289	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
290	dir := filepath.Join(work, "w")
291	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
292	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
293	mustGit(t, dir, env, "add", ".")
294	mustGit(t, dir, env, "commit", "-q", "-m", "first")
295	first := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
296	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("b\n"), 0o644)
297	mustGit(t, dir, env, "commit", "-qam", "second")
298	head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
299	mustGit(t, dir, env, "push", "-q", "origin", "main")
300
301	_, body := inst.get(t, "/alice/app/commit/"+head)
302	if !strings.Contains(body, `href="/alice/app/commit/`+first+`"`) {
303		t.Fatal("parent commit not linked")
304	}
305	_, body = inst.get(t, "/alice/app/commit/"+first)
306	if strings.Contains(body, ">parent") {
307		t.Fatal("root commit shows a parent")
308	}
309}
310
311// TestAuthorNamesResolve checks that a commit whose author address is
312// verified here displays the account's name rather than whatever git
313// config carried, and that an unknown address keeps its own name.
314func TestAuthorNamesResolve(t *testing.T) {
315	t.Parallel()
316	inst := startInstance(t)
317	aliceKey := inst.newKey(t, "alice")
318	inst.admin(t, "admin", "user", "create", "alice",
319		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
320	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
321		t.Fatalf("repo create: %s", errOut)
322	}
323	work := t.TempDir()
324	env := inst.gitEnv(aliceKey)
325	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
326	dir := filepath.Join(work, "w")
327
328	// One commit from the account's verified address under a different
329	// display name, one from an address nobody has proven.
330	known := append(append([]string{}, env...),
331		"GIT_AUTHOR_NAME=Alice Q. Longname", "GIT_AUTHOR_EMAIL=alice@example.test",
332		"GIT_COMMITTER_NAME=Alice Q. Longname", "GIT_COMMITTER_EMAIL=alice@example.test")
333	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
334	mustGit(t, dir, known, "checkout", "-q", "-b", "main")
335	mustGit(t, dir, known, "add", ".")
336	mustGit(t, dir, known, "commit", "-q", "-m", "from the account")
337	stranger := append(append([]string{}, env...),
338		"GIT_AUTHOR_NAME=Outside Person", "GIT_AUTHOR_EMAIL=outside@nowhere.test",
339		"GIT_COMMITTER_NAME=Outside Person", "GIT_COMMITTER_EMAIL=outside@nowhere.test")
340	os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
341	mustGit(t, dir, stranger, "add", ".")
342	mustGit(t, dir, stranger, "commit", "-q", "-m", "from a stranger")
343	// The tip is the account's, so the bar above the listing shows a link.
344	os.WriteFile(filepath.Join(dir, "c.txt"), []byte("c\n"), 0o644)
345	mustGit(t, dir, known, "add", ".")
346	mustGit(t, dir, known, "commit", "-q", "-m", "back to the account")
347	mustGit(t, dir, known, "push", "-q", "origin", "main")
348
349	// The log shows the account name for the verified address only.
350	_, body := inst.get(t, "/alice/app/log")
351	if strings.Contains(body, "Alice Q. Longname") {
352		t.Fatalf("log showed the git config name for a known address:\n%s", body)
353	}
354	if !strings.Contains(body, "Outside Person") {
355		t.Fatalf("log lost an unknown author's name:\n%s", body)
356	}
357	if !strings.Contains(body, "alice") {
358		t.Fatalf("log missing the account name:\n%s", body)
359	}
360	// A resolved name links to the profile; an unknown one stays text.
361	if !strings.Contains(body, `class="authorlink" href="/alice"`) {
362		t.Fatalf("account name is not a link:\n%s", body)
363	}
364	if strings.Contains(body, `href="/Outside Person"`) {
365		t.Fatalf("unknown author was linked:\n%s", body)
366	}
367	// The tipbar resolves and links the same way when the tip is an account's.
368	if _, tree := inst.get(t, "/alice/app"); !strings.Contains(tree, `class="authorlink" href="/alice"`) {
369		t.Fatalf("tipbar name is not a link:\n%s", tree)
370	}
371}
372
373// TestTreeSearchCodeAndClone: the overview links "Search code", not
374// "Find file", and shows two labelled clone blocks after the file table.
375func TestTreeSearchCodeAndClone(t *testing.T) {
376	t.Parallel()
377	inst := startInstance(t)
378	key := inst.newKey(t, "alice")
379	inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
380	inst.ssh(t, key, "", "repo", "create", "alice/app")
381	work := t.TempDir()
382	env := inst.gitEnv(key)
383	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
384	dir := filepath.Join(work, "w")
385	os.WriteFile(filepath.Join(dir, "README.md"), []byte("# app\n"), 0o644)
386	os.MkdirAll(filepath.Join(dir, "docs"), 0o755)
387	os.WriteFile(filepath.Join(dir, "docs", "README.md"), []byte("# docs\n"), 0o644)
388	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
389	mustGit(t, dir, env, "add", ".")
390	mustGit(t, dir, env, "commit", "-q", "-m", "init")
391	mustGit(t, dir, env, "push", "-q", "origin", "main")
392	_, body := inst.get(t, "/alice/app")
393	if strings.Contains(body, ">Find file<") || !strings.Contains(body, ">Search code<") {
394		t.Error("overview still says Find file")
395	}
396	if i, j := strings.Index(body, `<table class="tree">`), strings.Index(body, `<div class="clone">`); i < 0 || j < i {
397		t.Error("clone block does not follow the file table")
398	}
399	if !strings.Contains(body, `<span class="fieldname">SSH</span>`) || !strings.Contains(body, `<span class="fieldname">HTTPS</span>`) {
400		t.Error("clone blocks are not labelled")
401	}
402	if !strings.Contains(body, `class="overview withfacts"`) {
403		t.Error("root overview is not the two-column withfacts layout")
404	}
405	// Clone and about belong to the repository root, not a subdirectory.
406	_, sub := inst.get(t, "/alice/app/tree/main/docs")
407	if strings.Contains(sub, `<div class="facts">`) {
408		t.Error("subdirectory listing still shows the clone/about facts block")
409	}
410	if !strings.Contains(sub, `class="overview"`) || strings.Contains(sub, `class="overview withfacts"`) {
411		t.Error("subdirectory listing should have a plain overview, not withfacts")
412	}
413}