v0.34.0 7c1886f

web_inbox_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bufio"
7	"context"
8	"net/http"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// The owner's inbox end to end: two repositories with open merge requests and
21// issues, the filter, the home button, the owner stream, and a 404 for
22// everyone who is not the signed-in owner.
23func TestWebInboxEndToEnd(t *testing.T) {
24	r := newMRRepo(t, domain.VisibilityPublic)
25	b := browser{r.harness}
26	token := r.signIn(t)
27
28	_, err := r.forge.Repos.Create(r.ctx, domain.Root(), createParams(r.harness, "other", domain.VisibilityPrivate))
29	require.NoError(t, err)
30	otherURL := r.urlAuth(adminToken, "/other.git")
31	r.mustGit(r.wt, "push", "-q", otherURL, "main")
32	r.mustGit(r.wt, "push", "-q", otherURL, "feat/search")
33
34	owner := domain.IssuesOwner
35	for _, n := range []string{"proj", "other"} {
36		_, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(n), ports.UpdateRepoParams{Issues: &owner})
37		require.NoError(t, err)
38	}
39
40	// Nothing is open yet.
41	empty := b.get("/-/inbox", as(token))
42	require.Equal(t, 200, empty.status, empty.body)
43	assert.Contains(t, empty.body, "no open merge requests.")
44	assert.Contains(t, empty.body, "no open issues.")
45
46	// A queued run is visible and its run-level events refresh the owner stream.
47	draft, err := r.forge.store.CI().CreateRun(r.ctx, r.name("proj"), ".github/workflows/build.yml", func(id, number int64) ports.CIRunDraft {
48		return ports.CIRunDraft{Run: domain.CIRun{Repo: r.name("proj"), ID: id, Number: number, Attempt: 1, WorkflowName: "Inbox build", Status: domain.CIQueued, Ref: "refs/heads/main", Event: "push", CreatedAt: time.Now()}}
49	})
50	require.NoError(t, err)
51	runs := b.get("/-/inbox", as(token))
52	assert.Contains(t, runs.body, "Inbox build")
53	assert.Contains(t, runs.body, "<strong>1</strong> running")
54	assert.Contains(t, runs.body, `data-inbox href="/-/inbox"`)
55
56	// The owner stream sees both repositories.
57	stream := ownerStream(t, r.harness, token)
58
59	r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
60	var runFragments strings.Builder
61	for range 6 {
62		runFragments.WriteString(nextFragment(t, stream).data)
63	}
64	assert.Contains(t, runFragments.String(), "Inbox build")
65	// Completion removes the row and updates the total in the same live fragments.
66	draft.Run.Status, draft.Run.Conclusion = domain.CICompleted, domain.CISuccess
67	_, err = r.forge.store.CI().Apply(r.ctx, ports.CIWrite{Runs: []domain.CIRun{draft.Run}})
68	require.NoError(t, err)
69	r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
70	runFragments.Reset()
71	for range 6 {
72		runFragments.WriteString(nextFragment(t, stream).data)
73	}
74	assert.NotContains(t, runFragments.String(), "Inbox build")
75	assert.Contains(t, runFragments.String(), "no runs in progress.")
76	assert.Contains(t, runFragments.String(), "<strong>0</strong> running")
77
78	root := domain.Root()
79	_, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
80	require.NoError(t, err)
81	ev := nextFragment(t, stream)
82	assert.Equal(t, "inbox", ev.name)
83	assert.Contains(t, ev.data, `id="inbox-counts"`)
84	for range 5 { // the other fragments of this event
85		nextFragment(t, stream)
86	}
87	_, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
88	require.NoError(t, err)
89	var seen strings.Builder
90	for range 6 { // the six fragments of this event
91		seen.WriteString(nextFragment(t, stream).data)
92	}
93	assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
94	_, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("other"), ports.CreateMergeRequestParams{Title: "other work", Source: "feat/search", Target: "main"})
95	require.NoError(t, err)
96	_, err = r.forge.Issues.Create(r.ctx, root, r.name("proj"), ports.CreateIssueParams{Title: "proj bug"})
97	require.NoError(t, err)
98
99	all := b.get("/-/inbox", as(token))
100	require.Equal(t, 200, all.status, all.body)
101	assert.Equal(t, "private, no-store", all.hdr.Get("Cache-Control"))
102	assert.Contains(t, all.body, "<strong>2</strong> merge requests · <strong>2</strong> issues")
103	for _, want := range []string{"add search", "other work", "search is slow", "proj bug", `href="/other/issues/`, `href="/proj/merge-requests/`} {
104		assert.Contains(t, all.body, want)
105	}
106
107	only := b.get("/-/inbox?repo=other", as(token))
108	require.Equal(t, 200, only.status)
109	assert.Contains(t, only.body, "<strong>2</strong> merge requests · <strong>2</strong> issues", "the counters ignore the filter")
110	assert.Contains(t, only.body, "other work")
111	assert.Contains(t, only.body, "search is slow")
112	assert.NotContains(t, only.body, "add search")
113	assert.NotContains(t, only.body, "proj bug")
114
115	// Issues off: that repository's issues leave the inbox, its merge requests stay.
116	off := domain.IssuesOff
117	_, err = r.forge.Repos.Update(r.ctx, root, r.name("other"), ports.UpdateRepoParams{Issues: &off})
118	require.NoError(t, err)
119	hidden := b.get("/-/inbox", as(token))
120	assert.Contains(t, hidden.body, "<strong>2</strong> merge requests · <strong>1</strong> issues")
121	assert.NotContains(t, hidden.body, "search is slow")
122
123	// The home button, for the owner only.
124	assert.Contains(t, b.get("/", as(token)).body, `href="/-/inbox"`)
125	anonHome := b.get("/", nil)
126	assert.NotContains(t, anonHome.body, "/-/inbox")
127	assert.NotContains(t, anonHome.body, "Inbox")
128
129	// Anyone else: the 404 of an unknown page, and no stream.
130	missing := b.get("/-/no-such-page", nil)
131	anon := b.get("/-/inbox", nil)
132	assert.Equal(t, 404, anon.status)
133	assert.Equal(t, missing.body, anon.body)
134	assert.Equal(t, 404, b.get("/-/inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status, "a token is not a session")
135	unknown := b.get("/-/events?topic=nope&view=inbox", nil)
136	ownerAnon := b.get("/-/events?topic=owner&view=inbox", nil)
137	assert.Equal(t, unknown.status, ownerAnon.status, "the answer to an unknown topic")
138	assert.Equal(t, strings.ReplaceAll(unknown.body, "nope", "owner"), ownerAnon.body)
139	assert.Equal(t, unknown.status, b.get("/-/events?topic=owner&view=inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status)
140}
141
142// nextFragment returns the next event that carries a fragment. Events the
143// inbox does not show (a mergeability check) still send their id alone, so a
144// resumed stream starts after them; they are skipped.
145func nextFragment(t *testing.T, s *sseStream) sseEvent {
146	t.Helper()
147	for {
148		if ev := s.next(t, watchWait); ev.name != "" {
149			return ev
150		}
151	}
152}
153
154// ownerStream opens the owner topic with the session cookie.
155func ownerStream(t *testing.T, h *harness, token string) *sseStream {
156	t.Helper()
157	ctx, cancel := context.WithCancel(h.ctx)
158	t.Cleanup(cancel)
159	req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?topic=owner&view=inbox"), nil)
160	require.NoError(t, err)
161	req.Header.Set("Cookie", sessionCookie+"="+token)
162	resp, err := http.DefaultClient.Do(req)
163	require.NoError(t, err)
164	t.Cleanup(func() { _ = resp.Body.Close() })
165	require.Equal(t, http.StatusOK, resp.StatusCode)
166	s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel}
167	go func() {
168		defer close(s.events)
169		sc := bufio.NewScanner(resp.Body)
170		sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
171		var cur sseEvent
172		var data []string
173		for sc.Scan() {
174			line := sc.Text()
175			switch {
176			case line == "":
177				if cur.id != "" || cur.name != "" || len(data) > 0 {
178					cur.data = strings.Join(data, "\n")
179					s.events <- cur
180				}
181				cur, data = sseEvent{}, nil
182			case strings.HasPrefix(line, ":"):
183				cur.comment = true
184			case strings.HasPrefix(line, "event: "):
185				cur.name = strings.TrimPrefix(line, "event: ")
186			case strings.HasPrefix(line, "id: "):
187				cur.id = strings.TrimPrefix(line, "id: ")
188			case strings.HasPrefix(line, "data: "):
189				data = append(data, strings.TrimPrefix(line, "data: "))
190			}
191		}
192	}()
193	return s
194}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close