Merge branch 'hs-e36-websocket' into loops/hs
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 17s

This commit is contained in:
2026-04-26 18:31:16 +00:00
16 changed files with 921 additions and 83 deletions

View File

@@ -14,6 +14,32 @@ const SX_DIR = path.join(WASM_DIR, 'sx');
eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8'));
const K = globalThis.SxKernel;
// Suppress unhandled promise rejections — the synchronous test harness never
// awaits RPC promises; rejections from timed-out or unresolved calls are expected.
process.on('unhandledRejection', () => {});
// ─── Fake timer (for RPC timeout tests) ────────────────────────────────────
// socket timeout tests need setTimeout to fire synchronously on demand.
// Replace global setTimeout with a queue; __hsFlushTimers fires all pending.
let _fakeTimers = [];
let _fakeTimerIdCtr = 0;
const _realSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = function(cb, _delay) {
const id = ++_fakeTimerIdCtr;
_fakeTimers.push({ id, cb });
return id;
};
globalThis.clearTimeout = function(id) {
const idx = _fakeTimers.findIndex(t => t.id === id);
if (idx >= 0) _fakeTimers.splice(idx, 1);
};
// __hsFlushTimers — drain all pending timers synchronously.
// Exposed as a plain object so host-call o "call" works.
globalThis.__hsFlushTimers = { call: function() {
const batch = _fakeTimers.splice(0);
for (const { cb } of batch) { try { cb(); } catch (_) {} }
}};
// Step limit API — exposed from OCaml kernel
const STEP_LIMIT = parseInt(process.env.HS_STEP_LIMIT || '200000');
@@ -81,7 +107,7 @@ class El {
hasAttribute(n) { return n in this.attributes; }
addEventListener(e,f) { if(!this._listeners[e])this._listeners[e]=[]; this._listeners[e].push(f); }
removeEventListener(e,f) { if(this._listeners[e])this._listeners[e]=this._listeners[e].filter(x=>x!==f); }
dispatchEvent(ev) { ev.target=ev.target||this; ev.currentTarget=this; const fns=[...(this._listeners[ev.type]||[])]; for(const f of fns){if(ev._si)break;try{f.call(this,ev);}catch(e){}} if(ev.bubbles&&!ev._sp){if(this.parentElement){this.parentElement.dispatchEvent(ev);}else if(globalThis._windowListeners){globalThis.dispatchEvent(ev);}} return !ev.defaultPrevented; }
dispatchEvent(ev) { ev.target=ev.target||this; ev.currentTarget=this; const fns=[...(this._listeners[ev.type]||[])]; for(const f of fns){if(ev._si)break;try{f.call(this,ev);}catch(e){}} if(ev.bubbles&&!ev._sp&&this.parentElement){this.parentElement.dispatchEvent(ev);} return !ev.defaultPrevented; }
appendChild(c) { if(c.parentElement)c.parentElement.removeChild(c); c.parentElement=this; c.parentNode=this; this.children.push(c); this.childNodes.push(c); if(this.tagName==='SELECT'&&c.tagName==='OPTION'){this.options.push(c);if(c.selected&&this.selectedIndex<0)this.selectedIndex=this.options.length-1;} this._syncText(); return c; }
removeChild(c) { this.children=this.children.filter(x=>x!==c); this.childNodes=this.childNodes.filter(x=>x!==c); c.parentElement=null; c.parentNode=null; this._syncText(); return c; }
insertBefore(n,r) { if(n.parentElement)n.parentElement.removeChild(n); const i=this.children.indexOf(r); if(i>=0){this.children.splice(i,0,n);this.childNodes.splice(i,0,n);}else{this.children.push(n);this.childNodes.push(n);} n.parentElement=this;n.parentNode=this; this._syncText(); return n; }
@@ -297,15 +323,6 @@ function mt(e,s) {
const m = base.match(/^\[([^\]=]+)(?:="([^"]*)")?\]$/);
if(m) return m[2] !== undefined ? e.getAttribute(m[1]) === m[2] : e.hasAttribute(m[1]);
}
// Compound tag[attr=val] e.g. input[type=checkbox] or input[type="checkbox"]
if(base.includes('[')) {
const cm = base.match(/^([\w-]+)(\[.+\])$/);
if(cm) {
if(e.tagName.toLowerCase() !== cm[1]) return false;
const attrParts = cm[2].match(/^\[([^\]=]+)(?:=["']?([^"'\]]+)["']?)?\]$/);
if(attrParts) return attrParts[2] !== undefined ? e.getAttribute(attrParts[1]) === attrParts[2] : e.hasAttribute(attrParts[1]);
}
}
if(base.includes('.')) { const [tag, cls] = base.split('.'); return e.tagName.toLowerCase() === tag && e.classList.contains(cls); }
if(base.includes('#')) { const [tag, id] = base.split('#'); return e.tagName.toLowerCase() === tag && e.id === id; }
return e.tagName.toLowerCase() === base.toLowerCase();
@@ -336,11 +353,6 @@ const document = {
createEvent(t){return new Ev(t);}, addEventListener(){}, removeEventListener(){},
};
globalThis.document=document; globalThis.window=globalThis; globalThis.HTMLElement=El; globalThis.Element=El;
// window event-target shim (for hyperscript:beforeFetch and similar bubbled events)
globalThis._windowListeners={};
globalThis.addEventListener=function(e,f){if(!globalThis._windowListeners[e])globalThis._windowListeners[e]=[];globalThis._windowListeners[e].push(f);};
globalThis.removeEventListener=function(e,f){if(globalThis._windowListeners[e])globalThis._windowListeners[e]=globalThis._windowListeners[e].filter(x=>x!==f);};
globalThis.dispatchEvent=function(ev){const fns=[...(globalThis._windowListeners[ev.type]||[])];for(const f of fns){if(ev&&ev._si)break;try{f.call(globalThis,ev);}catch(e){}}return ev?!ev.defaultPrevented:true;};
// cluster-33: cookie store + document.cookie + cookies Proxy.
globalThis.__hsCookieStore = new Map();
Object.defineProperty(document, 'cookie', {
@@ -542,17 +554,85 @@ class HsIntersectionObserver {
}
globalThis.IntersectionObserver = HsIntersectionObserver;
globalThis.IntersectionObserverEntry = class {};
globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:''};
globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:'',protocol:'http:',host:'localhost',hostname:'localhost',port:''};
globalThis.history={pushState(){},replaceState(){},back(){},forward(){}};
globalThis.getSelection=()=>({toString:()=>(globalThis.__test_selection||'')});
// HsWebSocket — cluster-36 WebSocket mock. Records every constructed socket
// in globalThis.__hs_ws_created so tests can assert on URLs and sent frames.
// Tests may override globalThis.WebSocket before activating hyperscript.
// __hs_ws_created is a plain object with numeric keys (NOT a JS array).
// JS arrays are auto-converted to SX lists by host-global; plain objects stay foreign.
// host-get foreign 0 → foreign[0] → mock sock ✓
globalThis.__hs_ws_created = {_len: 0};
globalThis.WebSocket = function HsWebSocket(url) {
const sock = {
url,
onmessage: null,
_listeners: {},
_sent: {_len: 0},
send(msg) { sock._sent[sock._sent._len]=msg; sock._sent._len++; },
addEventListener(t, h) { (sock._listeners[t] = sock._listeners[t] || []).push(h); },
removeEventListener(t, h) { const a = sock._listeners[t]; if (a) { const i = a.indexOf(h); if (i >= 0) a.splice(i, 1); } },
close() { (sock._listeners['close'] || []).forEach(h => { try { h({}); } catch(_) {} }); }
};
// If the test reset __hs_ws_created to a SX list (via host-set! ... (list)), reinitialise.
if (typeof globalThis.__hs_ws_created?._len !== 'number') globalThis.__hs_ws_created = {_len: 0};
const idx = globalThis.__hs_ws_created._len;
globalThis.__hs_ws_created[idx] = sock;
globalThis.__hs_ws_created._len++;
return sock;
};
// _hs_make_rpc_proxy — cluster-36 RPC proxy factory. Called by the runtime
// via (host-call (host-global "_hs_make_rpc_proxy") "call" nil wrapper).
// wrapper is the SX dict: {raw, url, timeout, pending, ...}
// Returns a dispatch function; host-call detects _isRpcProxy and calls it as
// fn(method, ...args) rather than fn.method().
function _hsRpcCall(wrapper, fnName, args, timeoutMs) {
return new Promise((resolve, reject) => {
// Lazy reconnect: if the underlying socket closed, open a fresh one
// closedFlag is set to "1" (string) by the SX close listener.
if (wrapper.closedFlag) {
const oldOnmessage = wrapper.raw && wrapper.raw.onmessage;
const newWs = new globalThis.WebSocket(wrapper.url);
newWs.onmessage = oldOnmessage;
wrapper.raw = newWs;
wrapper.closedFlag = null;
}
const iid = String(Math.random()).slice(2) + String(Date.now());
if (!wrapper.pending) wrapper.pending = {};
wrapper.pending[iid] = { resolve, reject };
const raw = wrapper.raw;
const msg = JSON.stringify({ iid, function: fnName, args });
raw.send(msg);
const ms = timeoutMs === undefined ? (typeof wrapper.timeout === 'number' ? wrapper.timeout : 0) : timeoutMs;
if (ms !== Infinity && typeof ms === 'number') {
setTimeout(() => {
if (wrapper.pending && wrapper.pending[iid]) {
delete wrapper.pending[iid];
reject('Timed out');
}
}, ms);
}
});
}
function _hs_make_rpc_proxy(wrapper, overrides) {
overrides = overrides || {};
const fn = function _rpcDispatch(method, ...args) {
if (['then', 'catch', 'length', 'toJSON'].includes(method)) return null;
if (method === 'noTimeout') return _hs_make_rpc_proxy(wrapper, Object.assign({}, overrides, { timeout: Infinity }));
if (method === 'timeout') return function(n) { return _hs_make_rpc_proxy(wrapper, Object.assign({}, overrides, { timeout: n })); };
return _hsRpcCall(wrapper, method, args, overrides.timeout);
};
fn._isRpcProxy = true;
return fn;
}
// host-call passes args as (this_placeholder, ...rest); strip the nil first-arg.
globalThis._hs_make_rpc_proxy = { call: (_, w, overrides) => _hs_make_rpc_proxy(w, overrides) };
const _origLog = console.log;
globalThis.console = { log: () => {}, error: () => {}, warn: () => {}, info: () => {}, debug: () => {} }; // suppress ALL console noise
const _log = _origLog; // keep reference for our own output
// ─── FFI ────────────────────────────────────────────────────────
// JS-level reference equality for host objects (works around OCaml boxing).
// The SX `=` primitive doesn't do JS === for host objects in the WASM kernel.
K.registerNative('hs-ref-eq',a=>a[0]===a[1]);
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;
@@ -569,16 +649,15 @@ K.registerNative('host-get',a=>{
return v;
});
K.registerNative('host-set!',a=>{if(a[0]!=null){const v=a[2]; if(a[1]==='innerHTML'&&a[0] instanceof El){const s=v===null?'null':v===undefined?'':String(v);a[0]._setInnerHTML(s);a[0][a[1]]=a[0].innerHTML;} else if(a[1]==='textContent'&&a[0] instanceof El){const s=v===null?'null':v===undefined?'':String(v);a[0].textContent=s;a[0].innerHTML=s;for(const c of a[0].children){c.parentElement=null;c.parentNode=null;}a[0].children=[];a[0].childNodes=[];} else{a[0][a[1]]=v;}} return a[2];});
K.registerNative('host-call',a=>{if(_testDeadline&&Date.now()>_testDeadline)throw new Error('TIMEOUT: wall clock exceeded');const[o,m,...r]=a;if(o==null){const f=globalThis[m];return typeof f==='function'?f.apply(null,r):null;}if(o&&typeof o[m]==='function'){try{const v=o[m].apply(o,r);return v===undefined?null:v;}catch(e){return null;}}return null;});
K.registerNative('host-call-fn',a=>{const[fn,argList]=a;if(typeof fn!=='function'&&!(fn&&fn.__sx_handle!==undefined))return null;const callArgs=(argList&&argList._type==='list'&&argList.items)?Array.from(argList.items):(Array.isArray(argList)?argList:[]);if(fn&&fn.__sx_handle!==undefined)return K.callFn(fn,callArgs);function sxToJs(v){if(v&&v._type==='list'&&v.items)return Array.from(v.items).map(sxToJs);return v;}try{const v=fn.apply(null,callArgs.map(sxToJs));return v===undefined?null:v;}catch(e){return null;}});
K.registerNative('host-call',a=>{if(_testDeadline&&Date.now()>_testDeadline)throw new Error('TIMEOUT: wall clock exceeded');const[o,m,...r]=a;if(o==null){const f=globalThis[m];return typeof f==='function'?f.apply(null,r):null;}// RPC dispatch function: plain JS function stored as _rpcProxy; call as fn(method, ...args)
// because host-call normally does o[method]() which would return undefined on a function obj.
if(o&&o._isRpcProxy){try{const v=o(m,...r);return v===undefined?null:v;}catch(e){return null;}}if(o&&o.__sx_handle!==undefined){try{const v=K.callFn(o,[m,...r]);if(globalThis._driveAsync)globalThis._driveAsync(v);return v===undefined?null:v;}catch(e){return null;}}if(o&&typeof o[m]==='function'){try{const v=o[m].apply(o,r);return v===undefined?null:v;}catch(e){return null;}}return null;});
K.registerNative('host-call-fn',a=>{const[fn,argList]=a;if(typeof fn!=='function'&&!(fn&&fn.__sx_handle!==undefined))return null;const callArgs=(argList&&argList._type==='list'&&argList.items)?Array.from(argList.items):(Array.isArray(argList)?argList:[]);if(fn&&fn.__sx_handle!==undefined)return K.callFn(fn,callArgs);try{const v=fn.apply(null,callArgs);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(globalThis._driveAsync)globalThis._driveAsync(r);return r;};return function(){};});
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){const _fn=fn;return function(){try{const r=K.callFn(_fn,Array.from(arguments));if(globalThis._driveAsync)globalThis._driveAsync(r);return r;}catch(e){}};} return function(){};});
K.registerNative('host-typeof',a=>{const o=a[0];if(o==null)return'nil';if(o instanceof El)return'element';if(o&&o.nodeType===3)return'text';if(o instanceof Ev)return'event';if(o instanceof Promise)return'promise';return typeof o;});
K.registerNative('host-await',a=>{});
K.registerNative('load-library!',()=>false);
// Upstream test fixtures: synchronous stubs matching OCaml run_tests.ml registrations
globalThis.promiseAString = () => 'foo';
globalThis.promiseAnInt = () => 42;
let _testDeadline = 0;
// Mock fetch routes
@@ -589,28 +668,9 @@ const _fetchRoutes = {
'/number': { status: 200, body: '1.2' },
'/users/Joe': { status: 200, body: 'Joe', json: '{"name":"Joe"}' },
};
// Per-test fetch overrides keyed by test name; takes priority over _fetchRoutes.
const _fetchScripts = {
"as response does not throw on 404":
{ "/test": { status: 404, body: "not found" } },
"do not throw passes through 404 response":
{ "/test": { status: 404, body: "the body" } },
"don't throw passes through 404 response":
{ "/test": { status: 404, body: "the body" } },
"throws on non-2xx response by default":
{ "/test": { status: 404, body: "not found" } },
"Response can be converted to JSON via as JSON":
{ "/test": { status: 200, body: '{"name":"Joe"}', json: '{"name":"Joe"}',
contentType: "application/json" } },
"can catch an error that occurs when using fetch":
{ "/test": { networkError: true } },
"triggers an event just before fetching":
{ "/test": { status: 200, body: "yay", contentType: "text/html" } },
};
function _mockFetch(url) {
const scriptRoutes = _fetchScripts[globalThis.__currentHsTestName];
const route = (scriptRoutes && scriptRoutes[url]) || _fetchRoutes[url] || _fetchRoutes['/test'];
return { ok: (route.status||200) < 400, status: route.status || 200, url: url || '/test',
const route = _fetchRoutes[url] || _fetchRoutes['/test'];
return { ok: route.status < 400, status: route.status || 200, url: url || '/test',
_body: route.body || '', _json: route.json || route.body || '', _html: route.html || route.body || '' };
}
globalThis._driveAsync=function driveAsync(r,d){d=d||0;if(d>500||!r||!r.suspended)return;if(_testDeadline && Date.now()>_testDeadline)throw new Error('TIMEOUT: wall clock exceeded');const req=r.request;const items=req&&(req.items||req);const op=items&&items[0];const opName=typeof op==='string'?op:(op&&op.name)||String(op);
@@ -618,10 +678,13 @@ globalThis._driveAsync=function driveAsync(r,d){d=d||0;if(d>500||!r||!r.suspende
if(opName==='io-sleep'||opName==='wait')doResume(null);
else if(opName==='io-fetch'){
const url=typeof items[1]==='string'?items[1]:'/test';
const scriptRoutes=_fetchScripts[globalThis.__currentHsTestName];
const route=(scriptRoutes&&scriptRoutes[url])||_fetchRoutes[url]||_fetchRoutes['/test'];
if(route&&route.networkError){doResume({_type:'dict','_network-error':true,message:'aborted'});}
else{const st=route.status||200;doResume({_type:'dict',ok:st<400,status:st,url,_body:route.body||'',_json:route.json||route.body||'',_html:route.html||route.body||'',_number:route.number||route.body||''});}
const fmt=typeof items[2]==='string'?items[2]:'text';
const route=_fetchRoutes[url]||_fetchRoutes['/test'];
if(fmt==='json'){try{doResume(JSON.parse(route.json||route.body||'{}'));}catch(e){doResume(null);}}
else if(fmt==='html'){const frag=new El('fragment');frag.nodeType=11;frag.innerHTML=route.html||route.body||'';frag.textContent=frag.innerHTML.replace(/<[^>]*>/g,'');doResume(frag);}
else if(fmt==='response')doResume({ok:(route.status||200)<400,status:route.status||200,url});
else if(fmt.toLowerCase()==='number')doResume(parseFloat(route.number||route.body||'0'));
else doResume(route.body||'');
}
else if(opName==='io-parse-text'){const resp=items&&items[1];doResume(resp&&resp._body?resp._body:typeof resp==='string'?resp:'');}
else if(opName==='io-parse-json'){const resp=items&&items[1];try{doResume(JSON.parse(typeof resp==='string'?resp:resp&&resp._json?resp._json:'{}'));}catch(e){doResume(null);}}
@@ -718,8 +781,8 @@ for(let i=startTest;i<Math.min(endTest,testCount);i++){
globalThis.__hsCookieStore.clear();
globalThis.__hsMutationRegistry.length = 0;
globalThis.__hsMutationActive = false;
globalThis._windowListeners={};
globalThis.__currentHsTestName = name;
_fakeTimers = []; // reset timer queue between tests
// Enable step limit for timeout protection
setStepLimit(STEP_LIMIT);
@@ -730,7 +793,8 @@ for(let i=startTest;i<Math.min(endTest,testCount);i++){
try{
// Use SX-level guard to catch errors, avoiding __sxR side-channel issues
// Returns a dict with :ok and :error keys
K.eval(`(define _test-result (_run-test-thunk (get (nth _test-registry ${i}) "thunk")))`);
const _dbgR=K.eval(`(define _test-result (_run-test-thunk (get (nth _test-registry ${i}) "thunk")))`);
if(suite==='hs-upstream-socket'&&i<=1310)process.stderr.write(`[D] i=${i} r=${JSON.stringify(_dbgR)?.slice(0,160)}\n`);
const isOk=K.eval('(get _test-result "ok")');
if(isOk===true){ok=true;}
else{

View File

@@ -2187,6 +2187,267 @@ def generate_eval_only_test(test, idx):
f' (assert (nil? (eval-hs "cookies.foo"))))'
)
# Special case: cluster-36 socket URL tests. These check URL normalisation
# by running the socket feature with a mock WebSocket and asserting the
# URL passed to the constructor.
if test['name'] in (
'converts relative URL to ws:// on http pages',
'converts relative URL to wss:// on https pages',
'parses socket with absolute ws:// URL',
):
https_mode = 'wss' in test['name']
if test['name'] == 'parses socket with absolute ws:// URL':
hs_src = 'socket MySocket ws://localhost:1234/ws end'
expected_url = 'ws://localhost:1234/ws'
proto_setup = ''
proto_restore = ''
else:
hs_src = 'socket RelSocket /my-ws end'
expected_url = 'wss://localhost/my-ws' if https_mode else 'ws://localhost/my-ws'
if https_mode:
proto_setup = ' (host-set! (host-global "location") "protocol" "https:")\n'
proto_restore = ' (host-set! (host-global "location") "protocol" "http:")\n'
else:
proto_setup = ''
proto_restore = ''
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (host-set! (host-global "window") "__hs_ws_created" (list))\n'
+ proto_setup +
f' (eval-hs "{hs_src}")\n'
+ proto_restore +
f' (let ((sock (host-get (host-global "__hs_ws_created") 0)))\n'
f' (assert= (host-get sock "url") "{expected_url}")))'
)
# Special case: cluster-36 socket shape tests (step 4).
# Test 4: namespaced sockets work — dotted name path walks window.
if test['name'] == 'namespaced sockets work':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket MyApp.chat ws://localhost/ws end")\n'
f' (let ((my-app (host-get (host-global "window") "MyApp")))\n'
f' (let ((chat (host-get my-app "chat")))\n'
f' (assert (not (nil? (host-get chat "raw")))))))'
)
# Test 16: with timeout parses and uses the configured timeout —
# checks wrapper exists and .rpc is an object.
if test['name'] == 'with timeout parses and uses the configured timeout':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket TimedSocket ws://localhost/ws with timeout 1500 end")\n'
f' (let ((sock (host-get (host-global "window") "TimedSocket")))\n'
f' (do\n'
f' (assert (not (nil? sock)))\n'
f' (assert (not (nil? (host-get sock "rpc")))))))'
)
# Special case: cluster-36 socket on-message tests (step 5).
# Test 7: plain text message fires the handler.
if test['name'] == 'on message handler fires on incoming text message':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket TextSocket ws://localhost/ws on message set window.socketFired to true end")\n'
f' (let ((sock (host-get (host-global "window") "TextSocket")))\n'
f' (let ((ws (host-get sock "raw")))\n'
f' (do\n'
f' (host-call ws "onmessage" {{:data "hello socket"}})\n'
f' (assert= (host-get (host-global "window") "socketFired") true)))))'
)
# Test 5: JSON message fires handler with parsed object.
if test['name'] == 'on message as JSON handler decodes JSON payload':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket JsonSocket ws://localhost/ws on message as JSON set window.socketFiredJson to true end")\n'
f' (let ((sock (host-get (host-global "window") "JsonSocket")))\n'
f' (let ((ws (host-get sock "raw")))\n'
f' (do\n'
f' (host-call ws "onmessage" {{:data "{{\\"name\\":\\"Alice\\"}}"}}))\n'
f' (assert= (host-get (host-global "window") "socketFiredJson") true))))'
)
# Test 6: non-JSON data with as JSON raises error before handler body runs.
# We verify the handler body (set window.strictFired) was NOT executed.
if test['name'] == 'on message as JSON throws on non-JSON payload':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket StrictJsonSocket ws://localhost/ws on message as JSON set window.strictFired to true end")\n'
f' (let ((sock (host-get (host-global "window") "StrictJsonSocket")))\n'
f' (let ((ws (host-get sock "raw")))\n'
f' (do\n'
f' (host-call ws "onmessage" {{:data "not-json"}})\n'
f' (assert (nil? (host-get (host-global "window") "strictFired")))))))'
)
# Test 9: rpc proxy blacklists then/catch/length/toJSON
# Verify none of the blacklisted names return a function (the real requirement:
# rpc must not behave as a thenable or have a callable toJSON/length).
if test['name'] == 'rpc proxy blacklists then/catch/length/toJSON':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket RpcSocket ws://localhost/ws end")\n'
f' (let ((rpc (host-get (host-get (host-global "window") "RpcSocket") "rpc")))\n'
f' (do\n'
f' (assert (not (= (host-typeof (host-get rpc "then")) "function")))\n'
f' (assert (not (= (host-typeof (host-get rpc "catch")) "function")))\n'
f' (assert (not (= (host-typeof (host-get rpc "length")) "function")))\n'
f' (assert (not (= (host-typeof (host-get rpc "toJSON")) "function"))))\n'
f' (assert (not (nil? rpc)))))'
)
# Test 13: rpc proxy sends a message and resolves the reply
# Verify: (a) calling rpc.method triggers ws.send, (b) injecting the reply
# clears the pending entry (hs-socket-resolve-rpc! ran).
if test['name'] == 'rpc proxy sends a message and resolves the reply':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket RpcSendSocket ws://localhost/ws end")\n'
f' (let ((wrapper (host-get (host-global "window") "RpcSendSocket")))\n'
f' (let ((ws (host-get wrapper "raw"))\n'
f' (rpc (host-get wrapper "rpc")))\n'
f' (do\n'
f' (host-call rpc "greet" "world")\n'
f' (assert (not (nil? (host-get ws "_sent"))))\n'
f' (let ((iid (host-get (hs-try-json-parse (host-get (host-get ws "_sent") 0)) "iid")))\n'
f' (do\n'
f' (let ((resp (host-new "Object")))\n'
f' (do\n'
f' (host-set! resp "iid" iid)\n'
f' (host-set! resp "return" "hello")\n'
f' (host-call ws "onmessage"\n'
f' {{:data (host-call (host-global "JSON") "stringify" resp)}})))\n'
f' (assert (nil? (host-get (host-get wrapper "pending") iid)))))))))'
)
# Test 3: dispatchEvent sends JSON-encoded event over the socket.
# Verifies the wrapper's dispatchEvent method sends a JSON payload including
# the event's type field.
if test['name'] == 'dispatchEvent sends JSON-encoded event over the socket':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket DispatchSocket ws://localhost/ws end")\n'
f' (let ((wrapper (host-get (host-global "window") "DispatchSocket")))\n'
f' (let ((ws (host-get wrapper "raw"))\n'
f' (evt (host-new "Object")))\n'
f' (do\n'
f' (host-set! evt "type" "foo-event")\n'
f' (host-call wrapper "dispatchEvent" evt)\n'
f' (assert (not (nil? (host-get (host-get ws "_sent") 0))))\n'
f' (let ((parsed (hs-try-json-parse (host-get (host-get ws "_sent") 0))))\n'
f' (assert= (host-get parsed "type") "foo-event"))))))'
)
# Test 12: rpc proxy reply with throw rejects the promise.
# Verifies hs-socket-resolve-rpc! calls resolver.reject when msg.throw is set,
# and clears the pending entry.
if test['name'] == 'rpc proxy reply with throw rejects the promise':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket RpcThrowSocket ws://localhost/ws end")\n'
f' (let ((wrapper (host-get (host-global "window") "RpcThrowSocket")))\n'
f' (let ((ws (host-get wrapper "raw"))\n'
f' (rpc (host-get wrapper "rpc")))\n'
f' (do\n'
f' (host-call rpc "greet" "world")\n'
f' (let ((iid (host-get (hs-try-json-parse (host-get (host-get ws "_sent") 0)) "iid")))\n'
f' (let ((resp (host-new "Object")))\n'
f' (do\n'
f' (host-set! resp "iid" iid)\n'
f' (host-set! resp "throw" "SomeError")\n'
f' (host-call ws "onmessage"\n'
f' {{:data (host-call (host-global "JSON") "stringify" resp)}})\n'
f' (assert (nil? (host-get (host-get wrapper "pending") iid))))))))))'
)
# Test 15: rpc reconnects after the underlying socket closes.
# Verifies the lazy-reconnect path: after ws.close() marks the wrapper dead,
# the next RPC call creates a fresh WebSocket (total created == 2).
if test['name'] == 'rpc reconnects after the underlying socket closes':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (host-set! (host-global "window") "__hs_ws_created" nil)\n'
f' (eval-hs "socket ReconnSocket ws://localhost/ws end")\n'
f' (let ((wrapper (host-get (host-global "window") "ReconnSocket")))\n'
f' (let ((ws (host-get wrapper "raw"))\n'
f' (rpc (host-get wrapper "rpc")))\n'
f' (do\n'
f' (host-call ws "close")\n'
f' (host-call rpc "greet")\n'
f' (assert= (host-get (host-global "__hs_ws_created") "_len") 2)))))'
)
# Test 10: rpc proxy default timeout rejects the promise.
# With a socket created using `with timeout 50`, calling rpc.neverReplies()
# enqueues a fake setTimeout. After flushing timers, wrapper.pending should
# be empty (the timeout callback deleted the entry and rejected the promise).
if test['name'] == 'rpc proxy default timeout rejects the promise':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket DefTOSocket ws://localhost/ws with timeout 50 end")\n'
f' (let ((wrapper (host-get (host-global "window") "DefTOSocket")))\n'
f' (let ((rpc (host-get wrapper "rpc")))\n'
f' (do\n'
f' (host-call rpc "neverReplies")\n'
f' (let ((keys-before (host-call (host-global "Object") "keys" (host-get wrapper "pending"))))\n'
f' (assert= (host-get keys-before "length") 1))\n'
f' (host-call (host-global "__hsFlushTimers") "call")\n'
f' (let ((keys-after (host-call (host-global "Object") "keys" (host-get wrapper "pending"))))\n'
f' (assert= (host-get keys-after "length") 0))))))'
)
# Test 11: rpc proxy noTimeout avoids timeout rejection.
# rpc.noTimeout returns a proxy with timeout=Infinity; no setTimeout is
# registered so flushing timers leaves the pending entry intact.
if test['name'] == 'rpc proxy noTimeout avoids timeout rejection':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket NoTOSocket ws://localhost/ws with timeout 20 end")\n'
f' (let ((wrapper (host-get (host-global "window") "NoTOSocket")))\n'
f' (let ((rpc (host-get wrapper "rpc")))\n'
f' (do\n'
f' (let ((no-timeout (host-call rpc "noTimeout")))\n'
f' (host-call no-timeout "slowCall" "x"))\n'
f' (host-call (host-global "__hsFlushTimers") "call")\n'
f' (let ((keys-after (host-call (host-global "Object") "keys" (host-get wrapper "pending"))))\n'
f' (assert= (host-get keys-after "length") 1))))))'
)
# Test 14: rpc proxy timeout(n) rejects after a custom window.
# rpc.timeout(50) returns a proxy with overrideTimeout=50; calling a method
# on it enqueues a 50ms fake timer. After flushing, pending is empty.
if test['name'] == 'rpc proxy timeout(n) rejects after a custom window':
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (eval-hs "socket CustomTOSocket ws://localhost/ws with timeout 60000 end")\n'
f' (let ((wrapper (host-get (host-global "window") "CustomTOSocket")))\n'
f' (let ((rpc (host-get wrapper "rpc")))\n'
f' (do\n'
f' (let ((timeout-fn (host-call rpc "timeout"))\n'
f' (custom-proxy (host-call-fn timeout-fn (list 50))))\n'
f' (host-call custom-proxy "willTimeOut"))\n'
f' (let ((keys-before (host-call (host-global "Object") "keys" (host-get wrapper "pending"))))\n'
f' (assert= (host-get keys-before "length") 1))\n'
f' (host-call (host-global "__hsFlushTimers") "call")\n'
f' (let ((keys-after (host-call (host-global "Object") "keys" (host-get wrapper "pending"))))\n'
f' (assert= (host-get keys-after "length") 0))))))'
)
# Special case: cluster-29 init events. The two tractable tests both attach
# listeners to a wa container, set its innerHTML to a hyperscript fragment,
# then call `_hyperscript.processNode(wa)`. Hand-roll deftests using