browser_test.go
⎇
Raw
1package e2e
2
3import (
4 "fmt"
5 "net/url"
6 "os"
7 "path/filepath"
8 "sort"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/go-rod/rod"
14 "github.com/go-rod/rod/lib/launcher"
15 "github.com/go-rod/rod/lib/proto"
16)
17
18// The browser suite covers what needs JavaScript or a real layout engine:
19// auto-submitting selects, details popups, the theme script, and the
20// responsive layout. Everything else runs without a browser.
21
22// chromePath finds a Chromium binary: HEARTHFORGE_BROWSER, then PATH, then
23// a Playwright download. It returns "" when none is installed.
24func chromePath() string {
25 if p := os.Getenv("HEARTHFORGE_BROWSER"); p != "" {
26 return p
27 }
28 if p, ok := launcher.LookPath(); ok {
29 return p
30 }
31 home, _ := os.UserHomeDir()
32 matches, _ := filepath.Glob(filepath.Join(home, ".cache", "ms-playwright", "chromium-*", "chrome-linux*", "chrome"))
33 sort.Strings(matches)
34 if len(matches) > 0 {
35 return matches[len(matches)-1]
36 }
37 return ""
38}
39
40// browser launches headless Chromium for one test.
41func browser(t *testing.T) *rod.Browser {
42 t.Helper()
43 bin := chromePath()
44 if bin == "" {
45 t.Skip("no Chromium found; set HEARTHFORGE_BROWSER")
46 }
47 l := launcher.New().Bin(bin).Headless(true).NoSandbox(true)
48 u, err := l.Launch()
49 if err != nil {
50 t.Skipf("launch %s: %v", bin, err)
51 }
52 b := rod.New().ControlURL(u)
53 if err := b.Connect(); err != nil {
54 t.Fatal(err)
55 }
56 t.Cleanup(func() {
57 b.MustClose()
58 l.Cleanup()
59 })
60 return b
61}
62
63// pageAs opens a page that carries the session's cookies, so the browser is
64// signed in as that user without going through the login form.
65func pageAs(t *testing.T, b *rod.Browser, s *session, width int) *rod.Page {
66 t.Helper()
67 p := b.MustIncognito().MustPage()
68 p.MustSetViewport(width, 900, 1, false)
69 if c := s.cookie("session"); c != nil {
70 p.MustSetCookies(&proto.NetworkCookieParam{Name: c.Name, Value: c.Value, URL: s.env.Base})
71 }
72 for _, name := range []string{"repo_sort", "theme"} {
73 if c := s.cookie(name); c != nil {
74 p.MustSetCookies(&proto.NetworkCookieParam{Name: c.Name, Value: c.Value, URL: s.env.Base})
75 }
76 }
77 return p
78}
79
80func goTo(p *rod.Page, base, path string) *rod.Page {
81 p.Timeout(15 * time.Second).MustNavigate(base + path).MustWaitLoad()
82 return p
83}
84
85func pathOf(p *rod.Page) string {
86 u, _ := url.Parse(p.MustInfo().URL)
87 return u.Path
88}
89
90func TestBrowser(t *testing.T) {
91 e := newEnv(t)
92 admin := e.admin()
93 e.createRepo(admin, "js-repo")
94 e.seedRepo("js-repo", nil)
95 admin.post("/js-repo/settings/labels", url.Values{"name": {"bug"}, "color": {"#d73a4a"}})
96 admin.post("/js-repo/settings/labels", url.Values{"name": {"docs"}, "color": {"#0075ca"}})
97 issue := admin.post("/js-repo/issues", url.Values{
98 "title": {"Picker issue"}, "body": {"Body **bold**."},
99 }).mustRedirect("/js-repo/issues/")
100 admin.post(issue+"/comments", url.Values{"body": {"A comment."}})
101 b := browser(t)
102
103 t.Run("sort select auto-submits and sets the cookie", func(t *testing.T) {
104 s := e.admin()
105 p := pageAs(t, b, s, 1280)
106 goTo(p, e.Base, "/")
107 wait := p.MustWaitNavigation()
108 p.MustElement(".repo-sort-select").MustSelect("Name")
109 wait()
110 p.MustWaitLoad()
111 if got := p.MustElement(".repo-sort-select").MustProperty("value").Str(); got != "name" {
112 t.Errorf("sort value after auto-submit = %q", got)
113 }
114 cookies := p.MustCookies()
115 found := false
116 for _, c := range cookies {
117 if c.Name == "repo_sort" && c.Value == "name" {
118 found = true
119 }
120 }
121 if !found {
122 t.Error("repo_sort cookie not set to name")
123 }
124 })
125
126 t.Run("Go button is a noscript fallback", func(t *testing.T) {
127 // Rod drives the page with injected JS, so it cannot run with scripts
128 // off. The no-JS path is covered by TestSorting posting /sort
129 // directly. Here: the fallback is in the HTML, and hidden with JS.
130 if !e.admin().get("/").Has(`form[action="/sort"] noscript`) {
131 t.Fatal("noscript Go button missing from the HTML")
132 }
133 p := pageAs(t, b, e.admin(), 1280)
134 goTo(p, e.Base, "/")
135 if p.MustHas(`form[action="/sort"] button[type=submit]`) {
136 t.Error("noscript Go button rendered with JS enabled")
137 }
138 })
139
140 t.Run("theme cookie applies data-theme before paint", func(t *testing.T) {
141 s := e.admin()
142 s.post("/settings/theme", url.Values{"theme": {"dark"}})
143 p := pageAs(t, b, s, 1280)
144 goTo(p, e.Base, "/")
145 if got := p.MustEval(`() => document.documentElement.getAttribute("data-theme")`).Str(); got != "dark" {
146 t.Errorf("data-theme = %q", got)
147 }
148 })
149
150 t.Run("reaction picker opens and posts a reaction", func(t *testing.T) {
151 p := pageAs(t, b, e.admin(), 1280)
152 goTo(p, e.Base, issue)
153 p.MustElement(".reaction-picker > summary").MustClick()
154 btn := p.MustElement(".reaction-picker-dropdown .reaction-picker-btn")
155 if !btn.MustVisible() {
156 t.Fatal("picker button not visible after opening")
157 }
158 wait := p.MustWaitNavigation()
159 btn.MustClick()
160 wait()
161 p.MustWaitLoad()
162 if !p.MustHas(".reaction-btn") {
163 t.Error("no reaction shown after clicking the picker")
164 }
165 })
166
167 t.Run("label filter popup applies the filter", func(t *testing.T) {
168 p := pageAs(t, b, e.admin(), 1280)
169 goTo(p, e.Base, "/js-repo/issues")
170 p.MustElement("details.label-filter > summary").MustClick()
171 p.MustElement(`.label-filter-popup input[type=checkbox]`).MustClick()
172 wait := p.MustWaitNavigation()
173 p.MustElement(`.label-filter-popup button[type=submit]`).MustClick()
174 wait()
175 p.MustWaitLoad()
176 u, _ := url.Parse(p.MustInfo().URL)
177 if u.Query().Get("labels") == "" {
178 t.Errorf("filter did not add labels param: %s", u)
179 }
180 })
181
182 t.Run("create-tag checkbox reveals the tag fields", func(t *testing.T) {
183 p := pageAs(t, b, e.admin(), 1280)
184 goTo(p, e.Base, "/js-repo/releases/new")
185 group := p.MustElement("#tag-fields-group")
186 if group.MustVisible() {
187 t.Fatal("tag fields visible before the checkbox is ticked")
188 }
189 p.MustElement("#create_tag").MustClick()
190 if !group.MustVisible() {
191 t.Error("tag fields hidden after the checkbox is ticked")
192 }
193 })
194
195 t.Run("login form works in a browser", func(t *testing.T) {
196 p := b.MustIncognito().MustPage()
197 goTo(p, e.Base, "/login")
198 p.MustElement("[name=username]").MustInput("admin")
199 p.MustElement("[name=password]").MustInput(adminPass)
200 wait := p.MustWaitNavigation()
201 p.MustElement("button[type=submit]").MustClick()
202 wait()
203 p.MustWaitLoad()
204 if pathOf(p) != "/" || !p.MustHas(".nav-user") {
205 t.Errorf("after login: path %s, nav-user %v", pathOf(p), p.MustHas(".nav-user"))
206 }
207 })
208
209 t.Run("no horizontal overflow at 360 and 1280", func(t *testing.T) {
210 sha := e.headCommit("js-repo")
211 pages := []string{
212 "/", "/js-repo", "/js-repo/issues", issue, "/js-repo/commits/main",
213 "/js-repo/commit/" + sha, "/js-repo/blob/main/index.js", "/js-repo/settings",
214 "/settings", "/login",
215 }
216 // 360, not 320: a classic 15px scrollbar plus the 320px body floor
217 // would always scroll at exactly 320.
218 for _, width := range []int{360, 1280} {
219 p := pageAs(t, b, e.admin(), width)
220 for _, path := range pages {
221 goTo(p, e.Base, path)
222 sw := p.MustEval(`() => document.documentElement.scrollWidth`).Int()
223 cw := p.MustEval(`() => document.documentElement.clientWidth`).Int()
224 if sw > cw {
225 t.Errorf("%s at %dpx: scrollWidth %d > clientWidth %d", path, width, sw, cw)
226 }
227 }
228 }
229 })
230
231 t.Run("raw svg document cannot read cookies", func(t *testing.T) {
232 p := pageAs(t, b, e.admin(), 1280)
233 goTo(p, e.Base, "/js-repo/raw/main/logo.svg")
234 res := p.MustEval(`() => { try { return "ok:" + document.cookie } catch (e) { return "err:" + e.name } }`).Str()
235 if !strings.HasPrefix(res, "err:") {
236 t.Errorf("raw svg could read document.cookie: %s", res)
237 }
238 })
239}
240
241// TestBrowserConsoleClean loads the main pages and fails on any console
242// error, which catches broken asset paths and script errors.
243func TestBrowserConsoleClean(t *testing.T) {
244 e := newEnv(t)
245 admin := e.admin()
246 e.createRepo(admin, "console-repo")
247 e.seedRepo("console-repo", nil)
248 b := browser(t)
249 p := pageAs(t, b, e.admin(), 1280)
250
251 var errs []string
252 go p.EachEvent(func(ev *proto.RuntimeExceptionThrown) {
253 errs = append(errs, ev.ExceptionDetails.Text)
254 }, func(ev *proto.LogEntryAdded) {
255 if ev.Entry.Level == proto.LogLogEntryLevelError {
256 errs = append(errs, fmt.Sprintf("%s %s", ev.Entry.Source, ev.Entry.Text))
257 }
258 })()
259 for _, path := range []string{"/", "/console-repo", "/console-repo/issues", "/settings", "/login", "/register"} {
260 goTo(p, e.Base, path)
261 }
262 time.Sleep(200 * time.Millisecond)
263 if len(errs) > 0 {
264 t.Errorf("console errors:\n%s", strings.Join(errs, "\n"))
265 }
266}
267