* 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.
111 lines
4.2 KiB
Go
111 lines
4.2 KiB
Go
//go:build e2e
|
|
|
|
package e2e
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/mxschmitt/playwright-go"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// score reads the vote counter of the comment carrying the text
|
|
func score(frame playwright.FrameLocator, text string) playwright.Locator {
|
|
return comment(frame, text).Locator(`[title="Votes score"]`).First()
|
|
}
|
|
|
|
// voteScenario posts a comment as one user and returns a second, signed-in user's page and
|
|
// its view of that comment. remark42 hides the vote buttons on your own comment, so the
|
|
// author and the voter cannot be the same person
|
|
func voteScenario(t *testing.T, author, text string) (playwright.Page, playwright.FrameLocator, playwright.Locator) {
|
|
t.Helper()
|
|
|
|
authorPage := newPage(t)
|
|
authorFrame := openThread(t, authorPage)
|
|
signInAnon(t, authorFrame, author)
|
|
postComment(t, authorFrame, text)
|
|
|
|
voter := newPage(t)
|
|
voterFrame := openThread(t, voter)
|
|
signInDev(t, voter, voterFrame)
|
|
|
|
target := comment(voterFrame, text)
|
|
waitVisible(t, target)
|
|
return voter, voterFrame, target
|
|
}
|
|
|
|
func TestVote_UpvoteCountsOnce(t *testing.T) {
|
|
text := "vote target " + runID
|
|
voter, voterFrame, target := voteScenario(t, "voteauthor", text)
|
|
|
|
require.NoError(t, target.Locator(`button[title="Vote up"]`).Click())
|
|
|
|
eventually(t, waitTimeout, "score did not reach 1", func() bool {
|
|
v, err := pollText(score(voterFrame, text))
|
|
return err == nil && v == "1"
|
|
})
|
|
|
|
// the vote is stored rather than only reflected in local state
|
|
voterFrame = reload(t, voter)
|
|
eventually(t, waitTimeout, "score did not survive reload", func() bool {
|
|
v, err := pollText(score(voterFrame, text))
|
|
return err == nil && v == "1"
|
|
})
|
|
|
|
// and the same voter cannot stack a second one. this has to come after the reload: for
|
|
// 200ms after a click the button is disabled by the in-flight loading state, so checking
|
|
// it earlier would pass without the caller's own vote ever coming back from /find
|
|
disabled, err := comment(voterFrame, text).Locator(`button[title="Vote up"]`).IsDisabled()
|
|
require.NoError(t, err)
|
|
assert.True(t, disabled, "an already-cast upvote should not be repeatable")
|
|
}
|
|
|
|
// TestVote_FailureShowsAnErrorAndRestoresTheScore drives the catch branch in comment-votes.tsx.
|
|
// The score is optimistic, so a failed request has to put it back rather than leave the
|
|
// reader believing a vote landed.
|
|
func TestVote_FailureShowsAnErrorAndRestoresTheScore(t *testing.T) {
|
|
text := "vote failure " + runID
|
|
voter, voterFrame, target := voteScenario(t, "votefailauthor", text)
|
|
|
|
// hold the response open. answering instantly would let the test pass with the optimistic
|
|
// increment removed altogether, since the score would simply never leave 0
|
|
release := make(chan struct{})
|
|
var releaseOnce sync.Once
|
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
|
// on any failure below the handler would otherwise sit on <-release for the rest of the
|
|
// process, with the intercepted request never answered
|
|
defer unblock()
|
|
require.NoError(t, voter.Route("**/api/v1/vote/**", func(route playwright.Route) {
|
|
<-release
|
|
// 409 rather than 500: the widget maps a handful of statuses to copy of their own and
|
|
// everything else to a generic "something went wrong", which is also what it shows when
|
|
// error handling fails altogether. asserting a distinct string is what makes this
|
|
// assertion mean anything
|
|
_ = route.Fulfill(playwright.RouteFulfillOptions{
|
|
Status: playwright.Int(409),
|
|
ContentType: playwright.String("application/json"),
|
|
Body: `{"code":19,"details":"vote rejected","error":"failed"}`,
|
|
})
|
|
}))
|
|
|
|
require.NoError(t, target.Locator(`button[title="Vote up"]`).Click())
|
|
|
|
// while the request is in flight the widget shows the vote as though it had landed
|
|
eventually(t, waitTimeout, "the score was never incremented optimistically", func() bool {
|
|
v, err := pollText(score(voterFrame, text))
|
|
return err == nil && v == "1"
|
|
})
|
|
|
|
unblock()
|
|
|
|
// the widget shows its own copy for the status rather than the raw body
|
|
waitVisible(t, target.Locator("text=Conflict."))
|
|
|
|
eventually(t, waitTimeout, "the optimistic score was not rolled back", func() bool {
|
|
v, err := pollText(score(voterFrame, text))
|
|
return err == nil && v == "0"
|
|
})
|
|
}
|