Skip to content

Commit e560e69

Browse files
committed
cljd AOT test: round-trip the suite through the native binary
Rework the AOT test to mirror SCI_TEST_ENV=native on the JVM. aot_main is now a generic form+ctx eval CLI (the cljd sci.impl.main). tu/eval* gets a cljd native branch that shells out to the AOT binary and parses stdout, so each eval round-trips through the native build. Interop tests carry override fns that cannot serialize, so they use sci/eval-string in-process, as on the JVM. The binary also runs --selftest for the fn-override AOT path that cannot round-trip. Verified: bugging the binary fails 169 round-tripped assertions.
1 parent 59412d8 commit e560e69

5 files changed

Lines changed: 110 additions & 64 deletions

File tree

doc/ai/0008-cljd-port.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,17 @@ map written for the JVM works on cljd if it carries `:class`.
6464

6565
This survives tree-shaking: an override body that calls a real Dart method
6666
(`(fn [s] (.toUpperCase s))`) compiles to a direct call site, so AOT keeps the
67-
method. Guarded by script/test/cljd-native (CI test-cljd job): it
68-
`dart compile exe`s test/sci/aot_main.cljd and runs the native binary, which
69-
asserts the override returns "HELLO FROM SCI" and an unlisted member is denied.
70-
This is the AOT analogue of script/test/native (GraalVM); the JIT `dart test`
71-
suite cannot catch a tree-shaken reflective path. This is why overrides are the
72-
right interop model for Dart, which has no runtime reflection.
67+
method. Guarded by script/test/cljd-native (CI test-cljd job), the AOT analogue
68+
of script/test/native (GraalVM). test/sci/aot_main.cljd is a generic form+ctx
69+
eval CLI (the cljd sci.impl.main); the script `dart compile exe`s it and:
70+
- runs `--selftest`, asserting an override survives tree-shaking and an unlisted
71+
member is denied (the fn-override path, which cannot round-trip);
72+
- runs the suite with SCI_TEST_ENV=native so tu/eval* shells out to the binary,
73+
round-tripping each eval through the native build (like ./sci on the JVM). A
74+
ctx carrying override fns cannot serialize, so those tests use sci/eval-string
75+
(in-process), matching the JVM. The JIT `dart test` suite cannot catch a
76+
tree-shaken reflective path; this can. This is why overrides are the right
77+
interop model for Dart, which has no runtime reflection.
7378

7479
Non-record deftype works on cljd: the cljs arm
7580
of analyze-deftype* became :default (SciType path, platform-neutral).

script/test/cljd-native

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
set -eo pipefail
44

5-
# AOT test for ClojureDart: dart compile exe a non-interactive sci-interop
6-
# program and run the native binary. Verifies the reflection-free interop model
7-
# survives Dart AOT tree-shaking, which the JIT `dart test` suite cannot catch.
8-
# Analogous to script/test/native (GraalVM). Run after script/test/cljd, which
9-
# scaffolds the cljd project (pubspec.yaml, deps).
5+
# AOT test for ClojureDart: round-trip the test suite through a native binary,
6+
# the cljd analogue of SCI_TEST_ENV=native on the JVM (which shells out ./sci).
7+
# sci.aot-main is a generic form+ctx eval CLI; script AOT-compiles it and runs
8+
# the suite with SCI_TEST_ENV=native so tu/eval* shells out to it. Tests whose
9+
# ctx carries override fns cannot serialize (same limit as the JVM) and use
10+
# sci/eval-string directly, running in-process. Run after script/test/cljd,
11+
# which scaffolds the cljd project.
1012

1113
if [ ! -f pubspec.yaml ]; then
1214
echo 'No pubspec.yaml. Run script/test/cljd first to scaffold the cljd project.' >&2
@@ -18,17 +20,21 @@ mkdir -p bin
1820
echo 'Compiling sci.aot-main to Dart'
1921
clojure -M:cljd compile sci.aot-main
2022

21-
echo 'AOT-compiling to a native executable'
23+
echo 'AOT-compiling the sci eval binary'
2224
echo 'export "../lib/cljd-out/sci/aot-main.dart" show main;' > bin/aot_test.dart
23-
bin_out="${TMPDIR:-/tmp}/sci-aot-test"
25+
bin_out="${TMPDIR:-/tmp}/sci-cljd-native"
2426
dart compile exe bin/aot_test.dart -o "$bin_out"
2527

26-
echo 'Running the native binary'
27-
out="$("$bin_out")"
28-
echo "$out"
29-
if echo "$out" | grep -q 'AOT-OK'; then
30-
echo 'AOT test passed'
31-
else
32-
echo 'AOT test FAILED' >&2
33-
exit 1
28+
echo 'Self-test: interop override survives AOT tree-shaking'
29+
"$bin_out" --selftest | grep -q 'SELFTEST-OK'
30+
31+
echo 'Round-tripping the suite through the native binary'
32+
export SCI_TEST_ENV=native
33+
export SCI_NATIVE_BIN="$bin_out"
34+
# Namespaces whose tu/eval* ctx is plain data. fn-opts tests use sci/eval-string
35+
# and run in-process, as on the JVM.
36+
namespaces=(sci.core-test)
37+
if [ $# -gt 0 ]; then
38+
namespaces=("$@")
3439
fi
40+
clojure -M:cljd test "${namespaces[@]}"

test/sci/aot_main.cljd

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,42 @@
11
(ns sci.aot-main
2-
"Non-interactive main for the Dart AOT test (script/test/cljd-native).
3-
Exercises the reflection-free interop model under `dart compile exe`: an
4-
override that calls a real Dart method must survive tree-shaking, and an
5-
unlisted member must be denied. Prints AOT-OK on success, AOT-FAIL otherwise,
6-
and exits non-zero on failure so the script can assert."
2+
"Generic SCI eval CLI for the Dart AOT round-trip test, the cljd analogue of
3+
sci.impl.main. Args: [form ctx-edn]. Reads the ctx as data, evals the form,
4+
prints the result with prn. Errors go to stderr with a non-zero exit so the
5+
caller can distinguish them. script/test/cljd-native AOT-compiles this and
6+
tu/eval* native mode shells out to it, round-tripping the suite through the
7+
native binary (like SCI_TEST_ENV=native on the JVM)."
78
(:require ["dart:io" :as io]
9+
[edamame.core :as e]
810
[sci.core :as sci]))
911

10-
(defn main []
12+
(defn selftest
13+
"The interop override path cannot round-trip (fns do not serialize), so assert
14+
it here inside the AOT binary: an override that calls a real Dart method must
15+
survive tree-shaking, and an unlisted member must be denied."
16+
[]
1117
(let [ctx (sci/init {:classes {'String {:class String
1218
:instance-methods {'upper (fn [s] (.toUpperCase s))}}}})
13-
upper (sci/eval-string* ctx "(.upper \"hello from sci\")")
19+
upper (sci/eval-string* ctx "(.upper \"ok\")")
1420
denied (try (sci/eval-string* ctx "(.lower \"x\")")
1521
::not-denied
1622
(catch Object _ ::denied))]
17-
(if (and (= "HELLO FROM SCI" upper) (= ::denied denied))
18-
(println "AOT-OK" upper)
19-
(do (println "AOT-FAIL upper=" (pr-str upper) "denied=" (pr-str denied))
23+
(if (and (= "OK" upper) (= ::denied denied))
24+
(println "SELFTEST-OK")
25+
(do (.write io/stderr (str "SELFTEST-FAIL upper=" (pr-str upper)
26+
" denied=" (pr-str denied) "\n"))
2027
(io/exit 1)))))
28+
29+
(defn main [args]
30+
(if (= "--selftest" (first args))
31+
(selftest)
32+
(let [form (first args)
33+
ctx-str (second args)
34+
ctx (if (and ctx-str (pos? (count ctx-str)))
35+
(e/parse-string ctx-str)
36+
{})]
37+
(try
38+
(let [v (sci/eval-string form ctx)]
39+
(when (some? v) (prn v)))
40+
(catch Object e
41+
(.write io/stderr (str (or (ex-message e) e) "\n"))
42+
(io/exit 1))))))

test/sci/cljd_interop_test.cljd

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,45 @@
11
(ns sci.cljd-interop-test
2+
;; override fns in the ctx cannot serialize to the native binary, so these
3+
;; tests use sci/eval-string (in-process), not tu/eval*, matching the JVM.
24
(:require [clojure.test :refer [deftest is testing]]
5+
[sci.core :as sci]
36
[sci.test-utils :as tu]))
47

58
(deftest instance-method-override-test
69
(testing "instance method dispatches to override fn"
7-
(is (= 3 (tu/eval* "(.foo \"abc\")"
8-
{:classes {'String {:class String :instance-methods {'foo count}}}}))))
10+
(is (= 3 (sci/eval-string "(.foo \"abc\")"
11+
{:classes {'String {:class String :instance-methods {'foo count}}}}))))
912
(testing "override receives args"
10-
(is (= "abc!" (tu/eval* "(.foo \"abc\" \"!\")"
11-
{:classes {'String {:class String :instance-methods {'foo str}}}}))))
13+
(is (= "abc!" (sci/eval-string "(.foo \"abc\" \"!\")"
14+
{:classes {'String {:class String :instance-methods {'foo str}}}}))))
1215
(testing "override body calls a real host method (compiled, tree-shakeable)"
13-
(is (= "ABC" (tu/eval* "(.upper \"abc\")"
14-
{:classes {'String {:class String :instance-methods {'upper (fn [s] (.toUpperCase s))}}}}))))
16+
(is (= "ABC" (sci/eval-string "(.upper \"abc\")"
17+
{:classes {'String {:class String :instance-methods {'upper (fn [s] (.toUpperCase s))}}}}))))
1518
(testing "unlisted method throws"
1619
(is (thrown? cljd.core/ExceptionInfo
17-
(tu/eval* "(.bar \"abc\")"
18-
{:classes {'String {:class String :instance-methods {'foo (fn [_s] :x)}}}})))))
20+
(sci/eval-string "(.bar \"abc\")"
21+
{:classes {'String {:class String :instance-methods {'foo (fn [_s] :x)}}}})))))
1922

2023
(deftest instance-field-override-test
2124
(testing "instance field dispatches to override fn, which reads a real host field"
22-
(is (= 4 (tu/eval* "(.-len \"abcd\")"
23-
{:classes {'String {:class String :instance-fields {'len (fn [s] (.-length s))}}}}))))
25+
(is (= 4 (sci/eval-string "(.-len \"abcd\")"
26+
{:classes {'String {:class String :instance-fields {'len (fn [s] (.-length s))}}}}))))
2427
(testing "unlisted field throws"
2528
(is (thrown? cljd.core/ExceptionInfo
26-
(tu/eval* "(.-nope \"abcd\")"
27-
{:classes {'String {:class String :instance-fields {'len (fn [_] 1)}}}})))))
29+
(sci/eval-string "(.-nope \"abcd\")"
30+
{:classes {'String {:class String :instance-fields {'len (fn [_] 1)}}}})))))
2831

2932
(deftest static-method-override-test
3033
(testing "static method dispatches to override fn"
31-
(is (= :parsed (tu/eval* "(DateTime/parse \"2020\")"
32-
{:classes {'DateTime {:class DateTime
33-
:static-methods {'parse (fn [_ _s] :parsed)}}}})))))
34+
(is (= :parsed (sci/eval-string "(DateTime/parse \"2020\")"
35+
{:classes {'DateTime {:class DateTime
36+
:static-methods {'parse (fn [_ _s] :parsed)}}}})))))
3437

3538
(deftest static-field-override-test
3639
(testing "static field dispatches to override fn"
37-
(is (= :field (tu/eval* "DateTime/monthsPerYear"
38-
{:classes {'DateTime {:class DateTime
39-
:static-fields {'monthsPerYear (fn [_] :field)}}}})))))
40+
(is (= :field (sci/eval-string "DateTime/monthsPerYear"
41+
{:classes {'DateTime {:class DateTime
42+
:static-fields {'monthsPerYear (fn [_] :field)}}}})))))
4043

4144
(deftest named-arg-sugar-test
4245
(testing "trailing .name val pairs desugar to keyword args"
@@ -77,16 +80,16 @@
7780

7881
(deftest string-namespace-require-test
7982
(testing "a namespace registered under a string name is requirable and aliasable"
80-
(is (= "hi!" (tu/eval* "(require '[\"my.pkg\" :as p]) (p/shout \"hi\")"
81-
{:namespaces {"my.pkg" {'shout (fn [s] (str s "!"))}}}))))
83+
(is (= "hi!" (sci/eval-string "(require '[\"my.pkg\" :as p]) (p/shout \"hi\")"
84+
{:namespaces {"my.pkg" {'shout (fn [s] (str s "!"))}}}))))
8285
(testing "string require works in an ns form"
83-
(is (= 42 (tu/eval* "(ns app (:require [\"my.pkg\" :as p])) (p/answer)"
84-
{:namespaces {"my.pkg" {'answer (fn [] 42)}}}))))
86+
(is (= 42 (sci/eval-string "(ns app (:require [\"my.pkg\" :as p])) (p/answer)"
87+
{:namespaces {"my.pkg" {'answer (fn [] 42)}}}))))
8588
(testing "a string-named and a symbol-named ns coexist in loaded-libs"
8689
(is (= [1 2]
87-
(tu/eval* "(require '[\"pkg.one\" :as a] '[reg.two :as b]) [(a/x) (b/y)]"
88-
{:namespaces {"pkg.one" {'x (fn [] 1)}
89-
'reg.two {'y (fn [] 2)}}}))))
90+
(sci/eval-string "(require '[\"pkg.one\" :as a] '[reg.two :as b]) [(a/x) (b/y)]"
91+
{:namespaces {"pkg.one" {'x (fn [] 1)}
92+
'reg.two {'y (fn [] 2)}}}))))
9093
(testing "requiring the same string ns twice is idempotent"
91-
(is (= 1 (tu/eval* "(require '[\"pkg.one\" :as a]) (require '[\"pkg.one\" :as a]) (a/x)"
92-
{:namespaces {"pkg.one" {'x (fn [] 1)}}})))))
94+
(is (= 1 (sci/eval-string "(require '[\"pkg.one\" :as a]) (require '[\"pkg.one\" :as a]) (a/x)"
95+
{:namespaces {"pkg.one" {'x (fn [] 1)}}})))))

test/sci/test_utils.cljc

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
(ns sci.test-utils
2-
(:require #?@(:cljd [] :clj [[edamame.core :as edamame]])
2+
(:require #?@(:cljd [["dart:io" :as io]
3+
[edamame.core :as edamame]]
4+
:clj [[edamame.core :as edamame]])
35
#?@(:cljd [] :clj [[me.raynes.conch :refer [let-programs] :as sh]])
46
[clojure.test :as test :refer [is]]
57
;; :refer of fns from ported namespaces breaks the cljd host pass
@@ -10,18 +12,26 @@
1012
[sci.test-utils.utils :as u])
1113
#?(:cljs (:require-macros [sci.test-utils.macros])))
1214

13-
(def native? #?(:cljd false
15+
(def native? #?(:cljd (= "native" (get io/Platform.environment "SCI_TEST_ENV"))
1416
:clj (= "native" (System/getenv "SCI_TEST_ENV"))
1517
:cljs false))
1618

1719
(when native? (println "Testing native version."))
1820

1921
(defn eval* [form ctx]
20-
(if #?(:cljd true
21-
:clj (not native?)
22-
:cljs true)
22+
(if (not native?)
2323
(#?(:cljd sci/eval-string :default eval-string) (str form) ctx)
24-
#?(:clj
24+
#?(:cljd
25+
;; shell out to the AOT-compiled sci.aot-main binary, like the JVM
26+
;; SCI_TEST_ENV=native path shells out to ./sci
27+
(let [bin (get io/Platform.environment "SCI_NATIVE_BIN")
28+
result (io/Process.runSync bin [(str form) (str ctx)])]
29+
(if (zero? (.-exitCode result))
30+
(let [out (.trim (str (.-stdout result)))]
31+
(when-not (= "" out)
32+
(edamame/parse-string out {:all true :location? (constantly false)})))
33+
(throw (ex-info (str (.-stderr result)) {}))))
34+
:clj
2535
(let [v (let-programs [sci "./sci"]
2636
(try (sci (str form) (str ctx))
2737
(catch #?(:clj Exception :cljs :default) e

0 commit comments

Comments
 (0)