package e2e import ( "fmt" "net/url" "os" "path/filepath" "sort" "strings" "testing" "time" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/proto" ) // The browser suite covers what needs JavaScript or a real layout engine: // auto-submitting selects, details popups, the theme script, and the // responsive layout. Everything else runs without a browser. // chromePath finds a Chromium binary: HEARTHFORGE_BROWSER, then PATH, then // a Playwright download. It returns "" when none is installed. func chromePath() string { if p := os.Getenv("HEARTHFORGE_BROWSER"); p != "" { return p } if p, ok := launcher.LookPath(); ok { return p } home, _ := os.UserHomeDir() matches, _ := filepath.Glob(filepath.Join(home, ".cache", "ms-playwright", "chromium-*", "chrome-linux*", "chrome")) sort.Strings(matches) if len(matches) > 0 { return matches[len(matches)-1] } return "" } // browser launches headless Chromium for one test. func browser(t *testing.T) *rod.Browser { t.Helper() bin := chromePath() if bin == "" { t.Skip("no Chromium found; set HEARTHFORGE_BROWSER") } l := launcher.New().Bin(bin).Headless(true).NoSandbox(true) u, err := l.Launch() if err != nil { t.Skipf("launch %s: %v", bin, err) } b := rod.New().ControlURL(u) if err := b.Connect(); err != nil { t.Fatal(err) } t.Cleanup(func() { b.MustClose() l.Cleanup() }) return b } // pageAs opens a page that carries the session's cookies, so the browser is // signed in as that user without going through the login form. func pageAs(t *testing.T, b *rod.Browser, s *session, width int) *rod.Page { t.Helper() p := b.MustIncognito().MustPage() p.MustSetViewport(width, 900, 1, false) if c := s.cookie("session"); c != nil { p.MustSetCookies(&proto.NetworkCookieParam{Name: c.Name, Value: c.Value, URL: s.env.Base}) } for _, name := range []string{"repo_sort", "theme"} { if c := s.cookie(name); c != nil { p.MustSetCookies(&proto.NetworkCookieParam{Name: c.Name, Value: c.Value, URL: s.env.Base}) } } return p } func goTo(p *rod.Page, base, path string) *rod.Page { p.Timeout(15 * time.Second).MustNavigate(base + path).MustWaitLoad() return p } func pathOf(p *rod.Page) string { u, _ := url.Parse(p.MustInfo().URL) return u.Path } func TestBrowser(t *testing.T) { e := newEnv(t) admin := e.admin() e.createRepo(admin, "js-repo") e.seedRepo("js-repo", nil) admin.post("/js-repo/settings/labels", url.Values{"name": {"bug"}, "color": {"#d73a4a"}}) admin.post("/js-repo/settings/labels", url.Values{"name": {"docs"}, "color": {"#0075ca"}}) issue := admin.post("/js-repo/issues", url.Values{ "title": {"Picker issue"}, "body": {"Body **bold**."}, }).mustRedirect("/js-repo/issues/") admin.post(issue+"/comments", url.Values{"body": {"A comment."}}) b := browser(t) t.Run("sort select auto-submits and sets the cookie", func(t *testing.T) { s := e.admin() p := pageAs(t, b, s, 1280) goTo(p, e.Base, "/") wait := p.MustWaitNavigation() p.MustElement(".repo-sort-select").MustSelect("Name") wait() p.MustWaitLoad() if got := p.MustElement(".repo-sort-select").MustProperty("value").Str(); got != "name" { t.Errorf("sort value after auto-submit = %q", got) } cookies := p.MustCookies() found := false for _, c := range cookies { if c.Name == "repo_sort" && c.Value == "name" { found = true } } if !found { t.Error("repo_sort cookie not set to name") } }) t.Run("Go button is a noscript fallback", func(t *testing.T) { // Rod drives the page with injected JS, so it cannot run with scripts // off. The no-JS path is covered by TestSorting posting /sort // directly. Here: the fallback is in the HTML, and hidden with JS. if !e.admin().get("/").Has(`form[action="/sort"] noscript`) { t.Fatal("noscript Go button missing from the HTML") } p := pageAs(t, b, e.admin(), 1280) goTo(p, e.Base, "/") if p.MustHas(`form[action="/sort"] button[type=submit]`) { t.Error("noscript Go button rendered with JS enabled") } }) t.Run("theme cookie applies data-theme before paint", func(t *testing.T) { s := e.admin() s.post("/settings/theme", url.Values{"theme": {"dark"}}) p := pageAs(t, b, s, 1280) goTo(p, e.Base, "/") if got := p.MustEval(`() => document.documentElement.getAttribute("data-theme")`).Str(); got != "dark" { t.Errorf("data-theme = %q", got) } }) t.Run("reaction picker opens and posts a reaction", func(t *testing.T) { p := pageAs(t, b, e.admin(), 1280) goTo(p, e.Base, issue) p.MustElement(".reaction-picker > summary").MustClick() btn := p.MustElement(".reaction-picker-dropdown .reaction-picker-btn") if !btn.MustVisible() { t.Fatal("picker button not visible after opening") } wait := p.MustWaitNavigation() btn.MustClick() wait() p.MustWaitLoad() if !p.MustHas(".reaction-btn") { t.Error("no reaction shown after clicking the picker") } }) t.Run("label filter popup applies the filter", func(t *testing.T) { p := pageAs(t, b, e.admin(), 1280) goTo(p, e.Base, "/js-repo/issues") p.MustElement("details.label-filter > summary").MustClick() p.MustElement(`.label-filter-popup input[type=checkbox]`).MustClick() wait := p.MustWaitNavigation() p.MustElement(`.label-filter-popup button[type=submit]`).MustClick() wait() p.MustWaitLoad() u, _ := url.Parse(p.MustInfo().URL) if u.Query().Get("labels") == "" { t.Errorf("filter did not add labels param: %s", u) } }) t.Run("create-tag checkbox reveals the tag fields", func(t *testing.T) { p := pageAs(t, b, e.admin(), 1280) goTo(p, e.Base, "/js-repo/releases/new") group := p.MustElement("#tag-fields-group") if group.MustVisible() { t.Fatal("tag fields visible before the checkbox is ticked") } p.MustElement("#create_tag").MustClick() if !group.MustVisible() { t.Error("tag fields hidden after the checkbox is ticked") } }) t.Run("login form works in a browser", func(t *testing.T) { p := b.MustIncognito().MustPage() goTo(p, e.Base, "/login") p.MustElement("[name=username]").MustInput("admin") p.MustElement("[name=password]").MustInput(adminPass) wait := p.MustWaitNavigation() p.MustElement("button[type=submit]").MustClick() wait() p.MustWaitLoad() if pathOf(p) != "/" || !p.MustHas(".nav-user") { t.Errorf("after login: path %s, nav-user %v", pathOf(p), p.MustHas(".nav-user")) } }) t.Run("no horizontal overflow at 360 and 1280", func(t *testing.T) { sha := e.headCommit("js-repo") pages := []string{ "/", "/js-repo", "/js-repo/issues", issue, "/js-repo/commits/main", "/js-repo/commit/" + sha, "/js-repo/blob/main/index.js", "/js-repo/settings", "/settings", "/login", } // 360, not 320: a classic 15px scrollbar plus the 320px body floor // would always scroll at exactly 320. for _, width := range []int{360, 1280} { p := pageAs(t, b, e.admin(), width) for _, path := range pages { goTo(p, e.Base, path) sw := p.MustEval(`() => document.documentElement.scrollWidth`).Int() cw := p.MustEval(`() => document.documentElement.clientWidth`).Int() if sw > cw { t.Errorf("%s at %dpx: scrollWidth %d > clientWidth %d", path, width, sw, cw) } } } }) t.Run("raw svg document cannot read cookies", func(t *testing.T) { p := pageAs(t, b, e.admin(), 1280) goTo(p, e.Base, "/js-repo/raw/main/logo.svg") res := p.MustEval(`() => { try { return "ok:" + document.cookie } catch (e) { return "err:" + e.name } }`).Str() if !strings.HasPrefix(res, "err:") { t.Errorf("raw svg could read document.cookie: %s", res) } }) } // TestBrowserConsoleClean loads the main pages and fails on any console // error, which catches broken asset paths and script errors. func TestBrowserConsoleClean(t *testing.T) { e := newEnv(t) admin := e.admin() e.createRepo(admin, "console-repo") e.seedRepo("console-repo", nil) b := browser(t) p := pageAs(t, b, e.admin(), 1280) var errs []string go p.EachEvent(func(ev *proto.RuntimeExceptionThrown) { errs = append(errs, ev.ExceptionDetails.Text) }, func(ev *proto.LogEntryAdded) { if ev.Entry.Level == proto.LogLogEntryLevelError { errs = append(errs, fmt.Sprintf("%s %s", ev.Entry.Source, ev.Entry.Text)) } })() for _, path := range []string{"/", "/console-repo", "/console-repo/issues", "/settings", "/login", "/register"} { goTo(p, e.Base, path) } time.Sleep(200 * time.Millisecond) if len(errs) > 0 { t.Errorf("console errors:\n%s", strings.Join(errs, "\n")) } }