* 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.
195 lines
7.6 KiB
Go
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")
|
|
})
|
|
}
|