Miguel de Benito Delgado ca6dbbe658 🚧 WIP 28, enable GPU support
2026-10-29 22:57:42 +01:00

895 lines
43 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns performance-tests.render-wasm.lifecycle-test
"Tests the browser bridge with fake DOM and renderer boundaries.
It covers bridge input, ownership, disposal and `load-scene` with fake
canvases and a fake Emscripten factory. Progressive frames use an optional
clock at the browser boundary."
(:require
[app.common.render-wasm.wasm :as wasm]
[app.common.transit :as transit]
[benches.render-wasm.browser.bridge :as browser]
[benches.render-wasm.cases :as cases]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.measurement :as measurement]
[benches.render-wasm.report.compare :as compare]
[benches.render-wasm.report.format :as format]
[benches.render-wasm.report.summarize :as summary]
[benches.render-wasm.result :as result]
[cljs.test :as t :include-macros true]))
;; Forward declaration: entry-test helpers below build args against the
;; fake Emscripten factory URL defined alongside the fake-DOM harness.
(declare factory-url)
;; Partial-init unwinding of `init-context` is covered in
;; `frontend-tests.render-wasm.webgl-test`, which also pins the success,
;; nil-context and registration-failure cases.
(defn- load-case
"Collected `:rects/load` descriptor for entry tests."
[]
(first (cases/collect-cases {:master-seed 42 :filter "rects/load"})))
(defn- scene-args
"Transit request for `browser/load-scene`. `overrides` change the
collected case descriptor."
[seed overrides]
(let [base (or (some #(when (= (:id %) (:id overrides)) %)
(cases/collect-cases {:master-seed 42}))
(load-case))]
(transit/encode-str
{:case (merge base {:scene-seed seed} overrides)
:module-url (factory-url)
:wasm-url "./fake.wasm"})))
(defn- expect-failed-args
"Calls `load-scene` with `args` (never touching DOM/FFI: validation runs
first) and asserts a failure map. Calls `done` when finished."
[args check-phase? done]
(-> (.then (browser/load-scene args)
(fn [result]
(let [m (transit/decode-str result)]
(t/is (= "failed" (:status m)))
(when check-phase?
(t/is (= "invalid-args" (:phase m))))
(done))))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))
(t/deftest invalid-seed-fails-before-timers
(t/async done
(expect-failed-args (scene-args -1 {}) true done)))
(t/deftest missing-seed-fails-before-timers
(t/async done
(expect-failed-args (transit/encode-str {:case (dissoc (load-case) :scene-seed)}) true done)))
(t/deftest missing-case-fails-before-timers
(t/async done
(expect-failed-args (transit/encode-str {}) true done)))
(t/deftest malformed-transit-fails-before-timers
(t/async done
(expect-failed-args "not transit" true done)))
(t/deftest invalid-view-fails-before-timers
(t/async done
(expect-failed-args
(scene-args 42 {:view {:scale -1 :x 0 :y 0}})
true
done)))
(t/deftest invalid-viewport-fails-before-timers
(t/async done
(expect-failed-args
(scene-args 42 {:view {:scale 1 :x 0 :y 0
:viewport {:width -5}}})
true
done)))
(t/deftest missing-compiled-body-fails-before-claim
(t/async done
(expect-failed-args (scene-args 42 {:id :rects/missing}) true done)))
(t/deftest wrong-scene-body-fails-before-claim
(let [id :rects/wrong-scene
descriptor (load-case)]
(decl/register-case! {:id id :scene :other :ns "lifecycle-test"
:run! (fn [_] nil)})
(try
(let [request (transit/encode-str {:case (assoc descriptor :id id)})
parsed (@#'browser/parse-load-request request)]
(t/is (= "invalid-args" (get-in parsed [:error :phase]))))
(finally
(decl/unregister-case! id)))))
(t/deftest non-finite-view-values-fail-before-timers
(t/async done
(expect-failed-args (scene-args 42 {:view {:scale js/Infinity :x 0 :y 0}})
true
(fn []
(expect-failed-args (scene-args 42 {:view {:scale 1 :x js/NaN :y 0}})
true
(fn []
(expect-failed-args (scene-args 42 {:view {:scale 1 :x 0 :y js/Infinity}})
true
done)))))))
(t/deftest viewport-defaults-apply-at-use
(t/is (= {:width 1920 :height 1080 :dpr 2}
(decl/resolve-viewport {:scale 1 :x 0 :y 0})))
(t/is (= {:width 800 :height 600 :dpr 2}
(decl/resolve-viewport {:scale 1 :x 0 :y 0
:viewport {:width 800 :height 600}}))))
(t/deftest dispose-resets-lifecycle-state
(set! wasm/internal-module #js {})
(set! wasm/context-initialized? true)
(reset! wasm/context-lost? true)
(set! wasm/gl-context-handle 7)
(set! wasm/gl-context #js {})
(set! wasm/canvas #js {})
(let [removed (atom false)
fake #js {:remove (fn [] (reset! removed true))}]
(reset! @#'browser/canvas* fake)
(let [result (browser/dispose!)]
(t/is (= "disposed" (unchecked-get result "status")))
(t/is (nil? wasm/internal-module))
(t/is (false? wasm/context-initialized?))
(t/is (false? @wasm/context-lost?))
(t/is (nil? wasm/gl-context-handle))
(t/is (nil? wasm/gl-context))
(t/is (nil? wasm/canvas))
(t/is (nil? @@#'browser/canvas*))
(t/is (true? @removed) "canvas is removed from the DOM")))
(t/testing "double dispose is idempotent"
(let [result (browser/dispose!)]
(t/is (= "disposed" (unchecked-get result "status")))
(t/is (nil? wasm/internal-module))))
(t/testing "dispose bumps the owner epoch"
(let [before @@#'browser/owner-epoch*]
(browser/dispose!)
(t/is (< before @@#'browser/owner-epoch*)))))
(t/deftest stale-work-never-disposes-the-newer-owner
(let [guard @#'browser/guard-current!
terminate @#'browser/terminal-failure
epoch @@#'browser/owner-epoch*]
(t/testing "guard passes for the current owner"
(t/is (nil? (guard epoch "module-init"))))
(t/testing "guard throws stale once superseded"
(browser/dispose!)
(let [thrown (try
(guard epoch "module-init")
nil
(catch :default cause
cause))]
(t/is (true? (:benches.render-wasm.failures/stale (ex-data thrown)))
"the marker lives in ex-data, never in message text")))
(t/testing "terminal stale resolves without disposal"
(let [before @@#'browser/owner-epoch*
result (terminate epoch
(ex-info "superseded"
{:benches.render-wasm.failures/stale true
:phase "upload"})
"upload")
after @@#'browser/owner-epoch*]
(t/is (= "stale" (:status result)))
(t/is (= before after) "no epoch bump: the newer owner is untouched")))
(t/testing "terminal failure keeps its phase and disposes the current owner"
(let [current @@#'browser/owner-epoch*
result (terminate current (ex-info "upload failed" {:phase "upload"}) "aborted")]
(t/is (= "failed" (:status result)))
(t/is (= "upload" (:phase result)))
(t/is (< current @@#'browser/owner-epoch*) "current owner disposed")))
(t/testing "phaseless failures use the fallback phase"
(set! wasm/internal-module nil)
(let [current @@#'browser/owner-epoch*
result (terminate current (ex-info "boom" {}) "aborted")]
(t/is (= "failed" (:status result)))
(t/is (= "aborted" (:phase result)))
(t/is (< current @@#'browser/owner-epoch*) "current owner disposed")))))
(t/deftest bridge-exposes-executor-entries
(doseq [key ["ping" "loadScene" "prepareScene" "runPrepared" "dispose"]]
(t/is (fn? (unchecked-get browser/bridge key))
(str key " is callable through the browser ABI")))
(t/is (= "ok" (unchecked-get (.ping browser/bridge) "status"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Through-entry fake harness.
;;
;; The DOM/module boundary the fakes cover does not change with the entry:
;; fake DOM, fake Emscripten factory, call recording.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- record!
"Appends `event` (a vector) to the shared call log."
[event]
(.push (unchecked-get js/globalThis "__benchCalls") (clj->js event)))
(defn- fake-canvas
"Numbered fake canvas identified by `id`. Removal and context loss are
recorded in the shared call log. The context reports a drawing-buffer size
distinct from any canvas size, so tests prove `read-graphics` reads it from
the context rather than the canvas attributes."
([id]
(fake-canvas id nil))
([id {:keys [vendor renderer]}]
(let [debug-ext (when (and vendor renderer)
#js {:UNMASKED_VENDOR_WEBGL 1 :UNMASKED_RENDERER_WEBGL 2})
lose-ext #js {:loseContext (fn [] (record! ["lose" id]) nil)}
context #js {:getExtension (fn [name]
(case name
"WEBGL_lose_context" lose-ext
"WEBGL_debug_renderer_info" debug-ext
nil))
:getParameter (fn [parameter] (case parameter 1 vendor 2 renderer nil))
:getContextAttributes (fn []
#js {"antialias" false
"depth" true
"stencil" false
"alpha" false
"preserveDrawingBuffer" false})
:drawingBufferWidth 1234
:drawingBufferHeight 5678}]
#js {:id id
:width 0
:height 0
:style #js {}
:getContext (fn [_ _] context)
:addEventListener (fn [_ _] nil)
:remove (fn [] (record! ["remove" id]) nil)})))
(defn- install-fake-dom!
"Installs fake DOM, import, clock and scenario globals and returns a
`restore!` thunk that restores their previous values. Created canvases are
numbered from 1 per installation.
The import polyfill mirrors
`resources/polyfills/dynamicImport.js`, which ships with the app HTML but
not with the unit-test bundle."
[]
(let [prev-document (unchecked-get js/globalThis "document")
prev-window (unchecked-get js/globalThis "window")
prev-import (unchecked-get js/globalThis "dynamicImport")
prev-performance (unchecked-get js/globalThis "performance")
prev-now-fn (unchecked-get prev-performance "now")
prev-now-desc (js/Object.getOwnPropertyDescriptor prev-performance "now")
prev-now (unchecked-get js/globalThis "__benchNow")
prev-script (unchecked-get js/globalThis "__benchRenderScript")
prev-calls (unchecked-get js/globalThis "__benchCalls")
prev-factory (unchecked-get js/globalThis "__benchFactoryCalls")
prev-throw (unchecked-get js/globalThis "__benchThrowIn")
prev-gate (unchecked-get js/globalThis "__benchGateRaf")
prev-raf (unchecked-get js/globalThis "__benchRafCb")
next-id (atom 0)
document #js {:createElement (fn [_]
(let [id (swap! next-id inc)]
(record! ["create" id])
(fake-canvas id)))
:body #js {:appendChild (fn [canvas]
(record! ["append" (unchecked-get canvas "id")])
canvas)}
:querySelectorAll (fn [_] #js [])}
window #js {:requestAnimationFrame
(fn [cb]
;; Park the first frame while `__benchGateRaf`
;; is set so a second load can supersede a live,
;; active owner; later frames run synchronously.
(if (and (unchecked-get js/globalThis "__benchGateRaf")
(nil? (unchecked-get js/globalThis "__benchRafCb")))
(unchecked-set js/globalThis "__benchRafCb" cb)
(if-some [clock (unchecked-get js/globalThis "__benchNow")]
(let [timestamp (+ clock 16)]
(unchecked-set js/globalThis "__benchNow" timestamp)
(cb timestamp))
(cb 0)))
nil)}
fake-now (fn []
(if-some [clock (unchecked-get js/globalThis "__benchNow")]
clock
(.call prev-now-fn prev-performance)))
dynamic-import (fn [url] (js/eval (str "import(" (pr-str url) ")")))]
(unchecked-set js/globalThis "document" document)
(unchecked-set js/globalThis "window" window)
;; Patch `now` on the real object: shadow hangs the compiled
;; `performance.*` namespaces off the global `performance` object, so
;; replacing that global hides every `benches.render-wasm.*` var.
(js/Object.defineProperty prev-performance "now"
#js {:value fake-now :configurable true :writable true})
(unchecked-set js/globalThis "dynamicImport" dynamic-import)
(fn restore! []
(unchecked-set js/globalThis "document" prev-document)
(unchecked-set js/globalThis "window" prev-window)
(if (undefined? prev-now-desc)
(js-delete prev-performance "now")
(js/Object.defineProperty prev-performance "now" prev-now-desc))
(unchecked-set js/globalThis "__benchNow" prev-now)
(unchecked-set js/globalThis "__benchRenderScript" prev-script)
(unchecked-set js/globalThis "__benchCalls" prev-calls)
(unchecked-set js/globalThis "__benchFactoryCalls" prev-factory)
(unchecked-set js/globalThis "__benchThrowIn" prev-throw)
(unchecked-set js/globalThis "__benchGateRaf" prev-gate)
(unchecked-set js/globalThis "__benchRafCb" prev-raf)
(unchecked-set js/globalThis "dynamicImport" prev-import))))
(def ^:private factory-source
"ES module stubbing the Emscripten factory for entry tests. Records every
renderer call in `globalThis.__benchCalls`; `globalThis.__benchThrowIn`
names one module fn that throws. `globalThis.__benchRenderScript` holds
optional [frame-type, duration-ms] pairs that advance `__benchNow`; absent
a script, `_render` immediately returns Full. The instance trap answers
`undefined` for `then` and non-strings: promise assimilation would treat the
instance as a never-settling thenable and hang the load. A real 16 MiB
heap with bump allocation backs `_alloc_bytes`/`HEAPU8`, so the real batch
upload assembles bytes instead of throwing on stub returns."
(str "export default function (opts) {"
"globalThis.__benchFactoryCalls.push(opts || null);"
"var rec = globalThis.__benchCalls;"
"function push(e) { rec.push(e); }"
"var GL = {"
"registerContext: function () { push(['register']); return 7; },"
"makeContextCurrent: function () {},"
"deleteContext: function (h) { push(['delete', h]); }"
"};"
"var heapBuf = new ArrayBuffer(16777216);"
"var heapNext = 1024;"
"var base = {"
"GL: GL,"
"HEAPU8: new Uint8Array(heapBuf),"
"HEAPU32: new Uint32Array(heapBuf),"
"HEAP32: new Int32Array(heapBuf),"
"HEAPF32: new Float32Array(heapBuf),"
"_alloc_bytes: function (size) { var p = heapNext; heapNext += ((size + 3) & ~3); return p; },"
"_free_bytes: function () {},"
"_read_error_code: function () { return 0; },"
"_render: function (timestamp, flags) {"
"push(['render', timestamp, flags]);"
"var script = globalThis.__benchRenderScript;"
"if (script && script.length) {"
"var next = script.shift(); globalThis.__benchNow += next[1]; return next[0];"
"}"
"return 2;"
"},"
"_clean_up: function () { push(['clean']); return 0; }"
"};"
"var inst = new Proxy(base, {"
"get: function (t, p) {"
"if (typeof p !== 'string') return undefined;"
"if (p === 'then') return undefined;"
"if (p in t) return t[p];"
"return function () {"
"push(['call', p]);"
"if (globalThis.__benchThrowIn === p) throw new Error('fake ' + p + ' failure');"
"return 0;"
"};"
"}"
"});"
"return Promise.resolve(inst);"
"}"))
(defn- factory-url
"Data-URL Emscripten factory for entry tests. Fully hermetic: no
filesystem or network access."
[]
(str "data:text/javascript," (js/encodeURIComponent factory-source)))
(defn- reset-bench-globals!
"Resets the recording and scenario flags the fake factory reads."
[]
(unchecked-set js/globalThis "__benchCalls" #js [])
(unchecked-set js/globalThis "__benchFactoryCalls" #js [])
(unchecked-set js/globalThis "__benchThrowIn" nil)
(unchecked-set js/globalThis "__benchGateRaf" false)
(unchecked-set js/globalThis "__benchRafCb" nil)
(unchecked-set js/globalThis "__benchNow" nil)
(unchecked-set js/globalThis "__benchRenderScript" nil))
(defn- read-calls
"Call log as Clojure data."
[]
(js->clj (unchecked-get js/globalThis "__benchCalls")))
(defn- effect-count
"Counts recorded effects without filtering any returned evidence."
[calls effect]
(count (filter #(= effect (first %)) calls)))
(defn- load-result
"Decodes the bridge's Transit result for `k`. A rejected bridge arrives
as a `threw` map so the test fails with the cause attached."
[args k]
(-> (browser/load-scene args)
(.then (fn [result]
(t/is (string? result) "loadScene returns Transit")
(k (transit/decode-str result))))
(.catch (fn [cause] (k {:status "threw" :cause (str cause)})))))
(defn- with-entry-env
"Installs the fake DOM, resets bench globals, then calls `f` with a
`cleanup` thunk that disposes the owner and restores the previous globals.
Call `cleanup` after asserting, before `done`."
[f]
(let [restore-dom! (install-fake-dom!)
cleanup (fn []
(try
(browser/dispose!)
(finally
(restore-dom!))))]
(reset-bench-globals!)
(try
(f cleanup)
(catch :default cause
(cleanup)
(throw cause)))))
(t/deftest ^:async preparations-record-the-context-identity-and-software-status
(doseq [[identity software] [[{:vendor "NVIDIA" :renderer "ANGLE (NVIDIA, Vulkan)"} false]
[{:vendor "Google" :renderer "ANGLE (SwiftShader)"} true]
[{:vendor "Mesa" :renderer "llvmpipe (LLVM)"} true]
[nil false]]]
(await
(with-entry-env
(^:async fn [cleanup]
(try
(set! (.-createElement js/document) (fn [_] (fake-canvas 1 identity)))
(let [response (transit/decode-str (await (browser/prepare-scene-request (scene-args 7 {}))))
graphics (get-in response [:preparation :effective-graphics])]
(t/is (= "ok" (:status response)))
(t/is (= (or (:vendor identity) "unknown") (:vendor graphics)))
(t/is (= (or (:renderer identity) "unknown") (:renderer graphics)))
(t/is (= software (:software graphics))))
(finally
(cleanup))))))))
(t/deftest successful-load-reaches-full
(t/async done
(with-entry-env
(fn [cleanup]
(load-result (scene-args 7 {})
(fn [m]
(t/is (= "ok" (:status m)))
(t/is (= 1 (count (get-in m [:trace :slices]))))
(t/is (= 1001 (:shapes (:scene m))) "the canonical scene uploads whole")
(let [graphics (:effective-graphics m)]
(t/is (= 1234 (:drawing-buffer-width graphics))
"drawing buffer width comes from the context")
(t/is (= 5678 (:drawing-buffer-height graphics))
"drawing buffer height comes from the context")
(t/is (false? (:antialias graphics))
"context attributes pass through")
(t/is (true? (:depth graphics))
"context attributes pass through"))
(let [calls (read-calls)]
(t/is (= 1 (effect-count calls "create")) "one canvas per load")
(t/is (zero? (effect-count calls "remove")) "success keeps the owner live"))
(cleanup)
(done)))))))
(t/deftest successful-load-produces-portable-reports
(t/async done
(with-entry-env
(fn [cleanup]
(load-result
(scene-args 7 {})
(fn [evidence]
(let [case (load-case)
preparation (:preparation evidence)
run (-> (result/create-run {:run-id "load-report" :started-at "start"
:plan {:cases [case] :warmups 0 :repetitions 1}
:metadata {:git {:sha nil :dirty nil}
:build {:functional {:mode :test}}
:environment {} :scored? false}})
(result/record-preparation preparation)
(result/record-attempt (measurement/attempt (:id case) (:preparation-id preparation) false evidence))
(result/finish-run "end" {:reason :completed}))
text (result/encode run)
report (summary/summarize (result/decode text))
comparison (compare/compare-runs run run)
comparison-text (.compare browser/bridge text text (transit/encode-str {}))]
(t/is (= report (transit/decode-str (.summarize browser/bridge text))))
(t/is (= (format/format-run report) (.formatRun browser/bridge text)))
(t/is (= comparison (result/decode comparison-text)))
(t/is (= (format/format-comparison comparison) (.formatComparison browser/bridge comparison-text))))
(cleanup)
(done)))))))
(t/deftest exported-load-scene-retains-progressive-frame-evidence
(t/async done
(with-entry-env
(fn [cleanup]
(unchecked-set js/globalThis "__benchNow" 0)
(unchecked-set js/globalThis "__benchRenderScript"
#js [#js [wasm/FRAME_TYPE_PARTIAL 2]
#js [wasm/FRAME_TYPE_VIEWPORT_READY 3]
#js [wasm/FRAME_TYPE_FULL 5]])
(-> (.loadScene browser/bridge (scene-args 7 {}))
(.then (fn [text]
(t/is (string? text) "the exported loadScene returns Transit")
(let [{:keys [status slices viewport-ready-ms full-ms metrics trace scene]}
(transit/decode-str text)
retained (:slices trace)
render-calls (filterv #(= "render" (first %)) (read-calls))]
(t/is (= "ok" status))
(t/is (= {:shapes 1001 :seed 7} scene)
"the real canonical case prepares and uploads the whole scene")
(t/is (= 3 (count retained)) "all three slices cross Transit")
(t/is (= [wasm/FRAME_TYPE_PARTIAL
wasm/FRAME_TYPE_VIEWPORT_READY
wasm/FRAME_TYPE_FULL]
(mapv :frame-type retained)))
(t/is (= [0 wasm/FRAME_TYPE_PARTIAL wasm/FRAME_TYPE_VIEWPORT_READY]
(mapv :flags retained))
"ViewportReady becomes the next flags and the drain continues to Full")
(t/is (= [16 34 53] (mapv :timestamp retained))
"each continuation waits for the next frame")
(t/is (= [2 3 5] (mapv :duration-ms retained)))
(t/is (= render-calls
(mapv (fn [{:keys [timestamp flags]}]
["render" timestamp flags])
retained))
"decoded slices retain every WASM call's timestamp and flags in order")
(t/is (= slices retained) "raw and trace slices survive Transit together")
(t/is (= 37 viewport-ready-ms
(+ (:timestamp (nth retained 1))
(:duration-ms (nth retained 1))))
"viewport completion ends with the ViewportReady slice")
(t/is (= 58 full-ms
(+ (:timestamp (nth retained 2))
(:duration-ms (nth retained 2))))
"Full completion ends with the final slice")
(t/is (< viewport-ready-ms full-ms))
(t/is (= full-ms (get-in metrics [:first-render-ms :value])))
(t/is (= viewport-ready-ms (get-in metrics [:viewport-ready-ms :value])))
(t/is (= 10
(reduce + 0 (map :duration-ms retained))
(get-in metrics [:renderer-call-total-ms :value]))
"renderer-call total sums the retained calls without frame waits"))
(cleanup)
(done)))
(.catch (fn [cause]
(t/is false (str "exported progressive load failed: " cause))
(cleanup)
(done))))))))
(t/deftest invalid-args-keep-the-current-owner
(t/async done
(with-entry-env
(fn [cleanup]
(load-result (scene-args 7 {})
(fn [loaded]
(t/is (= "ok" (:status loaded)))
(let [owner @@#'browser/canvas*
epoch @@#'browser/owner-epoch*]
(load-result (transit/encode-str {:seed -1})
(fn [invalid]
(t/is (= "invalid-args" (:phase invalid)))
(t/is (identical? owner @@#'browser/canvas*))
(t/is (= epoch @@#'browser/owner-epoch*))
(t/is (zero? (effect-count (read-calls) "remove")))
(cleanup)
(done))))))))))
(t/deftest warm-pan-restores-and-reaches-full
(t/async done
(with-entry-env
(fn [cleanup]
(load-result (scene-args 7 {:id :rects/pan :context :reuse})
(fn [m]
(t/is (= "ok" (:status m)))
(t/is (= 20 (count (:cached-slices m))))
(t/is (= 100 (:settling-requested-ms m)))
(t/is (= [4] (mapv :flags (:slices m))))
(t/is (= [2] (mapv :frame-type (:slices m))))
(t/is (= 3 (effect-count (read-calls) "render"))
"setup, restore and finalization each drain once")
(cleanup)
(done)))))))
(t/deftest warm-zoom-reaches-full
(t/async done
(with-entry-env
(fn [cleanup]
(load-result (scene-args 7 {:id :rects/zoom :context :reuse})
(fn [m]
(t/is (= "ok" (:status m)))
(t/is (= 20 (count (:cached-slices m))))
(t/is (= [2] (mapv :frame-type (:slices m))))
(cleanup)
(done)))))))
(t/deftest prepared-warm-scene-runs-twice-with-one-context-and-upload
(t/async done
(with-entry-env
(fn [cleanup]
(-> (browser/prepare-scene-request
(scene-args 7 {:id :rects/pan :context :reuse
:operation {:steps 2 :dx 10 :dy 5 :settle-ms 0}}))
(.then (fn [text]
(let [prepared (transit/decode-str text)
prep (:preparation prepared)
request (transit/encode-str {:preparation-id (:preparation-id prep)})]
(t/is (= "ok" (:status prepared)))
(t/is (seq (get-in prep [:diagnostics :setup-render :slices])))
(-> (browser/run-prepared request)
(.then (fn [first-text]
(let [first-result (transit/decode-str first-text)]
(t/is (= "ok" (:status first-result)))
(t/is (not (contains? (:metrics first-result) :upload-ms)))
(t/is (= prep (:preparation first-result)))
(browser/run-prepared request))))
(.then (fn [second-text]
(let [second-result (transit/decode-str second-text)
calls (read-calls)]
(t/is (= "ok" (:status second-result)))
(t/is (= prep (:preparation second-result)))
(t/is (= 1 (effect-count calls "create")))
(t/is (= 5 (effect-count calls "render")))
(t/is (= 1 (count (filter #(= ["call" "_init_shapes_pool"] %) calls))))
(t/is (= 2 (count (:cached-slices second-result))))
(cleanup)
(done))))))))
(.catch (fn [cause]
(t/is false (str "prepared reuse failed: " cause))
(cleanup)
(done))))))))
(t/deftest fresh-preparation-runs-once-and-saves-structured-metrics
(t/async done
(with-entry-env
(fn [cleanup]
(-> (browser/prepare-scene-request (scene-args 7 {}))
(.then (fn [text]
(let [request (transit/encode-str
{:preparation-id (get-in (transit/decode-str text)
[:preparation :preparation-id])})]
(t/is (zero? (effect-count (read-calls) "render")))
(-> (browser/run-prepared request)
(.then (fn [text]
(let [evidence (transit/decode-str text)]
(t/is (= "ok" (:status evidence)))
(t/is (= (:full-ms evidence) (get-in evidence [:metrics :first-render-ms :value])))
(t/is (contains? (:metrics evidence) :upload-ms))
(t/is (not (contains? (:metrics evidence) :full-ms)))
(t/is (not (contains? evidence :first-render-ms)))
(t/is (not (contains? evidence :render-frames)))
(browser/run-prepared request))))
(.then (fn [text]
(t/is (= "invalid-args" (:phase (transit/decode-str text))))
(cleanup)
(done)))))))
(.catch (fn [cause]
(t/is false (str "fresh preparation failed: " cause))
(cleanup)
(done))))))))
(t/deftest rejected-camera-body-releases-owner
(t/async done
(with-entry-env
(fn [cleanup]
(unchecked-set js/globalThis "__benchThrowIn" "_render_from_cache")
(load-result (scene-args 7 {:id :rects/pan :context :reuse})
(fn [m]
(t/is (= "failed" (:status m)))
(t/is (= "interact" (:phase m)))
(t/is (= 1 (effect-count (read-calls) "remove")))
(t/is (nil? @@#'browser/canvas*))
(cleanup)
(done)))))))
(t/deftest rejected-import-resolves-module-init-failure
(t/async done
;; Needs the entry env for the import polyfill, even though the import
;; itself fails before touching the DOM.
(with-entry-env
(fn [cleanup]
(load-result (transit/encode-str
{:case (assoc (load-case) :scene-seed 7)
:module-url "data:text/javascript,this is not valid javascript((("})
(fn [m]
(t/is (= "failed" (:status m)))
(t/is (= "module-init" (:phase m)))
(cleanup)
(done)))))))
(defn- assert-wasm-cause
"Shared cause assertions for WASM failures through `load-scene`: plain-data
vector, wire contract, failing fn name, fake message and WASM code."
[cause fn-name message-pattern]
(t/is (vector? cause) "cause is plain-data vector")
(t/is (decl/transit-round-trips? cause) "cause satisfies wire contract")
(t/is (some #(= fn-name (:fn %)) cause) "cause names the failing WASM fn")
(t/is (some #(and (string? (:message %))
(re-find message-pattern (:message %)))
cause)
"fake message preserved")
(t/is (some #(let [code (:code %)]
(and (some? code)
(re-find #"wasm-critical" (str code))))
cause)
"WASM code preserved"))
(t/deftest set-browser-failure-releases-registered-context
(t/async done
(with-entry-env
(fn [cleanup]
(unchecked-set js/globalThis "__benchThrowIn" "_set_browser")
(load-result (scene-args 7 {})
(fn [m]
(t/is (= "failed" (:status m)))
(t/is (= "graphics-init" (:phase m)))
(t/is (= "graphics init failed" (:message m))
"top message stays phase-labeled")
(assert-wasm-cause (:cause m)
"_set_browser"
#"fake _set_browser failure")
(let [calls (read-calls)]
(t/is (= 1 (effect-count calls "register")))
(t/is (= 1 (effect-count calls "clean")) "renderer state is released")
(t/is (some #{["delete" 7]} calls) "the registered handle is deleted")
(t/is (= 1 (effect-count calls "lose")) "the browser context is released")
(t/is (= 1 (effect-count calls "remove")) "the canvas is removed"))
(t/is (nil? @@#'browser/canvas*) "no owner left behind")
(cleanup)
(done)))))))
(t/deftest upload-failure-keeps-wasm-cause
(t/async done
(with-entry-env
(fn [cleanup]
(unchecked-set js/globalThis "__benchThrowIn" "_set_view")
(load-result (scene-args 7 {})
(fn [m]
(t/is (= "failed" (:status m)))
(t/is (= "upload" (:phase m)))
(t/is (= "upload failed" (:message m))
"top message stays phase-labeled")
(assert-wasm-cause (:cause m)
"_set_view"
#"fake _set_view failure")
(cleanup)
(done)))))))
(t/deftest hostile-cause-renders-plain-data
(let [terminate @#'browser/terminal-failure
current @@#'browser/owner-epoch*
hostile (ex-info "boom"
{:fn (fn [] 1)
:code #js {:evil 1}
:type :wasm-error
:hint (apply str (repeat 1000 "x"))}
(js/Error. "inner boom"))
result (terminate current hostile "aborted")
m result
cause (:cause m)]
(t/is (= "failed" (:status m)))
(t/is (= "aborted" (:phase m)))
(t/is (= "boom" (:message m)) "top message stays as-is")
(t/is (vector? cause) "cause is plain-data vector")
(t/is (decl/transit-round-trips? cause) "hostile values become strings")
(t/is (every? (fn [entry]
(every? (fn [[_ v]]
(or (not (string? v)) (<= (count v) 500)))
entry))
cause)
"strings truncated to the hard cap")
(let [outer (first cause)]
(t/is (string? (:fn outer)) "function value stringified")
(t/is (string? (:code outer)) "host object value stringified")
(t/is (= 500 (count (:hint outer))) "long hint truncated"))
(t/is (some #(and (string? (:message %))
(re-find #"inner boom" (:message %)))
cause)
"inner message preserved"))
(t/testing "values pr-str cannot render fall back to plain strings"
(let [evil (js-obj)
_ (js/Object.defineProperty
evil "evil"
#js {:enumerable true
:get (fn [] (throw (js/Error. "evil")))})
terminate @#'browser/terminal-failure
current @@#'browser/owner-epoch*
hostile (ex-info "evil boom" {:fn evil} (js/Error. "inner"))
result (terminate current hostile "aborted")
m result
cause (:cause m)
outer (first cause)]
(t/is (= "failed" (:status m)))
(t/is (decl/transit-round-trips? cause) "throwing value still crosses the wire")
(t/is (= "unrenderable value" (:fn outer))
"stringification failure falls back")))
(t/testing "causes pr-str cannot render fall back to plain strings"
(let [evil (js-obj)
_ (js/Object.defineProperty
evil "evil"
#js {:enumerable true
:get (fn [] (throw (js/Error. "evil")))})
terminate @#'browser/terminal-failure
current @@#'browser/owner-epoch*
result (terminate current evil "aborted")
m result
cause (:cause m)]
(t/is (= "failed" (:status m)))
(t/is (decl/transit-round-trips? cause) "throwing cause still crosses the wire")
(t/is (= "unrenderable cause" (:message (first cause)))
"level fallback holds"))))
(t/deftest non-finite-failure-details-cross-transit-as-numbers
(let [terminate @#'browser/terminal-failure
current @@#'browser/owner-epoch*
failure (ex-info "bad measurement"
{:code js/NaN
:hint js/Infinity
:type js/-Infinity})
result (terminate current failure "aborted")
decoded (-> result transit/encode-str transit/decode-str)
detail (first (:cause decoded))]
(t/is (number? (:code detail)))
(t/is (js/Number.isNaN (:code detail)))
(t/is (= js/Infinity (:hint detail)))
(t/is (= js/-Infinity (:type detail)))))
(t/deftest nested-stale-marker-does-not-resolve-stale
(let [terminate @#'browser/terminal-failure
current @@#'browser/owner-epoch*
nested (ex-info "upload failed"
{:phase "upload"}
(ex-info "superseded"
{:benches.render-wasm.failures/stale true
:phase "upload"}))
result (terminate current nested "aborted")
m result]
(t/is (= "failed" (:status m)) "only the top level decides staleness")
(t/is (= "upload" (:phase m)))
(t/is (vector? (:cause m)) "cause still rendered")))
(t/deftest overlapping-load-keeps-the-newer-owner
(t/async done
(with-entry-env
(fn [cleanup]
(unchecked-set js/globalThis "__benchGateRaf" true)
(let [args (fn [seed] (scene-args seed {}))
first-load (browser/load-scene (args 7))]
;; Microtasks drain before this macrotask: the first load parked on
;; its first frame with a live canvas. Macrotask ordering
;; (never wall timing) sequences the assertions.
(js/setTimeout
(fn []
(t/is (some? (unchecked-get js/globalThis "__benchRafCb"))
"first load is waiting for a frame")
(let [second-load (browser/load-scene (args 8))]
(js/setTimeout
(fn []
;; The second load completed; release the parked frame so
;; the first load observes its superseded epoch.
(if-some [parked (unchecked-get js/globalThis "__benchRafCb")]
(parked 0)
(t/is false "first load never parked on a frame"))
(-> (js/Promise.all #js [first-load second-load])
(.then (fn [results]
(let [[stale ok] (mapv transit/decode-str (array-seq results))]
(t/is (= "stale" (:status stale)) "superseded load resolves stale")
(t/is (= "ok" (:status ok)) "newer owner completes")
(t/is (= 8 (:seed (:scene ok))) "the completer is the second load")
(let [calls (read-calls)]
(t/is (= 1 (effect-count calls "remove")) "superseded canvas is removed")
(t/is (some #{["remove" 1]} calls))
(t/is (nil? (some #{["remove" 2]} calls)) "newer canvas survives"))
(t/is (some? @@#'browser/canvas*) "the newer owner stays live")
(t/is (true? wasm/context-initialized?)))
(cleanup)
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(cleanup)
(done)))))
0)))
0))))))