e2e/mrweb_test.go

v1.26.0
gitbay/e2e/mrweb_test.go history · blame · raw

455 lines · 19215 bytes

  1package e2e
  2
  3import (
  4	"encoding/json"
  5	"net/http"
  6	"net/url"
  7	"os"
  8	"path/filepath"
  9	"regexp"
 10	"strconv"
 11	"strings"
 12	"testing"
 13)
 14
 15// login returns a browser holding a session for the given key's account.
 16func (i *instance) login(t *testing.T, key string) *http.Client {
 17	t.Helper()
 18	out, errOut, code := i.ssh(t, key, "", "web", "login", "--json")
 19	if code != 0 {
 20		t.Fatalf("web login: %s", errOut)
 21	}
 22	var env struct {
 23		Data struct {
 24			URL string `json:"url"`
 25		} `json:"data"`
 26	}
 27	json.Unmarshal([]byte(out), &env)
 28	c := newBrowser(t)
 29	path := env.Data.URL[strings.Index(env.Data.URL, "/login"):]
 30	if status, _ := browserGet(t, c, i.base()+path); status != 200 {
 31		t.Fatalf("login landed: %d", status)
 32	}
 33	return c
 34}
 35
 36// TestMRWebReviewLoop drives review, thread resolution, and merge from the
 37// browser. Every action runs the same control command the CLI runs, so the
 38// test also proves the merge gates apply to web merges.
 39func TestMRWebReviewLoop(t *testing.T) {
 40	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
 41	aliceKey := inst.newKey(t, "alice")
 42	bobKey := inst.newKey(t, "bob")
 43	inst.admin(t, "admin", "user", "create", "alice",
 44		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
 45	inst.admin(t, "admin", "user", "create", "bob",
 46		"--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
 47
 48	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
 49		t.Fatalf("repo create: %s", errOut)
 50	}
 51	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/lib", "bob", "write"); code != 0 {
 52		t.Fatalf("grant: %s", errOut)
 53	}
 54	// Unresolved review threads block merges, so the gate is observable.
 55	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-resolved", "alice/lib", "on"); code != 0 {
 56		t.Fatalf("require-resolved: %s", errOut)
 57	}
 58
 59	env := inst.gitEnv(aliceKey)
 60	work := t.TempDir()
 61	mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
 62	dir := filepath.Join(work, "w")
 63	os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("v1\n"), 0o644)
 64	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 65	mustGit(t, dir, env, "add", ".")
 66	mustGit(t, dir, env, "commit", "-q", "-m", "base")
 67	mustGit(t, dir, env, "push", "-q", "origin", "main")
 68
 69	// Bob proposes a change and leaves a review thread on it.
 70	bobEnv := inst.gitEnv(bobKey)
 71	bobWork := t.TempDir()
 72	mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/lib"), "w")
 73	bobDir := filepath.Join(bobWork, "w")
 74	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feature", "origin/main")
 75	os.WriteFile(filepath.Join(bobDir, "feature.txt"), []byte("bob's work\n"), 0o644)
 76	mustGit(t, bobDir, bobEnv, "add", ".")
 77	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "add feature")
 78	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feature")
 79	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
 80		"--source", "feature", "--target", "main", "--title", "'add feature'"); code != 0 {
 81		t.Fatalf("mr create: %s", errOut)
 82	}
 83	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "diff-comment", "alice/lib", "1",
 84		"--path", "feature.txt", "--line", "1", "--message", "'is this right?'"); code != 0 {
 85		t.Fatalf("diff-comment: %s", errOut)
 86	}
 87
 88	mrURL := inst.base() + "/alice/lib/mrs/1"
 89	alice := inst.login(t, aliceKey)
 90
 91	// The controls are on the page, and carry the thread to resolve.
 92	_, body := browserGet(t, alice, mrURL)
 93	for _, want := range []string{`value="approve"`, `action="/alice/lib/mrs/1/merge"`} {
 94		if !strings.Contains(body, want) {
 95			t.Fatalf("MR page missing %q", want)
 96		}
 97	}
 98	// Review threads live on the diff view, where their lines are.
 99	_, diffBody := browserGet(t, alice, mrURL+"?view=diff")
100	m := regexp.MustCompile(`name="thread" value="(\d+)"`).FindStringSubmatch(diffBody)
101	if m == nil {
102		t.Fatalf("no thread control on the diff view:\n%s", diffBody)
103	}
104	threadID := m[1]
105
106	// Approve from the browser; the CLI sees the review.
107	if status, _ := browserPost(t, alice, mrURL+"/review", url.Values{"verdict": {"approve"}}); status != 200 {
108		t.Fatalf("review post: %d", status)
109	}
110	show := inst.mrShow(t, aliceKey, "alice/lib", "1")
111	if len(show.Reviews) != 1 || show.Reviews[0].Reviewer != "alice" || show.Reviews[0].Verdict != "approve" {
112		t.Fatalf("review not recorded: %+v", show.Reviews)
113	}
114
115	// Merging is refused while the thread is open, and the page says why.
116	_, body = browserPost(t, alice, mrURL+"/merge", url.Values{"strategy": {"auto"}})
117	if !strings.Contains(body, "unresolved") {
118		t.Fatalf("merge gate not surfaced:\n%s", body)
119	}
120	if st := inst.mrShow(t, aliceKey, "alice/lib", "1").State; st != "open" {
121		t.Fatalf("blocked merge changed state to %s", st)
122	}
123
124	// Resolve the thread, then merge.
125	if status, _ := browserPost(t, alice, mrURL+"/thread",
126		url.Values{"thread": {threadID}, "action": {"resolve"}}); status != 200 {
127		t.Fatalf("resolve post: %d", status)
128	}
129	out, _, _ := inst.ssh(t, aliceKey, "", "mr", "threads", "alice/lib", "1")
130	if !strings.Contains(out, "resolved") {
131		t.Fatalf("thread not resolved:\n%s", out)
132	}
133	if status, _ := browserPost(t, alice, mrURL+"/merge", url.Values{"strategy": {"auto"}}); status != 200 {
134		t.Fatalf("merge post: %d", status)
135	}
136	merged := inst.mrShow(t, aliceKey, "alice/lib", "1")
137	if merged.State != "merged" {
138		t.Fatalf("MR state after web merge: %s", merged.State)
139	}
140	// Who merged it and when, so the page can stop saying alice wants to.
141	if merged.MergedBy != "alice" || merged.MergedAt == "" {
142		t.Fatalf("merge not attributed: %+v", merged)
143	}
144	if merged.Reviews[0].CreatedAt == "" {
145		t.Fatalf("review carries no timestamp: %+v", merged.Reviews[0])
146	}
147	_, body = browserGet(t, alice, mrURL)
148	if strings.Contains(body, "opened by") {
149		t.Fatalf("merged MR still says it was opened:\n%s", body)
150	}
151	if !strings.Contains(body, "merged by <a href=\"/alice\">alice</a>") {
152		t.Fatalf("merged MR does not name the merger:\n%s", body)
153	}
154	mustGit(t, dir, env, "pull", "-q", "origin", "main")
155	if _, err := os.Stat(filepath.Join(dir, "feature.txt")); err != nil {
156		t.Fatal("merged content missing from main")
157	}
158	// A merged MR shows its merged head, and marks a source branch that
159	// no longer exists.
160	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "--delete", "feature")
161	_, body = browserGet(t, alice, mrURL)
162	if !strings.Contains(body, "merged at") || !strings.Contains(body, "branch deleted") {
163		t.Fatalf("merged MR sidebar after the branch was deleted:\n%s", body)
164	}
165
166	// Readers get no controls, and a forged POST is refused by the command.
167	_, anon := browserGet(t, newBrowser(t), mrURL)
168	if strings.Contains(anon, `value="approve"`) {
169		t.Fatal("anonymous visitor sees review controls")
170	}
171	carol := inst.newKey(t, "carol")
172	inst.admin(t, "admin", "user", "create", "carol", "--key", carol+".pub")
173	if _, errOut, code := inst.ssh(t, carol, "", "repo", "create", "carol/own"); code != 0 {
174		t.Fatalf("carol repo: %s", errOut)
175	}
176	_, denied := browserPost(t, inst.login(t, carol), mrURL+"/close", url.Values{})
177	if !strings.Contains(denied, `class="error"`) || !strings.Contains(denied, "write access") {
178		t.Fatalf("reader was not refused:\n%s", denied)
179	}
180}
181
182// TestMRWebCreate opens a merge request from the browser and checks the
183// form survives a refusal with the draft intact.
184func TestMRWebCreate(t *testing.T) {
185	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
186	aliceKey := inst.newKey(t, "alice")
187	inst.admin(t, "admin", "user", "create", "alice",
188		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
189	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
190		t.Fatalf("repo create: %s", errOut)
191	}
192	env := inst.gitEnv(aliceKey)
193	work := t.TempDir()
194	mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
195	dir := filepath.Join(work, "w")
196	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
197	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
198	mustGit(t, dir, env, "add", ".")
199	mustGit(t, dir, env, "commit", "-q", "-m", "base")
200	mustGit(t, dir, env, "push", "-q", "origin", "main")
201	mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
202	os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
203	mustGit(t, dir, env, "add", ".")
204	mustGit(t, dir, env, "commit", "-q", "-m", "topic work")
205	mustGit(t, dir, env, "push", "-q", "origin", "topic")
206
207	alice := inst.login(t, aliceKey)
208	base := inst.base() + "/alice/lib"
209
210	// The list links to the form, and the form offers the pushed branches.
211	if _, body := browserGet(t, alice, base+"/mrs"); !strings.Contains(body, "/alice/lib/mrs/new") {
212		t.Fatalf("no create link on the list:\n%s", body)
213	}
214	_, form := browserGet(t, alice, base+"/mrs/new")
215	for _, want := range []string{`name="source"`, `value="topic"`, `value="main"`} {
216		if !strings.Contains(form, want) {
217			t.Fatalf("form missing %q:\n%s", want, form)
218		}
219	}
220
221	// A refusal keeps the draft: the branch does not exist.
222	_, retry := browserPost(t, alice, base+"/mrs/new", url.Values{
223		"source": {"nope"}, "target": {"main"}, "title": {"my title"}, "body": {"my body"}})
224	if !strings.Contains(retry, `class="error"`) || !strings.Contains(retry, "my title") ||
225		!strings.Contains(retry, "my body") {
226		t.Fatalf("refusal lost the draft:\n%s", retry)
227	}
228
229	// A real one lands on the merge request it created.
230	status, created := browserPost(t, alice, base+"/mrs/new", url.Values{
231		"source": {"topic"}, "target": {"main"}, "title": {"topic into main"}, "body": {"please review"}})
232	if status != 200 || !strings.Contains(created, "topic into main") {
233		t.Fatalf("create failed: %d\n%s", status, created)
234	}
235	show := inst.mrShow(t, aliceKey, "alice/lib", "1")
236	if show.State != "open" || show.Source != "topic" {
237		t.Fatalf("created MR wrong: %+v", show)
238	}
239}
240
241// TestMRWebDiffThreads opens a review thread on a diff line and replies to
242// it from the browser. The CLI's view of the threads afterwards is what
243// proves the page dispatched mr diff-comment rather than writing its own
244// rows.
245func TestMRWebDiffThreads(t *testing.T) {
246	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
247	aliceKey := inst.newKey(t, "alice")
248	inst.admin(t, "admin", "user", "create", "alice",
249		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
250
251	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
252		t.Fatalf("repo create: %s", errOut)
253	}
254	env := inst.gitEnv(aliceKey)
255	work := t.TempDir()
256	mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
257	dir := filepath.Join(work, "w")
258	os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {\n}\n"), 0o644)
259	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
260	mustGit(t, dir, env, "add", ".")
261	mustGit(t, dir, env, "commit", "-q", "-m", "base")
262	mustGit(t, dir, env, "push", "-q", "origin", "main")
263	mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
264	os.WriteFile(filepath.Join(dir, "main.go"),
265		[]byte("package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n"), 0o644)
266	mustGit(t, dir, env, "add", ".")
267	mustGit(t, dir, env, "commit", "-q", "-m", "add greeting")
268	mustGit(t, dir, env, "push", "-q", "origin", "feat")
269	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/lib",
270		"--source", "feat", "--target", "main", "--title", "'greeting'"); code != 0 {
271		t.Fatalf("mr create: %s", errOut)
272	}
273
274	mrURL := inst.base() + "/alice/lib/mrs/1"
275	alice := inst.login(t, aliceKey)
276
277	// The gutter carries the handle, and following it renders the form
278	// anchored to that line. There is no JavaScript, so the anchor has to
279	// survive a round trip in the query.
280	_, body := browserGet(t, alice, mrURL+"?view=diff")
281	if !strings.Contains(body, "cpath=main.go&amp;cline=6&amp;cside=new") {
282		t.Fatalf("no comment handle in the diff gutter:\n%s", body)
283	}
284	_, body = browserGet(t, alice, mrURL+"?view=diff&cpath=main.go&cline=6&cside=new")
285	if !strings.Contains(body, `id="compose"`) || !strings.Contains(body, `name="line" value="6"`) {
286		t.Fatalf("compose form not rendered:\n%s", body)
287	}
288
289	if status, _ := browserPost(t, alice, mrURL+"/diff-comment", url.Values{
290		"path": {"main.go"}, "line": {"6"}, "side": {"new"},
291		"body": {"use log instead of fmt"}}); status != 200 {
292		t.Fatalf("open thread: %d", status)
293	}
294	threads := inst.mrThreads(t, aliceKey, "alice/lib", "1")
295	if len(threads) != 1 || threads[0].Path != "main.go" || threads[0].Line != 6 {
296		t.Fatalf("thread not anchored: %+v", threads)
297	}
298	if len(threads[0].Comments) != 1 || threads[0].Comments[0].Body != "use log instead of fmt" {
299		t.Fatalf("comment body not stored: %+v", threads[0].Comments)
300	}
301
302	// The rendered thread offers reply and resolve to its author.
303	_, body = browserGet(t, alice, mrURL+"?view=diff")
304	if !strings.Contains(body, `name="reply" value="`+strconv.FormatInt(threads[0].ID, 10)+`"`) {
305		t.Fatalf("no reply form on the thread:\n%s", body)
306	}
307	if !strings.Contains(body, `value="resolve"`) {
308		t.Fatalf("no resolve control on the thread:\n%s", body)
309	}
310
311	if status, _ := browserPost(t, alice, mrURL+"/diff-comment", url.Values{
312		"reply": {strconv.FormatInt(threads[0].ID, 10)}, "body": {"agreed, switching"}}); status != 200 {
313		t.Fatalf("reply: %d", status)
314	}
315	threads = inst.mrThreads(t, aliceKey, "alice/lib", "1")
316	if len(threads) != 1 || len(threads[0].Comments) != 2 ||
317		threads[0].Comments[1].Body != "agreed, switching" {
318		t.Fatalf("reply not on the thread: %+v", threads)
319	}
320
321	// An empty body is refused, and says so on the page it returns to.
322	_, body = browserPost(t, alice, mrURL+"/diff-comment", url.Values{
323		"reply": {strconv.FormatInt(threads[0].ID, 10)}, "body": {"  "}})
324	if !strings.Contains(body, "empty comment") {
325		t.Fatalf("empty reply not refused:\n%s", body)
326	}
327}
328
329// mrThread is one review thread as mr threads --json reports it.
330type mrThread struct {
331	ID       int64  `json:"id"`
332	Path     string `json:"path"`
333	Side     string `json:"side"`
334	Line     int64  `json:"line"`
335	Comments []struct {
336		Author string `json:"author"`
337		Body   string `json:"body"`
338	} `json:"comments"`
339}
340
341// mrThreads reads the review threads on a merge request over SSH.
342func (i *instance) mrThreads(t *testing.T, key, repo, n string) []mrThread {
343	t.Helper()
344	out, errOut, code := i.ssh(t, key, "", "mr", "threads", repo, n, "--json")
345	if code != 0 {
346		t.Fatalf("mr threads: %s", errOut)
347	}
348	var env struct {
349		Data []mrThread `json:"data"`
350	}
351	if err := json.Unmarshal([]byte(out), &env); err != nil {
352		t.Fatalf("mr threads json: %v", err)
353	}
354	return env.Data
355}
356
357// TestMRDiffEmptyExplained: a merge request whose head was fast-forwarded
358// into the target outside the request shows why its diff is empty.
359func TestMRDiffEmptyExplained(t *testing.T) {
360	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
361	key := inst.newKey(t, "alice")
362	inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", "--email", "alice@example.test", "--verified")
363	if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 {
364		t.Fatalf("repo create: %s", errOut)
365	}
366	env := inst.gitEnv(key)
367	work := t.TempDir()
368	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
369	dir := filepath.Join(work, "w")
370	os.WriteFile(filepath.Join(dir, "README"), []byte("base\n"), 0o644)
371	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
372	mustGit(t, dir, env, "add", ".")
373	mustGit(t, dir, env, "commit", "-q", "-m", "base")
374	mustGit(t, dir, env, "push", "-q", "origin", "main")
375
376	mustGit(t, dir, env, "checkout", "-q", "-b", "feature")
377	os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\n"), 0o644)
378	mustGit(t, dir, env, "add", ".")
379	mustGit(t, dir, env, "commit", "-q", "-m", "one")
380	mustGit(t, dir, env, "push", "-q", "origin", "feature")
381	if _, errOut, code := inst.ssh(t, key, "", "mr", "create", "alice/app", "--source", "feature", "--target", "main", "--title", "one"); code != 0 {
382		t.Fatal(errOut)
383	}
384	mustGit(t, dir, env, "push", "-q", "origin", "feature:main")
385	_, body := inst.get(t, "/alice/app/mrs/1?view=diff")
386	if !strings.Contains(body, "No changes between the source and target.") ||
387		!strings.Contains(body, "already merged or fast-forwarded into <code>main</code>") {
388		t.Fatalf("empty diff unexplained:\n%s", body)
389	}
390}
391
392// TestMRSupersedes closes one merge request in favour of another from the
393// web form, and checks both pages say so; clearing it over ssh removes
394// both lines again (#223).
395func TestMRSupersedes(t *testing.T) {
396	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
397	aliceKey := inst.newKey(t, "alice")
398	inst.admin(t, "admin", "user", "create", "alice",
399		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
400	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
401		t.Fatalf("repo create: %s", errOut)
402	}
403	env := inst.gitEnv(aliceKey)
404	work := t.TempDir()
405	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
406	dir := filepath.Join(work, "w")
407	os.WriteFile(filepath.Join(dir, "README"), []byte("base\n"), 0o644)
408	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
409	mustGit(t, dir, env, "add", ".")
410	mustGit(t, dir, env, "commit", "-q", "-m", "base")
411	mustGit(t, dir, env, "push", "-q", "origin", "main")
412
413	for _, branch := range []string{"one", "two"} {
414		mustGit(t, dir, env, "checkout", "-q", "main")
415		mustGit(t, dir, env, "checkout", "-q", "-b", branch)
416		os.WriteFile(filepath.Join(dir, branch+".txt"), []byte(branch+"\n"), 0o644)
417		mustGit(t, dir, env, "add", ".")
418		mustGit(t, dir, env, "commit", "-q", "-m", branch)
419		mustGit(t, dir, env, "push", "-q", "origin", branch)
420	}
421	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
422		"--source", "one", "--target", "main", "--title", "one"); code != 0 {
423		t.Fatalf("mr create one: %s", errOut)
424	}
425	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
426		"--source", "two", "--target", "main", "--title", "two"); code != 0 {
427		t.Fatalf("mr create two: %s", errOut)
428	}
429
430	alice := inst.login(t, aliceKey)
431	if status, body := browserPost(t, alice, inst.base()+"/alice/app/mrs/1/close", url.Values{"by": {"2"}}); status != 200 {
432		t.Fatalf("close post: %d\n%s", status, body)
433	}
434
435	_, body1 := browserGet(t, alice, inst.base()+"/alice/app/mrs/1")
436	if !strings.Contains(body1, `in favour of <a href="/alice/app/mrs/2">!2</a>`) {
437		t.Fatalf("!1 does not say it was superseded:\n%s", body1)
438	}
439	_, body2 := browserGet(t, alice, inst.base()+"/alice/app/mrs/2")
440	if !strings.Contains(body2, `supersedes <a href="/alice/app/mrs/1">!1</a>`) {
441		t.Fatalf("!2 does not say what it supersedes:\n%s", body2)
442	}
443
444	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "edit", "alice/app", "1", "--superseded-by", "none"); code != 0 {
445		t.Fatalf("mr edit --superseded-by none: %s", errOut)
446	}
447	_, body1 = browserGet(t, alice, inst.base()+"/alice/app/mrs/1")
448	if strings.Contains(body1, "in favour of") {
449		t.Fatalf("!1 still says it was superseded after clearing:\n%s", body1)
450	}
451	_, body2 = browserGet(t, alice, inst.base()+"/alice/app/mrs/2")
452	if strings.Contains(body2, "supersedes") {
453		t.Fatalf("!2 still says it supersedes after clearing:\n%s", body2)
454	}
455}