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

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

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

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

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

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

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

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

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

* Update golangci-lint to 2.13.1 in the backend workflow

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

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

195 lines
7.6 KiB
Go

//go:build e2e
package e2e
import (
"regexp"
"testing"
"time"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The parent page sets color-scheme on the iframe element from the theme param. If the iframe
// document does not carry the same color-scheme before its bundle runs, the canvas is painted
// opaque white instead of staying transparent. Blocking the bundle freezes the document in
// that pre-script state, so these assert the inline head script has already applied the scheme.
func TestIframe_ColorSchemeIsSetBeforeTheBundleRuns(t *testing.T) {
cases := []struct {
name string
query string
expected string
}{
{"dark theme", "?site_id=remark&theme=dark", "dark"},
{"light theme", "?site_id=remark&theme=light", "light"},
{"no theme falls back to light", "?site_id=remark", "light"},
}
for _, engine := range engines() {
for _, tc := range cases {
t.Run(engine+"/"+tc.name, func(t *testing.T) {
page := newPageOn(t, browserFor(t, engine))
require.NoError(t, page.Route(regexp.MustCompile(`remark\.m?js$`), func(route playwright.Route) {
_ = route.Abort()
}))
pauseForAuthLimit()
_, err := page.Goto(probeURL + "/web/iframe.html" + tc.query)
require.NoError(t, err)
inline, err := page.Evaluate("() => document.documentElement.style.colorScheme")
require.NoError(t, err)
assert.Equal(t, tc.expected, inline)
computed, err := page.Evaluate("() => getComputedStyle(document.documentElement).colorScheme")
require.NoError(t, err)
assert.Equal(t, tc.expected, computed)
})
}
}
}
// TestIframe_ParentAndDocumentAgreeOnColorScheme covers the other half of the same defect.
// The tests above load the widget document on its own, so removing the color-scheme the
// parent puts on the iframe element would not disturb them, and it is the disagreement
// between the two that paints the opaque canvas.
func TestIframe_ParentAndDocumentAgreeOnColorScheme(t *testing.T) {
schemes := map[string]*playwright.ColorScheme{
"dark": playwright.ColorSchemeDark,
"light": playwright.ColorSchemeLight,
}
for _, engine := range engines() {
for theme, scheme := range schemes {
t.Run(engine+"/"+theme, func(t *testing.T) {
page := newPageOn(t, browserFor(t, engine))
// the demo page reads prefers-color-scheme rather than a query parameter
require.NoError(t, page.EmulateMedia(playwright.PageEmulateMediaOptions{ColorScheme: scheme}))
pauseForAuthLimit()
_, err := page.Goto(renderURL(t))
require.NoError(t, err)
widget(t, page)
onElement, err := page.Evaluate(`() => document.querySelector('#remark42 iframe').style.colorScheme`)
require.NoError(t, err)
assert.Equal(t, theme, onElement, "the parent has to mark the iframe element")
inDocument, err := page.FrameLocator("#remark42 iframe").Locator(":root").Evaluate(
`(el) => getComputedStyle(el).colorScheme`, nil)
require.NoError(t, err)
assert.Equal(t, theme, inDocument, "and the document inside it has to agree")
})
}
}
}
// Browsers paint a default surface for an iframe before its document is parsed, and that
// surface is opaque when the element carries a color-scheme the document does not have yet.
// WebKit shows it as a white flash on dark host pages. The parent keeps the iframe hidden
// until the document reports itself inited, so the surface is never presented.
const (
// REVEAL_TIMEOUT in app/utils/create-iframe.ts. the fallback timer starts when the iframe
// is created, during page load, so any assertion with a deadline at or past this value can
// be satisfied by the fallback alone and says nothing about the message path. bound the
// message-path assertions well under it
revealTimeout = 5 * time.Second
// generous enough for a cold navigation on a loaded runner, and still well under the
// fallback, which is the point of the assertion
messageRevealBudget = 3 * time.Second
)
// openWithBlockedIframeDoc loads the demo page with the widget document aborted, so the
// iframe element exists but never reports itself inited
func openWithBlockedIframeDoc(t *testing.T, page playwright.Page) time.Time {
t.Helper()
require.NoError(t, page.Route(regexp.MustCompile(`/web/iframe\.html`), func(route playwright.Route) {
_ = route.Abort()
}))
// the gate's sleep happens before the iframe exists, so it must not count against the
// reveal budgets: start the clock with the navigation
pauseForAuthLimit()
start := time.Now()
_, err := page.Goto(renderURL(t))
require.NoError(t, err)
require.NoError(t, page.Locator("#remark42 iframe").WaitFor(playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateAttached,
Timeout: playwright.Float(float64(waitTimeout.Milliseconds())),
}))
return start
}
func iframeVisibility(t *testing.T, page playwright.Page) string {
t.Helper()
v, err := page.Evaluate(`() => {
const iframe = document.querySelector('#remark42 iframe');
return iframe ? iframe.style.visibility : 'no-iframe';
}`)
require.NoError(t, err)
s, _ := v.(string)
return s
}
func TestIframe_StaysHiddenUntilTheDocumentReportsInited(t *testing.T) {
forEachEngine(t, func(t *testing.T, page playwright.Page) {
start := openWithBlockedIframeDoc(t, page)
// sampling once would pass against a widget that revealed a frame moments later, so
// hold the assertion for a stretch of the window in which it must stay hidden
for time.Since(start) < revealTimeout/2 {
require.Equal(t, "hidden", iframeVisibility(t, page))
time.Sleep(100 * time.Millisecond)
}
// a slow run could have let the fallback fire, which would make the assertion above
// pass or fail for the wrong reason. fail loudly instead of flaking
assert.Less(t, time.Since(start), revealTimeout)
})
}
// forEachEngine runs body once per configured browser, on a fresh page each time
func forEachEngine(t *testing.T, body func(t *testing.T, page playwright.Page)) {
t.Helper()
for _, engine := range engines() {
t.Run(engine, func(t *testing.T) {
body(t, newPageOn(t, browserFor(t, engine)))
})
}
}
// The reveal has to come from the inited message, not the fallback: a broken message listener
// would leave the widget invisible for five seconds on every load. The fallback timer starts
// when the iframe is created, partway through the navigation, so bounding only the poll leaves
// the navigation window unmeasured. Time the whole thing.
func TestIframe_IsRevealedByTheInitedMessage(t *testing.T) {
forEachEngine(t, func(t *testing.T, page playwright.Page) {
pauseForAuthLimit()
start := time.Now()
_, err := page.Goto(renderURL(t))
require.NoError(t, err)
eventually(t, messageRevealBudget, "iframe was not revealed by the inited message", func() bool {
return iframeVisibility(t, page) == "visible"
})
assert.Less(t, time.Since(start), revealTimeout)
waitVisible(t, page.Locator("#remark42 iframe"))
})
}
// The aborted document never reports its height, so the iframe box stays empty and a
// visibility assertion on geometry would fail. Assert the property the fallback actually sets.
func TestIframe_IsRevealedByTheTimeoutWhenInitedNeverArrives(t *testing.T) {
forEachEngine(t, func(t *testing.T, page playwright.Page) {
start := openWithBlockedIframeDoc(t, page)
// and not before it: without a lower bound, shortening the fallback to a value that
// defeats its purpose would still pass
eventually(t, revealTimeout*2, "fallback never revealed the iframe", func() bool {
return iframeVisibility(t, page) == "visible"
})
assert.Greater(t, time.Since(start), revealTimeout*3/4,
"the reveal came too early to have been the fallback timer")
})
}