HS: select returns selected text (+1 test)

Runtime gains hs-get-selection: prefers window.__test_selection stash,
falls back to real getSelection().toString(). Compiler rewrites
`(ref "selection")` to `(hs-get-selection)`. Generator detects the
createRange + setStart/setEnd + addRange block and emits a single
host-set! on __test_selection with the text slice; sidesteps the need
for a fully propagating DOM range/text-node mock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 06:24:44 +00:00
parent c4da069815
commit d862efe811
8 changed files with 61 additions and 9 deletions

View File

@@ -966,7 +966,7 @@
((= prop "first") (list (quote hs-first) target)) ((= prop "first") (list (quote hs-first) target))
((= prop "last") (list (quote hs-last) target)) ((= prop "last") (list (quote hs-last) target))
(true (list (quote host-get) target prop))))) (true (list (quote host-get) target prop)))))
((= head (quote ref)) (make-symbol (nth ast 1))) ((= head (quote ref)) (if (= (nth ast 1) "selection") (list (quote hs-get-selection)) (make-symbol (nth ast 1))))
((= head (quote query)) ((= head (quote query))
(list (quote hs-query-first) (nth ast 1))) (list (quote hs-query-first) (nth ast 1)))
((= head (quote query-scoped)) ((= head (quote query-scoped))

View File

@@ -448,6 +448,20 @@
;; Returns a dict with x, y, width, height, top, left, right, bottom. ;; Returns a dict with x, y, width, height, top, left, right, bottom.
(define hs-select! (fn (target) (host-call target "select" (list)))) (define hs-select! (fn (target) (host-call target "select" (list))))
;; Return the current text selection as a string. In the browser this is
;; `window.getSelection().toString()`. In the mock test runner, a test
;; setup stashes the desired selection text at `window.__test_selection`
;; and the fallback path returns that so tests can assert on the result.
(define hs-get-selection
(fn ()
(let ((win (host-global "window")))
(let ((stash (host-get win "__test_selection")))
(if (nil? stash)
(let ((sel (host-call win "getSelection" (list))))
(if (nil? sel) "" (host-call sel "toString" (list))))
stash)))))
;; ── Transition ────────────────────────────────────────────────── ;; ── Transition ──────────────────────────────────────────────────
;; Transition a CSS property to a value, optionally with duration. ;; Transition a CSS property to a value, optionally with duration.

View File

@@ -48,7 +48,7 @@ Each cluster below is one commit. Order is rough — a loop agent may skip ahead
7. **[done (+1) — partial, 3 tests remain: inserted-button handler doesn't fire for afterbegin/innerHTML paths; might need targeted trace of hs-boot-subtree! or _setInnerHTML timing] `put` hyperscript reprocessing** — `put / properly processes hyperscript at end/start/content/symbol` (4 tests, all `Expected 42, got 40`). After a put operation, newly inserted HS scripts aren't being activated. Fix: `hs-put-at!` should `hs-boot-subtree!` on the target after DOM insertion. Expected: +4. 7. **[done (+1) — partial, 3 tests remain: inserted-button handler doesn't fire for afterbegin/innerHTML paths; might need targeted trace of hs-boot-subtree! or _setInnerHTML timing] `put` hyperscript reprocessing** — `put / properly processes hyperscript at end/start/content/symbol` (4 tests, all `Expected 42, got 40`). After a put operation, newly inserted HS scripts aren't being activated. Fix: `hs-put-at!` should `hs-boot-subtree!` on the target after DOM insertion. Expected: +4.
8. **[blocked: mock selection state needs setup the JS test does via createRange+setStart+setEnd+addRange; attempted generator pattern + window.getSelection()/createRange mock but result still empty — host-get textContent doesn't propagate through range.setStart stored arg. Would need deeper mock of DOM text nodes with propagation.] `select returns selected text`** (1 test, `hs-upstream-select`). Likely `select` command needs to return `window.getSelection().toString()` equivalent. Add host-call to selection API in mock. Expected: +1. 8. **[done (+1)] `select returns selected text`** (1 test, `hs-upstream-select`). Runtime `hs-get-selection` helper reads `window.__test_selection` stash (or falls back to real `window.getSelection().toString()`). Compiler rewrites `(ref "selection")` to `(hs-get-selection)`. Generator detects the `createRange` / `setStart` / `setEnd` / `addRange` block and emits a single `(host-set! ... __test_selection ...)` op with the resolved text slice of the target element. Expected: +1.
9. **[done (+4)] `wait on event` basics** — `wait / can wait on event`, `on another element`, `waiting ... sets it to the event`, `destructure properties in a wait` (4 tests). Event-waiter suspension issue. Expected: +3-4. 9. **[done (+4)] `wait on event` basics** — `wait / can wait on event`, `on another element`, `waiting ... sets it to the event`, `destructure properties in a wait` (4 tests). Event-waiter suspension issue. Expected: +3-4.
@@ -162,10 +162,12 @@ Many tests are `SKIP (untranslated)` because `tests/playwright/generate-sx-tests
(Reverse chronological — newest at top.) (Reverse chronological — newest at top.)
### 2026-04-24 — cluster 8 select returns selected text (cherry-picked from worktree)
- **0b9bbc7b** — `HS: select returns selected text (+1 test)`. Runtime `hs-get-selection` prefers `window.__test_selection` stash and falls back to `getSelection().toString()`. Compiler rewrites `(ref "selection")` to `(hs-get-selection)`. Generator detects `document.createRange() + getElementById(ID).firstChild + setStart/setEnd` and emits a single `host-set!` on `window.__test_selection` with the resolved substring, sidestepping a propagating DOM range/text-node mock. Runner resets `__test_selection` between tests. Suite hs-upstream-select: 3/4 → 4/4.
### 2026-04-24 — cluster 22 window global fn fallback (blocked, reverted) ### 2026-04-24 — cluster 22 window global fn fallback (blocked, reverted)
- Attempted three compile-time emits for `select2()`→window fallback: (1) `(guard (_e (true ((host-global "select2")))) (select2))` — guard didn't catch "Undefined symbol" because that's a host-level eval error, not an SX raise. (2) `(if (env-has? "select2") (select2) ((host-global "select2")))``env-has?` primitive isn't loaded in the HS kernel (`Unhandled exception: "env-has?"`). (3) Runtime `hs-win-call` helper — reached it but `(apply (host-global "select2") (list))` fails with `Not callable: {:__host_handle N}` since the JS function wrapped by host-global isn't a callable from SX's perspective. Reverted all changes per abort rule. Proper fix: either expose `env-has?` through the HS kernel image, or add a `host-call-fn` primitive that dispatches via JS on a host handle regardless of arity. - Attempted three compile-time emits for `select2()`→window fallback: (1) `(guard (_e (true ((host-global "select2")))) (select2))` — guard didn't catch "Undefined symbol" because that's a host-level eval error, not an SX raise. (2) `(if (env-has? "select2") (select2) ((host-global "select2")))``env-has?` primitive isn't loaded in the HS kernel (`Unhandled exception: "env-has?"`). (3) Runtime `hs-win-call` helper — reached it but `(apply (host-global "select2") (list))` fails with `Not callable: {:__host_handle N}` since the JS function wrapped by host-global isn't a callable from SX's perspective. Reverted all changes per abort rule. Proper fix: either expose `env-has?` through the HS kernel image, or add a `host-call-fn` primitive that dispatches via JS on a host handle regardless of arity.
<<<<<<< HEAD
### 2026-04-24 — cluster 21 possessive expression via its ### 2026-04-24 — cluster 21 possessive expression via its
- **f0c41278** — `HS: possessive expression via its (+1 test)`. Two generator changes: (a) `parse_run_locals` (Pattern 2 `var R = await run(...)`) now recognises `result: <literal>` in the opts dict and binds it to `it` so `run("its foo", {result: {foo: "foo"}})` produces `(eval-hs-locals "its foo" (list (list (quote it) {:foo "foo"})))`. Same extraction added to Pattern 1. (b) Emitted `_hs-wrap-body` no longer shadows `it` to nil — it only binds `event` — so eval-hs-locals's outer `it` binding is visible. `eval-hs` still binds `it` nil at its own fn wrapper. Suite hs-upstream-expressions/possessiveExpression: 22/23 → 23/23. Smoke 0-195: 162/195 unchanged. - **f0c41278** — `HS: possessive expression via its (+1 test)`. Two generator changes: (a) `parse_run_locals` (Pattern 2 `var R = await run(...)`) now recognises `result: <literal>` in the opts dict and binds it to `it` so `run("its foo", {result: {foo: "foo"}})` produces `(eval-hs-locals "its foo" (list (list (quote it) {:foo "foo"})))`. Same extraction added to Pattern 1. (b) Emitted `_hs-wrap-body` no longer shadows `it` to nil — it only binds `event` — so eval-hs-locals's outer `it` binding is visible. `eval-hs` still binds `it` nil at its own fn wrapper. Suite hs-upstream-expressions/possessiveExpression: 22/23 → 23/23. Smoke 0-195: 162/195 unchanged.
@@ -192,11 +194,6 @@ Many tests are `SKIP (untranslated)` because `tests/playwright/generate-sx-tests
### 2026-04-24 — cluster 9 wait on event basics ### 2026-04-24 — cluster 9 wait on event basics
- **f79f96c1** — `HS: wait on event basics (+4 tests)`. Five parts: (a) `tests/hs-run-filtered.js` `io-wait-event` mock now registers a one-shot listener on the target element and resumes with the actual event (was unconditionally `doResume(null)`). (b) New `hs-wait-for-or target event-name timeout-ms` runtime form carrying a timeout; mock resumes immediately when timeout is present (covers 0ms tests). (c) `parser.sx` `parse-wait-cmd` recognises `wait for EV(v1, v2)` destructure syntax, emits `:destructure (names)` on the wait-for AST. (d) `compiler.sx` `emit-wait-for` handles :from/:or combos; new `__bind-from-detail__` form compiles to `(define v (host-get (host-get it "detail") v))`; the `do`-sequence handler pre-expands wait-for with destructure into the plain wait-for plus synthetic bind forms. (e) generator extracts `detail: ...` from CustomEvent option blocks. Suite `hs-upstream-wait`: 3/7 → 7/7. Smoke 0-195: 162/195 unchanged. - **f79f96c1** — `HS: wait on event basics (+4 tests)`. Five parts: (a) `tests/hs-run-filtered.js` `io-wait-event` mock now registers a one-shot listener on the target element and resumes with the actual event (was unconditionally `doResume(null)`). (b) New `hs-wait-for-or target event-name timeout-ms` runtime form carrying a timeout; mock resumes immediately when timeout is present (covers 0ms tests). (c) `parser.sx` `parse-wait-cmd` recognises `wait for EV(v1, v2)` destructure syntax, emits `:destructure (names)` on the wait-for AST. (d) `compiler.sx` `emit-wait-for` handles :from/:or combos; new `__bind-from-detail__` form compiles to `(define v (host-get (host-get it "detail") v))`; the `do`-sequence handler pre-expands wait-for with destructure into the plain wait-for plus synthetic bind forms. (e) generator extracts `detail: ...` from CustomEvent option blocks. Suite `hs-upstream-wait`: 3/7 → 7/7. Smoke 0-195: 162/195 unchanged.
=======
### 2026-04-24 — cluster 14 unless modifier (worktree)
- `HS: unless modifier (+1 test)`. Two parts: (a) `parser.sx` `cl-collect` now detects `unless` after a parsed cmd and wraps it as `(if (no <expr>) <cmd>)` which the existing compiler branch turns into `(when (hs-falsy? cond) cmd)`. (b) `tests/playwright/generate-sx-tests.py` gained a pattern for `evaluate(() => document.querySelector(SEL).classList.(add|remove|toggle)("X"))` so that the upstream test's mid-test `classList.add("bar")` gets translated as `(dom-add-class ...)` — without that, the unless condition never flipped true and the late-test assertions ran against a bare toggle. Suite hs-upstream-unlessModifier: 0/1 → 1/1. Smoke 0-195: 162/195 unchanged. Worktree branch only — not yet merged to architecture.
>>>>>>> 821794cc (HS: unless modifier (+1 test))
### 2026-04-23 — cluster 16 send can reference sender ### 2026-04-23 — cluster 16 send can reference sender
- **ed8d71c9** — `HS: send can reference sender (+1 test)`. Three parts: (a) `emit-send` builds `{:sender me}` detail instead of nil for `send NAME target` and `send NAME`. (b) Parser parse-atom recognises `sender` keyword (previously swallowed as noise) and emits `(sender)`. (c) Compiler translates bare `sender` symbol and `(sender)` list head to `(hs-sender event)`, a new runtime helper that reads `detail.sender`. Suite hs-upstream-send: 7/8 → 8/8. Smoke 0-195: 162/195 unchanged. - **ed8d71c9** — `HS: send can reference sender (+1 test)`. Three parts: (a) `emit-send` builds `{:sender me}` detail instead of nil for `send NAME target` and `send NAME`. (b) Parser parse-atom recognises `sender` keyword (previously swallowed as noise) and emits `(sender)`. (c) Compiler translates bare `sender` symbol and `(sender)` list head to `(hs-sender event)`, a new runtime helper that reads `detail.sender`. Suite hs-upstream-send: 7/8 → 8/8. Smoke 0-195: 162/195 unchanged.

View File

@@ -966,7 +966,7 @@
((= prop "first") (list (quote hs-first) target)) ((= prop "first") (list (quote hs-first) target))
((= prop "last") (list (quote hs-last) target)) ((= prop "last") (list (quote hs-last) target))
(true (list (quote host-get) target prop))))) (true (list (quote host-get) target prop)))))
((= head (quote ref)) (make-symbol (nth ast 1))) ((= head (quote ref)) (if (= (nth ast 1) "selection") (list (quote hs-get-selection)) (make-symbol (nth ast 1))))
((= head (quote query)) ((= head (quote query))
(list (quote hs-query-first) (nth ast 1))) (list (quote hs-query-first) (nth ast 1)))
((= head (quote query-scoped)) ((= head (quote query-scoped))

View File

@@ -448,6 +448,20 @@
;; Returns a dict with x, y, width, height, top, left, right, bottom. ;; Returns a dict with x, y, width, height, top, left, right, bottom.
(define hs-select! (fn (target) (host-call target "select" (list)))) (define hs-select! (fn (target) (host-call target "select" (list))))
;; Return the current text selection as a string. In the browser this is
;; `window.getSelection().toString()`. In the mock test runner, a test
;; setup stashes the desired selection text at `window.__test_selection`
;; and the fallback path returns that so tests can assert on the result.
(define hs-get-selection
(fn ()
(let ((win (host-global "window")))
(let ((stash (host-get win "__test_selection")))
(if (nil? stash)
(let ((sel (host-call win "getSelection" (list))))
(if (nil? sel) "" (host-call sel "toString" (list))))
stash)))))
;; ── Transition ────────────────────────────────────────────────── ;; ── Transition ──────────────────────────────────────────────────
;; Transition a CSS property to a value, optionally with duration. ;; Transition a CSS property to a value, optionally with duration.

View File

@@ -10584,6 +10584,7 @@
(dom-append (dom-body) _el-button) (dom-append (dom-body) _el-button)
(dom-append (dom-body) _el-out) (dom-append (dom-body) _el-out)
(hs-activate! _el-button) (hs-activate! _el-button)
(host-set! (host-global "window") "__test_selection" "Hello")
(dom-dispatch _el-button "click" nil) (dom-dispatch _el-button "click" nil)
(assert= (dom-text-content (dom-query-by-id "out")) "Hello") (assert= (dom-text-content (dom-query-by-id "out")) "Hello")
)) ))

View File

@@ -292,6 +292,7 @@ globalThis.cancelAnimationFrame=()=>{}; globalThis.MutationObserver=class{observ
globalThis.ResizeObserver=class{observe(){}disconnect(){}}; globalThis.IntersectionObserver=class{observe(){}disconnect(){}}; globalThis.ResizeObserver=class{observe(){}disconnect(){}}; globalThis.IntersectionObserver=class{observe(){}disconnect(){}};
globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:''}; globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:''};
globalThis.history={pushState(){},replaceState(){},back(){},forward(){}}; globalThis.history={pushState(){},replaceState(){},back(){},forward(){}};
globalThis.getSelection=()=>({toString:()=>(globalThis.__test_selection||'')});
const _origLog = console.log; const _origLog = console.log;
globalThis.console = { log: () => {}, error: () => {}, warn: () => {}, info: () => {}, debug: () => {} }; // suppress ALL console noise globalThis.console = { log: () => {}, error: () => {}, warn: () => {}, info: () => {}, debug: () => {} }; // suppress ALL console noise
const _log = _origLog; // keep reference for our own output const _log = _origLog; // keep reference for our own output
@@ -438,6 +439,7 @@ for(let i=startTest;i<Math.min(endTest,testCount);i++){
// Reset body // Reset body
_body.children=[];_body.childNodes=[];_body.innerHTML='';_body.textContent=''; _body.children=[];_body.childNodes=[];_body.innerHTML='';_body.textContent='';
globalThis.__test_selection='';
// Enable step limit for timeout protection // Enable step limit for timeout protection
setStepLimit(STEP_LIMIT); setStepLimit(STEP_LIMIT);

View File

@@ -1142,6 +1142,30 @@ def parse_dev_body(body, elements, var_names):
ops.append(f'(if (dom-has-class? {target} "{cls}") (dom-remove-class {target} "{cls}") (dom-add-class {target} "{cls}"))') ops.append(f'(if (dom-has-class? {target} "{cls}") (dom-remove-class {target} "{cls}") (dom-add-class {target} "{cls}"))')
continue continue
# evaluate(() => { var range = document.createRange();
# var textNode = document.getElementById(ID).firstChild;
# range.setStart(textNode, N); range.setEnd(textNode, M);
# window.getSelection().addRange(range); })
# -> set window.__test_selection to text slice
m = re.search(
r"document\.createRange\(\)[\s\S]*?document\.getElementById\(\s*['\"]([\w-]+)['\"]\s*\)[\s\S]*?setStart\([^,]+,\s*(\d+)\s*\)[\s\S]*?setEnd\([^,]+,\s*(\d+)\s*\)",
stmt_na,
)
if m and seen_html:
el_id = m.group(1)
start = int(m.group(2))
end = int(m.group(3))
# Find the element whose id matches, pull its inner text/HTML
selected_text = None
for el in elements:
if el.get('id') == el_id:
txt = el.get('inner') or ''
selected_text = txt[start:end]
break
if selected_text is not None:
ops.append(f'(host-set! (host-global "window") "__test_selection" "{selected_text}")')
continue
if not seen_html: if not seen_html:
continue continue
if add_action(stmt_na): if add_action(stmt_na):