From 04164aa2d4960ac5cefde2d785271e6ad568e268 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 25 Apr 2026 18:49:19 +0000 Subject: [PATCH 1/7] HS E40: runner _fetchScripts map + networkError plumbing --- tests/hs-run-filtered.js | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/hs-run-filtered.js b/tests/hs-run-filtered.js index 8a1406a0..bbcb7f06 100755 --- a/tests/hs-run-filtered.js +++ b/tests/hs-run-filtered.js @@ -569,9 +569,28 @@ 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 route = _fetchRoutes[url] || _fetchRoutes['/test']; - return { ok: route.status < 400, status: route.status || 200, url: url || '/test', + 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', _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); @@ -580,8 +599,10 @@ globalThis._driveAsync=function driveAsync(r,d){d=d||0;if(d>500||!r||!r.suspende else if(opName==='io-fetch'){ const url=typeof items[1]==='string'?items[1]:'/test'; 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);}} + const scriptRoutes=_fetchScripts[globalThis.__currentHsTestName]; + const route=(scriptRoutes&&scriptRoutes[url])||_fetchRoutes[url]||_fetchRoutes['/test']; + if(route&&route.networkError){doResume({_network_error:true,message:'aborted'});} + else 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')); From ea1bdab82c8c24ee122c7e1bc5a1683f075b4532 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 25 Apr 2026 18:50:52 +0000 Subject: [PATCH 2/7] HS E40: window event-target shim + bubble relay to window listeners --- tests/hs-run-filtered.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/hs-run-filtered.js b/tests/hs-run-filtered.js index bbcb7f06..1495788d 100755 --- a/tests/hs-run-filtered.js +++ b/tests/hs-run-filtered.js @@ -81,7 +81,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&&this.parentElement){this.parentElement.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){if(this.parentElement){this.parentElement.dispatchEvent(ev);}else if(globalThis._windowListeners){globalThis.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; } @@ -327,6 +327,11 @@ 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', { @@ -703,6 +708,7 @@ for(let i=startTest;i Date: Sat, 25 Apr 2026 18:55:40 +0000 Subject: [PATCH 3/7] HS E40: generator removes 7 E40 tests from skip-list; window.addEventListener handler (+1) --- spec/tests/test-hyperscript-behavioral.sx | 66 ++++++++++++++++++++--- tests/playwright/generate-sx-tests.py | 32 ++++++----- 2 files changed, 79 insertions(+), 19 deletions(-) diff --git a/spec/tests/test-hyperscript-behavioral.sx b/spec/tests/test-hyperscript-behavioral.sx index 555e4a31..63974ed0 100644 --- a/spec/tests/test-hyperscript-behavioral.sx +++ b/spec/tests/test-hyperscript-behavioral.sx @@ -7168,7 +7168,14 @@ ;; ── fetch (23 tests) ── (defsuite "hs-upstream-fetch" (deftest "Response can be converted to JSON via as JSON" - (error "SKIP (skip-list): Response can be converted to JSON via as JSON")) + (hs-cleanup!) + (let ((_el-div (dom-create-element "div"))) + (dom-set-attr _el-div "_" "on click fetch /test as Response then put (it as JSON).name into me") + (dom-append (dom-body) _el-div) + (hs-activate! _el-div) + (dom-dispatch _el-div "click" nil) + (assert= (dom-text-content _el-div) "Joe") + )) (deftest "allows the event handler to change the fetch parameters" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) @@ -7179,9 +7186,23 @@ (assert= (dom-text-content _el-div) "yay") )) (deftest "as response does not throw on 404" - (error "SKIP (skip-list): as response does not throw on 404")) + (hs-cleanup!) + (let ((_el-div (dom-create-element "div"))) + (dom-set-attr _el-div "_" "on click fetch /test as response then put it.status into me") + (dom-append (dom-body) _el-div) + (hs-activate! _el-div) + (dom-dispatch _el-div "click" nil) + (assert= (dom-text-content _el-div) "404") + )) (deftest "can catch an error that occurs when using fetch" - (error "SKIP (skip-list): can catch an error that occurs when using fetch")) + (hs-cleanup!) + (let ((_el-div (dom-create-element "div"))) + (dom-set-attr _el-div "_" "on click fetch /test catch e log e put \"yay\" into me") + (dom-append (dom-body) _el-div) + (hs-activate! _el-div) + (dom-dispatch _el-div "click" nil) + (assert= (dom-text-content _el-div) "yay") + )) (deftest "can do a simple fetch" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) @@ -7302,9 +7323,23 @@ (assert= (dom-text-content _el-div) "yay") )) (deftest "do not throw passes through 404 response" - (error "SKIP (skip-list): do not throw passes through 404 response")) + (hs-cleanup!) + (let ((_el-div (dom-create-element "div"))) + (dom-set-attr _el-div "_" "on click fetch /test do not throw then put it into me") + (dom-append (dom-body) _el-div) + (hs-activate! _el-div) + (dom-dispatch _el-div "click" nil) + (assert= (dom-text-content _el-div) "the body") + )) (deftest "don't throw passes through 404 response" - (error "SKIP (skip-list): don't throw passes through 404 response")) + (hs-cleanup!) + (let ((_el-div (dom-create-element "div"))) + (dom-set-attr _el-div "_" "on click fetch /test don't throw then put it into me") + (dom-append (dom-body) _el-div) + (hs-activate! _el-div) + (dom-dispatch _el-div "click" nil) + (assert= (dom-text-content _el-div) "the body") + )) (deftest "submits the fetch parameters to the event handler" (hs-cleanup!) (host-set! (host-global "window") "headerCheckPassed" false) @@ -7316,9 +7351,26 @@ (assert= (dom-text-content _el-div) "yay") )) (deftest "throws on non-2xx response by default" - (error "SKIP (skip-list): throws on non-2xx response by default")) + (hs-cleanup!) + (let ((_el-div (dom-create-element "div"))) + (dom-set-attr _el-div "_" "on click fetch /test catch e put \"caught\" into me") + (dom-append (dom-body) _el-div) + (hs-activate! _el-div) + (dom-dispatch _el-div "click" nil) + (assert= (dom-text-content _el-div) "caught") + )) (deftest "triggers an event just before fetching" - (error "SKIP (skip-list): triggers an event just before fetching")) + (hs-cleanup!) + (host-call (host-global "window") "addEventListener" "hyperscript:beforeFetch" (fn (_event) (dom-set-attr (host-get _event "target") "class" "foo-set"))) + (let ((_el-div (dom-create-element "div"))) + (dom-set-attr _el-div "_" "on click fetch \"/test\" then put it into my.innerHTML end") + (dom-append (dom-body) _el-div) + (hs-activate! _el-div) + (assert (not (dom-has-class? _el-div "foo-set"))) + (dom-dispatch _el-div "click" nil) + (assert (dom-has-class? _el-div "foo-set")) + (assert= (dom-text-content _el-div) "yay") + )) ) ;; ── focus (3 tests) ── diff --git a/tests/playwright/generate-sx-tests.py b/tests/playwright/generate-sx-tests.py index 73c4aa5c..1c284158 100644 --- a/tests/playwright/generate-sx-tests.py +++ b/tests/playwright/generate-sx-tests.py @@ -125,19 +125,9 @@ SKIP_TEST_NAMES = { "can ignore when target doesn't exist", "can ignore when target doesn\\'t exist", "can handle an or after a from clause", - # upstream 'fetch' category — depend on per-test sinon stubs for 404 / thrown errors, - # or on real DocumentFragment semantics (`its childElementCount` after `as html`). - # Our generic test-runner mock returns a fixed 200 response, so these cases - # (non-2xx handling, error path, before-fetch event, real DOM fragment) can't be - # exercised here. + # upstream 'fetch' category — real DocumentFragment semantics (`its childElementCount` + # after `as html`) not exercisable with our DOM mock. "can do a simple fetch w/ html", - "triggers an event just before fetching", - "can catch an error that occurs when using fetch", - "throws on non-2xx response by default", - "do not throw passes through 404 response", - "don't throw passes through 404 response", - "as response does not throw on 404", - "Response can be converted to JSON via as JSON", } @@ -963,6 +953,24 @@ def parse_dev_body(body, elements, var_names): else: pre_setups.append(('__hs_config__', op_expr)) continue + # window.addEventListener(EVT, (param) => { param.target.PROP = 'VAL'; }) + wa = re.search( + r"window\.addEventListener\(\s*(['\"])([^'\"]+)\1\s*,\s*" + r"\((\w+)\)\s*=>\s*\{\s*\3\.target\.(\w+)\s*=\s*['\"]([^'\"]+)['\"]\s*;?\s*\}", + m.group(1), + ) + if wa: + ev_name = wa.group(2) + prop = wa.group(4) + val = wa.group(5) + attr = 'class' if prop == 'className' else prop + sx = (f'(host-call (host-global "window") "addEventListener" "{ev_name}" ' + f'(fn (_event) (dom-set-attr (host-get _event "target") "{attr}" "{val}")))') + if seen_html: + ops.append(sx) + else: + pre_setups.append(('__hs_config__', sx)) + continue # fall through # evaluate(() => _hyperscript.config.X = ...) single-line variant. From 3a755947ef58167bdd9ff7d47ce258554f865129 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 26 Apr 2026 10:03:06 +0000 Subject: [PATCH 4/7] HS: fetch do-not-throw modifier (+1 test) --- lib/hyperscript/compiler.sx | 2 +- lib/hyperscript/parser.sx | 15 ++++++++++++++- lib/hyperscript/runtime.sx | 28 ++++++++++++++++++++++++---- shared/static/wasm/sx/hs-compiler.sx | 2 +- shared/static/wasm/sx/hs-parser.sx | 15 ++++++++++++++- shared/static/wasm/sx/hs-runtime.sx | 28 ++++++++++++++++++++++++---- 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/lib/hyperscript/compiler.sx b/lib/hyperscript/compiler.sx index c7549d51..7d296f3e 100644 --- a/lib/hyperscript/compiler.sx +++ b/lib/hyperscript/compiler.sx @@ -1832,7 +1832,7 @@ (list (quote fn) (list) (hs-to-sx (nth ast 1))) (list (quote fn) (list) (hs-to-sx (nth ast 2))))) ((= head (quote fetch)) - (list (quote hs-fetch) (hs-to-sx (nth ast 1)) (nth ast 2))) + (list (quote hs-fetch) (hs-to-sx (nth ast 1)) (nth ast 2) (nth ast 3))) ((= head (quote fetch-gql)) (list (quote hs-fetch-gql) diff --git a/lib/hyperscript/parser.sx b/lib/hyperscript/parser.sx index 0c337953..ce28b3f4 100644 --- a/lib/hyperscript/parser.sx +++ b/lib/hyperscript/parser.sx @@ -1700,7 +1700,20 @@ ((fmt-after (if (and (not fmt-before) (match-kw "as")) (do (when (and (or (= (tp-type) "ident") (= (tp-type) "keyword")) (or (= (tp-val) "an") (= (tp-val) "a"))) (adv!)) (let ((f (tp-val))) (adv!) f)) nil))) (let ((fmt (or fmt-before fmt-after "text"))) - (list (quote fetch) url fmt))))))))) + (let + ((do-not-throw + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "do")) + (do + (adv!) + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "not")) + (do + (adv!) + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "throw")) + (do (adv!) true) + false)) + false)) + false))) + (list (quote fetch) url fmt do-not-throw)))))))))) (define parse-call-args (fn diff --git a/lib/hyperscript/runtime.sx b/lib/hyperscript/runtime.sx index 4daa71d9..7a749487 100644 --- a/lib/hyperscript/runtime.sx +++ b/lib/hyperscript/runtime.sx @@ -874,12 +874,30 @@ (define hs-fetch (fn - (url format) + (url format do-not-throw) (let - ((fmt (cond ((nil? format) "text") ((or (= format "json") (= format "JSON") (= format "Object")) "json") ((or (= format "html") (= format "HTML")) "html") ((or (= format "response") (= format "Response")) "response") ((or (= format "text") (= format "Text")) "text") (true format)))) + ((fmt (cond ((nil? format) "text") ((or (= format "json") (= format "JSON") (= format "Object")) "json") ((or (= format "html") (= format "HTML")) "html") ((or (= format "response") (= format "Response")) "response") ((or (= format "text") (= format "Text")) "text") ((or (= format "number") (= format "Number")) "number") (true format)))) (let - ((raw (perform (list "io-fetch" url fmt)))) - (cond ((= fmt "json") (hs-host-to-sx raw)) (true raw)))))) + ((raw (perform (list "io-fetch" url "response" (dict))))) + (do + (when (get raw :_network-error) (raise {:response raw :message "Network error" :_hs-error "FetchError"})) + (when + (and (not (get raw :ok)) (not (= fmt "response")) (not do-not-throw)) + (raise {:response raw :status (get raw :status) :message "Fetch error" :_hs-error "FetchError"})) + (cond + ((= fmt "response") raw) + ((= fmt "json") + (let + ((parsed (perform (list "io-parse-json" (get raw :_json))))) + (hs-host-to-sx parsed))) + ((= fmt "html") + (perform (list "io-parse-html" (get raw :_html)))) + ((= fmt "number") + (or + (parse-number (get raw :_number)) + (parse-number (get raw :_body)) + 0)) + (true (get raw :_body)))))))) (define hs-json-escape @@ -970,6 +988,8 @@ (true (str value)))) ((= type-name "JSON") (cond + ((and (dict? value) (dict-has? value :_json)) + (guard (_e (true value)) (json-parse (get value :_json)))) ((string? value) (guard (_e (true value)) (json-parse value))) ((dict? value) (hs-json-stringify value)) ((list? value) (hs-json-stringify value)) diff --git a/shared/static/wasm/sx/hs-compiler.sx b/shared/static/wasm/sx/hs-compiler.sx index c7549d51..7d296f3e 100644 --- a/shared/static/wasm/sx/hs-compiler.sx +++ b/shared/static/wasm/sx/hs-compiler.sx @@ -1832,7 +1832,7 @@ (list (quote fn) (list) (hs-to-sx (nth ast 1))) (list (quote fn) (list) (hs-to-sx (nth ast 2))))) ((= head (quote fetch)) - (list (quote hs-fetch) (hs-to-sx (nth ast 1)) (nth ast 2))) + (list (quote hs-fetch) (hs-to-sx (nth ast 1)) (nth ast 2) (nth ast 3))) ((= head (quote fetch-gql)) (list (quote hs-fetch-gql) diff --git a/shared/static/wasm/sx/hs-parser.sx b/shared/static/wasm/sx/hs-parser.sx index 0c337953..ce28b3f4 100644 --- a/shared/static/wasm/sx/hs-parser.sx +++ b/shared/static/wasm/sx/hs-parser.sx @@ -1700,7 +1700,20 @@ ((fmt-after (if (and (not fmt-before) (match-kw "as")) (do (when (and (or (= (tp-type) "ident") (= (tp-type) "keyword")) (or (= (tp-val) "an") (= (tp-val) "a"))) (adv!)) (let ((f (tp-val))) (adv!) f)) nil))) (let ((fmt (or fmt-before fmt-after "text"))) - (list (quote fetch) url fmt))))))))) + (let + ((do-not-throw + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "do")) + (do + (adv!) + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "not")) + (do + (adv!) + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "throw")) + (do (adv!) true) + false)) + false)) + false))) + (list (quote fetch) url fmt do-not-throw)))))))))) (define parse-call-args (fn diff --git a/shared/static/wasm/sx/hs-runtime.sx b/shared/static/wasm/sx/hs-runtime.sx index 4daa71d9..7a749487 100644 --- a/shared/static/wasm/sx/hs-runtime.sx +++ b/shared/static/wasm/sx/hs-runtime.sx @@ -874,12 +874,30 @@ (define hs-fetch (fn - (url format) + (url format do-not-throw) (let - ((fmt (cond ((nil? format) "text") ((or (= format "json") (= format "JSON") (= format "Object")) "json") ((or (= format "html") (= format "HTML")) "html") ((or (= format "response") (= format "Response")) "response") ((or (= format "text") (= format "Text")) "text") (true format)))) + ((fmt (cond ((nil? format) "text") ((or (= format "json") (= format "JSON") (= format "Object")) "json") ((or (= format "html") (= format "HTML")) "html") ((or (= format "response") (= format "Response")) "response") ((or (= format "text") (= format "Text")) "text") ((or (= format "number") (= format "Number")) "number") (true format)))) (let - ((raw (perform (list "io-fetch" url fmt)))) - (cond ((= fmt "json") (hs-host-to-sx raw)) (true raw)))))) + ((raw (perform (list "io-fetch" url "response" (dict))))) + (do + (when (get raw :_network-error) (raise {:response raw :message "Network error" :_hs-error "FetchError"})) + (when + (and (not (get raw :ok)) (not (= fmt "response")) (not do-not-throw)) + (raise {:response raw :status (get raw :status) :message "Fetch error" :_hs-error "FetchError"})) + (cond + ((= fmt "response") raw) + ((= fmt "json") + (let + ((parsed (perform (list "io-parse-json" (get raw :_json))))) + (hs-host-to-sx parsed))) + ((= fmt "html") + (perform (list "io-parse-html" (get raw :_html)))) + ((= fmt "number") + (or + (parse-number (get raw :_number)) + (parse-number (get raw :_body)) + 0)) + (true (get raw :_body)))))))) (define hs-json-escape @@ -970,6 +988,8 @@ (true (str value)))) ((= type-name "JSON") (cond + ((and (dict? value) (dict-has? value :_json)) + (guard (_e (true value)) (json-parse (get value :_json)))) ((string? value) (guard (_e (true value)) (json-parse value))) ((dict? value) (hs-json-stringify value)) ((list? value) (hs-json-stringify value)) From 1b1b67c72efc849e996fe2884f58d30ac749572f Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 26 Apr 2026 10:15:44 +0000 Subject: [PATCH 5/7] HS: fetch don't throw contraction (+1 test) --- lib/hyperscript/parser.sx | 31 ++++++++++++++++----------- lib/hyperscript/tokenizer.sx | 24 +++++++++++++++++---- shared/static/wasm/sx/hs-parser.sx | 31 ++++++++++++++++----------- shared/static/wasm/sx/hs-tokenizer.sx | 24 +++++++++++++++++---- 4 files changed, 78 insertions(+), 32 deletions(-) diff --git a/lib/hyperscript/parser.sx b/lib/hyperscript/parser.sx index ce28b3f4..8aea5615 100644 --- a/lib/hyperscript/parser.sx +++ b/lib/hyperscript/parser.sx @@ -1684,7 +1684,7 @@ ((url (if (and (= (tp-type) "keyword") (= (tp-val) "from")) (do (adv!) (parse-arith (parse-poss (parse-atom)))) nil))) (list (quote fetch-gql) gql-source url)))) (let - ((url-atom (if (and (= (tp-type) "op") (= (tp-val) "/")) (do (adv!) (let ((path-parts (list "/"))) (define read-path (fn () (when (and (not (at-end?)) (or (= (tp-type) "ident") (= (tp-type) "op") (= (tp-type) "dot") (= (tp-type) "number"))) (append! path-parts (tp-val)) (adv!) (read-path)))) (read-path) (join "" path-parts))) (parse-atom)))) + ((url-atom (if (and (= (tp-type) "op") (= (tp-val) "/")) (do (adv!) (let ((path-parts (list "/"))) (define read-path (fn () (when (and (not (at-end?)) (or (and (= (tp-type) "ident") (not (string-contains? (tp-val) "'"))) (= (tp-type) "op") (= (tp-type) "dot") (= (tp-type) "number"))) (append! path-parts (tp-val)) (adv!) (read-path)))) (read-path) (join "" path-parts))) (parse-atom)))) (let ((url (if (nil? url-atom) url-atom (parse-arith (parse-poss url-atom))))) (let @@ -1702,17 +1702,24 @@ ((fmt (or fmt-before fmt-after "text"))) (let ((do-not-throw - (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "do")) - (do - (adv!) - (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "not")) - (do - (adv!) - (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "throw")) - (do (adv!) true) - false)) - false)) - false))) + (cond + ((and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "do")) + (do + (adv!) + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "not")) + (do + (adv!) + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "throw")) + (do (adv!) true) + false)) + false))) + ((and (= (tp-type) "ident") (= (tp-val) "don't")) + (do + (adv!) + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "throw")) + (do (adv!) true) + false))) + (true false)))) (list (quote fetch) url fmt do-not-throw)))))))))) (define parse-call-args diff --git a/lib/hyperscript/tokenizer.sx b/lib/hyperscript/tokenizer.sx index 2483ea8c..eb9fdf68 100644 --- a/lib/hyperscript/tokenizer.sx +++ b/lib/hyperscript/tokenizer.sx @@ -536,10 +536,26 @@ (do (let ((word (read-ident start))) - (hs-emit! - (if (hs-keyword? word) "keyword" "ident") - word - start)) + (let + ((full-word + (if + (and + (< pos src-len) + (= (hs-cur) "'") + (< (+ pos 1) src-len) + (hs-letter? (hs-peek 1)) + (not + (and + (= (hs-peek 1) "s") + (or + (>= (+ pos 2) src-len) + (not (hs-ident-char? (hs-peek 2))))))) + (do (hs-advance! 1) (str word "'" (read-ident pos))) + word))) + (hs-emit! + (if (hs-keyword? full-word) "keyword" "ident") + full-word + start))) (scan!)) (and (or (= ch "=") (= ch "!") (= ch "<") (= ch ">")) diff --git a/shared/static/wasm/sx/hs-parser.sx b/shared/static/wasm/sx/hs-parser.sx index ce28b3f4..8aea5615 100644 --- a/shared/static/wasm/sx/hs-parser.sx +++ b/shared/static/wasm/sx/hs-parser.sx @@ -1684,7 +1684,7 @@ ((url (if (and (= (tp-type) "keyword") (= (tp-val) "from")) (do (adv!) (parse-arith (parse-poss (parse-atom)))) nil))) (list (quote fetch-gql) gql-source url)))) (let - ((url-atom (if (and (= (tp-type) "op") (= (tp-val) "/")) (do (adv!) (let ((path-parts (list "/"))) (define read-path (fn () (when (and (not (at-end?)) (or (= (tp-type) "ident") (= (tp-type) "op") (= (tp-type) "dot") (= (tp-type) "number"))) (append! path-parts (tp-val)) (adv!) (read-path)))) (read-path) (join "" path-parts))) (parse-atom)))) + ((url-atom (if (and (= (tp-type) "op") (= (tp-val) "/")) (do (adv!) (let ((path-parts (list "/"))) (define read-path (fn () (when (and (not (at-end?)) (or (and (= (tp-type) "ident") (not (string-contains? (tp-val) "'"))) (= (tp-type) "op") (= (tp-type) "dot") (= (tp-type) "number"))) (append! path-parts (tp-val)) (adv!) (read-path)))) (read-path) (join "" path-parts))) (parse-atom)))) (let ((url (if (nil? url-atom) url-atom (parse-arith (parse-poss url-atom))))) (let @@ -1702,17 +1702,24 @@ ((fmt (or fmt-before fmt-after "text"))) (let ((do-not-throw - (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "do")) - (do - (adv!) - (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "not")) - (do - (adv!) - (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "throw")) - (do (adv!) true) - false)) - false)) - false))) + (cond + ((and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "do")) + (do + (adv!) + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "not")) + (do + (adv!) + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "throw")) + (do (adv!) true) + false)) + false))) + ((and (= (tp-type) "ident") (= (tp-val) "don't")) + (do + (adv!) + (if (and (or (= (tp-type) "keyword") (= (tp-type) "ident")) (= (tp-val) "throw")) + (do (adv!) true) + false))) + (true false)))) (list (quote fetch) url fmt do-not-throw)))))))))) (define parse-call-args diff --git a/shared/static/wasm/sx/hs-tokenizer.sx b/shared/static/wasm/sx/hs-tokenizer.sx index 2483ea8c..eb9fdf68 100644 --- a/shared/static/wasm/sx/hs-tokenizer.sx +++ b/shared/static/wasm/sx/hs-tokenizer.sx @@ -536,10 +536,26 @@ (do (let ((word (read-ident start))) - (hs-emit! - (if (hs-keyword? word) "keyword" "ident") - word - start)) + (let + ((full-word + (if + (and + (< pos src-len) + (= (hs-cur) "'") + (< (+ pos 1) src-len) + (hs-letter? (hs-peek 1)) + (not + (and + (= (hs-peek 1) "s") + (or + (>= (+ pos 2) src-len) + (not (hs-ident-char? (hs-peek 2))))))) + (do (hs-advance! 1) (str word "'" (read-ident pos))) + word))) + (hs-emit! + (if (hs-keyword? full-word) "keyword" "ident") + full-word + start))) (scan!)) (and (or (= ch "=") (= ch "!") (= ch "<") (= ch ">")) From d7244d1dc8394c43e56c64fe538e54710707cc08 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 26 Apr 2026 11:33:04 +0000 Subject: [PATCH 6/7] HS: hyperscript:beforeFetch event + runner dict format (+1 test) - hs-fetch gains target param; dispatches hyperscript:beforeFetch before fetch - compiler emits (quote me) as target arg - runner io-fetch returns unified dict {_type:'dict', ok, status, _body, ...} so runtime (get raw :key) calls work correctly (22/23 fetch tests pass) Co-Authored-By: Claude Sonnet 4.6 --- lib/hyperscript/compiler.sx | 2 +- lib/hyperscript/runtime.sx | 13 ++++++++----- shared/static/wasm/sx/hs-compiler.sx | 2 +- shared/static/wasm/sx/hs-runtime.sx | 13 ++++++++----- tests/hs-run-filtered.js | 9 ++------- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/lib/hyperscript/compiler.sx b/lib/hyperscript/compiler.sx index 7d296f3e..7fd27959 100644 --- a/lib/hyperscript/compiler.sx +++ b/lib/hyperscript/compiler.sx @@ -1832,7 +1832,7 @@ (list (quote fn) (list) (hs-to-sx (nth ast 1))) (list (quote fn) (list) (hs-to-sx (nth ast 2))))) ((= head (quote fetch)) - (list (quote hs-fetch) (hs-to-sx (nth ast 1)) (nth ast 2) (nth ast 3))) + (list (quote hs-fetch) (hs-to-sx (nth ast 1)) (nth ast 2) (nth ast 3) (quote me))) ((= head (quote fetch-gql)) (list (quote hs-fetch-gql) diff --git a/lib/hyperscript/runtime.sx b/lib/hyperscript/runtime.sx index 7a749487..c8f3a4cc 100644 --- a/lib/hyperscript/runtime.sx +++ b/lib/hyperscript/runtime.sx @@ -874,12 +874,15 @@ (define hs-fetch (fn - (url format do-not-throw) + (url format do-not-throw target) (let ((fmt (cond ((nil? format) "text") ((or (= format "json") (= format "JSON") (= format "Object")) "json") ((or (= format "html") (= format "HTML")) "html") ((or (= format "response") (= format "Response")) "response") ((or (= format "text") (= format "Text")) "text") ((or (= format "number") (= format "Number")) "number") (true format)))) - (let - ((raw (perform (list "io-fetch" url "response" (dict))))) - (do + (do + (when (not (nil? target)) + (dom-dispatch target "hyperscript:beforeFetch" nil)) + (let + ((raw (perform (list "io-fetch" url "response" (dict))))) + (do (when (get raw :_network-error) (raise {:response raw :message "Network error" :_hs-error "FetchError"})) (when (and (not (get raw :ok)) (not (= fmt "response")) (not do-not-throw)) @@ -897,7 +900,7 @@ (parse-number (get raw :_number)) (parse-number (get raw :_body)) 0)) - (true (get raw :_body)))))))) + (true (get raw :_body))))))))) (define hs-json-escape diff --git a/shared/static/wasm/sx/hs-compiler.sx b/shared/static/wasm/sx/hs-compiler.sx index 7d296f3e..7fd27959 100644 --- a/shared/static/wasm/sx/hs-compiler.sx +++ b/shared/static/wasm/sx/hs-compiler.sx @@ -1832,7 +1832,7 @@ (list (quote fn) (list) (hs-to-sx (nth ast 1))) (list (quote fn) (list) (hs-to-sx (nth ast 2))))) ((= head (quote fetch)) - (list (quote hs-fetch) (hs-to-sx (nth ast 1)) (nth ast 2) (nth ast 3))) + (list (quote hs-fetch) (hs-to-sx (nth ast 1)) (nth ast 2) (nth ast 3) (quote me))) ((= head (quote fetch-gql)) (list (quote hs-fetch-gql) diff --git a/shared/static/wasm/sx/hs-runtime.sx b/shared/static/wasm/sx/hs-runtime.sx index 7a749487..c8f3a4cc 100644 --- a/shared/static/wasm/sx/hs-runtime.sx +++ b/shared/static/wasm/sx/hs-runtime.sx @@ -874,12 +874,15 @@ (define hs-fetch (fn - (url format do-not-throw) + (url format do-not-throw target) (let ((fmt (cond ((nil? format) "text") ((or (= format "json") (= format "JSON") (= format "Object")) "json") ((or (= format "html") (= format "HTML")) "html") ((or (= format "response") (= format "Response")) "response") ((or (= format "text") (= format "Text")) "text") ((or (= format "number") (= format "Number")) "number") (true format)))) - (let - ((raw (perform (list "io-fetch" url "response" (dict))))) - (do + (do + (when (not (nil? target)) + (dom-dispatch target "hyperscript:beforeFetch" nil)) + (let + ((raw (perform (list "io-fetch" url "response" (dict))))) + (do (when (get raw :_network-error) (raise {:response raw :message "Network error" :_hs-error "FetchError"})) (when (and (not (get raw :ok)) (not (= fmt "response")) (not do-not-throw)) @@ -897,7 +900,7 @@ (parse-number (get raw :_number)) (parse-number (get raw :_body)) 0)) - (true (get raw :_body)))))))) + (true (get raw :_body))))))))) (define hs-json-escape diff --git a/tests/hs-run-filtered.js b/tests/hs-run-filtered.js index 1495788d..ba38cacd 100755 --- a/tests/hs-run-filtered.js +++ b/tests/hs-run-filtered.js @@ -603,15 +603,10 @@ 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 fmt=typeof items[2]==='string'?items[2]:'text'; const scriptRoutes=_fetchScripts[globalThis.__currentHsTestName]; const route=(scriptRoutes&&scriptRoutes[url])||_fetchRoutes[url]||_fetchRoutes['/test']; - if(route&&route.networkError){doResume({_network_error:true,message:'aborted'});} - else 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||''); + 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||''});} } 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);}} From 4c43918a99de8b38ce8fbc3f21917cc8aa6bd4b5 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 26 Apr 2026 11:34:51 +0000 Subject: [PATCH 7/7] HS-plan: E40 done +7; scoreboard 1310/1496 (+97) Co-Authored-By: Claude Sonnet 4.6 --- plans/hs-conformance-scoreboard.md | 6 +++--- plans/hs-conformance-to-100.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plans/hs-conformance-scoreboard.md b/plans/hs-conformance-scoreboard.md index e081b61b..4eec76f0 100644 --- a/plans/hs-conformance-scoreboard.md +++ b/plans/hs-conformance-scoreboard.md @@ -4,7 +4,7 @@ Live tally for `plans/hs-conformance-to-100.md`. Update after every cluster comm ``` Baseline: 1213/1496 (81.1%) -Merged: 1303/1496 (87.1%) delta +90 +Merged: 1310/1496 (87.6%) delta +97 Worktree: all landed Target: 1496/1496 (100.0%) Remaining: ~194 tests (clusters 17/29(partial)/31 blocked; 33/34 partial) @@ -75,7 +75,7 @@ Remaining: ~194 tests (clusters 17/29(partial)/31 blocked; 33/34 partial) | 37 | Tokenizer-as-API | design-done | `plans/designs/e37-tokenizer-api.md` | | 38 | SourceInfo API | design-done | `plans/designs/e38-sourceinfo.md` | | 39 | WebWorker plugin | design-done | `plans/designs/e39-webworker.md` | -| 40 | Fetch non-2xx / before-fetch / real response | design-done | `plans/designs/e40-real-fetch.md` | +| 40 | Fetch non-2xx / before-fetch / real response | done | +7 | d7244d1d | ### Bucket F — generator translation gaps @@ -89,7 +89,7 @@ Defer until A–D drain. Estimated ~25 recoverable tests. | B | 7 | 0 | 0 | 0 | 0 | — | 7 | | C | 4 | 1 | 0 | 0 | 0 | — | 5 | | D | 2 | 2 | 0 | 0 | 1 | — | 5 | -| E | 0 | 0 | 0 | 0 | 0 | 5 | 5 | +| E | 1 | 0 | 0 | 0 | 0 | 4 | 5 | | F | — | — | — | ~10 | — | — | ~10 | ## Maintenance diff --git a/plans/hs-conformance-to-100.md b/plans/hs-conformance-to-100.md index 2e078de9..ea8f94a0 100644 --- a/plans/hs-conformance-to-100.md +++ b/plans/hs-conformance-to-100.md @@ -137,7 +137,7 @@ All five have design docs on their own worktree branches pending review + merge. 39. **[design-done, pending review — `plans/designs/e39-webworker.md` on `hs-design-e39-webworker`] WebWorker plugin** — 1 test. Parser-only stub that errors with a link to upstream docs; no runtime, no mock Worker class. Hand-write the test (don't patch the generator). Single commit. -40. **[design-done, pending review — `plans/designs/e40-real-fetch.md` on `worktree-agent-a94612a4283eaa5e0`] Fetch non-2xx / before-fetch event / real response object** — 7 tests. SX-dict Response wrapper `{:_hs-response :ok :status :url :_body :_json :_html}`; restructured `hs-fetch` that always fetches wrapper then converts by format; test-name-keyed `_fetchScripts`. 11-step checklist. Watch for regression on cluster-1 JSON unwrap. +40. **[done +7 — d7244d1d] Fetch non-2xx / before-fetch event / real response object** — 7 tests. SX-dict Response wrapper `{:_hs-response :ok :status :url :_body :_json :_html}`; restructured `hs-fetch` that always fetches wrapper then converts by format; test-name-keyed `_fetchScripts`. 11-step checklist. Watch for regression on cluster-1 JSON unwrap. ### Bucket F: generator translation gaps (after bucket A-D)