v0.34.0 7c1886f

mr_notes_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"bnema.dev/ermite/internal/ports/mocks"
7	"bytes"
8	"context"
9	"github.com/stretchr/testify/mock"
10	"net/http"
11	"net/http/httptest"
12	"net/url"
13	"strings"
14	"testing"
15	"time"
16
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19	"github.com/stretchr/testify/assert"
20	"github.com/stretchr/testify/require"
21)
22
23func TestParseOnRange(t *testing.T) {
24	t.Parallel()
25	for _, raw := range []string{"new:0-2:a", "new:2-2:a", "new:3-2:a", "new:1-x:a", "new:1-2:", "other:1-2:a"} {
26		assert.Nil(t, parseOn(raw), raw)
27	}
28	assert.Equal(t, &lineRef{side: "new", start: 10, line: 24, path: "dir:a.go"}, parseOn("new:10-24:dir:a.go"))
29	assert.Equal(t, &lineRef{side: "old", line: 10, path: "a"}, parseOn("old:10:a"))
30}
31
32func TestNoteThreads(t *testing.T) {
33	t.Parallel()
34	text, err := domain.ParsePlainText("**hello** ^2")
35	require.NoError(t, err)
36	s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
37	comments := []domain.Comment{
38		{ID: 1, Author: "alice", Text: text},
39		{ID: 2, Author: "bob", ReplyTo: 1, Text: text},
40		{ID: 3, Author: "alice", Deleted: true},
41		{ID: 4, Author: "alice", Deleted: true},
42		{ID: 5, Author: "bob", ReplyTo: 4, Text: text},
43		{ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
44	}
45	p := MRPage{NoteAuthor: "alice"}
46	s.placeComments(t.Context(), &p, "demo", 1, comments, "", true, true, nil)
47	require.Len(t, p.General, 3)
48	assert.Equal(t, 4, p.NComments)
49	assert.Equal(t, 1, p.NUnresolved)
50	require.Len(t, p.General[0].Replies, 1)
51	assert.NotNil(t, p.General[0].Edit)
52	assert.Nil(t, p.General[0].Replies[0].Edit)
53	assert.Nil(t, p.General[0].Replies[0].Resolve)
54	assert.Contains(t, string(p.General[0].Body), "<strong>hello</strong>")
55	assert.Contains(t, string(p.General[0].Body), `href="#c2"`)
56	assert.True(t, p.General[1].Deleted)
57	var b bytes.Buffer
58	require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
59	assert.Contains(t, b.String(), `name="reply_to" value="1"`)
60	assert.Contains(t, b.String(), `data-js data-quote=`)
61	assert.Contains(t, b.String(), `/comments/1/edit`)
62	assert.NotContains(t, b.String(), `/comments/2/edit`)
63}
64
65func TestNoteLiveEvents(t *testing.T) {
66	t.Parallel()
67	l := liveStream{view: "mr", n: "1"}
68	events := []domain.Event{domain.CommentEdited{Number: 1}, domain.CommentDeleted{Number: 1}, domain.CommentResolved{Number: 1}}
69	for _, ev := range events {
70		we := ports.WatchEvent{Event: ev}
71		assert.True(t, liveEvent(we))
72		fs, err := l.repoPage(context.Background(), we)
73		require.NoError(t, err)
74		require.Len(t, fs, 1)
75		assert.Contains(t, fs[0].html, "reload")
76	}
77}
78
79func TestNotePosts(t *testing.T) {
80	t.Parallel()
81	svc := mocks.NewMergeRequestService(t)
82	repo := repoName(t, "demo")
83	s := &Server{cfg: Config{MergeRequests: svc}}
84	ctx := domain.ContextWithPrincipal(context.Background(), domain.Owner())
85	request := func(values url.Values) *http.Request {
86		r := httptest.NewRequest("POST", "/demo/merge-requests/1/comments", strings.NewReader(values.Encode())).WithContext(ctx)
87		r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
88		r.SetPathValue("repo", "demo")
89		r.SetPathValue("n", "1")
90		r.SetPathValue("id", "2")
91		require.NoError(t, r.ParseForm())
92		return r
93	}
94	svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool { return p.ReplyTo == 2 && p.Text == "reply" })).Return(domain.Comment{ID: 7}, nil).Once()
95	to, _, err := s.mrComment(ctx, request(url.Values{"reply_to": {"2"}, "text": {"reply"}}))
96	require.NoError(t, err)
97	assert.Equal(t, "/demo/merge-requests/1#c7", to)
98	svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool {
99		return p.StartLine == 10 && p.Line == 24 && p.Path == "a.go" && p.Verdict == domain.VerdictApprove
100	})).Return(domain.Comment{ID: 8}, nil).Once()
101	to, _, err = s.mrComment(ctx, request(url.Values{"path": {"a.go"}, "line": {"24"}, "start_line": {"10"}, "side": {"new"}, "verdict": {"approve"}}))
102	require.NoError(t, err)
103	assert.Equal(t, "/demo/merge-requests/1#c8", to)
104	svc.EXPECT().EditComment(mock.Anything, mock.Anything, repo, int64(1), int64(2), "new\ntext").Return(domain.Comment{ID: 2}, nil).Once()
105	to, _, err = s.mrEditComment(ctx, request(url.Values{"text": {"new\r\ntext"}}))
106	require.NoError(t, err)
107	assert.Equal(t, "/demo/merge-requests/1#c2", to)
108	svc.EXPECT().DeleteComment(mock.Anything, mock.Anything, repo, int64(1), int64(2)).Return(domain.Comment{ID: 2}, nil).Once()
109	to, _, err = s.mrDeleteComment(ctx, request(nil))
110	require.NoError(t, err)
111	assert.Equal(t, "/demo/merge-requests/1#c2", to)
112}
113
114func TestDeletedNoteRendering(t *testing.T) {
115	t.Parallel()
116	text, err := domain.ParsePlainText("surviving reply")
117	require.NoError(t, err)
118	s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119	p := MRPage{}
120	s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{
121		{ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122		{ID: 2, Deleted: true},
123		{ID: 3, ReplyTo: 2, Deleted: true},
124		{ID: 4, Deleted: true},
125		{ID: 5, ReplyTo: 4, Text: text},
126	}, "", true, true, nil)
127	require.Len(t, p.General, 2)
128	assert.Equal(t, "1", p.General[0].ID)
129	assert.Equal(t, "4", p.General[1].ID)
130	var b bytes.Buffer
131	require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
132	assert.Contains(t, b.String(), `<span class="c-who">owner</span>`)
133	assert.Contains(t, b.String(), "approve")
134	assert.Contains(t, b.String(), "text deleted")
135	b.Reset()
136	require.NoError(t, commentView(p.General[1], "").Render(context.Background(), &b))
137	assert.Contains(t, b.String(), "surviving reply")
138	assert.NotContains(t, b.String(), "Quote")
139	assert.NotContains(t, b.String(), "data-quote")
140}
141
142func TestInlineNoteHeadingIDs(t *testing.T) {
143	t.Parallel()
144	// "S H" would get the id the split copy of "H" gets if the prefixes of the
145	// two copies overlapped.
146	text, err := domain.ParsePlainText("# H\n\n# S H\n\n[jump](#h)")
147	require.NoError(t, err)
148	s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149	p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150	s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151	notes := p.Files[0].Hunks[0].Lines[0].Notes
152	require.Len(t, notes, 1)
153	var b bytes.Buffer
154	require.NoError(t, commentView(notes[0], "").Render(context.Background(), &b))
155	require.NoError(t, commentView(notes[0], "-s").Render(context.Background(), &b))
156	html := b.String()
157	for _, id := range []string{"c12-h", "c12-s-h", "c12s-h", "c12s-s-h"} {
158		assert.Equal(t, 1, strings.Count(html, `id="`+id+`"`), id)
159	}
160	assert.Contains(t, html, `href="#c12-h"`)
161	assert.Contains(t, html, `href="#c12s-h"`)
162}

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