The shell HTML included closing </body></html> tags. Resolve script chunks arrived AFTER the document end — browser ignored them (ERR_INCOMPLETE_CHUNKED_ENCODING). Now strips </body></html> from shell, sends resolve scripts inside the body, closes document last. Added live server Playwright tests that hit the actual streaming endpoint and verify suspense slots resolve with content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
432 lines
18 KiB
JavaScript
432 lines
18 KiB
JavaScript
// @ts-check
|
|
/**
|
|
* Streaming page sandbox tests — verify suspense + resolve DOM behavior.
|
|
*
|
|
* Boots the WASM kernel in sandbox mode (no server), loads the streaming
|
|
* demo components, renders the shell with suspense placeholders, and tests
|
|
* that __sxResolve correctly replaces placeholders with resolved content.
|
|
*/
|
|
const { test, expect } = require('playwright/test');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
|
const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm');
|
|
const SX_DIR = path.join(WASM_DIR, 'sx');
|
|
|
|
const WEB_MODULES = [
|
|
'render', 'core-signals', 'signals', 'deps', 'router',
|
|
'page-helpers', 'freeze', 'dom', 'browser',
|
|
'adapter-html', 'adapter-sx', 'adapter-dom',
|
|
'boot-helpers', 'hypersx', 'engine', 'orchestration', 'boot',
|
|
];
|
|
|
|
function readModule(mod) {
|
|
const sxPath = path.join(SX_DIR, mod + '.sx');
|
|
try { return fs.readFileSync(sxPath, 'utf8'); } catch { return null; }
|
|
}
|
|
|
|
function readFile(relPath) {
|
|
return fs.readFileSync(path.join(PROJECT_ROOT, relPath), 'utf8');
|
|
}
|
|
|
|
// Component files needed for streaming demo (order matters — deps first)
|
|
const COMPONENT_FILES = [
|
|
'shared/sx/templates/tw.sx',
|
|
'shared/sx/templates/tw-layout.sx',
|
|
'shared/sx/templates/tw-type.sx',
|
|
'shared/sx/templates/layout.sx',
|
|
'shared/sx/templates/pages.sx',
|
|
'sx/sx/streaming-demo.sx',
|
|
];
|
|
|
|
async function bootSandbox(page) {
|
|
await page.goto('about:blank');
|
|
|
|
// Set up a minimal HTML page with a body container
|
|
await page.setContent(`
|
|
<html><head></head><body>
|
|
<div id="sx-root"></div>
|
|
</body></html>
|
|
`);
|
|
|
|
const kernelSrc = fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8');
|
|
await page.addScriptTag({ content: kernelSrc });
|
|
await page.waitForFunction('!!window.SxKernel', { timeout: 10000 });
|
|
|
|
// Register host FFI natives
|
|
await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
K.registerNative('host-global', a => { const n=a[0]; return (n in globalThis)?globalThis[n]:null; });
|
|
K.registerNative('host-get', a => { if(a[0]==null)return null; const v=a[0][a[1]]; return v===undefined?null:v; });
|
|
K.registerNative('host-set!', a => { if(a[0]!=null)a[0][a[1]]=a[2]; return a[2]; });
|
|
K.registerNative('host-call', a => {
|
|
const[o,m,...r]=a;
|
|
if(o==null){const f=globalThis[m];return typeof f==='function'?f.apply(null,r):null;}
|
|
if(typeof o[m]!=='function')return null;
|
|
try{const v=o[m].apply(o,r);return v===undefined?null:v;}catch(e){return null;}
|
|
});
|
|
K.registerNative('host-new', a => {
|
|
const C=typeof a[0]==='string'?globalThis[a[0]]:a[0];
|
|
return typeof C==='function'?new C(...a.slice(1)):null;
|
|
});
|
|
K.registerNative('host-callback', a => {
|
|
const fn=a[0];
|
|
if(typeof fn==='function'&&fn.__sx_handle===undefined)return fn;
|
|
if(fn&&fn.__sx_handle!==undefined){
|
|
return function(){const r=K.callFn(fn,Array.from(arguments));if(window._driveAsync)window._driveAsync(r);return r;};
|
|
}
|
|
return function(){};
|
|
});
|
|
K.registerNative('host-typeof', a => {
|
|
const o=a[0]; if(o==null)return'nil';
|
|
if(o instanceof Element)return'element'; if(o instanceof Text)return'text';
|
|
if(o instanceof DocumentFragment)return'fragment'; if(o instanceof Document)return'document';
|
|
if(o instanceof Event)return'event'; if(o instanceof Promise)return'promise';
|
|
return typeof o;
|
|
});
|
|
K.registerNative('host-await', a => {
|
|
const[p,cb]=a;if(p&&typeof p.then==='function'){const f=(cb&&cb.__sx_handle!==undefined)?v=>K.callFn(cb,[v]):()=>{};p.then(f);}
|
|
});
|
|
K.registerNative('load-library!', () => false);
|
|
window._driveAsync = function driveAsync(result) {
|
|
if(!result||!result.suspended)return;
|
|
const req=result.request;const items=req&&(req.items||req);
|
|
const op=items&&items[0];const opName=typeof op==='string'?op:(op&&op.name)||String(op);
|
|
const arg=items&&items[1];
|
|
function doResume(val,delay){setTimeout(()=>{try{const r=result.resume(val);driveAsync(r);}catch(e){}},delay);}
|
|
if(opName==='io-sleep'||opName==='wait')doResume(null,Math.min(typeof arg==='number'?arg:0,10));
|
|
else if(opName==='io-fetch')doResume({ok:true,text:''},1);
|
|
};
|
|
K.eval('(define SX_VERSION "streaming-test-1.0")');
|
|
K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');
|
|
K.eval('(define parse sx-parse)');
|
|
K.eval('(define serialize sx-serialize)');
|
|
});
|
|
|
|
// Load web modules (bytecode)
|
|
const loadErrors = [];
|
|
await page.evaluate(() => { if (window.SxKernel.beginModuleLoad) window.SxKernel.beginModuleLoad(); });
|
|
for (const mod of WEB_MODULES) {
|
|
const src = readModule(mod);
|
|
if (!src) { loadErrors.push(mod); continue; }
|
|
const err = await page.evaluate(s => {
|
|
try { window.SxKernel.load(s); return null; } catch(e) { return e.message; }
|
|
}, src);
|
|
if (err) loadErrors.push(mod + ': ' + err);
|
|
}
|
|
await page.evaluate(() => { if (window.SxKernel.endModuleLoad) window.SxKernel.endModuleLoad(); });
|
|
|
|
// Load component files (.sx source)
|
|
for (const f of COMPONENT_FILES) {
|
|
const src = readFile(f);
|
|
const err = await page.evaluate(s => {
|
|
try { window.SxKernel.load(s); return null; } catch(e) { return e.message; }
|
|
}, src);
|
|
if (err) loadErrors.push(f + ': ' + err);
|
|
}
|
|
|
|
// The boot module's imports prevent its defines from running in sandbox.
|
|
// Define resolve-suspense inline using the same deps that ARE available.
|
|
await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
K.eval(`(define resolve-suspense (fn (id sx)
|
|
(let ((el (dom-query (str "[data-suspense=\\"" id "\\"]"))))
|
|
(when el
|
|
(let ((exprs (sx-parse sx))
|
|
(env (get-render-env nil)))
|
|
(dom-set-text-content el "")
|
|
(for-each (fn (expr) (dom-append el (render-to-dom expr env nil))) exprs))))))`);
|
|
});
|
|
|
|
// Set up Sx.resolveSuspense using the kernel (mirrors sx-platform.js)
|
|
await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
window.Sx = window.Sx || {};
|
|
Sx.resolveSuspense = function(id, sx) {
|
|
try {
|
|
K.eval('(resolve-suspense "' + id.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '" "' + sx.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '")');
|
|
} catch(e) {
|
|
console.error("[sx] resolveSuspense error:", e);
|
|
}
|
|
};
|
|
// Bootstrap (same as server sends in streaming response)
|
|
window.__sxPending = [];
|
|
window.__sxResolve = function(id, sx) {
|
|
if (window.Sx && Sx.resolveSuspense) {
|
|
Sx.resolveSuspense(id, sx);
|
|
} else {
|
|
window.__sxPending.push({ id, sx });
|
|
}
|
|
};
|
|
});
|
|
|
|
return loadErrors;
|
|
}
|
|
|
|
test.describe('Streaming sandbox', () => {
|
|
test.describe.configure({ timeout: 60000 });
|
|
|
|
test('suspense component renders placeholder with data-suspense attr', async ({ page }) => {
|
|
const errors = await bootSandbox(page);
|
|
expect(errors).toEqual([]);
|
|
|
|
// Render a suspense component into the DOM
|
|
const html = await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
return K.eval('(render-to-dom (~shared:pages/suspense :id "test-slot" :fallback (div "Loading...")))');
|
|
});
|
|
|
|
// Mount it into the page
|
|
await page.evaluate((h) => {
|
|
const root = document.getElementById('sx-root');
|
|
if (typeof h === 'object' && h instanceof Node) {
|
|
root.appendChild(h);
|
|
} else {
|
|
// render-to-dom returns a DOM node — check if Sx.render works
|
|
const node = window.SxKernel.eval('(render-to-dom (~shared:pages/suspense :id "test-slot" :fallback (div "Loading...")))');
|
|
root.appendChild(node);
|
|
}
|
|
}, html);
|
|
|
|
// Verify the placeholder exists with correct attributes
|
|
const suspense = page.locator('[data-suspense="test-slot"]');
|
|
await expect(suspense).toHaveCount(1);
|
|
await expect(suspense).toContainText('Loading...');
|
|
});
|
|
|
|
test('__sxResolve replaces suspense placeholder content', async ({ page }) => {
|
|
const errors = await bootSandbox(page);
|
|
expect(errors).toEqual([]);
|
|
|
|
// Render suspense placeholder
|
|
await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
const node = K.eval('(render-to-dom (~shared:pages/suspense :id "resolve-test" :fallback (div "skeleton")))');
|
|
document.getElementById('sx-root').appendChild(node);
|
|
});
|
|
|
|
// Verify placeholder shows fallback
|
|
await expect(page.locator('[data-suspense="resolve-test"]')).toContainText('skeleton');
|
|
|
|
// Now resolve it — simulate server sending __sxResolve
|
|
// Uses the real Sx.resolveSuspense (calls kernel's resolve-suspense fn)
|
|
await page.evaluate(() => {
|
|
window.__sxResolve('resolve-test', '(div :class "resolved" "Content loaded!")');
|
|
});
|
|
|
|
// Verify the placeholder content was replaced
|
|
const slot = page.locator('[data-suspense="resolve-test"]');
|
|
await expect(slot).toContainText('Content loaded!');
|
|
await expect(slot).not.toContainText('skeleton');
|
|
});
|
|
|
|
test('multiple suspense slots resolve independently', async ({ page }) => {
|
|
const errors = await bootSandbox(page);
|
|
expect(errors).toEqual([]);
|
|
|
|
// Render 3 suspense slots (like the streaming demo shell)
|
|
await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
const ids = ['stream-fast', 'stream-medium', 'stream-slow'];
|
|
const root = document.getElementById('sx-root');
|
|
for (const id of ids) {
|
|
const node = K.eval(
|
|
'(render-to-dom (~shared:pages/suspense :id "' + id + '" :fallback (div "Loading ' + id + '...")))'
|
|
);
|
|
root.appendChild(node);
|
|
}
|
|
});
|
|
|
|
// All 3 placeholders should exist
|
|
await expect(page.locator('[data-suspense]')).toHaveCount(3);
|
|
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('Loading stream-fast');
|
|
await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('Loading stream-slow');
|
|
|
|
// Resolve middle slot only — uses real kernel resolve-suspense
|
|
await page.evaluate(() => {
|
|
window.__sxResolve('stream-medium', '(div "Medium resolved!")');
|
|
});
|
|
|
|
// Medium resolved, others still loading
|
|
await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('Medium resolved!');
|
|
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('Loading stream-fast');
|
|
await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('Loading stream-slow');
|
|
|
|
// Resolve remaining slots
|
|
await page.evaluate(() => {
|
|
window.__sxResolve('stream-fast', '(div "Fast resolved!")');
|
|
window.__sxResolve('stream-slow', '(div "Slow resolved!")');
|
|
});
|
|
|
|
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('Fast resolved!');
|
|
await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('Slow resolved!');
|
|
});
|
|
|
|
test('streaming-demo-data returns iterable list of dicts with stream-ids', async ({ page }) => {
|
|
const errors = await bootSandbox(page);
|
|
expect(errors).toEqual([]);
|
|
|
|
// Verify the data function returns a list that can be iterated
|
|
// (catches ListRef vs List type mismatch)
|
|
const result = await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
try {
|
|
const type = K.eval('(type-of (streaming-demo-data))');
|
|
const len = K.eval('(len (streaming-demo-data))');
|
|
const ids = K.eval('(join "," (map (fn (item) (get item "stream-id")) (streaming-demo-data)))');
|
|
const delays = K.eval('(join "," (map (fn (item) (str (get item "delay"))) (streaming-demo-data)))');
|
|
return { type, len, ids, delays };
|
|
} catch(e) {
|
|
return { error: e.message };
|
|
}
|
|
});
|
|
|
|
expect(result.error || '').toBe('');
|
|
expect(result.type).toBe('list');
|
|
expect(result.len).toBe(3);
|
|
expect(result.ids).toContain('stream-fast');
|
|
expect(result.ids).toContain('stream-medium');
|
|
expect(result.ids).toContain('stream-slow');
|
|
expect(result.delays).toContain('1000');
|
|
});
|
|
|
|
test('streaming shell renders with outer layout gutters', async ({ page }) => {
|
|
const errors = await bootSandbox(page);
|
|
expect(errors).toEqual([]);
|
|
|
|
// Render shell wrapped in outer layout (like the server does)
|
|
const result = await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
try {
|
|
const html = K.eval(`(render-to-html
|
|
(~shared:layout/app-body :content
|
|
(~streaming-demo/layout
|
|
(~shared:pages/suspense :id "stream-fast"
|
|
:fallback (~streaming-demo/stream-skeleton))
|
|
(~shared:pages/suspense :id "stream-medium"
|
|
:fallback (~streaming-demo/stream-skeleton))
|
|
(~shared:pages/suspense :id "stream-slow"
|
|
:fallback (~streaming-demo/stream-skeleton)))))`);
|
|
document.getElementById('sx-root').innerHTML = html;
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, error: e.message };
|
|
}
|
|
});
|
|
|
|
expect(result.error || '').toBe('');
|
|
|
|
// Should have outer layout structure (gutters)
|
|
await expect(page.locator('#root-panel')).toHaveCount(1);
|
|
await expect(page.locator('#main-panel')).toHaveCount(1);
|
|
|
|
// Should have 3 suspense slots inside the layout
|
|
await expect(page.locator('[data-suspense]')).toHaveCount(3);
|
|
|
|
// Should have the demo heading
|
|
await expect(page.locator('h1')).toContainText('Streaming');
|
|
});
|
|
|
|
test('resolve fills suspense slots with chunk content end-to-end', async ({ page }) => {
|
|
const errors = await bootSandbox(page);
|
|
expect(errors).toEqual([]);
|
|
|
|
// Phase 1: Render shell with suspense skeletons
|
|
await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
const html = K.eval(`(render-to-html
|
|
(~shared:layout/app-body :content
|
|
(~streaming-demo/layout
|
|
(~shared:pages/suspense :id "stream-fast"
|
|
:fallback (~streaming-demo/stream-skeleton))
|
|
(~shared:pages/suspense :id "stream-medium"
|
|
:fallback (~streaming-demo/stream-skeleton))
|
|
(~shared:pages/suspense :id "stream-slow"
|
|
:fallback (~streaming-demo/stream-skeleton)))))`);
|
|
document.getElementById('sx-root').innerHTML = html;
|
|
});
|
|
|
|
// Verify skeletons are showing
|
|
await expect(page.locator('[data-suspense="stream-fast"]')).toHaveCount(1);
|
|
|
|
// Phase 2: Staggered resolve — simulates chunked transfer with async IO delays.
|
|
// Each resolve arrives independently; other slots remain as skeletons.
|
|
|
|
// Resolve "fast" first (~1s in production) — others still loading
|
|
await page.evaluate(() => {
|
|
window.__sxResolve('stream-fast',
|
|
'(~streaming-demo/chunk :stream-label "Fast source" :stream-color "emerald" :stream-message "Resolved in ~1s (async IO)" :stream-time "~1s")');
|
|
});
|
|
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('Fast source');
|
|
// Medium and slow still show skeletons
|
|
await expect(page.locator('[data-suspense="stream-medium"]')).not.toContainText('Medium source');
|
|
await expect(page.locator('[data-suspense="stream-slow"]')).not.toContainText('Slow source');
|
|
|
|
// Resolve "medium" (~3s in production) — slow still loading
|
|
await page.evaluate(() => {
|
|
window.__sxResolve('stream-medium',
|
|
'(~streaming-demo/chunk :stream-label "Medium source" :stream-color "amber" :stream-message "Resolved in ~3s (async IO)" :stream-time "~3s")');
|
|
});
|
|
await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('Medium source');
|
|
await expect(page.locator('[data-suspense="stream-slow"]')).not.toContainText('Slow source');
|
|
|
|
// Resolve "slow" (~5s in production) — all done
|
|
await page.evaluate(() => {
|
|
window.__sxResolve('stream-slow',
|
|
'(~streaming-demo/chunk :stream-label "Slow source" :stream-color "violet" :stream-message "Resolved in ~5s (async IO)" :stream-time "~5s")');
|
|
});
|
|
|
|
// Phase 3: All slots filled with actual content
|
|
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('Fast source');
|
|
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('~1s');
|
|
await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('Medium source');
|
|
await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('~3s');
|
|
await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('Slow source');
|
|
await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('~5s');
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Live server tests — verify the actual chunked response works end-to-end
|
|
// =========================================================================
|
|
const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013';
|
|
|
|
test.describe('Streaming live server', () => {
|
|
test.describe.configure({ timeout: 30000 });
|
|
|
|
test('streaming page resolves suspense slots from server', async ({ page }) => {
|
|
// Load the streaming page from the actual server
|
|
await page.goto(BASE_URL + '/sx/(geography.(isomorphism.streaming))', {
|
|
waitUntil: 'networkidle',
|
|
timeout: 20000,
|
|
});
|
|
|
|
// Should have 3 suspense slots
|
|
await expect(page.locator('[data-suspense]')).toHaveCount(3);
|
|
|
|
// All 3 slots should have resolved content (not empty, not just skeletons)
|
|
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('Fast source', { timeout: 15000 });
|
|
await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('Medium source', { timeout: 15000 });
|
|
await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('Slow source', { timeout: 15000 });
|
|
});
|
|
|
|
test('no chunked encoding errors in console', async ({ page }) => {
|
|
const errors = [];
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'error') errors.push(msg.text());
|
|
});
|
|
|
|
await page.goto(BASE_URL + '/sx/(geography.(isomorphism.streaming))', {
|
|
waitUntil: 'networkidle',
|
|
timeout: 20000,
|
|
});
|
|
|
|
// Filter for chunked encoding errors specifically
|
|
const chunkErrors = errors.filter(e =>
|
|
e.includes('CHUNKED') || e.includes('INCOMPLETE') || e.includes('ERR_EMPTY'));
|
|
expect(chunkErrors).toEqual([]);
|
|
});
|
|
});
|