diff --git a/STATUS.md b/STATUS.md index 3ac5156..6105b1f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -55,9 +55,19 @@ Three things need to be proven against a fixture before calling this beyond pre- Does `scripts/eval-cljs.sh '(+ 1 2)'` return `{:ok? true :value 3}`? If not, `ops.clj`'s `cljs-eval-value` parsing needs adjustment. -### 3. `data-rf2-source-coord` format +### 3. `data-rf2-source-coord` format — RESOLVED 2026-05-09 (rf2-7g2q) -`runtime.cljs/parse-rf2-coord` assumes a `'|::'` shape. The actual format re-frame2 emits when `(rf/configure :source-coords {:annotate-dom? true})` is on needs verification. Tool-Pair.md §Source-mapping declares the value format opaque — we parse to recover `:ns` / `:line` / `:file`, but the exact separators may differ. Update the parser in one place if so. +Resolved against re-frame2 rf2-z7f7 (PR #135). Per [Spec 006 §Source-coord annotation](https://github.com/day8/re-frame2/blob/master/spec/006-ReactiveSubstrate.md) and [Tool-Pair §Source-mapping](https://github.com/day8/re-frame2/blob/master/spec/Tool-Pair.md) the emitted attribute value is: + +``` +data-rf2-source-coord=":::" +``` + +Four colon-separated segments, where `` and `` derive from the registry id keyword (`(namespace id)` / `(name id)`). Either coord segment may be the literal `?` for programmatic `reg-view*` calls that bypassed the macro path. Non-DOM roots (Fragment `:<>`, `:>` interop, fn-component head) are exempt — pair tools fall back to `(rf/handler-meta :view id)` for those. + +`scripts/runtime.cljs/parse-rf2-coord` now returns `{:ns :handler-id :line :col}` (or nil for malformed / non-4-segment input). Verified by `tests/runtime/parse_rf2_coord_test.clj` (run via `bb`). + +> **Compatibility note.** Tool-Pair.md declares the attribute's value format opaque to consumers — pair2 parses it pragmatically so the DOM-to-source bridge can be useful, but skill consumers MUST NOT depend on the parsed shape's stability across re-frame2 versions. If the format shifts, update the parser (and these tests) in one place. --- diff --git a/docs/TESTING.md b/docs/TESTING.md index 73e670d..137047f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -17,7 +17,7 @@ node out/test.js Failing points to flag on first run: -- `parse-rf2-coord` assumes a `'|::'` shape for `data-rf2-source-coord`. Real attribute format needs verification (Tool-Pair §Source-mapping declares the format opaque). +- ~~`parse-rf2-coord` assumes a `'|::'` shape for `data-rf2-source-coord`.~~ Resolved 2026-05-09 (rf2-7g2q): parser updated to the canonical `:::` shape per Spec 006 §Source-coord annotation. Bb-runnable tests at `tests/runtime/parse_rf2_coord_test.clj` until the shadow-cljs harness lands. - `parse-rc-src` assumes a `"file:line"` or `"file:line:column"` format for `data-rc-src`. Real re-com attribute format needs verification. ## 2. Bash-shim integration (`tests/shim/`) @@ -84,7 +84,6 @@ Mitigation until we can run E2E per-push: ## What's explicitly **not** tested yet - Connection against a real shadow-cljs build with nREPL enabled -- `data-rf2-source-coord` format verification - Multi-frame routing under real concurrency - `restore-epoch` failure-mode traces - Hot-reload probe-form selection heuristics diff --git a/scripts/runtime.cljs b/scripts/runtime.cljs index 7d430b0..d0ed9c1 100644 --- a/scripts/runtime.cljs +++ b/scripts/runtime.cljs @@ -536,30 +536,59 @@ ;; ;; Two attribute sources, in priority order: ;; 1. data-rf2-source-coord (re-frame2's own annotation when -;; :annotate-dom? is on — Tool-Pair §Source-mapping) +;; :annotate-dom? is on — Tool-Pair §Source-mapping; +;; Spec 006 §Source-coord annotation, rf2-z7f7) ;; 2. data-rc-src (re-com's debug attribute, fallback) ;; -;; Both parse to {:ns :file :line :column}. The runtime returns the -;; first one it finds. +;; The two attributes resolve to different schemas — re-frame2's +;; carries the registry-id derived / with /; +;; re-com's carries //. The runtime returns +;; whichever map the first present attribute parses to. (defn- parse-rf2-coord - "Parse re-frame2's `data-rf2-source-coord` attribute. Per - Tool-Pair §Source-mapping, the value format is opaque; this - parser uses the documented `'|::'` shape used - by the reference implementation. If the actual shape ships - different, update this in one place." + "Parse re-frame2's `data-rf2-source-coord` attribute. + + Per Spec 006 §Source-coord annotation (rf2-z7f7) and Tool-Pair + §Source-mapping the attribute value is a four-segment colon- + separated string: + + ::: + + where and derive from the registry id keyword + (`(namespace id)` and `(name id)`) and / are the + captured source coords from the reg-view macro. Either coord + segment may be the literal `?` for programmatic registrations + that bypassed the macro path (Spec 006 §Attribute value format). + + Returns + {:ns + :handler-id + :line + :col } + + - :line and :col are nil when the corresponding segment is `?` + or otherwise non-numeric. + - Returns nil for blank input, non-strings, or fewer than four + segments. Pair-shaped consumers fall back to (rf/handler-meta + :view id) for those cases (Spec 006 §Documented exemption). + + Tool-Pair.md declares the value format opaque to consumers; this + parser exists so pair2's DOM-to-source bridge can be useful, but + downstream callers MUST NOT depend on the parsed shape's + stability across re-frame2 versions." [attr-val] (when (and (string? attr-val) (seq attr-val)) - (let [[ns-part loc-part] (str/split attr-val #"\|" 2) - parts (when loc-part (str/split loc-part #":")) - valid-int? (fn [s] (and s (re-matches #"\d+" s)))] - (when (and (seq parts) (valid-int? (nth parts 1 nil))) - {:ns ns-part - :file (first parts) - :line (js/parseInt (nth parts 1) 10) - :column (when (and (>= (count parts) 3) - (valid-int? (nth parts 2 nil))) - (js/parseInt (nth parts 2) 10))})))) + (let [parts (str/split attr-val #":")] + (when (= 4 (count parts)) + (let [[ns-part sym-part line-part col-part] parts + parse-int (fn [s] + (when (and (string? s) (re-matches #"\d+" s)) + (js/parseInt s 10)))] + (when (and (seq ns-part) (seq sym-part)) + {:ns ns-part + :handler-id sym-part + :line (parse-int line-part) + :col (parse-int col-part)})))))) (defn- parse-rc-src "Parse re-com's `data-rc-src` attribute into {:file :line :column}. @@ -629,8 +658,12 @@ (defn dom-source-at "Given a CSS selector (or `:last-clicked`), return the source coord - {:ns :file :line :column} attached by re-frame2's annotation or - re-com's debug path. Returns a structured result." + attached by re-frame2's annotation or re-com's debug path. Shape + depends on which attribute matched: + - re-frame2: {:ns :handler-id :line :col} (Spec 006 §Source-coord + annotation) + - re-com: {:file :line :column} + Returns a structured result wrapping the coord under :src." [selector] (if-let [el (selector-or-last-clicked selector)] (if-let [coord (read-coord-from-element el)] @@ -644,10 +677,18 @@ "Nothing clicked this session; interact with the page first, or pass a CSS selector instead.")})) (defn dom-find-by-src - "Find live DOM elements whose source-coord attributes mention - file+line. Searches both `data-rf2-source-coord` and `data-rc-src`." - [file line] - (let [needle (str file ":" line) + "Find live DOM elements whose source-coord attributes mention the + needle `:`. Searches both `data-rf2-source-coord` + and `data-rc-src`. + + For `data-rc-src` the needle pairs the source file and line. + For `data-rf2-source-coord` (whose value is `:::`, + per Spec 006 §Source-coord annotation) callers typically pass the + handler-id as the first argument so the substring match hits the + handler-id segment. Pair-shaped consumers wanting -scoped + queries should construct their own selector." + [file-or-id line] + (let [needle (str file-or-id ":" line) nodes (.querySelectorAll js/document (str "[data-rf2-source-coord*='" needle "']," "[data-rc-src*='" needle "']"))] @@ -659,11 +700,13 @@ :src (read-coord-from-element node)}))))) (defn dom-fire-click - "Synthesise a click on the element matching file+line. Picks the - first match if multiple. Returns {:ok? true :clicked {...}} — the - resulting epoch lands asynchronously, fetch it with `last-epoch`." - [file line] - (let [needle (str file ":" line) + "Synthesise a click on the element whose source-coord attribute + contains the substring `:`. Picks the first + match if multiple. Returns {:ok? true :clicked {...}} — the + resulting epoch lands asynchronously, fetch it with `last-epoch`. + See `dom-find-by-src` for the needle semantics." + [file-or-id line] + (let [needle (str file-or-id ":" line) selector (str "[data-rf2-source-coord*='" needle "']," "[data-rc-src*='" needle "']") el (.querySelector js/document selector)] @@ -673,7 +716,7 @@ {:ok? true :clicked {:tag (.toLowerCase (.-tagName el)) :id (not-empty (.-id el))}}) - {:ok? false :reason :no-element-at-src :file file :line line}))) + {:ok? false :reason :no-element-at-src :file-or-id file-or-id :line line}))) (defn dom-describe "Summarise a DOM element." diff --git a/tests/runtime/parse_rf2_coord_test.clj b/tests/runtime/parse_rf2_coord_test.clj new file mode 100644 index 0000000..1afb293 --- /dev/null +++ b/tests/runtime/parse_rf2_coord_test.clj @@ -0,0 +1,210 @@ +;;;; tests/runtime/parse_rf2_coord_test.clj +;;;; +;;;; Babashka-runnable verification of `parse-rf2-coord` from +;;;; `scripts/runtime.cljs`. +;;;; +;;;; Why a parallel implementation lives here: +;;;; +;;;; `scripts/runtime.cljs` is a CLJS-only file injected into the +;;;; browser app over nREPL. It uses `js/parseInt`, so it can't run +;;;; under bb directly. The real shadow-cljs test build (planned per +;;;; `docs/TESTING.md` §1) will exercise the `.cljs` source in place +;;;; under Node — that's the canonical home for these tests once the +;;;; build is wired up. +;;;; +;;;; Until then, this file mirrors the parser logic and asserts +;;;; behaviour against samples cribbed from re-frame2's own tests +;;;; (`implementation/reagent/test/re_frame/source_coord_dom_cljs_test.cljs` +;;;; and `implementation/core/test/re_frame/ssr_source_coord_test.clj`) +;;;; so format drift in `runtime.cljs` shows up under `bb` until the +;;;; shadow-cljs harness lands. +;;;; +;;;; Run: bb tests/runtime/parse_rf2_coord_test.clj +;;;; Exit: 0 = pass, non-zero = fail. + +(ns parse-rf2-coord-test + (:require [clojure.string :as str] + [clojure.test :refer [deftest is run-tests testing]])) + +;; --------------------------------------------------------------------------- +;; Mirror of scripts/runtime.cljs `parse-rf2-coord`. +;; +;; KEEP IN SYNC WITH scripts/runtime.cljs. Logic is identical except +;; for `js/parseInt` -> `Long/parseLong` (bb runtime). The regex, +;; segment count, and shape are the contract under test. +;; --------------------------------------------------------------------------- + +(defn- parse-int-str [s] + (when (and (string? s) (re-matches #"\d+" s)) + (Long/parseLong s))) + +(defn parse-rf2-coord + "See scripts/runtime.cljs for the canonical version. + Returns {:ns :handler-id :line :col} or nil." + [attr-val] + (when (and (string? attr-val) (seq attr-val)) + (let [parts (str/split attr-val #":")] + (when (= 4 (count parts)) + (let [[ns-part sym-part line-part col-part] parts] + (when (and (seq ns-part) (seq sym-part)) + {:ns ns-part + :handler-id sym-part + :line (parse-int-str line-part) + :col (parse-int-str col-part)})))))) + +;; --------------------------------------------------------------------------- +;; Canonical case — the DOM snippet from PR #135 (rf2-z7f7). +;; --------------------------------------------------------------------------- + +(deftest canonical-form-1 + (testing "canonical 4-segment shape from PR #135" + (is (= {:ns "counter.core" + :handler-id "counter-buttons" + :line 47 + :col 11} + (parse-rf2-coord "counter.core:counter-buttons:47:11"))))) + +;; --------------------------------------------------------------------------- +;; Real Form-1 sample shape — derived from +;; re-frame2/implementation/reagent/test/re_frame/source_coord_dom_cljs_test.cljs +;; (annotates-dom-root-without-attrs). The real test asserts the regex +;; `^rf\.src-coord-test:no-attrs:\d+:\d+$` — concrete sample below. +;; --------------------------------------------------------------------------- + +(deftest form-1-rf2-test-sample + (testing "Form-1 sample matching the rf2 test regex" + (is (= {:ns "rf.src-coord-test" + :handler-id "no-attrs" + :line 54 + :col 7} + (parse-rf2-coord "rf.src-coord-test:no-attrs:54:7"))))) + +;; --------------------------------------------------------------------------- +;; Form-2 sample shape — re-frame2 tests confirm the inner output is +;; annotated using the SAME 4-segment format (the wrapper recurses on +;; the inner fn's hiccup; the attribute value is identical to Form-1). +;; Sample: rf.src-coord-test/form-2 from source_coord_dom_cljs_test.cljs. +;; --------------------------------------------------------------------------- + +(deftest form-2-inner-output + (testing "Form-2 wrapper annotates inner hiccup with the same 4-segment shape" + (is (= {:ns "rf.src-coord-test" + :handler-id "form-2" + :line 104 + :col 5} + (parse-rf2-coord "rf.src-coord-test:form-2:104:5"))))) + +;; --------------------------------------------------------------------------- +;; SSR sample — derived from +;; re-frame2/implementation/core/test/re_frame/ssr_source_coord_test.clj +;; (reg-view-root-is-annotated). Test asserts the regex +;; `data-rf2-source-coord="rf\.ssr-coord-test:banner:\d+:\d+"`. +;; --------------------------------------------------------------------------- + +(deftest ssr-sample + (testing "SSR-rendered registered view carries the same 4-segment format" + (is (= {:ns "rf.ssr-coord-test" + :handler-id "banner" + :line 48 + :col 13} + (parse-rf2-coord "rf.ssr-coord-test:banner:48:13"))))) + +;; --------------------------------------------------------------------------- +;; Programmatic-registration degradation — Spec 006 §Attribute value +;; format: / may be `?` when the macro path was bypassed. +;; The CLJS test (programmatic-registration-degrades-gracefully) +;; asserts the literal value "rf.src-coord-test:programmatic:?:?". +;; --------------------------------------------------------------------------- + +(deftest degraded-line-col + (testing "programmatic registration with `?` placeholders parses the id portion; + line/col are nil" + (is (= {:ns "rf.src-coord-test" + :handler-id "programmatic" + :line nil + :col nil} + (parse-rf2-coord "rf.src-coord-test:programmatic:?:?")))) + + (testing "partial degradation — only column missing" + (is (= {:ns "ns.x" + :handler-id "view" + :line 42 + :col nil} + (parse-rf2-coord "ns.x:view:42:?"))))) + +;; --------------------------------------------------------------------------- +;; Malformed / edge cases — must never throw; return nil. +;; --------------------------------------------------------------------------- + +(deftest malformed-too-few-segments + (testing "too few segments returns nil" + (is (nil? (parse-rf2-coord "ns:view:42"))) + (is (nil? (parse-rf2-coord "ns:view"))) + (is (nil? (parse-rf2-coord "ns"))) + (is (nil? (parse-rf2-coord ""))))) + +(deftest malformed-too-many-segments + (testing "more than 4 segments returns nil — strict 4-segment shape" + (is (nil? (parse-rf2-coord "a:b:c:d:e"))) + (is (nil? (parse-rf2-coord "a:b:1:2:3"))))) + +(deftest empty-segments + (testing "empty or segments return nil" + (is (nil? (parse-rf2-coord ":handler:1:2"))) + (is (nil? (parse-rf2-coord "ns::1:2"))))) + +(deftest non-string-input + (testing "non-string input returns nil — never throws" + (is (nil? (parse-rf2-coord nil))) + (is (nil? (parse-rf2-coord 42))) + (is (nil? (parse-rf2-coord :keyword))) + (is (nil? (parse-rf2-coord ["v" "e" "c"]))))) + +;; --------------------------------------------------------------------------- +;; Hyphenated / dotted ids — re-frame's namespace and registry-id +;; conventions allow dots in the namespace and hyphens in the symbol. +;; --------------------------------------------------------------------------- + +(deftest dotted-and-hyphenated + (testing "dotted ns and hyphenated handler-id parse cleanly" + (is (= {:ns "my-app.cart.view" + :handler-id "apply-coupon-button" + :line 125 + :col 4} + (parse-rf2-coord "my-app.cart.view:apply-coupon-button:125:4"))))) + +;; --------------------------------------------------------------------------- +;; Round-trip (idempotence) — re-formatting a parsed coord and parsing +;; that string back yields equivalent shape (line/col only meaningful +;; when both were captured numerically). +;; --------------------------------------------------------------------------- + +(defn- format-coord + "Mirror of re-frame2/views.cljs/format-source-coord: emits the + ::: shape, with `?` for nil line/col." + [{:keys [ns handler-id line col]}] + (str ns ":" handler-id ":" + (if line (str line) "?") + ":" + (if col (str col) "?"))) + +(deftest round-trip-numeric + (testing "parse -> format -> parse is idempotent for numeric coords" + (let [s "counter.core:counter-buttons:47:11" + once (parse-rf2-coord s) + twice (parse-rf2-coord (format-coord once))] + (is (= once twice))))) + +(deftest round-trip-degraded + (testing "parse -> format -> parse is idempotent for ?:? coords" + (let [s "rf.x:programmatic:?:?" + once (parse-rf2-coord s) + twice (parse-rf2-coord (format-coord once))] + (is (= once twice))))) + +;; --------------------------------------------------------------------------- +;; Run. +;; --------------------------------------------------------------------------- + +(let [{:keys [fail error]} (run-tests 'parse-rf2-coord-test)] + (System/exit (if (and (zero? fail) (zero? error)) 0 1)))