From ef7e1e1123e50ff1ed773d733c83b1261401f77a Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 13:53:50 +0200 Subject: [PATCH 01/17] W6.B0: browser test harness, CI job and smoke tests e2e/browser drives the real app in headless Chromium through playwright-go (behind the build tag `browser`). Pages are guarded against uncaught errors, console errors, CSP violations and failed or 404/5xx requests to the app, and a failed test saves a screenshot and a trace to .ui-artifacts/. - e2e: WithRealAssets serves cmd/web/dist, Run is Main without the exit, Client.Cookies exposes the session for the browser - make ui-deps, test-ui (RUN, COUNT, HEADED, SLOWMO), ui-trace; the quiet targets take TAGS - CI: a browser job with cached Chromium, uploading traces on failure - smoke tests: login and boosted navigation (title, head merge, window kept), an action button, the error toast on a server error. Each fails when json-enc, head-support or the responseError handler is removed from index.js, or the action controller is broken - docs/testing.md gains "Browser tests"; the frontend-htmx and test-failure skills point at it playwright-go moved to github.com/mxschmitt/playwright-go; the module path changed with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cd9fFJUp1qzhpKBpjvNw3r --- .claude/skills/frontend-htmx/SKILL.md | 8 +- .claude/skills/test-failure/SKILL.md | 7 +- .github/workflows/ci.yml | 50 +++++ .gitignore | 1 + .golangci.yml | 2 + Makefile | 34 ++- docs/plan/w6.md | 7 +- docs/testing.md | 67 +++++- e2e/browser/browser.go | 296 ++++++++++++++++++++++++++ e2e/browser/main_test.go | 11 + e2e/browser/smoke_test.go | 103 +++++++++ e2e/client.go | 11 + e2e/harness.go | 50 ++++- go.mod | 3 + go.sum | 6 + 15 files changed, 630 insertions(+), 26 deletions(-) create mode 100644 e2e/browser/browser.go create mode 100644 e2e/browser/main_test.go create mode 100644 e2e/browser/smoke_test.go diff --git a/.claude/skills/frontend-htmx/SKILL.md b/.claude/skills/frontend-htmx/SKILL.md index 8c8103fb..624e9bce 100644 --- a/.claude/skills/frontend-htmx/SKILL.md +++ b/.claude/skills/frontend-htmx/SKILL.md @@ -62,9 +62,11 @@ Server can control behavior via htmx response headers (`HX-Reswap`, `HX-Redirect ## Checking a frontend change -The browser suite (`e2e/browser`, from W6 on; `docs/testing.md` has the "Browser tests" section) runs the -real app with freshly built assets in Chromium. It fails on console errors, uncaught exceptions and CSP -violations on every page, so an inline script or a style without the nonce shows up without a dedicated test. +The browser suite (`e2e/browser`; `docs/testing.md` has the "Browser tests" section with a worked example) +runs the real app with freshly built assets in Chromium. It fails on console errors, uncaught exceptions, +CSP violations and failed or 404/5xx requests on every page, so an inline script or a style without the +nonce shows up without a dedicated test. A test that provokes an error on purpose exempts it with +`browser.Allow`. - `make test-ui RUN=''` while iterating; `HEADED=1 SLOWMO=250` to watch it run. The target runs `yarn build` first. Run the whole suite once before committing. diff --git a/.claude/skills/test-failure/SKILL.md b/.claude/skills/test-failure/SKILL.md index b1a3412d..5c0fc8db 100644 --- a/.claude/skills/test-failure/SKILL.md +++ b/.claude/skills/test-failure/SKILL.md @@ -14,9 +14,10 @@ Goal: find the cause while reading as little as possible. Stop at the first step 3. **One test, verbose:** `go test ./pkg/x/ -run '^TestName$' -count=1 -v 2>&1 | tail -n 40`. Add `/subtest_name` to the `-run` pattern for table tests. 4. **Search the log, don't print it:** `grep -n -A5 'TestName' `. Never `cat` a log. - **Browser tests** (`e2e/browser`, `make test-ui`): the report line names a screenshot and a trace. Look - at the screenshot first (the Read tool shows images), then the guard message: console error, CSP - violation or failed request. Open the trace only if both leave it unclear. Re-run one test with + **Browser tests** (`e2e/browser`, `make test-ui`; see "Browser tests" in `docs/testing.md`): the + report line names a screenshot and a trace in `.ui-artifacts/`. Look at the screenshot first (the Read + tool shows images), then the guard message (`browser: console.error: …`, a CSP violation, a failed + request or a 404/5xx response). Open the trace only if both leave it unclear. Re-run one test with `make test-ui RUN='^TestName$'`. Stale assets are a common cause, and `make test-ui` rebuilds them. A locator timeout usually means the markup changed, not that the app is slow; don't raise timeouts. An `architecture` test failure (`pkg/arch`, from RS on) names the package and the forbidden import: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 940777f2..876e385a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,56 @@ jobs: commit_message: "chore: apply go fix" file_pattern: "*.go" + browser: + name: Browser + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "yarn" + cache-dependency-path: cmd/web/yarn.lock + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libvips-dev + + - name: Install frontend dependencies + run: yarn install --frozen-lockfile + working-directory: cmd/web + + - name: Cache Playwright driver and Chromium + uses: actions/cache@v6 + with: + path: | + ~/.cache/ms-playwright + ~/.cache/ms-playwright-go + key: playwright-${{ runner.os }}-${{ hashFiles('go.sum') }} + + - name: Install Chromium + run: make ui-deps UI_DEPS_FLAGS=--with-deps + + - name: Browser tests + run: make test-ui + env: + QRUN_MAX: "400" + + - name: Upload traces and screenshots + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ui-artifacts + path: .ui-artifacts/ + if-no-files-found: ignore + frontend: name: Frontend runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index a0bb7d28..742050eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .cover/ coverage.out +.ui-artifacts/ diff --git a/.golangci.yml b/.golangci.yml index 9bc459cc..563b75af 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,8 @@ version: "2" run: tests: true + build-tags: + - browser linters: enable: - misspell diff --git a/Makefile b/Makefile index 56d957a6..6f1239a8 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ -.PHONY: shell tunnel lint test test-short cover build check fix check-q test-q vet-q cover-q model +.PHONY: shell tunnel lint test test-short cover build check fix check-q test-q vet-q cover-q model ui-deps test-ui ui-trace PKG ?= ./... +TAGS ?= +tags_flag = $(if $(TAGS),-tags $(TAGS)) shell: flyctl postgres connect -a pcomdb @@ -46,20 +48,42 @@ fix: # Quiet variants for agents: one line on success, a trimmed report on failure # (full output goes to a log file). They run the same steps as `make check`, # which stays the verbose CI form. -# Narrow with PKG, for example `make test-q PKG=./pkg/links/...`. +# Narrow with PKG, for example `make test-q PKG=./pkg/links/...`, and pass +# build tags with TAGS, for example `make vet-q PKG=./e2e/browser/... TAGS=browser`. check-q: @tools/qrun.sh build go build -o /dev/null ./... @tools/qrun.sh vet go vet ./... @tools/qrun.sh test go test ./... test-q: - @tools/qrun.sh test go test $(PKG) + @tools/qrun.sh test go test $(tags_flag) $(PKG) vet-q: - @tools/qrun.sh vet go vet $(PKG) + @tools/qrun.sh vet go vet $(tags_flag) $(PKG) cover-q: - @QRUN_SHOW_OK=1 tools/qrun.sh cover go test -cover $(PKG) + @QRUN_SHOW_OK=1 tools/qrun.sh cover go test $(tags_flag) -cover $(PKG) + +# Browser tests (e2e/browser, build tag `browser`). `make ui-deps` installs the +# Playwright driver and Chromium once (UI_DEPS_FLAGS=--with-deps also installs +# the system libraries, on Linux). `make test-ui` builds the frontend and runs +# the suite quietly: RUN= narrows it, COUNT= repeats it, and +# HEADED=1 SLOWMO=250 shows the browser. A failed test logs the path of its +# trace; open it with `make ui-trace F=`. +PLAYWRIGHT = go run github.com/mxschmitt/playwright-go/cmd/playwright +RUN ?= +COUNT ?= 1 +UI_DEPS_FLAGS ?= + +ui-deps: + $(PLAYWRIGHT) install $(UI_DEPS_FLAGS) chromium + +test-ui: + @tools/qrun.sh ui-build yarn --cwd cmd/web build + @HEADED=$(HEADED) SLOWMO=$(SLOWMO) tools/qrun.sh test-ui go test -tags browser -count=$(COUNT) $(if $(RUN),-run '$(RUN)') ./e2e/browser/... + +ui-trace: + $(PLAYWRIGHT) show-trace $(F) # Shape of a generated model without reading pkg/model/core: # `make model` lists the models, `make model T=User` prints one. diff --git a/docs/plan/w6.md b/docs/plan/w6.md index a304a9f6..530df267 100644 --- a/docs/plan/w6.md +++ b/docs/plan/w6.md @@ -22,21 +22,22 @@ not this wave. ### Tool: playwright-go -[playwright-go](https://github.com/playwright-community/playwright-go) rather +[playwright-go](https://github.com/mxschmitt/playwright-go) (module +`github.com/mxschmitt/playwright-go`; it moved from `playwright-community`) rather than the Node runner, because the tests have to reuse what W0 built: the harness starts the real binary against a fresh test database, and fixtures come from `pkg/testutil/factory`, the same way every other test describes the world. A Node runner would need a second fixture mechanism. playwright-go bundles the Playwright driver, so traces, `codegen` and `show-trace` remain available for iterating -(`go run github.com/playwright-community/playwright-go/cmd/playwright …`). +(`go run github.com/mxschmitt/playwright-go/cmd/playwright …`). Visual snapshot (pixel) tests are left out. Font rendering differs between macOS and Linux, so baselines made on one fail on the other. Revisit once browsers run inside the tools container, where baselines would be produced in one place. -### B0 Harness, CI and a smoke test (strong, coordinator) +### B0 Harness, CI and a smoke test (strong, coordinator) — done This task defines the contract for B1–B6. diff --git a/docs/testing.md b/docs/testing.md index eda5919c..e610e1ca 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -113,11 +113,76 @@ with `RequireStatus(code)`, `Doc()` (goquery) and `Location()`; `Header` and `Bo never a real remote URL. Mail is asserted through the outgoing queue for now: `factory.ListOutgoingEmails(ctx, app.DB, core.OutgoingEmailWhere.EmailType.EQ(...))`. +## Browser tests: e2e/browser + +Every user flow that needs the page's JavaScript is tested here, in headless Chromium through +[playwright-go](https://github.com/mxschmitt/playwright-go): forms, action buttons, htmx swaps and +redirects, Stimulus controllers, confirmations, toasts, dark mode. The package is behind the build tag +`browser`, so `make test` and `make check` need no browser. Run it with `make test-ui`, which builds the +frontend first (`RUN=` narrows it, `COUNT=` repeats it, `HEADED=1 SLOWMO=250` shows the browser); +install Chromium once with `make ui-deps`. Compile it with `make vet-q PKG=./e2e/browser/... TAGS=browser`. + +- `e2e.Start(t, e2e.WithRealAssets())` serves the real `cmd/web/dist`. Every test starts its own app. +- `browser.NewUser(t, app, opts...)` is `factory.User` with the password `browser.Password`, and + `browser.Page(t, app, browser.As(user))` returns a page in a fresh browser context, already logged in + (it reuses an HTTP login's session cookie). The base URL is the app's, so `page.Goto("/feed")`. +- **Guards:** the page fails the test on an uncaught error, a `console.error`, a CSP violation, a failed + request to the app or an app response of 404 or 5xx. A test that causes an error on purpose exempts it + with `browser.Allow(regexp)`, matched against the guard message. `browser.Configure(fn)` changes the + context options (viewport, `ColorScheme`). +- **On failure** a full-page screenshot and a trace are saved to `.ui-artifacts/` (or `$UI_ARTIFACTS`) and + both paths are logged in one line; open the trace with `make ui-trace F=`. CI uploads the directory. + +Reliability rules, because a red run must mean a real regression: + +1. **Assert outcomes, not mechanisms:** what the user sees (text, roles, visibility, URL, title) and the + database state through the factory readers. Never wait for htmx events, read `hx-*` attributes, inspect + request headers or call `window.htmx`, so the tests survive an htmx upgrade and catch one that breaks a page. +2. **Wait by assertion only:** `browser.Expect` (Playwright's auto-waiting assertions), never a sleep or + `WaitForTimeout`. A "nothing happened" check asserts on a state the action would have changed. +3. **Locate by role, label and text**, then by existing CSS classes. Test waves don't add `data-testid`. +4. **Isolation:** no shared fixtures; every test may call `t.Parallel()`. +5. **No retries:** a flaky test is fixed, or skipped with an issue number. A new test must pass + `make test-ui RUN= COUNT=3` before it is accepted. + +A browser test: + +```go +//go:build browser + +package browser_test + +func TestSmoke_ActionButton(t *testing.T) { + t.Parallel() + + app := e2e.Start(t, e2e.WithRealAssets()) + user := browser.NewUser(t, app) + draft, err := factory.Post(context.Background(), app.DB, user.ID) + require.NoError(t, err) + + page := browser.Page(t, app, browser.As(user)) + page.OnDialog(func(d playwright.Dialog) { _ = d.Accept() }) + + _, err = page.Goto("/controls") + require.NoError(t, err) + + row := page.GetByRole("row").Filter(playwright.LocatorFilterOptions{HasText: draft.Subject.String}) + require.NoError(t, row.GetByRole("button").Click()) + + require.NoError(t, browser.Expect.Locator(row).ToHaveCount(0)) + + posts, err := factory.ListPosts(context.Background(), app.DB, user.ID) + require.NoError(t, err) + require.Empty(t, posts) +} +``` + ## Make targets `make test-short` runs everything except E2E. `make cover` runs unit, package and E2E tests together under one `GOCOVERDIR` and prints a merged per-package coverage table. The quiet targets `check-q`, `test-q`, `vet-q` and -`cover-q` (see `AGENTS.md`) are for agents and narrow with `PKG=./pkg/links/...`. +`cover-q` (see `AGENTS.md`) are for agents, narrow with `PKG=./pkg/links/...` and take build tags with +`TAGS=browser`. The browser targets are `ui-deps`, `test-ui` and `ui-trace` (above). ## Worked examples diff --git a/e2e/browser/browser.go b/e2e/browser/browser.go new file mode 100644 index 00000000..3df9dac1 --- /dev/null +++ b/e2e/browser/browser.go @@ -0,0 +1,296 @@ +//go:build browser + +// Package browser drives the real app in headless Chromium, for everything a +// user does in a page: forms, action buttons, htmx swaps and redirects, +// Stimulus controllers, toasts. It builds on the e2e harness, so each test +// gets its own server and database, and fixtures come from the factories. +// +// The package is behind the build tag `browser`; run it with `make test-ui`. +// Every test package needs a TestMain that calls Main: +// +// func TestMain(m *testing.M) { browser.Main(m) } +package browser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "testing" + + "github.com/can3p/pcom/e2e" + "github.com/can3p/pcom/pkg/model/core" + "github.com/can3p/pcom/pkg/testutil/factory" + "github.com/mxschmitt/playwright-go" +) + +// Password is the login password of every user made by NewUser. +const Password = "browser-pw" + +// Expect holds Playwright's auto-waiting assertions. Wait for a state with +// these, never with a sleep. +var Expect = playwright.NewPlaywrightAssertions(5000) + +var ( + repoRoot = func() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Dir(filepath.Dir(filepath.Dir(file))) + }() + + chromium playwright.Browser +) + +// Main starts one Chromium for the test package, builds the web binary +// through e2e.Run and runs the tests. HEADED=1 shows the browser and +// SLOWMO= slows every action down, for watching a test locally. +func Main(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + pw, err := playwright.Run(&playwright.RunOptions{Verbose: false}) + if err != nil { + fmt.Fprintln(os.Stderr, "browser: starting Playwright (run `make ui-deps` once):", err) + return 1 + } + defer func() { _ = pw.Stop() }() + + opts := playwright.BrowserTypeLaunchOptions{Headless: playwright.Bool(os.Getenv("HEADED") == "")} + if ms, err := strconv.ParseFloat(os.Getenv("SLOWMO"), 64); err == nil { + opts.SlowMo = playwright.Float(ms) + } + + chromium, err = pw.Chromium.Launch(opts) + if err != nil { + fmt.Fprintln(os.Stderr, "browser: launching Chromium (run `make ui-deps` once):", err) + return 1 + } + defer func() { _ = chromium.Close() }() + + return e2e.Run(m) +} + +// NewUser creates a user who can log in with Password. +func NewUser(t testing.TB, app *e2e.App, opts ...factory.UserOpt) *core.User { + t.Helper() + + u, err := factory.User(context.Background(), app.DB, append([]factory.UserOpt{factory.WithPassword(Password)}, opts...)...) + if err != nil { + t.Fatal(err) + } + + return u +} + +// PageOption configures Page. +type PageOption func(*pageConfig) + +type pageConfig struct { + user *core.User + allow []*regexp.Regexp + context playwright.BrowserNewContextOptions +} + +// As logs the page in as user, who must have been made by NewUser. It reuses +// the session of an HTTP login rather than filling in the login form. +func As(user *core.User) PageOption { + return func(c *pageConfig) { c.user = user } +} + +// Allow exempts guard violations matching the regular expression, for a +// test that causes an error on purpose, such as a server error that must +// show the error toast. +func Allow(pattern string) PageOption { + return func(c *pageConfig) { c.allow = append(c.allow, regexp.MustCompile(pattern)) } +} + +// Configure changes the browser context options, for example the viewport +// or the emulated color scheme. +func Configure(fn func(*playwright.BrowserNewContextOptions)) PageOption { + return func(c *pageConfig) { fn(&c.context) } +} + +// cspReporter turns a CSP violation into a console error, which the guards +// catch like any other. +const cspReporter = `document.addEventListener('securitypolicyviolation', (e) => { + console.error('CSP violation: ' + e.violatedDirective + ' blocked ' + (e.blockedURI || 'inline code')); +});` + +// Page opens a blank page in a fresh browser context, with the app's URL as +// the base URL, so tests call page.Goto("/feed"). +// +// The page is guarded: the test fails on an uncaught page error, a +// console.error, a CSP violation, a failed request to the app, or an app +// response of 404 or 5xx, unless Allow exempts it. On failure a trace and a +// full-page screenshot are saved and their paths logged. +func Page(t testing.TB, app *e2e.App, opts ...PageOption) playwright.Page { + t.Helper() + + cfg := pageConfig{} + for _, o := range opts { + o(&cfg) + } + + cfg.context.BaseURL = playwright.String(app.URL) + + ctx, err := chromium.NewContext(cfg.context) + if err != nil { + t.Fatal(err) + } + + if cfg.user != nil { + login(t, app, ctx, cfg.user) + } + + if err := ctx.AddInitScript(playwright.Script{Content: playwright.String(cspReporter)}); err != nil { + t.Fatal(err) + } + + if err := ctx.Tracing().Start(playwright.TracingStartOptions{ + Screenshots: playwright.Bool(true), + Snapshots: playwright.Bool(true), + }); err != nil { + t.Fatal(err) + } + + page, err := ctx.NewPage() + if err != nil { + t.Fatal(err) + } + + g := &guard{app: app.URL, allow: cfg.allow} + g.watch(page) + + t.Cleanup(func() { + for _, v := range g.violations() { + t.Errorf("browser: %s", v) + } + + if t.Failed() { + saveArtifacts(t, ctx, page) + } else { + _ = ctx.Tracing().Stop() + } + + _ = ctx.Close() + }) + + return page +} + +func login(t testing.TB, app *e2e.App, ctx playwright.BrowserContext, user *core.User) { + t.Helper() + + client := app.Client(t) + client.LoginAs(user.Email, Password) + + var cookies []playwright.OptionalCookie + for _, c := range client.Cookies() { + cookies = append(cookies, playwright.OptionalCookie{Name: c.Name, Value: c.Value, URL: playwright.String(app.URL)}) + } + + if err := ctx.AddCookies(cookies); err != nil { + t.Fatal(err) + } +} + +// guard collects what a page did wrong while the test ran. Playwright calls +// the handlers from its own goroutine. +type guard struct { + app string + allow []*regexp.Regexp + + mu sync.Mutex + errs []string +} + +func (g *guard) watch(page playwright.Page) { + page.OnPageError(func(err error) { g.add("uncaught page error: " + err.Error()) }) + + page.OnConsole(func(m playwright.ConsoleMessage) { + if m.Type() == "error" { + g.add("console.error: " + m.Text()) + } + }) + + page.OnRequestFailed(func(r playwright.Request) { + if !strings.HasPrefix(r.URL(), g.app) { + return + } + + reason := "" + if f := r.Failure(); f != nil { + reason = f.Error() + } + + // A navigation or swap that supersedes a pending request aborts it; + // that is the browser working, not the app failing. + if strings.Contains(reason, "ERR_ABORTED") { + return + } + + g.add(fmt.Sprintf("request failed: %s %s: %s", r.Method(), r.URL(), reason)) + }) + + page.OnResponse(func(r playwright.Response) { + if s := r.Status(); strings.HasPrefix(r.URL(), g.app) && (s == 404 || s >= 500) { + g.add(fmt.Sprintf("response %d: %s %s", s, r.Request().Method(), r.URL())) + } + }) +} + +func (g *guard) add(msg string) { + for _, re := range g.allow { + if re.MatchString(msg) { + return + } + } + + g.mu.Lock() + defer g.mu.Unlock() + g.errs = append(g.errs, msg) +} + +func (g *guard) violations() []string { + g.mu.Lock() + defer g.mu.Unlock() + return append([]string(nil), g.errs...) +} + +// artifactsDir is where failed tests leave traces and screenshots: +// UI_ARTIFACTS if set, else .ui-artifacts in the repository root. +func artifactsDir() string { + if dir := os.Getenv("UI_ARTIFACTS"); dir != "" { + return dir + } + + return filepath.Join(repoRoot, ".ui-artifacts") +} + +var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_.-]+`) + +func saveArtifacts(t testing.TB, ctx playwright.BrowserContext, page playwright.Page) { + dir := artifactsDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Logf("browser: saving artifacts: %v", err) + return + } + + base := filepath.Join(dir, unsafeName.ReplaceAllString(t.Name(), "_")) + trace, shot := base+".trace.zip", base+".png" + + if _, err := page.Screenshot(playwright.PageScreenshotOptions{Path: playwright.String(shot), FullPage: playwright.Bool(true)}); err != nil { + shot = "none (" + err.Error() + ")" + } + + if err := ctx.Tracing().Stop(trace); err != nil { + trace = "none (" + err.Error() + ")" + } + + t.Logf("browser: screenshot %s, trace %s (make ui-trace F=%s)", shot, trace, trace) +} diff --git a/e2e/browser/main_test.go b/e2e/browser/main_test.go new file mode 100644 index 00000000..47d10589 --- /dev/null +++ b/e2e/browser/main_test.go @@ -0,0 +1,11 @@ +//go:build browser + +package browser_test + +import ( + "testing" + + "github.com/can3p/pcom/e2e/browser" +) + +func TestMain(m *testing.M) { browser.Main(m) } diff --git a/e2e/browser/smoke_test.go b/e2e/browser/smoke_test.go new file mode 100644 index 00000000..df7f3d11 --- /dev/null +++ b/e2e/browser/smoke_test.go @@ -0,0 +1,103 @@ +//go:build browser + +package browser_test + +import ( + "context" + "regexp" + "testing" + + "github.com/can3p/pcom/e2e" + "github.com/can3p/pcom/e2e/browser" + "github.com/can3p/pcom/pkg/model/core" + "github.com/can3p/pcom/pkg/testutil/factory" + "github.com/mxschmitt/playwright-go" + "github.com/stretchr/testify/require" +) + +// Logging in through the real form lands on the feed, and boosted links swap +// the page in place: the window survives, the title changes, and the head is +// merged (the journal's RSS link comes and goes with the page). +func TestSmoke_LoginAndBoostedNavigation(t *testing.T) { + t.Parallel() + + app := e2e.Start(t, e2e.WithRealAssets()) + user := browser.NewUser(t, app, factory.WithVisibility(core.ProfileVisibilityPublic)) + page := browser.Page(t, app) + rss := page.Locator(`head link[rel="alternate"][type="application/rss+xml"]`) + + _, err := page.Goto("/login") + require.NoError(t, err) + + require.NoError(t, page.GetByLabel("Email address").Fill(user.Email)) + require.NoError(t, page.GetByLabel("Password").Fill(browser.Password)) + require.NoError(t, page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Log in"}).Click()) + + require.NoError(t, browser.Expect.Page(page).ToHaveURL(regexp.MustCompile(`/feed$`))) + require.NoError(t, browser.Expect.Locator(rss).ToHaveCount(0)) + + _, err = page.Evaluate(`window.smokeMarker = "kept"`) + require.NoError(t, err) + + require.NoError(t, page.GetByRole("navigation").GetByRole("link", playwright.LocatorGetByRoleOptions{Name: user.Username, Exact: playwright.Bool(true)}).Click()) + + require.NoError(t, browser.Expect.Page(page).ToHaveTitle(regexp.MustCompile(`Journal$`))) + require.NoError(t, browser.Expect.Locator(rss).ToHaveCount(1)) + + require.NoError(t, page.GetByRole("link", playwright.PageGetByRoleOptions{Name: "Controls", Exact: playwright.Bool(true)}).Click()) + + require.NoError(t, browser.Expect.Page(page).ToHaveURL(regexp.MustCompile(`/controls/?$`))) + require.NoError(t, browser.Expect.Page(page).ToHaveTitle(regexp.MustCompile(`Controls$`))) + require.NoError(t, browser.Expect.Locator(rss).ToHaveCount(0)) + + marker, err := page.Evaluate(`window.smokeMarker`) + require.NoError(t, err) + require.Equal(t, "kept", marker, "a boosted link reloaded the whole page") +} + +// An action button posts its payload as JSON through htmx and reloads the +// page: deleting a draft on /controls after confirming removes it. +func TestSmoke_ActionButton(t *testing.T) { + t.Parallel() + + app := e2e.Start(t, e2e.WithRealAssets()) + user := browser.NewUser(t, app) + draft, err := factory.Post(context.Background(), app.DB, user.ID) + require.NoError(t, err) + + page := browser.Page(t, app, browser.As(user)) + page.OnDialog(func(d playwright.Dialog) { _ = d.Accept() }) + + _, err = page.Goto("/controls") + require.NoError(t, err) + + row := page.GetByRole("row").Filter(playwright.LocatorFilterOptions{HasText: draft.Subject.String}) + require.NoError(t, row.GetByRole("button").Click()) + + require.NoError(t, browser.Expect.Locator(row).ToHaveCount(0)) + + posts, err := factory.ListPosts(context.Background(), app.DB, user.ID) + require.NoError(t, err) + require.Empty(t, posts) +} + +// A server error on a boosted navigation shows the error toast. +func TestSmoke_ServerErrorShowsToast(t *testing.T) { + t.Parallel() + + app := e2e.Start(t, e2e.WithRealAssets()) + user := browser.NewUser(t, app) + page := browser.Page(t, app, browser.As(user), browser.Allow(`\b500\b`)) + + _, err := page.Goto("/feed") + require.NoError(t, err) + + require.NoError(t, page.Route("**/controls**", func(r playwright.Route) { + _ = r.Fulfill(playwright.RouteFulfillOptions{Status: playwright.Int(500), Body: "boom"}) + })) + + require.NoError(t, page.GetByRole("link", playwright.PageGetByRoleOptions{Name: "Controls", Exact: playwright.Bool(true)}).Click()) + + require.NoError(t, browser.Expect.Locator(page.GetByRole("alert")).ToContainText("Server error")) + require.NoError(t, browser.Expect.Page(page).ToHaveURL(regexp.MustCompile(`/feed$`))) +} diff --git a/e2e/client.go b/e2e/client.go index 1ba4f374..ce968011 100644 --- a/e2e/client.go +++ b/e2e/client.go @@ -110,6 +110,17 @@ func (c *Client) LoginAs(email, password string) { } } +// Cookies returns the cookies the client holds for the app, such as the +// session cookie after LoginAs, so a browser can reuse the session. +func (c *Client) Cookies() []*http.Cookie { + u, err := url.Parse(c.app.URL) + if err != nil { + c.t.Fatal(err) + } + + return c.http.Jar.Cookies(u) +} + // Do sends req, adding the CSRF token to anything but a GET. func (c *Client) Do(req *http.Request) *Response { c.t.Helper() diff --git a/e2e/harness.go b/e2e/harness.go index 2dee420b..619af5fe 100644 --- a/e2e/harness.go +++ b/e2e/harness.go @@ -48,30 +48,36 @@ var ( // Main builds the web binary once for the test package, runs the tests and // cleans up. With -short it builds nothing, and Start skips the test. func Main(m *testing.M) { + os.Exit(Run(m)) +} + +// Run is Main without the exit: it returns the exit code, for a TestMain +// that has its own setup and teardown around the tests. +func Run(m *testing.M) int { flag.Parse() if testing.Short() { - os.Exit(m.Run()) + return m.Run() } dir, err := os.MkdirTemp("", "pcom-e2e-") if err != nil { fmt.Fprintln(os.Stderr, "e2e:", err) - os.Exit(1) + return 1 } + defer func() { _ = os.RemoveAll(dir) }() binPath, err = buildBinary(dir) if err != nil { fmt.Fprintln(os.Stderr, "e2e:", err) - _ = os.RemoveAll(dir) - os.Exit(1) + return 1 } code := m.Run() _ = postgres.Cleanup() - _ = os.RemoveAll(dir) - os.Exit(code) + + return code } // buildBinary builds ./cmd/web with coverage instrumentation. An overlay adds @@ -135,7 +141,8 @@ type App struct { type Option func(*config) type config struct { - env map[string]string + env map[string]string + realAssets bool } // WithEnv sets an extra environment variable for the binary. @@ -143,6 +150,13 @@ func WithEnv(key, value string) Option { return func(c *config) { c.env[key] = value } } +// WithRealAssets serves the frontend build in cmd/web/dist instead of the +// stub assets, for tests that run the page's JavaScript and styles in a +// browser. The build must exist: `make test-ui` runs `yarn build` first. +func WithRealAssets() Option { + return func(c *config) { c.realAssets = true } +} + // Start runs the web binary against a fresh database and returns once it // serves GET / with 200. The process is stopped when the test ends, and its // output is logged if the test failed. @@ -163,7 +177,7 @@ func Start(t testing.TB, opts ...Option) *App { } db := testdb.New(t) - work := workDir(t) + work := workDir(t, cfg.realAssets) port := freePort(t) url := fmt.Sprintf("http://127.0.0.1:%d", port) @@ -243,9 +257,10 @@ func processEnv(overrides map[string]string) []string { } // workDir makes the binary's working directory: templates and articles -// through a `client` symlink, and a stub `dist` whose manifest names every -// asset the templates ask for, because static_asset panics on unknown keys. -func workDir(t testing.TB) string { +// through a `client` symlink, and either the real `dist` through a symlink or +// a stub `dist` whose manifest names every asset the templates ask for, +// because static_asset panics on unknown keys. +func workDir(t testing.TB, realAssets bool) string { t.Helper() work := t.TempDir() @@ -254,6 +269,19 @@ func workDir(t testing.TB) string { t.Fatal(err) } + if realAssets { + dist := filepath.Join(repoRoot, "cmd", "web", "dist") + if _, err := os.Stat(filepath.Join(dist, "manifest.json")); err != nil { + t.Fatalf("e2e: no frontend build in %s; run `yarn build` in cmd/web (make test-ui does): %v", dist, err) + } + + if err := os.Symlink(dist, filepath.Join(work, "dist")); err != nil { + t.Fatal(err) + } + + return work + } + keys, err := templateAssets(filepath.Join(repoRoot, "cmd", "web", "client", "html")) if err != nil { t.Fatal(err) diff --git a/go.mod b/go.mod index 44f68b2a..18127d5a 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/mmcdole/gofeed v1.4.2 + github.com/mxschmitt/playwright-go v0.6201.1 github.com/ovechkin-dm/mockio/v2 v2.0.5 github.com/pkg/errors v0.9.1 github.com/samber/lo v1.53.0 @@ -76,6 +77,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect github.com/docker/go-connections v0.7.0 // indirect @@ -92,6 +94,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-stack/stack v1.8.1 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/gofrs/uuid v4.2.0+incompatible // indirect diff --git a/go.sum b/go.sum index 3ac2df45..06bb3f45 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidbyttow/govips/v2 v2.19.0 h1:vIFpRodf/jrQlnKetj0BaQ1OwGNR+bR+QIBQ2sgxtxc= github.com/davidbyttow/govips/v2 v2.19.0/go.mod h1:QK5liLrx7YaHC7xPcYnm4KaQIYvu9xSGrBt9goXrfgo= +github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= +github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= @@ -166,6 +168,8 @@ github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJ github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= +github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -277,6 +281,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mxschmitt/playwright-go v0.6201.1 h1:KBBDopE+IIdjAp2FIk6zhLcKCCSSx/T6rGjKZmfo/Bs= +github.com/mxschmitt/playwright-go v0.6201.1/go.mod h1:vLBP2yVO1gokUlb9ENA+YE3VQaVqf2A9BGxcb7aPjMI= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= From eacb96b0b5bb4e24f24183c6496cde027406fc27 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 14:35:38 +0200 Subject: [PATCH 02/17] plan: W6 makes the Browser check required when it merges Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cd9fFJUp1qzhpKBpjvNw3r --- docs/plan/w6.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/plan/w6.md b/docs/plan/w6.md index 530df267..1803bdfb 100644 --- a/docs/plan/w6.md +++ b/docs/plan/w6.md @@ -69,7 +69,10 @@ This task defines the contract for B1–B6. `make vet-q PKG=./e2e/browser/... TAGS=browser` compiles the suite. - **CI:** a `browser` job that runs `yarn install --frozen-lockfile`, `make ui-deps` (with a cached browser directory) and `make test-ui`. It is - required for merging from W6 on. + required for merging from W6 on: right after the W6 PR merges, wave-close + makes the `Browser` check required on `master` (not before, or every open + PR without the job would wait for it forever). `master` has no branch + protection yet, and its "protect main" ruleset is disabled. - **Smoke test:** log in through the real form, land on `/feed`, and navigate to `/controls` through a boosted link. Assert that the title changed and the page didn't fully reload: a marker set on `window` before From 8e9e7567da3cb4a020a6c86719c4284be7c2325c Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 16:07:50 +0200 Subject: [PATCH 03/17] W6.B1: browser tests for navigation and page chrome Boosted navigation with head merge and history, flashes and toasts, the toggle and spoiler controllers, dark mode, and the error toast on a server error and a dropped connection. The mobile menu test is skipped on #140 (CSP violation when it opens). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cd9fFJUp1qzhpKBpjvNw3r --- e2e/browser/navigation_test.go | 279 +++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 e2e/browser/navigation_test.go diff --git a/e2e/browser/navigation_test.go b/e2e/browser/navigation_test.go new file mode 100644 index 00000000..75dc053b --- /dev/null +++ b/e2e/browser/navigation_test.go @@ -0,0 +1,279 @@ +//go:build browser + +package browser_test + +import ( + "context" + "fmt" + "regexp" + "testing" + + "github.com/can3p/pcom/e2e" + "github.com/can3p/pcom/e2e/browser" + "github.com/can3p/pcom/pkg/model/core" + "github.com/can3p/pcom/pkg/testutil/factory" + "github.com/mxschmitt/playwright-go" + "github.com/stretchr/testify/require" +) + +// b1NavLink locates a link by its accessible name inside the page's