test(e2e): real-browser pass in Playwright Chromium

Three end-to-end scenarios against a static+SSR test server: the
zero-build page (import map + native ESM, no bundler), the SSR page
(rendered content in the raw response, live after hydration, no mismatch
warnings), and adoption (an expando on the server-rendered node survives
a real render). Renders are raf-batched so assertions poll.

Real Chromium flushed out three fixes happy-dom couldn't see:
- nanoraf called an extracted requestAnimationFrame bare — Illegal
  invocation under strict-mode ESM (sloppy CJS had masked it); wrapped.
- the counter example only routed '/', so serving it from any subpath
  threw; it now has a wildcard fallback.
- hydration mismatch detection is now whitespace-insensitive (the parser
  reparents whitespace, e.g. text after </body>), and page scripts belong
  in <head> when a view owns <body> — same convention bankai v9 used.

CI gets an e2e job with chromium-headless-shell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-08 17:57:27 +02:00
co-authored by Claude Fable 5
parent 13155a3469
commit 63b56144a5
8 changed files with 230 additions and 7 deletions
+4 -1
View File
@@ -7,7 +7,10 @@ export default function nanoraf (render, raf) {
equal(typeof render, 'function', 'nanoraf: render should be a function')
ok(typeof raf === 'function' || typeof raf === 'undefined', 'nanoraf: raf should be a function or undefined')
if (!raf) raf = globalThis.requestAnimationFrame
// Wrap rather than alias: calling an extracted requestAnimationFrame
// with no receiver throws 'Illegal invocation' in strict-mode ESM
// (the CJS original survived only thanks to sloppy-mode this-patching).
if (!raf) raf = (cb) => globalThis.requestAnimationFrame(cb)
let redrawScheduled = false
let args = null
+18 -3
View File
@@ -42,7 +42,10 @@ function firstDifference (a, b, path) {
}
if (a.nodeType === 3 || a.nodeType === 8) { // text, comment
if (a.nodeValue !== b.nodeValue) {
// whitespace-insensitive: the parser shuffles insignificant
// whitespace (e.g. text after </body> reparents into body), and
// morph reconciles it silently — only report meaningful text
if (a.nodeValue.trim() !== b.nodeValue.trim()) {
return {
path: pathString(path),
reason: 'text',
@@ -64,8 +67,8 @@ function firstDifference (a, b, path) {
}
}
const aKids = a.childNodes
const bKids = b.childNodes
const aKids = significant(a.childNodes)
const bKids = significant(b.childNodes)
if (aKids.length !== bKids.length) {
return {
path: pathString(path.concat(a.nodeName.toLowerCase())),
@@ -83,6 +86,18 @@ function firstDifference (a, b, path) {
return null
}
// child nodes that matter for comparison: everything except
// whitespace-only text nodes
function significant (childNodes) {
const out = []
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i]
if (node.nodeType === 3 && !node.nodeValue.trim()) continue
out.push(node)
}
return out
}
function attrDifference (a, b) {
const aAttrs = a.attributes
const bAttrs = b.attributes