From 63b56144a55049cf316bb20417a54ba75443039c Mon Sep 17 00:00:00 2001
From: Bendik Aagaard Lynghaug
Date: Tue, 8 Sep 2026 17:57:27 +0200
Subject: [PATCH] test(e2e): real-browser pass in Playwright Chromium
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
— same convention bankai v9 used.
CI gets an e2e job with chromium-headless-shell.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
---
.github/workflows/ci.yml | 11 +++++
examples/counter/app.js | 3 ++
examples/counter/index.html | 3 +-
package.json | 7 ++-
packages/core/lib/raf.js | 5 +-
packages/html/hydrate.js | 21 ++++++--
test/e2e/counter.test.js | 96 +++++++++++++++++++++++++++++++++++++
test/e2e/serve.js | 91 +++++++++++++++++++++++++++++++++++
8 files changed, 230 insertions(+), 7 deletions(-)
create mode 100644 test/e2e/counter.test.js
create mode 100644 test/e2e/serve.js
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8d1735f..7078b57 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,3 +18,14 @@ jobs:
node-version: ${{ matrix.node }}
- run: npm install
- 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
diff --git a/examples/counter/app.js b/examples/counter/app.js
index b3bd1fc..d86d53e 100644
--- a/examples/counter/app.js
+++ b/examples/counter/app.js
@@ -10,6 +10,9 @@ export default function createApp () {
const app = choo()
app.use(countStore)
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
}
diff --git a/examples/counter/index.html b/examples/counter/index.html
index dc62a3e..4ddccf2 100644
--- a/examples/counter/index.html
+++ b/examples/counter/index.html
@@ -17,7 +17,8 @@
"@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/morph": "../../packages/html/morph.js",
+ "@choojs/html/hydrate": "../../packages/html/hydrate.js"
}
}
diff --git a/package.json b/package.json
index 522d36d..fa77d2e 100644
--- a/package.json
+++ b/package.json
@@ -11,11 +11,14 @@
"node": ">=24"
},
"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",
"license": "MIT",
"devDependencies": {
- "happy-dom": "^20.14.0"
+ "happy-dom": "^20.14.0",
+ "playwright": "^1.63.0"
}
}
diff --git a/packages/core/lib/raf.js b/packages/core/lib/raf.js
index 15dac42..f3d8da8 100644
--- a/packages/core/lib/raf.js
+++ b/packages/core/lib/raf.js
@@ -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
diff --git a/packages/html/hydrate.js b/packages/html/hydrate.js
index 5c433e2..8656dbf 100644
--- a/packages/html/hydrate.js
+++ b/packages/html/hydrate.js
@@ -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 , scripts belong in : type="module" is
+ // deferred by definition, and body must contain only what the view
+ // renders or hydration would (rightly) flag the extra nodes.
+ return `
+
+
+
+choo v8 ssr counter
+
+
+
+${body}
+`
+}
+
+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))
+ })
+ })
+ })
+}
), and page scripts belong
in
when a view owns
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
diff --git a/test/e2e/counter.test.js b/test/e2e/counter.test.js
new file mode 100644
index 0000000..cafe128
--- /dev/null
+++ b/test/e2e/counter.test.js
@@ -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()
+})
diff --git a/test/e2e/serve.js b/test/e2e/serve.js
new file mode 100644
index 0000000..b7493eb
--- /dev/null
+++ b/test/e2e/serve.js
@@ -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