1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "bytes"
7 "strings"
8 "testing"
9
10 "bnema.dev/ermite/internal/domain"
11 "bnema.dev/ermite/internal/ports"
12
13 "github.com/a-h/templ"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16)
17
18func renderToString(t *testing.T, c templ.Component) string {
19 t.Helper()
20 var b bytes.Buffer
21 require.NoError(t, c.Render(t.Context(), &b))
22 return b.String()
23}
24
25// A live banner is the content of a status region that stays in the page: the
26// empty slot of the page and the fragment of the stream are the same element
27// (same id, role and marker), so that ermiteLive can replace the children and
28// leave the region where a screen reader watches it.
29func TestLiveSlotsAreStableStatusRegions(t *testing.T) {
30 t.Parallel()
31 for _, tt := range []struct {
32 name string
33 c templ.Component
34 id string
35 text string
36 }{
37 {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""},
38 {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""},
39 {"mergeability slot, as sent by the stream", MRMergeabilityFragment(MergeabilityView{State: "up_to_date", Label: "Ready to merge", FFOk: true, MergeOk: true}), "mr-mergeability", "Ready to merge"},
40 {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"},
41 {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"},
42 } {
43 t.Run(tt.name, func(t *testing.T) {
44 t.Parallel()
45 got := renderToString(t, tt.c)
46 assert.True(t, strings.HasPrefix(got, `<div id="`+tt.id+`" class="live-slot" role="status" data-slot>`), got)
47 if tt.text == "" {
48 assert.True(t, strings.HasSuffix(got, "data-slot></div>"), "an empty slot has no child: %s", got)
49 } else {
50 assert.Contains(t, got, tt.text)
51 }
52 assert.True(t, strings.HasSuffix(got, "</div>"), got)
53 })
54 }
55}
56
57// The header's refs button is a link to the refs page, which the script turns
58// into the dialog; the filters and the buttons that need scripts are marked.
59func TestShellDegradesWithoutScripts(t *testing.T) {
60 t.Parallel()
61 l := Layout{Repo: "demo", Ref: "main", Short: "abc1234", Assets: Assets{}}
62 got := renderToString(t, Header(l))
63 assert.Contains(t, got, `<a class="btn ref" href="/demo/refs" data-fire="refs"`)
64 assert.NotContains(t, got, "hx-on:click", "no handler is inlined in the header")
65 assert.Contains(t, got, `class="btn search" data-js`)
66 assert.NotContains(t, got, `data-fire="help"`, "the help button is in the footer only")
67 assert.Contains(t, renderToString(t, Footer(l)), `class="foot-keys" data-js data-fire="help"`)
68}
69
70// Data reaches the scripts through data-* attributes, never spliced into an
71// attribute that is evaluated.
72func TestDiffControlsPassDataThroughAttributes(t *testing.T) {
73 t.Parallel()
74 files := renderToString(t, FileList([]DiffFile{{ID: "f1'x", Path: "a.go", Status: "M"}}))
75 assert.NotContains(t, files, ":aria-current", "the file in view is marked by ermite.js")
76 assert.NotContains(t, files, "f1'x'", "no id is spliced into a script")
77
78 link := renderToString(t, viewLink(DiffView{Both: true, View: "split"}, "split", "Split", "d", "?view=split"))
79 assert.Contains(t, link, `data-fire="split"`)
80 assert.Contains(t, link, `this.dataset.view`)
81 assert.NotContains(t, link, "=== 'split'")
82}
83
84// The push signal is intentionally constant-sized: diff work belongs to the
85// subsequent windowed page request, not to each subscriber's event handler.
86func TestMRPushedHeadSignal(t *testing.T) {
87 t.Parallel()
88 l := liveStream{s: &Server{}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
89 for _, tt := range []struct {
90 ref string
91 want int
92 }{{"refs/heads/feature", 1}, {"refs/heads/main", 0}, {"refs/heads/other", 0}} {
93 fs, err := l.mrPushed(t.Context(), domain.PushEvent{Updates: []domain.RefUpdate{{Ref: tt.ref}}})
94 require.NoError(t, err)
95 require.Len(t, fs, tt.want)
96 if tt.want > 0 {
97 assert.Equal(t, "mr-head", fs[0].event)
98 assert.Equal(t, `<div id="mr-head"></div>`, fs[0].html)
99 assert.NotContains(t, fs[0].html, "the branch changed")
100 }
101 }
102}
103
104// Release lifecycle events invalidate the release pages without putting notes
105// or asset metadata in SSE payloads. Other views need no release fragments.
106func TestReleaseLiveUpdates(t *testing.T) {
107 for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108 we := ports.WatchEvent{Event: ev}
109 require.True(t, liveEvent(we), ev.EventKind())
110 for _, view := range []string{"releases", "release", "summary", "refs", "mr"} {
111 st := &liveStream{repo: "proj", view: view}
112 got, err := st.render(t.Context(), we)
113 require.NoError(t, err)
114 if view == "release" || view == "releases" {
115 assert.Equal(t, gap, got)
116 } else {
117 assert.Empty(t, got)
118 }
119 }
120 }
121}