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.
320 lines
13 KiB
Go
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")
|
|
})
|
|
}
|