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
+11
View File
@@ -18,3 +18,14 @@ jobs:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- run: npm install - run: npm install
- run: npm test - run: npm test
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm install
- run: npx playwright install --with-deps chromium-headless-shell
- run: npm run test:e2e
+3
View File
@@ -10,6 +10,9 @@ export default function createApp () {
const app = choo() const app = choo()
app.use(countStore) app.use(countStore)
app.route('/', mainView) app.route('/', mainView)
// the demo gets served from arbitrary subpaths (npx serve ., test
// servers); a wildcard fallback makes it mount anywhere
app.route('*', mainView)
return app return app
} }
+2 -1
View File
@@ -17,7 +17,8 @@
"@choojs/core/timing": "../../packages/core/lib/timing.js", "@choojs/core/timing": "../../packages/core/lib/timing.js",
"@choojs/html": "../../packages/html/browser.js", "@choojs/html": "../../packages/html/browser.js",
"@choojs/html/raw": "../../packages/html/raw.js", "@choojs/html/raw": "../../packages/html/raw.js",
"@choojs/html/morph": "../../packages/html/morph.js" "@choojs/html/morph": "../../packages/html/morph.js",
"@choojs/html/hydrate": "../../packages/html/hydrate.js"
} }
} }
</script> </script>
+5 -2
View File
@@ -11,11 +11,14 @@
"node": ">=24" "node": ">=24"
}, },
"scripts": { "scripts": {
"test": "node --test packages/core/test/ packages/html/test/ packages/component/test/" "test": "node --test packages/core/test/ packages/html/test/ packages/component/test/",
"test:e2e": "node --test test/e2e/",
"bench": "node bench/render.js"
}, },
"repository": "choojs/choo", "repository": "choojs/choo",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"happy-dom": "^20.14.0" "happy-dom": "^20.14.0",
"playwright": "^1.63.0"
} }
} }
+4 -1
View File
@@ -7,7 +7,10 @@ export default function nanoraf (render, raf) {
equal(typeof render, 'function', 'nanoraf: render should be a function') 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') 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 redrawScheduled = false
let args = null 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.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 { return {
path: pathString(path), path: pathString(path),
reason: 'text', reason: 'text',
@@ -64,8 +67,8 @@ function firstDifference (a, b, path) {
} }
} }
const aKids = a.childNodes const aKids = significant(a.childNodes)
const bKids = b.childNodes const bKids = significant(b.childNodes)
if (aKids.length !== bKids.length) { if (aKids.length !== bKids.length) {
return { return {
path: pathString(path.concat(a.nodeName.toLowerCase())), path: pathString(path.concat(a.nodeName.toLowerCase())),
@@ -83,6 +86,18 @@ function firstDifference (a, b, path) {
return null 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) { function attrDifference (a, b) {
const aAttrs = a.attributes const aAttrs = a.attributes
const bAttrs = b.attributes const bAttrs = b.attributes
+96
View File
@@ -0,0 +1,96 @@
// Real-browser pass (Playwright Chromium): the zero-build page and the
// SSR + hydration page, with console output treated as part of the spec —
// no errors, no hydration warnings.
//
// Run with: npm run test:e2e (needs `npx playwright install chromium-headless-shell`)
import { test, before, after } from 'node:test'
import assert from 'node:assert'
import { chromium } from 'playwright'
import { startServer } from './serve.js'
let browser, ctx, srv
before(async () => {
srv = await startServer()
browser = await chromium.launch()
ctx = await browser.newContext()
})
after(async () => {
await browser?.close()
await srv?.close()
})
// renders are raf-batched, so poll instead of reading synchronously
function waitForCount (page, text) {
return page.waitForFunction(
(t) => document.querySelector('h1')?.textContent === t,
text,
{ timeout: 5000 }
)
}
async function openPage (path) {
const page = await ctx.newPage()
const console_ = { errors: [], warnings: [] }
page.on('console', (msg) => {
if (msg.type() === 'error') console_.errors.push(msg.text())
if (msg.type() === 'warning') console_.warnings.push(msg.text())
})
page.on('pageerror', (err) => console_.errors.push(String(err)))
await page.goto(srv.origin + path)
return { page, console_ }
}
test('zero-build page: import map + native ESM, no bundler', async () => {
const { page, console_ } = await openPage('/examples/counter/')
await waitForCount(page, 'count is 0')
await page.click('button')
await waitForCount(page, 'count is 1')
await page.click('button')
await page.click('button')
await waitForCount(page, 'count is 3')
assert.deepStrictEqual(console_.errors, [], 'no console errors')
await page.close()
})
test('SSR page: content before JavaScript, then live after hydration', async () => {
// the raw response must already contain the rendered view
const res = await fetch(srv.origin + '/ssr')
const rawHtml = await res.text()
assert.match(rawHtml, /count is 0/, 'server sent rendered markup')
const { page, console_ } = await openPage('/ssr')
await waitForCount(page, 'count is 0')
// hydrated: the server DOM must now respond to clicks
await page.click('button')
await waitForCount(page, 'count is 1')
assert.deepStrictEqual(console_.errors, [], 'no console errors')
const mismatches = console_.warnings.filter((w) => w.includes('markup differ'))
assert.deepStrictEqual(mismatches, [], 'no hydration mismatch warnings')
await page.close()
})
test('SSR page: server DOM is adopted, not replaced', async () => {
const { page } = await openPage('/ssr')
await waitForCount(page, 'count is 0')
// Prove adoption by a surviving expando: mark the node, force a real
// render, and confirm the same node object still holds the marker.
await page.evaluate(() => {
document.querySelector('h1').__marker = 'server-node'
})
await page.click('button')
await waitForCount(page, 'count is 1')
const marker = await page.evaluate(() => document.querySelector('h1').__marker)
assert.strictEqual(marker, 'server-node', 'h1 survived that render in place')
await page.close()
})
+91
View File
@@ -0,0 +1,91 @@
// Test server for e2e runs: static files from the repo root plus /ssr,
// which server-renders the counter app and serves a hydration page — the
// same app module the import map hands to the browser.
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { join, normalize, extname, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import createApp from '../../examples/counter/app.js'
const root = normalize(join(dirname(fileURLToPath(import.meta.url)), '..', '..'))
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8'
}
const IMPORT_MAP = JSON.stringify({
imports: {
'@choojs/core': '/packages/core/index.js',
'@choojs/core/timing': '/packages/core/lib/timing.js',
'@choojs/html': '/packages/html/browser.js',
'@choojs/html/raw': '/packages/html/raw.js',
'@choojs/html/morph': '/packages/html/morph.js',
'@choojs/html/hydrate': '/packages/html/hydrate.js'
}
})
function ssrPage () {
const body = createApp().toString('/')
// When a view owns <body>, scripts belong in <head>: type="module" is
// deferred by definition, and body must contain only what the view
// renders or hydration would (rightly) flag the extra nodes.
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>choo v8 ssr counter</title>
<script type="importmap">${IMPORT_MAP}</script>
<script type="module">
import createApp from '/examples/counter/app.js'
createApp().mount('body')
</script>
</head>
${body}
</html>`
}
export function startServer () {
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost')
if (url.pathname === '/ssr') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(ssrPage())
return
}
let pathname = decodeURIComponent(url.pathname)
if (pathname.endsWith('/')) pathname += 'index.html'
const file = normalize(join(root, pathname))
if (!file.startsWith(root)) {
res.writeHead(403).end()
return
}
try {
const data = await readFile(file)
res.writeHead(200, {
'content-type': MIME[extname(file)] || 'application/octet-stream'
})
res.end(data)
} catch (e) {
res.writeHead(404).end('not found: ' + pathname)
}
})
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
server,
origin: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((r) => server.close(r))
})
})
})
}