// @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(`
`); 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'); }); }); // ========================================================================= // Chunked transfer test — spins up a real HTTP server with chunked encoding, // serves the actual page using the real WASM kernel + sx-platform.js + // component defs. Verifies resolve scripts execute and fill suspense slots. // ========================================================================= const http = require('http'); function buildStreamingPage() { // Read component defs (same as server sends in `; // Bootstrap (same as _sx_streaming_bootstrap in sx_server.ml) const bootstrap = ``; // Resolve scripts (same as sx_streaming_resolve_script produces) const resolves = [ { id: 'stream-fast', sx: '(div "Fast source resolved")', delay: 500 }, { id: 'stream-medium', sx: '(div "Medium source resolved")', delay: 1000 }, { id: 'stream-slow', sx: '(div "Slow source resolved")', delay: 1500 }, ]; const tail = '\n