mirror of
https://github.com/penpot/penpot.git
synced 2026-10-31 23:10:31 +00:00
895 lines
43 KiB
Clojure
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))))))
|