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 ), and page scripts belong in when a view owns — 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 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 , 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)) + }) + }) + }) +}