Broaden the e2e suite from 21 cases to 63, and harden its harness (#2196)

* Pin the published /web surface in the e2e suite

#2178 renamed the widget bundles from .js to .mjs and the URLs earlier
releases served under those names stopped resolving. Three were noticed
from the demo site; the rest, including every locale chunk, were found
only by requesting the whole surface of both images over HTTP. #2192
restored them with a server-side alias, and nothing in the suite would
have caught the break or would notice it returning.

Two cases with deliberately different criteria. The documented names are
written out, because the documentation decides that list and not the
build: an operator pastes privacy.html into an OAuth application, the
nginx manual proxies index.html by name, and the integration guides start
from the embed script. Everything else is taken from the build itself, so
whatever the bundler emitted has to serve identical bytes under its
legacy .js name and parse as a classic script, which is the premise
serving one under the other rests on. A third case requests a name that
does not exist, without which a fallback serving one page for everything
would keep the whole table green.

All of them check the content type as well as the bytes: nosniff is set
on every response, so a bundle served as text/plain is as broken as one
that 404s while comparing equal.

On e3d1d0e2, the commit before the alias, this fails with 32 red subtests.

* Cover the widget behavior the e2e suite never drove

Removing npm from the widget takes the jest tests with it, and everything
here was protected by jest or by nothing at all.

Signing out was untested at every level, and the panel repainting is the
half that always works: the assertion after the reload is the one that
catches a session the server never ended. The edit form has to hand back
the source that was posted and not the rendered comment, which #2040
shipped the other way round, taking every entity and tag the author had
written with it. A draft has to survive a reload and be gone once the
comment is posted. A refused comment has to stay in the form with
something said about it, so that case drives the backend's own
restricted-words code and then retries with the route removed.

Uploads had no browser coverage in either direction. The posted case
asserts naturalWidth instead of visibility, since a broken src still
renders as an empty box, and the failing case holds the intercepted
request long enough for the in-flight state to be observed: without that,
"the text is unchanged afterwards" would hold for an upload that never
started.

Moderation and reader-side hiding are asserted from a page other than the
one that made the change. Hiding seeds a second author, so it proves one
person is hidden and the thread not emptied, and both the blocked
author and the moderated one carry the run id in their names: a block and
a verification are properties of the user and outlive the run in the
stack's database, so a fixed name works exactly once.

Locales were the largest hole. Each catalog is a chunk fetched at
runtime, and loadLocale falls back to english on any failure instead of
throwing, exactly as an unrecognized name does, so every case compares
the rendered string against the file on disk. One case per catalog
covers that they are all served and render; another fetches one through
the widget document and asserts it parsed, which is the half a chunk that
serves but fails to parse would slip through. The delete page and the
last-comments stylesheet had no coverage of any kind.

Two things the suite itself needed. The widget's own aria-label is
translated, so commentFormSel only ever finds an english widget and a
localized case cannot use widget(). And the auth probe is capped at two
requests a second for the whole suite, hard-coded in rest.go: the added
cases pushed past it and the suite began manufacturing its own 429s,
which render as a signed-out widget and fail whichever test happens to be
signing in, so the pacing gap is wider and the locale cases stub the
probe they never needed.

* Harden the e2e harness against silent failures and stale stacks

Three things the suite could not tell you about itself.

A browser failure nothing asserts on now fails the test that caused it.
Uncaught exceptions are the ones worth the machinery: a widget throwing
while it renders leaves most of these cases green, since they assert on
elements the browser lays out either way. Rate-limit responses are
recorded as well as logged for the same reason. A test driving an error
path declares what it expects by substring, so a case that means to
break something says which thing.

The stack the suite adopts is now checked against the sources under
test. Every checkout builds the image tag the compose file names, so a
stack from another worktree, or from this one before an edit, answers on
these ports and passes every readiness probe while serving code nobody
is looking at. stamp.sh digests what goes into the image, compose passes
it as the revision label, and a mismatch is refused with what to do
about it. Checked after our own build too, or a stamp that never reaches
the image would be a guard that silently passes everything.

assertSignedIn no longer waits out the whole timeout on a refused status
read. /auth/ is capped at two requests a second for the entire suite, a
bare literal at rest.go:242, and a case signing in on two pages spends
that twice; when the read that repaints the panel is the one the limiter
turns down, the widget shows signed out over a session that exists and
no later request will ask again. The short first wait now ends in a
focus handoff, which the widget answers by re-probing, and only then
does the real wait run. A sign-in that genuinely failed still fails,
since the second read finds no state either. That is what
TestComment_AdminPinsAndVerifies and TestComment_BlockedAuthorCannotPost
were failing on in CI while passing locally.

signInAnon takes the page for that reason, and its callers pass it.

* Cover iframe geometry, the embed contract and five deployment modes

Seventeen cases for the parts of the widget that broke repeatedly and
that nothing here could see, plus the areas jest was the only check on.

Geometry is the biggest of them. The widget measures its own document
and posts the number for the parent to apply, and every way that has
gone wrong is invisible to assertions about elements, which read the
same whether the frame is right, twice too tall or a strip. So: the
first height the parent is given describes rendered content and not the
preloader, the frame matches the document it holds and does not stand
24px taller, no_footer leaves the last comment inside the frame, and the
frame follows the sign-in dropdown and the growing textarea and comes
back down again. Each was verified by reintroducing the defect it covers
and watching it fail: a 63px report before the real one, six pixels of
body padding, and a frame sized under the content.

The embed surface is the other half the widget cannot see. An element
placeholder gives way to exactly one iframe carrying the embed's own
marker, a second createInstance reuses it, destroy takes it away with
its handles, and a theme change after load reaches both the element and
the document. A page that posts a message of its own, which is all
embed.ts's own title observer does, no longer empties an open login
form.

Five configurations that cannot share an instance get one each, since
each changes the widget for every reader: an admin's unlimited edit
window, a session carried in a header and not a cookie, an instance with
no auth provider to offer, anonymous voting, and the notify module,
without which email_notifications is false and the subscribe control
never renders at all. The subscription round trip is the one place a
token from a real message is exchanged for state the server keeps.

Two things surfaced there and are left alone, both said so in place. The
panel confirming an unsubscribe cannot be observed, because the click
changes the step and the dropdown closes on an element no longer in the
rerendered view, which the component notes as its own awkwardness; the
case asserts the request and the answer, which is what decides whether
the reader still gets mail. And the widget takes the subscribed state
from user.email_subscription, absent from what it hydrates the user with
on the next load, so after a reload it offers to subscribe somebody who
already is.

simple_view needs no instance, being a query parameter, so both branches
run against the main one. A transient failure of the status probe has a
case too: the session belongs to the server, and one refused answer must
not end it.

The suite runs about four and a half minutes now, so the workflow's own
budget goes to 20m to match the Makefile, well inside the job timeout,
and the workflow stamps the stack it starts the way the Makefile does.
The locale case that loads the widget document directly is renamed for
what it protects, the origin and CSP its chunks are fetched under.

Telegram gets no test: the base URL is formatted inline inside
go-pkgz/auth, so nothing here can point it elsewhere, and the fix
belongs upstream in v1 and v2 both. Reported as #2208.

Three things CI found that a laptop cannot. The anonymous sign-in form
validates its input against pattern="[\p{L}\d\s_]+", and E2E_RUN_ID is
"<run id>-<attempt>" on a runner, so every username built from it
carried a hyphen the browser refused to submit: no request was made and
the case waited out its timeout on a panel that was never going to
change. Names are built by anonName now, which drops what the pattern
does not allow and adds the pid, so a second run against a surviving
stack does not meet its own blocked and verified users. signInAnon waits
for the request the submit makes, so the next such refusal fails as
itself.

The admin instance takes its admin from an email address, not a name.
remark42 hashes an anonymous id from the name and the client address
together, to tell apart two people picking the same name, so the id
written into ADMIN_SHARED_ID belonged to nobody on a runner and the
instance had no admin at all: the countdown stayed, and the backend
refused the edit. An email id is sha1 of the address, which is the same
everywhere.

The subscription case clears its own precondition and confirms through
the page's session. The dev user is shared and a subscription outlives
the run, so the panel opened on the subscribed step; and the panel moves
to that step while its token textarea is still on screen, leaving no
moment at which the control to submit it exists.

Three settings of remark_config get cases of their own, none having had
any: __colors__, which is the one setting that travels through
window.name and not the query string, so nothing else in the suite would
notice the path going; the url override, which is how a canonical
address keeps one conversation across pages that differ; and the
subscription controls an integrator turns off, with the both-shown case
as the control.

Writing the url case turned up a backend defect, reported as #2204 and
not fixed here: a thread url containing "&" cannot be commented on at
all. Sanitize runs the locator
through SanitizeAsURL, which round-trips it through bluemonday, so the
url is stored html-escaped; the bucket is created under the escaped key,
the read-back uses the real one and answers 500, and every later find,
count and feed asks for the real url and is told the thread is empty.
Any page addressed with two query parameters is affected. The case uses
a single-parameter url for that reason.

Five more from the same audit, none needing a service. Collapsing a
thread shrinks the frame, which every other geometry case would miss:
they all assert growth, and a widget that only grew would satisfy them
while leaving a hole under each collapsed thread. Voting gains the
direction nothing covered, downvoting and its survival of a reload, and
the rule the other vote cases work around, that your own comment offers
no buttons and the backend refuses the vote anyway.

A vote with the X-XSRF-TOKEN header stripped has to be refused. That
check is why a document navigation, an iframe src among them, is always
anonymous and why the widget hydrates its user over XHR, so anything
designed around that wants it pinned.

An unrecognized locale has to render English, which is loadLocale's only
observable guarantee: it falls back the same way for a name it does not
know and for a chunk it cannot fetch. And a comment's timestamp has to
be the reader's own, which is the one part of rendering that cannot move
to the server, asserted from a context in Kiritimati against the same
Intl the widget uses.

A host page on an origin the widget is not served from, which is the
separate-domain setup the manuals describe and the configuration readers
actually hit problems with. Every other host page here is served by
remark42 itself, so the cross-site path was never taken: an nginx on its
own name and port serves e2e/hostsite, and the case asserts the frame is
revealed, which means its document loaded and reported itself inited
across the origin boundary, and that the thread it renders is the one
the page's address names. Signing in is left out on purpose, an embedded
cookie needing SameSite=None, which browsers take only as Secure, and
this stack speaks http; that is #1139 and not something a case here can
settle.

The other half is ALLOWED_HOSTS. The no-provider instance names only
itself, so a page elsewhere embedding it is refused by the browser, the
document never runs, and the reveal comes from the widget's own fallback
five seconds later. Both directions are worth holding: without the
fallback a mistyped host leaves a permanently invisible widget with
nothing to say why, and without the refusal the setting does nothing.

The host page's title reaching the stored comment gets a case, the path
running the other way from everything else here: the page posts its
title into the widget, the widget sends it with the comment, and it is
what a feed and the admin listing show. Set after the widget is up, so
it covers the observer embed.ts installs and not the value read at boot.

max_shown_comments has no case. The setting reaches the widget, appears
in the iframe's query string and changes nothing: four comments render
with it set to two, which is #812, still open, where the reproduction is
now recorded. A case for it would be red on master.

The README gains what the suite cannot reach. Every service here speaks
http, so anything the browser gates on the page protocol is invisible: a
Secure cookie, anything keyed on window.location.protocol, and the
SameSite=None with Secure and Partitioned form that is the only one an
embedded frame can still use. That is not hypothetical, setAuthCookie
having decorated its cookies with __Host- on any https page and survived
precisely because nothing here runs on one. The cross-origin case names
the assertion to add if the stack ever gets TLS, which is the reload:
the widget holds its token in memory for the life of a page, so signing
in and posting without reloading passes while persistence is broken.

And the trap waiting for whoever acts on that: playwright's own default
--disable-features argument carries ThirdPartyStoragePartitioning, and it
beats both --test-third-party-cookie-phaseout and
--block-third-party-cookies passed through Args, so a run meaning to
prove the third-party case keeps an ordinary third-party cookie exactly
as it would with no flags at all. IgnoreDefaultArgs is the lever, and a
blocking run has to assert a control before anything it reports can be
believed. None of it reaches the widget's own storage fallback either:
IS_STORAGE_AVAILABLE stays true with partitioning enforced, chromium
partitioning localStorage instead of denying it, so comments.html needs
webkit and not a flag.

The downvote case now actually corrects. It claimed the score ends where
the second vote leaves it and never cast one, so #728, a reader taking a
vote back, could break with it green. It also turns out the opposite
vote takes the first one back instead of flipping it, so the score
returns to zero and never reaches +1, which is what the case asserts,
before and after a reload. Renamed for what it covers.
This commit is contained in:
Dmitry Verkhoturov
2026-08-22 11:59:37 -05:00
committed by GitHub
parent a82dc8d3f1
commit 4d5dae20e2
22 changed files with 2699 additions and 79 deletions
+9 -2
View File
@@ -88,8 +88,12 @@ jobs:
key: playwright-${{ hashFiles('e2e/go.sum') }}
restore-keys: playwright-
# E2E_STAMP is what the suite compares the running stack against, so a stack started here
# has to carry the same value `make e2e-up` and the suite itself would give it
- 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
run: |
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 E2E_STAMP=$(./e2e/stamp.sh) \
docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
# no retry: a failure here is evidence about a suite too young to have a flake rate,
# and a rerun is how an intermittent regression becomes invisible. revisit when there
@@ -99,7 +103,10 @@ jobs:
# log names the run it came from
env:
E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
run: cd e2e && go test -tags=e2e -count 1 -timeout 8m -v ./...
# 20m, matching the Makefile. the suite runs about four minutes on a laptop and a runner
# is slower, so a tighter budget turns a loaded runner into a timeout panic instead of a
# readable failure. the job's own timeout above is what bounds a wedged run
run: cd e2e && go test -tags=e2e -count 1 -timeout 20m -v ./...
- name: Server logs on failure
if: failure()
+3 -1
View File
@@ -39,8 +39,10 @@ rundev:
docker compose -f compose-private.yml build
docker compose -f compose-private.yml up
# stamped the same way the suite stamps a stack it starts itself, so one brought up here is
# accepted instead of rejected as belonging to another checkout
e2e-up:
docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
E2E_STAMP=$$(./e2e/stamp.sh) docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
e2e-down:
docker compose -f compose-e2e-test.yml down -v
+165 -4
View File
@@ -21,7 +21,7 @@ services:
# 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
# the hostname has to be a name, not 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:
@@ -35,13 +35,27 @@ services:
- 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
# the auth sender reads AUTH_EMAIL_FROM, the notify module reads NOTIFY_EMAIL_FROM. both
# go to mailpit, which the suite reads back over HTTP
- AUTH_EMAIL_FROM=remark42@example.com
# without this email_notifications is false and the subscribe control never renders, so
# the whole subscribe, confirm and unsubscribe flow is unreachable from a browser
- NOTIFY_USERS=email
- NOTIFY_EMAIL_FROM=notify@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
# digest of the sources this stack was brought up from, from e2e/stamp.sh, which the suite
# reads back and compares against the checkout it is running in. every checkout builds the
# image tag above, so a stack from another worktree answers on these ports and passes every
# readiness probe while serving code nobody is looking at. remark42 ignores it.
#
# set as the environment and not as a build argument on purpose: an argument reaching
# the backend stage is part of the go build's cache key, so every frontend edit would
# rebuild the backend too. the cost is that `docker compose up` without --build would stamp
# a stale image, which neither `make e2e-up` nor the suite does
- E2E_SOURCE_STAMP=${E2E_STAMP:-unstamped}
volumes:
- remark42-e2e-var:/srv/var
depends_on:
@@ -88,6 +102,149 @@ services:
timeout: 3s
retries: 30
# ADMIN_EDIT gives admins an unlimited edit window, which #1986 reported the frontend ignoring.
# a separate instance because the setting is global and removes the countdown the ordinary
# deadline cases assert on
remark42-adminedit:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-adminedit"
pull_policy: never
depends_on:
remark42:
condition: service_started
mailpit:
condition: service_healthy
ports:
- "127.0.0.1:8082:8080"
environment:
- REMARK_URL=http://remark42-adminedit:8082
- SECRET=12345
- ADMIN_EDIT=true
# email auth, because its user id is the only one that can be written down ahead of time.
# the dev provider's port is fixed at 8084 and cannot be published twice, and the anonymous
# id is hashed from the name AND the client address (server.go:1220), which differs between
# a laptop and a runner, so an id pinned here would make an admin of nobody on CI.
# the email id is sha1 of the address alone (go-pkgz/auth provider/verify.go:92), so this is
# email_$(printf adminedit@example.com | shasum) and the test signs in as that address
- AUTH_EMAIL_ENABLE=true
- AUTH_EMAIL_FROM=remark42@example.com
- SMTP_HOST=mailpit
- SMTP_PORT=1025
- ADMIN_SHARED_ID=email_0212c0d9ebf846e898888d617f82d4147747bdc3
# short, so "after the deadline" is a few seconds instead of the default five minutes
- EDIT_TIME=15s
- UPDATE_LIMIT=100
volumes:
- remark42-e2e-adminedit-var:/srv/var
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# AUTH_SEND_JWT_HEADER moves the token out of the cookie and into a header the frontend has to
# keep for itself, which #1877 reported losing on every reload
remark42-jwtheader:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-jwtheader"
pull_policy: never
depends_on:
remark42:
condition: service_started
ports:
- "127.0.0.1:8083:8080"
environment:
- REMARK_URL=http://remark42-jwtheader:8083
- SECRET=12345
- AUTH_ANON=true
- AUTH_SEND_JWT_HEADER=true
- UPDATE_LIMIT=100
volumes:
- remark42-e2e-jwtheader-var:/srv/var
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# no auth provider at all, which #1456 reported rendering as a widget with nothing to say. it
# cannot be a setting on another instance: the absence is the configuration
remark42-noauth:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-noauth"
pull_policy: never
depends_on:
remark42:
condition: service_started
ports:
- "127.0.0.1:8085:8080"
environment:
- REMARK_URL=http://remark42-noauth:8085
- SECRET=12345
# its own address and nothing else, so this instance also serves as the negative case for
# embedding: the browser refuses to frame it anywhere but here, and the widget document
# never runs to report itself inited
- ALLOWED_HOSTS=http://remark42-noauth:8085
- UPDATE_LIMIT=100
volumes:
- remark42-e2e-noauth-var:/srv/var
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# anonymous voting, which the default configuration refuses: rest_private.go turns down a vote
# from an anonymous user unless ANON_VOTE is set, and it only works alongside VOTES_IP, which
# is what scopes it
remark42-anonvote:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-anonvote"
pull_policy: never
depends_on:
remark42:
condition: service_started
ports:
- "127.0.0.1:8086:8080"
environment:
- REMARK_URL=http://remark42-anonvote:8086
- SECRET=12345
- AUTH_ANON=true
- ANON_VOTE=true
- VOTES_IP=true
- UPDATE_LIMIT=100
volumes:
- remark42-e2e-anonvote-var:/srv/var
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# a page on an origin the widget is not served from, which is the separate-domain setup the
# manuals describe and the one readers actually hit problems with. every other host page in the
# suite is served by remark42 itself, so nothing else exercises a cross-origin embed
host-site:
image: nginx:1.29-alpine
container_name: "remark42-e2e-hostsite"
ports:
- "127.0.0.1:8090:80"
volumes:
- ./e2e/hostsite:/usr/share/nginx/html:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost/post.html"]
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
@@ -102,8 +259,12 @@ services:
timeout: 3s
retries: 30
# named rather than bind mounts, so `docker compose down -v` really does discard the
# named volumes, not 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:
remark42-e2e-adminedit-var:
remark42-e2e-jwtheader-var:
remark42-e2e-noauth-var:
remark42-e2e-anonvote-var:
+43 -6
View File
@@ -49,21 +49,57 @@ CI does not retry a failing test. The suite is young enough that a failure is ev
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.
A failing test also logs whatever the browser wrote to its console, which is where a failed request or a widget-side error shows up. Those are context only. What does fail a test on its own is an uncaught exception in the page and a rate-limit response, both of which otherwise corrupt a run silently: a widget that throws while rendering still satisfies most assertions here, and a refused `/auth/status` renders as a signed-out reader.
## The stack the suite runs against
The suite refuses a running stack that was not brought up from the sources under test. Every checkout builds the image tag the compose file names, so a stack from another worktree, or from this one before an edit, answers on these ports and passes every readiness probe while serving code nobody is looking at.
`e2e/stamp.sh` digests the content of `backend`, `frontend`, `Dockerfile` and `docker-init.sh`; compose puts it in the container's environment and the suite reads it back. It digests content and not `HEAD`, so a commit touching only the suite does not invalidate a stack. `make e2e-up` stamps the same way, so a stack started by hand is accepted. On a mismatch the failure says to run `make e2e-down`.
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:
`compose-e2e-test.yml` at the repository root runs six 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
- **remark42-adminedit** on `:8082`, with `ADMIN_EDIT=true` and the same short window, for the unlimited window an admin is supposed to get
- **remark42-jwtheader** on `:8083`, with `AUTH_SEND_JWT_HEADER=true`, where the token arrives in a header instead of a cookie and the frontend has to keep it itself
- **remark42-noauth** on `:8085`, with no auth provider at all, which the widget has to say something about, and with `ALLOWED_HOSTS` set to its own address so it doubles as the instance that refuses to be framed elsewhere
- **remark42-anonvote** on `:8086`, with `ANON_VOTE` and the `VOTES_IP` it depends on, since the default configuration turns an anonymous vote down
- **host-site** on `:8090`, an nginx serving `e2e/hostsite/`, which is a page on an origin the widget is not served from. Every other host page here is served by remark42 itself, so without it the separate-domain setup the manuals describe is never exercised. `post.html` embeds the main instance; `restricted.html` embeds the one whose `ALLOWED_HOSTS` names only itself, which is the refusal case
- **mailpit** on `:8025`, which catches the email-auth verification message and the subscription token for the suite to read back
Three settings exist for the tests rather than for realism, and each is there for a reason:
The main instance enables the notify module (`NOTIFY_USERS=email`). Without it `email_notifications` is false in the config, the widget never renders the subscribe control, and the whole subscribe, confirm and unsubscribe flow is unreachable from a browser.
The four remark42 instances beyond the first offer anonymous sign-in only, for the reason `remark42-shortedit` does: the dev oauth2 provider binds a port fixed at 8084 and cannot be published twice. `remark42-adminedit` gets its admin from `ADMIN_SHARED_ID`, since the anonymous provider derives the user id from the name and the id for a chosen name can be written into the compose file ahead of time.
Three settings exist for the tests and not 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`.
- 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` and not by a setting. See `pauseForAuthLimit`.
## What this suite cannot reach
Every service here speaks http, and nothing in it holds a certificate. Any behaviour the browser gates on the page protocol is therefore invisible: a cookie the widget writes with `Secure`, anything keyed on `window.location.protocol`, and the whole third-party cookie form of `SameSite=None; Secure; Partitioned`, which is the only one browsers still accept from an embedded frame.
That is not hypothetical. `setAuthCookie` prefixed its cookies with `__Host-` on any https page, so a real deployment stored `__Host-JWT` while the backend looked for `JWT`; it survived because the prefix comes from the page protocol and every test and the dev server run on http. Fixed in #2197, under a second suite pinned to an https page, because this one cannot show it.
There is a second trap waiting for whoever gives the stack TLS and then tries to prove the third-party case. Playwright's own default `--disable-features` argument carries `ThirdPartyStoragePartitioning`, and it beats both `--test-third-party-cookie-phaseout` and `--block-third-party-cookies` passed through `Args`. A run configured that way keeps an ordinary third-party cookie exactly as it would with no flags at all, so it proves nothing while looking like it proved something. The lever is `IgnoreDefaultArgs` on the launch options: drop that default entry and re-supply `--disable-features` without that one feature. Measured on a cross-site https embed:
| | ordinary third-party cookie | `Partitioned` cookie |
|---|---|---|
| Playwright defaults | kept | kept |
| partitioning left enabled | dropped | stored, with its partition key |
So a blocking run has to assert a control before anything it reports can be believed: set an ordinary `SameSite=None` cookie from inside the widget frame and require the browser to drop it. If it survives, the run is not blocking anything.
None of that reaches the widget's own storage fallback, which the auth panel offers as `comments.html` when `IS_THIRD_PARTY && !IS_STORAGE_AVAILABLE`. `IS_STORAGE_AVAILABLE` stays true even with partitioning properly enforced, because Chromium partitions `localStorage` instead of denying it, so the probe behind that constant never throws and the condition cannot fire. That case needs WebKit, not a Chromium flag.
The practical consequence is for the cross-origin case in `crossorigin_test.go`, which asserts rendering and deliberately not signing in. Give the stack TLS and signing in there becomes testable, and the assertion that matters is **the reload**: the widget holds its token in memory for the life of a page, so a case that signs in and posts without reloading passes while persistence is entirely broken.
## Isolation
@@ -73,9 +109,9 @@ Thread URLs deliberately keep the underscores a test name carries, since collaps
## 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.
The `iframe_test.go` group runs in Chromium, Firefox and WebKit. It is about rendering and not 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.
Those tests address the server as `127.0.0.1` and not 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.
@@ -84,5 +120,6 @@ Everything else runs in Chromium alone, for the same reason inverted: those test
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.
- The production build hashes every css-module class name to a short opaque id, so a component's own class is not something a test can hold. `role` is: the footer is `[role="contentinfo"]` and the edit countdown is `[role="timer"]`.
- 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.
+155 -17
View File
@@ -4,6 +4,7 @@ package e2e
import (
"fmt"
"net/http"
"regexp"
"testing"
@@ -30,11 +31,12 @@ func signInDev(t *testing.T, page playwright.Page, frame playwright.FrameLocator
// frame to make it re-read auth state
pauseForAuthLimit()
require.NoError(t, page.Locator("#remark42 iframe").Press("Tab"))
assertSignedIn(t, frame)
assertSignedIn(t, page, 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) {
// signInAnon signs in through the anonymous provider, an in-frame form with no popup. it takes
// the page because assertSignedIn may have to nudge it, see there
func signInAnon(t *testing.T, page playwright.Page, frame playwright.FrameLocator, username string) {
t.Helper()
pauseForAuthLimit()
@@ -48,9 +50,17 @@ func signInAnon(t *testing.T, frame playwright.FrameLocator, username string) {
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)
// wait for the request the submit is supposed to make, so a form the browser refuses to
// submit fails here naming that, and not fifteen seconds later on a panel that was never
// going to change. the input is validated against a pattern, see anonName
_, err := page.ExpectResponse("**/auth/anonymous/login**", func() error {
return frame.Locator(".auth-submit").Click()
}, playwright.PageExpectResponseOptions{Timeout: playwright.Float(float64(waitTimeout.Milliseconds()))})
require.NoError(t, err, "the anonymous form was never submitted, most likely because %q is not "+
"a username it accepts", username)
assertSignedIn(t, page, frame)
}
func TestAuth_DevProviderSignsIn(t *testing.T) {
@@ -58,15 +68,15 @@ func TestAuth_DevProviderSignsIn(t *testing.T) {
frame := openThread(t, page)
signInDev(t, page, frame)
assertSignedIn(t, frame)
assertSignedIn(t, page, frame)
}
func TestAuth_AnonymousSignsIn(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInAnon(t, frame, "anontester")
assertSignedIn(t, frame)
signInAnon(t, page, frame, "anontester")
assertSignedIn(t, page, frame)
name, err := frame.Locator(`[title="Open My Profile"]`).InnerText()
require.NoError(t, err)
@@ -75,21 +85,32 @@ func TestAuth_AnonymousSignsIn(t *testing.T) {
// 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.
// broken token round-trip fails here and not 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)
signInEmail(t, page, frame, "emailtester", fmt.Sprintf("email-tester-%s@example.com", runID))
}
// signInEmail completes the email flow: ask for a code, read it out of the mail catcher, submit
// it. The address decides the user id, sha1 of it, so a case needing a known id picks the address
func signInEmail(t *testing.T, page playwright.Page, frame playwright.FrameLocator, username, address string) {
t.Helper()
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"))
// only rendered when more than one form provider is enabled; with email alone the form is
// shown directly
tab := frame.Locator(`label[for="form-provider-email"]`)
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-input-email").Fill(address))
require.NoError(t, frame.Locator(".auth-submit").Click())
@@ -99,16 +120,35 @@ func TestAuth_EmailSignsIn(t *testing.T) {
require.NoError(t, frame.Locator(".auth-token-textarea").Fill(token))
require.NoError(t, frame.Locator(".auth-submit").Click())
assertSignedIn(t, frame)
assertSignedIn(t, page, 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) {
// Sign Out is an icon button, so its title is the only text it carries.
//
// The first wait is deliberately short. Everything under /auth/ is capped at two requests a
// second for the whole suite, a bare literal at backend/app/rest/api/rest.go:242, and a case
// that signs in on two pages spends that budget twice over. When the read that repaints the
// panel is the request the limiter refuses, the widget shows signed out over a session that
// exists, and waiting longer cannot help because nothing will ask again. So on the short wait
// expiring, hand focus back to the frame: the widget re-probes on visibilitychange and window
// focus while a sign-in is pending, and by then the cookie is long since set. A sign-in that
// genuinely failed still fails here, since the second read finds no state either
func assertSignedIn(t *testing.T, page playwright.Page, frame playwright.FrameLocator) {
t.Helper()
waitVisible(t, frame.Locator(`[title="Sign Out"]`))
signOut := frame.Locator(`[title="Sign Out"]`)
if err := signOut.WaitFor(playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateVisible,
Timeout: playwright.Float(float64(authRepaintWait.Milliseconds())),
}); err != nil {
pauseForAuthLimit()
require.NoError(t, page.Locator("#remark42 iframe").Press("Tab"))
}
waitVisible(t, signOut)
waitVisible(t, frame.Locator(`[title="Open My Profile"]`))
waitHidden(t, frame.Locator(".auth-button"))
waitHidden(t, frame.Locator(".auth-button"), "the panel still offers sign-in after a sign-in")
}
// verificationToken pulls the JWT out of the confirmation mail
@@ -118,3 +158,101 @@ func verificationToken(t *testing.T, body string) string {
require.NotEmpty(t, m, "no token in message body:\n%s", body)
return m
}
// TestAuth_SignOutEndsTheSession covers the one half of authentication nothing tested at any
// level: that signing out actually ends the session instead of only repainting the panel. The
// assertion after the reload is the point, since a cleared store with a live cookie looks
// identical until the page comes back
func TestAuth_SignOutEndsTheSession(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
pauseForAuthLimit()
require.NoError(t, frame.Locator(`[title="Sign Out"]`).Click())
waitVisible(t, frame.Locator(".auth-button"))
waitHidden(t, frame.Locator(`[title="Sign Out"]`))
frame = reload(t, page)
waitVisible(t, frame.Locator(".auth-button"))
waitHidden(t, frame.Locator(`[title="Sign Out"]`),
"the panel signed out but the session survived the reload, so the cookie was never cleared")
}
// TestAuth_HostPageMessageDoesNotCloseTheDropdown covers #2139. Every message reaching the widget
// closed the sign-in dropdown, because the handler returned early only for a clickOutside payload
// and fell through to closing in every other case. embed.ts watches the host page's title element
// and posts on every mutation, so a page that updates its own title discarded whatever the reader
// had typed into the login form. Browser extensions posting into the page did the same, which is
// what #1761 reports.
//
// The theme change at the end is the synchronization, not a second assertion: postMessage is
// delivered in order, so a widget that has visibly acted on the later message has already had the
// title message. Without it this would assert on a dropdown that simply has not closed yet.
func TestAuth_HostPageMessageDoesNotCloseTheDropdown(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
embedConfig(t, page, map[string]any{"theme": "light"})
frame := widget(t, page)
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())
const typed = "half-written name"
require.NoError(t, frame.Locator(".auth-input-username").Fill(typed))
// an ordinary thing for a host page to do, and all it took
_, err := page.Evaluate(`() => { document.title = 'the host page renamed itself'; }`)
require.NoError(t, err)
_, err = page.Evaluate(`() => window.REMARK42.changeTheme('dark')`)
require.NoError(t, err)
eventually(t, waitTimeout, "the widget never acted on the message sent after the title change", func() bool {
return widgetColorScheme(t, page) == "dark"
})
waitVisible(t, frame.Locator(".auth-dropdown"))
value, err := frame.Locator(".auth-input-username").InputValue()
require.NoError(t, err)
assert.Equal(t, typed, value, "the host page's own message emptied the login form")
// and the message that is supposed to close it still does, or the fix above would be a
// dropdown that never closes
require.NoError(t, page.Mouse().Click(5, 5))
waitHidden(t, frame.Locator(".auth-dropdown"), "a genuine click outside the widget no longer closes the dropdown")
}
// TestAuth_SessionSurvivesATransientStatusFailure covers the probe #1763 introduced. The widget
// asks /auth/status on every load, and a single failed answer must not be taken for a signed-out
// reader in any lasting way: the session lives in a cookie the server issued, and one bad
// response says nothing about it. A reader on a flaky connection otherwise appears to be logged
// out and cannot get back without signing in again
func TestAuth_SessionSurvivesATransientStatusFailure(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
// one failure, then out of the way. Unroute and not a counter, so the restored state is the
// real endpoint and not a stub standing in for it
require.NoError(t, page.Route("**/auth/status**", func(route playwright.Route) {
_ = route.Fulfill(playwright.RouteFulfillOptions{
Status: playwright.Int(http.StatusInternalServerError),
ContentType: playwright.String("application/json"),
Body: playwright.String(`{"error":"failed"}`),
})
}))
pauseForAuthLimit()
_, err := page.Reload()
require.NoError(t, err)
widget(t, page)
require.NoError(t, page.Unroute("**/auth/status**"))
// the session has to be there again once the endpoint is, which is the whole claim: a
// transient failure cost the reader nothing
frame = reload(t, page)
assertSignedIn(t, page, frame)
}
+280 -4
View File
@@ -3,8 +3,12 @@
package e2e
import (
"encoding/base64"
"fmt"
"net/http"
neturl "net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -72,7 +76,7 @@ func TestComment_EditWithinTheDeadline(t *testing.T) {
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
// an edit replaces the comment instead of 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()
@@ -98,7 +102,7 @@ func TestComment_EditExpiresAfterTheDeadline(t *testing.T) {
page := newPage(t)
url := threadURLOn(t, shortEditURL)
frame := openURL(t, page, url)
signInAnon(t, frame, "expirytester")
signInAnon(t, page, frame, "expirytester")
text := "expires " + runID
postComment(t, frame, text)
@@ -136,7 +140,7 @@ func TestComment_DeleteRemovesTheText(t *testing.T) {
// 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")
signInAnon(t, page, frame, "deletetester")
text := "doomed " + runID
survivor := "survivor " + runID
@@ -154,10 +158,282 @@ func TestComment_DeleteRemovesTheText(t *testing.T) {
// 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
// and it stays gone instead of 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")
}
// TestComment_EditKeepsTheOriginalSource covers what the widget puts back in the textarea when a
// comment is edited. The thread shows rendered html, so the form has to hold the source it was
// posted with: #2040 shipped a version that handed back the rendered text, and everything the
// author had written in entities or markup was lost on the next save
func TestComment_EditKeepsTheOriginalSource(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
// entities, markup and a character outside latin1, each of which a render-and-read-back
// round trip mangles differently
source := "5 &lt; 10 &amp; **bold** <b>tag</b> ю " + runID
postCommentMatching(t, frame, source, runID)
require.NoError(t, actions(frame, runID).Locator(`button:has-text("Edit")`).Click())
form := replyForm(t, frame)
got, err := form.Locator("textarea").InputValue()
require.NoError(t, err)
assert.Equal(t, source, got, "the edit form has to hold the source that was posted, not the rendered comment")
submitForm(t, form, source+" edited")
waitVisible(t, comment(frame, "edited"))
frame = reload(t, page)
require.NoError(t, actions(frame, runID).Locator(`button:has-text("Edit")`).Click())
got, err = replyForm(t, frame).Locator("textarea").InputValue()
require.NoError(t, err)
assert.Equal(t, source+" edited", got, "the stored source has to survive the round trip through the backend")
}
// TestComment_DraftSurvivesReloadAndClearsAfterPost covers the local draft. A reader who reloads
// mid-sentence keeps what they typed, and a reader who posts does not get it handed back
func TestComment_DraftSurvivesReloadAndClearsAfterPost(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
draft := "half written " + runID
require.NoError(t, frame.Locator(commentFormSel).First().Locator("textarea").Fill(draft))
frame = reload(t, page)
textarea := frame.Locator(commentFormSel).First().Locator("textarea")
eventually(t, waitTimeout, "the draft was not restored after the reload", func() bool {
v, err := textarea.InputValue()
return err == nil && v == draft
})
postCommentMatching(t, frame, draft, draft)
frame = reload(t, page)
got, err := frame.Locator(commentFormSel).First().Locator("textarea").InputValue()
require.NoError(t, err)
assert.Empty(t, got, "a posted draft has to be cleared, or the reader is handed their own comment back")
}
// TestComment_PostFailureKeepsTheText covers the path a reader hits when the server refuses the
// comment. The text is the only copy they have, so it has to stay in the form, and the failure has
// to say something instead of swallowing itself
func TestComment_PostFailureKeepsTheText(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
require.NoError(t, page.Route("**/api/v1/comment?**", func(route playwright.Route) {
require.NoError(t, route.Fulfill(playwright.RouteFulfillOptions{
Status: playwright.Int(http.StatusBadRequest),
ContentType: playwright.String("application/json"),
Body: playwright.String(`{"code":19,"details":"comment contains restricted words","error":"rejected"}`),
}))
}))
text := "rejected " + runID
form := frame.Locator(commentFormSel).First()
submitForm(t, form, text)
waitVisible(t, form.Locator(`p[role="alert"]`))
got, err := form.Locator("textarea").InputValue()
require.NoError(t, err)
assert.Equal(t, text, got, "a refused comment has to stay in the form, it is the only copy the reader has")
require.NoError(t, page.Unroute("**/api/v1/comment?**"))
require.NoError(t, form.Locator(`button[type="submit"]`).Click())
waitVisible(t, comment(frame, text))
}
// TestComment_AdminPinsAndVerifies covers two moderator actions that change what every reader
// sees. Both are server-side, so the assertions come after a reload on a second reader's page
// and not from the moderator's own optimistic render
func TestComment_AdminPinsAndVerifies(t *testing.T) {
text := "moderated " + runID
// verification is a property of the user and outlives the run in the stack's database, so a
// fixed name is only verifiable once: the next run would toggle an already verified author
// off and wait for a badge that is being taken away
author := newPage(t)
authorFrame := openThread(t, author)
signInAnon(t, author, authorFrame, anonName("moderated"))
postComment(t, authorFrame, text)
admin := newPage(t)
adminFrame := openURL(t, admin, threadURL(t))
signInDev(t, admin, adminFrame)
admin.OnDialog(func(d playwright.Dialog) { _ = d.Accept() })
require.NoError(t, actions(adminFrame, text).Locator(`button:has-text("Pin")`).Click())
// pinning re-renders the thread, and a click that lands during that render is lost, so wait
// for the pinned region to exist before touching the same comment again
waitVisible(t, adminFrame.Locator(`[role="region"][aria-label="Pinned comments"]`))
// the verification toggle sits in the comment header beside the author, not in the action bar
require.NoError(t, comment(adminFrame, text).Locator(`[title="Toggle verification"]`).First().Click())
waitVisible(t, comment(adminFrame, text).Locator(`[title="Verified user"]`).First())
reader := newPage(t)
readerFrame := openURL(t, reader, threadURL(t))
pinned := readerFrame.Locator(`[role="region"][aria-label="Pinned comments"]`)
waitVisible(t, pinned)
waitVisible(t, pinned.Locator("article", playwright.LocatorLocatorOptions{HasText: text}))
waitVisible(t, comment(readerFrame, text).Locator(`[title="Verified user"]`).First())
// unpinning has to reach every reader too, so the region goes away instead of merely
// emptying on the moderator's own page
require.NoError(t, actions(adminFrame, text).Locator(`button:has-text("Unpin")`).Click())
readerFrame = reload(t, reader)
waitHidden(t, readerFrame.Locator(`[role="region"][aria-label="Pinned comments"]`),
"the comment was unpinned but readers still see the pinned region")
}
// TestComment_ImageUploadRendersAndRecovers covers the upload path end to end, which nothing
// exercised in a browser: the file input, the temporary markdown the form writes while the request
// is in flight, the final picture URL, and the image actually loading in the posted comment.
// The second half is the part a reader notices most, since a failed upload that leaves the
// placeholder behind corrupts what they were writing
func TestComment_ImageUploadRendersAndRecovers(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
// a 1x1 png, written out inline so the case does not depend on a fixture file
png, err := base64.StdEncoding.DecodeString(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
require.NoError(t, err)
path := filepath.Join(t.TempDir(), "pixel.png")
require.NoError(t, os.WriteFile(path, png, 0o600))
form := frame.Locator(commentFormSel).First()
textarea := form.Locator("textarea")
t.Run("a failed upload leaves the text as it was", func(t *testing.T) {
require.NoError(t, page.Route("**/api/v1/picture**", func(route playwright.Route) {
// held briefly so the in-flight state is observable: without it the placeholder
// comes and goes inside one frame, and "the text is unchanged" would hold just as
// well for an upload that never started
time.Sleep(300 * time.Millisecond)
require.NoError(t, route.Fulfill(playwright.RouteFulfillOptions{
Status: playwright.Int(http.StatusInternalServerError),
ContentType: playwright.String("application/json"),
Body: playwright.String(`{"code":0,"details":"upload failed","error":"nope"}`),
}))
}))
defer func() { require.NoError(t, page.Unroute("**/api/v1/picture**")) }()
written := "before the upload " + runID
require.NoError(t, textarea.Fill(written))
require.NoError(t, form.Locator(`input[type="file"]`).SetInputFiles(path))
eventually(t, waitTimeout, "the form never showed the upload in progress", func() bool {
v, verr := textarea.InputValue()
return verr == nil && v != written
})
waitVisible(t, form.Locator(`p[role="alert"]`))
eventually(t, waitTimeout, "the upload placeholder was left in the text after the failure", func() bool {
v, verr := textarea.InputValue()
return verr == nil && v == written
})
})
t.Run("an uploaded image is posted and renders", func(t *testing.T) {
require.NoError(t, textarea.Fill("with an image "+runID+" "))
require.NoError(t, form.Locator(`input[type="file"]`).SetInputFiles(path))
eventually(t, waitTimeout, "the upload never produced a picture url", func() bool {
v, verr := textarea.InputValue()
return verr == nil && strings.Contains(v, "/api/v1/picture/")
})
require.NoError(t, form.Locator(`button[type="submit"]`).Click())
posted := comment(frame, "with an image "+runID)
waitVisible(t, posted)
img := posted.Locator(`img[src*="/api/v1/picture/"]`).First()
waitVisible(t, img)
// visible is not loaded: a broken src renders as an empty box, and naturalWidth is the
// only thing that says the bytes came back
eventually(t, waitTimeout, "the posted image never loaded", func() bool {
w, jerr := img.Evaluate("el => el.naturalWidth", nil)
n, ok := w.(int)
return jerr == nil && ok && n > 0
})
})
}
// TestComment_BlockedAuthorCannotPost covers the refusal a blocked author meets. The backend
// answers with its own code, and the widget has to turn that into something the reader can read
// instead of swallowing it, which is the half no unit test can speak for
func TestComment_BlockedAuthorCannotPost(t *testing.T) {
text := "before the block " + runID
// the block is permanent and the stack's database outlives the run, so a fixed name would
// only be postable once: every later run would find the author already blocked
author := newPage(t)
authorFrame := openThread(t, author)
signInAnon(t, author, authorFrame, anonName("blocked"))
postComment(t, authorFrame, text)
admin := newPage(t)
adminFrame := openURL(t, admin, threadURL(t))
signInDev(t, admin, adminFrame)
admin.OnDialog(func(d playwright.Dialog) { _ = d.Accept() })
_, err := actions(adminFrame, text).Locator("select").SelectOption(playwright.SelectOptionValues{
Values: &[]string{"permanently"},
})
require.NoError(t, err)
// the author's own page still believes it can post, which is the point: the refusal has to
// come back from the server and be shown
form := authorFrame.Locator(commentFormSel).First()
submitForm(t, form, "after the block "+runID)
// not scoped to the form: the widget re-renders the whole panel once the server reports the
// author as blocked, so where the message lands is not the point, only that it is said
waitVisible(t, authorFrame.Locator("text=blocked").First())
}
// TestComment_ReadOnlyThreadTakesTheFormAway covers the admin switch that closes a thread. A
// reader arriving afterwards has to find no way to post, and the state has to come from the
// server and not from the admin's own page
func TestComment_ReadOnlyThreadTakesTheFormAway(t *testing.T) {
page := newPage(t)
url := threadURL(t)
frame := openURL(t, page, url)
signInDev(t, page, frame)
// the admin panel swaps its own button instead of showing the read-only notice, which is
// what an ordinary reader gets
require.NoError(t, frame.Locator(`button:has-text("Disable comments")`).Click())
waitVisible(t, frame.Locator(`button:has-text("Enable comments")`))
// not openURL: it waits for a comment form, and a read-only thread is exactly the case with
// no form to wait for
reader := newPage(t)
pauseForAuthLimit()
_, err := reader.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateDomcontentloaded})
require.NoError(t, err)
readerFrame := reader.FrameLocator("#remark42 iframe")
waitVisible(t, readerFrame.Locator(`text=Read-only`))
waitHidden(t, readerFrame.Locator(commentFormSel).First(),
"the thread is read-only but a reader is still shown a comment form")
require.NoError(t, frame.Locator(`button:has-text("Enable comments")`).Click())
waitVisible(t, frame.Locator(commentFormSel).First())
}
+220
View File
@@ -0,0 +1,220 @@
//go:build e2e
package e2e
import (
"encoding/json"
"fmt"
"net/http"
neturl "net/url"
"testing"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// remark_config is the widget's public surface: whatever an integrator sets there has to keep
// working, and discussion #1714 asks for it to be written down. None of it was covered, so a
// setting could stop reaching the widget without anything here going red.
// TestConfig_ColorsReachTheWidget covers __colors__, the only setting that does not travel in the
// iframe's query string: the parent puts it in window.name and an inline script in the widget
// document, in templates/iframe.ejs, reads it back before the bundle runs. Nothing else opens
// window.name, so the whole path could be removed unnoticed
func TestConfig_ColorsReachTheWidget(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
embedConfig(t, page, map[string]any{"__colors__": map[string]any{"--color15": "rgb(1, 2, 3)"}})
widget(t, page)
value, err := page.FrameLocator("#remark42 iframe").Locator(":root").Evaluate(
`(el) => getComputedStyle(el).getPropertyValue('--color15').trim()`, nil,
playwright.LocatorEvaluateOptions{Timeout: playwright.Float(float64(waitTimeout.Milliseconds()))})
require.NoError(t, err)
assert.Equal(t, "rgb(1, 2, 3)", value, "the colors an integrator sets never reached the widget document")
}
// TestConfig_URLOverrideDecidesTheThread covers remark_config.url, which is how a canonical
// address keeps one conversation across pages that differ: a print view, a path with tracking
// parameters, a page that moved. Two host pages at different addresses name the same thread here,
// and the second has to see what the first posted.
//
// The override carries a query string on purpose, since createInstance strips the fragment and
// nothing else, so the query is part of the thread's identity and dropping it would split the
// conversation in two. One parameter and not two: a url containing "&" cannot be commented on
// at all, see #2204
func TestConfig_URLOverrideDecidesTheThread(t *testing.T) {
shared := fmt.Sprintf("%s/web/?e2e=config-url-%s", baseURL, runID)
text := "shared thread " + runID
page := newPage(t)
embedConfigOn(t, page, "/web/privacy.html", map[string]any{"url": shared})
frame := widget(t, page)
signInAnon(t, page, frame, anonName("urloverride"))
postComment(t, frame, text)
// a different host page, same override
other := newPage(t)
stubSignedOut(t, other)
embedConfigOn(t, other, "/web/markdown-help.html", map[string]any{"url": shared})
otherFrame := widget(t, other)
// markdown-help.html is long enough to leave the widget below the fold, and comments render
// through an IntersectionObserver, so one that has not been reached is an empty article no
// text filter matches
require.NoError(t, other.Locator("#remark42 iframe").ScrollIntoViewIfNeeded())
waitVisible(t, comment(otherFrame, text))
// and the query string reached the widget, which is what makes this a test of the override
// and not of two pages agreeing on a default
src, err := other.Locator("#remark42 iframe").GetAttribute("src")
require.NoError(t, err)
assert.Contains(t, src, "e2e%3Dconfig-url", "the query string was dropped from the thread url")
}
// TestConfig_SubscriptionControlsCanBeHidden covers show_rss_subscription and
// show_email_subscription, which an integrator turns off to keep the form to itself, read in
// common/settings.ts. The both-shown case is the control: without it these would hold on a
// widget that offers neither
func TestConfig_SubscriptionControlsCanBeHidden(t *testing.T) {
for _, tc := range []struct {
name string
config map[string]any
rss, byMail bool
}{
{"both shown", map[string]any{}, true, true},
{"rss hidden", map[string]any{"show_rss_subscription": false}, false, true},
{"email hidden", map[string]any{"show_email_subscription": false}, true, false},
} {
t.Run(tc.name, func(t *testing.T) {
page := newPage(t)
embedConfig(t, page, tc.config)
frame := widget(t, page)
// the email control is offered to a registered reader only, which anonymous is not
signInDev(t, page, frame)
rss := frame.Locator(`[title="Subscribe by RSS"]`)
byMail := frame.Locator(`[title="Subscribe by Email"]`)
if tc.rss {
waitVisible(t, rss)
} else {
waitHidden(t, rss, "show_rss_subscription=false left the RSS control in place")
}
if tc.byMail {
waitVisible(t, byMail)
} else {
waitHidden(t, byMail, "show_email_subscription=false left the email control in place")
}
})
}
}
// TestConfig_UnknownLocaleFallsBackToEnglish covers loadLocale's only observable guarantee. It
// returns the English defaults both for a name it does not recognize and for a chunk it fails to
// fetch, and the two are indistinguishable from outside, so a widget rendering English for
// locale=xx is all a caller can be promised. Without this, a build that stopped resolving
// catalogs altogether would still look correct to anyone reading English
func TestConfig_UnknownLocaleFallsBackToEnglish(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
embedConfig(t, page, map[string]any{"locale": "xx"})
frame := widget(t, page)
placeholder, err := frame.Locator(commentFormSel).First().Locator("textarea").GetAttribute("placeholder")
require.NoError(t, err)
assert.Equal(t, "Your comment here", placeholder,
"an unrecognized locale did not fall back to the english defaults")
}
// TestConfig_TimesRenderInTheReadersTimezone covers the one part of a comment's rendering that
// cannot move to the server. The locale is configured and the server knows it, but the timezone
// belongs to the reader's machine, so a design that renders timestamps anywhere else has to keep
// this working. Kiritimati is UTC+14, far enough that a wrong timezone usually lands on the wrong
// day as well as the wrong hour
func TestConfig_TimesRenderInTheReadersTimezone(t *testing.T) {
const zone = "Pacific/Kiritimati"
page := newPageInContext(t, browser, playwright.BrowserNewContextOptions{
TimezoneId: playwright.String(zone),
})
frame := openThread(t, page)
signInAnon(t, page, frame, anonName("timezone"))
text := "timezone " + runID
posted := postCommentMatching(t, frame, text, text)
shown, err := posted.Locator(`a[href*="#remark42__comment-"]`).First().InnerText()
require.NoError(t, err)
// the server's own timestamp for that comment, so the comparison is against what was stored
// and not against the clock this process reads
status, body := pageFetch(t, page, "GET",
fmt.Sprintf("%s/api/v1/find?site=remark&url=%s&format=tree", baseURL, neturl.QueryEscape(threadURL(t))), nil)
require.Equal(t, http.StatusOK, status, "could not read the thread back: %s", body)
var thread struct {
Comments []struct {
Comment struct {
Time string `json:"time"`
} `json:"comment"`
} `json:"comments"`
}
require.NoError(t, json.Unmarshal([]byte(body), &thread))
require.NotEmpty(t, thread.Comments, "the thread came back empty")
// formatted in the page, by the same Intl the widget uses, so this asserts the timezone
// reached the rendering and not that two libraries agree on a format
want, err := page.Evaluate(`([iso, zone]) => {
const d = new Date(iso);
const day = new Intl.DateTimeFormat('en', {timeZone: zone}).format(d);
const time = new Intl.DateTimeFormat('en', {timeZone: zone, hour: 'numeric', minute: 'numeric'}).format(d);
return {day, time};
}`, []any{thread.Comments[0].Comment.Time, zone})
require.NoError(t, err)
parts, ok := want.(map[string]any)
require.True(t, ok, "unexpected shape from the page: %#v", want)
day, _ := parts["day"].(string)
require.NotEmpty(t, day)
assert.Contains(t, shown, day,
"the comment is dated %q, which is not the reader's own day in %s", shown, zone)
}
// TestConfig_PageTitleReachesTheStoredComment covers the title path, which runs the other way
// from everything else here: the host page's title is posted into the widget, the widget sends it
// with the comment, and the backend stores it against the thread. It is what a feed and the admin
// listing show, and nothing else here would notice it going
func TestConfig_PageTitleReachesTheStoredComment(t *testing.T) {
title := "A title only this test uses " + runID
page := newPage(t)
embedConfig(t, page, map[string]any{})
frame := widget(t, page)
// set after the widget is up, so this covers the observer embed.ts installs on the title
// element and not merely the value read once at boot
_, err := page.Evaluate(`(t) => { document.title = t; }`, title)
require.NoError(t, err)
signInAnon(t, page, frame, anonName("pagetitle"))
text := "titled " + runID
postComment(t, frame, text)
status, body := pageFetch(t, page, "GET",
fmt.Sprintf("%s/api/v1/find?site=remark&url=%s&format=tree", baseURL, neturl.QueryEscape(threadURL(t))), nil)
require.Equal(t, http.StatusOK, status, "could not read the thread back: %s", body)
var thread struct {
Comments []struct {
Comment struct {
Title string `json:"title"`
} `json:"comment"`
} `json:"comments"`
}
require.NoError(t, json.Unmarshal([]byte(body), &thread))
require.NotEmpty(t, thread.Comments)
assert.Equal(t, title, thread.Comments[0].Comment.Title,
"the host page's title never reached the stored comment")
}
+99
View File
@@ -0,0 +1,99 @@
//go:build e2e
package e2e
import (
"fmt"
"testing"
"time"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCrossOrigin_WidgetRendersOnAnotherOrigin covers the separate-domain setup the manuals
// describe, which is the configuration readers actually hit problems with and which nothing else
// here reaches: every other host page in this suite is served by remark42 itself, so the widget
// and its embedder always share an origin and the cross-site path is never taken.
//
// The host page is served by its own nginx on a different name and port, and the widget it embeds
// addresses remark42 by remark_config.host. What has to hold is that the frame is revealed at all,
// which means its document loaded and reported itself inited through postMessage across origins,
// and that the thread it renders is the one the page's own address names.
//
// Signing in is deliberately not asserted. An embedded cookie needs SameSite=None, which browsers
// only accept as Secure, and this stack speaks http, so the form cannot be delivered here at all;
// see "What this suite cannot reach" in the README. Give the stack TLS and the case to add is
// signing in and then reloading, since the widget holds its token in memory for the life of a
// page and a sign-in that never reloads passes while persistence is broken
func TestCrossOrigin_WidgetRendersOnAnotherOrigin(t *testing.T) {
thread := fmt.Sprintf("%s/post.html?e2e=%s-%s", hostSiteURL, "crossorigin", runID)
text := "cross origin " + runID
// seeded from a page on remark42's own origin, since posting needs a session and this case is
// about rendering, not about carrying a cookie across origins
seeder := newPage(t)
seederFrame := openThread(t, seeder)
signInAnon(t, seeder, seederFrame, anonName("crossorigin"))
status, body := pageFetch(t, seeder, "POST", baseURL+"/api/v1/comment?site=remark", map[string]any{
"text": text,
"locator": map[string]string{"site": "remark", "url": thread},
})
require.Equal(t, 201, status, "could not seed the cross-origin thread: %s", body)
page := newPage(t)
stubSignedOut(t, page)
pauseForAuthLimit()
_, err := page.Goto(thread)
require.NoError(t, err)
frame := widget(t, page)
// revealed, so the document loaded and its inited message crossed the origin boundary. a
// frame that never reported would still be here, hidden, until the fallback timer
waitVisible(t, page.Locator("#remark42 iframe"))
waitVisible(t, comment(frame, text))
}
// TestCrossOrigin_DisallowedHostNeverReportsInited is the other half of ALLOWED_HOSTS. An
// operator sets it so their comments cannot be framed by anyone else, and what the reader on such
// a page gets is decided entirely by the widget's own fallback: the browser refuses to load the
// document, nothing ever posts inited, and the frame is revealed five seconds later by the timer.
//
// Worth pinning in both directions. Without the fallback an integrator who mistyped the host
// would be left with a permanently invisible widget and nothing in the page to say why, and
// without the refusal ALLOWED_HOSTS would be doing nothing at all
func TestCrossOrigin_DisallowedHostNeverReportsInited(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
pauseForAuthLimit()
_, err := page.Goto(fmt.Sprintf("%s/restricted.html?e2e=%s-%s", hostSiteURL, "crossorigin-blocked", runID))
require.NoError(t, err)
require.NoError(t, page.Locator("#remark42 iframe").WaitFor(playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateAttached,
Timeout: playwright.Float(float64(waitTimeout.Milliseconds())),
}))
// hidden for as long as the fallback runs, since the document the browser refused cannot
// report anything
require.Equal(t, "hidden", iframeVisibility(t, page))
eventually(t, revealTimeout*2, "the frame was never revealed, so a mistyped host would stay invisible", func() bool {
_, ok := revealDelay(t, page)
return ok
})
delay, ok := revealDelay(t, page)
require.True(t, ok)
assert.Greater(t, delay, revealTimeout-500*time.Millisecond,
"the frame was revealed too early to have been the fallback, so something reported inited "+
"from a document the browser should not have loaded")
// and the widget really is not there, which is what makes the reveal above the fallback's
forms, err := page.FrameLocator("#remark42 iframe").Locator(commentFormSel).Count()
require.NoError(t, err)
assert.Zero(t, forms, "the widget rendered on a host the instance does not allow")
}
+116
View File
@@ -0,0 +1,116 @@
//go:build e2e
package e2e
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Three configurations that cannot be settings on an instance shared with anything else: each
// changes the widget for every reader of that instance, and each broke in a way nothing else in
// this suite can reach. Their services are in compose-e2e-test.yml.
// adminEditAddress is the address whose email-derived id compose names in ADMIN_SHARED_ID, so
// signing in with it on that instance produces an admin.
//
// Email and not anonymous: remark42 hashes an anonymous id from the name and the client address
// together (server.go:1220, to tell apart two people picking the same name), so an id written
// down here would belong to nobody on any machine but the one it was read from. The email id is
// sha1 of the address alone
const adminEditAddress = "adminedit@example.com"
// TestComment_AdminEditHasNoDeadline covers #1986: the backend honored ADMIN_EDIT while the
// frontend went on counting an admin's edit window down and taking the button away at the end,
// so the setting did nothing where it is visible. The instance's ordinary window is short, so the
// wait is the same one TestComment_EditExpiresAfterTheDeadline pays, and both halves are asserted
// against it: no countdown at any point, and an edit that still lands afterwards
func TestComment_AdminEditHasNoDeadline(t *testing.T) {
page := newPage(t)
url := threadURLOn(t, adminEditURL)
frame := openURL(t, page, url)
signInEmail(t, page, frame, "admineditor", adminEditAddress)
text := "admin edit " + runID
postedAt := time.Now()
postComment(t, frame, text)
// the countdown is what #2001 left running. an admin has no deadline, so it should never
// have been rendered at all
// asserted on a loaded thread, not on the comment the widget has just added to the
// page. The optimistic render puts a countdown on an admin's own new comment and drops it on
// the next load, so asserting here would fail on that instead of on the deadline logic this
// case is about
frame = reload(t, page)
count, err := actions(frame, text).Locator(`[role="timer"]`).Count()
require.NoError(t, err)
assert.Zero(t, count, "an admin's comment is counting down an edit window that does not apply to it")
// past the window every other user on this instance is held to. editWindow is EDIT_TIME on
// both short-window instances in compose-e2e-test.yml
waitPastEditWindow(postedAt)
require.NoError(t, actions(frame, text).Locator(`button:has-text("Edit")`).Click())
edited := "admin edited after the deadline " + runID
submitForm(t, replyForm(t, frame), edited)
waitVisible(t, comment(frame, edited))
// stored and not only rendered: the backend has its own view of the deadline, and the
// widget showing the new text says nothing about which of the two answered
frame = reload(t, page)
waitVisible(t, comment(frame, edited))
}
// TestAuth_HeaderJWTSurvivesReload covers #1877. With AUTH_SEND_JWT_HEADER the token arrives in a
// response header instead of a cookie, so the frontend holds it itself and a reload starts with
// nothing in hand. Signing out matters as much as signing in: a token kept somewhere the sign-out
// does not clear leaves a session that outlives the button
func TestAuth_HeaderJWTSurvivesReload(t *testing.T) {
page := newPage(t)
frame := openURL(t, page, threadURLOn(t, jwtHeaderURL))
signInAnon(t, page, frame, anonName("headerjwt"))
frame = reload(t, page)
assertSignedIn(t, page, frame)
pauseForAuthLimit()
require.NoError(t, frame.Locator(`[title="Sign Out"]`).Click())
waitVisible(t, frame.Locator(".auth-button"))
frame = reload(t, page)
waitVisible(t, frame.Locator(".auth-button"))
waitHidden(t, frame.Locator(`[title="Sign Out"]`),
"the header-borne session came back after signing out")
}
// TestAuth_NoProvidersSaysSo covers #1456, where an instance with no auth provider rendered a
// sign-in panel offering nothing and no explanation, so the operator's own misconfiguration read
// as the widget being broken
func TestAuth_NoProvidersSaysSo(t *testing.T) {
page := newPage(t)
frame := openURL(t, page, threadURLOn(t, noAuthURL))
require.NoError(t, frame.Locator(".auth-button").Click())
waitVisible(t, frame.Locator("text=No providers available"))
// and no empty form is offered alongside it
inputs, err := frame.Locator(".auth-input-username").Count()
require.NoError(t, err)
assert.Zero(t, inputs, "a sign-in form is offered on an instance with nothing to sign in with")
}
// waitPastEditWindow waits out the instance's ordinary edit window, measured from the moment the
// comment was posted. There is nothing to poll for here: the admin's comment shows no countdown,
// which is the assertion above, so the deadline passing is not observable in the page. Sleeping
// until a deadline computed from a known setting is not the same as sleeping for a guess
func waitPastEditWindow(postedAt time.Time) {
// a margin over the window itself, since the backend compares against its own clock and the
// post round trip sits between the two
deadline := postedAt.Add(editWindow + time.Second)
if wait := time.Until(deadline); wait > 0 {
time.Sleep(wait)
}
}
+166 -16
View File
@@ -6,11 +6,19 @@
// up itself when nothing is listening. Files:
//
// - e2e_test.go: TestMain, shared helpers, constants
// - harness_test.go: the stale-stack guard and the browser failures no assertion covers
// - 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
// - geometry_test.go: the height the widget reports and the parent applies
// - embed_test.go: the surface the host page holds, placeholder to destroy
// - config_test.go: the remark_config surface an integrator sets
// - crossorigin_test.go: a host page on an origin the widget is not served from
// - deployment_test.go: the instances whose configuration is the thing under test
// - subscribe_test.go: the email subscription round trip
// - webfiles_test.go: the published /web surface
// - widgets_test.go: last-comments, counter and the profile iframe
package e2e
@@ -28,22 +36,37 @@ import (
"sync/atomic"
"testing"
"time"
"unicode"
"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
// what the browser asks for. these have to be names, not 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"
// the deployment modes that cannot be a setting on an instance shared with anything else,
// each covering a regression the default configuration cannot reach. see compose-e2e-test.yml
adminEditURL = "http://remark42-adminedit:8082"
jwtHeaderURL = "http://remark42-jwtheader:8083"
noAuthURL = "http://remark42-noauth:8085"
anonVoteURL = "http://remark42-anonvote:8086"
// a page on an origin the widget is not served from, see compose-e2e-test.yml
hostSiteURL = "http://host-site:8090"
// 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"
adminEditProbeURL = "http://127.0.0.1:8082"
jwtHeaderProbeURL = "http://127.0.0.1:8083"
noAuthProbeURL = "http://127.0.0.1:8085"
anonVoteProbeURL = "http://127.0.0.1:8086"
hostSiteProbeURL = "http://127.0.0.1:8090"
mailpitURL = "http://127.0.0.1:8025"
composeFile = "../compose-e2e-test.yml"
@@ -57,6 +80,11 @@ const (
// generous because CI runners are slower and less predictable than a laptop
waitTimeout = 15 * time.Second
// how long assertSignedIn waits before nudging the widget into re-reading auth state. short
// enough that a refused status read costs a fraction of a test instead of a whole timeout,
// long enough that the ordinary path never takes the nudge
authRepaintWait = 3 * 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
@@ -73,7 +101,7 @@ var (
extraBrowsersMu sync.Mutex
// distinguishes this run's threads from those a previous run left in the database.
// E2E_RUN_ID pins it, so the urls a CI run works on name that run rather than the moment
// E2E_RUN_ID pins it, so the urls a CI run works on name that run and not the moment
// the process started
runID = firstNonEmpty(os.Getenv("E2E_RUN_ID"), fmt.Sprintf("%d", time.Now().UnixNano()))
@@ -88,12 +116,16 @@ var (
)
// 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
// literal at backend/app/rest/api/rest.go:242 and not 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
// 1200ms, up from the 700 this started at: the widget fires an unpaced /auth/status on
// every load and again on visibilitychange, so the paced side has to stay well under the
// 2/s cap to leave room for them. at 700 the suite manufactured its own 429s, and a lost
// probe renders as signed out, which fails whichever test happens to be signing in
const spacing = 1200 * time.Millisecond
authGate.Lock()
defer authGate.Unlock()
@@ -129,7 +161,10 @@ func TestMain(m *testing.M) {
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",
"--host-resolver-rules=MAP remark42 127.0.0.1, MAP remark42-shortedit 127.0.0.1, " +
"MAP remark42-adminedit 127.0.0.1, MAP remark42-jwtheader 127.0.0.1, " +
"MAP remark42-noauth 127.0.0.1, MAP remark42-anonvote 127.0.0.1, " +
"MAP host-site 127.0.0.1",
},
})
if err != nil {
@@ -150,11 +185,22 @@ func TestMain(m *testing.M) {
// ensureStack waits for a running stack and starts one with compose when there is none
func ensureStack() error {
// the digest of the sources the image is built from, exported so compose stamps the build
// with it. read back off the running stack below, since answering on the right ports says
// nothing about which checkout the code came from
stamp, err := sourceStamp()
if err != nil {
return err
}
if err := os.Setenv(stampEnv, stamp); err != nil {
return fmt.Errorf("exporting %s: %w", stampEnv, err)
}
// 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
return assertStackMatches(stamp, true)
}
log.Printf("[INFO] no complete stack on 127.0.0.1, bringing one up from %s", composeFile)
@@ -175,7 +221,9 @@ func ensureStack() error {
if !stackReady(waitTimeout) {
return fmt.Errorf("compose reported the stack healthy but it does not answer")
}
return nil
// checked after our own build too, so a stamp that never reaches the image is a failure
// here, and not a guard that silently passes everything for the rest of its life
return assertStackMatches(stamp, false)
}
func firstNonEmpty(values ...string) string {
@@ -192,6 +240,11 @@ func stackReady(timeout time.Duration) bool {
for _, url := range []string{
probeURL + "/ping",
shortEditProbeURL + "/ping",
adminEditProbeURL + "/ping",
jwtHeaderProbeURL + "/ping",
noAuthProbeURL + "/ping",
anonVoteProbeURL + "/ping",
hostSiteProbeURL + "/post.html",
mailpitURL + "/api/v1/messages",
} {
if err := serverReady(url, timeout); err != nil {
@@ -246,11 +299,18 @@ func newPage(t *testing.T) playwright.Page {
func newPageOn(t *testing.T, b playwright.Browser) playwright.Page {
t.Helper()
ctx, err := b.NewContext()
return newPageInContext(t, b, playwright.BrowserNewContextOptions{})
}
// newPageInContext is newPageOn with the context configured, for the cases where what the browser
// says about itself is the thing under test
func newPageInContext(t *testing.T, b playwright.Browser, opts playwright.BrowserNewContextOptions) playwright.Page {
t.Helper()
ctx, err := b.NewContext(opts)
require.NoError(t, err)
// the reveal timers start when the iframe element is created, so the tests that bound
// them have to measure from there rather than from anything this process can time
// them have to measure from there and not from anything this process can time
require.NoError(t, ctx.AddInitScript(playwright.Script{Content: playwright.String(iframeMarkScript)}))
tracing := ctx.Tracing().Start(playwright.TracingStartOptions{
@@ -268,7 +328,7 @@ func newPageOn(t *testing.T, b playwright.Browser) playwright.Page {
_ = ctx.Close()
return
}
// say so rather than swallowing it: this runs only on a test that already failed,
// say so instead of swallowing it: this runs only on a test that already failed,
// and a silently missing trace is what the reader goes looking for
// the pid keeps two processes sharing a run id, and so a trace directory, from
// overwriting each other: the counter restarts with every process
@@ -288,12 +348,17 @@ func newPageOn(t *testing.T, b playwright.Browser) playwright.Page {
page, err := ctx.NewPage()
require.NoError(t, err)
// before the first navigation, so nothing the page reports on its way up is missed
watchPage(t, page)
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
// arrives as an unexplained locator timeout. recorded as well as logged: a lost
// /auth/status renders as signed out, and every sign-in assertion then fails
// somewhere that does not name the cause
recordPageIssue(page, "rate limited", r.URL())
log.Printf("[WARN] rate limited: %s", r.URL())
case debug && r.Status() >= 400:
body, _ := r.Text()
@@ -382,6 +447,31 @@ func threadURLOn(t *testing.T, base string) string {
return fmt.Sprintf("%s/web/?e2e=%s-%s", base, name, runID)
}
// anonName builds a username no earlier run has used. Blocking a user and verifying one are
// properties of the user, and the stack's database outlives the run, so a name that comes back
// arrives already blocked or already verified: the case then toggles the state off and waits for
// something that is being taken away.
//
// The run id alone is not enough, since E2E_RUN_ID pins it and a second run against a surviving
// stack repeats it, which is exactly what `make e2e-up` invites. The pid distinguishes those
// while leaving CI, where every job is one process, with the run id it wants in the name.
//
// Everything outside [\p{L}\d\s_] is dropped, because the form validates its input against that
// pattern (auth.tsx:332) and the browser refuses to submit anything else: no request is made, and
// the test waits out its timeout on a panel that was never going to change. E2E_RUN_ID is
// "<run id>-<attempt>" on CI, whose hyphen is exactly such a character
func anonName(prefix string) string {
var b strings.Builder
b.WriteString(prefix)
for _, r := range runID {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
b.WriteRune(r)
}
}
fmt.Fprintf(&b, "_%d", os.Getpid())
return b.String()
}
// 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()
@@ -398,6 +488,63 @@ func openURL(t *testing.T, page playwright.Page, url string) playwright.FrameLoc
return widget(t, page)
}
// embedConfig loads a page carrying a remark_config of the test's choosing and runs the embed
// script against it.
//
// The demo page cannot be used for this: it writes its own remark_config, so anything a test
// needs to vary has to go into a page that carries no widget of its own. privacy.html is served
// by the instance and has none, which also keeps the widget on its own origin, where its CSP
// allows the chunks it loads. host, site_id and url are filled in when the caller leaves them out
// placeholder, when given, is markup left inside the root before the widget runs, which the
// documentation promises is cleared once the iframe reports itself inited
func embedConfig(t *testing.T, page playwright.Page, config map[string]any, placeholder ...string) {
t.Helper()
embedConfigOn(t, page, "/web/privacy.html", config, placeholder...)
}
// embedConfigOn is embedConfig on a chosen host page, for the cases where the page's own address
// is part of what is under test
func embedConfigOn(t *testing.T, page playwright.Page, hostPage string, config map[string]any, placeholder ...string) {
t.Helper()
for key, value := range map[string]any{"host": baseURL, "site_id": "remark", "url": threadURL(t)} {
if _, ok := config[key]; !ok {
config[key] = value
}
}
pauseForAuthLimit()
_, err := page.Goto(baseURL + hostPage)
require.NoError(t, err)
_, err = page.Evaluate(`([config, placeholder]) => {
window.remark_config = config;
const node = document.createElement('div');
node.id = 'remark42';
node.innerHTML = placeholder;
document.body.appendChild(node);
}`, []any{config, strings.Join(placeholder, "")})
require.NoError(t, err)
_, err = page.AddScriptTag(playwright.PageAddScriptTagOptions{URL: playwright.String(baseURL + "/web/embed.mjs")})
require.NoError(t, err)
}
// stubSignedOut answers the widget's auth probe from the browser, for a page that never signs
// in. /auth/ is capped at two requests a second for the whole suite and the widget probes on
// every load, so a case that only needs a signed-out widget should not spend that budget: the
// tests that do sign in are the ones that cannot fake it
func stubSignedOut(t *testing.T, page playwright.Page) {
t.Helper()
require.NoError(t, page.Route("**/auth/status**", func(route playwright.Route) {
require.NoError(t, route.Fulfill(playwright.RouteFulfillOptions{
Status: playwright.Int(http.StatusOK),
ContentType: playwright.String("application/json"),
Body: playwright.String(`{"status":"not logged in"}`),
}))
}))
}
// 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
@@ -437,7 +584,7 @@ func postCommentMatching(t *testing.T, frame playwright.FrameLocator, source, ma
}
// 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
// depending on the form's mode, so match on the type and not the text
func submitForm(t *testing.T, form playwright.Locator, text string) {
t.Helper()
require.NoError(t, form.Locator("textarea").Fill(text))
@@ -487,7 +634,7 @@ func actions(frame playwright.FrameLocator, text string) playwright.Locator {
}
// 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
// comment form and not 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")
@@ -503,12 +650,15 @@ func waitVisible(t *testing.T, loc playwright.Locator) {
}))
}
func waitHidden(t *testing.T, loc playwright.Locator) {
// waitHidden waits for the element to go away. msgAndArgs says what its staying would mean,
// since the failure otherwise names a selector and not the behavior that broke
func waitHidden(t *testing.T, loc playwright.Locator, msgAndArgs ...any) {
t.Helper()
require.NoError(t, loc.WaitFor(playwright.LocatorWaitForOptions{
err := loc.WaitFor(playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateHidden,
Timeout: playwright.Float(float64(waitTimeout.Milliseconds())),
}))
})
require.NoError(t, err, msgAndArgs...)
}
// pollText reads an element's text with a timeout short enough to be used inside eventually
+121
View File
@@ -0,0 +1,121 @@
//go:build e2e
package e2e
import (
"testing"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestEmbed_PlaceholderGivesWayToOneOwnedIframe covers #2192 together with the placeholder
// support #2002 added. createInstance took root.firstElementChild as its iframe, so anything the
// integrator left in the root was adopted instead: a <noscript> fallback became "the iframe",
// createIframe never ran, and the height messages went to an element that cannot show comments.
// The same defect defeated the placeholder promise, since an element placeholder is mistaken for
// the iframe and inited never arrives to clear it.
//
// A text node and an element, because only the element was ever adopted and a test carrying text
// alone passes against the defect.
func TestEmbed_PlaceholderGivesWayToOneOwnedIframe(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
embedConfig(t, page, map[string]any{},
`loading the comments`, `<div id="element-placeholder">a spinner of the integrator's own</div>`)
widget(t, page)
// the placeholder goes only when the document reports itself inited, so reaching this means
// the message arrived from a real widget document and not from an adopted div
waitHidden(t, page.Locator("#element-placeholder"),
"the element placeholder survived, so the widget never reported itself inited")
frames, err := page.Locator("#remark42 iframe").Count()
require.NoError(t, err)
assert.Equal(t, 1, frames, "the root should hold exactly the one iframe the embed created")
owned, err := page.Locator("#remark42 iframe[data-remark42-iframe]").Count()
require.NoError(t, err)
assert.Equal(t, 1, owned, "the iframe is not marked as the embed's own, so the next "+
"createInstance cannot tell it from whatever else is in the root")
text, err := page.Locator("#remark42").InnerText()
require.NoError(t, err)
assert.NotContains(t, text, "loading the comments", "the text placeholder was never cleared")
// a second instance has to reuse the marked iframe, not add another. this is what a
// single-page app does on every navigation
_, err = page.Evaluate(`() => window.REMARK42.createInstance(window.remark_config)`)
require.NoError(t, err)
frames, err = page.Locator("#remark42 iframe").Count()
require.NoError(t, err)
assert.Equal(t, 1, frames, "a second createInstance added an iframe instead of reusing the one there")
}
// TestEmbed_DestroyRemovesTheWidgetAndCreateInstanceBringsItBack covers the rest of the surface a
// single-page app holds, documented in configuration/frontend/spa.md and exercised by nothing.
// Neither half is observable from inside the widget, which is where the rest of this suite looks
func TestEmbed_DestroyRemovesTheWidgetAndCreateInstanceBringsItBack(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
embedConfig(t, page, map[string]any{})
widget(t, page)
_, err := page.Evaluate(`() => window.REMARK42.destroy()`)
require.NoError(t, err)
waitHidden(t, page.Locator("#remark42 iframe"), "destroy left the iframe in the page")
// and destroy drops the two functions it documents dropping, so a caller can tell a live
// instance from a destroyed one
live, err := page.Evaluate(`() => typeof window.REMARK42.destroy`)
require.NoError(t, err)
assert.Equal(t, "undefined", live, "destroy left its own handle behind")
_, err = page.Evaluate(`() => window.REMARK42.createInstance(window.remark_config)`)
require.NoError(t, err)
widget(t, page)
}
// TestEmbed_ThemeChangesReachTheWidgetAfterLoad covers the runtime half of the color-scheme
// contract. The iframe cases assert what a load with a theme parameter produces; this is the
// path the demo page's own toggle takes, and the one an integrator calls when a reader switches
// themes on the host page
func TestEmbed_ThemeChangesReachTheWidgetAfterLoad(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
embedConfig(t, page, map[string]any{"theme": "light"})
widget(t, page)
require.Equal(t, "light", widgetColorScheme(t, page), "the widget did not start in the theme it was given")
_, err := page.Evaluate(`() => window.REMARK42.changeTheme('dark')`)
require.NoError(t, err)
// both sides, since it is the disagreement between them that paints the opaque canvas the
// iframe cases are about
eventually(t, waitTimeout, "the widget document never took the new theme", func() bool {
return widgetColorScheme(t, page) == "dark"
})
onElement, err := page.Evaluate(`() => document.querySelector('#remark42 iframe').style.colorScheme`)
require.NoError(t, err)
assert.Equal(t, "dark", onElement, "the parent left the old scheme on the iframe element")
}
// widgetColorScheme is the color-scheme in force inside the widget document
func widgetColorScheme(t *testing.T, page playwright.Page) string {
t.Helper()
v, err := page.FrameLocator("#remark42 iframe").Locator(":root").Evaluate(
`(el) => getComputedStyle(el).colorScheme`, nil,
playwright.LocatorEvaluateOptions{Timeout: playwright.Float(float64(pollTimeout.Milliseconds()))})
if err != nil {
return ""
}
scheme, _ := v.(string)
return scheme
}
+283
View File
@@ -0,0 +1,283 @@
//go:build e2e
package e2e
import (
"fmt"
"slices"
"strings"
"testing"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The widget lives in an iframe the host page owns, so its height is not something CSS can
// settle: the document measures itself and posts the number, and the parent applies it. Every
// way that has broken is invisible to the rest of this suite, which asserts on elements the
// browser lays out inside a frame of whatever size, and reads the same whether the frame is
// right, twice too tall or collapsed to a strip.
const (
// the collapse in #2126 reported 63px while the preloader was up. anything under this is
// not a rendered widget
preloaderCeiling = 150.0
// layout lands on fractional pixels, and the parent rounds through a css string
heightTolerance = 2.0
// enough that a change is the widget resizing, not a font or a scrollbar settling
growthFloor = 50.0
)
// TestGeometry_FirstReportedHeightIsRenderedContent covers #2126, where the iframe was told the
// preloader's height and shrank to a strip before growing back as content rendered. On pages
// with many comments that reads as the widget blinking several times on every load.
//
// An empty thread, not a populated one: the widget reports once, so a first report describing the
// preloader is the whole difference between the working and the broken version, and not one step
// in a sequence that legitimately grows as comments arrive.
func TestGeometry_FirstReportedHeightIsRenderedContent(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
embedConfig(t, page, map[string]any{})
widget(t, page)
waitHeightSettled(t, page)
heights := heightReports(t, page)
require.NotEmpty(t, heights, "the widget never told the parent how tall it is")
assert.Greater(t, heights[0], preloaderCeiling,
"the first height handed to the parent was %v, which is preloader-sized: the iframe "+
"collapses on load and grows back. full sequence %v", heights[0], heights)
assert.Equal(t, slices.Min(heights), heights[0],
"the iframe was told to shrink below the height it started at, which is the blink itself. "+
"full sequence %v", heights)
}
// TestGeometry_ReportedHeightMatchesTheDocument covers #2151: the body carried 6px of padding
// and the reported height added 12 on top of an offsetHeight that already included it, so every
// embed sat inset with 24px of empty space underneath. Both halves are asserted, since the
// arithmetic and the padding failed independently
func TestGeometry_ReportedHeightMatchesTheDocument(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
embedConfig(t, page, map[string]any{})
widget(t, page)
waitHeightSettled(t, page)
document := inWidget(t, page, `() => document.body.offsetHeight`)
require.Greater(t, document, preloaderCeiling, "the widget document never rendered")
assert.InDelta(t, document, frameHeight(t, page), heightTolerance,
"the parent sized the iframe at %v for a document of %v, so the difference is empty space",
frameHeight(t, page), document)
padding := inWidget(t, page, `() => ['Top', 'Right', 'Bottom', 'Left']
.reduce((total, side) => total + parseFloat(getComputedStyle(document.body)['padding' + side]), 0)`)
assert.Zero(t, padding,
"the widget body carries %vpx of padding, so the widget cannot sit flush with the host layout", padding)
}
// TestGeometry_NoFooterKeepsTheContentInsideTheFrame covers #2073, where the negative margin on
// the last thread had no footer margin to collapse against and propagated up instead, leaving
// the document shorter than what it draws. The frame was then sized below the visible bottom of
// the last comment.
//
// Both modes run, because the footer-shown case is the control: it is what says the parameter
// reached the widget at all instead of being quietly ignored.
func TestGeometry_NoFooterKeepsTheContentInsideTheFrame(t *testing.T) {
thread := threadURL(t)
poster := newPage(t)
posted := openURL(t, poster, thread)
signInAnon(t, poster, posted, "geometrytester")
postComment(t, posted, "geometry "+runID)
for _, tc := range []struct {
name string
noFooter bool
}{
{"footer shown", false},
{"no footer", true},
} {
t.Run(tc.name, func(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
config := map[string]any{"url": thread}
if tc.noFooter {
config["no_footer"] = true
}
embedConfig(t, page, config)
frame := widget(t, page)
waitVisible(t, frame.Locator("article").First())
waitHeightSettled(t, page)
// by role: the production build hashes every css-module class name to a short opaque
// id, so the footer's own class is not a selector any test can hold
footers, err := frame.Locator(`[role="contentinfo"]`).Count()
require.NoError(t, err)
if tc.noFooter {
require.Zero(t, footers, "no_footer did not reach the widget, so nothing below is about it")
} else {
require.NotZero(t, footers, "the footer is missing without no_footer, so the control proves nothing")
}
// the bottom of the last comment against the frame it has to fit inside. measured
// from the document, not from the reported height, which is the number under test and
// no witness to itself
bottom := inWidget(t, page, `() => {
const articles = document.querySelectorAll('article');
const last = articles[articles.length - 1];
return last ? last.getBoundingClientRect().bottom + window.scrollY : -1;
}`)
require.Positive(t, bottom, "the thread rendered no comment to measure")
height := frameHeight(t, page)
assert.LessOrEqual(t, bottom, height+heightTolerance,
"the last comment ends at %vpx in a frame of %vpx, so it is clipped", bottom, height)
assert.InDelta(t, inWidget(t, page, `() => document.body.offsetHeight`), height, heightTolerance,
"the frame and the document it holds disagree on the height")
})
}
}
// TestGeometry_HeightFollowsTheAuthPanelAndTheTextarea covers the resize path itself, reverted
// once in #1495 and repaired repeatedly since. The dropdown is positioned absolutely, so the
// document does not grow with it and the widget has to measure it separately; the textarea grows
// the document directly. Both have to come back down again, or the widget leaves a hole in the
// page for as long as the reader stays on it
func TestGeometry_HeightFollowsTheAuthPanelAndTheTextarea(t *testing.T) {
page := newPage(t)
stubSignedOut(t, page)
embedConfig(t, page, map[string]any{})
frame := widget(t, page)
waitHeightSettled(t, page)
baseline := frameHeight(t, page)
require.Greater(t, baseline, preloaderCeiling, "the widget never rendered, so there is no baseline")
require.NoError(t, frame.Locator(".auth-button").Click())
waitVisible(t, frame.Locator(".auth-dropdown"))
waitHeightAbove(t, page, baseline+growthFloor, "opening the sign-in dropdown did not grow the iframe")
// a click on the host page, not in the widget: the parent forwards it as
// clickOutside, which is what closes the dropdown
require.NoError(t, page.Mouse().Click(5, 5))
waitHidden(t, frame.Locator(".auth-dropdown"), "the click on the host page did not close the dropdown")
waitHeightNear(t, page, baseline, "the iframe kept the dropdown's height after it closed")
textarea := frame.Locator(commentFormSel).First().Locator("textarea")
require.NoError(t, textarea.Fill(strings.Repeat("a line of a comment\n", 12)))
waitHeightAbove(t, page, baseline+growthFloor, "the iframe did not grow with the comment being typed")
require.NoError(t, textarea.Fill(""))
waitHeightNear(t, page, baseline, "the iframe did not come back down after the text was cleared")
}
// heightReports is every height the widget has asked the parent for, in order. The recorder is
// installed as an init script, so the sequence starts with the one applied during page load
func heightReports(t *testing.T, page playwright.Page) []float64 {
t.Helper()
v, err := page.Evaluate(`() => ((window.__r42Marks && window.__r42Marks.heights) || []).map((r) => r.h)`)
require.NoError(t, err)
raw, ok := v.([]any)
require.True(t, ok, "expected a list of heights from the page, got %T (%v)", v, v)
out := make([]float64, 0, len(raw))
for _, item := range raw {
out = append(out, asNumber(t, item))
}
return out
}
// frameHeight is the height the parent applied to the iframe element, which is the only number
// the reader ever sees
func frameHeight(t *testing.T, page playwright.Page) float64 {
t.Helper()
return evalNumber(t, page, `() => {
const frame = document.querySelector('#remark42 iframe');
return frame ? frame.getBoundingClientRect().height : -1;
}`)
}
// inWidget reads a number out of the widget's own document, which page.Evaluate cannot reach
func inWidget(t *testing.T, page playwright.Page, script string) float64 {
t.Helper()
v, err := page.FrameLocator("#remark42 iframe").Locator("body").Evaluate(script, nil,
playwright.LocatorEvaluateOptions{Timeout: playwright.Float(float64(waitTimeout.Milliseconds()))})
require.NoError(t, err)
return asNumber(t, v)
}
// waitHeightSettled waits for the widget to stop re-reporting its height, so an assertion about
// the sequence is made against a complete one, not against however much of it has arrived
func waitHeightSettled(t *testing.T, page playwright.Page) {
t.Helper()
seen, stable := 0, 0
eventually(t, waitTimeout, "the widget never stopped changing the iframe height", func() bool {
n := len(heightReports(t, page))
if n > 0 && n == seen {
stable++
} else {
stable = 0
}
seen = n
// five polls of quiet, against a poll interval of 50ms
return n > 0 && stable >= 5
})
}
func waitHeightAbove(t *testing.T, page playwright.Page, floor float64, msg string) {
t.Helper()
eventually(t, waitTimeout, msg, func() bool { return frameHeight(t, page) > floor })
}
func waitHeightNear(t *testing.T, page playwright.Page, want float64, msg string) {
t.Helper()
eventually(t, waitTimeout, msg, func() bool {
got := frameHeight(t, page)
return got >= want-heightTolerance && got <= want+heightTolerance
})
}
// TestGeometry_CollapsingAThreadShrinksTheFrame covers the direction the cases above do not.
// Everything else here asserts the frame growing, and a widget that only ever grew would satisfy
// all of them while leaving a hole in the page under every collapsed thread for as long as the
// reader stays on it
func TestGeometry_CollapsingAThreadShrinksTheFrame(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInAnon(t, page, frame, anonName("collapsegeometry"))
parent := "collapse geometry parent " + runID
postComment(t, frame, parent)
require.NoError(t, actions(frame, parent).Locator(`button:has-text("Reply")`).Click())
reply := "collapse geometry reply " + runID
submitForm(t, replyForm(t, frame), reply)
waitVisible(t, comment(frame, reply))
waitHeightSettled(t, page)
expanded := frameHeight(t, page)
require.Greater(t, expanded, preloaderCeiling, "the widget never rendered, so there is no baseline")
id, err := comment(frame, parent).GetAttribute("id")
require.NoError(t, err)
thread := frame.Locator(fmt.Sprintf("[aria-expanded]:has(article#%s)", id))
require.NoError(t, thread.Locator(`:scope > [role="button"]`).Click())
eventually(t, waitTimeout, "the frame did not shrink when the thread collapsed", func() bool {
return frameHeight(t, page) < expanded-growthFloor
})
// and the document agrees, so the frame is following content and not merely being told a
// smaller number
assert.InDelta(t, inWidget(t, page, `() => document.body.offsetHeight`), frameHeight(t, page), heightTolerance,
"the collapsed frame and the document it holds disagree on the height")
}
+178
View File
@@ -0,0 +1,178 @@
//go:build e2e
package e2e
import (
"context"
"fmt"
"os/exec"
"slices"
"strings"
"sync"
"testing"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
)
// stampEnv carries the source digest into compose, which sets it on the remark42 service.
// stamp.sh computes it and the Makefile exports it too, so a stack started by hand is stamped
// exactly as one the suite starts itself
const stampEnv = "E2E_STAMP"
// stampVar is what compose names it inside the container
const stampVar = "E2E_SOURCE_STAMP"
// sourceStamp digests the sources that end up in the image. Shelling out keeps one definition of
// what the digest covers, since the Makefile needs the same value and cannot call into this package
func sourceStamp() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), dockerTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "./stamp.sh").Output()
if err != nil {
return "", fmt.Errorf("computing the source stamp: %w: %s", err, exitStderr(err))
}
stamp := strings.TrimSpace(string(out))
if stamp == "" {
return "", fmt.Errorf("stamp.sh produced nothing")
}
return stamp, nil
}
// stackStamp is what the running stack was brought up from, read out of the container
func stackStamp() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), dockerTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "exec", stackContainer, "printenv", stampVar).Output()
if err != nil {
return "", fmt.Errorf("reading %s out of %s: %w: %s", stampVar, stackContainer, err, exitStderr(err))
}
return strings.TrimSpace(string(out)), nil
}
// assertStackMatches refuses a stack built from other sources than the ones under test.
//
// Every checkout shares the image tag the compose file names, so a stack brought up from
// another worktree, or from this one before an edit, answers on the same ports and passes every
// readiness probe while serving code nobody is looking at. adopted says whether the suite found
// the stack instead of starting it, which is the only case a mismatch is expected in:
// after our own build it means the stamp never reached the image
func assertStackMatches(want string, adopted bool) error {
got, err := stackStamp()
if err != nil {
return err
}
if got == want {
return nil
}
if !adopted {
return fmt.Errorf("the stack was just started from %s but reports %q, so %s is not reaching the container",
want, got, stampEnv)
}
return fmt.Errorf("the stack answering on 127.0.0.1 was brought up from %q, not from the sources here (%s). "+
"it belongs to another checkout or predates an edit: `make e2e-down` and run again", got, want)
}
// pageIssue is a failure the browser reported that no assertion in the test looks at
type pageIssue struct {
kind string
text string
}
func (i pageIssue) String() string { return i.kind + ": " + i.text }
// pageWatch collects those failures for one page. Without it a widget throwing on every load,
// or a run quietly eating rate-limit responses, shows up only as whichever assertion happens to
// depend on the damage, and plenty of them depend on none
type pageWatch struct {
mu sync.Mutex
issues []pageIssue
// console errors, which are context and not a verdict: the browser logs one for every
// request that fails, so on the cases driving an error path they restate a status the test
// has already asserted on, and the wording differs between engines. logged when the test
// fails, never the reason it failed
noted []pageIssue
}
func (w *pageWatch) record(kind, text string) {
w.mu.Lock()
defer w.mu.Unlock()
w.issues = append(w.issues, pageIssue{kind: kind, text: text})
}
func (w *pageWatch) note(kind, text string) {
w.mu.Lock()
defer w.mu.Unlock()
w.noted = append(w.noted, pageIssue{kind: kind, text: text})
}
func (w *pageWatch) notes() []pageIssue {
w.mu.Lock()
defer w.mu.Unlock()
return slices.Clone(w.noted)
}
// unexpected is everything recorded that has to fail the test
func (w *pageWatch) unexpected() []pageIssue {
w.mu.Lock()
defer w.mu.Unlock()
return slices.Clone(w.issues)
}
var (
watchesMu sync.Mutex
watches = map[playwright.Page]*pageWatch{}
)
// watchPage starts collecting the page's own failure reports and fails the test at cleanup for
// any the test did not declare.
//
// Uncaught exceptions are what this is for: a widget that throws while rendering still leaves
// most of this suite green, since the assertions are about elements the browser lays out either
// way, and #1979 and #851 were both exactly that. Console errors are collected but never fail a
// test: the browser logs one for every failed request, so the cases that drive an error path
// would all have to declare a status they already assert on, and the wording is engine-specific
func watchPage(t *testing.T, page playwright.Page) {
t.Helper()
w := &pageWatch{}
watchesMu.Lock()
watches[page] = w
watchesMu.Unlock()
page.OnPageError(func(err error) { w.record("uncaught exception", err.Error()) })
page.OnConsole(func(msg playwright.ConsoleMessage) {
if msg.Type() == "error" {
w.note("console error", msg.Text())
}
})
t.Cleanup(func() {
watchesMu.Lock()
delete(watches, page)
watchesMu.Unlock()
for _, issue := range w.unexpected() {
assert.Fail(t, "the browser reported a failure no assertion covers", issue.String())
}
if t.Failed() {
for _, issue := range w.notes() {
t.Logf("from the browser: %s", issue)
}
}
})
}
// recordPageIssue files something seen outside the browser's own event handlers, such as a
// rate-limit response, against the page it happened on
func recordPageIssue(page playwright.Page, kind, text string) {
watchesMu.Lock()
w, ok := watches[page]
watchesMu.Unlock()
if ok {
w.record(kind, text)
}
}
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>A page on somebody else's site</title>
</head>
<body>
<h1>A page on somebody else's site</h1>
<p>
This page is served from a different origin than the comments it embeds, which is the
separate-domain setup in the manuals and the one nothing else in the suite covers.
</p>
<div id="remark42"></div>
<script>
var remark_config = {
host: 'http://remark42:8080',
site_id: 'remark',
components: ['embed'],
// the whole address, as the demo page does. left to itself the widget uses origin and
// path only, so every query string would share one thread and the suite could not give a
// case a thread of its own
url: window.location.href,
};
(function (c, d) {
for (var i = 0; i < c.length; i++) {
var s = d.createElement('script');
s.type = 'module';
s.async = true;
s.defer = true;
s.src = remark_config.host + '/web/' + c[i] + '.mjs';
(d.head || d.body).appendChild(s);
}
})(remark_config.components, document);
</script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>An embed the instance does not allow</title>
</head>
<body>
<h1>An embed the instance does not allow</h1>
<p>
The instance this page embeds sets ALLOWED_HOSTS to its own address, so the browser refuses
to frame it here and the widget document never runs.
</p>
<div id="remark42"></div>
<script>
var remark_config = {
host: 'http://remark42-noauth:8085',
site_id: 'remark',
components: ['embed'],
url: window.location.href,
};
(function (c, d) {
for (var i = 0; i < c.length; i++) {
var s = d.createElement('script');
s.type = 'module';
s.async = true;
s.defer = true;
s.src = remark_config.host + '/web/' + c[i] + '.mjs';
(d.head || d.body).appendChild(s);
}
})(remark_config.components, document);
</script>
</body>
</html>
+28 -11
View File
@@ -65,7 +65,7 @@ func TestIframe_ParentAndDocumentAgreeOnColorScheme(t *testing.T) {
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
// the demo page reads prefers-color-scheme, not a query parameter
require.NoError(t, page.EmulateMedia(playwright.PageEmulateMediaOptions{ColorScheme: scheme}))
pauseForAuthLimit()
@@ -129,6 +129,17 @@ const iframeMarkScript = `(() => {
if (window.top !== window) { return; }
if (window.__r42Marks) { return; }
window.__r42Marks = {};
// every height the widget asks the parent for, in order and against the same clock. installed
// here and not in the test because embed.ts applies the first one during page load, and a
// listener added afterwards sees only what the widget reports from then on, which is exactly
// the part that was never wrong
window.__r42Marks.heights = [];
window.addEventListener('message', (event) => {
const data = event.data;
if (data && typeof data === 'object' && typeof data.height === 'number') {
window.__r42Marks.heights.push({h: data.height, t: performance.now()});
}
});
const watch = (frame) => {
if (window.__r42Marks.created !== undefined) { return; }
window.__r42Marks.created = performance.now();
@@ -142,7 +153,7 @@ const iframeMarkScript = `(() => {
style.observe(frame, {attributes: true, attributeFilter: ['style']});
seen();
};
// document rather than documentElement: an init script runs before the root element
// document and not documentElement: an init script runs before the root element
// exists, and observing null would throw before any of this could take effect
const tree = new MutationObserver(() => { scan(); });
const scan = () => {
@@ -159,13 +170,19 @@ const iframeMarkScript = `(() => {
scan();
})()`
// evalMillis reads a number out of the page. it takes int as well as float64, because the
// driver hands back whichever the value happens to be and a bare float64 assertion turns an
// integral sentinel into a silent zero
func evalMillis(t *testing.T, page playwright.Page, script string) float64 {
// evalNumber reads a number out of the page
func evalNumber(t *testing.T, page playwright.Page, script string) float64 {
t.Helper()
v, err := page.Evaluate(script)
require.NoError(t, err)
return asNumber(t, v)
}
// asNumber converts what the driver hands back. it takes int as well as float64, because the
// driver returns whichever the value happens to be and a bare float64 assertion turns an
// integral sentinel into a silent zero
func asNumber(t *testing.T, v any) float64 {
t.Helper()
switch n := v.(type) {
case float64:
@@ -186,7 +203,7 @@ func evalMillis(t *testing.T, page playwright.Page, script string) float64 {
// here reads slightly short: bounds below a budget are conservative, bounds above it are not
func iframeAge(t *testing.T, page playwright.Page) time.Duration {
t.Helper()
ms := evalMillis(t, page, `() => window.__r42Marks && window.__r42Marks.created !== undefined
ms := evalNumber(t, page, `() => window.__r42Marks && window.__r42Marks.created !== undefined
? performance.now() - window.__r42Marks.created : -1`)
require.GreaterOrEqual(t, ms, float64(0), "the iframe element has not been created yet")
return time.Duration(ms) * time.Millisecond
@@ -196,7 +213,7 @@ func iframeAge(t *testing.T, page playwright.Page) time.Duration {
// reports false while the frame is still hidden
func revealDelay(t *testing.T, page playwright.Page) (time.Duration, bool) {
t.Helper()
ms := evalMillis(t, page, `() => {
ms := evalNumber(t, page, `() => {
const m = window.__r42Marks;
if (!m || m.created === undefined) { return -2; }
return m.revealed !== undefined ? m.revealed - m.created : -1;
@@ -268,13 +285,13 @@ func TestIframe_IsRevealedByTheInitedMessage(t *testing.T) {
return ok
})
// the reveal has to have come from the message rather than the fallback, and the two
// the reveal has to have come from the message and not the fallback, and the two
// are only distinguishable against the frame's own clock: navigation can outlast the
// whole 5s window without the widget being at fault
delay, ok := revealDelay(t, page)
require.True(t, ok, "the frame reported no reveal at all")
assert.Less(t, delay, messageRevealBudget,
"the reveal was slow enough to have come from the fallback rather than the message")
"the reveal was slow enough to have come from the fallback and not the message")
waitVisible(t, page.Locator("#remark42 iframe"))
})
}
@@ -293,7 +310,7 @@ func TestIframe_IsRevealedByTheTimeoutWhenInitedNeverArrives(t *testing.T) {
// and not before it: without a lower bound, shortening the fallback to a value that
// defeats its purpose would still pass. against the frame's own clock, so that a slow
// navigation cannot be mistaken for the timer having run
// close to the fallback rather than three quarters of it: measured in the page there is
// close to the fallback and not three quarters of it: measured in the page there is
// no navigation to make room for, and a wider floor tolerates a fallback shortened
// enough to defeat its purpose
delay, ok := revealDelay(t, page)
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# Digest of everything that ends up in the e2e image.
#
# The compose stack tags its image ghcr.io/umputun/remark42:dev, which every checkout of this
# repository shares, so a stack brought up from one worktree answers on the same ports as one
# brought up from another. The suite stamps the image it builds with this value and refuses a
# running stack carrying a different one, so it never tests code nobody is looking at.
#
# Tracked content is covered exactly; an untracked file changes the digest when it appears,
# by name, but later edits to it do not.
set -eu
cd "$(dirname "$0")/.."
sources="backend frontend Dockerfile docker-init.sh"
digest() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum
else
shasum -a 256
fi
}
{
# the content of those paths, not the commit: an e2e-only commit cannot change the image, and
# keying on HEAD would rebuild the stack for every one of them
# shellcheck disable=SC2086 # the path list is deliberately split into arguments
for path in $sources; do
git rev-parse "HEAD:$path"
done
# shellcheck disable=SC2086
git diff HEAD -- $sources
# shellcheck disable=SC2086
git status --porcelain -- $sources
} | digest | cut -c1-16
+88
View File
@@ -0,0 +1,88 @@
//go:build e2e
package e2e
import (
"fmt"
"net/http"
"os"
"testing"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSubscribe_EmailRoundTrip drives the email subscription: ask for it from the panel, confirm
// it with the token that arrives in the message, and give it up again.
//
// The whole area was unreachable from a browser until the stack enabled the notify module, since
// the widget renders the control from `email_notifications` in the config and hides it otherwise.
// It is also the only place a token out of a real message is exchanged for state the server
// keeps, so a broken template, a broken token round trip or a broken unsubscribe all land here.
//
// The dev user, not an email one: signing in by email leaves the account already subscribed, so
// the panel opens on the subscribed step and there is nothing to ask for. Anonymous will not do
// either, the control being disabled for anonymous users.
//
// The confirmation goes through the page's own session instead of the panel's textarea. The panel
// moves to its subscribed step while that textarea is still on screen, so there is no moment at
// which a control to submit it can be located; the request carries the token that arrived in the
// message either way, which is what the exchange is.
func TestSubscribe_EmailRoundTrip(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
// per-run and per-process, for the reason anonName carries the pid: mailpit keeps every
// message, so a fixed address would also let this read a token from an earlier run
address := fmt.Sprintf("subscriber-%s-%d@example.com", runID, os.Getpid())
// the dev user is shared by the whole suite and a subscription outlives the run in the
// stack's database, so a stack this has already run against starts on the subscribed step.
// cleared through the API and not the panel: the precondition is not what is under test.
// 200 and 400 both leave nothing subscribed, which is all this needs
status, body := pageFetch(t, page, "DELETE", baseURL+"/api/v1/email?site=remark", nil)
require.Contains(t, []int{http.StatusOK, http.StatusBadRequest}, status,
"could not clear a subscription left by an earlier run: %s", body)
frame = reload(t, page)
subscribe := frame.Locator(`[title="Subscribe by Email"]`)
waitVisible(t, subscribe)
require.NoError(t, subscribe.Click())
email := frame.Locator(`input[placeholder="Email"]`)
waitVisible(t, email)
require.NoError(t, email.Fill(address))
// the request the panel makes, so a submit going nowhere fails as itself
resp, err := page.ExpectResponse("**/api/v1/email/subscribe**", func() error {
return frame.Locator(`button:text-is("Submit")`).Click()
}, playwright.PageExpectResponseOptions{Timeout: playwright.Float(float64(waitTimeout.Milliseconds()))})
require.NoError(t, err, "the panel asked the server for nothing")
require.Equal(t, http.StatusOK, resp.Status(), "the server refused to send a verification")
// the token out of the message the server actually sent
token := verificationToken(t, mailpitMessage(t, address))
status, body = pageFetch(t, page,
"POST", fmt.Sprintf("%s/api/v1/email/confirm?site=remark&tkn=%s", baseURL, token), nil)
assert.Equal(t, http.StatusOK, status, "the server refused the token it had just sent: %s", body)
// the subscription is the server's now, so the panel offers to end it on the next load
frame = reload(t, page)
require.NoError(t, frame.Locator(`[title="Subscribe by Email"]`).Click())
unsubscribe := frame.Locator(`button:text-is("Unsubscribe")`)
waitVisible(t, unsubscribe)
// the panel confirming an unsubscribe is not observable: the click changes the step, and the
// dropdown closes on an element no longer in the rerendered view, which the component notes
// as a known awkwardness of its own. So assert what the server was asked and what it
// answered, which is what decides whether the reader still gets mail
resp, err = page.ExpectResponse("**/api/v1/email**", func() error {
return unsubscribe.Click()
}, playwright.PageExpectResponseOptions{Timeout: playwright.Float(float64(waitTimeout.Milliseconds()))})
require.NoError(t, err, "clicking Unsubscribe asked the server nothing")
assert.Equal(t, "DELETE", resp.Request().Method())
assert.Equal(t, http.StatusOK, resp.Status(), "the server refused the unsubscribe")
}
+82 -5
View File
@@ -4,6 +4,7 @@ package e2e
import (
"fmt"
neturl "net/url"
"strings"
"testing"
@@ -13,8 +14,8 @@ import (
)
// 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
// error instead of failing, because every caller polls it while the list re-renders and a
// momentarily empty list has to be retried, not fail the test
func firstCommentText(frame playwright.FrameLocator) (string, error) {
return pollText(frame.Locator("article").First())
}
@@ -84,7 +85,7 @@ func TestThread_CollapsePersistsAcrossReload(t *testing.T) {
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
// anchor on the comment's own id, not 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")
@@ -103,14 +104,14 @@ func TestThread_CollapsePersistsAcrossReload(t *testing.T) {
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
// counting instead of 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
// collapse is client-only state, kept in localStorage and not on the server
frame = reload(t, page)
thread = frame.Locator(threadSel)
eventually(t, waitTimeout, "collapse did not survive reload", func() bool {
@@ -120,3 +121,79 @@ func TestThread_CollapsePersistsAcrossReload(t *testing.T) {
assert.Equal(t, 1, articleCount(t, frame), "a collapsed thread should not render its replies after reload")
}
// TestThread_HideUserRemovesTheirCommentsOnly covers hiding an author, which is a reader-side
// setting and not a moderator action: it patches every comment already on screen, persists in the
// browser and not on the server, and has to be undone from the settings panel. The second author
// is what makes it a test of hiding one person and not of emptying the thread
func TestThread_HideUserRemovesTheirCommentsOnly(t *testing.T) {
hidden := "hidden author " + runID
kept := "kept author " + runID
author := newPage(t)
authorFrame := openThread(t, author)
signInAnon(t, author, authorFrame, "hidetarget")
postComment(t, authorFrame, hidden)
postComment(t, authorFrame, hidden+" second")
other := newPage(t)
otherFrame := openURL(t, other, threadURL(t))
signInAnon(t, other, otherFrame, "hidekeeper")
postComment(t, otherFrame, kept)
reader := newPage(t)
frame := openURL(t, reader, threadURL(t))
signInDev(t, reader, frame)
before := articleCount(t, frame)
require.Equal(t, 3, before, "the three seeded comments have to be on screen before hiding one author")
reader.OnDialog(func(d playwright.Dialog) { _ = d.Accept() })
require.NoError(t, actions(frame, hidden).Locator(`button:has-text("Hide")`).Click())
eventually(t, waitTimeout, "hiding the author did not remove both of their comments", func() bool {
return articleCount(t, frame) == before-2
})
waitVisible(t, comment(frame, kept))
frame = reload(t, reader)
assert.Equal(t, before-2, articleCount(t, frame), "hiding has to survive a reload, it is stored in the browser")
waitVisible(t, comment(frame, kept))
require.NoError(t, frame.Locator(`button:has-text("Show settings")`).Click())
// the restore control is a span and not a button, and "show" appears in the settings
// toggle as well, so match it exactly and only inside the hidden users region
hiddenUsers := frame.Locator(`[role="region"][aria-label="Hidden users"]`)
waitVisible(t, hiddenUsers)
require.NoError(t, hiddenUsers.GetByText("show", playwright.LocatorGetByTextOptions{
Exact: playwright.Bool(true),
}).Click())
frame = reload(t, reader)
assert.Equal(t, before, articleCount(t, frame), "restoring the author has to bring their comments back")
}
// TestThread_WidgetDocumentServesItsOwnChunks covers the widget document loaded directly on the
// instance's origin, which is how a reader's browser loads it and how nothing else here does:
// TestWidgets_EveryLocaleLoadsAndRenders injects a config of its own into a plain page, so it
// exercises the translations and not the delivery.
//
// The distinction is the point. The bundle addresses the host the build was substituted with and
// its CSP is `self`, so a mismatch between the two blocks the widget's own chunks with nothing in
// the page to say why. A locale is the payload because it is the one thing loaded as a separate
// chunk after boot, so it fails when the origin is wrong and renders English instead of throwing.
func TestThread_WidgetDocumentServesItsOwnChunks(t *testing.T) {
page := newPage(t)
pauseForAuthLimit()
// the widget document, not the demo page, since nothing there can pass a locale. it has to
// be opened on the instance's own origin: the bundle addresses the host the build was
// substituted with, and its CSP is `self`, so a mismatched origin blocks its own chunk
_, err := page.Goto(fmt.Sprintf("%s/web/iframe.html?site_id=remark&locale=ru&url=%s",
baseURL, neturl.QueryEscape(threadURL(t))))
require.NoError(t, err)
// two strings, not one, and both from the chunk and not from the code's own
// defaults: the sort label is always present, and the sign-in button only while signed out
waitVisible(t, page.Locator("text=Сортировать по"))
waitVisible(t, page.Locator(`text=Войти`))
}
+143 -5
View File
@@ -3,6 +3,10 @@
package e2e
import (
"fmt"
"net/http"
neturl "net/url"
"strings"
"sync"
"testing"
@@ -24,7 +28,7 @@ func voteScenario(t *testing.T, author, text string) (playwright.Page, playwrigh
authorPage := newPage(t)
authorFrame := openThread(t, authorPage)
signInAnon(t, authorFrame, author)
signInAnon(t, authorPage, authorFrame, author)
postComment(t, authorFrame, text)
voter := newPage(t)
@@ -47,7 +51,7 @@ func TestVote_UpvoteCountsOnce(t *testing.T) {
return err == nil && v == "1"
})
// the vote is stored rather than only reflected in local state
// the vote is stored and not 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))
@@ -63,7 +67,7 @@ func TestVote_UpvoteCountsOnce(t *testing.T) {
}
// 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
// The score is optimistic, so a failed request has to put it back instead of leaving the
// reader believing a vote landed.
func TestVote_FailureShowsAnErrorAndRestoresTheScore(t *testing.T) {
text := "vote failure " + runID
@@ -79,7 +83,7 @@ func TestVote_FailureShowsAnErrorAndRestoresTheScore(t *testing.T) {
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
// 409 and not 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
@@ -100,7 +104,7 @@ func TestVote_FailureShowsAnErrorAndRestoresTheScore(t *testing.T) {
unblock()
// the widget shows its own copy for the status rather than the raw body
// the widget shows its own copy for the status, not the raw body
waitVisible(t, target.Locator("text=Conflict."))
eventually(t, waitTimeout, "the optimistic score was not rolled back", func() bool {
@@ -108,3 +112,137 @@ func TestVote_FailureShowsAnErrorAndRestoresTheScore(t *testing.T) {
return err == nil && v == "0"
})
}
// TestVote_AnonymousVoterCounts covers ANON_VOTE, which the default configuration refuses:
// rest_private.go turns a vote down when the user id carries the anonymous prefix unless the
// setting is on, and it is only meaningful alongside VOTES_IP, which is what scopes it. Its own
// instance, since both are server flags. The vote and the author are separate anonymous users
// because remark42 hides the buttons on your own comment either way
func TestVote_AnonymousVoterCounts(t *testing.T) {
thread := threadURLOn(t, anonVoteURL)
text := "anon vote target " + runID
author := newPage(t)
authorFrame := openURL(t, author, thread)
signInAnon(t, author, authorFrame, "anonvoteauthor")
postComment(t, authorFrame, text)
voter := newPage(t)
voterFrame := openURL(t, voter, thread)
signInAnon(t, voter, voterFrame, "anonvoter")
target := comment(voterFrame, text)
waitVisible(t, target)
require.NoError(t, target.Locator(`button[title="Vote up"]`).Click())
eventually(t, waitTimeout, "the anonymous vote did not register", func() bool {
v, err := pollText(score(voterFrame, text))
return err == nil && v == "1"
})
// stored, not merely reflected in the optimistic state the click set, which is the half a
// refused vote would still satisfy
voterFrame = reload(t, voter)
eventually(t, waitTimeout, "the anonymous vote did not survive a reload", func() bool {
v, err := pollText(score(voterFrame, text))
return err == nil && v == "1"
})
}
// TestVote_OwnCommentCannotBeVotedOn covers the rule every other vote case has to work around:
// remark42 does not offer the buttons on your own comment, and the backend refuses the vote even
// when the buttons are put back. Both halves, since the widget hiding them is only politeness
func TestVote_OwnCommentCannotBeVotedOn(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInAnon(t, page, frame, anonName("selfvoter"))
text := "own comment " + runID
posted := postCommentMatching(t, frame, text, text)
buttons, err := posted.Locator(`button[title="Vote up"]`).Count()
require.NoError(t, err)
assert.Zero(t, buttons, "the widget offered a vote on the reader's own comment")
id, err := posted.GetAttribute("id")
require.NoError(t, err)
commentID := strings.TrimPrefix(id, "remark42__comment-")
// and the backend refuses it, which is what makes the absence above a courtesy and not the
// rule itself
vote := fmt.Sprintf("%s/api/v1/vote/%s?site=remark&url=%s&vote=1",
baseURL, commentID, neturl.QueryEscape(threadURL(t)))
status, body := pageFetch(t, page, "PUT", vote, nil)
assert.GreaterOrEqual(t, status, http.StatusBadRequest,
"the backend allowed a vote on the voter's own comment: %d %s", status, body)
}
// TestVote_DownvoteAndCorrection covers the other direction and the correction #728 asked for: a
// reader who changes their mind has to be able to, and the score has to end where the second vote
// leaves it. Nothing covered downvoting at all
func TestVote_DownvoteAndCorrection(t *testing.T) {
text := "downvote target " + runID
voter, voterFrame, target := voteScenario(t, "downvoteauthor", text)
require.NoError(t, target.Locator(`button[title="Vote down"]`).Click())
eventually(t, waitTimeout, "the downvote did not register", func() bool {
v, err := pollText(score(voterFrame, text))
return err == nil && v == "-1"
})
// and it is the server's, not the optimistic state the click set
voterFrame = reload(t, voter)
eventually(t, waitTimeout, "the downvote did not survive a reload", func() bool {
v, err := pollText(score(voterFrame, text))
return err == nil && v == "-1"
})
// then the reader changes their mind, which is what #728 asked for and #729 fixed: the
// opposite vote has to be accepted and not refused as a repeat. It takes the vote back
// instead of flipping it, so the score returns to zero and does not become +1. Asserted
// after a reload as well, since a correction the server never recorded leaves the reader
// looking at a number nobody else sees
require.NoError(t, comment(voterFrame, text).Locator(`button[title="Vote up"]`).Click())
eventually(t, waitTimeout, "the opposite vote did not take the downvote back", func() bool {
v, err := pollText(score(voterFrame, text))
return err == nil && v == "0"
})
voterFrame = reload(t, voter)
eventually(t, waitTimeout, "the correction did not survive a reload", func() bool {
v, err := pollText(score(voterFrame, text))
return err == nil && v == "0"
})
}
// TestVote_WithoutTheXSRFHeaderIsRefused pins the mechanism that decides how the widget can be
// rendered at all. go-pkgz/auth rejects a cookie-borne token whose X-XSRF-TOKEN header does not
// match the jti in it, with no exemption by method, which is why a document navigation, an iframe
// src among them, is always anonymous and why the widget hydrates its user over XHR. Anyone
// designing around that needs it pinned, since nothing else here would notice the check going
func TestVote_WithoutTheXSRFHeaderIsRefused(t *testing.T) {
text := "xsrf vote " + runID
voter, voterFrame, target := voteScenario(t, "xsrfauthor", text)
// strip the header the widget attaches to every call, and let the rest of the request go as
// it was: same cookie, same body, same origin
require.NoError(t, voter.Route("**/api/v1/vote/**", func(route playwright.Route) {
headers := route.Request().Headers()
delete(headers, "x-xsrf-token")
_ = route.Continue(playwright.RouteContinueOptions{Headers: headers})
}))
resp, err := voter.ExpectResponse("**/api/v1/vote/**", func() error {
return target.Locator(`button[title="Vote up"]`).Click()
}, playwright.PageExpectResponseOptions{Timeout: playwright.Float(float64(waitTimeout.Milliseconds()))})
require.NoError(t, err, "the vote was never sent")
assert.GreaterOrEqual(t, resp.Status(), http.StatusBadRequest,
"a vote without the XSRF header was accepted, so the check that forces anonymous-first "+
"rendering is no longer there")
// and the reader is not left believing it landed
eventually(t, waitTimeout, "the optimistic score was not rolled back", func() bool {
v, err := pollText(score(voterFrame, text))
return err == nil && v == "0"
})
}
+266
View File
@@ -0,0 +1,266 @@
//go:build e2e
package e2e
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
"os/exec"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// the e2e stack's container, named in compose-e2e-test.yml. addressed directly and not
// through `docker compose exec`, which resolves the service through a project name taken from
// the directory the compose file sits in: run the suite from a git worktree and it looks for a
// project named after the worktree instead of the stack that is answering
stackContainer = "remark42-e2e"
// what the served web root is inside that container
stackWebRoot = "/srv/web"
// enough for a healthy docker CLI and short enough to fail as itself. an unbounded call to a
// wedged daemon hangs until the package timeout panics the binary, which skips TestMain's
// teardown and leaves the stack running
dockerTimeout = 30 * time.Second
localesDir = "../frontend/apps/remark42/app/locales"
)
// the /web paths the documentation publishes, plus the legacy names of the same files. each is
// held by somebody outside this repository: an operator pastes privacy.html into an OAuth
// application, an nginx config proxies index.html, the integration guides start from the embed
// script, and the comment form links markdown-help.html. written out and not derived, because
// the documentation decides the list, so a name joining or leaving is an edit made on purpose
var documentedWebPaths = []struct {
path string
// what publishes it, so a failure names who is holding the URL
where string
// the content type it has to keep. nosniff is set on every response, so a bundle served as
// text/plain is as broken as one that 404s while its bytes still compare equal
mime string
// something only the right file contains. without it a directory listing, or an error page
// carrying a 200, passes for the real thing
marker string
}{
{"/web", "getting-started/installation, the first URL an operator opens", "text/html", `id="remark42"`},
{"/web/", "linked from most documentation pages", "text/html", `id="remark42"`},
{"/web/index.html", "manuals/nginx proxies this exact URL", "text/html", `id="remark42"`},
{"/web/embed.mjs", "configuration/frontend/spa.md, manuals/subdomain, contributing/frontend", "text/javascript", "remark_config"},
{"/web/embed.js", "the legacy name integrations still hold", "text/javascript", "remark_config"},
{"/web/counter.mjs", "built from remark_config.components by the loader in configuration/frontend", "text/javascript", "remark42__counter"},
{"/web/counter.js", "the legacy name the same loader built", "text/javascript", "remark42__counter"},
{"/web/last-comments.mjs", "built from remark_config.components by the same loader", "text/javascript", "remark_config"},
{"/web/last-comments.js", "the legacy name the same loader built", "text/javascript", "remark_config"},
{"/web/privacy.html", "configuration/authorization, pasted into an OAuth application", "text/html", "Privacy Policy"},
{"/web/markdown-help.html", "linked by the comment form", "text/html", "Markdown"},
{"/web/400x400.jpeg", "embedded by markdown-help.html", "image/jpeg", ""},
}
// TestWeb_DocumentedURLsResolve pins the published surface as a compatibility contract. A page or
// an OAuth application set up years ago holds these URLs for good, and the build changing shape is
// not their problem
func TestWeb_DocumentedURLsResolve(t *testing.T) {
for _, tc := range documentedWebPaths {
t.Run(strings.TrimPrefix(tc.path, "/"), func(t *testing.T) {
resp := getWeb(t, tc.path)
assert.Equal(t, http.StatusOK, resp.status, "%s has to keep resolving: %s", tc.path, tc.where)
assert.NotEmpty(t, resp.body, "%s resolved but served nothing", tc.path)
assert.Contains(t, resp.mime, tc.mime, "%s serves the wrong type, and nosniff means the "+
"browser will refuse it whatever the bytes are", tc.path)
if tc.marker != "" {
assert.Contains(t, resp.body, tc.marker, "%s resolved but is not the file it should be", tc.path)
}
})
}
}
// TestWeb_UnknownNameIs404 is the negative control for the case above: without it a fallback
// serving one page for everything under /web would keep every assertion there green
func TestWeb_UnknownNameIs404(t *testing.T) {
resp := getWeb(t, "/web/no-such-file.html")
assert.Equal(t, http.StatusNotFound, resp.status, "an unknown name has to 404, or the cases "+
"above cannot tell a served file from a fallback")
}
// TestWeb_EveryBundleServesUnderBothSuffixes covers the names the list above does not, and takes
// its input from the build and not from a list somebody maintains, so a locale joining or
// leaving needs no edit here. Whatever the bundler emitted has to answer under its legacy .js name
// with the same bytes and the same type, and has to parse as a classic script, which is the premise
// serving one under the other rests on
func TestWeb_EveryBundleServesUnderBothSuffixes(t *testing.T) {
names := emittedBundles(t)
// a listing from the wrong place, or one that lost most of its entries to a chunk directory,
// would leave a handful of cases running and report green. every locale is a chunk of its own,
// so the catalog count on disk is a floor the build cannot drop below
require.Contains(t, names, "remark.mjs", "listing is not the served web root: %v", names)
require.GreaterOrEqual(t, len(names), localeCount(t),
"%d bundles is fewer than there are locales, so the listing is missing chunks: %v", len(names), names)
t.Logf("checking %d bundles", len(names))
page := parsePage(t)
for _, name := range names {
t.Run(name, func(t *testing.T) {
legacy := strings.TrimSuffix(name, ".mjs") + ".js"
modern, alias := getWeb(t, "/web/"+name), getWeb(t, "/web/"+legacy)
require.NotEmpty(t, modern.body, "%s serves nothing, so everything below compares emptiness", name)
assert.Equal(t, digest(modern.body), digest(alias.body),
"%s and %s serve different content (%d and %d bytes), so the legacy name is not the same file",
name, legacy, len(modern.body), len(alias.body))
assert.Contains(t, alias.mime, "javascript",
"%s serves the wrong type, and nosniff means the browser refuses it however right the bytes are", legacy)
require.Empty(t, classicScriptError(t, page, modern.body),
"%s does not parse as a classic script, so serving it as %s hands a syntax error to the "+
"oldest integrations", name, legacy)
})
}
}
// webResponse is what the cases assert on: everything read from one request, since the body has to
// be consumed and closed before the next one anyway
type webResponse struct {
status int
mime string
body string
}
// getWeb requests a path from the stack. Redirects are followed, because whoever holds a
// documented URL cares whether the page arrives, not how many hops it took: /web and
// /web/index.html both answer 301 towards the directory
func getWeb(t *testing.T, path string) webResponse {
t.Helper()
pauseForWebLimit()
resp, err := probeClient.Get(probeURL + path)
require.NoError(t, err)
defer func() { assert.NoError(t, resp.Body.Close()) }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NotEqual(t, http.StatusTooManyRequests, resp.StatusCode,
"%s was rate limited, which is this file pacing itself wrong and not a broken URL", path)
return webResponse{status: resp.StatusCode, mime: resp.Header.Get("Content-Type"), body: string(body)}
}
func digest(body string) string {
sum := sha256.Sum256([]byte(body))
return hex.EncodeToString(sum[:])
}
// emittedBundles lists the .mjs files the running stack serves, recursively, so chunks landing in
// a subdirectory stay in the set. Read from the container because the bundler runs inside the
// image build: the host has no build output, and a list from anywhere else describes another build
func emittedBundles(t *testing.T) []string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), dockerTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "exec", stackContainer,
"find", stackWebRoot, "-name", "*.mjs").Output()
require.NoError(t, err, "listing %s in %s: %s", stackWebRoot, stackContainer, exitStderr(err))
var names []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if name := strings.TrimPrefix(strings.TrimSpace(line), stackWebRoot+"/"); name != "" {
names = append(names, name)
}
}
slices.Sort(names)
return names
}
// localeCount is how many message catalogs the app carries, each of which the bundler emits as
// its own chunk
func localeCount(t *testing.T) int {
t.Helper()
entries, err := os.ReadDir(localesDir)
require.NoError(t, err, "reading %s", localesDir)
count := 0
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".json") {
count++
}
}
require.NotZero(t, count, "no catalogs in %s, so the floor below would be meaningless", localesDir)
return count
}
// exitStderr is what the command wrote to stderr, which Output keeps out of the parsed result
func exitStderr(err error) string {
var exit *exec.ExitError
if errors.As(err, &exit) {
return string(exit.Stderr)
}
return ""
}
// parsePage is a browser page for the parse probe alone. Not newPage: that one records a trace on
// failure, and a trace of a page which never navigates sends the reader to an empty recording
func parsePage(t *testing.T) playwright.Page {
t.Helper()
page, err := browser.NewPage()
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, page.Close()) })
return page
}
// classicScriptError returns the parse error a classic script parser gives for src, and an empty
// string when there is none. new Function parses its argument as a function body, which rejects a
// top level import, export or import.meta exactly as a classic script does, and runs nothing. The
// grammars are not identical, a function body also accepting return and new.target, but only in
// the direction that lets more through, so the probe never fails a bundle a browser would take
func classicScriptError(t *testing.T, page playwright.Page, src string) string {
t.Helper()
v, err := page.Evaluate(`src => {
try { new Function(src); return ""; }
catch (e) { return e.name === "SyntaxError" ? String(e) : ""; }
}`, src)
require.NoError(t, err)
msg, ok := v.(string)
require.True(t, ok, "the parse probe returned %T instead of a string", v)
return msg
}
var (
webGate sync.Mutex
lastWebGet time.Time
)
// everything under /web/ is rate limited to 20 requests a second, hard coded in rest.go rather
// than settable, and the bundle case asks for two files per bundle. Without pacing this file
// manufactures the 429s it would then have to tell apart from a missing URL
func pauseForWebLimit() {
const spacing = 55 * time.Millisecond
webGate.Lock()
defer webGate.Unlock()
if wait := spacing - time.Since(lastWebGet); wait > 0 {
time.Sleep(wait)
}
lastWebGet = time.Now()
}
+147 -8
View File
@@ -5,7 +5,11 @@ package e2e
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/mxschmitt/playwright-go"
@@ -15,26 +19,49 @@ import (
)
// 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
// embedding page and not 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")
signInAnon(t, poster, frame, "lastcommenter")
postComment(t, frame, text)
page := newPage(t)
// the stylesheet is appended at runtime and nothing waits for it, so the comments render
// whether or not it arrives. wait for the response itself instead of sampling afterwards,
// which reads whatever has landed by then and passes when the miss is still in flight
pauseForAuthLimit()
_, err := page.Goto(baseURL + "/web/last-comments.html")
require.NoError(t, err)
css, err := page.ExpectResponse("**/last-comments.css", func() error {
_, gerr := page.Goto(baseURL + "/web/last-comments.html")
return gerr
}, playwright.PageExpectResponseOptions{Timeout: playwright.Float(float64(waitTimeout.Milliseconds()))})
require.NoError(t, err, "the page never asked for its stylesheet")
assert.Equal(t, 200, css.Status(), "the last-comments stylesheet did not load")
list := page.Locator(".remark42__last-comments")
waitVisible(t, list)
waitVisible(t, list.Locator("text="+text))
}
// TestWidgets_DeleteMePageServesAndRuns covers the GDPR delete page, which nothing else opens. It
// needs an admin token to do its work, so this drives the branch it takes without one: reaching
// that message proves the html and its bundle were both served and executed.
func TestWidgets_DeleteMePageServesAndRuns(t *testing.T) {
page := newPage(t)
pauseForAuthLimit()
resp, err := page.Goto(baseURL + "/web/deleteme.html")
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, 200, resp.Status())
waitVisible(t, page.Locator("text=You are not logged in"))
}
// TestWidgets_CounterFillsInTheCommentCount covers the other host-page script.
//
// The demo page hard-codes both counters to one fixed url, so "some digits appeared" would
@@ -45,7 +72,7 @@ func TestWidgets_CounterFillsInTheCommentCount(t *testing.T) {
poster := newPage(t)
frame := openThread(t, poster)
signInAnon(t, frame, "countertester")
signInAnon(t, poster, frame, "countertester")
before := commentCount(t, poster, counted)
for i := range 2 {
@@ -96,7 +123,7 @@ func TestWidgets_CounterFillsInTheCommentCount(t *testing.T) {
})
}
// and the data-url branch resolves to its own thread rather than the page's
// and the data-url branch resolves to its own thread and not 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"))
@@ -109,7 +136,7 @@ func TestWidgets_LegacyJSURLLoadsAsAClassicScript(t *testing.T) {
poster := newPage(t)
frame := openThread(t, poster)
signInAnon(t, frame, "aliastester")
signInAnon(t, poster, frame, "aliastester")
postComment(t, frame, "alias "+runID)
want := strconv.Itoa(commentCount(t, poster, thread))
@@ -163,7 +190,7 @@ func TestWidgets_ProfileOpensInItsOwnIframe(t *testing.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
// match on the page parameter and not 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()
@@ -187,3 +214,115 @@ func TestWidgets_ProfileOpensInItsOwnIframe(t *testing.T) {
require.NoError(t, err)
assert.Zero(t, inWidget)
}
// TestWidgets_EveryLocaleLoadsAndRenders drives the dynamic import behind every translation, once
// per catalog. The catalogs are separate chunks the bundle fetches at runtime, so a change to
// how chunks are named or emitted breaks them without touching a line of widget code, and the
// failure is silent: loadLocale falls back to english instead of throwing, which is also what an
// unrecognized name does. Each case therefore compares against the catalog on disk, so english
// coming back is a failure and not a pass.
//
// The catalog set comes from the locales directory and not a list here, so a language added to
// the app is covered without an edit, and injected into a plain page and not the demo one,
// which is the only way to hand the widget a remark_config of this test's choosing
func TestWidgets_EveryLocaleLoadsAndRenders(t *testing.T) {
const localesDir = "../frontend/apps/remark42/app/locales"
entries, err := os.ReadDir(localesDir)
require.NoError(t, err, "reading %s", localesDir)
require.NotEmpty(t, entries, "no catalogs in %s, so this test would assert nothing", localesDir)
for _, entry := range entries {
locale, found := strings.CutSuffix(entry.Name(), ".json")
if !found {
continue
}
t.Run(locale, func(t *testing.T) {
raw, rerr := os.ReadFile(filepath.Join(localesDir, entry.Name())) //nolint:gosec // name from the walk
require.NoError(t, rerr)
var catalog map[string]string
require.NoError(t, json.Unmarshal(raw, &catalog))
want := catalog["commentForm.input-placeholder"]
require.NotEmpty(t, want, "%s carries no placeholder message to compare against", entry.Name())
page := newPage(t)
// this case never signs in, and the widget probes /auth/status on every load. that
// probe is capped at 2/s for the whole suite, so twenty four of them would spend a
// budget the sign-in cases need and manufacture 429s for whichever test runs next
require.NoError(t, page.Route("**/auth/status**", func(route playwright.Route) {
require.NoError(t, route.Fulfill(playwright.RouteFulfillOptions{
Status: playwright.Int(http.StatusOK),
ContentType: playwright.String("application/json"),
Body: playwright.String(`{"status":"not logged in"}`),
}))
}))
_, gerr := page.Goto(baseURL + "/web/privacy.html")
require.NoError(t, gerr)
_, eerr := page.Evaluate(`([host, url, locale]) => {
window.remark_config = { host, site_id: 'remark', url, locale };
const node = document.createElement('div');
node.id = 'remark42';
document.body.appendChild(node);
}`, []any{baseURL, threadURL(t), locale})
require.NoError(t, eerr)
_, aerr := page.AddScriptTag(playwright.PageAddScriptTagOptions{URL: playwright.String(baseURL + "/web/embed.mjs")})
require.NoError(t, aerr)
// not widget(): commentFormSel matches the form's aria-label, which is itself
// translated, so the shared helper only ever finds an english widget
frame := page.FrameLocator("#remark42 iframe")
textarea := frame.Locator("form textarea").First()
waitVisible(t, textarea)
got, perr := textarea.GetAttribute("placeholder")
require.NoError(t, perr)
assert.Equal(t, want, got, "the %s catalog did not render, so the widget fell back to english", locale)
})
}
}
// TestWidgets_SimpleViewHidesTheEditingFurniture covers simple_view, the one mode of this kind
// that is a query parameter and not a server flag, so both branches run against the same
// instance. It takes the markdown toolbar and the preview away and leaves everything else,
// including the markdown help line, which is why that is not asserted either way. The full-view
// branch is the control: without it these would hold on a widget that never had a toolbar
func TestWidgets_SimpleViewHidesTheEditingFurniture(t *testing.T) {
for _, tc := range []struct {
name string
simple bool
}{
{"full view", false},
{"simple view", true},
} {
t.Run(tc.name, func(t *testing.T) {
page := newPage(t)
config := map[string]any{}
if tc.simple {
config["simple_view"] = true
}
embedConfig(t, page, config)
frame := widget(t, page)
// signed in, since the preview button is only offered to somebody who could post
signInAnon(t, page, frame, anonName("simpleview"))
// by element name and not by test id, which the production bundle strips, or by
// class, which it hashes
toolbar := frame.Locator(`md-bold`).First()
preview := frame.Locator(`button:has-text("Preview")`).First()
if tc.simple {
waitHidden(t, toolbar, "simple_view left the markdown toolbar in place")
waitHidden(t, preview, "simple_view left the preview button in place")
return
}
waitVisible(t, toolbar)
waitVisible(t, preview)
})
}
}