Files
remark42/e2e/comment_test.go
T
Dmitry VerkhoturovandGitHub 4d5dae20e2 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.
2026-08-22 11:59:37 -05:00

440 lines
18 KiB
Go

//go:build e2e
package e2e
import (
"encoding/base64"
"fmt"
"net/http"
neturl "net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestComment_PostRendersMarkdownAndSurvivesReload(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
// the marker has to be free of markdown syntax: the backend renders the comment, so a
// filter on the raw source would never match the rendered text
text := "hello from " + runID
posted := postCommentMatching(t, frame, text+" with **bold**", text)
// the backend renders the markdown, so a plain-text match would pass even if it stopped
bold, err := posted.Locator(".raw-content strong").InnerText()
require.NoError(t, err)
assert.Equal(t, "bold", bold)
frame = reload(t, page)
posted = comment(frame, text)
waitVisible(t, posted)
// the rendered markup has to survive the round trip too, not just the words
bold, err = posted.Locator(".raw-content strong").InnerText()
require.NoError(t, err)
assert.Equal(t, "bold", bold)
}
func TestComment_ReplyNestsUnderItsParent(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
parent := "parent " + runID
postComment(t, frame, parent)
require.NoError(t, actions(frame, parent).Locator(`button:has-text("Reply")`).Click())
reply := "reply " + runID
submitForm(t, replyForm(t, frame), reply)
// nesting is the point: the reply has to live inside the parent's thread, not beside it
parentThread := frame.Locator("[aria-expanded]", playwright.FrameLocatorLocatorOptions{HasText: parent}).First()
waitVisible(t, parentThread.Locator("article", playwright.LocatorLocatorOptions{HasText: reply}))
}
func TestComment_EditWithinTheDeadline(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
original := "before edit " + runID
postComment(t, frame, original)
// the countdown only renders while the comment is still editable
waitVisible(t, actions(frame, original).Locator(`[role="timer"]`))
require.NoError(t, actions(frame, original).Locator(`button:has-text("Edit")`).Click())
edited := "after edit " + runID
submitForm(t, replyForm(t, frame), edited)
waitVisible(t, comment(frame, edited))
// an edit replaces the comment 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()
require.NoError(t, err)
assert.NotContains(t, txt, original)
// the DOM update comes from the response, so without a reload a handler that returned the
// edited comment without storing it would pass
frame = reload(t, page)
waitVisible(t, comment(frame, edited))
txt, err = frame.Locator("article").First().InnerText()
require.NoError(t, err)
assert.NotContains(t, txt, original, "the edit should have been stored, not just rendered")
}
// TestComment_EditExpiresAfterTheDeadline runs against the second instance, whose edit window
// is short enough to wait out and long enough that the setup fits inside it. That instance
// offers anonymous auth only, see compose-e2e-test.yml.
// editWindow mirrors EDIT_TIME on the short-edit instance in compose-e2e-test.yml
const editWindow = 15 * time.Second
func TestComment_EditExpiresAfterTheDeadline(t *testing.T) {
page := newPage(t)
url := threadURLOn(t, shortEditURL)
frame := openURL(t, page, url)
signInAnon(t, page, frame, "expirytester")
text := "expires " + runID
postComment(t, frame, text)
editButton := actions(frame, text).Locator(`button:has-text("Edit")`)
timer := actions(frame, text).Locator(`[role="timer"]`)
waitVisible(t, editButton)
waitVisible(t, timer)
id, err := comment(frame, text).GetAttribute("id")
require.NoError(t, err)
commentID := strings.TrimPrefix(id, "remark42__comment-")
// the countdown fires onTimePassed, which drops the edit affordance entirely. the wait has
// to outlast the window itself, which started when the comment was posted
expiry := playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateHidden,
Timeout: playwright.Float(float64((waitTimeout + editWindow).Milliseconds())),
}
require.NoError(t, editButton.WaitFor(expiry))
require.NoError(t, timer.WaitFor(expiry))
// the button going away is only the widget being polite. the deadline is enforced by the
// backend, and without asking it directly this test would still pass with that guard
// removed, so put the request in from the signed-in page itself
edit := fmt.Sprintf("%s/api/v1/comment/%s?site=remark&url=%s", shortEditURL, commentID, neturl.QueryEscape(url))
status, body := pageFetch(t, page, "PUT", edit, map[string]string{"text": "edited after the deadline"})
assert.Equal(t, 400, status, "the backend should refuse an edit past the deadline")
assert.Contains(t, body, `"code":10`, "and say so with ErrCommentEditExpired")
}
func TestComment_DeleteRemovesTheText(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
// deliberately not the dev user: ADMIN_SHARED_ID makes that one an admin, and the widget
// sends admins to the admin endpoint, so signing in there would leave the path every
// ordinary reader takes untested
signInAnon(t, page, frame, "deletetester")
text := "doomed " + runID
survivor := "survivor " + runID
postComment(t, frame, text)
postComment(t, frame, survivor)
// delete is gated by window.confirm; without a handler playwright dismisses it and the
// comment quietly survives
page.OnDialog(func(d playwright.Dialog) { _ = d.Accept() })
require.NoError(t, actions(frame, text).Locator(`button:has-text("Delete")`).Click())
// the widget does not remove the node, it swaps the text for a tombstone. asserting the
// tombstone is present says more than asserting the old text is gone, which a comment
// scrolled out of view would also satisfy
waitVisible(t, comment(frame, "This comment was deleted"))
// and it stays gone 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())
}