e2e/design_test.go

b347d6c8c464e3c965455795f4e22e0aaeed0652
gitbay/e2e/design_test.go history · blame · raw

310 lines · 13932 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![logo](img/logo.png)\n"+
 30			"![ext](https://example.org/pic.png)\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 repo header renders the same on every tab of the repo. It sits
143	// above the tab bar, so anything that appears on one tab and not
144	// another moves the navigation between clicks of that navigation.
145	for _, page := range []string{"", "/issues", "/mrs", "/releases"} {
146		_, body := inst.get(t, "/alice/site"+page)
147		if !strings.Contains(body, `href="/explore?q=web"`) {
148			t.Errorf("repo header topics missing on /alice/site%s", page)
149		}
150	}
151}
152
153func TestWebInteractions(t *testing.T) {
154	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
155	aliceKey := inst.newKey(t, "alice")
156	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
157	if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "theorg"); code != 0 {
158		t.Fatal("org create failed")
159	}
160
161	out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
162	if code != 0 {
163		t.Fatal("web login failed")
164	}
165	var env2 struct {
166		Data struct {
167			URL string `json:"url"`
168		} `json:"data"`
169	}
170	json.Unmarshal([]byte(out), &env2)
171	browser := newBrowser(t)
172	browserGet(t, browser, inst.base()+env2.Data.URL[strings.Index(env2.Data.URL, "/login"):])
173
174	// Create a repo under the org through the web form.
175	if status, _ := browserPost(t, browser, inst.base()+"/new",
176		url.Values{"owner": {"theorg"}, "name": {"webborn"}, "visibility": {"public"}}); status != 200 {
177		t.Fatalf("org repo via web: %d", status)
178	}
179	if out, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "theorg/webborn"); code != 0 {
180		t.Fatalf("org repo missing: %s", out)
181	}
182
183	// Pin from the web; the repo header reflects it and the dashboard
184	// lists it; a second toggle unpins.
185	if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{}); status != 200 {
186		t.Fatal("pin toggle failed")
187	}
188	_, body := browserGet(t, browser, inst.base()+"/theorg/webborn")
189	if !strings.Contains(body, "</span> Pinned</button>") {
190		t.Fatal("repo header not pinned")
191	}
192	if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, ">theorg/</span>webborn") {
193		t.Fatal("dashboard missing pinned repo")
194	}
195	browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{})
196	if _, body = browserGet(t, browser, inst.base()+"/theorg/webborn"); !strings.Contains(body, "</span> Pin</button>") {
197		t.Fatal("unpin failed")
198	}
199
200	// New issue with labels through the form; label chips filter.
201	if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/issues/new",
202		url.Values{"title": {"styled"}, "body": {"b"}, "labels": {"bug ui"}}); status != 200 {
203		t.Fatal("issue via web failed")
204	}
205	if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/issues/new",
206		url.Values{"title": {"plain"}}); status != 200 {
207		t.Fatal("second issue failed")
208	}
209	_, body = browserGet(t, browser, inst.base()+"/theorg/webborn/issues?label=bug")
210	if !strings.Contains(body, "styled") || strings.Contains(body, ">plain<") {
211		t.Fatalf("label filter wrong:\n%s", body)
212	}
213	_, body = browserGet(t, browser, inst.base()+"/theorg/webborn/issues/1")
214	if !strings.Contains(body, `href="/theorg/webborn/issues?label=bug"`) ||
215		!strings.Contains(body, `href="/alice">alice</a>`) {
216		t.Fatal("issue page chips/author not linked")
217	}
218}
219
220func TestCommitParentLinks(t *testing.T) {
221	inst := startInstance(t)
222	aliceKey := inst.newKey(t, "alice")
223	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
224	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
225		t.Fatal("repo create failed")
226	}
227	work := t.TempDir()
228	env := inst.gitEnv(aliceKey)
229	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
230	dir := filepath.Join(work, "w")
231	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
232	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
233	mustGit(t, dir, env, "add", ".")
234	mustGit(t, dir, env, "commit", "-q", "-m", "first")
235	first := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
236	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("b\n"), 0o644)
237	mustGit(t, dir, env, "commit", "-qam", "second")
238	head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
239	mustGit(t, dir, env, "push", "-q", "origin", "main")
240
241	_, body := inst.get(t, "/alice/app/commit/"+head)
242	if !strings.Contains(body, `href="/alice/app/commit/`+first+`"`) {
243		t.Fatal("parent commit not linked")
244	}
245	_, body = inst.get(t, "/alice/app/commit/"+first)
246	if strings.Contains(body, ">parent") {
247		t.Fatal("root commit shows a parent")
248	}
249}
250
251// TestAuthorNamesResolve checks that a commit whose author address is
252// verified here displays the account's name rather than whatever git
253// config carried, and that an unknown address keeps its own name.
254func TestAuthorNamesResolve(t *testing.T) {
255	inst := startInstance(t)
256	aliceKey := inst.newKey(t, "alice")
257	inst.admin(t, "admin", "user", "create", "alice",
258		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
259	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
260		t.Fatalf("repo create: %s", errOut)
261	}
262	work := t.TempDir()
263	env := inst.gitEnv(aliceKey)
264	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
265	dir := filepath.Join(work, "w")
266
267	// One commit from the account's verified address under a different
268	// display name, one from an address nobody has proven.
269	known := append(append([]string{}, env...),
270		"GIT_AUTHOR_NAME=Alice Q. Longname", "GIT_AUTHOR_EMAIL=alice@example.test",
271		"GIT_COMMITTER_NAME=Alice Q. Longname", "GIT_COMMITTER_EMAIL=alice@example.test")
272	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
273	mustGit(t, dir, known, "checkout", "-q", "-b", "main")
274	mustGit(t, dir, known, "add", ".")
275	mustGit(t, dir, known, "commit", "-q", "-m", "from the account")
276	stranger := append(append([]string{}, env...),
277		"GIT_AUTHOR_NAME=Outside Person", "GIT_AUTHOR_EMAIL=outside@nowhere.test",
278		"GIT_COMMITTER_NAME=Outside Person", "GIT_COMMITTER_EMAIL=outside@nowhere.test")
279	os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
280	mustGit(t, dir, stranger, "add", ".")
281	mustGit(t, dir, stranger, "commit", "-q", "-m", "from a stranger")
282	// The tip is the account's, so the bar above the listing shows a link.
283	os.WriteFile(filepath.Join(dir, "c.txt"), []byte("c\n"), 0o644)
284	mustGit(t, dir, known, "add", ".")
285	mustGit(t, dir, known, "commit", "-q", "-m", "back to the account")
286	mustGit(t, dir, known, "push", "-q", "origin", "main")
287
288	// The log shows the account name for the verified address only.
289	_, body := inst.get(t, "/alice/app/log")
290	if strings.Contains(body, "Alice Q. Longname") {
291		t.Fatalf("log showed the git config name for a known address:\n%s", body)
292	}
293	if !strings.Contains(body, "Outside Person") {
294		t.Fatalf("log lost an unknown author's name:\n%s", body)
295	}
296	if !strings.Contains(body, "alice") {
297		t.Fatalf("log missing the account name:\n%s", body)
298	}
299	// A resolved name links to the profile; an unknown one stays text.
300	if !strings.Contains(body, `class="authorlink" href="/alice"`) {
301		t.Fatalf("account name is not a link:\n%s", body)
302	}
303	if strings.Contains(body, `href="/Outside Person"`) {
304		t.Fatalf("unknown author was linked:\n%s", body)
305	}
306	// The tipbar resolves and links the same way when the tip is an account's.
307	if _, tree := inst.get(t, "/alice/app"); !strings.Contains(tree, `class="authorlink" href="/alice"`) {
308		t.Fatalf("tipbar name is not a link:\n%s", tree)
309	}
310}