Files
remark42/e2e/iframe_test.go
Dmitry Verkhoturov 75e1b69342 Judge the iframe reveal by the mark, not by when the read lands
Three cases separate a reveal that came from the inited message from one that
came from the five second fallback, and two of them did it against a clock this
process holds. That is a bet on how fast an engine is, and both bets lose on a
loaded machine: TestIframe_StaysHiddenUntilTheDocumentReportsInited polled the
DOM after a loop bounded 500ms below the fallback, and an evaluate round trip
outlasts that margin, so a fallback firing exactly on time reads as an early
reveal; TestIframe_IsRevealedByTheInitedMessage capped the message path at 3s,
and webkit has reported inited 2.6s after creation here.

All three now judge the recorded reveal against one cutoff, half a second under
the fallback. A timer cannot fire early, so below it the reveal can only be the
message and above it only the fallback, and the mark carries the moment itself,
so a slow read cannot move it. The hidden case drops its polled visibility
assertion for the same mark, which is what the observer records in the first
place.
2026-08-23 21:22:42 +01:00

320 lines
13 KiB
Go

//go:build e2e
package e2e
import (
"math"
"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, not 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
// the line the three reveal cases are judged against, measured from the element's creation
// and not from anything this process can time. a timer cannot fire early, so below this the
// reveal can only have come from the message, and above it only from the fallback. a budget
// chosen for how long an engine takes instead would be a bet on the slowest one: webkit has
// reported inited 2.6s after creation on a loaded machine
revealCutoff = revealTimeout - 500*time.Millisecond
)
// 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) {
t.Helper()
require.NoError(t, page.Route(regexp.MustCompile(`/web/iframe\.html`), func(route playwright.Route) {
_ = route.Abort()
}))
pauseForAuthLimit()
_, 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())),
}))
}
// iframeMarkScript records, in the page, when the widget's iframe element is created and when
// it is first revealed. create-iframe.ts starts its fallback timer at creation, and neither
// moment can be timed from outside the page: navigation alone can outlast the budgets, which
// would let a bound pass without the assertion it guards ever running
const iframeMarkScript = `(() => {
// top document only. playwright runs an init script in every frame, and inside the widget
// document the selector below never matches, so the observer would run for the life of the
// busiest DOM in the suite without ever finding a reason to disconnect
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();
const style = new MutationObserver(() => { seen(); });
const seen = () => {
if (window.__r42Marks.revealed === undefined && frame.style.visibility === 'visible') {
window.__r42Marks.revealed = performance.now();
style.disconnect();
}
};
style.observe(frame, {attributes: true, attributeFilter: ['style']});
seen();
};
// 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 = () => {
const frame = document.querySelector('#remark42 iframe');
if (frame) { watch(frame); tree.disconnect(); }
};
tree.observe(document, {childList: true, subtree: true});
document.addEventListener('DOMContentLoaded', scan);
// and stop looking once the page has loaded. embed.ts creates the frame no later than
// DOMContentLoaded, so a page still without one never will have one, and the suite opens
// several: the widget document itself, the counter page and the last-comments page, whose
// own rendering would otherwise keep this observer busy for the life of the page
window.addEventListener('load', () => { tree.disconnect(); });
scan();
})()`
// 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:
require.False(t, math.IsNaN(n) || math.IsInf(n, 0), "got a non-finite number from the page")
return n
case int:
return float64(n)
default:
t.Fatalf("expected a number from the page, got %T (%v)", v, v)
return 0
}
}
// iframeAge is how long the iframe element has existed, measured in the page.
//
// the mark is taken when the element is inserted, while create-iframe.ts starts its fallback
// a moment earlier, when the detached element is built. the gap is one task, so every age
// 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 := 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
}
// revealDelay is how long after creation the iframe was revealed, measured in the page. it
// reports false while the frame is still hidden
func revealDelay(t *testing.T, page playwright.Page) (time.Duration, bool) {
t.Helper()
ms := evalNumber(t, page, `() => {
const m = window.__r42Marks;
if (!m || m.created === undefined) { return -2; }
return m.revealed !== undefined ? m.revealed - m.created : -1;
}`)
// -2 is the harness, -1 is the widget. without the distinction a broken selector reads as
// "the frame was never revealed" and the failure names the wrong thing
require.NotEqual(t, float64(-2), ms, "the iframe element was never seen by the page marks")
if ms < 0 {
return 0, false
}
return time.Duration(ms) * time.Millisecond, true
}
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) {
openWithBlockedIframeDoc(t, page)
// hold for almost the whole fallback window. stopping halfway would only prove the
// fallback is not shorter than that, and a widget that revealed on anything other than
// `inited` would still pass
for iframeAge(t, page) < revealCutoff {
time.Sleep(100 * time.Millisecond)
}
// the mark and not what is visible when the read lands: the two are separated by the
// margin above, and an evaluate round trip on a loaded engine outlasts it, which reads
// a fallback that fired exactly on time as an early reveal
delay, revealed := revealDelay(t, page)
assert.False(t, revealed && delay < revealCutoff,
"the frame was revealed %v after it was created, before its document reported inited", delay)
})
}
// 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()
_, err := page.Goto(renderURL(t))
require.NoError(t, err)
eventually(t, waitTimeout, "iframe was never revealed", func() bool {
_, ok := revealDelay(t, page)
return ok
})
// 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, revealCutoff,
"the reveal was slow enough to have come from the fallback and not the message")
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) {
openWithBlockedIframeDoc(t, page)
eventually(t, revealTimeout*2, "fallback never revealed the iframe", func() bool {
_, ok := revealDelay(t, page)
return ok
})
// 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
delay, ok := revealDelay(t, page)
require.True(t, ok, "the frame reported no reveal at all")
assert.Greater(t, delay, revealCutoff,
"the reveal came too early to have been the fallback timer")
})
}