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:
co-authored by
Claude Fable 5
parent
13155a3469
commit
63b56144a5
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user