Files
remark42/e2e/thread_test.go
T
Dmitry VerkhoturovandGitHub ff77f41a3a Move the e2e suite to Go and playwright-go (#2180)
* Move the e2e suite to Go and playwright-go

The seven playwright tests in `frontend/e2e` become twenty in `e2e/`, a
separate Go module driving the same browsers through playwright-go. The
npm project, its lockfile entries, its prettier config and
`Dockerfile.e2e` go with it, leaving `frontend/` a single-member
workspace.

The suite covers posting with markdown, replying and the nesting that
implies, editing inside the deadline and the backend refusing one outside
it, deleting, voting with the optimistic score observed mid-flight and
rolled back on failure, changing the sort, collapse persistence across a
reload, dev, anonymous and email sign-in end to end, the profile iframe,
and the two scripts that render into the host page rather than the
widget's own frame.

The rendering tests run in chromium, firefox and webkit. The rest sign in,
sign-in needs the dev oauth2 provider, and reaching that by name from the
host is chromium-only, so they run there alone.

`compose-e2e-test.yml` runs remark42, a second instance with a short edit
window so that path does not need a five-minute test, and mailpit, which
catches the email verification message the suite reads back. Everything
binds to the loopback interface: the stack holds a known secret and an
admin shared id, and `go test` can start it unattended. The tests run on
the host rather than in a container.

Three settings there exist for the tests rather than for realism.
`REMARK_URL` uses a hostname because the dev oauth2 server binds whatever
host it reads out of it, and a loopback bind inside a container cannot be
published. `UPDATE_LIMIT` is raised because the default of 0.5/sec rejects
any test posting twice in a row. The suite also paces its own `/auth/`
calls, which are capped at 2/sec by a bare literal in `rest.go` rather
than by a setting.

Each test gets its own comment thread from a query string on the demo
page, so nothing has to reset the database between runs.

CI gains a vet and lint job for the module, since the build tag keeps it
out of a plain `go test ./...`, and uploads a browser trace for any test
that fails.

`e2e/README.md` carries the rest: how to run it, what the stack is for,
and the widget behaviour the assertions have to work around.

* Update golangci-lint to 2.13.1 in the backend workflow

The pin sat three minors behind what the linter installs locally, so CI
checked the backend with an older set of rules than anyone running it by
hand. 2.10.1 also fetches its config schema over the network on every
`config verify`, which is a failure mode with no bearing on the code.

Both targets are clean on 2.13.1, `backend/app` and the memory_store
example.
2026-08-21 17:53:12 -05:00

123 lines
4.1 KiB
Go

//go:build e2e
package e2e
import (
"fmt"
"strings"
"testing"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// firstCommentText returns the text of the topmost comment in the thread. it returns the
// error rather than failing, because every caller polls it while the list re-renders and a
// momentarily empty list has to be retried rather than fail the test
func firstCommentText(frame playwright.FrameLocator) (string, error) {
return pollText(frame.Locator("article").First())
}
const (
sortOldestFirst = "+time"
sortNewestFirst = "-time"
)
func setSort(t *testing.T, frame playwright.FrameLocator, value string) {
t.Helper()
_, err := frame.Locator(".sort-picker select").SelectOption(playwright.SelectOptionValues{
Values: &[]string{value},
})
require.NoError(t, err)
}
func TestThread_SortChangeReordersComments(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
first := "sort first " + runID
second := "sort second " + runID
postComment(t, frame, first)
postComment(t, frame, second)
setSort(t, frame, sortOldestFirst)
eventually(t, waitTimeout, "oldest-first did not put the first comment on top", func() bool {
txt, err := firstCommentText(frame)
return err == nil && strings.Contains(txt, first)
})
setSort(t, frame, sortNewestFirst)
eventually(t, waitTimeout, "newest-first did not put the second comment on top", func() bool {
txt, err := firstCommentText(frame)
return err == nil && strings.Contains(txt, second)
})
// the choice is kept in localStorage and re-applied on the next load. it has to be the
// oldest-first one: the default is -active, which orders two reply-free comments exactly
// as -time does, so persisting newest-first would be indistinguishable from not
// persisting anything at all
setSort(t, frame, sortOldestFirst)
eventually(t, waitTimeout, "oldest-first did not take effect before reload", func() bool {
txt, err := firstCommentText(frame)
return err == nil && strings.Contains(txt, first)
})
frame = reload(t, page)
eventually(t, waitTimeout, "sort choice did not survive reload", func() bool {
txt, err := firstCommentText(frame)
return err == nil && strings.Contains(txt, first)
})
}
func TestThread_CollapsePersistsAcrossReload(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
parent := "collapse parent " + runID
postComment(t, frame, parent)
require.NoError(t, actions(frame, parent).Locator(`button:has-text("Reply")`).Click())
reply := "collapse reply " + runID
submitForm(t, replyForm(t, frame), reply)
waitVisible(t, comment(frame, reply))
// anchor on the comment's own id rather than its text: collapsing hides the text, which
// would make a hasText filter stop matching the element under test. the id also excludes
// the RSS dropdown, the other thing on the page carrying aria-expanded
id, err := comment(frame, parent).GetAttribute("id")
require.NoError(t, err)
require.NotEmpty(t, id)
threadSel := fmt.Sprintf("[aria-expanded]:has(article#%s)", id)
thread := frame.Locator(threadSel)
expanded, err := pollAttr(thread, "aria-expanded")
require.NoError(t, err)
require.Equal(t, "true", expanded)
require.NoError(t, thread.Locator(`:scope > [role="button"]`).Click())
eventually(t, waitTimeout, "thread did not collapse", func() bool {
v, aerr := pollAttr(thread, "aria-expanded")
return aerr == nil && v == "false"
})
// counting rather than filtering on the reply's text, which an off-screen comment would
// satisfy just as well as a collapsed one
eventually(t, waitTimeout, "the reply was still rendered after collapsing", func() bool {
n, err := frame.Locator("article").Count()
return err == nil && n == 1
})
// collapse is client-only state, kept in localStorage rather than on the server
frame = reload(t, page)
thread = frame.Locator(threadSel)
eventually(t, waitTimeout, "collapse did not survive reload", func() bool {
v, aerr := pollAttr(thread, "aria-expanded")
return aerr == nil && v == "false"
})
assert.Equal(t, 1, articleCount(t, frame), "a collapsed thread should not render its replies after reload")
}