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.
This commit is contained in:
Dmitry Verkhoturov
2026-08-21 17:53:12 -05:00
committed by GitHub
parent 1bb002348a
commit ff77f41a3a
29 changed files with 1820 additions and 406 deletions
+3 -5
View File
@@ -10,10 +10,6 @@
/frontend/node_modules/
/frontend/apps/remark42/node_modules/
/frontend/apps/remark42/public/
# e2e tests arficats
/frontend/e2e/playwright-report/
/frontend/e2e/playwright/.cache/
/frontend/e2e/test-results/
# source files
docker-compose.yml
@@ -36,4 +32,6 @@ debug.test
*.test
remark42
/backend/var/
/playwright-report/
# go e2e suite, never built into the image
/e2e/
+4 -9
View File
@@ -25,20 +25,15 @@ updates:
groups:
"Go modules updates":
dependency-type: "production"
- package-ecosystem: "npm"
directory: "/frontend"
open-pull-requests-limit: 0
ignore:
- dependency-name: "*"
- package-ecosystem: "gomod"
directory: "/e2e"
schedule:
interval: "monthly"
groups:
"NPM modules updates":
"Go modules updates":
dependency-type: "production"
"NPM modules updates for tests":
dependency-type: "development"
- package-ecosystem: "npm"
directory: "/frontend/e2e"
directory: "/frontend"
open-pull-requests-limit: 0
ignore:
- dependency-name: "*"
+2 -2
View File
@@ -66,13 +66,13 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: "v2.10.1"
version: "v2.13.1"
working-directory: backend/app
- name: golangci-lint on example directory
uses: golangci/golangci-lint-action@v9
with:
version: "v2.10.1"
version: "v2.13.1"
args: --config ../../.golangci.yml
working-directory: backend/_example/memory_store
+81 -17
View File
@@ -5,22 +5,32 @@ on:
branches: [master]
paths:
- ".github/workflows/e2e-tests.yml"
- "frontend/apps/remark42/**"
- "frontend/e2e/**"
- "frontend/Dockerfile.e2e"
- "backend/**"
- "frontend/**"
- "e2e/**"
- "compose-e2e-test.yml"
- "Dockerfile"
pull_request:
branches: [master]
paths:
- ".github/workflows/e2e-tests.yml"
- "frontend/apps/remark42/**"
- "frontend/e2e/**"
- "frontend/Dockerfile.e2e"
- "backend/**"
- "frontend/**"
- "e2e/**"
- "compose-e2e-test.yml"
- "Dockerfile"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
name: Tests
timeout-minutes: 60
# cheap gate: catches a compile break or a lint regression in the build-tagged suite
# without paying for the docker build and the browser download
vet:
name: Vet
timeout-minutes: 10
runs-on: ubuntu-latest
permissions:
contents: read
@@ -30,13 +40,67 @@ jobs:
with:
persist-credentials: false
- name: Build & run containers
id: tests
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
- uses: actions/upload-artifact@v7
if: always()
- name: Set up Go
uses: actions/setup-go@v7
with:
name: playwright-report
path: ./playwright-report/
go-version-file: e2e/go.mod
cache-dependency-path: e2e/go.sum
- name: Vet
run: cd e2e && go vet -tags=e2e ./...
- name: Lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.13.1
working-directory: e2e
args: --build-tags=e2e --config ../backend/.golangci.yml
tests:
name: Tests
needs: vet
timeout-minutes: 30
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: e2e/go.mod
cache-dependency-path: e2e/go.sum
# two directories: the driver (node plus the npm package) and the browser builds,
# which include firefox and webkit for the rendering tests
- name: Cache playwright driver and browsers
uses: actions/cache@v6
with:
path: |
~/.cache/ms-playwright
~/.cache/ms-playwright-go
key: playwright-${{ hashFiles('e2e/go.sum') }}
restore-keys: playwright-
- name: Build & start the stack
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
- name: Run e2e
run: cd e2e && go test -tags=e2e -count 1 -timeout 20m -v ./...
- name: Server logs on failure
if: failure()
run: docker compose -f compose-e2e-test.yml logs --tail=200
- name: Upload browser traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-traces
path: e2e/traces/
retention-days: 30
if-no-files-found: ignore
+3 -1
View File
@@ -25,8 +25,10 @@ compose-private-frontend.yml
compose-private.yml
/backend/_example/*/vendor
http-client.env.json
/playwright-report/
/backend/app/cmd/var
# ralphex progress logs
.ralphex/progress/
# traces from failed e2e runs
/e2e/traces/
+1
View File
@@ -12,6 +12,7 @@
- **Frontend**:
- Development: `cd frontend && pnpm dev:app`
- Tests: `cd frontend && pnpm test`
- **End-to-end**: `make e2e` drives the widget in a real browser; see `e2e/README.md`. Build-tagged, so `go test ./...` never runs it.
- **Lint**:
- Backend: `cd backend && golangci-lint run`
- **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`
+14 -3
View File
@@ -39,7 +39,18 @@ rundev:
docker compose -f compose-private.yml build
docker compose -f compose-private.yml up
e2e:
docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
e2e-up:
docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
.PHONY: bin docker dockerx release race_test backend frontend rundev e2e
e2e-down:
docker compose -f compose-e2e-test.yml down -v
# the suite brings the stack up itself when it finds none, so e2e-up is only worth running
# to keep the containers between invocations
e2e:
cd e2e && go test -tags=e2e -count 1 -timeout 20m ./...
e2e-ui:
cd e2e && E2E_HEADLESS=false E2E_KEEP=1 go test -tags=e2e -count 1 -v -timeout 20m ./...
.PHONY: bin docker dockerx release race_test backend frontend rundev e2e e2e-up e2e-down e2e-ui
+86 -12
View File
@@ -1,4 +1,8 @@
# compose for running e2e tests in CI
# compose for the e2e suite; the tests themselves run on the host, see e2e/
#
# every port is bound to the loopback interface on purpose. this stack runs with a known
# secret, dev oauth2 and an admin shared id, and `go test` can start it unattended, so it
# must not be reachable from the network. the suite only ever talks to 127.0.0.1.
services:
remark42:
@@ -10,26 +14,96 @@ services:
- SKIP_FRONTEND_TEST=true
image: ghcr.io/umputun/remark42:dev
container_name: "remark42"
container_name: "remark42-e2e"
ports:
- "127.0.0.1:8080:8080"
# dev oauth2 provider, registered from the REMARK_URL hostname
- "127.0.0.1:8084:8084"
# the hostname has to be a name rather than 127.0.0.1: the dev oauth2 server binds the
# host it reads out of REMARK_URL, and a loopback bind inside the container cannot be
# published. the suite maps the name back to 127.0.0.1 in the browser, see e2e/
environment:
# remark42 hostname used for proper dev auth work for tests container which connects to it
# using the such hostname (unlike local development where 127.0.0.1 is used)
- REMARK_URL=http://remark42:8080
- SECRET=12345
- DEBUG=true
- ADMIN_PASSWD=password
- AUTH_DEV=true # activate local OAuth "dev" listening on http://remark42:8084
- AUTH_DEV=true # local oauth2 "dev" provider, bound to the REMARK_URL host on :8084
# ADMIN_EDIT stays off: it gives admins an infinite edit window, which removes the
# countdown TestComment_EditWithinTheDeadline asserts on
- ADMIN_SHARED_ID=dev_user # set admin flag for default user on local oauth2
- AUTH_ANON=true
- AUTH_EMAIL_ENABLE=true
# the auth sender reads AUTH_EMAIL_FROM; NOTIFY_EMAIL_FROM feeds the notify module,
# which this stack does not enable
- AUTH_EMAIL_FROM=remark42@example.com
- SMTP_HOST=mailpit
- SMTP_PORT=1025
# default is 0.5/sec, which the suite exceeds whenever a test posts twice in a row
- UPDATE_LIMIT=100
volumes:
- ./var:/srv/var
- remark42-e2e-var:/srv/var
depends_on:
mailpit:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
tests:
build:
context: ./frontend
dockerfile: Dockerfile.e2e
depends_on: [remark42]
# a second instance whose edit window expires almost immediately, so the expired-edit
# path can be exercised without holding a test open for the default five minutes
remark42-shortedit:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-shortedit"
# this service has no build of its own, so without this a pull could substitute the
# published image for the one built from this checkout
pull_policy: never
# only needs the image the first service builds, not a running one
depends_on:
remark42:
condition: service_started
ports:
- "127.0.0.1:8081:8080"
# anonymous only: the dev oauth2 provider's port is hardcoded to 8084 against the
# REMARK_URL hostname, so a second instance on the same host would register the
# first one's provider
environment:
- REMARK_URL=http://remark42-shortedit:8081
- SECRET=12345
- AUTH_ANON=true
# long enough that the post round trip and the first assertion comfortably fit inside
# the window, short enough that waiting it out costs a few seconds
- EDIT_TIME=15s
- UPDATE_LIMIT=100
volumes:
- ./playwright-report:/frontend/e2e/playwright-report
- remark42-e2e-shortedit-var:/srv/var
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# catches the email-auth verification message; the suite reads it back over the HTTP API
mailpit:
image: axllent/mailpit:v1.30.7
container_name: "remark42-e2e-mailpit"
ports:
- "127.0.0.1:8025:8025"
# without this `--wait` returns as soon as the container starts, and remark42 can be
# healthy before anything is listening on 1025
healthcheck:
test: ["CMD", "/mailpit", "readyz"]
interval: 2s
timeout: 3s
retries: 30
# named rather than bind mounts, so `docker compose down -v` really does discard the
# databases and a run can start from an empty site
volumes:
remark42-e2e-var:
remark42-e2e-shortedit-var:
+86
View File
@@ -0,0 +1,86 @@
# End-to-end tests
Drives the widget in a real browser through playwright-go against a remark42 built from this checkout.
The import path is `github.com/mxschmitt/playwright-go`, which is what the module declares even though its repository is [playwright-community/playwright-go](https://github.com/playwright-community/playwright-go). Do not rewrite it to match the repository URL: the versions that carry the matching path cannot install their driver.
## Prerequisites
- Docker with compose, which the suite shells out to
- A Go toolchain matching `e2e/go.mod`
- Network access on the first run: the Playwright driver and the browsers are downloaded into the user cache directory, `~/.cache/…` on Linux and `~/Library/Caches/…` on macOS, and that download is the slowest part of a cold run
## Running
```
make e2e
```
The suite brings the compose stack up itself when it does not find one already answering, and tears it down again afterwards. To keep the containers between runs, start them first:
```
make e2e-up
make e2e
make e2e-down
```
Run from `e2e/`; the compose path is relative to it. A single test:
```
cd e2e && go test -tags=e2e -run TestComment_ReplyNestsUnderItsParent -v ./...
```
`make e2e-ui` runs with a visible browser and leaves the stack up. The env vars behind it:
- `E2E_HEADLESS=false` shows the browser and slows it to 50ms a step
- `E2E_KEEP=1` leaves the containers running afterwards, which only matters when the suite brought them up itself
- `E2E_DEBUG=1` logs every HTTP response of status 400 or above
- `E2E_BROWSERS=chromium` narrows the engines the rendering tests use, which is the quickest way to shorten a local run
Rate-limit responses are logged whether or not `E2E_DEBUG` is set, because they surface otherwise as unexplained locator timeouts.
The build tag keeps these out of `go test ./...`; nothing runs without `-tags=e2e`.
## When something fails
A failed test writes a Playwright trace to `e2e/traces/`, which CI uploads as an artifact. Open one with `npx playwright show-trace e2e/traces/<name>.zip`.
Beyond that: `docker compose -f compose-e2e-test.yml logs` for the server side, and mailpit's web UI on <http://127.0.0.1:8025> for anything email.
Before pushing, `cd e2e && go vet -tags=e2e ./...` and `golangci-lint run --build-tags=e2e --config ../backend/.golangci.yml`. CI runs both, and neither is covered by a plain `go vet ./...` because of the build tag.
## The stack
`compose-e2e-test.yml` at the repository root runs three services, each bound to the loopback interface since it holds a known secret and an admin shared id:
- **remark42** on `:8080`, with the dev oauth2 provider on `:8084`, anonymous and email sign-in
- **remark42-shortedit** on `:8081`, with `EDIT_TIME=15s` and anonymous sign-in only, since the dev oauth2 provider's port is fixed at 8084 and cannot be published twice. It exists so the expired-edit path is observable without holding a test open for the default five minutes
- **mailpit** on `:8025`, which catches the email-auth verification message for the suite to read back
Three settings exist for the tests rather than for realism, and each is there for a reason:
- `REMARK_URL` uses a **hostname**, not `127.0.0.1`. The dev oauth2 server binds whatever host it reads out of `REMARK_URL` (`localBindAddr` in go-pkgz/auth), and a loopback bind inside a container cannot be published. The browser maps the names back with `--host-resolver-rules`.
- `UPDATE_LIMIT=100`, because the default of 0.5 updates a second rejects any test that posts twice in a row.
- The suite paces its own calls to `/auth/`, which is limited to two requests a second by a bare literal at `backend/app/rest/api/rest.go:242` rather than by a setting. See `pauseForAuthLimit`.
## Isolation
Each test gets its own comment thread from a query string on the demo page, since the demo page passes `window.location.href` as `remark_config.url` and remark42 keys comments by it. A per-run id keeps threads apart from those an earlier run left behind.
The thread URL carries no underscores on purpose: collapse persistence stores its localStorage keys as `siteID_url_commentID` and splits them on `_`, so an underscore anywhere in the page URL makes the entry unreadable on the next load.
## Browsers
The `iframe_test.go` group runs in Chromium, Firefox and WebKit. It is about rendering rather than logic: the widget holds the frame hidden until its document reports itself inited, and the opaque canvas that guards against is a WebKit behaviour, so Chromium alone would not exercise it.
Those tests address the server as `127.0.0.1` rather than by name, since `--host-resolver-rules` is a Chromium flag and they need no dev oauth2, which is the only reason the hostname exists.
Everything else runs in Chromium alone, for the same reason inverted: those tests sign in, sign-in needs the dev oauth2 provider, and reaching it by name from the host is Chromium-only. Running them in the other engines would mean putting the suite back inside the compose network.
## Selectors
The production bundle strips `data-testid`, so tests use what ships: the stable class hooks the widget keeps outside CSS modules (`.auth-button`, `.auth-submit`, `.comment-actions`, `.sort-picker`, `.preloader`), `title` attributes on icon-only controls, and visible text. Three shapes are worth knowing:
- `.auth` only exists while signed out, so waiting on it hangs after sign-in. `widget()` waits on the comment form, which is present either way.
- Comments render through an IntersectionObserver, so one below the fold is an empty `article` with no text in it. That makes any absence assertion written as a text filter pass whether the comment is gone or merely off screen; count articles instead, which is what `articleCount` is for.
- Collapsing a thread hides the comment text, so a locator filtered by that text stops matching the element under test. `TestThread_CollapsePersistsAcrossReload` anchors on the comment's id instead.
+120
View File
@@ -0,0 +1,120 @@
//go:build e2e
package e2e
import (
"fmt"
"regexp"
"testing"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// signInDev completes the dev oauth2 flow, which opens a popup on the provider's own origin
func signInDev(t *testing.T, page playwright.Page, frame playwright.FrameLocator) {
t.Helper()
pauseForAuthLimit()
require.NoError(t, frame.Locator(".auth-button").Click())
popup, err := page.ExpectPopup(func() error {
return frame.Locator(".oauth-button").First().Click()
})
require.NoError(t, err)
require.NoError(t, popup.Locator("text=Authorize").Click())
// the popup closes itself through the ?selfClose stub. while an oauth sign-in is pending
// the widget listens for visibilitychange and window focus, so hand focus back to the
// frame to make it re-read auth state
pauseForAuthLimit()
require.NoError(t, page.Locator("#remark42 iframe").Press("Tab"))
assertSignedIn(t, frame)
}
// signInAnon signs in through the anonymous provider, an in-frame form with no popup
func signInAnon(t *testing.T, frame playwright.FrameLocator, username string) {
t.Helper()
pauseForAuthLimit()
require.NoError(t, frame.Locator(".auth-button").Click())
waitVisible(t, frame.Locator(".auth-dropdown"))
// the tabs only render when more than one form provider is enabled; with anonymous alone
// the form shows it directly. the labels are abbreviated ("anonym"), so match the radio
tab := frame.Locator(`label[for="form-provider-anonymous"]`)
if n, err := tab.Count(); err == nil && n > 0 {
require.NoError(t, tab.Click())
}
require.NoError(t, frame.Locator(".auth-input-username").Fill(username))
require.NoError(t, frame.Locator(".auth-submit").Click())
assertSignedIn(t, frame)
}
func TestAuth_DevProviderSignsIn(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
assertSignedIn(t, frame)
}
func TestAuth_AnonymousSignsIn(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInAnon(t, frame, "anontester")
assertSignedIn(t, frame)
name, err := frame.Locator(`[title="Open My Profile"]`).InnerText()
require.NoError(t, err)
assert.Contains(t, name, "anontester")
}
// TestAuth_EmailSignsIn drives the full email flow: request a code, read it back out of the
// mail catcher, and submit it. The token is what the widget sends, so a broken template or a
// broken token round-trip fails here rather than silently in production.
func TestAuth_EmailSignsIn(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
// the mailbox is per-run: mailpit keeps everything, and with the stack left up between
// runs a fixed address would let this test read a previous run's token
address := fmt.Sprintf("email-tester-%s@example.com", runID)
pauseForAuthLimit()
require.NoError(t, frame.Locator(".auth-button").Click())
waitVisible(t, frame.Locator(".auth-dropdown"))
require.NoError(t, frame.Locator(`label[for="form-provider-email"]`).Click())
require.NoError(t, frame.Locator(".auth-input-username").Fill("emailtester"))
require.NoError(t, frame.Locator(".auth-input-email").Fill(address))
require.NoError(t, frame.Locator(".auth-submit").Click())
waitVisible(t, frame.Locator(".auth-token-textarea"))
token := verificationToken(t, mailpitMessage(t, address))
require.NoError(t, frame.Locator(".auth-token-textarea").Fill(token))
require.NoError(t, frame.Locator(".auth-submit").Click())
assertSignedIn(t, frame)
}
// assertSignedIn checks the panel has swapped Sign In for the signed-in user's own controls.
// Sign Out is an icon button, so its title is the only text it carries
func assertSignedIn(t *testing.T, frame playwright.FrameLocator) {
t.Helper()
waitVisible(t, frame.Locator(`[title="Sign Out"]`))
waitVisible(t, frame.Locator(`[title="Open My Profile"]`))
waitHidden(t, frame.Locator(".auth-button"))
}
// verificationToken pulls the JWT out of the confirmation mail
func verificationToken(t *testing.T, body string) string {
t.Helper()
m := regexp.MustCompile(`[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}`).FindString(body)
require.NotEmpty(t, m, "no token in message body:\n%s", body)
return m
}
+163
View File
@@ -0,0 +1,163 @@
//go:build e2e
package e2e
import (
"fmt"
neturl "net/url"
"strings"
"testing"
"time"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestComment_PostRendersMarkdownAndSurvivesReload(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
// the marker has to be free of markdown syntax: the backend renders the comment, so a
// filter on the raw source would never match the rendered text
text := "hello from " + runID
posted := postCommentMatching(t, frame, text+" with **bold**", text)
// the backend renders the markdown, so a plain-text match would pass even if it stopped
bold, err := posted.Locator(".raw-content strong").InnerText()
require.NoError(t, err)
assert.Equal(t, "bold", bold)
frame = reload(t, page)
posted = comment(frame, text)
waitVisible(t, posted)
// the rendered markup has to survive the round trip too, not just the words
bold, err = posted.Locator(".raw-content strong").InnerText()
require.NoError(t, err)
assert.Equal(t, "bold", bold)
}
func TestComment_ReplyNestsUnderItsParent(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
parent := "parent " + runID
postComment(t, frame, parent)
require.NoError(t, actions(frame, parent).Locator(`button:has-text("Reply")`).Click())
reply := "reply " + runID
submitForm(t, replyForm(t, frame), reply)
// nesting is the point: the reply has to live inside the parent's thread, not beside it
parentThread := frame.Locator("[aria-expanded]", playwright.FrameLocatorLocatorOptions{HasText: parent}).First()
waitVisible(t, parentThread.Locator("article", playwright.LocatorLocatorOptions{HasText: reply}))
}
func TestComment_EditWithinTheDeadline(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
original := "before edit " + runID
postComment(t, frame, original)
// the countdown only renders while the comment is still editable
waitVisible(t, actions(frame, original).Locator(`[role="timer"]`))
require.NoError(t, actions(frame, original).Locator(`button:has-text("Edit")`).Click())
edited := "after edit " + runID
submitForm(t, replyForm(t, frame), edited)
waitVisible(t, comment(frame, edited))
// an edit replaces the comment rather than adding one, and counting is the only sound
// way to say the old text is gone: a text filter cannot tell absent from off screen
assert.Equal(t, 1, articleCount(t, frame), "editing should not add a comment")
txt, err := frame.Locator("article").First().InnerText()
require.NoError(t, err)
assert.NotContains(t, txt, original)
// the DOM update comes from the response, so without a reload a handler that returned the
// edited comment without storing it would pass
frame = reload(t, page)
waitVisible(t, comment(frame, edited))
txt, err = frame.Locator("article").First().InnerText()
require.NoError(t, err)
assert.NotContains(t, txt, original, "the edit should have been stored, not just rendered")
}
// TestComment_EditExpiresAfterTheDeadline runs against the second instance, whose edit window
// is short enough to wait out and long enough that the setup fits inside it. That instance
// offers anonymous auth only, see compose-e2e-test.yml.
// editWindow mirrors EDIT_TIME on the short-edit instance in compose-e2e-test.yml
const editWindow = 15 * time.Second
func TestComment_EditExpiresAfterTheDeadline(t *testing.T) {
page := newPage(t)
url := threadURLOn(t, shortEditURL)
frame := openURL(t, page, url)
signInAnon(t, frame, "expirytester")
text := "expires " + runID
postComment(t, frame, text)
editButton := actions(frame, text).Locator(`button:has-text("Edit")`)
timer := actions(frame, text).Locator(`[role="timer"]`)
waitVisible(t, editButton)
waitVisible(t, timer)
id, err := comment(frame, text).GetAttribute("id")
require.NoError(t, err)
commentID := strings.TrimPrefix(id, "remark42__comment-")
// the countdown fires onTimePassed, which drops the edit affordance entirely. the wait has
// to outlast the window itself, which started when the comment was posted
expiry := playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateHidden,
Timeout: playwright.Float(float64((waitTimeout + editWindow).Milliseconds())),
}
require.NoError(t, editButton.WaitFor(expiry))
require.NoError(t, timer.WaitFor(expiry))
// the button going away is only the widget being polite. the deadline is enforced by the
// backend, and without asking it directly this test would still pass with that guard
// removed, so put the request in from the signed-in page itself
edit := fmt.Sprintf("%s/api/v1/comment/%s?site=remark&url=%s", shortEditURL, commentID, neturl.QueryEscape(url))
status, body := pageFetch(t, page, "PUT", edit, map[string]string{"text": "edited after the deadline"})
assert.Equal(t, 400, status, "the backend should refuse an edit past the deadline")
assert.Contains(t, body, `"code":10`, "and say so with ErrCommentEditExpired")
}
func TestComment_DeleteRemovesTheText(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
// deliberately not the dev user: ADMIN_SHARED_ID makes that one an admin, and the widget
// sends admins to the admin endpoint, so signing in there would leave the path every
// ordinary reader takes untested
signInAnon(t, frame, "deletetester")
text := "doomed " + runID
survivor := "survivor " + runID
postComment(t, frame, text)
postComment(t, frame, survivor)
// delete is gated by window.confirm; without a handler playwright dismisses it and the
// comment quietly survives
page.OnDialog(func(d playwright.Dialog) { _ = d.Accept() })
require.NoError(t, actions(frame, text).Locator(`button:has-text("Delete")`).Click())
// the widget does not remove the node, it swaps the text for a tombstone. asserting the
// tombstone is present says more than asserting the old text is gone, which a comment
// scrolled out of view would also satisfy
waitVisible(t, comment(frame, "This comment was deleted"))
// and it stays gone rather than reappearing from cache on the next load. the survivor is
// what makes this assertion mean anything: without it a thread that had not rendered yet
// would satisfy "the deleted text is absent" just as well
frame = reload(t, page)
waitVisible(t, comment(frame, survivor))
assert.Equal(t, 1, articleCount(t, frame), "the deleted comment should be gone from the thread")
}
+607
View File
@@ -0,0 +1,607 @@
//go:build e2e
// Package e2e drives the remark42 widget in a real browser through playwright.
//
// The suite talks to the stack in compose-e2e-test.yml at the repository root and brings it
// up itself when nothing is listening. Files:
//
// - e2e_test.go: TestMain, shared helpers, constants
// - auth_test.go: dev, anonymous and email sign-in
// - comment_test.go: post, reply, edit, delete
// - vote_test.go: voting and its failure path
// - thread_test.go: sorting and collapse persistence
// - iframe_test.go: the iframe's color scheme and reveal handshake
// - widgets_test.go: last-comments, counter and the profile iframe
package e2e
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/require"
)
const (
// what the browser asks for. these have to be names rather than 127.0.0.1: the dev oauth2
// server binds whatever host it reads out of REMARK_URL, and a loopback bind inside the
// container cannot be published, so the browser resolves the names back with
// --host-resolver-rules and reaches the published ports
baseURL = "http://remark42:8080"
shortEditURL = "http://remark42-shortedit:8081"
// what this process asks for, since it is not the browser and has no resolver rules
probeURL = "http://127.0.0.1:8080"
shortEditProbeURL = "http://127.0.0.1:8081"
mailpitURL = "http://127.0.0.1:8025"
composeFile = "../compose-e2e-test.yml"
// every comment form carries this label, whatever mode it is in
commentFormSel = `form[aria-label="New comment"]`
// traces of failed tests land here; CI uploads the directory
traceDir = "traces"
// generous because CI runners are slower and less predictable than a laptop
waitTimeout = 15 * time.Second
// what a single locator call inside a poll body gets. playwright's own default is 30s,
// twice the budget of the loops here, so without this a missing element would block one
// attempt for longer than the whole loop and then report the wrong thing
pollTimeout = time.Second
)
var (
pw *playwright.Playwright
browser playwright.Browser
startedStack bool
// engines beyond chromium, launched on demand for the rendering tests
extraBrowsers = map[string]playwright.Browser{}
extraBrowsersMu sync.Mutex
// distinguishes this run's threads from those a previous run left in the database
runID = fmt.Sprintf("%d", time.Now().UnixNano())
authGate sync.Mutex
lastAuthCall time.Time
contextSeq atomic.Int64
// the default client has no timeout, so a port that accepts and then stalls would block
// a probe well past its own deadline and leave TestMain looking hung
probeClient = &http.Client{Timeout: 5 * time.Second}
)
// everything under /auth/ is rate limited to 2 requests a second, and that figure is a bare
// literal at backend/app/rest/api/rest.go:242 rather than a setting, so the suite has to pace
// itself: the widget calls /auth/status on every load, and again on visibilitychange or
// window focus while an oauth popup sign-in is pending. without this the limiter starts
// answering 429 and the widget renders as signed out
func pauseForAuthLimit() {
const spacing = 700 * time.Millisecond
authGate.Lock()
defer authGate.Unlock()
if wait := spacing - time.Since(lastAuthCall); wait > 0 {
time.Sleep(wait)
}
lastAuthCall = time.Now()
}
func TestMain(m *testing.M) {
if err := ensureStack(); err != nil {
log.Printf("[ERROR] stack not ready: %v", err)
teardown(1)
}
if err := playwright.Install(installOpts("chromium")); err != nil {
log.Printf("[ERROR] failed to install playwright: %v", err)
teardown(1)
}
var err error
if pw, err = playwright.Run(); err != nil {
log.Printf("[ERROR] failed to start playwright: %v", err)
teardown(1)
}
headless := os.Getenv("E2E_HEADLESS") != "false"
var slowMo float64
if !headless {
slowMo = 50 // slow the visible browser down enough to follow
}
browser, err = pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
Headless: playwright.Bool(headless),
SlowMo: playwright.Float(slowMo),
Args: []string{
"--host-resolver-rules=MAP remark42 127.0.0.1, MAP remark42-shortedit 127.0.0.1",
},
})
if err != nil {
_ = pw.Stop()
log.Printf("[ERROR] failed to launch browser: %v", err)
teardown(1)
}
code := m.Run()
for _, b := range extraBrowsers {
_ = b.Close()
}
_ = browser.Close()
_ = pw.Stop()
teardown(code)
}
// ensureStack waits for a running stack and starts one with compose when there is none
func ensureStack() error {
// every service, not just the first: compose-dev-backend.yml and `make rundev` also
// publish 8080, and adopting one of those would run the suite against a developer's own
// database and fail later as unexplained locator timeouts
if stackReady(2 * time.Second) {
return nil
}
log.Printf("[INFO] no complete stack on 127.0.0.1, bringing one up from %s", composeFile)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
// set before running, not after: a failed `up` leaves containers behind, and teardown
// has to know to remove them
startedStack = true
// --build matters: the image tag this compose file uses is the same one the dev compose
// files produce, so without it the suite can quietly test an image from another checkout
cmd := exec.CommandContext(ctx, "docker", "compose", "-f", composeFile,
"up", "-d", "--build", "--quiet-pull", "--wait")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("docker compose up: %w\n%s", err, out)
}
if !stackReady(waitTimeout) {
return fmt.Errorf("compose reported the stack healthy but it does not answer")
}
return nil
}
// stackReady reports whether every service the suite needs answers
func stackReady(timeout time.Duration) bool {
for _, url := range []string{
probeURL + "/ping",
shortEditProbeURL + "/ping",
mailpitURL + "/api/v1/messages",
} {
if err := serverReady(url, timeout); err != nil {
return false
}
}
return true
}
func teardown(code int) {
if startedStack && os.Getenv("E2E_KEEP") == "" {
composeDown()
}
os.Exit(code)
}
// composeDown is separate so its context is closed before teardown calls os.Exit. it is
// bounded because it runs after m.Run, where `go test -timeout` can no longer rescue a
// wedged daemon and the binary would otherwise hang until the CI job times out
func composeDown() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "compose", "-f", composeFile, "down", "-v")
if out, err := cmd.CombinedOutput(); err != nil {
log.Printf("[WARN] docker compose down: %v\n%s", err, out)
}
}
func serverReady(url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := probeClient.Get(url)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("%s not ready after %v", url, timeout)
}
// newPage opens an isolated context, so cookies and storage never leak between tests. the
// context records a trace, kept only when the test fails, since a CI-only failure otherwise
// leaves nothing to look at from the browser side
func newPage(t *testing.T) playwright.Page {
t.Helper()
return newPageOn(t, browser)
}
func newPageOn(t *testing.T, b playwright.Browser) playwright.Page {
t.Helper()
ctx, err := b.NewContext()
require.NoError(t, err)
tracing := ctx.Tracing().Start(playwright.TracingStartOptions{
Screenshots: playwright.Bool(true),
Snapshots: playwright.Bool(true),
}) == nil
// a test that opens two contexts would otherwise have them write the same file, and
// cleanup runs last-in-first-out, so the surviving trace would be of the page that was
// only setting the scenario up
seq := contextSeq.Add(1)
t.Cleanup(func() {
if tracing {
if !t.Failed() {
_ = ctx.Tracing().Stop()
_ = ctx.Close()
return
}
// say so rather than swallowing it: this runs only on a test that already failed,
// and a silently missing trace is what the reader goes looking for
name := strings.ReplaceAll(t.Name(), "/", "-")
path := filepath.Join(traceDir, fmt.Sprintf("%s-%d.zip", name, seq))
if serr := ctx.Tracing().Stop(path); serr != nil {
t.Logf("could not write the trace to %s: %v", path, serr)
}
}
_ = ctx.Close()
})
page, err := ctx.NewPage()
require.NoError(t, err)
debug := os.Getenv("E2E_DEBUG") != ""
page.OnResponse(func(r playwright.Response) {
switch {
case r.Status() == http.StatusTooManyRequests:
// the rate limiter answers the widget, not the test, so without this the failure
// arrives as an unexplained locator timeout
log.Printf("[WARN] rate limited: %s", r.URL())
case debug && r.Status() >= 400:
body, _ := r.Text()
log.Printf("[DEBUG] HTTP %d %s %s", r.Status(), r.URL(), body)
}
})
return page
}
// installOpts asks for the browser system libraries on CI only: install-deps shells out to
// apt with sudo, which is right for a runner and wrong for someone's laptop
func installOpts(browsers ...string) *playwright.RunOptions {
return &playwright.RunOptions{
Browsers: browsers,
WithDeps: os.Getenv("CI") != "",
}
}
// engines lists the browsers the rendering tests run in. hiding the frame until its
// document reports itself inited guards against a flash that was a WebKit one, so chromium
// alone would test the engine that never had the bug. E2E_BROWSERS narrows the set
func engines() []string {
if v := os.Getenv("E2E_BROWSERS"); v != "" {
return strings.Split(v, ",")
}
return []string{"chromium", "firefox", "webkit"}
}
// browserFor launches an engine once and keeps it for the rest of the run
func browserFor(t *testing.T, name string) playwright.Browser {
t.Helper()
if name == "chromium" {
return browser
}
extraBrowsersMu.Lock()
defer extraBrowsersMu.Unlock()
if b, ok := extraBrowsers[name]; ok {
return b
}
require.NoError(t, playwright.Install(installOpts(name)))
var bt playwright.BrowserType
switch name {
case "firefox":
bt = pw.Firefox
case "webkit":
bt = pw.WebKit
default:
t.Fatalf("unknown browser %q", name)
}
b, err := bt.Launch(playwright.BrowserTypeLaunchOptions{
Headless: playwright.Bool(os.Getenv("E2E_HEADLESS") != "false"),
})
require.NoError(t, err)
extraBrowsers[name] = b
return b
}
// renderURL is the thread url for the rendering tests. it uses the address directly rather
// than the mapped hostname, because --host-resolver-rules is a chromium flag and these tests
// need no dev oauth2, which is the only reason the hostname exists
func renderURL(t *testing.T) string {
t.Helper()
return threadURLOn(t, probeURL)
}
// threadURL gives each test its own comment thread. the demo page passes
// window.location.href as remark_config.url, and remark42 keys comments by it, so a unique
// query string isolates a test without resetting the database between runs
func threadURL(t *testing.T) string {
t.Helper()
return threadURLOn(t, baseURL)
}
// threadURLOn is threadURL against a given instance, for the tests that do not use the
// default one. every thread url goes through here so the rewriting below cannot be missed
func threadURLOn(t *testing.T, base string) string {
t.Helper()
// no underscores: collapse persistence stores its localStorage keys as
// "siteID_url_commentID" and splits them on "_" (store/thread/utils.ts), so an underscore
// anywhere in the page URL makes the entry unreadable on the next load. subtest names
// also carry a "/", which has no business in a query value
name := strings.NewReplacer("_", "-", "/", "-").Replace(strings.ToLower(t.Name()))
return fmt.Sprintf("%s/web/?e2e=%s-%s", base, name, runID)
}
// openThread loads the demo page on this test's own thread and waits for the widget
func openThread(t *testing.T, page playwright.Page) playwright.FrameLocator {
t.Helper()
return openURL(t, page, threadURL(t))
}
func openURL(t *testing.T, page playwright.Page, url string) playwright.FrameLocator {
t.Helper()
pauseForAuthLimit()
_, err := page.Goto(url, playwright.PageGotoOptions{
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
})
require.NoError(t, err)
return widget(t, page)
}
// reload re-navigates and returns the widget once the thread itself has loaded.
//
// waiting for the comment form is not enough here: root.tsx renders it as soon as the user
// fetch resolves, while the thread keeps its own preloader until /find answers. an assertion
// that a comment is absent would otherwise pass against a thread that has not rendered yet
func reload(t *testing.T, page playwright.Page) playwright.FrameLocator {
t.Helper()
pauseForAuthLimit()
_, err := page.ExpectResponse("**/api/v1/find**", func() error {
_, rerr := page.Reload()
return rerr
}, playwright.PageExpectResponseOptions{Timeout: playwright.Float(float64(waitTimeout.Milliseconds()))})
require.NoError(t, err)
frame := widget(t, page)
// the response has arrived; give preact the frame it needs to swap the preloader out
waitHidden(t, frame.Locator(`[role="list"] .preloader`))
return frame
}
// postComment types into the form the widget is currently showing and waits for the comment
// to appear in the thread
func postComment(t *testing.T, frame playwright.FrameLocator, text string) {
t.Helper()
postCommentMatching(t, frame, text, text)
}
// postCommentMatching posts source and waits for a comment carrying marker. the two differ
// whenever the source has markdown in it, since the thread shows the rendered result
func postCommentMatching(t *testing.T, frame playwright.FrameLocator, source, marker string) playwright.Locator {
t.Helper()
submitForm(t, frame.Locator(commentFormSel).First(), source)
posted := comment(frame, marker)
waitVisible(t, posted)
return posted
}
// submitForm fills a comment form and submits it. the submit label is Send, Reply or Save
// depending on the form's mode, so match on the type rather than the text
func submitForm(t *testing.T, form playwright.Locator, text string) {
t.Helper()
require.NoError(t, form.Locator("textarea").Fill(text))
require.NoError(t, form.Locator(`button[type="submit"]`).Click())
}
// comment locates the comment carrying the text.
//
// only sound for asserting that something IS there. comments render through an
// IntersectionObserver (components/root/in-view), so one below the fold is an empty
// placeholder article and no text filter matches it: an absence assertion written this way
// passes whether the comment is gone or merely off screen. use articleCount for absence
func comment(frame playwright.FrameLocator, text string) playwright.Locator {
return frame.Locator("article", playwright.FrameLocatorLocatorOptions{HasText: text})
}
// articleCount counts the comments in the thread, including any the viewport has not reached
// yet, which still occupy an empty article element.
//
// it is an absence oracle only after a reload. before one the widget keeps a deleted
// comment's node and only swaps its text, and the backend prunes a deleted comment from the
// tree only when it has no replies (store/service/tree.go)
func articleCount(t *testing.T, frame playwright.FrameLocator) int {
t.Helper()
n, err := frame.Locator("article").Count()
require.NoError(t, err)
return n
}
// replyForm returns the form the widget opened last, which is the reply or edit form.
//
// it waits for a second form to exist first: playwright auto-waits for the element a locator
// resolves to, not for a better one to appear, so filling .Last() too early would type into
// the top-level form and post a root comment instead of a reply
func replyForm(t *testing.T, frame playwright.FrameLocator) playwright.Locator {
t.Helper()
eventually(t, waitTimeout, "the reply or edit form did not open", func() bool {
n, err := frame.Locator(commentFormSel).Count()
return err == nil && n > 1
})
return frame.Locator(commentFormSel).Last()
}
// actions returns the action bar of the comment carrying the text
func actions(frame playwright.FrameLocator, text string) playwright.Locator {
return comment(frame, text).Locator(".comment-actions").First()
}
// widget returns the widget's iframe once its own content has rendered. it waits on the
// comment form rather than the auth panel, because .auth is only present while signed out
func widget(t *testing.T, page playwright.Page) playwright.FrameLocator {
t.Helper()
frame := page.FrameLocator("#remark42 iframe")
waitVisible(t, frame.Locator(commentFormSel).First())
return frame
}
func waitVisible(t *testing.T, loc playwright.Locator) {
t.Helper()
require.NoError(t, loc.WaitFor(playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateVisible,
Timeout: playwright.Float(float64(waitTimeout.Milliseconds())),
}))
}
func waitHidden(t *testing.T, loc playwright.Locator) {
t.Helper()
require.NoError(t, loc.WaitFor(playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateHidden,
Timeout: playwright.Float(float64(waitTimeout.Milliseconds())),
}))
}
// pollText reads an element's text with a timeout short enough to be used inside eventually
func pollText(loc playwright.Locator) (string, error) {
return loc.InnerText(playwright.LocatorInnerTextOptions{
Timeout: playwright.Float(float64(pollTimeout.Milliseconds())),
})
}
// pollAttr reads an attribute with the same short timeout
func pollAttr(loc playwright.Locator, name string) (string, error) {
return loc.GetAttribute(name, playwright.LocatorGetAttributeOptions{
Timeout: playwright.Float(float64(pollTimeout.Milliseconds())),
})
}
// eventually polls fn until it returns true, for assertions playwright's own auto-waiting
// does not cover, such as a value read out of the page with Evaluate
func eventually(t *testing.T, timeout time.Duration, msg string, fn func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if fn() {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("condition not met within %v: %s", timeout, msg)
}
// pageFetch issues a request from the page's own session, so it carries the browser's cookies
// and the XSRF header the widget's own calls carry. body may be nil for a bodyless request
func pageFetch(t *testing.T, page playwright.Page, method, url string, body interface{}) (status int, respBody string) {
t.Helper()
payload := ""
if body != nil {
raw, err := json.Marshal(body)
require.NoError(t, err)
payload = string(raw)
}
res, err := page.Evaluate(`async ({method, url, payload}) => {
const xsrf = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='));
const resp = await fetch(url, {
method,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': xsrf ? xsrf.slice(xsrf.indexOf('=') + 1) : '',
},
body: payload === '' ? undefined : payload,
});
return {status: resp.status, body: await resp.text()};
}`, map[string]interface{}{"method": method, "url": url, "payload": payload})
require.NoError(t, err)
out, ok := res.(map[string]interface{})
require.True(t, ok, "unexpected shape from the page: %#v", res)
switch v := out["status"].(type) {
case int:
status = v
case float64:
status = int(v)
default:
t.Fatalf("unexpected status type %T in %#v", v, out)
}
respBody, _ = out["body"].(string)
return status, respBody
}
// mailpitMessage returns the newest message sent to the address, with its body
func mailpitMessage(t *testing.T, to string) string {
t.Helper()
type item struct {
ID string `json:"ID"`
To []struct {
Address string `json:"Address"`
} `json:"To"`
}
var found string
eventually(t, waitTimeout, "no message for "+to, func() bool {
resp, err := probeClient.Get(mailpitURL + "/api/v1/messages?limit=50")
if err != nil {
return false
}
defer func() { _ = resp.Body.Close() }()
var list struct {
Messages []item `json:"messages"`
}
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
return false
}
for _, msg := range list.Messages {
for _, addr := range msg.To {
if strings.EqualFold(addr.Address, to) {
found = msg.ID
return true
}
}
}
return false
})
resp, err := probeClient.Get(fmt.Sprintf("%s/api/v1/message/%s", mailpitURL, found))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var body struct {
Text string `json:"Text"`
HTML string `json:"HTML"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
return body.Text + body.HTML
}
+15
View File
@@ -0,0 +1,15 @@
module github.com/umputun/remark42/e2e
go 1.25.0
require (
github.com/mxschmitt/playwright-go v0.6201.0
github.com/stretchr/testify v1.12.1
)
require (
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-stack/stack v1.8.1 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
)
+57
View File
@@ -0,0 +1,57 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
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/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/mxschmitt/playwright-go v0.6201.0 h1:VfBN0/A+8hIKhbJA/GrQZ4OIywXy12n8tcY073N9STs=
github.com/mxschmitt/playwright-go v0.6201.0/go.mod h1:A7VtrS3j/c8ToGnSVUaOfNtQQVxi6JotUS0jeuus6r4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+194
View File
@@ -0,0 +1,194 @@
//go:build e2e
package e2e
import (
"regexp"
"testing"
"time"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The parent page sets color-scheme on the iframe element from the theme param. If the iframe
// document does not carry the same color-scheme before its bundle runs, the canvas is painted
// opaque white instead of staying transparent. Blocking the bundle freezes the document in
// that pre-script state, so these assert the inline head script has already applied the scheme.
func TestIframe_ColorSchemeIsSetBeforeTheBundleRuns(t *testing.T) {
cases := []struct {
name string
query string
expected string
}{
{"dark theme", "?site_id=remark&theme=dark", "dark"},
{"light theme", "?site_id=remark&theme=light", "light"},
{"no theme falls back to light", "?site_id=remark", "light"},
}
for _, engine := range engines() {
for _, tc := range cases {
t.Run(engine+"/"+tc.name, func(t *testing.T) {
page := newPageOn(t, browserFor(t, engine))
require.NoError(t, page.Route(regexp.MustCompile(`remark\.m?js$`), func(route playwright.Route) {
_ = route.Abort()
}))
pauseForAuthLimit()
_, err := page.Goto(probeURL + "/web/iframe.html" + tc.query)
require.NoError(t, err)
inline, err := page.Evaluate("() => document.documentElement.style.colorScheme")
require.NoError(t, err)
assert.Equal(t, tc.expected, inline)
computed, err := page.Evaluate("() => getComputedStyle(document.documentElement).colorScheme")
require.NoError(t, err)
assert.Equal(t, tc.expected, computed)
})
}
}
}
// TestIframe_ParentAndDocumentAgreeOnColorScheme covers the other half of the same defect.
// The tests above load the widget document on its own, so removing the color-scheme the
// parent puts on the iframe element would not disturb them, and it is the disagreement
// between the two that paints the opaque canvas.
func TestIframe_ParentAndDocumentAgreeOnColorScheme(t *testing.T) {
schemes := map[string]*playwright.ColorScheme{
"dark": playwright.ColorSchemeDark,
"light": playwright.ColorSchemeLight,
}
for _, engine := range engines() {
for theme, scheme := range schemes {
t.Run(engine+"/"+theme, func(t *testing.T) {
page := newPageOn(t, browserFor(t, engine))
// the demo page reads prefers-color-scheme rather than a query parameter
require.NoError(t, page.EmulateMedia(playwright.PageEmulateMediaOptions{ColorScheme: scheme}))
pauseForAuthLimit()
_, err := page.Goto(renderURL(t))
require.NoError(t, err)
widget(t, page)
onElement, err := page.Evaluate(`() => document.querySelector('#remark42 iframe').style.colorScheme`)
require.NoError(t, err)
assert.Equal(t, theme, onElement, "the parent has to mark the iframe element")
inDocument, err := page.FrameLocator("#remark42 iframe").Locator(":root").Evaluate(
`(el) => getComputedStyle(el).colorScheme`, nil)
require.NoError(t, err)
assert.Equal(t, theme, inDocument, "and the document inside it has to agree")
})
}
}
}
// Browsers paint a default surface for an iframe before its document is parsed, and that
// surface is opaque when the element carries a color-scheme the document does not have yet.
// WebKit shows it as a white flash on dark host pages. The parent keeps the iframe hidden
// until the document reports itself inited, so the surface is never presented.
const (
// REVEAL_TIMEOUT in app/utils/create-iframe.ts. the fallback timer starts when the iframe
// is created, during page load, so any assertion with a deadline at or past this value can
// be satisfied by the fallback alone and says nothing about the message path. bound the
// message-path assertions well under it
revealTimeout = 5 * time.Second
// generous enough for a cold navigation on a loaded runner, and still well under the
// fallback, which is the point of the assertion
messageRevealBudget = 3 * time.Second
)
// openWithBlockedIframeDoc loads the demo page with the widget document aborted, so the
// iframe element exists but never reports itself inited
func openWithBlockedIframeDoc(t *testing.T, page playwright.Page) time.Time {
t.Helper()
require.NoError(t, page.Route(regexp.MustCompile(`/web/iframe\.html`), func(route playwright.Route) {
_ = route.Abort()
}))
// the gate's sleep happens before the iframe exists, so it must not count against the
// reveal budgets: start the clock with the navigation
pauseForAuthLimit()
start := time.Now()
_, err := page.Goto(renderURL(t))
require.NoError(t, err)
require.NoError(t, page.Locator("#remark42 iframe").WaitFor(playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateAttached,
Timeout: playwright.Float(float64(waitTimeout.Milliseconds())),
}))
return start
}
func iframeVisibility(t *testing.T, page playwright.Page) string {
t.Helper()
v, err := page.Evaluate(`() => {
const iframe = document.querySelector('#remark42 iframe');
return iframe ? iframe.style.visibility : 'no-iframe';
}`)
require.NoError(t, err)
s, _ := v.(string)
return s
}
func TestIframe_StaysHiddenUntilTheDocumentReportsInited(t *testing.T) {
forEachEngine(t, func(t *testing.T, page playwright.Page) {
start := openWithBlockedIframeDoc(t, page)
// sampling once would pass against a widget that revealed a frame moments later, so
// hold the assertion for a stretch of the window in which it must stay hidden
for time.Since(start) < revealTimeout/2 {
require.Equal(t, "hidden", iframeVisibility(t, page))
time.Sleep(100 * time.Millisecond)
}
// a slow run could have let the fallback fire, which would make the assertion above
// pass or fail for the wrong reason. fail loudly instead of flaking
assert.Less(t, time.Since(start), revealTimeout)
})
}
// forEachEngine runs body once per configured browser, on a fresh page each time
func forEachEngine(t *testing.T, body func(t *testing.T, page playwright.Page)) {
t.Helper()
for _, engine := range engines() {
t.Run(engine, func(t *testing.T) {
body(t, newPageOn(t, browserFor(t, engine)))
})
}
}
// The reveal has to come from the inited message, not the fallback: a broken message listener
// would leave the widget invisible for five seconds on every load. The fallback timer starts
// when the iframe is created, partway through the navigation, so bounding only the poll leaves
// the navigation window unmeasured. Time the whole thing.
func TestIframe_IsRevealedByTheInitedMessage(t *testing.T) {
forEachEngine(t, func(t *testing.T, page playwright.Page) {
pauseForAuthLimit()
start := time.Now()
_, err := page.Goto(renderURL(t))
require.NoError(t, err)
eventually(t, messageRevealBudget, "iframe was not revealed by the inited message", func() bool {
return iframeVisibility(t, page) == "visible"
})
assert.Less(t, time.Since(start), revealTimeout)
waitVisible(t, page.Locator("#remark42 iframe"))
})
}
// The aborted document never reports its height, so the iframe box stays empty and a
// visibility assertion on geometry would fail. Assert the property the fallback actually sets.
func TestIframe_IsRevealedByTheTimeoutWhenInitedNeverArrives(t *testing.T) {
forEachEngine(t, func(t *testing.T, page playwright.Page) {
start := openWithBlockedIframeDoc(t, page)
// and not before it: without a lower bound, shortening the fallback to a value that
// defeats its purpose would still pass
eventually(t, revealTimeout*2, "fallback never revealed the iframe", func() bool {
return iframeVisibility(t, page) == "visible"
})
assert.Greater(t, time.Since(start), revealTimeout*3/4,
"the reveal came too early to have been the fallback timer")
})
}
+122
View File
@@ -0,0 +1,122 @@
//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")
}
+110
View File
@@ -0,0 +1,110 @@
//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"
})
}
+151
View File
@@ -0,0 +1,151 @@
//go:build e2e
package e2e
import (
"encoding/json"
"fmt"
"strconv"
"testing"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestWidgets_LastCommentsRendersIntoTheHostPage covers the one component that writes into the
// embedding page rather than into the widget's iframe, so a change to the embed script can
// break it without any iframe test noticing
func TestWidgets_LastCommentsRendersIntoTheHostPage(t *testing.T) {
text := "last comments " + runID
poster := newPage(t)
frame := openThread(t, poster)
signInAnon(t, frame, "lastcommenter")
postComment(t, frame, text)
page := newPage(t)
pauseForAuthLimit()
_, err := page.Goto(baseURL + "/web/last-comments.html")
require.NoError(t, err)
list := page.Locator(".remark42__last-comments")
waitVisible(t, list)
waitVisible(t, list.Locator("text="+text))
}
// TestWidgets_CounterFillsInTheCommentCount covers the other host-page script.
//
// The demo page hard-codes both counters to one fixed url, so "some digits appeared" would
// hold just as well if the script always wrote a constant. Post to that same url and assert
// the rendered number moves by exactly as many comments as were added.
func TestWidgets_CounterFillsInTheCommentCount(t *testing.T) {
const counted = "https://remark42.com/demo/"
poster := newPage(t)
frame := openThread(t, poster)
signInAnon(t, frame, "countertester")
before := commentCount(t, poster, counted)
for i := range 2 {
status, body := pageFetch(t, poster, "POST", baseURL+"/api/v1/comment?site=remark", map[string]interface{}{
"text": fmt.Sprintf("counted %d %s", i, runID),
"locator": map[string]string{"site": "remark", "url": counted},
})
require.Equal(t, 201, status, "could not seed a comment: %s", body)
}
page := newPage(t)
// the demo page points both of its counters at the same url, one through data-url and one
// through remark_config, so as shipped the two branches are indistinguishable. add a third
// node carrying this test's own thread, which only the data-url branch can resolve
thread := threadURL(t)
err := page.AddInitScript(playwright.Script{
Content: playwright.String(`document.addEventListener('DOMContentLoaded', () => {
const node = document.createElement('span');
node.className = 'remark42__counter';
node.id = 'own-thread-counter';
node.dataset.url = ` + fmt.Sprintf("%q", thread) + `;
document.body.appendChild(node);
})`),
})
require.NoError(t, err)
pauseForAuthLimit()
_, err = page.Goto(baseURL + "/web/counter.html")
require.NoError(t, err)
counters := page.Locator(".remark42__counter")
count, err := counters.Count()
require.NoError(t, err)
require.NotZero(t, count, "the counter demo page should carry at least one counter node")
want := strconv.Itoa(before + 2)
for i := range count {
node := counters.Nth(i)
id, aerr := node.GetAttribute("id")
require.NoError(t, aerr)
if id == "own-thread-counter" {
continue // asserted separately below, it counts a different url
}
eventually(t, waitTimeout, "counter never reported the seeded comments", func() bool {
txt, ierr := pollText(node)
return ierr == nil && txt == want
})
}
// and the data-url branch resolves to its own thread rather than the page's
own := strconv.Itoa(commentCount(t, poster, thread))
eventually(t, waitTimeout, "the data-url counter did not report its own thread", func() bool {
txt, ierr := pollText(page.Locator("#own-thread-counter"))
return ierr == nil && txt == own
})
}
// commentCount asks the API what the counter should be showing
func commentCount(t *testing.T, page playwright.Page, url string) int {
t.Helper()
status, body := pageFetch(t, page, "POST", baseURL+"/api/v1/counts?site=remark", []string{url})
require.Equal(t, 200, status, "counts: %s", body)
var counts []struct {
Count int `json:"count"`
}
require.NoError(t, json.Unmarshal([]byte(body), &counts))
require.Len(t, counts, 1)
return counts[0].Count
}
// TestWidgets_ProfileOpensInItsOwnIframe covers the postMessage handoff: the widget asks the
// parent to open the profile, and the parent creates a second iframe outside #remark42
func TestWidgets_ProfileOpensInItsOwnIframe(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
// match on the page parameter rather than the word: the widget's own src carries the
// thread url, which contains this test's name
profile := page.Locator(`iframe[src*="page=profile"]`)
before, err := profile.Count()
require.NoError(t, err)
require.Zero(t, before, "no profile iframe should exist before it is asked for")
require.NoError(t, frame.Locator(`[title="Open My Profile"]`).Click())
waitVisible(t, profile.First())
src, err := profile.First().GetAttribute("src")
require.NoError(t, err)
assert.Contains(t, src, "current=1", "the profile should open on the signed-in user")
// the frame reveals itself after five seconds whether or not its bundle ran, so a 404 or a
// dead script would satisfy a visibility check on its own. look inside it
profileFrame := page.FrameLocator(`iframe[src*="page=profile"]`)
waitVisible(t, profileFrame.Locator("text=dev_user").First())
// it lives outside the widget's own container, appended straight to the body
inWidget, err := page.Locator(`#remark42 iframe[src*="page=profile"]`).Count()
require.NoError(t, err)
assert.Zero(t, inWidget)
}
-4
View File
@@ -1,7 +1,3 @@
/.vscode/
/.idea/
# e2e tests arficats
/e2e/playwright-report/
/e2e/playwright/.cache/
/e2e/test-results/
+1 -3
View File
@@ -7,14 +7,12 @@ Non-obvious constraints in the frontend toolchain and widget. Read before bumpin
CI staying green does **not** mean every pin is consistent — `.nvmrc` in particular is never read by CI, so it can silently drift. After changing the node or pnpm version, grep the whole repo and update every one of these, not just the ones CI exercises:
- `Dockerfile` (production image) — `FROM node:X-alpine` and `npm i -g pnpm@X.Y.Z`
- `frontend/Dockerfile.e2e``FROM mcr.microsoft.com/playwright:vX.Y.Z-noble` **and** `corepack prepare pnpm@X.Y.Z`
- `site/Dockerfile`, `site/Dockerfile.dev``FROM node:X-alpine` (site uses yarn, not pnpm)
- `frontend/.nvmrc`, `site/.nvmrc` — not read by CI at all; only matters to a human running `nvm use` locally. This is the one that drifted unnoticed: it sat at `16` through the whole node-20 migration because nothing red ever pointed at it.
- Every `package.json`'s `packageManager` field (`frontend/package.json`, `frontend/apps/remark42/package.json``frontend/e2e/package.json` has none) and `frontend/apps/remark42/package.json`'s `engines` block
- Every `package.json`'s `packageManager` field (`frontend/package.json`, `frontend/apps/remark42/package.json`) and `frontend/apps/remark42/package.json`'s `engines` block
- `pnpm/action-setup@vN` blocks in `.github/workflows/ci-frontend.yml` (5) and `release.yml` (2) — pin `version:` to the **exact** patch (e.g. `10.10.0`), matching `packageManager`, not just the major. A floating major here is silent in CI (it just resolves to whatever the latest patch is at run time) but breaks the "Dockerfile and CI use the same pnpm" guarantee.
- `node:` matrices in `.github/workflows/ci-frontend.yml` (every entry, not just the first) and the `node-version:` values in `release.yml`
- `site/package.json`'s `engines.node` and `engines.yarn` (site uses yarn, so its `packageManager` moves independently)
- `frontend/e2e/package.json`'s `@playwright/test`/`playwright` versions must match `frontend/Dockerfile.e2e`'s base image tag exactly, or the e2e container's bundled browser revision mismatches what the npm package expects.
When bumping pnpm/node, also re-check `frontend/apps/remark42/package.json`'s `engines` field — it's separate from `packageManager` and won't update itself.
-15
View File
@@ -1,15 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.61.1-noble
ENV CI true
WORKDIR /frontend
COPY ./package.json ./pnpm-workspace.yaml ./pnpm-lock.yaml /frontend/
COPY ./e2e/package.json /frontend/e2e/
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate && pnpm install
COPY ./e2e/playwright.config.ts /frontend/e2e/
COPY ./e2e/tests /frontend/e2e/tests/
WORKDIR /frontend/e2e
CMD pnpm test
-4
View File
@@ -1,4 +0,0 @@
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/
-19
View File
@@ -1,19 +0,0 @@
{
"name": "@remark42/tests",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test"
},
"author": "Paul Mineev <paul@mineev.me>",
"license": "MIT",
"devDependencies": {
"@playwright/test": "1.61.1",
"@types/node": "^26.0.1",
"nanoid": "^5.1.16",
"playwright": "1.61.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
-107
View File
@@ -1,107 +0,0 @@
import type { PlaywrightTestConfig } from '@playwright/test'
import { devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: './tests',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://remark42:8080' : 'http://127.0.0.1:8080',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// },
}
export default config
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('prettier').Config} */
module.exports = {
singleQuote: true,
semi: false,
arrowParens: 'always',
trailingComma: 'es5',
printWidth: 120,
}
-83
View File
@@ -1,83 +0,0 @@
import { test, expect, type Page } from '@playwright/test'
// the parent page sets color-scheme on the iframe element from the theme param. if the iframe
// document does not carry the same color-scheme before its bundle runs, the canvas is painted
// opaque white instead of staying transparent. block the bundle to freeze the document in that
// pre-script state and assert the inline head script has already applied the scheme.
test.describe('Iframe color scheme', () => {
test.beforeEach(async ({ page }) => {
await page.route(/remark\.m?js$/, (route) => route.abort())
})
const cases = [
{ name: 'dark theme', query: '?site_id=remark&theme=dark', expected: 'dark' },
{ name: 'light theme', query: '?site_id=remark&theme=light', expected: 'light' },
{ name: 'no theme falls back to light', query: '?site_id=remark', expected: 'light' },
]
for (const { name, query, expected } of cases) {
test(name, async ({ page }) => {
await page.goto(`/web/iframe.html${query}`)
const inline = await page.evaluate(() => document.documentElement.style.colorScheme)
expect(inline).toBe(expected)
const computed = await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)
expect(computed).toBe(expected)
})
}
})
// browsers paint a default surface for an iframe before its document is parsed, and that surface
// is opaque when the element carries a color-scheme the document does not have yet. WebKit shows
// it as a white flash on dark host pages. the parent keeps the iframe hidden until the document
// reports itself inited, so the surface is never presented.
test.describe('Iframe reveal', () => {
// REVEAL_TIMEOUT in app/utils/create-iframe.ts. the fallback timer starts when the
// iframe is created, during page load, so any assertion with a deadline at or past
// this value can be satisfied by the fallback alone and says nothing about the
// message path. bound the message-path assertions well under it.
const REVEAL_TIMEOUT = 5000
const MESSAGE_REVEAL_BUDGET = 1500
const visibility = (page: Page) =>
page.evaluate(() => {
const iframe = document.querySelector<HTMLIFrameElement>('#remark42 iframe')
return iframe ? iframe.style.visibility : 'no-iframe'
})
test('stays hidden until the document reports inited', async ({ page }) => {
await page.route(/\/web\/iframe\.html/, (route) => route.abort())
const start = Date.now()
await page.goto('/web/')
await page.waitForSelector('#remark42 iframe', { state: 'attached' })
expect(await visibility(page)).toBe('hidden')
// a slow run could have let the fallback fire, which would make the assertion
// above pass or fail for the wrong reason. fail loudly instead of flaking.
expect(Date.now() - start).toBeLessThan(REVEAL_TIMEOUT)
})
// must reveal from the inited message, not the fallback: a broken message listener would
// leave the widget invisible for 5s on every load. the fallback timer starts when the
// iframe is created, partway through goto(), so bounding only the poll leaves the
// navigation window unmeasured. time the whole thing.
test('is revealed by the inited message, well before the fallback', async ({ page }) => {
const start = Date.now()
await page.goto('/web/')
await expect.poll(() => visibility(page), { timeout: MESSAGE_REVEAL_BUDGET }).toBe('visible')
expect(Date.now() - start).toBeLessThan(REVEAL_TIMEOUT)
await expect(page.locator('#remark42 iframe')).toBeVisible()
})
// the aborted document never reports its height, so the iframe box stays empty and
// toBeVisible() would fail on geometry. assert the property the fallback actually sets.
test('is revealed by the timeout when inited never arrives', async ({ page }) => {
await page.route(/\/web\/iframe\.html/, (route) => route.abort())
await page.goto('/web/')
await page.waitForSelector('#remark42 iframe', { state: 'attached' })
await expect.poll(() => visibility(page), { timeout: REVEAL_TIMEOUT * 2 }).toBe('visible')
})
})
-30
View File
@@ -1,30 +0,0 @@
import { test } from '@playwright/test'
import { nanoid } from 'nanoid'
import * as path from 'path'
test.describe('Post comment', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/web/')
})
test('as dev user', async ({ page, browserName }) => {
const iframe = page.frameLocator('iframe[name]')
await iframe.locator('text=Sign In').click()
const [authPage] = await Promise.all([
page.waitForEvent('popup'),
iframe.locator("[title='Sign In with Dev']").click(),
])
await authPage.locator('text=Authorize').click()
// triggers tab visibility and enables widget to re-render with auth state
await page.press('iframe[name]', 'Tab')
await iframe.locator('textarea').click()
const message = `Hello world! ${nanoid()}`
await iframe.locator('textarea').type(message)
await iframe.locator('text=Send').click()
// checks if comment was posted
iframe.locator(`text=${message}`).first()
await page.reload()
// checks if saved comment is visible
iframe.locator(`text=${message}`).first()
})
})
-83
View File
@@ -351,27 +351,6 @@ importers:
specifier: '>=5.2.6 <6.0.0'
version: 5.2.6(tslib@2.8.1)(webpack-cli@4.10.0)(webpack@5.108.3)
e2e:
devDependencies:
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
'@types/node':
specifier: ^26.0.1
version: 26.0.1
nanoid:
specifier: ^5.1.16
version: 5.1.16
playwright:
specifier: 1.61.1
version: 1.61.1
ts-node:
specifier: ^10.9.2
version: 10.9.2(@swc/core@1.2.205)(@types/node@26.0.1)(typescript@5.9.3)
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
'@adobe/css-tools@4.5.0':
@@ -1542,11 +1521,6 @@ packages:
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -3472,11 +3446,6 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4570,11 +4539,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.16:
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
engines: {node: ^18 || >=20}
hasBin: true
nanospinner@1.2.2:
resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==}
@@ -4885,16 +4849,6 @@ packages:
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
engines: {node: '>=16.0.0'}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -7938,10 +7892,6 @@ snapshots:
tslib: 2.8.1
tsyringe: 4.10.0
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@polka/url@1.0.0-next.29': {}
'@prefresh/babel-plugin@0.4.4': {}
@@ -10278,9 +10228,6 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -11544,8 +11491,6 @@ snapshots:
nanoid@3.3.18: {}
nanoid@5.1.16: {}
nanospinner@1.2.2:
dependencies:
picocolors: 1.1.1
@@ -11847,14 +11792,6 @@ snapshots:
pvutils: 1.1.5
tslib: 2.8.1
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
possible-typed-array-names@1.1.0: {}
postcss-attribute-case-insensitive@5.0.2(postcss@8.5.26):
@@ -13107,26 +13044,6 @@ snapshots:
optionalDependencies:
'@swc/core': 1.2.205
ts-node@10.9.2(@swc/core@1.2.205)(@types/node@26.0.1)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 26.0.1
acorn: 8.17.0
acorn-walk: 8.3.5
arg: 4.1.3
create-require: 1.1.1
diff: 9.0.0
make-error: 1.3.6
typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optionalDependencies:
'@swc/core': 1.2.205
tsconfig-paths-webpack-plugin@3.5.2:
dependencies:
chalk: 4.1.2
-1
View File
@@ -1,3 +1,2 @@
packages:
- "apps/*"
- "e2e"