e2e/dashboard_test.go

v1.24.1
gitbay/e2e/dashboard_test.go history · blame · raw

411 lines · 16664 bytes

  1package e2e
  2
  3import (
  4	"encoding/json"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8	"testing"
  9)
 10
 11func TestDashboard(t *testing.T) {
 12	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
 13	aliceKey := inst.newKey(t, "alice")
 14	bobKey := inst.newKey(t, "bob")
 15	inst.admin(t, "admin", "user", "create", "alice",
 16		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
 17	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
 18
 19	// Alice's repo with an open issue and an open MR authored by bob.
 20	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
 21		t.Fatalf("repo create: %s", errOut)
 22	}
 23	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
 24		t.Fatal("grant failed")
 25	}
 26	work := t.TempDir()
 27	env := inst.gitEnv(aliceKey)
 28	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
 29	dir := filepath.Join(work, "w")
 30	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
 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	mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
 36	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\nb\n"), 0o644)
 37	mustGit(t, dir, env, "add", ".")
 38	mustGit(t, dir, env, "commit", "-q", "-m", "feat")
 39	mustGit(t, dir, env, "push", "-q", "origin", "feat")
 40	if _, _, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
 41		"--source", "feat", "--target", "main", "--title", "'from bob'"); code != 0 {
 42		t.Fatal("mr create failed")
 43	}
 44	if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'todo one'"); code != 0 {
 45		t.Fatal("issue create failed")
 46	}
 47
 48	// Pins: read access required; unpinning something never pinned 404s.
 49	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 {
 50		t.Fatal("pin failed")
 51	}
 52	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "unpin", "alice/app"); code != 0 {
 53		t.Fatal("unpin failed")
 54	}
 55	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "unpin", "alice/app"); code != 3 {
 56		t.Fatal("double unpin should be not-found")
 57	}
 58	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 {
 59		t.Fatal("re-pin failed")
 60	}
 61
 62	// Anonymous homepage: landing with explore link, repos on /explore.
 63	status, body := inst.get(t, "/")
 64	if status != 200 || !strings.Contains(body, `href="/explore"`) || !strings.Contains(body, "A git forge you drive from the terminal") {
 65		t.Fatalf("landing: %d", status)
 66	}
 67	if strings.Contains(body, "alice/app") {
 68		t.Fatal("landing lists repositories")
 69	}
 70	_, body = inst.get(t, "/explore")
 71	if !strings.Contains(body, "alice/app") {
 72		t.Fatal("explore missing public repo")
 73	}
 74
 75	// Logged-in homepage: dashboard with pinned repo and open items.
 76	out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
 77	if code != 0 {
 78		t.Fatalf("web login: %s", errOut)
 79	}
 80	var env2 struct {
 81		Data struct {
 82			URL string `json:"url"`
 83		} `json:"data"`
 84	}
 85	json.Unmarshal([]byte(out), &env2)
 86	loginPath := env2.Data.URL[strings.Index(env2.Data.URL, "/login"):]
 87	browser := newBrowser(t)
 88	if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
 89		t.Fatalf("login: %d", status)
 90	}
 91	status, body = browserGet(t, browser, inst.base()+"/")
 92	if status != 200 || !strings.Contains(body, ">Dashboard</h1>") {
 93		t.Fatalf("dashboard: %d", status)
 94	}
 95	for _, want := range []string{
 96		`aria-labelledby="rail-pinned"`, `</span>app</a>`, // rail lists the pinned repo
 97		"Waiting on your review", "Assigned to you", "Recent activity",
 98		"from bob", "alice/app!1", "todo one", "alice/app#1",
 99		`href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
100		"Yours anywhere, and every one in a repository you can write to.",
101	} {
102		if !strings.Contains(body, want) {
103			t.Errorf("dashboard missing %q", want)
104		}
105	}
106
107	// D01: alice has nothing assigned to her, so that section condenses to
108	// an empty heading, and populated sections (she has a review waiting)
109	// sort before it.
110	emptyHeading := `<h2 class="empty">Assigned to you <span class="count">0</span></h2>`
111	if !strings.Contains(body, emptyHeading) {
112		t.Fatalf("dashboard missing condensed empty heading %q:\n%s", emptyHeading, body)
113	}
114	reviewIdx := strings.Index(body, "Waiting on your review")
115	assignedIdx := strings.Index(body, emptyHeading)
116	if reviewIdx < 0 || assignedIdx < 0 || reviewIdx > assignedIdx {
117		t.Fatalf("populated heading should come before the empty one: review=%d assigned=%d", reviewIdx, assignedIdx)
118	}
119
120	// The diff has its own view rather than a fold at the foot of the
121	// conversation: the default view offers it, and asking for it renders
122	// the stat line and the patch.
123	_, body = inst.get(t, "/alice/app/mrs/1")
124	if !strings.Contains(body, `href="/alice/app/mrs/1?view=diff"`) {
125		t.Fatal("merge request missing the files-changed view")
126	}
127	if strings.Contains(body, `class="difftable"`) {
128		t.Fatal("diff rendered on the conversation view")
129	}
130	_, body = inst.get(t, "/alice/app/mrs/1?view=diff")
131	if !strings.Contains(body, "1 file changed") || !strings.Contains(body, `class="difftable"`) {
132		t.Fatalf("diff view missing stat or patch:\n%s", body)
133	}
134}
135
136// The dashboard control command returns the same aggregate as the web
137// dashboard — review queue, assigned/open work, pins, and activity — in
138// one read. Builds remain for clients that already consume them.
139func TestDashboardCommand(t *testing.T) {
140	inst := startInstance(t)
141	aliceKey := inst.newKey(t, "alice")
142	bobKey := inst.newKey(t, "bob")
143	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
144	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
145
146	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
147		t.Fatalf("repo create: %s", errOut)
148	}
149	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
150		t.Fatal("grant failed")
151	}
152	work := t.TempDir()
153	env := inst.gitEnv(aliceKey)
154	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
155	dir := filepath.Join(work, "w")
156	os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
157	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
158		"jobs:\n  test:\n    steps:\n      - echo ok\n"), 0o644)
159	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
160	mustGit(t, dir, env, "add", ".")
161	mustGit(t, dir, env, "commit", "-q", "-m", "base")
162	mustGit(t, dir, env, "push", "-q", "origin", "main")
163	mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
164	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
165	mustGit(t, dir, env, "add", ".")
166	mustGit(t, dir, env, "commit", "-q", "-m", "feat")
167	mustGit(t, dir, env, "push", "-q", "origin", "feat")
168
169	if _, _, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
170		"--source", "feat", "--target", "main", "--title", "'from bob'"); code != 0 {
171		t.Fatal("mr create failed")
172	}
173	if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'todo one'"); code != 0 {
174		t.Fatal("issue create failed")
175	}
176	if _, _, code := inst.ssh(t, aliceKey, "", "issue", "assign", "alice/app", "1", "--add", "alice"); code != 0 {
177		t.Fatal("assign failed")
178	}
179	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 {
180		t.Fatal("pin failed")
181	}
182	if _, _, code := inst.ssh(t, bobKey, "", "repo", "pin", "alice/app"); code != 0 {
183		t.Fatal("bob pin failed")
184	}
185
186	out, errOut, code := inst.ssh(t, aliceKey, "", "dashboard", "--json")
187	if code != 0 {
188		t.Fatalf("dashboard: %s", errOut)
189	}
190	var env2 struct {
191		Data struct {
192			Reviews []struct {
193				Repo   string `json:"repo"`
194				Number int64  `json:"number"`
195				Title  string `json:"title"`
196			} `json:"review_queue"`
197			Pinned []struct {
198				Path string `json:"path"`
199			} `json:"pinned"`
200			MRs []struct {
201				Repo   string `json:"repo"`
202				Number int64  `json:"number"`
203				Title  string `json:"title"`
204				Author string `json:"author"`
205				State  string `json:"state"`
206			} `json:"open_mrs"`
207			Assigned []struct {
208				Repo   string `json:"repo"`
209				Number int64  `json:"number"`
210				Title  string `json:"title"`
211			} `json:"assigned_issues"`
212			Issues []struct {
213				Repo   string `json:"repo"`
214				Number int64  `json:"number"`
215				Title  string `json:"title"`
216			} `json:"open_issues"`
217			Activity []struct {
218				Repo  string `json:"repo"`
219				Actor string `json:"actor"`
220				Kind  string `json:"kind"`
221			} `json:"recent_activity"`
222			Builds []struct {
223				Repo   string `json:"repo"`
224				Job    string `json:"job"`
225				Status string `json:"status"`
226				Ref    string `json:"ref"`
227			} `json:"builds"`
228		} `json:"data"`
229	}
230	if err := json.Unmarshal([]byte(out), &env2); err != nil {
231		t.Fatalf("bad json: %v\n%s", err, out)
232	}
233	d := env2.Data
234	if len(d.Pinned) != 1 || d.Pinned[0].Path != "alice/app" {
235		t.Fatalf("pinned = %+v", d.Pinned)
236	}
237	if len(d.MRs) != 1 || d.MRs[0].Repo != "alice/app" || d.MRs[0].Number != 1 ||
238		d.MRs[0].Title != "from bob" || d.MRs[0].Author != "bob" || d.MRs[0].State != "open" {
239		t.Fatalf("open_mrs = %+v", d.MRs)
240	}
241	if len(d.Reviews) != 1 || d.Reviews[0].Repo != "alice/app" || d.Reviews[0].Number != 1 ||
242		d.Reviews[0].Title != "from bob" {
243		t.Fatalf("review_queue = %+v", d.Reviews)
244	}
245	if len(d.Assigned) != 1 || d.Assigned[0].Repo != "alice/app" || d.Assigned[0].Number != 1 ||
246		d.Assigned[0].Title != "todo one" {
247		t.Fatalf("assigned_issues = %+v", d.Assigned)
248	}
249	if len(d.Issues) != 1 || d.Issues[0].Repo != "alice/app" || d.Issues[0].Number != 1 ||
250		d.Issues[0].Title != "todo one" {
251		t.Fatalf("open_issues = %+v", d.Issues)
252	}
253	if len(d.Activity) == 0 || d.Activity[0].Repo != "alice/app" {
254		t.Fatalf("recent_activity = %+v", d.Activity)
255	}
256	// Both pushes hit main and feat; each queues the ci.yml job.
257	if len(d.Builds) == 0 || d.Builds[0].Repo != "alice/app" || d.Builds[0].Job != "test" ||
258		d.Builds[0].Status != "pending" {
259		t.Fatalf("builds = %+v", d.Builds)
260	}
261
262	// Bob is not assigned and pinned repos he can no longer read disappear.
263	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "visibility", "alice/app", "private"); code != 0 {
264		t.Fatal("visibility failed")
265	}
266	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "revoke", "alice/app", "bob"); code != 0 {
267		t.Fatal("revoke failed")
268	}
269	out, _, code = inst.ssh(t, bobKey, "", "dashboard", "--json")
270	if code != 0 {
271		t.Fatal("bob dashboard failed")
272	}
273	if err := json.Unmarshal([]byte(out), &env2); err != nil {
274		t.Fatalf("bad json: %v\n%s", err, out)
275	}
276	if len(env2.Data.Pinned) != 0 {
277		t.Fatalf("bob still sees pinned = %+v", env2.Data.Pinned)
278	}
279	if len(env2.Data.Assigned) != 0 {
280		t.Fatalf("bob assigned = %+v", env2.Data.Assigned)
281	}
282	if len(env2.Data.Builds) != 0 {
283		t.Fatalf("bob builds = %+v", env2.Data.Builds)
284	}
285}
286
287// TestDashboardQueues covers the parts of the dashboard that answer "what
288// needs me": the review queue, assigned issues, and the activity feed.
289func TestDashboardQueues(t *testing.T) {
290	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
291	aliceKey := inst.newKey(t, "alice")
292	bobKey := inst.newKey(t, "bob")
293	inst.admin(t, "admin", "user", "create", "alice",
294		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
295	inst.admin(t, "admin", "user", "create", "bob",
296		"--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
297
298	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
299		t.Fatalf("repo create: %s", errOut)
300	}
301	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
302		t.Fatalf("grant: %s", errOut)
303	}
304	env := inst.gitEnv(aliceKey)
305	work := t.TempDir()
306	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
307	dir := filepath.Join(work, "w")
308	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
309	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
310	mustGit(t, dir, env, "add", ".")
311	mustGit(t, dir, env, "commit", "-q", "-m", "base")
312	mustGit(t, dir, env, "push", "-q", "origin", "main")
313
314	// Bob opens a merge request: it lands in alice's review queue.
315	bobEnv := inst.gitEnv(bobKey)
316	bobWork := t.TempDir()
317	mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/app"), "w")
318	bobDir := filepath.Join(bobWork, "w")
319	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "fix", "origin/main")
320	os.WriteFile(filepath.Join(bobDir, "b.txt"), []byte("b\n"), 0o644)
321	mustGit(t, bobDir, bobEnv, "add", ".")
322	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "the fix")
323	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "fix")
324	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
325		"--source", "fix", "--target", "main", "--title", "'needs a look'"); code != 0 {
326		t.Fatalf("mr create: %s", errOut)
327	}
328	// And an issue assigned to alice.
329	if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "create", "alice/app", "--title", "'please handle'"); code != 0 {
330		t.Fatalf("issue create: %s", errOut)
331	}
332	if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "assign", "alice/app", "1", "--add", "alice"); code != 0 {
333		t.Fatalf("assign: %s", errOut)
334	}
335
336	_, body := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
337	if !strings.Contains(body, "needs a look") {
338		t.Fatalf("review queue missing the MR:\n%s", body)
339	}
340	if !strings.Contains(body, "please handle") {
341		t.Fatalf("assigned issues missing the issue:\n%s", body)
342	}
343	// The feed reports what happened, phrased and linked.
344	for _, want := range []string{"opened merge request", "opened issue", `href="/alice/app/mrs/1"`} {
345		if !strings.Contains(body, want) {
346			t.Fatalf("feed missing %q:\n%s", want, body)
347		}
348	}
349
350	// Once alice reviews, the merge request leaves her queue.
351	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "review", "alice/app", "1", "--approve"); code != 0 {
352		t.Fatalf("review: %s", errOut)
353	}
354	_, after := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
355	if !strings.Contains(after, `<h2 class="empty">Waiting on your review <span class="count">0</span></h2>`) {
356		t.Fatalf("reviewed MR still waiting:\n%s", after)
357	}
358}
359
360// D04/D05: two jobs on one commit fold into a single feed line, marked
361// with the worse of the two outcomes, and shown with a relative time
362// carrying the exact UTC time in its title.
363func TestDashboardFeedFoldsBuildRun(t *testing.T) {
364	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
365	inst.runner = buildRunner(t)
366	aliceKey := inst.newKey(t, "alice")
367	inst.admin(t, "admin", "user", "create", "alice",
368		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
369	runnerKey := inst.newKey(t, "ci")
370	inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
371
372	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
373		t.Fatalf("repo create: %s", errOut)
374	}
375	work := t.TempDir()
376	env := inst.gitEnv(aliceKey)
377	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
378	dir := filepath.Join(work, "w")
379	os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
380	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
381		"jobs:\n  ok:\n    steps:\n      - echo fine\n  broken:\n    steps:\n      - \"false\"\n"), 0o644)
382	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
383	mustGit(t, dir, env, "add", ".")
384	mustGit(t, dir, env, "commit", "-q", "-m", "base")
385	mustGit(t, dir, env, "push", "-q", "origin", "main")
386	sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
387
388	// The runner processes both jobs ("broken" sorts first).
389	inst.runnerOnce(t, runnerKey)
390	inst.runnerOnce(t, runnerKey)
391
392	_, body := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
393	if !strings.Contains(body, "ran 2 jobs on") {
394		t.Fatalf("feed did not fold the two jobs into one run:\n%s", body)
395	}
396	if !strings.Contains(body, `class="dot bad"`) {
397		t.Fatalf("feed did not mark the run with the worse (failure) status:\n%s", body)
398	}
399	if !strings.Contains(body, sha[:10]) {
400		t.Fatalf("feed missing the short sha %q:\n%s", sha[:10], body)
401	}
402	// A build reported moments ago renders as "just now"; ago() only
403	// switches to "N ago" past a minute, so either form proves the
404	// relative-time rendering rather than the raw timestamp.
405	if !strings.Contains(body, ">just now<") && !strings.Contains(body, " ago<") {
406		t.Fatalf("feed missing a relative time:\n%s", body)
407	}
408	if !strings.Contains(body, "title=\"") || !strings.Contains(body, " UTC\"") {
409		t.Fatalf("feed missing the exact time in a title:\n%s", body)
410	}
411}