🔧 Add renderer benchmark runner (#12756)

Add a CLI under frontend/performance/benches/render_wasm that builds the
renderer, runs seeded scenes (rects, plus, paths, masks) in headless
Chromium and saves per-case timings.

- build, run: prepare or reuse builds; measure cases with warmups,
  repetitions, deadlines
- ab: build and compare Cargo feature configurations
- compare, merge: median changes with bootstrap CIs between saved runs,
  guarded by scene, build and settings checks
- purge: delete managed builds, runs or comparisons
- GPU runs through `run-devenv --gpu`

Lint, format and pnpm scripts now cover performance/ and its tests.

AI-assisted-by: gpt-6.1-sol, deepseek-flash, muse-spark, glm-5.3, gpt6-astra, claude-5.5-sonnet, claude-haiku-5.5
This commit is contained in:
Miguel de Benito Delgado 2026-11-12 11:02:34 +01:00 committed by GitHub
parent 8b9558b380
commit 756541939a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
102 changed files with 27497 additions and 25 deletions

View File

@ -9,7 +9,8 @@
app.common.data.macros/get-in clojure.core/get-in
app.common.data.macros/with-open clojure.core/with-open
app.common.data.macros/select-keys clojure.core/select-keys
app.common.logging/with-context clojure.core/do}
app.common.logging/with-context clojure.core/do
benches.render-wasm.errors/attempt clojure.core/do}
:hooks
{:analyze-call
@ -24,7 +25,11 @@
shadow.lazy/loadable hooks.export/rumext-lazycomponent
app.util.i18n/tr hooks.i18n/tr-dynamic
app.common.i18n/tr hooks.i18n/tr-dynamic
}}
benches.render-wasm.declarations/defscene hooks.benches/defscene
benches.render-wasm.declarations/defcase hooks.benches/defcase
benches.render-wasm.runtime.commands/defcommand hooks.benches/defcommand
benches.render-wasm.runtime.protocol/resolve-or-reject hooks.benches/resolve-or-reject
benches.render-wasm.runtime.protocol/resolve-or-reject-with hooks.benches/resolve-or-reject-with}}
:output
{:exclude-files
@ -36,7 +41,7 @@
"^(?:backend|frontend|exporter|common)/deps.edn"
"^(?:backend|frontend|exporter|common)/scripts/"
"^(?:backend|frontend|exporter|common)/dev/"
"^(?:backend|frontend|exporter|common)/test/"]
"^(?:backend|frontend|exporter|common)/test/(?!performance_tests/)"]
:linter-name true}
@ -101,6 +106,4 @@
:unused-binding
{:exclude-destructured-as true
:exclude-destructured-keys-in-fn-args false
}
}}
:exclude-destructured-keys-in-fn-args false}}}

View File

@ -0,0 +1,77 @@
;; 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 hooks.benches
(:require [clj-kondo.hooks-api :as api]))
(defn defscene
"Analyzes scene options and its compiled builder body."
[{:keys [node]}]
(let [[_ _ opts argv & body] (:children node)]
(if (and argv (api/vector-node? argv))
{:node (api/list-node [(api/token-node (quote do)) opts
(api/list-node (into [(api/token-node (quote fn)) argv] body))])}
{:node node})))
(defn defcase
"Analyzes case options and its compiled operation body."
[{:keys [node]}]
(let [[_ _ opts argv & body] (:children node)]
(if (and (seq body) argv (api/vector-node? argv))
{:node (api/list-node [(api/token-node (quote do)) opts
(api/list-node (into [(api/token-node (quote fn)) argv] body))])}
{:node node})))
(defn defcommand
"Analyzes command options and both generated functions."
[{:keys [node]}]
(let [[_ command & forms] (:children node)
[doc forms] (if (string? (api/sexpr (first forms)))
[(first forms) (next forms)]
[nil forms])
[options forms] (if (api/map-node? (first forms))
[(first forms) (next forms)]
[nil forms])
[argv & body] forms
[conditions body] (if (and (api/map-node? (first body)) (next body))
[(first body) (next body)]
[nil body])
handlers (:async (some-> options api/sexpr))
core (api/list-node
(into [(api/token-node 'fn)
(api/vector-node (mapv api/token-node handlers))]
body))
starred (api/token-node (symbol (str (api/sexpr command) "*")))
measured (get (some-> options api/sexpr) :measured-variant true)
definition (api/list-node
(into [(api/token-node 'defn) command]
(concat (when doc [doc]) [argv]
(when conditions [conditions]) [core])))]
{:node (api/list-node
(into [(api/token-node 'do)]
(concat (when options [(api/coerce (dissoc (api/sexpr options) :async))])
[definition]
(when measured
[(api/list-node [(api/token-node 'def)
starred command])]))))}))
(defn resolve-or-reject
"Analyzes the body of a Promise-returning form."
[{:keys [node]}]
(let [[_ & body] (:children node)]
{:node (api/list-node (into [(api/token-node (quote do))] body))}))
(defn resolve-or-reject-with
"Analyzes the cause handler and body. Binds `cause` in the handler only."
[{:keys [node]}]
(let [[_ cause on-error & body] (:children node)]
{:node (api/list-node
(into [(api/token-node (quote do))
(api/list-node
[(api/token-node (quote let))
(api/vector-node [cause (api/token-node nil)])
on-error])]
body))}))

View File

@ -1,3 +1,9 @@
;; 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 hooks.export
(:require [clj-kondo.hooks-api :as api]))

View File

@ -10,5 +10,7 @@
:extra-indents {rumext.v2/fnc [[:inner 0]]
cljs.test/async [[:inner 0]]
promesa.exec/thread [[:inner 0]]
specify! [[:inner 0] [:inner 1]]}
specify! [[:inner 0] [:inner 1]]
benches.render-wasm.runtime.commands/defcommand [[:inner 0]]
benches.render-wasm.runtime.protocol/resolve-or-reject-with [[:inner 0]]}
}

View File

@ -1,4 +1,4 @@
{:paths ["src" "vendor" "resources" "test"]
{:paths ["src" "vendor" "resources" "test" "performance"]
:deps
{;; Carries `app.common.render-wasm.*`, shared with the headless exporter.
penpot/common
@ -37,8 +37,7 @@
instaparse/instaparse {:mvn/version "1.5.0"}
garden/garden {:git/url "https://github.com/noprompt/garden"
:git/sha "05590ecb5f6fa670856f3d1ab400aa4961047480"}
}
:git/sha "05590ecb5f6fa670856f3d1ab400aa4961047480"}}
:aliases
{:outdated
@ -67,4 +66,7 @@
"-Dpenpot.wasm.profile-marks=true"
"-XX:+UnlockExperimentalVMOptions"
"-XX:CompileCommand=blackhole,criterium.blackhole.Blackhole::consume"]}
}}
:renderer-bench
{:main-opts ["-m" "shadow.cljs.devtools.cli"]
:jvm-opts ["-Dpenpot.wasm.profile-marks=false"]}}}

View File

@ -20,22 +20,27 @@
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
"build:wasm": "../render-wasm/build frontend",
"build:renderer-benchmarks:runner": "clojure -M:dev:renderer-bench release bench-render-wasm-commands bench-render-wasm-runner",
"render-bench": "sh -c 'w=; [ \"${PENPOT_GPU:-0}\" = 1 ] && w=\"xvfb-run -a\"; exec $w node target/performance/render-wasm/runner.cjs \"$@\"' render-bench",
"build:app:libs": "node ./scripts/build-libs.js",
"build:app:main": "clojure -M:dev:shadow-cljs release main worker",
"build:app:worker": "clojure -M:dev:shadow-cljs release worker",
"build:app": "pnpm run clear:shadow-cache && pnpm run build:app:main && pnpm run build:app:libs",
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"check-fmt:clj": "cljfmt check --parallel=true src/ performance/ test/",
"check-fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js",
"check-fmt:scss": "prettier -c resources/styles -c src/**/*.scss",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:clj": "cljfmt fix --parallel=true src/ performance/ test/",
"fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
"fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w",
"lint:clj": "clj-kondo --fail-level error --config-dir ../.clj-kondo --lint ../common/src src/",
"lint:clj": "clj-kondo --fail-level error --config-dir ../.clj-kondo --lint ../common/src src/ performance/ test/performance_tests/render_wasm/",
"lint:js": "exit 0",
"lint:renderer-benchmarks": "clj-kondo --config-dir ../.clj-kondo --lint performance/ test/performance_tests/render_wasm/",
"lint:scss": "pnpm exec stylelint '{src,resources}/**/*.scss'",
"build:test": "pnpm run build:wasm && clojure -M:dev:shadow-cljs compile test",
"build:renderer-benchmarks:tests": "clojure -M:dev:shadow-cljs compile performance-tests",
"test": "pnpm run build:test && node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js",
"test:renderer-benchmarks": "pnpm run build:renderer-benchmarks:tests && node target/performance-tests/test.js",
"test:storybook": "vitest run --project=storybook",
"watch:test": "mkdir -p target/tests && concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests --exec 'node target/tests/test.js'\"",
"test:e2e": "playwright test --project default",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Renderer benchmarks</title>
<style>
/* The canvas is the page: kill the UA body margin (8px) that would
leave a gutter around the scene in screenshots. */
html,
body {
margin: 0;
padding: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<!-- Same one-liner as frontend/resources/polyfills/dynamicImport.js:
the bench bundle is plain ESM and must not depend on app boot. -->
<script>
globalThis.dynamicImport = function (u) {
return import(u);
};
</script>
<script type="module">
import bridge from "/browser/browser.js";
window.__benchBridge = bridge;
window.__benchReady = true;
</script>
</body>
</html>

View File

@ -0,0 +1,512 @@
;; 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 benches.render-wasm.builder
"Seeded construction of benchmark scenes.
A `scene` consists of one seeded number generator, one object map, and one
label map.
Shape and container calls attach objects to the current parent in evaluation
order. Coordinates are page-relative. Frames use authored bounds; groups and
booleans derive geometry from their children after the body completes.
`scene` accepts a required :seed, optional :root attributes and :defaults.
It returns a validated snapshot with :objects and :refs. Labels name objects
within that snapshot. A container whose body or finalizer throws becomes
unfinished, and `finish!` rejects the snapshot.
Default attributes follow the same theme: `*defaults*` maps a shape type to
an attribute generator map, bound by the `scene` macro from the scope
params. Per-shape attrs are merged over the generated values, so a recipe
can override values locally and rebind `*defaults*` around a section.
Ordinary :require loads the macros and runtime helpers. Scene bodies support
let, doseq and calls to functions defined outside the scope."
#?(:cljs (:require-macros [benches.render-wasm.builder]))
#?(:cljs
(:require
[app.common.data :as d]
[app.common.files.helpers :as cfh]
[app.common.geom.shapes :as gsh]
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
[benches.render-wasm.random :as rnd :refer
[rng-float rng-hex-color rng-int rng-opacity rng-uuid]]
[benches.render-wasm.snapshot :as snapshot])))
#?(:clj
(do
(defn- emit-shape
[constructor label attrs]
`(~constructor
benches.render-wasm.builder/*state*
benches.render-wasm.builder/*defaults*
~label
~attrs))
(defn- emit-container
[type label-or-attrs body]
(let [labeled? (or (keyword? label-or-attrs) (vector? label-or-attrs))
attrs (if labeled? (first body) label-or-attrs)
label (when labeled? label-or-attrs)
body (if labeled? (rest body) body)]
`(benches.render-wasm.builder/with-container
~type ~attrs ~label (fn [] ~@body))))
(defmacro scene
"Runs `body` in a scene scope and returns the validated snapshot."
[params & body]
`(let [params# ~params
state# (benches.render-wasm.builder/start params#)]
(binding [benches.render-wasm.builder/*state* state#
benches.render-wasm.builder/*defaults* (:defaults params#)]
~@body
(benches.render-wasm.builder/finish! state#))))
(defmacro rect
"Adds a rectangle and returns its uuid.
Forms: (rect), (rect attrs), (rect label), (rect label attrs).
A single map argument supplies attributes; keywords and vectors label
the object in :refs. Attributes override the generated defaults."
([]
(emit-shape 'benches.render-wasm.builder/rect! nil {}))
([label-or-attrs]
(emit-shape 'benches.render-wasm.builder/rect! label-or-attrs {}))
([label attrs]
(emit-shape 'benches.render-wasm.builder/rect! label attrs)))
(defmacro path
"Adds a path and returns its uuid.
Forms: (path), (path attrs), (path label), (path label attrs).
A single map argument supplies attributes; keywords and vectors label
the object in :refs. Attributes override the generated defaults."
([]
(emit-shape 'benches.render-wasm.builder/path! nil {}))
([label-or-attrs]
(emit-shape 'benches.render-wasm.builder/path! label-or-attrs {}))
([label attrs]
(emit-shape 'benches.render-wasm.builder/path! label attrs)))
(defmacro circle
"Adds an ellipse and returns its uuid.
Forms: (circle), (circle attrs), (circle label), (circle label attrs).
A single map argument supplies attributes; keywords and vectors label
the object in :refs. Attributes override the generated defaults."
([]
(emit-shape 'benches.render-wasm.builder/circle! nil {}))
([label-or-attrs]
(emit-shape 'benches.render-wasm.builder/circle! label-or-attrs {}))
([label attrs]
(emit-shape 'benches.render-wasm.builder/circle! label attrs)))
(defmacro frame
"Adds a frame, runs `body` with the frame as parent, and returns its uuid.
Forms: (frame attrs & body), (frame label attrs & body).
A keyword or vector form supplies a label in :refs. Attributes must
carry numeric :x :y :width :height. Children use page-relative
coordinates. Frames allow an empty body."
[label-or-attrs & body]
(emit-container :frame label-or-attrs body))
(defmacro group
"Adds a group, runs `body` with the group as parent, and returns its uuid.
Forms: (group attrs & body), (group label attrs & body).
A keyword or vector form supplies a label in :refs. Groups derive their
geometry from children and require at least one child. With
:masked-group true, the first child supplies the mask geometry."
[label-or-attrs & body]
(emit-container :group label-or-attrs body))
(defmacro bool
"Adds a boolean shape, runs `body` with it as parent, and returns its uuid.
Forms: (bool attrs & body), (bool label attrs & body).
A keyword or vector form supplies a label in :refs. Attributes require
:bool-type :union, :difference, :intersection or :exclude.
Content and geometry derive from the children. Styles inherit from the
first child for :difference and the last child otherwise; supplied
non-nil styles take precedence. A bool requires at least one child and
accepts paths, rectangles, ellipses, groups and other booleans."
[label-or-attrs & body]
(emit-container :bool label-or-attrs body))))
#?(:cljs
(do
;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Attribute generators
;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn gen-int
"Integer generator over `[min, max)`."
[min max]
(fn [rng] (rng-int rng min max)))
(defn gen-float
"Float generator rounded to three decimals so snapshots stay readable."
[min max]
(fn [rng] (rnd/round3 (rng-float rng min max))))
(defn gen-hex-color
"Generator of a random `#rrggbb` string."
[]
(fn [rng] (rng-hex-color rng)))
(defn gen-one-of
"Generator that picks one value per draw. Rejects an empty collection."
[values]
(when (empty? values)
(throw (ex-info "gen-one-of requires at least one value"
{:type ::no-values})))
(fn [rng] (nth values (rng-int rng 0 (count values)))))
(defn gen-fill
"Generator of one translucent solid fill."
[]
(fn [rng]
{:fill-color (rng-hex-color rng)
:fill-opacity (rng-opacity rng)}))
(defn gen-stroke
"Centered stroke like the interim rectangle workload."
[width]
(fn [rng]
{:stroke-width width
:stroke-alignment :center
:stroke-color (rng-hex-color rng)
:stroke-opacity (rng-opacity rng)}))
(defn gen-vector
"Composes generators into a generator of a vector, one value per generator."
[& generators]
(fn [rng] (mapv #(% rng) generators)))
;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Scope state
;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:dynamic *state*
"Current scene scope; bound by the `scene` macro."
nil)
(def ^:dynamic *defaults*
"Per-shape-type attribute generator maps bound by the `scene` macro,
e.g. `{:rect {:x (gen-int 0 1920) ...}}`. Values are literals or one-arg
functions of the scope random source."
{})
(def ^:dynamic *parent*
"Current insertion point as `{:id parent-id :frame-id containing-frame}`;
bound by the container scopes, `uuid/zero` at the top."
{:id uuid/zero :frame-id uuid/zero})
(defn- check-seed!
"Requires a scene seed from 0 through 4294967295, retaining the builder
error type."
[seed]
(when-not (rnd/seed? seed)
(throw (ex-info "scene scope requires an integer :seed in [0, 2^32)"
{:type ::invalid-seed
:seed seed}))))
(defn start
"Starts a scene scope and returns its state atom with objects, refs and :rng.
The seeded generator belongs to this scope; other scopes and report draws
cannot advance its state."
[params]
(when (some? *state*)
(throw (ex-info "scene scopes cannot nest"
{:type ::nested-scope})))
(check-seed! (:seed params))
(let [seed (:seed params)
root (cts/setup-shape (merge (:root params)
{:id uuid/zero
:type :frame
:name "Root Frame"
:parent-id uuid/zero
:frame-id uuid/zero
:shapes []}))]
(atom {:seed seed
:rng (rnd/create seed)
:objects {uuid/zero root}
:refs {}
:unfinished {}})))
(defn- ensure-state
[state]
(or state
(throw (ex-info "shape used outside a scene scope"
{:type ::outside-scope}))))
(defn- resolve-attr
[rng spec]
(if (fn? spec) (spec rng) spec))
(defn- resolve-attrs
[rng defaults attrs]
(merge (reduce-kv (fn [result key spec]
(assoc result key (resolve-attr rng spec)))
{}
defaults)
attrs))
(defn- register-ref!
[state label id]
(when (some? label)
(when (contains? (:refs @state) label)
(throw (ex-info (str "duplicate scene label: " label)
{:type ::duplicate-label
:label label})))
(swap! state assoc-in [:refs label] id)))
(defn- add-object!
"Inserts `shape` under the current parent and registers `label`."
[state label shape]
(let [{:keys [id frame-id]} *parent*]
(register-ref! state label (:id shape))
(swap! state
#(ctst/add-shape (:id shape) shape % frame-id id nil true))))
;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Shape constructors
;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- normalize-label-and-attrs
"Resolves map attributes and keyword/vector labels for shape construction."
[label attrs]
(cond
(and (map? label) (empty? attrs)) [nil label]
(nil? label) [nil attrs]
(or (keyword? label) (vector? label)) [label attrs]
:else (throw (ex-info "rect label must be a keyword or vector"
{:type ::invalid-label
:label label}))))
(defn- construct-shape
[state defaults type label attrs]
(let [state (ensure-state state)
[label attrs] (normalize-label-and-attrs label attrs)
rng (:rng @state)
attrs (resolve-attrs rng (get defaults type) attrs)
id (rng-uuid rng)
shape (cts/setup-shape (merge attrs
{:id id
:type type}))]
(add-object! state label shape)
id))
(defn rect!
"Adds a rectangle with the selected defaults and attributes, returning its uuid."
[state defaults label attrs]
(construct-shape state defaults :rect label attrs))
(defn path!
"Adds a path with the selected defaults and attributes, returning its uuid."
[state defaults label attrs]
(construct-shape state defaults :path label attrs))
(defn circle!
"Adds an ellipse with the selected defaults and attributes, returning its uuid."
[state defaults label attrs]
(construct-shape state defaults :circle label attrs))
(def ^:private frame-bounds-keys
"Frames do not derive their bounds from children."
[:x :y :width :height])
(def ^:private group-geometry-keys
"Group geometry derives from its children."
[:x :y :width :height :selrect :points])
(def ^:private bool-geometry-keys
"Bool geometry, content and placement derive from its children."
[:x :y :width :height :selrect :points :content])
(def ^:private bool-transform-keys
"Construction does not accept transforms: `path/update-geometry` would
pivot them on the empty pre-finalization selrect."
[:transform :transform-inverse :rotation :flip-x :flip-y])
(defn- reject-attrs!
"Throws when `attrs` carries any of `attr-keys`. Shared by containers
whose geometry or content derives from their children."
[attrs id error-type message attr-keys]
(doseq [key attr-keys]
(when (some? (get attrs key))
(throw (ex-info message
{:type error-type
:id id
:key key})))))
(defn- setup-frame
[id attrs]
(doseq [key frame-bounds-keys]
(let [value (get attrs key)]
(cond
(nil? value)
(throw (ex-info "frame requires :x :y :width :height"
{:type ::frame-bounds
:id id
:missing key}))
(not (d/num? value))
(throw (ex-info "frame bounds must be numbers"
{:type ::frame-bounds
:id id
:invalid key})))))
(cts/setup-shape (assoc attrs :id id :type :frame)))
(defn- setup-group
[id attrs]
(reject-attrs! attrs id ::group-geometry
"group derives its geometry from children"
group-geometry-keys)
(cts/setup-shape (assoc attrs :id id :type :group)))
(defn- setup-bool
[id attrs]
(reject-attrs! attrs id ::bool-geometry
"bool derives its geometry and content from children"
bool-geometry-keys)
(reject-attrs! attrs id ::bool-transform
"bool construction does not accept transforms"
bool-transform-keys)
(when-not (contains? cts/bool-types (:bool-type attrs))
(throw (ex-info "bool requires :bool-type :union :difference :intersection or :exclude"
{:type ::bool-type
:id id
:bool-type (:bool-type attrs)
:supported cts/bool-types})))
(cts/setup-shape (assoc attrs :id id :type :bool)))
(defn- bool-head
"Canonical style head: the first child is the base for `:difference`, the
last child otherwise."
[bool-shape children]
(if (= :difference (:bool-type bool-shape))
(first children)
(last children)))
(defn- finalize-bool!
[state id]
(let [scope @state
bool (get-in scope [:objects id])
children (mapv (:objects scope) (:shapes bool))]
(when (empty? children)
(throw (ex-info "bool requires at least one child"
{:type ::empty-bool
:id id})))
(when-let [frame (first (filter cfh/frame-shape? children))]
(throw (ex-info "bool children cannot be frames"
{:type ::frame-in-bool
:id id
:child (:id frame)})))
(let [inherited (d/without-nils
(select-keys (bool-head bool children)
path/bool-style-properties))
supplied (d/without-nils
(select-keys (get-in scope [:container-attrs id])
path/bool-style-properties))
bool (-> bool (merge inherited) (merge supplied))
;; `path/update-bool-shape` dispatches to `path/wasm:calc-bool-content`,
;; the renderer override, so scene content would depend on renderer
;; initialization and A/B configuration. Compose the pure helpers:
;; scene generation is untimed and must not vary with the renderer
;; being compared.
content (path/calc-bool-content bool (:objects scope))
bool (path/update-geometry bool content)]
(swap! state assoc-in [:objects id] bool))))
(defn- finalize-group!
[state id]
(let [scope @state
group (get-in scope [:objects id])
children (mapv (:objects scope) (:shapes group))]
(when (empty? children)
(throw (ex-info "group requires at least one child"
{:type ::empty-group
:id id})))
(let [group (if (:masked-group group)
(gsh/update-mask-selrect group children)
(gsh/update-group-selrect group children))]
(swap! state assoc-in [:objects id] group))))
(def ^:private container-specs
"Per-container behavior: how to build it, which parent scope it opens,
and how to finalize it after the body. `:keep-attrs?` records the raw
attrs in the scope so the finalizer can let supplied styles win over
inherited ones."
{:frame {:setup setup-frame
:parent (fn [id _parent] {:id id :frame-id id})
:finalize (fn [_state _id] nil)}
:group {:setup setup-group
:parent (fn [id parent] {:id id :frame-id (:frame-id parent)})
:finalize finalize-group!}
:bool {:setup setup-bool
:parent (fn [id parent] {:id id :frame-id (:frame-id parent)})
:finalize finalize-bool!
:keep-attrs? true}})
(defn- check-container-attrs
[type id attrs]
(when-not (map? attrs)
(throw (ex-info (str (name type) " attrs must be a map; the label form takes the attrs map next")
{:type ::invalid-attrs :id id})))
attrs)
(defn with-container
"Creates a frame, group or bool, runs `thunk` with the new parent in
scope, finalizes the containers that derive state from children, and
returns the container uuid.
Any exception from the body or the finalizer marks the container
unfinished and propagates; `finish!` then rejects the instance even if the
caller catches the exception."
[type attrs label thunk]
(let [{:keys [setup parent finalize keep-attrs?]}
(or (get container-specs type)
(throw (ex-info "unsupported container type"
{:type ::invalid-container
:container-type type})))
state (ensure-state *state*)
id (rng-uuid (:rng @state))
attrs (check-container-attrs type id attrs)
shape (setup id attrs)]
(when keep-attrs?
(swap! state assoc-in [:container-attrs id] attrs))
(add-object! state label shape)
(binding [*parent* (parent id *parent*)]
(try
(thunk)
(finalize state id)
(catch :default cause
(swap! state assoc-in [:unfinished id] true)
(throw cause))))
id))
(defn finish!
"Validates the scope snapshot and returns it."
[state]
(let [{:keys [objects refs unfinished]} @state]
(when (seq unfinished)
(throw (ex-info "scene contains an unfinished container"
{:type ::unfinished-container
:ids (vec (keys unfinished))})))
(let [instance {:objects objects
:refs refs}]
(snapshot/validate! instance)
instance)))))

View File

@ -0,0 +1,128 @@
;; 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
;; Discovery loads each scene and its transitive requires, including any
;; colocated operation functions.
;; Keep this entire import subgraph safe for Node.
(ns benches.render-wasm.cases
"Case collection for the renderer benchmarks.
Loading this namespace registers the scene namespaces listed here. The require
list is the discovery manifest for case collection.
NOTE: Nothing here should import browser or renderer code, so the Node runner
and offline reports load scenes without requiring a browser.
Usage:
(cases/collect-cases {:master-seed 42})
;; =>
[{:id :rects/load
:scene :rects
:scene-description \"Seeded rectangles with translucent fills and centered strokes\"
:scene-seed 2602546180
:params {:count 1000 :width 1920 :height 1080 :min-size 20 :max-size 100}
:view {:scale 1 :x 0 :y 0}
:context :fresh
:preparation {:render :none}}
{:id :rects/pan ...}
{:id :rects/zoom ...}]"
(:require
[app.common.schema :as sm]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.random :as random]
benches.render-wasm.scenes.masks
benches.render-wasm.scenes.paths
benches.render-wasm.scenes.plus
benches.render-wasm.scenes.rects
[clojure.string :as str]))
(defn- check-master-seed!
"Collection requires an integer master seed in [0, 2^32)."
[seed]
(when-not (random/seed? seed)
(throw (ex-info "case collection requires an integer master seed in [0, 2^32)"
{:type ::invalid-master-seed
:seed seed}))))
(defn- as-invalid-case!
"Runs `thunk`, translating core case-validation failures into this
namespace's ::invalid-case so `collect-cases` has a single error
contract. Registry mechanics (::duplicate-*, ::unknown-scene) and scene
declarations (::invalid-scene) keep their own types."
[thunk]
(try
(thunk)
(catch :default cause
(let [data (ex-data cause)]
(if (contains? #{::decl/invalid-case ::decl/non-serializable-case} (:type data))
(throw (ex-info (ex-message cause)
(assoc data :type ::invalid-case)
cause))
(throw cause))))))
(defn- check-params!
"Validates case params against the scene's closed schema."
[case-id scene-id schema params]
(when-not (sm/validate schema params)
(throw (ex-info (str "invalid params for case " case-id)
{:type ::invalid-case
:id case-id
:scene scene-id
::sm/explain (sm/explain schema params)}))))
(defn- validate-case!
"Validates a registered case entry against its scene: unknown scene,
case-namespace mismatch, declaration schema and closed params."
[{:keys [id scene params] :as registered} scene-by-id]
(let [entry (get scene-by-id scene)]
(when (nil? entry)
(throw (ex-info (str "case " id " names an unknown scene: " scene)
{:type ::unknown-scene
:id id
:scene scene})))
(when-not (= (name scene) (namespace id))
(throw (ex-info (str "case id " id " is not namespaced by its scene: " scene)
{:type ::invalid-case
:id id
:scene scene})))
(as-invalid-case! #(decl/check-registered-case! registered))
(check-params! id scene (:params-schema entry) params)
registered))
(defn- describe-case
"Projects a validated case to its collected descriptor: scene description
and seed attached, internal keys stripped, transit wire checked."
[master-seed registered scene-entry]
(let [projected (-> (decl/project-case registered)
(assoc :scene-description (:description scene-entry)
:scene-seed (decl/derive-seed master-seed
(:scene registered)
(:params registered))))]
(as-invalid-case! #(decl/check-collected-case projected))))
(defn collect-cases
"Selected benchmark cases as plain data.
Validates scenes, cases and parameters before any browser work. `:filter`
is a substring match on the case id; an empty selection throws ::no-cases."
[{:keys [master-seed] :as options}]
(check-master-seed! master-seed)
(let [pattern (:filter options)
scene-by-id (into {} (map (juxt :id identity)) (decl/scenes))]
(doseq [scene (decl/scenes)]
(decl/check-registered-scene! scene))
(let [selected (->> (decl/cases)
(map #(validate-case! % scene-by-id))
(filter #(or (str/blank? pattern)
(str/includes? (str (:id %)) pattern)))
(mapv #(describe-case master-seed % (get scene-by-id (:scene %)))))]
(when (empty? selected)
(throw (ex-info "no benchmark cases match the selection"
{:type ::no-cases
:filter pattern})))
selected)))

View File

@ -0,0 +1,84 @@
;; 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 benches.render-wasm.codec
"Verifies that measurements survive transit encoding.
This ns exists mainly to preserve NaNs in the transit encoding of results.
We treat two NaN values as the same measurement.
Any data we cannot represent raises an error."
(:require
[app.common.transit :as transit]))
(declare equivalent?)
(defn check-value
"Checks optional transport data within depth and size limits.
Args:
value: Trace or diagnostic partial data to validate.
Returns:
The supplied value when traversal stays within depth 32, 100000 visited
values and 1048576 UTF-16 text units. Sequential collections have a finite
count. Numbers retain NaN and infinities.
Throws:
An error for unsupported values or data that exceeds either limit."
[value]
(loop [pending (list [value 0])
budget 100000
text-budget 1048576]
(when (neg? text-budget)
(throw (ex-info "Transport data exceeds text limits" {:type ::data-limit})))
(if-let [[item depth] (first pending)]
(do
(when (or (zero? budget) (> depth 32))
(throw (ex-info "Transport data exceeds traversal limits" {:type ::data-limit})))
(let [children (cond
(map? item) (mapcat (fn [[key value]] [[key (inc depth)] [value (inc depth)]]) item)
(or (and (sequential? item) (counted? item)) (set? item)) (map #(vector % (inc depth)) item)
(or (nil? item) (boolean? item) (number? item) (string? item)
(keyword? item) (symbol? item) (uuid? item) (inst? item)) nil
:else (throw (ex-info "Unsupported transport value" {:type ::unsupported-value})))]
(recur (concat children (rest pending)) (dec budget)
(- text-budget (cond
(string? item) (count item)
(or (keyword? item) (symbol? item)) (+ (count (name item)) (count (namespace item)))
:else 0)))))
value)))
(defn- equivalent-entry?
"Finds a map key and compares its value, including keys that contain NaN."
[other [k v]]
(if-let [entry (find other k)]
(equivalent? v (val entry))
(boolean (some (fn [[other-k other-v]]
(and (equivalent? k other-k) (equivalent? v other-v))) other))))
(defn equivalent?
"Checks Transit value equality while retaining NaN as a valid raw value."
[a b]
(or (= a b)
(cond
(and (number? a) (number? b)) (and (js/Number.isNaN a) (js/Number.isNaN b))
(and (map? a) (map? b)) (and (= (count a) (count b))
(every? #(equivalent-entry? b %) a))
(and (sequential? a) (sequential? b)) (and (= (count a) (count b))
(every? true? (map equivalent? a b)))
(and (set? a) (set? b)) (and (= (count a) (count b))
(every? (fn [item] (some #(equivalent? item %) b)) a))
:else false)))
(defn encode-str
"Encodes through app.common.transit and rejects any loss of supplied evidence."
([value] (encode-str value nil))
([value options]
(let [text (transit/encode-str value options)]
(when-not (equivalent? value (transit/decode-str text))
(throw (ex-info "Transit cannot preserve this value" {:type ::unrepresentable-value})))
text)))

View File

@ -0,0 +1,373 @@
;; 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 benches.render-wasm.declarations
"Declarations, registry and contracts for renderer benchmark scenes and cases.
A scene declares an id, description, parameters schema and build function.
A case declares an id namespaced by its scene, parameters, view, context
policy and a run function. Optional preparation and reset functions transform
the context before and after its main execution.
Ordinary :require loads the declaration macros and runtime helpers.
Collection validates declarations and produces plain descriptors."
#?(:cljs (:require-macros
[app.common.exceptions :as ex]
[benches.render-wasm.declarations]))
#?(:clj (:require [app.common.schema :as-alias sm]))
#?(:cljs
(:require
[app.common.schema :as sm]
[app.common.transit :as t]
[clojure.string :as str])))
#?(:clj
(do
(defn- single-arg-vector?
"True when `form` is a vector holding one plain symbol."
[form]
(and (vector? form)
(= 1 (count form))
(symbol? (first form))
(not= '& (first form))))
(defn- check-arg-vector!
[macro id argv]
(when-not (single-arg-vector? argv)
(throw (ex-info (str macro " " id " requires one argument vector with one symbol")
{:type ::invalid-run-function
:id id
:form argv}))))
(defmacro defscene
"Registers a scene and returns its id.
`opts` supplies :description and :params-schema. `argv` names one scene
parameter symbol, and `body` builds the snapshot.
Example:
(defscene :rects opts [params] (scene params))"
[id opts argv & body]
(check-arg-vector! "defscene" id argv)
`(benches.render-wasm.declarations/register-scene!
(assoc ~opts :id ~id :ns ~(str *ns*) :build (fn ~argv ~@body))))
(defn- case-registration-form
[id scene opts run-fn]
(let [options (gensym "options")]
`(let [~options ~opts]
(when (contains? ~options :run!)
(throw (ex-info (str "defcase " ~id " requires its run function as the fourth argument")
{:type :benches.render-wasm.declarations/invalid-run-function
:id ~id})))
(when (nil? ~scene)
(throw (ex-info (str "defcase " ~id " requires a :scene/case id")
{:type :benches.render-wasm.declarations/invalid-run-function
:id ~id
:form ~id})))
(benches.render-wasm.declarations/register-case!
(assoc ~options :id ~id :scene ~scene :ns ~(str *ns*)
~@(when run-fn [:run! run-fn]))))))
(defn- scene-from-id
"Returns the scene keyword from a qualified case id, or nil."
[id]
(when-let [scene-ns (namespace id)]
(keyword scene-ns)))
(defmacro defcase
"Registers a case and returns its id.
The scene comes from the namespace part of the :scene/case id. An
optional argument vector and body supply the run function, which accepts
one command context and returns its next context or a Promise of it.
`[:preparation :callback]` runs once after upload. Preparation renders
Full by default; `:render :none` selects a cold first render. `:reset`
runs after each successful main execution. Collection produces plain
case descriptors.
Example:
(defcase :rects/pan options [context] (pan* context pan-options))"
([id opts]
(case-registration-form id (scene-from-id id) opts nil))
([id opts argv & body]
(check-arg-vector! "defcase" id argv)
(case-registration-form id (scene-from-id id) opts `(fn ~argv ~@body))))))
#?(:cljs
(do
;; Case execution context:
;; :case - collected descriptor
;; :scene - scene snapshot {:objects ... :refs ...}
;; :view - current camera view
;; :hooks - clock, scheduling, guard and renderer operations
;; Case bodies return the next context or a Promise of it.
;; Declarations and their transitive imports load in Node and the browser.
;; Browser capabilities enter through the execution context; resource
;; acquisition belongs to untimed preparation. Scenes keep their imports
;; safe for Node discovery.
(def default-viewport
"Default viewport dimensions and device pixel ratio for a case."
{:width 1920 :height 1080 :dpr 2})
(def schema:viewport
[:map {:closed true}
[:width {:optional true} ::sm/positive-safe-number]
[:height {:optional true} ::sm/positive-safe-number]
[:dpr {:optional true} ::sm/positive-safe-number]])
(def schema:view
"Camera placement with finite coordinates, positive scale and optional
viewport overrides."
[:map {:closed true}
[:scale ::sm/positive-safe-number]
[:x ::sm/safe-number]
[:y ::sm/safe-number]
[:viewport {:optional true} schema:viewport]])
(defn resolve-viewport
"Resolves a view's viewport overrides against the default viewport."
[view]
(merge default-viewport (:viewport view)))
(def schema:scene
[:map {:closed true}
[:id simple-keyword?]
[:ns string?]
[:description string?]
[:params-schema vector?]
[:build fn?]])
(def schema:registered-case
[:map {:closed true}
[:id qualified-keyword?]
[:scene simple-keyword?]
[:ns string?]
[:params [:map]]
[:view schema:view]
[:context [:enum :fresh :reuse]]
[:preparation {:optional true}
[:map
[:render {:optional true} [:enum :full :none]]
[:callback {:optional true} fn?]]]
[:reset {:optional true} fn?]
[:run! {:optional true} fn?]])
(def schema:collected-case
[:map {:closed true}
[:id qualified-keyword?]
[:scene simple-keyword?]
[:scene-description string?]
[:scene-seed [:int {:min 0 :max 4294967295}]]
[:params [:map]]
[:view schema:view]
[:context [:enum :fresh :reuse]]
[:preparation [:map [:render {:optional true} [:enum :full :none]]]]])
(defonce registry
(atom {:scenes {}
:scene-order []
:cases {}
:case-order []}))
(defn- register-entry
[entries-key order-key kind duplicate-type {:keys [id ns] :as entry}]
(let [existing (get-in @registry [entries-key id])]
(when (and (some? existing) (not= ns (:ns existing)))
(throw (ex-info (str "duplicate " kind " id: " id)
{:type duplicate-type
:id id
:ns ns
:existing-ns (:ns existing)})))
(swap! registry
#(cond-> (assoc-in % [entries-key id] entry)
(not (contains? (get % entries-key) id))
(update order-key conj id))))
id)
(defn register-scene!
"Registers a scene. The same id from the same namespace replaces the
entry in declaration order. Another namespace raises ::duplicate-scene."
[scene]
(register-entry :scenes :scene-order "scene" ::duplicate-scene scene))
(defn register-case!
"Registers a case. The same id from the same namespace replaces the
entry in declaration order. Another namespace raises ::duplicate-case."
[bench-case]
(register-entry :cases :case-order "case" ::duplicate-case bench-case))
(defn- unregister-entry
[entries-key order-key id]
(swap! registry
(fn [reg]
(-> reg
(update entries-key dissoc id)
(update order-key #(vec (remove #{id} %))))))
id)
(defn unregister-scene!
"Removes a scene registration and returns its id."
[id]
(unregister-entry :scenes :scene-order id))
(defn unregister-case!
"Removes a case registration and returns its id."
[id]
(unregister-entry :cases :case-order id))
(defn scenes
"Returns registered scenes in declaration order."
[]
(mapv (:scenes @registry) (:scene-order @registry)))
(defn cases
"Returns registered cases in declaration order."
[]
(mapv (:cases @registry) (:case-order @registry)))
(defn registered-scene
"Returns the full registration for a scene id."
[id]
(get-in @registry [:scenes id]))
(defn registered-case
"Returns the full registration for a case id, including its run function."
[id]
(get-in @registry [:cases id]))
(defn- check-params-schema!
[{:keys [id params-schema]}]
(try
(sm/schema params-schema)
(catch :default cause
(throw (ex-info (str "invalid params schema for scene " id)
{:type ::invalid-scene :id id}
cause)))))
(defn- check-declaration
[schema declaration message error-type]
(when-not (sm/validate schema declaration)
(throw (ex-info (str message (:id declaration))
{:type error-type
:id (:id declaration)
::sm/explain (sm/explain schema declaration)})))
declaration)
(defn check-registered-scene!
"Validates a scene declaration and returns it unchanged."
[scene]
(check-params-schema! scene)
(check-declaration schema:scene scene "invalid scene declaration: " ::invalid-scene))
(defn check-registered-case!
"Validates a registered case declaration and returns it unchanged."
[bench-case]
(check-declaration schema:registered-case bench-case "invalid case declaration: " ::invalid-case))
(defn- check-param-keys!
"Rejects params keys that `canonical-params` cannot tell apart: only
simple keywords render distinctly by name."
[scene-id params]
(letfn [(check [value path]
(cond
(map? value)
(doseq [[k v] value]
(when-not (simple-keyword? k)
(throw (ex-info (str "seed identity requires simple keyword keys at " path)
{:type ::invalid-params
:scene scene-id
:path path
:key k})))
(check v (str path "/" (name k))))
(vector? value)
(dorun (map-indexed (fn [index v] (check v (str path "[" index "]"))) value))))]
(check params "")))
(defn- canonical-params
"Stable textual identity of a params map: keys render sorted by name,
vectors keep their order, nested values render the same way. Map key
order is an accident of construction, so sorting keeps the identity on
the parameters themselves: equal parameters give the same seed whatever
their literal order. Keys render by name, so params maps use simple
keyword keys; derive-seed rejects anything else."
[params]
(letfn [(render [value]
(cond
(map? value)
(str "{"
(str/join ","
(map (fn [[k v]] (str (name k) ":" (render v)))
(sort-by (comp name key) value)))
"}")
(vector? value)
(str "[" (str/join "," (map render value)) "]")
:else
(pr-str value)))]
(render params)))
(defn derive-seed
"FNV-1a 32-bit keyed hash of a case's parameter identity.
Fowler-Noll-Vo, 32-bit, offset basis 2166136261, prime 16777619, hashed
over `<master-seed>\\0<scene-id>/<canonical params>`. A keyed hash, not
the scene PRNG: the scene's mulberry32 generator is seeded with the
result, and a sequential draw cannot substitute because it would couple
cases through collection order. Keyed by scene and params only, so the
seed does not depend on case name, collection order, operation body,
attempts or A/B feature selection. Params maps use simple keyword keys;
namespaced keywords and string keys cannot be told apart by name and make
derive-seed throw ::invalid-params."
[master-seed scene-id params]
(check-param-keys! scene-id params)
(let [text (str master-seed "\u0000" (name scene-id) "/" (canonical-params params))]
(loop [index 0
seed 2166136261]
(if (< index (count text))
(recur (inc index)
(unsigned-bit-shift-right
(js/Math.imul (bit-xor seed (.charCodeAt text index)) 16777619)
0))
seed))))
(defn transit-round-trips?
"True when `value` survives Transit encoding and decoding with the same
wire representation, including NaN and infinities."
[value]
(true? (ex/ignoring
(let [encoded (t/encode-str value)]
(= encoded (-> encoded t/decode-str t/encode-str))))))
(defn project-case
"Returns a data descriptor with the declared preparation settings.
Args:
bench-case: registered case with local executable functions.
Returns:
Case data with an explicit preparation map."
[bench-case]
(-> bench-case
(select-keys [:id :scene :params :view :context :preparation])
(update :preparation #(dissoc (or % {}) :callback))))
(defn check-collected-case
"Validates a collected case descriptor and its Transit representation,
returning the descriptor unchanged."
[projected]
(check-declaration schema:collected-case projected "invalid collected case: " ::invalid-case)
(when-not (transit-round-trips? projected)
(throw (ex-info (str "collected case does not survive the transit wire: " (:id projected))
{:type ::non-serializable-case
:id (:id projected)})))
projected)))

View File

@ -0,0 +1,500 @@
;; 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 benches.render-wasm.errors
"Failure values and plain diagnostics for renderer benchmarks.
A step that can fail returns its value or an Error. `attempt` catches thrown
values and `failure?` identifies the returned Error. Fatal failures mark
writes to the owner's record or log and propagate to a boundary that uses
`:catch-fatal`.
`evidence` and `describe-cause` produce diagnostics for saved records and
Transit responses."
#?(:cljs (:require
[benches.render-wasm.codec :as codec]
[clojure.string :as str]))
#?(:cljs (:require-macros
[app.common.exceptions :as ex]
[benches.render-wasm.errors])))
#?(:clj
(defmacro attempt
"Evaluates `body` and returns its value, or the failure it throws.
A thrown value becomes an Error through `as-failure`. Fatal failures
propagate unless a leading `:catch-fatal` requests them as values.
Inside an `^:async` function, `body` may use `await`.
Examples:
(attempt (parse text))
(attempt (await (open-page owner)))
(attempt :catch-fatal (await (run-session state)))"
[& forms]
(let [catch-fatal? (= :catch-fatal (first forms))
body (if catch-fatal? (rest forms) forms)
thrown (gensym "thrown")
failure (gensym "failure")]
`(try
~@body
(catch :default ~thrown
(let [~failure (benches.render-wasm.errors/as-failure ~thrown)]
~(if catch-fatal?
failure
`(if (benches.render-wasm.errors/fatal? ~failure)
(throw ~failure)
~failure))))))))
#?(:cljs
(do
(def ^:private schema:cause-value
[:or nil? boolean? keyword? number? string?])
(def schema:cause-detail
"One level of a portable exception description."
[:map {:closed true}
[:message string?]
[:fn {:optional true} schema:cause-value]
[:code {:optional true} schema:cause-value]
[:type {:optional true} schema:cause-value]
[:hint {:optional true} schema:cause-value]])
(def ^:private diagnostic-fields
[[:phase string?]
[:message string?]
[:operation {:optional true} keyword?]
[:path {:optional true} [:string {:max 1024}]]
[:status {:optional true} [:enum "failed" "stale"]]
[:cause {:optional true} [:vector schema:cause-detail]]
[:partial {:optional true} map?]
[:reason {:optional true} :any]
[:deadline-exceeded {:optional true} boolean?]
[:expected-id {:optional true} [:string {:max 1024}]]
[:actual-id {:optional true} [:string {:max 1024}]]
[:command {:optional true} [:string {:max 1024}]]
[:exit-code {:optional true} :int]
[:pid {:optional true} [:int {:min 1}]]
[:pgid {:optional true} [:int {:min 1}]]
[:signals {:optional true} [:vector [:string {:max 1024}]]]
[:profile-path {:optional true} [:string {:max 1024}]]
[:fatal {:optional true} boolean?]
[:unresolved-process {:optional true} boolean?]
[:signal {:optional true} [:string {:max 1024}]]
[:endpoint {:optional true} [:string {:max 1024}]]])
(def schema:error-entry
"One failure occurrence with its operation and portable diagnostics."
(into [:map {:closed true}] diagnostic-fields))
(def schema:diagnostic
"Failure location, message and an optional ordered collection of errors."
(into [:map]
(conj diagnostic-fields
[:errors {:optional true} [:vector {:min 1} schema:error-entry]])))
(def ^:private evidence-cause-depth 3)
(def ^:private max-cause-chars 500)
(def ^:private unrenderable-value "unrenderable value")
(def ^:private unrenderable-cause-text "unrenderable cause")
(defn failure?
"True when `x` is an Error."
[x]
(instance? js/Error x))
(defn message-of
"Returns the error message or string form of `cause`, with a fallback
when formatting fails. The default fallback is unknown failure."
([cause]
(message-of cause "unknown failure"))
([cause fallback]
(or (ex/ignoring (let [message (ex-message cause)]
(when (string? message) message)))
(ex/ignoring (str cause))
fallback)))
(defn fatal-error
"Returns an exception that `fatal?` recognizes, with `data` and `cause`."
[message data cause]
(ex-info message (assoc data :fatal true) cause))
(defn fatal?
"True when `cause` or one of its causes has the ex-data `:fatal` marker."
[cause]
(loop [cause cause
seen #{}]
(cond
(nil? cause) false
(:fatal (ex-data cause)) true
(contains? seen cause) false
:else (recur (ex-cause cause) (conj seen cause)))))
(defn as-failure
"Returns an Error for `thrown`. Errors pass through; other values supply
the message and the `:thrown` ex-data."
[thrown]
(if (failure? thrown)
thrown
(ex-info (if (string? thrown)
thrown
(str "non-error value thrown: " (or (ex/ignoring (pr-str thrown)) "unprintable value")))
{:thrown thrown})))
(defn with-data
"Adds default ex-data to an Error, keeping its message and using it as
the cause. Existing keys win. Other values pass through."
[x data]
(if (failure? x)
(ex-info (message-of x) (merge data (ex-data x)) x)
x))
(declare combine)
(defn with-context
"Returns an exception with `message` and added diagnostic context.
New fields override existing fields; existing partial measurements win
over earlier measurements in `data`. An exception with ex-data keeps
its underlying cause. Other values become the cause."
[cause message data]
(let [existing (ex-data cause)
malformed (and (contains? existing :partial) (not (map? (:partial existing)))
(map? (:partial data)))
source (if malformed (combine [{:error cause :partial (:partial existing)}]) cause)
context (cond-> (merge (ex-data source) data)
(contains? data :partial)
(assoc :partial (if (map? (:partial data))
(merge (:partial data) (when (map? (:partial existing)) (:partial existing)))
(:partial data))))]
(ex-info message context (if (and (some? existing) (not malformed)) (ex-cause cause) source))))
(defn- merge-error-context
[outer inner]
(cond-> (merge outer inner)
(and (map? (:partial outer)) (map? (:partial inner)))
(assoc :partial (merge (:partial outer) (:partial inner)))))
(defn- get-error-collection
[cause]
(loop [current cause
seen #{}
context {}]
(let [data (ex-data current)
context (merge-error-context context (dissoc data ::errors))]
(if-let [entries (::errors data)]
(update entries 0 #(merge-error-context context %))
(when (and (ex-cause current) (not (contains? seen current)))
(recur (ex-cause current) (conj seen current) context))))))
(defn get-errors
"Returns failure occurrences in order, retaining their Error objects.
Args:
cause: An Error, including one wrapped with additional context.
Returns:
A flat vector of maps with an Error under :error and available phase,
operation and path context."
[cause]
(or (get-error-collection cause)
[(assoc (select-keys (ex-data cause) [:phase :operation :path]) :error cause)]))
(defn combine
"Combines failure occurrences into an Error, or returns nil on success.
Args:
entries: Ordered maps with :error and optional :phase, :operation and
:path. Nil errors represent successful operations. An Error may
contain a collection from an earlier operation.
Returns:
An Error retaining a flat collection and the first error's message
and data. Retained Error objects expose their causes. Any fatal
occurrence makes the combined error fatal."
[entries]
(let [sources (filterv #(failure? (:error %)) entries)
error (:error (first sources))
entries (reduce (fn [collected {:keys [error] :as entry}]
(let [context (dissoc entry :error)
group (get-errors error)
group (if (seq context)
(mapv #(merge-error-context context %) group)
group)]
(if (empty? collected) group (into collected group))))
[] sources)]
(when error
(ex-info (message-of error)
(cond-> (merge (select-keys (first entries) [:phase :operation :path])
(ex-data error)
{::errors entries})
(some #(fatal? (:error %)) sources) (assoc :fatal true))
(if (::errors (ex-data error)) (ex-cause error) error)))))
(defn stale?
"True when the exception's own data marks superseded work."
[cause]
(true? (::stale (ex-data cause))))
(defn fail-data
"Marks diagnostic data with the failed status."
[data]
(assoc data :status "failed"))
(defn bounded-text
"Returns text within a size limit, preserving UTF-16 boundaries.
Converts `value` to a string and limits it to max-chars UTF-16 code
units. If conversion fails, it returns 'unrenderable value'. When
truncating, we check whether the cut would leave a high surrogate
without its matching low surrogate. If so, we remove that last code unit
to keep the pair intact.
Args:
value: Value to convert to text.
max-chars: Positive limit in UTF-16 units.
Returns:
Bounded text, or a fallback string when conversion fails."
[value max-chars]
(or (ex/ignoring
(let [text (str value)]
(if (<= (count text) max-chars)
text
(let [cut (subs text 0 max-chars)
last (.charCodeAt cut (dec max-chars))]
(if (and (>= last 0xD800)
(<= last 0xDBFF)
(let [nxt (.charCodeAt text max-chars)]
(and (>= nxt 0xDC00) (<= nxt 0xDFFF))))
(subs cut 0 (dec max-chars))
cut)))))
(subs unrenderable-value 0 (min max-chars (count unrenderable-value)))))
(defn terminal-text
"Returns bounded text with terminal controls escaped.
Args:
value: Value to render as text.
max-chars: Positive limit in UTF-16 units after escaping.
Returns:
Text with C0, DEL and C1 controls represented as Unicode escapes."
[value max-chars]
(bounded-text
(or (ex/ignoring
(str/replace (bounded-text value max-chars) #"[\x00-\x1f\x7f-\x9f]"
(fn [character]
(str "\\u" (.padStart (.toString (.charCodeAt character 0) 16) 4 "0")))))
unrenderable-value)
max-chars))
(defn format-value
"Returns a bounded, readable value with terminal controls escaped.
Args:
value: Value to print. Strings use JSON quoting.
max-chars: Positive limit in UTF-16 units after escaping.
Returns:
Readable text, or fallback text when printing fails."
[value max-chars]
(terminal-text
(or (ex/ignoring
(if (string? value)
(js/JSON.stringify (bounded-text value max-chars))
(binding [*print-level* 8 *print-length* 32]
(pr-str value))))
unrenderable-value)
max-chars))
(defn project-context
"Returns bounded identity, process and ownership details.
Args:
data: Diagnostic data with available identity or process fields.
Returns:
Bounded text, integer process IDs and exit code, attempted signals,
and available fatal or unresolved-ownership markers."
[data]
(cond-> {}
(string? (:expected-id data)) (assoc :expected-id (bounded-text (:expected-id data) 1024))
(string? (:actual-id data)) (assoc :actual-id (bounded-text (:actual-id data) 1024))
(string? (:command data)) (assoc :command (bounded-text (:command data) 1024))
(integer? (:exit-code data)) (assoc :exit-code (:exit-code data))
(and (integer? (:pid data)) (pos? (:pid data))) (assoc :pid (:pid data))
(and (integer? (:pgid data)) (pos? (:pgid data))) (assoc :pgid (:pgid data))
(and (vector? (:signals data)) (every? string? (:signals data)))
(assoc :signals (mapv #(bounded-text % 1024) (:signals data)))
(string? (:profile-path data)) (assoc :profile-path (bounded-text (:profile-path data) 1024))
(true? (:fatal data)) (assoc :fatal true)
(true? (:unresolved-process data)) (assoc :unresolved-process true)
(string? (:signal data)) (assoc :signal (bounded-text (:signal data) 1024))
(string? (:endpoint data)) (assoc :endpoint (bounded-text (:endpoint data) 1024))))
(declare project-diagnostic)
(defn- sanitize-cause-value
[value]
(cond
(or (nil? value) (boolean? value) (keyword? value) (number? value))
value
(string? value)
(bounded-text value max-cause-chars)
:else
(bounded-text (or (ex/ignoring
(binding [*print-level* 8 *print-length* 32]
(pr-str value)))
unrenderable-value)
max-cause-chars)))
(defn- describe-cause-level
[cause]
(or (ex/ignoring
(let [message (ex/ignoring (ex-message cause))
data (ex/ignoring (ex-data cause))
text (cond
(string? message) message
(string? cause) cause
(nil? cause) "unknown failure"
:else (binding [*print-level* 8 *print-length* 32] (pr-str cause)))
out {:message (sanitize-cause-value text)}]
(if (map? data)
(reduce (fn [m k]
(if (contains? data k)
(assoc m k (sanitize-cause-value (get data k)))
m))
out
[:fn :code :type :hint])
out)))
{:message unrenderable-cause-text}))
(defn describe-cause
"Returns up to three cause levels as plain data. Each level keeps a
bounded message and available :fn, :code, :type and :hint details.
Unprintable values produce fallback strings."
[cause]
(or (ex/ignoring
(loop [current cause
depth 0
acc []
seen #{}]
(if (or (nil? current) (contains? seen current) (>= depth evidence-cause-depth))
(if (seq acc)
acc
[{:message "unknown failure"}])
(let [level (describe-cause-level current)
next (ex/ignoring (ex-cause current))]
(recur next (inc depth) (conj acc level) (conj seen current))))))
[{:message unrenderable-cause-text}]))
(defn- diagnostic-data
[phase cause]
(let [partial (ex/ignoring (:partial (ex-data cause)))]
(cond-> (merge (project-context (ex-data cause))
(select-keys (ex-data cause) [:operation])
{:phase (if (keyword? phase) (name phase) (str phase))
:message (bounded-text (message-of cause) 1024)
:cause (describe-cause cause)})
(contains? (ex-data cause) :partial) (assoc :partial partial)
(string? (:path (ex-data cause)))
(assoc :path (bounded-text (:path (ex-data cause)) 1024)))))
(defn evidence
"Returns plain diagnostics, including ordered failure occurrences.
Args:
phase: Failure location for the enclosing operation.
cause: Error with available operation context and collected failures.
Returns:
Portable diagnostics with bounded cause descriptions. Collected
errors retain occurrence order and messages within 1024 UTF-16 units."
[phase cause]
(let [entries (get-error-collection cause)
projected (when entries
(mapv (fn [{:keys [error operation path partial] entry-phase :phase :as entry}]
(cond-> (merge (diagnostic-data (or entry-phase phase) error)
(project-context entry)
(select-keys entry [:reason :deadline-exceeded])
{:message (bounded-text (message-of error) 1024)})
operation (assoc :operation operation)
partial (assoc :partial partial)
(string? path) (assoc :path (bounded-text path 1024))))
entries))]
(project-diagnostic
(cond-> (diagnostic-data phase cause)
projected (assoc :errors projected)
(map? (:partial (first projected))) (assoc :partial (:partial (first projected)))
(some :unresolved-process projected) (assoc :unresolved-process true)))))
(defn- partial-encoding-error
[path cause]
{:phase "diagnostic-encoding" :operation :encode-diagnostic
:path (format-value path 1024)
:message "Diagnostic partial evidence cannot cross Transit"
:cause (describe-cause cause)})
(defn- project-partial
[partial]
(if (map? partial)
(reduce-kv (fn [[kept rejected] key value]
(try
(codec/check-value {key value})
(codec/encode-str {key value})
[(assoc kept key value) rejected]
(catch :default cause
[kept (conj rejected (partial-encoding-error [:partial key] cause))])))
[{} []]
partial)
[nil [(partial-encoding-error [:partial]
(ex-info "Diagnostic partial evidence must be a map" {}))]]))
(defn- project-entry
[entry]
(let [[partial rejected] (when (contains? entry :partial) (project-partial (:partial entry)))
projected (cond-> (project-context entry)
(contains? #{"failed" "stale"} (:status entry)) (assoc :status (:status entry))
(boolean? (:deadline-exceeded entry)) (assoc :deadline-exceeded (:deadline-exceeded entry))
(keyword? (:operation entry)) (assoc :operation (:operation entry))
(contains? entry :phase) (assoc :phase (bounded-text (:phase entry) 1024))
(contains? entry :message) (assoc :message (bounded-text (:message entry) 1024))
(contains? entry :reason) (assoc :reason (sanitize-cause-value (:reason entry)))
(contains? entry :cause)
(assoc :cause (mapv (fn [level]
(reduce-kv (fn [out key value]
(assoc out key (sanitize-cause-value value)))
{} (select-keys level [:message :fn :code :type :hint])))
(take evidence-cause-depth (:cause entry))))
(some? partial) (assoc :partial partial)
(string? (:path entry)) (assoc :path (bounded-text (:path entry) 1024)))]
[projected rejected]))
(defn project-diagnostic
"Returns bounded diagnostic fields and representable partial evidence.
Args:
evidence: Execution evidence with optional failure details.
Returns:
Diagnostic fields with bounded text. Partial entries that cannot
cross Transit produce ordered encoding diagnostics. Representable
partial values retain their raw numeric values."
[evidence]
(let [alias? (and (seq (:errors evidence)) (contains? evidence :partial)
(identical? (:partial evidence) (:partial (first (:errors evidence)))))
[projected rejected] (project-entry (if alias? (dissoc evidence :partial) evidence))
entries (mapv project-entry (:errors evidence))
rejected (into (vec rejected) (mapcat second) entries)
errors (mapv first entries)
projected (cond-> projected
(and alias? (contains? (first errors) :partial))
(assoc :partial (:partial (first errors))))]
(cond-> projected
(seq errors) (assoc :errors errors)
(seq rejected) (assoc :errors (into (if (seq errors) errors [(dissoc projected :partial)]) rejected)))))))

View File

@ -0,0 +1,76 @@
;; 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 benches.render-wasm.features.plan
"Requested-feature configurations and comparison edges."
(:require
[benches.render-wasm.features.selection :as selection]
[clojure.string :as str]))
(def ^:const default-max-features 3)
(defn check-manifest
"Checks names and empty declared defaults against Cargo metadata's feature
definitions. Cargo owns activation; these definitions are not resolved here."
[features definitions]
(let [features (:features (selection/feature-selection {:features features}))]
(when-not (and (map? definitions)
(every? string? (keys definitions))
(every? #(and (vector? %) (every? string? %)) (vals definitions)))
(throw (ex-info "invalid Cargo feature definitions" {:type ::invalid-manifest})))
(when (seq (get definitions "default"))
(throw (ex-info "ab requires empty declared Cargo defaults" {:type ::nonempty-defaults})))
(when-let [unknown (seq (remove #(contains? definitions %) features))]
(throw (ex-info "unknown requested Cargo features"
{:type ::unknown-features :features (vec unknown)})))
features))
(defn plan
"Plans sorted, MSB-first reflected Gray configurations and all off-to-on
edges. Each selection disables Cargo defaults. Execution needs confirmation
above eight configurations or with an empty filter; dry-run needs none."
[features {:keys [max-features confirm dry-run filter]
:or {max-features default-max-features confirm false dry-run false}}]
(let [features (:features (selection/feature-selection {:features features}))
n (count features)]
(when-not (and (int? max-features) (<= 1 max-features 6))
(throw (ex-info "max-features must be an integer from 1 to 6" {:type ::invalid-limit})))
(when-not (and (boolean? confirm) (boolean? dry-run)
(or (nil? filter) (string? filter)))
(throw (ex-info "invalid feature planning options" {:type ::invalid-options})))
(when (zero? n)
(throw (ex-info "ab requires at least one requested feature" {:type ::missing-features})))
(when (> n max-features)
(throw (ex-info "requested features exceed max-features"
{:type ::too-many-features :count n :max-features max-features})))
(when (and (not dry-run) (not confirm)
(or (> (bit-shift-left 1 n) 8) (str/blank? filter)))
(throw (ex-info "ab requires --confirm above eight configurations or with an empty filter"
{:type ::confirmation-required})))
(let [slug (fn [bits]
(str/join (map #(if (bit-test bits %) "1" "0")
(reverse (range n)))))
gray-values (mapv #(bit-xor % (bit-shift-right % 1))
(range (bit-shift-left 1 n)))
configurations (mapv (fn [bits]
{:slug (slug bits)
:selection {:default-features? false
:features (into []
(keep-indexed
(fn [i feature]
(when (bit-test bits (- n i 1))
feature)))
features)}})
gray-values)
edges (vec
(for [bits gray-values
[i feature] (map-indexed vector features)
:let [bit (- n i 1)]
:when (not (bit-test bits bit))]
{:from (slug bits)
:to (slug (bit-set bits bit))
:feature feature}))]
{:features features :configurations configurations :edges edges})))

View File

@ -0,0 +1,29 @@
;; 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 benches.render-wasm.features.selection
"Feature validation.")
(def ^:private diagnostic-features #{"stats" "profile" "profile-macros" "profile-raf"})
(defn feature-name?
"True for a Cargo feature name made of letters, digits, underscores or hyphens."
[value]
(and (string? value)
(boolean (re-matches #"[A-Za-z0-9_-]+" value))))
(defn feature-selection
"Validates measurement feature selection before allocating an attempt.
Cargo validates functional feature names during the retained build."
[{:keys [default-features? features]
:or {default-features? true features []}}]
(when-not (and (boolean? default-features?) (vector? features)
(every? feature-name? features))
(throw (ex-info "invalid Cargo feature selection" {:type ::invalid-features})))
(when-let [selected (seq (filter diagnostic-features features))]
(throw (ex-info "measurement builds cannot enable diagnostic Cargo features"
{:type ::diagnostic-features :features (vec selected)})))
{:default-features? default-features? :features (vec (sort (distinct features)))})

View File

@ -0,0 +1,77 @@
;; 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 benches.render-wasm.fingerprint
"Scene fingerprints to uniquely identify snapshots by their content."
(:require
[benches.render-wasm.codec :as codec]
[goog.crypt :as crypt]
[goog.crypt.Sha256]))
(declare canonicalize)
(defn- canonical-text
"Encodes a Transit representation as text."
[value]
(js/JSON.stringify value))
(defn- canonical-array
"Preserves array order while canonicalizing each element."
[value]
(into-array (map canonicalize (array-seq value))))
(defn- canonical-pairs
"Orders a compound map by canonical key text, preserving key/value pairs."
[value]
(->> (partition 2 (array-seq value))
(map (fn [[k v]] [(canonicalize k) (canonicalize v)]))
(sort-by (comp canonical-text first))
(mapcat identity)
(into-array)))
(defn- canonicalize
"Orders verbose Transit objects, compound maps and unordered sets.
Tagged ordered-map and ordered-set representations retain their array order."
[value]
(cond
(array? value)
(canonical-array value)
(and (some? value) (= "object" (goog/typeOf value)))
(let [keys (sort (array-seq (js/Object.keys value)))
out (js/Object.create nil)]
(doseq [k keys]
(let [v (unchecked-get value k)]
(aset out k
(cond
(= k "~#cmap") (canonical-pairs v)
(= k "~#set") (->> (array-seq v)
(map canonicalize)
(sort-by canonical-text)
(into-array))
:else (canonicalize v)))))
out)
:else value))
(defn snapshot-text
"Returns canonical Transit text for a validated snapshot.
Realizing :objects removes ObjectsMap's serialized shape strings while keeping
production shape, geometry and path tags in the values."
[snapshot]
(-> (assoc snapshot :objects (into {} (:objects snapshot)))
(codec/encode-str {:type :json-verbose})
(js/JSON.parse)
(canonicalize)
(canonical-text)))
(defn fingerprint
"Returns the SHA-256 and digest for a validated snapshot."
[snapshot]
(let [hash (goog.crypt.Sha256.)]
(.update hash (crypt/stringToUtf8ByteArray (snapshot-text snapshot)))
{:algorithm :sha-256
:digest (crypt/byteArrayToHex (.digest hash))}))

View File

@ -0,0 +1,36 @@
;; 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 benches.render-wasm.identity
"Build identity shared by the isolated Node catalog and browser bundle."
#?(:cljs (:require-macros [benches.render-wasm.identity :refer [compiled-profile-marks]]))
#?(:clj (:require [app.render-wasm.performance :as performance])))
#?(:clj
(defmacro compiled-profile-marks
"Captures the profiling switch used by renderer macros in this compiler."
[]
(performance/enabled?)))
#?(:cljs
(do
(goog-define build-id "unprepared")
(defn data
[]
{:build-id build-id :cljs-profile-marks (compiled-profile-marks)})
(defn check
"Checks a loaded bundle's identity against the selected build."
[expected-id actual]
(when-not (and (map? actual)
(string? (:build-id actual))
(= expected-id (:build-id actual)))
(throw (ex-info "renderer build identity mismatch"
{:type ::mismatch
:expected-id expected-id
:actual-id (:build-id actual)})))
actual)))

View File

@ -0,0 +1,85 @@
;; 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 benches.render-wasm.measurement
"Projects browser phase evidence into saved attempts."
(:require
[app.common.schema :as sm]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.result :as result]))
(def ^:private schema:response
[:multi {:dispatch :status}
["ok" [:map [:status [:= "ok"]]]]
["failed" (sm/merge errors/schema:diagnostic [:map [:status [:= "failed"]]])]
["stale" [:map {:closed true} [:status [:= "stale"]]]]])
(defn partial-phases
"Returns the valid prefix of delivered phase history.
Args:
phases: raw blocks from a browser response.
preparation-id: expected owner of the blocks.
order: allowed phase names in execution order.
Returns:
Valid blocks up to the first malformed block or failed phase."
[phases preparation-id order]
(loop [phases (when (sequential? phases) (seq phases)) order order out []]
(if-let [phase (first phases)]
(if (and (sm/validate result/schema:phase phase)
(= preparation-id (:preparation-id phase))
(= (first order) (:phase phase))
(every? #(= 1 (count (get-in % [:metrics :command/elapsed]))) (:events phase)))
(let [out (conj out phase)]
(if (= :failed (:outcome phase)) out
(recur (next phases) (next order) out)))
out)
out)))
(defn check-response
"Returns checked bridge evidence or an invalid-output failure.
Stale responses retain their status and gain an ownership diagnostic.
Malformed responses retain their valid phase prefix and raw response.
Args:
phase: The preparation or attempt receiving the response.
preparation-id: Expected owner of delivered phase blocks.
response: Decoded bridge envelope.
Returns:
Execution evidence with a known status and diagnostic fields on failure."
[phase preparation-id response]
(if (sm/validate schema:response response)
(if (= "stale" (:status response))
(assoc response :phase (name phase) :message "browser context was superseded")
response)
(assoc (errors/fail-data
(errors/evidence :invalid-output
(ex-info "invalid bridge response"
{:type ::invalid-response :partial {:response response}})))
:phases (partial-phases (:phases response) preparation-id
(if (= :preparation phase) [:preparation] [:main :reset])))))
(defn attempt
"Returns an attempt with raw phase histories and available failure evidence.
Args:
case-id: saved case identifier.
preparation-id: preparation identifier, or nil for an early failure.
warmup?: whether this execution occupies a warmup slot.
evidence: browser response with status, phases and optional failure details.
Returns:
An attempt ready for chronological recording."
[case-id preparation-id warmup? evidence]
(cond-> {:case-id case-id :preparation-id preparation-id :warmup? warmup?
:outcome (if (= "ok" (:status evidence)) :completed :failed)
:phases (or (:phases evidence) [])}
(not= "ok" (:status evidence))
(assoc :failure (dissoc (errors/project-diagnostic evidence) :partial))
(:partial evidence) (assoc :partial (:partial evidence))))

View File

@ -0,0 +1,59 @@
;; 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 benches.render-wasm.node.catalog.entry
"Node entry points of a compiled renderer benchmark build.
The `:bench-render-wasm-catalog` build in `frontend/shadow-cljs.edn` names
this namespace as its compilation entry through `:exports`, which exposes
`collect-cases` to Node as `collectCases` and `build-identity` as
`buildIdentity`. Both functions use Transit-encoded strings at the Node
boundary.
Requiring this namespace loads the benchmark scene declarations. Each build's
`cases.cjs` artifact has its own scene registry and compiled identity; load
the artifact of the build being queried."
(:require
[app.common.transit :as transit]
[benches.render-wasm.cases :as cases]
[benches.render-wasm.codec :as codec]
[benches.render-wasm.identity :as identity]))
(defn collect-cases
"Collects the benchmark cases selected from this build.
Args:
encoded-selection: A Transit-encoded map. The required :master-seed is an
integer from 0 through 4294967295 used to derive the seed of each case.
The optional :filter is a substring; a blank value selects all cases,
otherwise only case ids that contain it are collected.
Returns:
A Transit-encoded map with :build-id, the identity compiled into this build,
and :cases, a non-empty vector of validated case descriptors.
Each descriptor has the case's :id and :scene, a :scene-description, its
:params and :scene-seed, and the execution settings used by the runner.
Throws:
When the selection cannot be decoded, :master-seed is missing or not in
0 through 4294967295, no cases are selected, or a registered scene or case
fails validation. Case collection failures carry a :type in their
exception data."
[encoded-selection]
(codec/encode-str
{:build-id identity/build-id
:cases (cases/collect-cases (transit/decode-str encoded-selection))}))
(defn build-identity
"Returns the identity compiled into this build.
Returns:
A Transit-encoded string. Decoding it yields a map with :build-id, the
identifier compiled into this artifact, defaulting to `unprepared`, and
:cljs-profile-marks, a boolean that records whether renderer profiling
marks were enabled at compile time."
[]
(codec/encode-str (identity/data)))

View File

@ -0,0 +1,47 @@
;; 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 benches.render-wasm.node.catalog.loader
"Loads case descriptors from a selected build, without the runner's registry."
(:require
["module" :as node-module]
[app.common.transit :as transit]
[benches.render-wasm.codec :as codec]
[benches.render-wasm.declarations :as declarations]
[benches.render-wasm.identity :as identity]))
(defn- load-catalog
[build]
(let [cases-path (:cases-path build)]
(if (exists? js/require)
(js/require cases-path)
((node-module/createRequire cases-path) cases-path))))
(defn build-identity
"Reads and validates the selected catalog's compiled build facts."
[build]
(let [loaded (transit/decode-str (.buildIdentity ^js (load-catalog build)))]
(identity/check (:id build) loaded)
loaded))
(defn collect
"Requires the selected cases.cjs and checks both identity responses.
build comes from artifacts/load-build or a newly completed build. The
createRequire branch also supports the Node ESM test entry points."
[build selection]
(let [catalog (load-catalog build)
loaded (transit/decode-str (.buildIdentity ^js catalog))]
(identity/check (:id build) loaded)
(let [response (transit/decode-str
(.collectCases ^js catalog (codec/encode-str selection)))
cases (:cases response)]
(identity/check (:id build) response)
(when-not (and (vector? cases) (seq cases))
(throw (ex-info "renderer build returned an invalid case catalog"
{:type ::invalid-catalog
:build-id (:id build)})))
(mapv declarations/check-collected-case cases))))

View File

@ -0,0 +1,55 @@
;; 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 benches.render-wasm.node.cli.commands.ab
"Options for comparing configurations of Cargo features."
(:require
[benches.render-wasm.features.plan :as feature-plan]
[benches.render-wasm.node.cli.shared :as shared]
[benches.render-wasm.node.settings :as settings]))
(def schema:max-features
[:int {:min 1 :max 6}])
(def schema:options
[:merge shared/schema:measurement shared/schema:comparison-permissions
[:map {:closed true}
[:features [:vector {:min 1} shared/schema:feature]]
[:max-features {:default feature-plan/default-max-features} schema:max-features]
[:confirm {:default false} :boolean]
[:dry-run {:default false} :boolean]
[:build-timeout-ms {:default settings/default-build-timeout-ms} shared/schema:timeout-ms]]])
(def ^:const usage "runner ab --features A,B [options]")
(def ^:const description
"Compare configurations of Cargo features.
Managed output: frontend/target/performance/render-wasm/feature-comparisons/<ab-id>/feature-comparison.transit")
(def cli-options
(into [[nil "--features FEATURES" "Comma-separated functional Cargo features"
:parse-fn shared/parse-feature-list]
(shared/number-option "--max-features N" "Maximum number of requested features"
schema:max-features "must be an integer from 1 to 6")
[nil "--confirm" "Confirm above eight configurations or with a blank filter"]
[nil "--dry-run" "Validate and print the plan"]
(shared/build-timeout-option "Deadline for metadata and each configuration build")]
(concat shared/measurement-options shared/comparison-options)))
(defn resolve-options
"Resolves AB arguments and requires a feature selection."
[options arguments]
(shared/check-arguments arguments 0)
(when-not (contains? options :features)
(throw (ex-info "ab requires at least one requested feature (--features)"
{:type ::shared/invalid-args})))
options)
(defn check-options
"Checks feature limits and confirmation for resolved AB options."
[options]
(feature-plan/plan (:features options) options)
options)

View File

@ -0,0 +1,41 @@
;; 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 benches.render-wasm.node.cli.commands.build
"Options for preparing a benchmark build."
(:require
[benches.render-wasm.features.selection :as selection]
[benches.render-wasm.node.cli.shared :as shared]
[benches.render-wasm.node.settings :as settings]))
(def schema:options
[:map {:closed true}
[:default-features {:default true} :boolean]
[:features {:default []} [:vector shared/schema:feature]]
[:build-timeout-ms {:default settings/default-build-timeout-ms} shared/schema:timeout-ms]])
(def ^:const usage "runner build [options]")
(def ^:const description "Prepare a renderer build with the selected Cargo features.")
(def cli-options
[[nil "--[no-]default-features" "Enable Cargo defaults"]
[nil "--features FEATURES" "Comma-separated functional Cargo features"
:parse-fn shared/parse-feature-list]
(shared/build-timeout-option "Complete preparation deadline")])
(defn resolve-options
"Resolves build arguments and supplied flags."
[options arguments]
(shared/check-arguments arguments 0)
options)
(defn check-options
"Checks measurement feature policy and returns the resolved build options."
[resolved]
(selection/feature-selection {:default-features? (:default-features resolved)
:features (:features resolved)})
resolved)

View File

@ -0,0 +1,50 @@
;; 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 benches.render-wasm.node.cli.commands.compare
"Options for comparing saved benchmark records."
(:require
[app.common.schema :as sm]
[benches.render-wasm.node.cli.shared :as shared]
[clojure.string :as str]))
(def schema:case-pair
[:tuple ::sm/text ::sm/text])
(def schema:options
[:merge shared/schema:comparison-permissions
[:map {:closed true}
[:baseline-path ::sm/text]
[:candidate-path ::sm/text]
[:case-pair {:default nil} [:maybe schema:case-pair]]]])
(def ^:const usage "runner compare BASELINE CANDIDATE [options]")
(def ^:const description
"Compare two saved records. Paths resolve from the caller's working directory.
--cases selects one exact baseline,candidate pair by saved case ID or name.")
(def ^:private valid-case-pair?
(sm/validator schema:case-pair))
(defn- parse-case-pair
[text]
(let [pair (str/split text #"," -1)]
(when-not (valid-case-pair? pair)
(throw (ex-info "--cases requires one exact nonblank A,B pair"
{:type ::shared/invalid-args})))
pair))
(def cli-options
(into [[nil "--cases A,B" "Select one baseline,candidate pair" :id :case-pair
:parse-fn parse-case-pair]]
shared/comparison-options))
(defn resolve-options
"Resolves two literal paths and supplied comparison flags."
[options arguments]
(shared/check-arguments arguments 2)
(assoc options :baseline-path (first arguments) :candidate-path (second arguments)))

View File

@ -0,0 +1,31 @@
;; 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 benches.render-wasm.node.cli.commands.merge
"Options for merging saved benchmark records."
(:require
[app.common.schema :as sm]
[benches.render-wasm.node.cli.shared :as shared]))
(def schema:options
[:map {:closed true}
[:base-path ::sm/text]
[:additional-path ::sm/text]
[:output-dir {:default nil} [:maybe ::sm/text]]])
(def ^:const usage "runner merge BASE ADDITIONAL [options]")
(def ^:const description
"Merge two saved records. Paths resolve from the caller's working directory.")
(def cli-options
[shared/output-option])
(defn resolve-options
"Resolves two literal paths and the supplied output directory."
[options arguments]
(shared/check-arguments arguments 2)
(assoc options :base-path (first arguments) :additional-path (second arguments)))

View File

@ -0,0 +1,32 @@
;; 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 benches.render-wasm.node.cli.commands.purge
"Options for purging managed benchmark artifacts."
(:require
[benches.render-wasm.node.cli.shared :as shared]))
(def targets [:builds :runs :comparisons :all])
(def schema:options
[:map {:closed true}
[:target (into [:enum] targets)]])
(def ^:const usage "runner purge builds|runs|comparisons|all [options]")
(def ^:const description "Purge files in one explicit category of managed benchmark artifacts.")
(def cli-options [])
(defn resolve-options
"Resolves the required artifact category."
[options arguments]
(shared/check-arguments arguments 1)
(let [target (some #(when (= (name %) (first arguments)) %) targets)]
(when-not target
(throw (ex-info "purge requires builds, runs, comparisons or all"
{:type ::shared/invalid-args})))
(assoc options :target target)))

View File

@ -0,0 +1,69 @@
;; 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 benches.render-wasm.node.cli.commands.run
"Options for a benchmark run and its selected build source."
(:require
[app.common.schema :as sm]
[benches.render-wasm.node.cli.commands.build :as build]
[benches.render-wasm.node.cli.shared :as shared]))
(def schema:build
[:multi {:dispatch :kind}
[:prepare
[:merge build/schema:options
[:map {:closed true}
[:kind [:= :prepare]]]]]
[:existing
[:map {:closed true}
[:kind [:= :existing]]
[:id shared/schema:build-id]]]])
(def schema:options
[:merge shared/schema:measurement
[:map {:closed true}
[:build schema:build]
[:remove {:default false} :boolean]]])
(def ^:const usage "runner run [options]")
(def ^:const description
"Run cases using existing or new builds.
Managed output: frontend/target/performance/render-wasm/runs/<run-id>/run.transit")
(def cli-options
(into [[nil "--build ID" "Reuse a ready build" :id :build-id
:validate [(sm/validator shared/schema:build-id) "must be a benchmark artifact ID"]]]
(concat build/cli-options shared/measurement-options
[[nil "--remove" "Remove the used build after shutdown"]])))
(defn resolve-options
"Selects an existing build or a preparation branch from supplied flags."
[options arguments]
(shared/check-arguments arguments 0)
(let [preparation (select-keys options [:default-features :features :build-timeout-ms])]
(when (and (contains? options :build-id) (seq preparation))
(throw (ex-info "--build cannot be combined with --features, --[no-]default-features or --build-timeout-ms"
{:type ::shared/invalid-args})))
(assoc (dissoc options :build-id :default-features :features :build-timeout-ms)
:build (if (contains? options :build-id)
{:kind :existing :id (:build-id options)}
(assoc preparation :kind :prepare)))))
(defn check-options
"Checks measurement feature policy for the selected build source."
[options]
(when (= :prepare (get-in options [:build :kind]))
(build/check-options (:build options)))
options)
(defn get-help-defaults
"Returns CLI defaults for run options with a preparation source."
[options]
(assoc (dissoc options :build)
:default-features (get-in options [:build :default-features])
:features (get-in options [:build :features])
:build-timeout-ms (get-in options [:build :build-timeout-ms])))

View File

@ -0,0 +1,40 @@
;; 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 benches.render-wasm.node.cli.console
"Console settings carried by resolved benchmark requests."
(:require
[benches.render-wasm.node.cli.shared :as shared]))
(def schema:console
[:map {:closed true}
[:level {:default :info} [:enum :info :warn :debug]]
[:color {:default :auto} [:enum :auto :always :never]]])
(def cli-options
[["-q" "--quiet" "Only warnings, errors and results"]
["-v" "--verbose" "Show debug details and raw build output"]
[nil "--[no-]color" "Colorize terminal output"]])
(defn console-level
"Returns the console level selected by quiet or verbose options."
[opts]
(cond (:quiet opts) :warn (:verbose opts) :debug :else :info))
(defn color-mode
"Returns automatic, forced or disabled console coloring."
[opts]
(case (:color opts) true :always false :never :auto))
(defn resolve-options
"Resolves supplied console flags into console settings."
[options]
(when (and (:quiet options) (:verbose options))
(throw (ex-info "--quiet and --verbose are mutually exclusive"
{:type ::shared/invalid-args})))
(cond-> {}
(or (:quiet options) (:verbose options)) (assoc :level (console-level options))
(contains? options :color) (assoc :color (color-mode options))))

View File

@ -0,0 +1,74 @@
;; 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 benches.render-wasm.node.cli.deferred
"Deferred execution entry for build, run and feature comparison commands."
(:require
[app.common.transit :as transit]
[benches.render-wasm.errors :refer [attempt failure?]]
[benches.render-wasm.node.cli.execution :as execution]
[benches.render-wasm.node.outcome :as outcome]
[benches.render-wasm.node.runner.build :as build]
[benches.render-wasm.node.runner.feature-comparison :as feature-comparison]
[benches.render-wasm.node.runner.run :as run]
[benches.render-wasm.node.storage.artifacts :as artifacts]
[benches.render-wasm.report.format :as format]))
(defn- ^:async build-command
[{:keys [get-root signal]} options]
(let [created (atom nil)
result (attempt :catch-fatal
(await (build/prepare
{:root (get-root)
:selection {:default-features? (:default-features options)
:features (:features options)}
:build-timeout-ms (:build-timeout-ms options)
:signal signal :on-created #(reset! created %)})))
result (if (failure? result)
(outcome/failed result {:build @created})
(outcome/succeeded {:build result :log-path (:log-path result)}))]
(cond-> result
@created (assoc :report (str "Build " (:id @created) "\nOutput " (:dir @created))))))
(defn- ^:async run-command
[{:keys [get-root signal services]} options]
(let [{:keys [home report] :as result} (await (run/run (get-root) options signal services))]
(assoc result :report (str "Run " (:run-id home) "\nOutput " (:dir home)
(when report (str "\nReport\n" report))))))
(defn- ^:async ab-command
[{:keys [get-root signal services]} options]
(let [root (get-root)
prepared (await (feature-comparison/prepare root options signal))]
(if (:dry-run options)
(outcome/succeeded {:report (feature-comparison/format-plan prepared)})
(let [{:keys [home exit-code] :as result}
(await (feature-comparison/execute root prepared signal {:services services}))
interrupted (.-aborted signal)
failures (when (and home (= 1 exit-code) (not interrupted)
(= :failed (get-in result [:comparison :status]))
(empty? (:errors result)))
(format/format-feature-comparison-failures (:comparison result) home))]
(cond-> result
home (assoc :report (str "Comparison " (:comparison-id home)
"\nOutput " (:dir home)
"\nStatus " (cond
interrupted "interrupted"
(zero? exit-code) "completed"
:else "failed")
(when failures (str "\n" failures)))))))))
(def ^:private handlers
{"build" build-command "run" run-command "ab" ab-command})
(defn ^:async execute
"Accepts a Transit string containing a complete request and returns its
numeric exit status after final publication."
[text]
(let [request (attempt :catch-fatal (transit/decode-str text))]
(if (failure? request)
(execution/fail request)
(await (execution/execute request handlers {:get-root artifacts/frontend-root})))))

View File

@ -0,0 +1,258 @@
;; 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 benches.render-wasm.node.cli.execution
"Validate benchmark command requests, run their handlers and print results.
Both the CLI entry and the separately compiled renderer command entry use
this lifecycle. Callers supply a complete request and the handlers available
at that entry. Handlers own the command's files, processes and cleanup; this
module supplies cancellation, prints final reports and errors, and returns
a numeric status for the entry to use as its exit code.
`fail` reports parsing, loading or decoding failures that occur before a
request reaches `execute`. Signal helpers also support work outside the
checked command lifecycle. Execution and failure reporting share console
settings; do not overlap calls that require different settings or streams."
(:require
[app.common.schema :as sm]
[benches.render-wasm.errors :as errors :refer [attempt failure?]]
[benches.render-wasm.node.cli.registry :as registry]
[benches.render-wasm.node.cli.request :as request]
[benches.render-wasm.node.log :as log]
[benches.render-wasm.node.outcome :as outcome]
[benches.render-wasm.node.settings :as settings]
[benches.render-wasm.report.format :as format]))
(defn on-signals
"Listen for process interrupts so the caller can stop work and finish cleanup.
Args:
callback: Function called with the string `SIGINT` or `SIGTERM` each time the
Node process receives that signal while the listeners are installed.
Returns:
An idempotent function that removes only the listeners installed by this
call. Call it when the work settles. This helper does not abort work,
wait for cleanup or choose an exit status."
[callback]
(let [interrupt (fn [] (callback "SIGINT"))
terminate (fn [] (callback "SIGTERM"))]
(.on js/process "SIGINT" interrupt)
(.on js/process "SIGTERM" terminate)
(fn []
(.removeListener js/process "SIGINT" interrupt)
(.removeListener js/process "SIGTERM" terminate))))
(defn ^:async with-interruption
"Run work with cooperative cancellation and preserve its result on interruption.
The first SIGINT or SIGTERM aborts the supplied signal. Its reason is an
exception with `:signal` set to that signal's name. Later signals do not
replace the reason or force termination. The work must observe cancellation
and settle after its own cleanup; this wrapper imposes no timeout and cannot
preempt synchronous code. Listeners are removed after the work settles.
Args:
f: Function called once with an AbortSignal. Return an outcome map, or a
promise of one, containing `:exit-code`, an `:errors` vector and
`:log-path` (nil when there is no log). Additional fields may retain partial
results, file paths or a final `:report` string.
Returns:
A promise of the outcome. Thrown or rejected failures become outcomes with
exit code 1. After interruption, set the exit code to 130 for SIGINT or 143
for SIGTERM and retain only errors recognized by `errors/fatal?`. Other
outcome fields are preserved. This function does not print the outcome."
[f]
(let [controller (js/AbortController.)
signal (.-signal controller)
interrupted (atom nil)
remove-signals (on-signals
(fn [signal-name]
(when-not (.-aborted signal)
(reset! interrupted signal-name)
(.abort controller
(ex-info "interrupted"
{:type ::interrupted :signal signal-name})))))]
(try
(let [result (attempt :catch-fatal (await (f signal)))
finished (if (failure? result) (outcome/failed result {}) result)]
(cond-> finished
@interrupted (-> (update :errors #(filterv errors/fatal? %))
(assoc :exit-code (outcome/interruption-code @interrupted)))))
(finally
(remove-signals)))))
(defn- configure-console
[console local]
(log/configure (merge (select-keys local [:stdout :stderr]) console)))
(defn- check-gpu
[command options]
(when (and (:require-gpu options)
(or (= command "run")
(and (= command "ab") (not (:dry-run options))))
(not= "1" (.-PENPOT_GPU (.-env js/process))))
(throw (ex-info (str "--require-gpu needs PENPOT_GPU=1. " settings/gpu-guidance)
{:type ::gpu-required}))))
(defn- log-failures
[finished]
(doseq [[index cause] (map-indexed vector (:errors finished))]
(let [data (ex-data cause)
context (mapv (fn [[key value]] [key (errors/format-value value 1024)])
(errors/project-context data))
path (when-let [path (:path data)]
(errors/format-value path 1024))
validation (some-> (::sm/explain data) sm/simplify)
entries (:errors (errors/evidence (or (:phase data) :execution) cause))
alias? (and (seq entries)
(= (errors/bounded-text (errors/message-of cause) 1024) (:message (first entries))))
entry-context (when (and alias?
(not= (select-keys data [:phase :operation :path])
(select-keys (first entries) [:phase :operation :path])))
(format/format-error-entry
(select-keys (first entries) [:phase :operation :path])))
entries (if alias?
(rest entries)
entries)]
(log/error {:message (errors/message-of cause)
:fields (into (mapv (fn [[key value]]
[key (if (string? value)
(errors/terminal-text value 1024)
(errors/format-value value 1024))])
(filterv (comp some? second)
[[:type (:type data)] [:cause-type (:cause-type data)]
[:path path]
[:role (:role data)] [:operation (:operation data)]
[:phase (:phase data)] [:build (:build-id data)]
[:reason (:reason data)] [:manifest-error (:manifest-error data)]
[:baseline (:baseline data)] [:candidate (:candidate data)]
[:features (:features data)]
[:count (:count data)] [:max-features (:max-features data)]
[:validation validation]]))
(concat context
(map #(vector :difference (errors/format-value (:field %) 1024)) (:mismatches data))
(when entry-context [[:error-context entry-context]])
(map #(vector :error (format/format-error-entry %)) (take 8 entries))
(when (> (count entries) 8)
[[:errors-omitted (- (count entries) 8)]])))
:tail-log (:log-path data)
:tail? (zero? index)}))))
(defn- report-fallback
[cause local]
(attempt :catch-fatal
(let [message (errors/message-of cause)
stream (or (:stderr local) (.-stderr js/process))]
(.write ^js stream
(str "Could not report benchmark failure: "
(errors/terminal-text message 1024) "\n")))))
(defn- finish
"Print the optional report and errors without retrying failed report writes."
[result local]
(binding [log/*log-path* (:log-path result)]
(let [finished (if-some [report (:report result)]
(outcome/publish result #(log/result report))
result)
reported (attempt :catch-fatal (log-failures finished))]
(if (failure? reported)
(do
(report-fallback reported local)
(:exit-code (outcome/add-error finished reported)))
(:exit-code finished)))))
(defn fail
"Report a failure that occurred before checked command execution could begin.
Use this for argument parsing, module loading or request decoding failures.
Do not report a failure again after `execute` has returned its numeric status.
Args:
cause: Error or thrown value to report. Error data may supply a log path,
usage text, validation details or comparison differences for diagnosis.
console: Trusted console settings with `:level` (`:info`, `:warn`, `:debug`)
and `:color` (`:auto`, `:always`, `:never`). An empty map uses defaults.
Do not take these settings from a request that failed validation.
local: Optional `:stdout` and `:stderr` writable streams with a Node-style
`write` method. Omitted streams use the process streams.
Returns:
The number 1 after attempting to write the error to stderr and any log
named by its error data. Reporting failures get one bounded stderr fallback
attempt. The one-argument form uses default console settings and streams."
([cause]
(fail cause {} {}))
([cause console local]
(configure-console console local)
(finish (outcome/failed (errors/as-failure cause) {}) local)))
(defn ^:async execute
"Validate a command request, await its handler and print its final outcome.
Check the request without adding defaults or coercing values. Invalid
requests use default console settings; valid requests choose their console
settings before handler lookup and command policy checks. Unsupported
commands and policy failures are reported without invoking a handler or
looking up the frontend root.
For `run` and non-dry-run `ab` commands, `:require-gpu` requires the environment
variable `PENPOT_GPU` to equal the string `1` before invoking the handler.
This checks an environment prerequisite, not actual GPU availability.
Args:
received: Complete request map with a string `:command`, resolved
`:console` settings and command-specific `:options`. Obtain it from
`cli/parse-resolve` or decode it from the CLI's Transit request. Missing
required fields and unknown fields fail validation.
handlers: Map of supported command-name strings to functions called as
`(handler context options)`. Return an outcome or a promise of one, with
`:exit-code`, an `:errors` vector and `:log-path` (nil when there is no log).
An optional `:report` string supplies all final stdout text. Other
result fields do not generate output. Handlers own progress logging
and must await their work and cleanup before returning.
local: Optional map containing `:stdout` and `:stderr` writable streams,
a zero-argument `:get-root` function returning the frontend directory,
and a `:services` map of handler-specific overrides. The context contains
an AbortSignal at `:signal`, plus `:get-root` and `:services` when supplied.
Root lookup and service calls remain the handler's responsibility. These
functions, streams and services are local, not part of the request.
Returns:
A promise of a numeric exit status after the handler settles and final
output writes have been attempted. Print `:report` once, when present,
followed by errors on stderr, using the outcome's log path for file output.
No report means no final stdout text. Output writes do not wait for streams
to drain. The two-argument form uses process streams and supplies no root
function or services.
Validation, policy and handler failures produce status 1. Interruption
follows `with-interruption`: await cleanup, preserve partial result fields
and fatal errors, and return 130 for SIGINT or 143 for SIGTERM. A report or
error-reporting failure changes other statuses to 1, with one bounded
stderr fallback if error reporting fails. Interrupted statuses take
precedence. Failed report writes are not retried."
([received handlers]
(execute received handlers {}))
([received handlers local]
(configure-console {} local)
(let [result (attempt :catch-fatal
(let [{:keys [command console options]} (request/check received)]
(configure-console console local)
(let [handler (or (get handlers command)
(throw (ex-info "command is unavailable at this execution entry"
{:type ::unsupported-command :command command})))
options (registry/check-options command options)]
(check-gpu command options)
(await (with-interruption
(fn [signal]
(handler (assoc (select-keys local [:get-root :services])
:signal signal)
options)))))))
result (if (failure? result) (outcome/failed result {}) result)]
(finish result local))))

View File

@ -0,0 +1,25 @@
;; 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 benches.render-wasm.node.cli.help
"Options for benchmark CLI help."
(:require
[benches.render-wasm.node.cli.registry :as registry]))
(def schema:options
[:map {:closed true}
[:topic {:default nil}
[:maybe (into [:enum] registry/command-names)]]])
(def ^:const usage "runner COMMAND [options]")
(def ^:const description
"Renderer benchmarks. Use COMMAND --help for command options.
Numeric options accept JavaScript number syntax, including exponents, radix
prefixes and surrounding whitespace, within each option's integer bounds.")
(def cli-options
[["-h" "--help" "Print help"]])

View File

@ -0,0 +1,60 @@
;; 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 benches.render-wasm.node.cli.main
"Command-line entry for renderer benchmarks and saved results."
(:require
["path" :as path]
[app.common.transit :as transit]
[benches.render-wasm.errors :refer [attempt failure?]]
[benches.render-wasm.node.cli.execution :as execution]
[benches.render-wasm.node.cli.resolve :as cli]
[benches.render-wasm.node.outcome :as outcome]
[benches.render-wasm.node.report.compare :as compare]
[benches.render-wasm.node.report.merge :as merge]
[benches.render-wasm.node.storage.artifacts :as artifacts]))
(defn- help-command
[_ {:keys [topic]}]
(outcome/succeeded {:report (cli/get-help topic)}))
(defn- compare-command
[_ {:keys [baseline-path candidate-path] :as options}]
(let [result (compare/execute baseline-path candidate-path options)]
(outcome/succeeded {:comparison (:comparison result) :report (:text result)})))
(defn- merge-command
[{:keys [get-root]} {:keys [base-path additional-path] :as options}]
(let [{:keys [run home]} (merge/execute (get-root) base-path additional-path options)]
(outcome/succeeded {:run run :home home :log-path (:log-path home)
:report (str "Run " (:run-id run) "\nOutput " (:dir home))})))
(defn- purge-command
[{:keys [get-root]} {:keys [target]}]
(artifacts/purge (get-root) target)
(outcome/succeeded {}))
(def ^:private handlers
{"help" help-command "compare" compare-command "merge" merge-command "purge" purge-command})
(defn- ^:async run-cli
[argv]
(let [request (attempt :catch-fatal (cli/parse-resolve argv))]
(if (failure? request)
(execution/fail request)
(if (contains? handlers (:command request))
(await (execution/execute request handlers {:get-root artifacts/frontend-root}))
(let [status (attempt :catch-fatal
(let [commands (js/require (path/join js/__dirname "commands.cjs"))]
(await (.execute commands (transit/encode-str request)))))]
(if (failure? status)
(execution/fail status (:console request) {})
status))))))
(defn ^:async -main
"Runs argv and sets the process exit status after publication and cleanup."
[& argv]
(set! (.-exitCode js/process) (await (run-cli argv))))

View File

@ -0,0 +1,68 @@
;; 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 benches.render-wasm.node.cli.registry
"Ordered command definitions for parsing, request schemas and help."
(:require
[benches.render-wasm.node.cli.commands.ab :as ab]
[benches.render-wasm.node.cli.commands.build :as build]
[benches.render-wasm.node.cli.commands.compare :as compare]
[benches.render-wasm.node.cli.commands.merge :as merge]
[benches.render-wasm.node.cli.commands.purge :as purge]
[benches.render-wasm.node.cli.commands.run :as run]))
(def commands
[["build" {:usage build/usage
:description build/description
:options build/cli-options
:resolve build/resolve-options
:schema build/schema:options
:check build/check-options}]
["run" {:usage run/usage
:description run/description
:options run/cli-options
:resolve run/resolve-options
:schema run/schema:options
:help-options {:build {:kind :prepare}}
:help-defaults run/get-help-defaults
:check run/check-options}]
["ab" {:usage ab/usage
:description ab/description
:options ab/cli-options
:resolve ab/resolve-options
:schema ab/schema:options
:check ab/check-options}]
["compare" {:usage compare/usage
:description compare/description
:options compare/cli-options
:resolve compare/resolve-options
:schema compare/schema:options}]
["merge" {:usage merge/usage
:description merge/description
:options merge/cli-options
:resolve merge/resolve-options
:schema merge/schema:options}]
["purge" {:usage purge/usage
:description purge/description
:options purge/cli-options
:resolve purge/resolve-options
:schema purge/schema:options}]])
(def command-names (mapv first commands))
(def ^:private definitions (into {} commands))
(defn get-definition
"Returns a command definition, or nil for an unknown command."
[command]
(get definitions command))
(defn check-options
"Checks command policy and returns the resolved options."
[command options]
(if-let [check (:check (get-definition command))]
(check options)
options))

View File

@ -0,0 +1,41 @@
;; 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 benches.render-wasm.node.cli.request
"Resolved benchmark requests, sender defaults and receiver validation."
(:require
[app.common.schema :as sm]
[benches.render-wasm.node.cli.console :as console]
[benches.render-wasm.node.cli.help :as help]
[benches.render-wasm.node.cli.registry :as registry]
[malli.transform :as mt]))
(defn- command-schema
[command options]
[:map {:closed true}
[:command [:= command]]
[:console {:default {}} console/schema:console]
[:options {:default {}} options]])
(def schema:request
(into [:multi {:dispatch :command}]
(map (fn [[command definition]]
[command (command-schema command (:schema definition))]))
(conj registry/commands ["help" {:schema help/schema:options}])))
(def ^:private default-decoder
(delay (sm/decoder schema:request (mt/default-value-transformer))))
(defn apply-defaults
"Fills contextual defaults for the selected command and build source."
[partial-request]
(@default-decoder partial-request))
(def check
"Returns a complete valid request, or throws with schema explain data."
(sm/check-fn schema:request
:hint "invalid renderer benchmark request"
:type ::invalid-request))

View File

@ -0,0 +1,106 @@
;; 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 benches.render-wasm.node.cli.resolve
"Command-specific argument resolution and help for renderer benchmarks."
(:require
[benches.render-wasm.features.plan :as feature-plan]
[benches.render-wasm.node.cli.console :as console]
[benches.render-wasm.node.cli.help :as help]
[benches.render-wasm.node.cli.registry :as registry]
[benches.render-wasm.node.cli.request :as request]
[benches.render-wasm.node.cli.shared :as shared]
[clojure.string :as str]
[clojure.tools.cli :as tools]))
(defn- get-definition
[topic]
(if (nil? topic)
{:usage help/usage :description help/description :options []}
(registry/get-definition topic)))
(defn- get-declarations
[definition]
(into help/cli-options (concat console/cli-options (:options definition))))
(defn- argument-error
[topic message cause]
(let [usage (or (:usage (get-definition topic)) help/usage)]
(ex-info (str message "\nUsage: " usage)
(assoc (ex-data cause)
:type ::invalid-args :cause-type (:type (ex-data cause))
:command topic :usage usage)
cause)))
(defn parse-resolve
"Returns a complete request for command-first argv, or throws an argument error.
A leading pnpm forwarding delimiter is accepted."
[argv]
(let [argv (if (= "--" (first argv)) (rest argv) argv)
first-arg (first argv)
topic (when (and first-arg (not (str/starts-with? first-arg "-"))) first-arg)
args (if topic (rest argv) argv)]
(when (and topic (nil? (registry/get-definition topic)))
(throw (argument-error topic (str "unknown command: " topic) nil)))
(let [definition (get-definition topic)]
(try
(let [{:keys [options arguments errors]}
(tools/parse-opts args (get-declarations definition) :no-defaults true)
_ (when (seq errors)
(throw (ex-info (str/join "\n" errors) {:type ::shared/invalid-args})))
console (console/resolve-options options)
partial (if (:help options)
(do
(when (and (nil? topic) (seq arguments))
(throw (ex-info "a command must precede its options"
{:type ::shared/invalid-args})))
{:command "help" :console console :options {:topic topic}})
(do
(when-not topic
(throw (ex-info "a command is required" {:type ::shared/invalid-args})))
{:command topic :console console
:options ((:resolve definition)
(dissoc options :help :quiet :verbose :color) arguments)}))
resolved (request/check (request/apply-defaults partial))]
(update resolved :options #(registry/check-options (:command resolved) %)))
(catch :default cause
(if (contains? #{::shared/invalid-args ::request/invalid-request
::feature-plan/too-many-features ::feature-plan/confirmation-required}
(:type (ex-data cause)))
(throw (argument-error topic (ex-message cause) cause))
(throw cause)))))))
(defn- get-defaults
[topic]
(let [definition (get-definition topic)
partial {:command (or topic "help") :options (or (:help-options definition) {})}
resolved (request/apply-defaults partial)
options ((or (:help-defaults definition) identity) (:options resolved))]
(merge (:console resolved) options)))
(defn get-help
"Returns root help for nil or help for a known command name."
[topic]
(let [definition (get-definition topic)
defaults (get-defaults topic)
summary (:summary
(tools/parse-opts
[] (get-declarations definition)
:no-defaults true
:summary-fn
(fn [specs]
(tools/summarize
(mapv (fn [{:keys [id] :as spec}]
(if (contains? defaults id)
(assoc spec :default (get defaults id)
:default-desc (pr-str (get defaults id)))
spec))
specs)))))]
(str "Usage: " (:usage definition) "\n\n" (:description definition) "\n\n"
(when (nil? topic)
(str (str/join "\n" (map (fn [[_ definition]] (str " " (:usage definition))) registry/commands))
"\n\n"))
"Options:\n" summary)))

View File

@ -0,0 +1,109 @@
;; 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 benches.render-wasm.node.cli.shared
"Value constraints and option groups shared by benchmark commands."
(:require
[app.common.schema :as sm]
[benches.render-wasm.features.selection :as selection]
[benches.render-wasm.node.settings :as settings]
[benches.render-wasm.node.storage.artifacts :as artifacts]
[benches.render-wasm.random :as random]
[clojure.string :as str]))
(def schema:nonnegative-count
[:int {:min 0 :max js/Number.MAX_SAFE_INTEGER}])
(def schema:positive-count
[:int {:min 1 :max js/Number.MAX_SAFE_INTEGER}])
(def schema:timeout-ms
"Positive integer milliseconds within Node's timer range."
[:int {:min 1 :max 2147483647}])
(def schema:seed
[:fn {:error/message "expected a uint32 seed"} random/seed?])
(def schema:build-id
[:fn {:error/message "expected a benchmark artifact ID"} artifacts/valid-id?])
(def schema:feature
[:fn {:error/message "expected a Cargo feature name"} selection/feature-name?])
(def schema:measurement
[:map {:closed true}
[:seed {:default 42} schema:seed]
[:filter {:default nil} [:maybe :string]]
[:warmups {:default 3} schema:nonnegative-count]
[:repetitions {:default 10} schema:positive-count]
[:timeout-ms {:default 30000} schema:timeout-ms]
[:startup-timeout-ms {:default settings/default-startup-timeout-ms} schema:timeout-ms]
[:output-dir {:default nil} [:maybe ::sm/text]]
[:screenshots {:default false} :boolean]
[:require-gpu {:default false} :boolean]])
(def schema:comparison-permissions
[:map {:closed true}
[:allow-build-differences {:default false} :boolean]
[:allow-scene-differences {:default false} :boolean]
[:allow-all-differences {:default false} :boolean]])
(defn parse-feature-list
[text]
(:features (selection/feature-selection {:features (mapv str/trim (str/split text #"," -1))})))
(defn- parse-number
[text]
(if (str/blank? text) js/NaN (js/Number text)))
(defn number-option
"Declares a numeric option with the given value constraint.
Values accept JavaScript number syntax, including exponents, radix prefixes
and surrounding whitespace."
[flag description schema message]
[nil flag description
:parse-fn parse-number
:validate [(sm/validator schema) message]])
(def output-option
[nil "--output-dir DIR" "Explicit output home; must not exist"
:validate [(sm/validator ::sm/text) "must be a nonblank path"]])
(def measurement-options
[(number-option "--seed N" "Master scene seed" schema:seed
"must be a uint32 integer")
[nil "--filter SUBSTR" "Select cases whose id contains SUBSTR"]
(number-option "--warmups N" "Warmup attempts per case" schema:nonnegative-count
"must be a nonnegative JavaScript-safe integer")
(number-option "--repetitions N" "Measured attempts per case" schema:positive-count
"must be a positive JavaScript-safe integer")
(number-option "--timeout-ms MS" "Case deadline including setup, attempts and diagnostics" schema:timeout-ms
"must be a positive integer within Node's timer range")
(number-option "--startup-timeout-ms MS" "Host/browser readiness deadline" schema:timeout-ms
"must be a positive integer within Node's timer range")
output-option
[nil "--screenshots" "Capture one screenshot per case"]
[nil "--require-gpu" "Fail when GPU rendering is unavailable"]])
(def comparison-options
[[nil "--allow-build-differences" "Allow recorded build differences"]
[nil "--allow-scene-differences" "Allow scene inputs and fingerprint content differences"]
[nil "--allow-all-differences" "Allow all comparison compatibility differences"]])
(defn build-timeout-option
"Declares a preparation deadline with the given description."
[description]
(number-option "--build-timeout-ms MS" description schema:timeout-ms
"must be a positive integer within Node's timer range"))
(defn check-arguments
"Checks the number of positional arguments."
[arguments expected]
(when-not (= expected (count arguments))
(throw (ex-info (str "expected " expected " positional "
(if (= expected 1) "argument" "arguments")
", received " (count arguments))
{:type ::invalid-args :arguments arguments}))))

View File

@ -0,0 +1,51 @@
;; 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 benches.render-wasm.node.files
"Filesystem paths and storage for benchmark artifacts."
(:require
["fs" :as fs]
["path" :as path]
[benches.render-wasm.errors :as errors :refer-macros [attempt]]
[clojure.string :as str]))
(defn contained?
"True when `file` is `root` or a path below it."
[root file]
(let [relative (path/relative root file)]
(and (not (path/isAbsolute relative))
(not= relative "..")
(not (str/starts-with? relative (str ".." path/sep))))))
(defn get-real-path
"Resolves symlink ancestors even when the final path does not exist yet."
[file]
(let [file (path/resolve file)]
(if (fs/existsSync file)
(fs/realpathSync file)
(path/join (get-real-path (path/dirname file)) (path/basename file)))))
(defn atomic-write
"Writes (UTF-8) content to a temporary file and renames it over `file`,
so readers see either the previous complete file or the new one. The parent
directory must exist.
A failed write or rename leaves the previous file untouched and attempts to
remove the temporary file. Failures retain operation context in order."
[file content]
(let [temporary (str file "." (random-uuid) ".tmp")
written (attempt :catch-fatal
(fs/writeFileSync temporary content #js {:encoding "utf8" :flag "wx"})
nil)
renamed (when-not written
(attempt :catch-fatal (fs/renameSync temporary file) nil))
removed (when (or written renamed)
(attempt :catch-fatal (fs/rmSync temporary #js {:force true}) nil))]
(when-let [failure (errors/combine
[{:phase "storage" :operation :write-temporary :path file :error written}
{:phase "storage" :operation :rename-temporary :path file :error renamed}
{:phase "storage" :operation :remove-temporary :path temporary :error removed}])]
(throw failure))))

View File

@ -0,0 +1,181 @@
;; 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 benches.render-wasm.node.log
"Console output and scoped file logs for the benchmark CLI.
Public API:
(log/configure {:level :info
:color :auto
:stdout stream
:stderr stream})
(log/result text)
(log/info text)
(log/debug text)
(log/warn text)
(log/error {:message ...
:fields [...]
:tail-log ...})
Every output function uses the same private one:
...
└── log/write <-- this guy
├── append text to the bound log file
└── filter and render console output
├── result → stdout
└── diagnostics → stderr
The CLI selects the logging threshold for the console:
- Default: :info
- --quiet: :warn
- --verbose: :debug
But console filtering and file logging are independent:
| Function | Console destination | Console visibility | File log |
|----------|---------------------|---------------------|----------|
| result | stdout | Always | Always |
| info | stderr | Default and verbose | Always |
| debug | stderr | Verbose | Always |
| warn | stderr | Always | Always |
| error | stderr | Always | Always |
So: quiet mode still records detailed diagnostics in files.
Also, the console renderer adds headings, prefixes, and colors."
(:require-macros
[app.common.exceptions :as ex])
(:require
["fs" :as fs]
[benches.render-wasm.errors :as errors]
[clojure.string :as str]))
;; Each high-level task binds its own path and awaits its work inside that scope:
;; (binding [log/*log-path* (:log-path home)]
;; (await (execute-task ...)))
;; Returning or throwing from a child restores the parent's destination
(def ^:dynamic *log-path* nil)
(defonce ^:private config (atom nil))
(defn configure
"Configures console level, color mode and output streams."
[options]
(reset! config (merge {:level :info :color :auto
:stdout (.-stdout js/process)
:stderr (.-stderr js/process)}
options)))
(defn- color?
[stream mode]
(case mode
:always true
:never false
(and (.-isTTY ^js stream)
(empty? (.-NO_COLOR (.-env js/process)))
(not= "dumb" (.-TERM (.-env js/process))))))
(defn- paint
[enabled code text]
(if enabled (str "\u001b[" code "m" text "\u001b[0m") text))
(defn- render
[level text color]
(str/join "\n"
(map (fn [line]
(case level
:info (if (re-find #"^\S" line) (paint color "1;36" line) line)
:debug (paint color "2" (str "· " line))
:warn (paint color "33" (str "! " line))
:error (paint color "31" line)
line))
(str/split-lines text))))
(defn- write
[level text]
(when-not @config (configure {}))
(let [{:keys [stdout stderr color] console-level :level} @config
text (str/replace (str text) #"\n$" "")
failure (when *log-path*
(try
(fs/appendFileSync *log-path* (str text "\n") "utf8")
nil
(catch :default cause
(errors/fatal-error "cannot append benchmark log"
{:type ::storage-error :log-path *log-path*}
cause))))
enabled (case level
:debug (= :debug console-level)
:info (contains? #{:info :debug} console-level)
true)
stream (if (= :result level) stdout stderr)
display (if (contains? #{:debug :info :warn} level)
(->> (str/split-lines (errors/bounded-text text 1024))
(map #(errors/terminal-text % 1024))
(str/join "\n")
(#(errors/bounded-text % 1024)))
text)]
(when enabled
(.write ^js stream (str (if (= :result level)
display
(render level display (color? stream color))) "\n")))
(when failure
(if (= :error level)
(.write ^js stderr (str "Could not append log: " (errors/terminal-text *log-path* 1024) "\n"))
(throw failure)))))
(defn result [text] (write :result text))
(defn info [text] (write :info text))
(defn debug [text] (write :debug text))
(defn warn [text] (write :warn text))
(defn- get-tail
[path]
(when path
(ex/ignoring
(let [fd (fs/openSync path "r")]
(try
(let [size (.-size (fs/fstatSync fd))
length (min size 8192)
buffer (js/Buffer.alloc length)
read (fs/readSync fd buffer 0 length (- size length))
text (->> (str/split-lines (.toString buffer "utf8" 0 read))
(remove str/blank?)
(take-last 20)
(map #(errors/terminal-text % (* 6 8192)))
(str/join "\n"))
start (max 0 (- (count text) 8192))
first (.charCodeAt text start)
start (if (and (pos? start) (<= 0xDC00 first 0xDFFF)
(<= 0xD800 (.charCodeAt text (dec start)) 0xDBFF))
(inc start)
start)]
(str/split-lines (subs text start)))
(finally (fs/closeSync fd)))))))
(defn error
"Prints a terminal failure with ordered fields and an optional bounded log tail."
[{:keys [message fields tail-log tail?] :or {tail? true}}]
(let [tail-path (or tail-log *log-path*)
fields (cond-> (vec fields)
(and *log-path* (not (some #(= :log (first %)) fields)))
(conj [:log (errors/terminal-text *log-path* 1024)])
(and tail-log (not= tail-log *log-path*))
(conj [:tail-log (errors/terminal-text tail-log 1024)]))
width (reduce max 0 (map #(count (name (first %))) fields))
tail (when tail? (get-tail tail-path))]
(write :error
(str "✖ " (errors/terminal-text message 1024)
(apply str
(map (fn [[key value]]
(let [label (name key)]
(str "\n " label
(apply str (repeat (+ 2 (- width (count label))) " "))
value)))
fields))
(when (seq tail)
(str "\n tail\n" (str/join "\n" (map #(str " " %) tail))))))))

View File

@ -0,0 +1,57 @@
;; 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 benches.render-wasm.node.outcome
"Result of a benchmark command.
An outcome is a map with `:exit-code`. A command that failed adds `:errors`
(a vector, primary failure first) and `:log-path`. Exit code 130 and 143
mark an interruption and no failure replaces them."
(:require
[benches.render-wasm.errors :as errors :refer [attempt failure?]]))
(defn interruption-code
"Returns the exit code for the interrupting signal name: 143 for SIGTERM,
130 otherwise."
[signal-name]
(case signal-name "SIGTERM" 143 130))
(defn succeeded
"Returns a successful outcome with command result data."
[data]
(merge {:exit-code 0 :errors [] :log-path nil} data))
(defn failed
"Returns an outcome for `cause` with defaults for errors, exit code and log.
Args:
cause: Failure supplying the default error and log path.
data: Outcome fields that override defaults, including :errors,
:exit-code and :log-path.
Returns:
An outcome with exit code 1 and the failure's log path as defaults."
[cause data]
(merge {:errors [cause]
:exit-code 1
:log-path (:log-path (ex-data cause))}
data))
(defn add-error
"Appends `cause` to the errors of `outcome`. An exit code that is not an
interruption becomes 1."
[outcome cause]
(-> outcome
(update :errors (fnil conj []) cause)
(update :exit-code #(if (contains? #{130 143} %) % 1))))
(defn publish
"Calls `f` to print a result of `outcome`. A failure of `f` is added to the
errors of the returned outcome."
[outcome f]
(let [published (attempt :catch-fatal (f))]
(cond-> outcome
(failure? published) (add-error published))))

View File

@ -0,0 +1,604 @@
;; 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 benches.render-wasm.node.process
"Deadline-bound Node commands with owned POSIX process groups."
(:require
["child_process" :as child-process]
["fs" :as fs]
["node:readline" :as readline]
[benches.render-wasm.errors :as errors :refer [attempt failure?]]
[clojure.string :as str]))
(def default-termination-grace-ms 5000)
(def ^:private final-termination-wait-ms 5000)
(def ^:private max-timer-delay-ms
"Largest delay that Node timers accept; longer delays overflow a signed 32-bit integer."
2147483647)
(def ^:private default-options
{:termination-grace-ms default-termination-grace-ms
:capture-output? true})
(defn signal-name
"Returns the signal name for an aborted AbortSignal, defaulting to SIGINT."
[signal]
(when (and signal (.-aborted ^js signal))
(let [reason (.-reason ^js signal)]
(cond
(string? reason) reason
(keyword? reason) (name reason)
:else (or (:signal (ex-data reason)) "SIGINT")))))
(defn deadline
"Returns an absolute Date.now() timestamp, in milliseconds. All commands
in a preparation can share it, including commands waiting for a lock.
This is a wall clock deadline, so clock changes affect the time left."
[timeout-ms]
(+ (js/Date.now) timeout-ms))
(defn remaining
"Returns the nonnegative milliseconds left on a Date.now() deadline."
[deadline]
(max 0 (- deadline (js/Date.now))))
(defn- unresolved-cause?
[cause seen]
(cond
(nil? cause) false
(:unresolved-process (ex-data cause)) true
(contains? seen cause) false
:else (recur (ex-cause cause) (conj seen cause))))
(defn unresolved?
"True when a command's process group may still be running.
Args:
cause: An Error, including collected errors and wrapped causes, or nil.
Returns:
True if any error reports :unresolved-process true after termination."
[cause]
(boolean
(some (fn [{:keys [error] :as entry}]
(or (:unresolved-process entry) (unresolved-cause? error #{})))
(errors/get-errors cause))))
(defn- signal-group
[pid signal]
(try
(.kill js/process (- pid) signal)
true
(catch :default cause
(if (= "ESRCH" (.-code cause))
false
(throw cause)))))
(defn- linux-group-member?
[pid entry]
(when (re-matches #"\d+" entry)
(try
;; comm may contain spaces or ')'; fields after its final ')' start
;; with state, ppid and pgrp. Zombies retain a group but no resources.
(let [stat (fs/readFileSync (str "/proc/" entry "/stat") "utf8")
fields (str/split (str/trim (subs stat (inc (.lastIndexOf stat ")")))) #"\s+")]
(and (= pid (js/Number (nth fields 2)))
(not (contains? #{"Z" "X" "x"} (first fields)))))
(catch :default cause
(if (contains? #{"ENOENT" "ESRCH"} (.-code cause))
false
(throw cause))))))
(defn- group-running?
[pid]
(and (signal-group pid 0)
(if (= "linux" (.-platform js/process))
;; Container init need not reap orphan zombies promptly. Waiting
;; for kill(-pgid, 0) to return ESRCH can therefore hang forever.
(some #(linux-group-member? pid %) (array-seq (fs/readdirSync "/proc")))
true)))
(defn- pause
[ms]
(js/Promise. (fn [resolve _] (js/setTimeout resolve ms))))
(defn- get-group-observation
"Checks whether a process group has a live member.
Args:
pid: The group's leader PID.
Returns:
A map with :status set to :running, :stopped or :unknown, and :error
when the operating system rejects the probe."
[pid]
(let [result (attempt :catch-fatal (group-running? pid))]
(if (failure? result)
{:status :unknown :error result}
{:status (if result :running :stopped)})))
(defn- wait-again?
"Decides whether to take another process-group observation.
Args:
observation: A map with :status :running, :stopped or :unknown.
context: The absolute :deadline and :phase, either :grace before SIGKILL
or :confirmation after SIGKILL.
now: Current time in milliseconds since the Unix epoch.
Returns:
True while time remains and the group may be running. Grace ends on a
failed probe; confirmation continues through probe failures."
[observation {:keys [phase deadline]} now]
(and (not= :stopped (:status observation))
(< now deadline)
(or (= :confirmation phase) (= :running (:status observation)))))
(defn- group-failed
"Returns the failure of an operation on a command group.
Args:
kind: The failure type.
action: Text that describes the failed operation. The message appends the
bounded text of the cause.
cause: The Error that the operation raised.
data: Diagnostic data of the failure."
[kind action cause data]
{:type kind
:message (str action ": " (errors/bounded-text (errors/message-of cause) 500))
:cause cause
:data data})
(defn- ^:async wait-for-group
[{:keys [pid deadline signals] :as context} record]
(let [observation (get-group-observation pid)
now (js/Date.now)]
(when-let [cause (:error observation)]
(record :probe-group (group-failed :process-probe "Could not inspect command group" cause {:signals signals})))
(if (wait-again? observation context now)
(do
(await (pause (min 10 (- deadline now))))
(await (wait-for-group context record)))
observation)))
(defn- send-group-signal
[pid signal record]
(let [result (attempt :catch-fatal (signal-group pid signal))]
(when (failure? result)
(record :signal-group
(group-failed :process-signal (str "Could not send " signal " to command group") result {:signal signal})))
result))
(defn- ^:async terminate-group
[pid grace-deadline record]
(let [signals ["SIGTERM"]
term (send-group-signal pid "SIGTERM" record)
grace {:pid pid :phase :grace :signals signals
:deadline (if (failure? term) (js/Date.now) grace-deadline)}
observation (await (wait-for-group grace record))]
(if (= :stopped (:status observation))
{:signals signals :unresolved-process false}
(let [signals (conj signals "SIGKILL")]
(send-group-signal pid "SIGKILL" record)
(let [confirmation (assoc grace :phase :confirmation :signals signals
:deadline (deadline final-termination-wait-ms))
observation (await (wait-for-group confirmation record))
unresolved (not= :stopped (:status observation))]
(when unresolved
(record :terminate-group
{:type :process-unresolved
:message "Command group ownership remains unresolved after SIGKILL"
:data {:fatal true :unresolved-process true :signals signals}}))
{:signals signals :unresolved-process unresolved})))))
(defn- process-error
[{:keys [command args cwd]} result {:keys [type message cause data]}]
(ex-info message
(merge {:type type :command command :args args :cwd cwd} result data)
cause))
(defn- cancelled
[^js signal]
(let [reason (.-reason signal)]
{:type :process-cancelled
:message "Command cancelled"
:cause (when (failure? reason) reason)
:data {:reason (signal-name signal)}}))
(defn- timed-out
[deadline]
{:type :process-timeout
:message "Command deadline expired"
:data {:deadline deadline}})
(defn- spawn-failed
[^js cause]
{:type :process-spawn
:message "Could not spawn command"
:cause cause
:data {:code (.-code cause)}})
(defn- stream-done
[^js stream]
(js/Promise.
(fn [resolve _]
(.once stream "end" resolve)
(.once stream "close" resolve)
(.once stream "error" resolve))))
(defn- drain-output
"Allow queued pipe data to drain, but never wait indefinitely for an EOF."
[done grace-deadline]
(js/Promise.
(fn [resolve _]
(let [timer (js/setTimeout resolve (max 10 (remaining grace-deadline)))]
(.then done
(fn [_]
(js/clearTimeout timer)
(resolve)))))))
(def ^:private empty-result {:exit-code nil :signal nil :stdout "" :stderr ""})
(def ^:private read-failed-message "Could not read command output")
(def ^:private callback-failed-message "Command output callback failed")
(defn- group-ids
"Returns {:pid pid :pgid pid} for the leader of a detached process group."
[pid]
{:pid pid :pgid pid})
(defn- launch-error
[options failure]
(process-error options empty-result failure))
(defn- launch-failure
"Returns the failure that forbids starting a command, or nil.
Args:
options: Command settings with the absolute :deadline and the optional
AbortSignal :signal."
[{:keys [signal deadline]}]
(cond
(and signal (.-aborted ^js signal)) (cancelled signal)
(zero? (remaining deadline)) (timed-out deadline)))
(defn- output-failed
[stream cause message]
{:type :process-output :message message :cause cause :data {:stream stream}})
(defn- completion-failure
"Returns the failure to record for the reason a command completes.
Args:
reason: The exit status {:exit-code :signal} or a failure map with :type.
Returns:
The failure, or nil for a zero exit code. Output failures are recorded
when they occur."
[reason]
(let [kind (:type reason)]
(cond
(= :process-output kind) nil
kind reason
(not= 0 (:exit-code reason)) {:type :process-exit :message "Command exited unsuccessfully"})))
(defn- spawn-child
"Spawns the command in a new process group.
Args:
options: Command settings with :command and literal :args, and optional
:cwd and :env. A map or JS object under :env merges over process.env.
Returns:
The ChildProcess.
Throws:
A launch error when the operating system rejects the spawn."
[{:keys [command args cwd env] :as options}]
(let [environment (js/Object.assign #js {} (.-env js/process) (if (map? env) (clj->js env) env))]
(try
(child-process/spawn command (clj->js args)
#js {:cwd cwd :env environment :detached true
:stdio #js ["pipe" "pipe" "pipe"]})
(catch :default cause
(throw (launch-error options (spawn-failed cause)))))))
(defn- watch-exit
"Observes the exit and the process errors of a child.
Returns:
A map with :promise, which resolves with the first outcome, and :state,
a volatile holding the latest outcome. An outcome is {:exit-code :signal}
or the failure of a process error."
[^js child]
(let [state (volatile! nil)
observe (fn [resolve outcome]
(vreset! state outcome)
(resolve outcome))]
{:state state
:promise (js/Promise.
(fn [resolve _]
(.once child "exit" (fn [code signal] (observe resolve {:exit-code code :signal signal})))
(.once child "error" #(observe resolve (spawn-failed %)))))}))
(defn- destroy-stream
[^js stream]
(when stream (.destroy stream)))
(defn- failure-recorder
"Returns (record operation failure), which appends a failure occurrence.
Args:
options: Command settings. Occurrences carry its :command, :args and :cwd.
child: The ChildProcess.
exit-state: Volatile with the latest exit outcome. Occurrences carry the
exit status known when they occur.
failures: Volatile vector that receives the occurrences in order."
[options ^js child exit-state failures]
(fn [operation failure]
(let [context (merge empty-result
(group-ids (.-pid child))
(select-keys @exit-state [:exit-code :signal]))]
(vswap! failures conj {:phase "process" :operation operation
:error (process-error options context failure)}))))
(defn- new-completion
"Returns the settlement of the completion of a command.
Args:
record: Function from `failure-recorder`.
Returns:
A map with :promise and :settle. `settle` takes a completion reason. The
first reason records its failure, if any, and resolves :promise. Later
reasons do nothing."
[record]
(let [^js completion (js/Promise.withResolvers)
settled (volatile! false)]
{:promise (.-promise completion)
:settle (fn [reason]
(when-not @settled
(vreset! settled true)
(when-let [failure (completion-failure reason)]
(record :run-command failure))
((.-resolve completion) reason)))}))
(defn- watch-deadline
"Calls `settle` with a timeout failure once `deadline` passes.
Args:
deadline: Absolute Date.now() timestamp in milliseconds.
settle: Function of a completion reason.
Returns:
A function that cancels the pending check."
[deadline settle]
(let [timer (volatile! nil)
check (fn check []
(let [ms (remaining deadline)]
(if (zero? ms)
(settle (timed-out deadline))
(vreset! timer (js/setTimeout check (min ms max-timer-delay-ms))))))]
(check)
#(js/clearTimeout @timer)))
(defn- watch-abort
"Calls `settle` with a cancellation failure when `signal` aborts, or at once
when it already aborted.
Args:
signal: An AbortSignal, or nil.
settle: Function of a completion reason.
Returns:
A function that removes the listener."
[^js signal settle]
(if signal
(let [abort (fn [_] (settle (cancelled signal)))]
(.addEventListener signal "abort" abort #js {:once true})
(when (.-aborted signal) (abort nil))
#(.removeEventListener signal "abort" abort))
(constantly nil)))
(defn- watch-completion
"Calls `settle` with the first completion reason among the exit outcome, the
deadline and the abort.
Args:
options: Command settings with :deadline and the optional :signal.
exited: Promise of the exit outcome.
settle: Function of a completion reason.
Returns:
A function that stops watching the deadline and the signal."
[{:keys [deadline signal]} ^js exited settle]
(.then exited settle)
(let [stop-deadline (watch-deadline deadline settle)
stop-abort (watch-abort signal settle)]
(fn []
(stop-deadline)
(stop-abort))))
(defn- open-line-reader
"Returns a readline interface that passes the complete lines of `input` to
`on-line` with `stream`. Callback failures and stream errors go to `fail`.
Stream errors keep going to `fail` after the reader closes."
[^js input stream on-line fail]
(let [reader (readline/createInterface #js {:input input :terminal false :crlfDelay js/Infinity})
unreadable #(fail stream % read-failed-message)]
(.on reader "line"
(fn [line]
(try
(on-line line stream)
(catch :default cause
(fail stream cause callback-failed-message)))))
(.on reader "error" unreadable)
(.once reader "close" #(.on input "error" unreadable))
reader))
(defn- observe-output
"Decodes stdout and stderr of a child as UTF-8 and routes their text.
Appends each chunk to `captured` when :capture-output? is set, and calls
:on-output with each chunk and :on-line with each complete line.
Args:
options: Command settings with :capture-output?, :on-output and :on-line.
child: The ChildProcess.
captured: Volatile map from :stdout and :stderr to vectors of text.
fail: Function of a stream, a cause and a message that receives the
failures of streams and callbacks.
Returns:
A vector with the line readers it opened."
[{:keys [on-output on-line capture-output?]} ^js child captured fail]
(let [route (fn [stream text]
(when capture-output? (vswap! captured update stream conj text))
(when on-output
(try
(on-output text stream)
(catch :default cause
(fail stream cause callback-failed-message)))))
listen (fn [^js input stream]
(.setEncoding input "utf8")
(.on input "data" #(route stream %))
(when-not on-line
(.on input "error" #(fail stream % read-failed-message))))]
(listen (.-stdout child) :stdout)
(listen (.-stderr child) :stderr)
(if on-line
[(open-line-reader (.-stdout child) :stdout on-line fail)
(open-line-reader (.-stderr child) :stderr on-line fail)]
[])))
(defn- close-stdin
"Closes the stdin of a child. Write errors go to `fail`, except EPIPE, which
a command that exits without reading stdin causes."
[^js child fail]
(.on (.-stdin child) "error"
(fn [cause]
(when-not (= "EPIPE" (.-code cause))
(fail :stdin cause "Could not close command stdin"))))
(.end (.-stdin child)))
(defn- command-result
"Returns the exit status and the captured text of a finished command."
[status captured]
{:exit-code (:exit-code status)
:signal (:signal status)
:stdout (str/join "" (:stdout captured))
:stderr (str/join "" (:stderr captured))})
(defn- ^:async terminate-and-drain
"Terminates the process group of a child and drains its queued output.
Args:
options: Command settings with :termination-grace-ms.
child: The ChildProcess.
exit: Map from `watch-exit`.
output-done: Promise that resolves when stdout and stderr end.
record: Function from `failure-recorder`.
Returns:
A promise of {:status exit-status :termination termination}. The status
comes from the exit of the child. When the group stays unresolved, the
status is the one already known, and the child no longer keeps this
process alive."
[{:keys [termination-grace-ms]} ^js child {exit-state :state exited :promise} output-done record]
(let [grace-deadline (deadline termination-grace-ms)
termination (await (terminate-group (.-pid child) grace-deadline record))
unresolved? (:unresolved-process termination)]
(when unresolved? (.unref child))
(let [status (if unresolved? @exit-state (await exited))]
(await (drain-output output-done grace-deadline))
{:status status :termination termination})))
(defn- ^:async supervise
"Watches a spawned command until it completes, then terminates its group.
Args:
options: The options of `run` with defaults applied.
child: The spawned ChildProcess.
exit: Map from `watch-exit`.
Returns:
A promise of {:exit-code 0 :stdout string :stderr string}.
Throws:
The Error that combines the recorded failures with the result, the
termination and the process identity."
[options ^js child exit]
(let [pid (.-pid child)
failures (volatile! [])
captured (volatile! {:stdout [] :stderr []})
record (failure-recorder options child (:state exit) failures)
{completed :promise settle :settle} (new-completion record)
fail (fn [stream cause message]
(let [failure (output-failed stream cause message)]
(record :read-output failure)
(settle failure)))
output-done (js/Promise.all #js [(stream-done (.-stdout child))
(stream-done (.-stderr child))])
readers (observe-output options child captured fail)
stop-waiting (watch-completion options (:promise exit) settle)]
(try
(close-stdin child fail)
(await completed)
(stop-waiting)
(let [{:keys [status termination]} (await (terminate-and-drain options child exit output-done record))
result (command-result status @captured)
failure (errors/combine @failures)]
(if failure
(throw (errors/with-context failure (errors/message-of failure)
(merge result termination (group-ids pid))))
(dissoc result :signal)))
(finally
(run! #(.close ^js %) readers)
(stop-waiting)))))
(defn ^:async run
"Runs a command and owns its detached POSIX process group.
An expired deadline or aborted signal starts no process. Output callbacks
receive UTF-8 chunks or complete lines, including a final unterminated
line. Completion follows the exit of the root process, not the close of its
output pipes, because descendants can keep the pipes open. Cleanup sends
SIGTERM and waits the termination grace. If the group still runs, it sends
SIGKILL once and allows five more seconds for the group to stop. Unresolved
ownership produces a fatal failure.
Args:
options: Command settings with :command, literal :args, optional :cwd and
:env, absolute :deadline and optional AbortSignal under :signal.
:termination-grace-ms defaults to 5000. :on-output receives text and
:stdout/:stderr; :on-line receives a line and its stream. Output capture
defaults to true and accepts :capture-output? false for streamed output.
Returns:
A promise resolving to {:exit-code 0 :stdout string :stderr string}.
Throws:
Failures reject with command, output and process identity. Collected
occurrences retain execution, output, signalling and probe failures in
order. :process-unresolved marks unresolved group ownership as fatal.
Cancellation records the signal name as :reason and retains an Error
supplied as the abort reason as a cause."
[options]
(let [options (merge default-options options)]
(when-let [failure (launch-failure options)]
(throw (launch-error options failure)))
(let [^js child (spawn-child options)
exit (watch-exit child)]
(try
(if (.-pid child)
(await (supervise options child exit))
(throw (launch-error options (await (:promise exit)))))
(finally
(destroy-stream (.-stdin child))
(destroy-stream (.-stdout child))
(destroy-stream (.-stderr child)))))))

View File

@ -0,0 +1,46 @@
;; 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 benches.render-wasm.node.report.compare
"Offline comparison of two saved schema:run records.
Inputs are user file paths resolved from the caller's cwd."
(:require
[benches.render-wasm.node.storage.runs :as runs]
[benches.render-wasm.report.compare :as compare]
[benches.render-wasm.report.format :as format]
[benches.render-wasm.result :as result]))
(defn execute
"Reads and compares two saved run files.
Runs are only compared if the build options and scene configuration match.
These can be overriden via flags.
It is also possible to restrict comparison to specific scene ids.
Options:
- `:case-pair` contains the baseline and candidate selectors, or nil.
- The three `:allow-*-differences` options select build, scene or all
compatibility permissions.
Returns the validated `comparison` with its selected permissions, retained
differences and formatted text."
[baseline-path candidate-path {:keys [case-pair allow-build-differences
allow-scene-differences allow-all-differences]}]
(let [baseline (runs/get-record baseline-path {:role :baseline})
candidate (runs/get-record candidate-path {:role :candidate})
comparison (result/check-record
(compare/compare-runs
baseline candidate
(cond-> {:allow-build-differences (boolean allow-build-differences)
:allow-scene-differences (boolean allow-scene-differences)
:allow-all-differences (boolean allow-all-differences)}
case-pair
(assoc :pairs (compare/selector-pair baseline candidate
case-pair)))))]
{:comparison comparison
:text (format/format-comparison comparison)}))

View File

@ -0,0 +1,28 @@
;; 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 benches.render-wasm.node.report.merge
"Offline merge of two saved schema:run records."
(:require
["fs" :as fs]
[benches.render-wasm.node.storage.runs :as runs]
[benches.render-wasm.result :as result]))
(defn execute
"Reads and merges two saved run files from the caller's cwd, then writes a
new run home. Inputs remain unchanged. Returns the merged record and home."
[root base-path additional-path {:keys [output-dir]}]
(let [base (runs/get-record base-path {:role :base})
additional (runs/get-record additional-path {:role :additional})
run-id (str (random-uuid))
merged (result/merge-runs base additional {:run-id run-id})
home (runs/create-home {:root root
:run-id run-id
:output-dir output-dir})]
(runs/write-record! home merged)
(fs/appendFileSync (:log-path home)
(str "merge " (:run-id base) " + " (:run-id additional) "\n"))
{:run merged :home home}))

View File

@ -0,0 +1,385 @@
;; 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 benches.render-wasm.node.runner.browser
"Owns Chromium through process/run from spawn, then attaches Playwright by CDP.
Shutdown stops the owned process group before disconnecting the client."
(:require-macros
[app.common.exceptions :as ex])
(:require
["fs" :as fs]
["os" :as os]
["path" :as path]
["playwright" :as playwright]
[app.common.transit :as transit]
[benches.render-wasm.errors :as errors :refer [attempt]]
[benches.render-wasm.identity :as identity]
[benches.render-wasm.node.process :as process]
[benches.render-wasm.node.settings :as settings]))
(defn gpu-active?
"Returns whether the development container enables GPU access."
[]
(= "1" (.-PENPOT_GPU (.-env js/process))))
(defn launch-settings
"Returns the resolved Chromium settings for this environment."
[]
{:headless true
:transport :cdp
:args (cond-> ["--headless"
"--no-sandbox"
"--disable-dev-shm-usage"
"--enable-unsafe-swiftshader"
"--no-first-run"
"--no-default-browser-check"
"--disable-background-networking"
"--disable-background-timer-throttling"
"--disable-renderer-backgrounding"
"--hide-scrollbars"
"--mute-audio"]
(gpu-active?) (into ["--enable-gpu"
"--use-gl=angle"
"--use-angle=vulkan"]))})
(defn within
"Calls work while its absolute deadline and optional AbortSignal allow it.
Clears the timer/listener on every outcome and handles late rejections.
The caller owns shutdown of the browser running the work."
[work {:keys [deadline signal phase]}]
(js/Promise.
(fn [resolve reject]
(let [settled (atom false)
timer (atom nil)]
(letfn [(cleanup []
(js/clearTimeout @timer)
(when signal
(.removeEventListener ^js signal "abort" aborted)))
(succeeded [value]
(when (compare-and-set! settled false true)
(cleanup)
(resolve value)))
(failed [cause]
(when (compare-and-set! settled false true)
(cleanup)
(reject cause)))
(aborted []
(failed (or (.-reason ^js signal)
(ex-info "browser work aborted" {:type ::aborted :phase phase}))))
(expired []
(failed (ex-info "browser work timed out" {:type ::timeout :phase phase})))]
(when signal
(.addEventListener ^js signal "abort" aborted #js {:once true}))
(cond
(and signal (.-aborted ^js signal))
(aborted)
(zero? (process/remaining deadline))
(expired)
:else
(do
(reset! timer (js/setTimeout expired (process/remaining deadline)))
(try
(.then (js/Promise.resolve (work)) succeeded failed)
(catch :default cause
(failed cause))))))))))
(defn- deadline-failure
[cause deadline]
(if (contains? (ex-data cause) ::deadline-exceeded)
cause
(errors/with-context cause (errors/message-of cause)
{::deadline-exceeded (or (= ::timeout (:type (ex-data cause)))
(zero? (process/remaining deadline)))})))
(defn evaluate
"Calls a fixed bridge method, optionally with a Transit string.
JSON quoting embeds the argument; descriptors and results remain Transit."
([^js page method]
(.evaluate page (str "window.__benchBridge." method "()")))
([^js page method text]
(.evaluate page (str "window.__benchBridge." method "(" (.stringify js/JSON text) ")"))))
(defn- endpoint-waiter
"Keeps the last 64 KiB of startup stderr until a complete endpoint line arrives."
[]
(let [callbacks (atom nil)
settled (atom false)
output (atom "")
address (atom nil)
promise (js/Promise. (fn [resolve reject]
(reset! callbacks {:resolve resolve :reject reject})))]
;; Cancellation may precede the first within call. Keep the endpoint's
;; rejection handled even when that call never subscribes to it.
(.catch promise (fn [_] nil))
{:promise promise
:address address
:on-output (fn [text stream]
(when (and (= :stderr stream) (not @settled))
(let [buffer (str @output text)]
(if-let [[_ endpoint] (re-find #"DevTools listening on (ws://[^\s]+)\r?\n" buffer)]
(do
(reset! settled true)
(reset! address endpoint)
(reset! output "")
((:resolve @callbacks) endpoint))
(reset! output (subs buffer (max 0 (- (count buffer) 65536))))))))
:failed (fn [cause]
(when (compare-and-set! settled false true)
((:reject @callbacks)
(ex-info "Chromium exited before its CDP endpoint became ready"
(merge (errors/project-context (ex-data cause))
{:type ::endpoint-exit :stderr @output})
cause))))}))
(defn- ^:async stop-process
[{:keys [controller process]}]
(.abort controller)
(try
(await process)
nil
(catch :default cause
(let [entries (remove (fn [{:keys [error]}]
(and (= :process-cancelled (:type (ex-data error)))
(not (errors/fatal? error))))
(errors/get-errors cause))]
(when-let [failure (errors/combine entries)]
(throw (errors/with-data failure (errors/project-context (ex-data cause)))))))))
(defn- disconnect
"Disconnects only after the group has stopped. A late CDP attachment is also
closed by this continuation; the caller waits at most one termination grace."
[{:keys [connection]}]
(within (fn []
(if-let [pending @connection]
(.then pending
(fn [^js browser]
(when (.isConnected browser)
(.close browser)))
(fn [_] nil))
nil))
{:deadline (process/deadline process/default-termination-grace-ms)
:phase :browser-disconnect}))
(defn ^:async close-page
"Closes an owned page and its context within one termination grace.
Repeated or already-closed pages resolve without work."
[{:keys [page context]}]
(when (or page context)
(let [deadline (process/deadline process/default-termination-grace-ms)
page-failure
(attempt :catch-fatal
(when (and page (not (.isClosed ^js page)))
(await (within #(.close ^js page)
{:deadline deadline :phase :browser-page-close})))
nil)
context-failure
(attempt :catch-fatal
(when context
(await (within #(.close ^js context)
{:deadline deadline :phase :browser-context-close})))
nil)]
(when-let [cause (errors/combine
[{:phase "cleanup" :operation :close-page :error page-failure}
{:phase "cleanup" :operation :close-context :error context-failure}])]
(throw cause)))))
(defn- ^:async shutdown-owner
"Disposes the owned process group, CDP connection and profile.
Successful process shutdown releases page/context resources. Other shutdown
failures retain the available page/context failure evidence."
[{:keys [user-data-dir] :as owner}]
(let [page-failure (attempt :catch-fatal (await (close-page owner)) nil)
process-failure (attempt :catch-fatal (await (stop-process owner)) nil)
disconnect-failure (attempt :catch-fatal (await (disconnect owner)) nil)
profile-failure (when-not (process/unresolved? process-failure)
(attempt :catch-fatal
(fs/rmSync user-data-dir #js {:recursive true :force true})
nil))]
(when (or process-failure disconnect-failure profile-failure)
(throw (errors/with-data
(errors/combine
[{:phase "cleanup" :operation :close-page :error page-failure}
{:phase "cleanup" :operation :stop-process :profile-path user-data-dir :error process-failure}
{:phase "cleanup" :operation :disconnect-browser :error disconnect-failure}
{:phase "cleanup" :operation :remove-profile :path user-data-dir :error profile-failure}])
{:profile-path user-data-dir})))))
(defn close
"Returns one shutdown Promise for this owner. Aborting the internal process
lifecycle invokes process/run's SIGTERM/5s/SIGKILL group cleanup, even during
startup or blocked page JS. A settled process exit or cancellation releases
the owned process resources."
[{:keys [shutdown signal abort-listener] :as owner}]
(or @shutdown
(do
(when signal
(.removeEventListener ^js signal "abort" @abort-listener))
(let [closing (shutdown-owner owner)]
(reset! shutdown closing)
closing))))
(defn ^:async launch
"Spawns a Chromium instance and attaches via Chrome DevTools Protocol (CDP).
Options include an optional signal, startup-timeout-ms (30000 by default)
and an optional absolute deadline shared with server startup. The result
owns the process group, user-data directory and CDP connection. It holds no
page or context; use new-page for those.
Startup failures include available process details, endpoint and profile."
[{:keys [signal startup-timeout-ms deadline]
:or {startup-timeout-ms settings/default-startup-timeout-ms}}]
(let [deadline (or deadline (process/deadline startup-timeout-ms))
budget (process/remaining deadline)]
(when (and signal (.-aborted ^js signal))
(throw (or (.-reason ^js signal) (ex-info "browser startup aborted" {:type ::aborted}))))
(when (zero? budget)
(throw (ex-info "browser startup timed out" {:type ::timeout :phase :browser-startup})))
(let [^js chromium (.-chromium playwright)
command (.executablePath chromium)
user-data-dir (fs/mkdtempSync (path/join (os/tmpdir) "penpot-render-browser-"))
controller (js/AbortController.)
endpoint (endpoint-waiter)
running (process/run {:command command
:args (into (:args (launch-settings))
["--remote-debugging-port=0"
"--remote-debugging-address=127.0.0.1"
(str "--user-data-dir=" user-data-dir)
"about:blank"])
:deadline js/Number.MAX_SAFE_INTEGER
:signal (.-signal controller)
:capture-output? false
:on-output (:on-output endpoint)})
owner {:controller controller :process running :shutdown (atom nil)
:connection (atom nil) :user-data-dir user-data-dir
:signal signal :abort-listener (atom nil)}
bounds {:deadline deadline :signal signal :phase :browser-startup}
abort (fn []
;; Startup/caller cleanup also awaits this same promise.
(.catch (close owner) (fn [_] nil)))]
(.then running
(fn [result]
((:failed endpoint) (ex-info "Chromium exited during startup"
{:type ::process-exit :command command
:exit-code (:exit-code result)})))
(:failed endpoint))
(reset! (:abort-listener owner) abort)
(when signal
(.addEventListener ^js signal "abort" abort #js {:once true}))
(try
(let [address (await (within (fn [] (:promise endpoint)) bounds))
^js browser (await (within (fn []
(let [connecting (.connectOverCDP chromium address
#js {:timeout (max 1 (process/remaining deadline))})]
(reset! (:connection owner) connecting)
connecting))
bounds))]
(assoc owner :browser browser :version (.version browser) :endpoint address))
(catch :default cause
(let [observed cause
cause (deadline-failure cause deadline)
cleanup-cause (attempt :catch-fatal (await (close owner)) nil)
cleanup (when (errors/failure? cleanup-cause)
(let [reported (loop [current observed seen #{}]
(if (or (nil? current) (contains? seen current))
(into seen (map :error (errors/get-errors observed)))
(recur (ex-cause current) (conj seen current))))]
(errors/combine
(remove #(contains? reported (:error %))
(errors/get-errors cleanup-cause)))))]
(if cleanup
(throw (errors/with-context
(errors/combine [{:phase "browser-startup" :operation :start-browser :error cause}
{:phase "cleanup" :operation :close-browser :error cleanup}])
"browser shutdown failed after startup error"
(merge (errors/project-context (ex-data cause))
(errors/project-context (ex-data cleanup-cause))
{:type ::startup-cleanup
:endpoint @(:address endpoint) :user-data-dir user-data-dir
:startup-failure (errors/describe-cause cause)})))
(throw (ex-info (errors/message-of cause)
(assoc (ex-data cause) :endpoint @(:address endpoint) :user-data-dir user-data-dir)
cause)))))))))
(defn ^:async new-page
"Creates a context and page in a browser process we own, navigates to host,
waits for the bridge and checks its compiled build identity.
Returns {:page :context}.
Failures close the started page/context before rejecting."
[owner host {:keys [viewport signal startup-timeout-ms deadline]
:or {startup-timeout-ms settings/default-startup-timeout-ms}}]
(let [deadline (or deadline (process/deadline startup-timeout-ms))
bounds {:deadline deadline :signal signal :phase :browser-page-startup}
^js browser (:browser owner)
^js context (await (within #(.newContext browser
#js {:viewport #js {:width (:width viewport)
:height (:height viewport)}
:deviceScaleFactor (:dpr viewport)})
bounds))]
(try
(let [page (await (within #(.newPage context) bounds))
page-own {:page page :context context}]
(try
(await (within #(.goto ^js page (str (:base-url host) "/")
#js {:timeout (max 1 (process/remaining deadline))})
bounds))
(await (within #(.waitForFunction ^js page "window.__benchReady === true" nil
#js {:timeout (max 1 (process/remaining deadline))})
bounds))
(identity/check (get-in host [:build :id])
(transit/decode-str (await (within #(evaluate page "buildIdentity") bounds))))
page-own
(catch :default cause
(let [cause (deadline-failure cause deadline)]
(ex/ignoring (await (close-page page-own)))
(throw cause)))))
(catch :default cause
(let [cause (deadline-failure cause deadline)]
(ex/ignoring (await (close-page {:context context})))
(throw cause))))))
(defn connected?
"True while the owned CDP connection is live."
[{:keys [browser]}]
(boolean (and browser (.isConnected ^js browser))))
(defn prepare-scene
"Calls prepareScene and decodes its Transit response under bounds."
[page request bounds]
(.then (within #(evaluate page "prepareScene" request) bounds)
transit/decode-str))
(defn run-prepared
"Calls runPrepared and decodes its Transit response under bounds."
[page request bounds]
(.then (within #(evaluate page "runPrepared" request) bounds)
transit/decode-str))
(defn screenshot
"Writes one PNG under bounds. The timeout belongs to Playwright's call."
[page file {:keys [deadline] :as bounds}]
(.then (within #(.screenshot ^js page
#js {:path file
:timeout (max 1 (process/remaining deadline))})
bounds)
(fn [_] nil)))
(def service
"Interface to the browser used by the serial runner.
Tests can replace this map with mocks."
{:launch launch
:new-page new-page
:close-page close-page
:close close
:connected? connected?
:prepare prepare-scene
:run run-prepared
:screenshot screenshot})

View File

@ -0,0 +1,216 @@
;; 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 benches.render-wasm.node.runner.build
"These functions build the renderer and package it with the code that runs
benchmarks in Node and the browser.
Each build gets its own directory under
frontend/target/performance/render-wasm/builds/. It contains the renderer
compiled to WebAssembly, a list of benchmarks that Node can load, and the
page and JavaScript that run them in a browser.
Run `prepare` from Node in a checkout with the project's build tools and
dependencies installed. Stop other builds and any processes that rebuild the
renderer on file changes until it finishes."
(:require
["fs" :as fs]
["path" :as path]
["url" :as url]
[app.common.transit :as transit]
[benches.render-wasm.errors :as errors :refer [attempt failure?]]
[benches.render-wasm.features.selection :as selection]
[benches.render-wasm.identity :as identity]
[benches.render-wasm.node.catalog.loader :as catalog]
[benches.render-wasm.node.log :as log]
[benches.render-wasm.node.process :as process]
[benches.render-wasm.node.settings :as settings]
[benches.render-wasm.node.storage.artifacts :as artifacts]
[clojure.string :as str]))
(defn ^:async git-provenance
"Read the Git commit ID and whether the checkout containing `root` has local
changes, including staged changes and untracked files.
`bounds` supplies :deadline, an absolute time in milliseconds since the Unix
epoch, and an optional :signal AbortSignal to cancel the work.
Return a promise of a map containing :sha, the commit ID, and :dirty, which
is true when the checkout has local changes. If Git information is
unavailable, log a warning and return nil for both values. Fatal process
failures, timeouts and cancellation reject the promise."
[root bounds]
(try
(let [options (merge bounds {:command "git" :cwd root})
sha (await (process/run (assoc options :args ["rev-parse" "HEAD"])))
status (await (process/run (assoc options :args ["status" "--porcelain"])))]
{:sha (str/trim (:stdout sha)) :dirty (not (str/blank? (:stdout status)))})
(catch :default cause
(if (or (errors/fatal? cause)
(contains? #{:process-timeout :process-cancelled} (:type (ex-data cause))))
(throw cause)
(do
(log/warn (str "Git provenance unavailable: " (errors/message-of cause)))
{:sha nil :dirty nil})))))
(defn- check-profile-marks
"Require :cljs-profile-marks to be false in the map describing the compiled
code. This flag indicates whether extra calls record timings for profiling;
those calls can affect benchmark measurements. Return the map or throw."
[compiled]
(when-not (false? (:cljs-profile-marks compiled))
(throw (ex-info "compiled benchmark enables profiling marks or omits its setting"
{:type ::profile-marks :compiled compiled})))
compiled)
(defn- ^:async validate-browser
"Checks that the JavaScript compiled for the browser belongs to the requested
build and has profiling disabled.
build: supplies the expected :id and :browser-dir, the directory containing
browser.js.
root: the frontend directory.
bounds: supplies :deadline, an absolute time in milliseconds since the Unix
epoch, and an optional :signal AbortSignal for cancellation.
Returns a promise of the code's build information, including :build-id and
:cljs-profile-marks. Rejects the promise if loading or checking the code fails."
[build root bounds]
(let [module-url (.-href (url/pathToFileURL (path/join (:browser-dir build) "browser.js")))
script "const {default: bridge} = await import(process.env.RENDER_BENCH_MODULE_URL); process.stdout.write(bridge.buildIdentity());"
result (await (process/run (merge bounds
{:command (.-execPath js/process) :cwd root
:args ["--input-type=module" "-e" script]
:env {"RENDER_BENCH_MODULE_URL" module-url}})))]
(check-profile-marks (identity/check (:id build) (transit/decode-str (:stdout result))))))
(defn ^:async prepare
"Build the renderer and the programs that run benchmarks, and save the files
for later benchmark runs.
Pass an options map with :root set to the frontend directory. If omitted,
the function locates that directory from the Node program's location.
Cargo features choose which optional parts of the renderer to compile. Use
:selection {:default-features? true :features []} to enable Cargo's defaults.
Put additional feature names from render-wasm/Cargo.toml in the vector, and
set :default-features? to false to disable the declared defaults. The
:features option accepts the same map when :selection is absent. Omit both
options to use Cargo's defaults.
:build-timeout-ms sets the time allowed for building, in milliseconds
(300000 by default). An optional `:signal AbortSignal` cancels the work.
An optional :on-created function receives a map with the build's ID and
paths after its directory, log and initial metadata file exist. This callback
runs before compilation. Await the promise from `prepare` before using the
compiled files.
The promise resolves to a map with :id, the build's unique identifier;
:dir, its output directory; :log-path, its compiler log; and :manifest,
metadata describing the build and its settings. :cases-path points to the
JavaScript module that lists the benchmarks for Node. :browser-dir and
:renderer-dir contain the compiled programs, and :index-path points to the
HTML page.
A failure rejects the promise and keeps any files already written for
inspection. Error data includes :build-id and :log-path when available."
[{:keys [root features selection build-timeout-ms signal on-created]
:or {build-timeout-ms settings/default-build-timeout-ms}}]
(when-not (and (js/Number.isSafeInteger build-timeout-ms) (pos? build-timeout-ms))
(throw (ex-info "build timeout must be a positive integer" {:type ::invalid-timeout})))
(let [root (or root (artifacts/frontend-root))
selection (selection/feature-selection (or selection features))
deadline (process/deadline build-timeout-ms)
bounds {:deadline deadline :signal signal}
initial (artifacts/create-build root selection {:git {:sha nil :dirty nil}
:feature-selection selection})
build-ref (atom initial)
phase (atom :storage)]
(binding [log/*log-path* (:log-path initial)]
(try
(when on-created (on-created initial))
(reset! phase :provenance)
(let [git (await (git-provenance root bounds))]
(swap! build-ref #(artifacts/write-manifest % (assoc-in (:manifest %) [:provenance :git] git))))
(reset! phase :renderer)
(log/info "Renderer compiling")
(let [build @build-ref
facts-path (path/join (:renderer-dir build) "cargo-build.json")
args (cond-> ["frontend"]
(not (:default-features? selection)) (conj "--no-default-features")
(seq (:features selection)) (into ["--features" (str/join "," (:features selection))]))]
(await (process/run (merge bounds
{:command (path/resolve root ".." "render-wasm" "build")
:args args :cwd (path/resolve root ".." "render-wasm")
:capture-output? false :on-line (fn [line _] (log/debug line))
:env {"PENPOT_WASM_PREPARED" "1" "NODE_ENV" "production"
"BUILD_MODE" "release"
"VERSION_TAG" (:id build)
"FRONTEND_DEST" (:renderer-dir build)
"FRONTEND_SHARED_DEST" (path/join root "src" "app" "render_wasm" "api" "shared.js")
"RENDER_WASM_SHARED_ARTIFACT" (path/join (:renderer-dir build) "shared.js")
"RENDER_WASM_BUILD_INFO" facts-path}})))
(let [facts (js/JSON.parse (fs/readFileSync facts-path "utf8"))
profile (js->clj (.-profile facts) :keywordize-keys true)
resolved (:features (selection/feature-selection {:features (js->clj (.-features facts))
:default-features? (:default-features? selection)}))]
(when-not (and (= "3" (:opt_level profile))
(false? (:debug_assertions profile)))
(throw (ex-info "Cargo did not produce an optimized renderer"
{:type ::renderer-profile :profile profile})))
(swap! build-ref #(artifacts/write-manifest
% (cond-> (-> (:manifest %)
(assoc-in [:functional :features] resolved)
(assoc-in [:functional :default-features?] (boolean (some #{"default"} resolved)))
(assoc-in [:provenance :cargo-profile] profile))
(js/Object.hasOwn facts "environment")
(assoc :environment (js->clj (.-environment facts))))))))
(reset! phase :cljs)
(log/info "Modules compiling")
(let [build @build-ref]
(await (process/run (merge bounds
{:command "clojure" :cwd root
:args ["-M:dev:renderer-bench" "release"
"bench-render-wasm-catalog" "bench-render-wasm-browser"]
:capture-output? false :on-line (fn [line _] (log/debug line))
:env {"RENDER_BENCH_BUILD_ID" (:id build)
"RENDER_BENCH_CASES_PATH" (:cases-path build)
"RENDER_BENCH_BROWSER_DIR" (:browser-dir build)}})))
(fs/copyFileSync (path/join root "performance" "benches" "render_wasm" "browser" "index.html")
(:index-path build))
(reset! phase :identity)
(log/info "Validating")
(check-profile-marks (catalog/build-identity build))
(catalog/collect build {:master-seed 42})
(await (validate-browser build root bounds))
(artifacts/check-files build)
(when (zero? (process/remaining deadline))
(throw (ex-info "preparation deadline expired" {:type :process-timeout})))
(log/info " ready")
(let [ready (artifacts/write-manifest build (assoc (:manifest build) :status :ready))]
(reset! build-ref ready)
(artifacts/load-build root (:id ready))))
(catch :default cause
(let [build @build-ref
context (errors/project-context (ex-data cause))
failure (errors/evidence @phase cause)
saved (when (= :building (get-in build [:manifest :status]))
(attempt :catch-fatal
(artifacts/write-manifest
build (assoc (:manifest build) :status :failed :failure failure))))
manifest-error (when (failure? saved) (errors/message-of saved))
data (cond-> (merge context
{:type ::preparation-failed :build-id (:id build)
:phase @phase :log-path (or (:log-path (ex-data cause)) (:log-path build))
:reason (str/join " -> " (keep :message (:cause failure)))})
manifest-error (assoc :manifest-error manifest-error))]
(throw (if manifest-error
(errors/fatal-error
"renderer build preparation failed" data
(errors/combine [{:error cause}
{:phase "storage" :operation :write-manifest
:path (:manifest-path build) :error saved}]))
(ex-info "renderer build preparation failed" data cause)))))))))

View File

@ -0,0 +1,880 @@
;; 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 benches.render-wasm.node.runner.feature-comparison
"Plan and run benchmarks for combinations of optional Cargo renderer features.
`prepare` reads Cargo metadata and collects the selected benchmark cases.
Call `format-plan` to inspect that data, or await `execute` to build and run
each configuration in sequence. Each configuration disables Cargo defaults.
An edge is a comparison between two configurations that differ by one
requested feature, from disabled to enabled.
Execution saves progress, run records, logs and edge reports for inspection
after completion, failure or cancellation. Builds use the checkout's renderer
and benchmark sources. Keep other renderer builds and watchers stopped until
execution finishes."
(:require
["fs" :as fs]
["node:timers/promises" :as timers]
["path" :as path]
[benches.render-wasm.cases :as cases]
[benches.render-wasm.errors :as errors :refer [attempt failure?]]
[benches.render-wasm.features.plan :as feature-plan]
[benches.render-wasm.node.catalog.loader :as catalog]
[benches.render-wasm.node.files :as files]
[benches.render-wasm.node.log :as log]
[benches.render-wasm.node.outcome :as outcome]
[benches.render-wasm.node.process :as process]
[benches.render-wasm.node.runner.build :as build]
[benches.render-wasm.node.runner.run :as run]
[benches.render-wasm.node.settings :as settings]
[benches.render-wasm.node.storage.artifacts :as artifacts]
[benches.render-wasm.node.storage.runs :as runs]
[benches.render-wasm.node.time :as time]
[benches.render-wasm.report.compare :as compare]
[benches.render-wasm.result :as result]
[clojure.set :as set]
[clojure.string :as str]))
(def ^:private warnings
["Timing values do not determine success."
"Intervals are exploratory; the comparison records no p-value verdicts."])
(def default-services
{:prepare-build build/prepare
:collect-catalog catalog/collect
:create-run-home runs/create-home
:execute-run run/execute})
(defn create-home
"Creates a directory and an empty log for a new feature comparison.
Args:
root: The frontend directory.
comparison-id: Contains 1 to 128 ASCII letters, digits, underscores or
hyphens, starting with a letter or digit.
output-dir: With nil `output-dir`, uses
`target/performance/render-wasm/feature-comparisons/<comparison-id>` under
root. An explicit path resolves from the Node process's working directory.
The chosen directory must not exist and must not be inside the managed
builds tree, including through symlink ancestors.
Returns:
A map containing `:comparison-id`, `:dir`, `:log-path` and `:record-path`.
The log is `feature-comparison.log`; `:record-path` names the future
`feature-comparison.transit` file, which this function does not write.
Throws:
Invalid identifiers, rejected paths and filesystem failures throw an error;
path and filesystem errors include `:path` when available."
[root comparison-id output-dir]
(artifacts/check-home-id comparison-id "invalid feature comparison id")
(let [default-dir (path/join (artifacts/managed-root root)
"feature-comparisons" comparison-id)
dir (artifacts/check-output-dir (or output-dir default-dir))]
(when (artifacts/inside-builds? root dir)
(artifacts/invalid-home "feature comparison cannot use a directory inside builds" {:path dir}))
(assoc (artifacts/create-home dir "feature-comparison"
"cannot create feature comparison home")
:comparison-id comparison-id)))
(defn write-record
"Validates and saves a feature-comparison record.
Encodes `record` as Transit after schema validation, then atomically replaces
that file so readers see either the previous complete record or the new one.
Args:
home: Supplies `:record-path` in an existing directory.
record: The feature-comparison record.
Returns:
The record.
Throws:
Validation, write and rename failures throw without replacing the previous
record."
[home record]
(files/atomic-write (:record-path home) (result/encode record))
record)
(defn- permissions
[options]
{:allow-build-differences (boolean (:allow-build-differences options))
:allow-scene-differences (boolean (:allow-scene-differences options))
:allow-all-differences (boolean (:allow-all-differences options))})
(defn- storage-error
[cause log-path]
(errors/fatal-error "cannot persist feature comparison"
{:type ::storage-error :cause (errors/message-of cause) :log-path log-path}
cause))
(defn- change-config
[record slug f]
(update record :configurations
#(mapv (fn [config]
(if (= slug (:slug config))
(f config)
config))
%)))
(defn- change-edge
[record from to f]
(update record :edges
#(mapv (fn [edge]
(if (and (= from (:from edge)) (= to (:to edge)))
(f edge)
edge))
%)))
(defn- config-by-slug
[record slug]
(some #(when (= slug (:slug %)) %) (:configurations record)))
(defn- persist
"Save updated progress before exposing it as the current and last saved record."
[state update]
(let [next (assoc (update @(:record-ref state)) :updated-at (time/now))
saved (try
(write-record (:home state) next)
(catch :default cause
(throw (storage-error cause (get-in state [:home :log-path])))))]
(reset! (:record-ref state) saved)
(reset! (:saved-ref state) saved)
saved))
(defn- initial-record
[prepared comparison-id started-at]
(let [plan (:plan prepared)
options (:options prepared)]
{:type :feature-comparison
:comparison-id comparison-id
:started-at started-at
:updated-at started-at
:status :running
:options options
:permissions (permissions options)
:cases (:cases prepared)
:features (:features plan)
:configurations (mapv #(assoc % :status :planned) (:configurations plan))
:edges (mapv #(assoc % :status :planned) (:edges plan))
:warnings warnings}))
(defn- fail-config
[state slug phase cause]
(persist state
(fn [record]
(change-config record slug
#(-> % (assoc :status :failed
:failure (errors/evidence phase cause))
(dissoc :reason))))))
(defn- interrupt-config
[state slug signal failure]
(persist state
(fn [record]
(change-config record slug
#(cond-> (assoc % :status :failed :reason signal)
(some? failure) (assoc :failure failure))))))
(defn- mark-configs-unattempted
[state configurations reason]
(when-let [[configuration & pending] (seq configurations)]
(let [slug (:slug configuration)]
(when (= :planned (:status (config-by-slug @(:record-ref state) slug)))
(persist state #(change-config % slug (fn [entry]
(assoc entry :status :unattempted :reason reason)))))
(mark-configs-unattempted state pending reason))))
(defn- mark-edge
[state edge status data]
(persist state
(fn [record]
(change-edge record (:from edge) (:to edge)
#(merge % (assoc data :status status))))))
(defn- mark-edges-unattempted
([state reason]
(mark-edges-unattempted state (:edges @(:record-ref state)) reason))
([state edges reason]
(when-let [[edge & pending] (seq edges)]
(when (= :planned (:status edge))
(mark-edge state edge :unattempted {:reason reason}))
(mark-edges-unattempted state pending reason))))
(defn- storage-failure
"Combines earlier failures with a failed comparison-storage operation.
Args:
operation: Operation that failed while saving comparison evidence.
causes: Earlier failures in occurrence order.
cause: Failure of the storage operation.
Returns:
An Error retaining earlier failures followed by the storage failure."
[operation causes cause]
(errors/combine (conj (mapv #(hash-map :error %) causes)
{:phase "comparison-storage" :operation operation :error cause})))
(defn- finish
[state status exit-code causes]
(let [saved (attempt :catch-fatal (persist state #(assoc % :status status :ended-at (time/now))))
failure (when (failure? saved)
(storage-failure :finish-comparison causes saved))]
{:comparison (if failure @(:saved-ref state) saved)
:home (:home state)
:log-path (get-in state [:home :log-path])
:errors (if failure [failure] (vec (remove nil? causes)))
:exit-code (if (and failure (not (contains? #{130 143} exit-code))) 1 exit-code)}))
(defn- finish-root
[state error]
(let [record @(:record-ref state)
completed (and (every? #(= :completed (:status %)) (:configurations record))
(every? #(= :completed (:status %)) (:edges record)))]
(if completed
(finish state :completed 0 [error])
(finish state :failed 1 [error]))))
(defn- failure-outcome
[state signal cause]
(assoc (outcome/failed cause {:comparison @(:saved-ref state)
:home (:home state)
:log-path (get-in state [:home :log-path])})
:exit-code (if signal (outcome/interruption-code signal) 1)))
(defn- interrupt-comparison
[state signal error]
(let [reason (str "interrupted by " signal)
recorded (attempt :catch-fatal
(mark-configs-unattempted state (:configurations @(:record-ref state)) reason)
(mark-edges-unattempted state reason)
nil)]
(if (failure? recorded)
(failure-outcome state signal
(storage-failure :record-interruption [error] recorded))
(finish state :interrupted (outcome/interruption-code signal) [error]))))
(defn- record-created-build
[state slug build]
(persist state
#(change-config % slug
(fn [configuration]
(-> configuration
(assoc :status :building
:build (select-keys build [:id :manifest-path :log-path]))
(dissoc :failure :reason))))))
(defn- stop-outcome
[slug reason cause]
{:stop? true
:reason (str "not attempted after " reason " in configuration " slug)
:error cause})
(defn- fail-configuration
[state slug phase reason cause]
(fail-config state slug phase cause)
(stop-outcome slug reason cause))
(defn- interrupt-configuration
[state slug signal phase cause]
(interrupt-config state slug signal (when cause (errors/evidence phase cause)))
(cond-> {:interrupted? true :signal signal}
cause (assoc :error cause)))
(defn- fail-or-interrupt
[state slug phase cause]
(try
(if-let [signal (process/signal-name (:signal state))]
(interrupt-configuration state slug signal phase cause)
(fail-configuration state slug phase (str (name phase) " failure") cause))
(catch :default recorded
(throw (storage-failure :record-configuration-failure [cause] recorded)))))
(defn- begin-configuration
[state configuration]
(let [slug (:slug configuration)]
(if-let [signal (process/signal-name (:signal state))]
{:configuration configuration
:outcome (interrupt-configuration state slug signal nil nil)}
(do
(persist state #(change-config % slug (fn [entry]
(-> entry (assoc :status :building)
(dissoc :failure :reason)))))
{:configuration configuration}))))
(defn- accept-build
[state flow selected]
(let [{:keys [slug selection]} (:configuration flow)
effective (get-in selected [:manifest :functional :features])
saved-selection (get-in selected [:manifest :provenance :feature-selection])
valid-features? (and (vector? effective) (every? string? effective))]
(if (and (= selection saved-selection) valid-features?)
(do
(persist state #(change-config % slug (fn [entry]
(assoc entry :status :ready :effective-features effective))))
(log/info (str "Configuration " slug " built"))
(assoc flow :build selected))
(let [cause (ex-info "renderer build manifest disagrees with the planned selection"
{:type ::invalid-build :log-path (:log-path selected)
:selection selection :saved-selection saved-selection})]
(assoc flow :outcome (fail-configuration state slug :build "build failure" cause))))))
(defn- ^:async build-configuration
[state flow]
(if (:outcome flow)
flow
(let [{:keys [slug selection]} (:configuration flow)
built (attempt :catch-fatal
(await ((:prepare-build (:services state))
{:root (:root state)
:selection selection
:build-timeout-ms (get-in state [:options :build-timeout-ms])
:signal (:signal state)
:on-created (partial record-created-build state slug)})))
signal (process/signal-name (:signal state))]
(cond
(and (failure? built) (errors/fatal? built) (not (process/unresolved? built)))
(throw built)
(failure? built)
(assoc flow :outcome (fail-or-interrupt state slug :build built))
signal
(assoc flow :outcome (interrupt-configuration state slug signal nil nil))
:else (accept-build state flow built)))))
(defn- check-catalog
[state flow]
(if (:outcome flow)
flow
(let [slug (get-in flow [:configuration :slug])
selected (:build flow)
mismatch? (-> (attempt
(not= ((:collect-catalog (:services state))
selected
{:master-seed (get-in state [:options :seed])
:filter (get-in state [:options :filter])})
(:cases state)))
(errors/with-data {:log-path (:log-path selected)}))
signal (process/signal-name (:signal state))
step (cond
(failure? mismatch?) (fail-or-interrupt state slug :catalog mismatch?)
signal (interrupt-configuration state slug signal nil nil)
mismatch?
(fail-configuration state slug :catalog "catalog mismatch"
(ex-info "build catalog disagrees with the prepared cases"
{:type ::catalog-mismatch
:log-path (:log-path selected)
:configuration slug})))]
(cond-> flow step (assoc :outcome step)))))
(defn- allocate-run
[state flow]
(if (:outcome flow)
flow
(let [slug (get-in flow [:configuration :slug])
home (attempt
((:create-run-home (:services state))
{:root (:root state) :run-id (str (random-uuid))
:output-dir (path/join (get-in state [:home :dir]) "configs" slug)}))]
(if (failure? home)
(assoc flow :outcome (fail-configuration state slug :run-home "run-home failure" home))
(assoc flow :run-home home)))))
(defn- ^:async execute-run
[state flow]
(attempt
(await ((:execute-run (:services state))
(:root state) (:run-home flow) (:build flow) (:cases state)
(run/get-execution-options (:options state))
(:signal state)))))
(defn- get-evidence-error
[slug home response]
(or (some #(when (errors/fatal? %) %) (:errors response))
(attempt
(when-not (= (get-in response [:run :run-id]) (:run-id (runs/get-record (:record-path home))))
(throw (ex-info "saved run identity disagrees with execution result"
{:type ::invalid-run :configuration slug}))))))
(defn- get-run-evidence
[slug home response]
(let [run (:run response)
home (or (:home response) home)
evidence-error (get-evidence-error slug home response)
error (errors/with-data (or evidence-error (first (:errors response)))
{:log-path (:log-path home)})]
{:run run
:home home
:evidence-error evidence-error
:error error
:gpu-failure (some #(when (= :gpu (:stage %)) %) (get-in run [:metadata :diagnostics :failures]))
:run-ref {:run-id (:run-id run) :record-path (:record-path home)
:log-path (:log-path home) :exit-code (:exit-code response)}}))
(defn- get-run-failure
[{:keys [run evidence-error gpu-failure error]}]
(cond
evidence-error (errors/evidence :run-evidence evidence-error)
gpu-failure gpu-failure
(map? (get-in run [:termination :failure])) (get-in run [:termination :failure])
error (errors/evidence :run error)))
(defn- classify-run
[signal evidence]
(let [termination (get-in evidence [:run :termination :reason])
completed (and (nil? (:evidence-error evidence)) (= :completed termination)
(zero? (get-in evidence [:run-ref :exit-code])))
interrupted (or signal (when (= :interrupted termination)
(or (get-in evidence [:run :termination :signal]) "SIGINT")))
failure (get-run-failure evidence)
reason (or interrupted (when (and (not completed) (nil? failure)) "run did not complete"))]
(assoc evidence :completed completed :signal interrupted :failure failure :reason reason)))
(defn- record-run-result
[state slug {:keys [completed run-ref reason failure]}]
(persist state
#(change-config % slug
(fn [entry]
(if completed
(-> entry (assoc :status :completed :run run-ref) (dissoc :failure :reason))
(cond-> (assoc entry :status :failed :run run-ref)
(some? reason) (assoc :reason reason)
(some? failure) (assoc :failure failure)))))))
(defn- zero-attempt-outcome
[slug home]
(stop-outcome slug "a run with no attempts"
(ex-info "configuration run made no attempts"
{:type ::zero-attempts :log-path (:log-path home) :configuration slug})))
(defn- config-run-outcome
[slug {:keys [run home completed signal reason error evidence-error gpu-failure]}]
(cond
completed
(if (empty? (:attempts run))
(zero-attempt-outcome slug home)
(do (log/info (str "Configuration " slug " completed")) {:stop? false}))
signal {:interrupted? true :signal reason :error error}
evidence-error (stop-outcome slug "run evidence failure" error)
gpu-failure (stop-outcome slug "GPU failure"
(ex-info (:message gpu-failure)
{:type ::gpu-required :log-path (:log-path home) :configuration slug}))
(empty? (:attempts run)) (zero-attempt-outcome slug home)
:else (do (log/warn (str "Configuration " slug " failed")) {:stop? false :error error})))
(defn- finish-config-run
[state flow response]
(let [slug (get-in flow [:configuration :slug])
facts (get-run-evidence slug (:run-home flow) response)
evidence (classify-run (process/signal-name (:signal state)) facts)]
(try
(record-run-result state slug evidence)
(catch :default cause
(throw (storage-failure :record-run-result [(:error evidence)] cause))))
(config-run-outcome slug evidence)))
(defn- get-missing-run-cause
"Returns why execution produced no run record, or nil when it produced one."
[response home]
(-> (cond
(failure? response) response
(nil? (:run response)) (or (first (:errors response))
(ex-info "run executor returned no record" {:type ::missing-run})))
(errors/with-data {:log-path (:log-path home)})))
(defn- ^:async run-configuration
[state flow]
(if (:outcome flow)
flow
(let [slug (get-in flow [:configuration :slug])
home (:run-home flow)]
(persist state #(change-config % slug (fn [entry]
(assoc entry :status :running
:run {:run-id (:run-id home)
:record-path (:record-path home)
:log-path (:log-path home)}))))
(log/info (str "Configuration " slug " running"))
(let [response (await (execute-run state flow))
cause (get-missing-run-cause response home)]
(assoc flow :outcome (if cause
(fail-configuration state slug :run "run failure" cause)
(finish-config-run state flow response)))))))
(defn- ^:async execute-config
[state configuration]
(let [started (begin-configuration state configuration)
built (await (build-configuration state started))
catalogued (check-catalog state built)
allocated (allocate-run state catalogued)
executed (await (run-configuration state allocated))]
(:outcome executed)))
(defn- sorted-features
[features]
(when-not (and (vector? features) (every? string? features))
(throw (ex-info "run metadata lacks an effective feature vector"
{:type ::missing-features})))
(vec (sort (distinct features))))
(defn- edge-facts
[baseline candidate edge]
(let [baseline-features (sorted-features
(get-in baseline [:metadata :build :functional :features]))
candidate-features (sorted-features
(get-in candidate [:metadata :build :functional :features]))
baseline-set (set baseline-features)
candidate-set (set candidate-features)]
{:from (:from edge)
:to (:to edge)
:feature (:feature edge)
:baseline-features baseline-features
:candidate-features candidate-features
:added (vec (sort (set/difference candidate-set baseline-set)))
:removed (vec (sort (set/difference baseline-set candidate-set)))}))
(defn- save-comparison
[path comparison log-path]
(let [text (result/encode comparison)]
(try
(files/atomic-write path text)
(catch :default cause
(throw (storage-error cause log-path))))))
(defn- unacknowledged-count
[comparison]
(reduce + 0 (map (fn [pair] (count (remove :waived? (:mismatches pair))))
(:pairs comparison))))
(defn- get-comparison-options
[record facts]
(assoc (:permissions record)
:expected-feature-change (select-keys facts [:feature :baseline-features
:candidate-features :added :removed])
:edge facts
:on-unallowed-difference :record))
(defn- analyze-edge
"Returns the path of the saved comparison and its count of unacknowledged
differences, or the failure, fatal failures included."
[state edge]
(attempt
:catch-fatal
(let [record @(:record-ref state)
baseline-config (config-by-slug record (:from edge))
candidate-config (config-by-slug record (:to edge))
baseline (runs/get-record (get-in baseline-config [:run :record-path]))
candidate (runs/get-record (get-in candidate-config [:run :record-path]))
facts (edge-facts baseline candidate edge)
comparison (compare/compare-runs baseline candidate (get-comparison-options record facts))
path (path/join (get-in state [:home :dir])
"comparisons"
(str (:from edge) "__" (:to edge) ".transit"))]
(save-comparison path comparison (get-in state [:home :log-path]))
{:path path
:unacknowledged (unacknowledged-count comparison)})))
(defn- record-edge-outcome
"Records the result of analyzing an edge.
Returns:
An Error retaining accumulated analysis failures, or nil on success.
Throws:
Fatal analysis or persistence failures with available earlier failures."
[state edge result error]
(let [fatal? (and (failure? result) (errors/fatal? result))
analyzed (if (failure? result)
(errors/combine [{:error error}
{:phase "comparison" :operation :analyze-edge :error result}])
error)
recorded (attempt
:catch-fatal
(cond
fatal?
(do
(mark-edge state edge :failed {:failure (errors/evidence :comparison-storage result)})
(mark-edges-unattempted state "not attempted after comparison storage failure"))
(failure? result)
(mark-edge state edge :failed {:failure (errors/evidence :comparison result)})
(pos? (:unacknowledged result))
(do
(log/warn (str "Comparison " (:from edge) " -> " (:to edge)
" has " (:unacknowledged result) " unacknowledged differences"))
(mark-edge state edge :failed {:path (:path result)
:failure {:unacknowledged-differences (:unacknowledged result)}}))
:else
(mark-edge state edge :completed {:path (:path result)}))
nil)]
(cond
(failure? recorded)
(throw (storage-failure :record-edge [analyzed] recorded))
fatal? (throw analyzed)
:else analyzed)))
(defn- ^:async analyze-edges
[state edges error]
(await (timers/setImmediate))
(if-let [signal (process/signal-name (:signal state))]
(do
(try
(mark-edges-unattempted state (str "interrupted by " signal))
(catch :default cause
(throw (storage-failure :record-interruption [error] cause))))
{:interrupted signal :error error})
(if-let [[edge & pending] (seq edges)]
(analyze-edges state pending (record-edge-outcome state edge (analyze-edge state edge) error))
{:done true :error error})))
(defn- create-comparison-directory
[state]
(try
(fs/mkdirSync (path/join (get-in state [:home :dir]) "comparisons") #js {:recursive true})
(catch :default cause
(throw (storage-error cause (get-in state [:home :log-path]))))))
(defn- stop-unresolved-comparison
[state error]
(let [recorded (attempt :catch-fatal
(mark-edges-unattempted state "process group may still be running")
nil)]
(if (failure? recorded)
(failure-outcome state (process/signal-name (:signal state))
(storage-failure :skip-comparisons [error] recorded))
(finish state :failed 1 [error]))))
(defn- ^:async run-edges
[state error]
(let [signal (process/signal-name (:signal state))]
(cond
signal (interrupt-comparison state signal error)
(process/unresolved? error) (stop-unresolved-comparison state error)
:else
(do
(try
(create-comparison-directory state)
(catch :default cause
(throw (storage-failure :create-comparison-directory [error] cause))))
(let [analyzed (attempt :catch-fatal
(await (analyze-edges state (:edges @(:record-ref state)) error)))]
(cond
(failure? analyzed)
(if-let [signal (process/signal-name (:signal state))]
(failure-outcome state signal analyzed)
(finish state :failed 1 [analyzed]))
(:interrupted analyzed) (interrupt-comparison state (:interrupted analyzed) (:error analyzed))
:else (finish-root state (:error analyzed))))))))
(defn- ^:async run-comparisons
[state configurations]
(if-let [signal (process/signal-name (:signal state))]
(interrupt-comparison state signal nil)
(if-let [[configuration & pending] (seq configurations)]
(let [step (await (execute-config state configuration))]
(cond
(:interrupted? step)
(interrupt-comparison state (:signal step) (:error step))
(:stop? step)
(do
(try
(mark-configs-unattempted state pending (:reason step))
(catch :default cause
(throw (storage-failure :stop-configurations [(:error step)] cause))))
(run-edges state (:error step)))
:else (run-comparisons state pending)))
(run-edges state nil))))
(defn- create-comparison-state
[root prepared signal services home comparison-id]
{:root root
:home home
:options (:options prepared)
:cases (:cases prepared)
:signal signal
:services services
:record-ref (atom (initial-record prepared comparison-id (time/now)))
:saved-ref (atom nil)})
(defn- initialize-comparison
[state]
(let [initial @(:record-ref state)
home (:home state)
saved (write-record home initial)]
(reset! (:record-ref state) saved)
(reset! (:saved-ref state) saved)))
(defn- ^:async execute-comparison
[state]
(let [home (:home state)]
(binding [log/*log-path* (:log-path home)]
(let [result (attempt :catch-fatal
(initialize-comparison state)
(await (run-comparisons state (:configurations @(:record-ref state)))))]
(if (failure? result)
(failure-outcome state (process/signal-name (:signal state)) result)
result)))))
(defn ^:async execute
"Build and benchmark every prepared feature configuration, then compare runs.
The caller must handle `:dry-run` by calling `format-plan` instead; this function
always executes.
Create a fresh comparison directory and save its initial record before the
first build. Build and run configurations serially, checking each compiled
catalog against the prepared cases. Runs live in `configs/<slug>` under the
comparison directory; edge reports live in `comparisons/<from>__<to>.transit`.
Build artifacts remain in the managed builds directory.
Timing differences alone do not cause failure. Unacknowledged differences
in build, scene or other comparison metadata do cause an edge to fail.
The prepared options `:allow-build-differences` and `:allow-scene-differences`
acknowledge their respective categories; `:allow-all-differences` acknowledges
every compatibility mismatch. Reports retain acknowledged differences.
Build or catalog failures stop later configurations. A failed run may allow
later configurations when it retains attempts and usable evidence; missing
evidence, GPU failure or zero attempts stop them. Cancellation skips remaining
work. Files already written remain for diagnosis.
Args:
root: The frontend directory.
prepared: The result of `prepare`.
signal: An optional AbortSignal that cancels execution.
settings: May contain `:services` overrides with the same contracts as the
defaults: `:prepare-build` uses `node.runner.build/prepare`, `:collect-catalog`
uses `node.catalog.loader/collect`, `:create-run-home` uses `node.storage.runs/create-home`,
and `:execute-run` uses `node.runner.run/execute`. Omitted services use
`default-services`.
Returns:
Await the returned promise for a map with `:comparison`, the last
successfully saved progress record; `:home`, the comparison ID and paths;
`:log-path`; `:errors`, retained error objects; and `:exit-code`. The exit
code is 0 only when all configurations and edges complete, 1 on failure,
or 130/143 on interruption.
If storage fails, the returned `:comparison` reflects the last successful
atomic write, or nil if no record was saved; `:home` is nil if directory
creation failed. Inspect the saved statuses and `:errors` together, since
`:errors` is not an exhaustive list of failed configurations or edges."
[root prepared signal settings]
(let [services (merge default-services (:services settings))
comparison-id (str (random-uuid))
home (attempt (create-home root comparison-id (:output-dir prepared)))]
(if (failure? home)
(outcome/failed home {:comparison nil :home nil})
(await
(execute-comparison
(create-comparison-state root prepared signal services home comparison-id))))))
(defn- get-root-features
"Read feature definitions for cwd/Cargo.toml from Cargo metadata JSON."
[cwd text]
(let [metadata (js->clj (js/JSON.parse text))
manifest (fs/realpathSync (path/join cwd "Cargo.toml"))
package (first (filter #(= manifest (fs/realpathSync
(path/resolve cwd (get % "manifest_path"))))
(get metadata "packages")))]
(when-not package
(throw (ex-info "Cargo metadata does not contain the renderer manifest"
{:type ::missing-package :manifest-path manifest})))
(get package "features")))
(defn ^:async prepare
"Collect and validate a feature comparison plan without building or running it.
Run `cargo metadata --offline --locked --no-deps` in `render-wasm` with the
inherited environment. Cargo and its offline metadata must be available.
`:build-timeout-ms` bounds that subprocess in milliseconds, not the entire
planning operation.
Require empty declared Cargo defaults and known requested feature names;
Cargo remains responsible for resolving feature activation during builds.
Planning creates no comparison output and starts no build, server or browser,
including for a dry-run.
Args:
root: The frontend directory, with render-wasm as its sibling.
opts: Resolved CLI options, including defaults. `:features` supplies the
requested Cargo feature names, `:seed` supplies the master scene seed,
and `:filter` selects case IDs by substring. `:max-features` limits the
requested feature count. Execution requires `:confirm` for more than
eight configurations or a blank filter; `:dry-run` bypasses that
confirmation requirement only.
An optional `:output-dir` resolves from the Node process's working
directory and must not exist. Check it without creating or reserving it.
`execute` checks it again and also rejects paths inside the managed
builds tree.
signal: An optional AbortSignal that cancels the process.
Returns:
Await the promise for a map with `:plan`, `:cases`, `:output-dir` and
`:options`. The `:plan` map contains `:features` in sorted order,
`:configurations` with a `:slug` and Cargo `:selection`, and `:edges` with
`:from` and `:to` slugs and a `:feature`. Slugs are bit strings in sorted
feature order: `0` means disabled, `1` enabled. The `:cases` vector holds
case descriptions collected once. `:output-dir` is the absolute explicit
path or nil. `:options` contains the supplied options with normalized
features and output path. Pass this map to `format-plan` or `execute`.
Throws:
Invalid options or paths, Cargo failures or cancellation, unsupported
manifest features, and case collection failures reject the promise.
An empty case selection is an error."
[root opts signal]
(let [plan (feature-plan/plan (:features opts) opts)
output-dir (artifacts/check-output-dir (:output-dir opts))
cwd (path/resolve root ".." "render-wasm")
metadata (await (process/run
{:command "cargo"
:args ["metadata" "--offline" "--locked" "--no-deps" "--format-version" "1"]
:cwd cwd
:deadline (process/deadline (:build-timeout-ms opts settings/default-build-timeout-ms))
:signal signal}))]
(feature-plan/check-manifest (:features plan) (get-root-features cwd (:stdout metadata)))
{:plan plan
:cases (cases/collect-cases {:master-seed (:seed opts) :filter (:filter opts)})
:output-dir output-dir
:options (assoc opts :features (:features plan) :output-dir output-dir)}))
(defn format-plan
"Return a text summary of the map produced by `prepare`.
This function neither collects cases again nor reads the filesystem,
starts processes or executes benchmarks.
Returns:
Feature names, configurations, comparison edges, case count, sampling
counts and configured timeouts."
[{:keys [plan cases options]}]
(str "Feature comparison plan\n"
"Features: " (str/join ", " (:features plan)) "\n"
"Configurations (Cargo defaults disabled):\n"
(str/join "\n" (map #(str " " (:slug %) ": "
(str/join ", " (get-in % [:selection :features])))
(:configurations plan))) "\n"
"Edges:\n"
(str/join "\n" (map #(str " " (:from %) " -> " (:to %) ": " (:feature %))
(:edges plan))) "\n"
"Cases: " (count cases) "\n"
"Attempts per case: " (:warmups options) " warmups, " (:repetitions options) " measured\n"
"Timeouts (ms): case " (:timeout-ms options)
", build " (:build-timeout-ms options) ", startup " (:startup-timeout-ms options) "\n"))

View File

@ -0,0 +1,916 @@
;; 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 benches.render-wasm.node.runner.run
"Renderer benchmark runs over fixed builds.
Runs are stored in a `home`, and execute via a loopback host and at most one
Chromium process.
`:fresh` cases prepare once per slot on a new page and context.
`:reuse` cases prepare once and run every slot on the same page and context.
A failed attempt consumes its slot.
Browser failures leave the current case's remaining slots unattempted and
continue with the next case in a new browser."
(:require-macros
[app.common.exceptions :as ex])
(:require
["module" :as node-module]
["os" :as os]
["path" :as path]
[app.common.transit :as transit]
[benches.render-wasm.declarations :as declarations]
[benches.render-wasm.errors :as errors :refer [attempt failure?]]
[benches.render-wasm.measurement :as measurement]
[benches.render-wasm.node.catalog.loader :as catalog]
[benches.render-wasm.node.log :as log]
[benches.render-wasm.node.outcome :as outcome]
[benches.render-wasm.node.process :as process]
[benches.render-wasm.node.runner.browser :as browser]
[benches.render-wasm.node.runner.build :as build]
[benches.render-wasm.node.runner.server :as server]
[benches.render-wasm.node.settings :as settings]
[benches.render-wasm.node.storage.artifacts :as artifacts]
[benches.render-wasm.node.storage.runs :as runs]
[benches.render-wasm.node.time :as time]
[benches.render-wasm.report.format :as format]
[benches.render-wasm.report.summarize :as summary]
[benches.render-wasm.result :as result]
[clojure.string :as str]))
(defn get-execution-options
"Returns resolved settings for executing a prepared run."
[options]
(select-keys options [:seed :filter :warmups :repetitions :timeout-ms
:startup-timeout-ms :screenshots :require-gpu :remove]))
(defn- cause-evidence
"Plain failure evidence for a rejected browser, server or persistence action."
[cause fallback-phase]
(cond-> (errors/fail-data
(errors/evidence (or (:phase (ex-data cause)) fallback-phase) cause))
(contains? (ex-data cause) ::browser/deadline-exceeded)
(assoc :deadline-exceeded (::browser/deadline-exceeded (ex-data cause)))))
(defn- interruption
[signal]
(when-let [name (process/signal-name signal)]
{:signal name :cause (.-reason ^js signal)}))
(defn- interrupted?
[state]
(some? (interruption (:signal state))))
(defn- check-deadline
[deadline]
(when (zero? (process/remaining deadline))
(throw (ex-info "case deadline exceeded" {:type ::browser/timeout :phase :deadline}))))
(defn- failure-outcome
[state deadline evidence]
(cond
(interrupted? state) :interrupted
(if (contains? evidence :deadline-exceeded)
(:deadline-exceeded evidence)
(or (zero? (process/remaining deadline)) (= "deadline" (:phase evidence)))) :timed-out
:else :failed))
(defn- keep-sink-failure
"Remembers the first failed write to the run record or log. The failure stays
in the outcome even when a later write succeeds."
[state cause]
(swap! (:sink-failure-ref state) #(or % cause))
cause)
(defn- write-log
[state output text]
(try
(output text)
(catch :default cause
(throw (keep-sink-failure
state
(if (errors/fatal? cause)
cause
(errors/fatal-error "cannot write the run log" {} cause)))))))
(defn- warn-safely
"Appends runner diagnostics without replacing an error already under handling."
[state text]
(ex/ignoring (write-log state log/warn text))
nil)
(defn- persist
[state]
(try
(runs/write-record! (:home state) @(:run-ref state))
(catch :default cause
(throw (keep-sink-failure
state
(errors/fatal-error "cannot save the run record" {:log-path (get-in state [:home :log-path])} cause))))))
(defn- record-preparation
[state preparation]
(swap! (:run-ref state) result/record-preparation preparation)
(persist state)
(write-log state log/debug (str "Prepared " (result/case-name (:case-id preparation)))))
(defn- record-attempt
[state measured]
(swap! (:run-ref state) result/record-attempt measured)
(persist state)
(let [remaining (get-in @(:run-ref state) [:remaining (:case-id measured)])
total (+ (:warmups (:opts state)) (:repetitions (:opts state)))
index (- total (:warmups remaining) (:repetitions remaining))]
(write-log state (if (= :failed (:outcome measured)) log/warn log/info)
(str " attempt " index "/" total " " (name (:outcome measured))
(when (:warmup? measured) " (warmup)")))))
(defn- record-failure
[state case-id stage evidence]
(let [failure (cond-> (assoc (errors/project-diagnostic evidence)
:stage stage)
(some? case-id) (assoc :case-id case-id))]
(reset! (:harness-failed? state) true)
(swap! (:run-ref state) update-in [:metadata :diagnostics :failures]
(fnil conj []) failure)
(persist state)
(write-log state log/warn (str (name stage) " failed"
(when (some? case-id) (str " for " (result/case-name case-id)))
": " (:message failure) "\n"))
failure))
(defn- record-screenshot-status
[state case-id status]
(swap! (:run-ref state) assoc-in
[:metadata :diagnostics :screenshots (result/case-name case-id)] status)
(persist state))
(defn- screenshot-status
[state case-id]
(get-in @(:run-ref state)
[:metadata :diagnostics :screenshots (result/case-name case-id)]))
(defn- record-screenshot-skip
[state case-id reason]
(when (:screenshots (:opts state))
(when-not (screenshot-status state case-id)
(record-screenshot-status state case-id {:status :skipped :reason reason}))))
(defn- ensure-screenshot-status
[state case reason]
(when (:screenshots (:opts state))
(when-not (screenshot-status state (:id case))
(record-screenshot-skip state (:id case) reason))))
(defn- ^:async screenshot-case
[state case page deadline]
(if (:screenshots (:opts state))
(let [{:keys [file path]} (runs/screenshot-path (:home state) (:id case))
bounds {:deadline deadline
:signal (:signal state) :phase :screenshot}
shot (attempt (await ((get-in state [:services :screenshot]) page path bounds)))]
(if (failure? shot)
(do
(record-screenshot-status state (:id case)
{:file file :status :failed
:message (errors/message-of shot)
:cause (errors/describe-cause shot)})
(write-log state log/warn (str "Screenshot failed for "
(result/case-name (:id case)) ": "
(errors/message-of shot) "\n"))
(let [outcome (failure-outcome state deadline (cause-evidence shot :screenshot))]
(when (= :timed-out outcome)
(record-failure state (:id case) :case-deadline (cause-evidence shot :screenshot)))
(if (= :failed outcome) :ok outcome)))
(do
(record-screenshot-status state (:id case) {:file file :status :ok})
(write-log state log/debug (str "Screenshot " file))
:ok)))
:ok))
(defn- case-slots
"Returns each scheduled slot in order: all warmups, then measured slots.
True marks a warmup."
[opts]
(vec (concat (repeat (:warmups opts) true)
(repeat (:repetitions opts) false))))
(defn- renderer-url
[build-id file]
(str "/renderer/" file "?build=" (js/encodeURIComponent build-id)))
(defn- ^:async prepare-case
[state case page deadline warmup?]
(check-deadline deadline)
(let [build-id (:id (:selected-build state))
preparation-id (str (random-uuid))
response (attempt
(measurement/check-response
:preparation preparation-id
(await ((get-in state [:services :prepare])
page
(transit/encode-str
{:case case
:preparation-id preparation-id
:timeout-ms (max 1 (process/remaining deadline))
:module-url (renderer-url build-id "render-wasm.js")
:wasm-url (renderer-url build-id "render-wasm.wasm")})
{:deadline deadline :signal (:signal state) :phase :preparation}))))
accepted (when (and (not (failure? response)) (= "ok" (:status response)))
(attempt
(let [preparation (assoc (:preparation response) :warmup? warmup?)]
(when-not (and (= preparation-id (:preparation-id preparation))
(= (:id case) (:case-id preparation))
(not (:failure preparation)))
(throw (ex-info "preparation success response is inconsistent" {})))
(record-preparation state preparation)
preparation)))]
(if (and accepted (not (failure? accepted)))
{:preparation accepted}
(let [failure (cond
(failure? accepted) (assoc (cause-evidence accepted :invalid-output)
:partial {:response response})
(failure? response) (cause-evidence response :preparation)
:else response)
phases (if (failure? accepted) (get-in response [:preparation :phases]) (:phases failure))]
(record-preparation state {:preparation-id preparation-id :case-id (:id case)
:warmup? warmup?
:phases (measurement/partial-phases phases preparation-id [:preparation])
:diagnostics (or (get-in failure [:partial :setup]) {})
:failure (errors/project-diagnostic failure)})
{:failure failure :preparation-id preparation-id}))))
(defn- ^:async run-attempt
"Returns the bridge evidence of one attempt, or the failure."
[state page preparation-id deadline]
(attempt
(measurement/check-response
:attempt preparation-id
(await ((get-in state [:services :run])
page
(transit/encode-str
{:preparation-id preparation-id
:timeout-ms (max 1 (process/remaining deadline))})
{:deadline deadline :signal (:signal state) :phase :attempt})))))
(defn- ^:async recycle-browser
[state]
(when-let [owner @(:owner-ref state)]
(let [closed (attempt (await ((get-in state [:services :close]) owner)))]
(if (failure? closed)
(do
(record-failure state nil :browser-close (cause-evidence closed :browser-close))
(reset! (:abort-run? state) true))
(do
(reset! (:owner-ref state) nil)
(reset! (:page-ref state) nil))))))
(defn- ^:async close-current-page
[state case]
(let [owner @(:owner-ref state)
connected? (get-in state [:services :connected?])]
(if (and owner connected? (not (connected? owner)))
(do
(record-failure state (:id case) :browser-disconnected
{:phase "browser-disconnected" :message "browser disconnected"})
(reset! (:page-ref state) nil)
(await (recycle-browser state))
false)
(if-let [page @(:page-ref state)]
(do
(reset! (:page-ref state) nil)
(let [closed (attempt (await ((get-in state [:services :close-page]) page)))]
(if (failure? closed)
(do
(record-failure state (:id case) :browser-close (cause-evidence closed :browser-close))
(await (recycle-browser state))
false)
true)))
true))))
(defn- ^:async ensure-browser
[state deadline]
(or @(:owner-ref state)
(let [opts (:opts state)
options {:startup-timeout-ms (:startup-timeout-ms opts)
:signal (:signal state) :deadline deadline}
owner (await ((get-in state [:services :launch]) options))]
(reset! (:owner-ref state) owner)
(swap! (:run-ref state) assoc-in [:metadata :environment :browser] (:version owner))
(persist state)
(write-log state log/debug (str "Browser " (:version owner)))
owner)))
(defn- usable-graphics-identity?
[value]
(and (string? value)
(not (contains? #{"" "unknown" "unavailable" "null" "undefined"}
(str/lower-case (str/trim value))))))
(defn- check-gpu
[state preparation]
(let [graphics (:effective-graphics preparation)
accepted (and (true? (:webgl2 graphics))
(false? (:software graphics))
(usable-graphics-identity? (:vendor graphics))
(usable-graphics-identity? (:renderer graphics)))]
(if (and (:require-gpu (:opts state)) (not accepted))
(do
(record-failure state (:case-id preparation) :gpu
{:status "failed" :phase "gpu"
:message (str "GPU check failed; renderer: " (pr-str (:renderer graphics))
". Hardware WebGL2 with a usable vendor and renderer is required."
" " settings/gpu-guidance)
:partial {:graphics graphics}})
(reset! (:abort-run? state) true)
:abort)
:ok)))
(defn- ^:async open-case-page
[state case owner deadline]
(let [opened (attempt
(await ((get-in state [:services :new-page])
owner @(:host-ref state)
{:viewport (declarations/resolve-viewport (:view case))
:deadline deadline
:startup-timeout-ms (:startup-timeout-ms (:opts state))
:signal (:signal state)})))]
(cond
(not (failure? opened))
(do
(reset! (:page-ref state) opened)
{:page (:page opened)})
(interrupted? state)
{:outcome :interrupted}
:else
(let [evidence (cause-evidence opened :browser-page)
outcome (failure-outcome state deadline evidence)]
(record-failure state (:id case) :browser-page evidence)
(await (recycle-browser state))
{:outcome outcome}))))
(defn- ^:async open-page
[state case deadline]
(check-deadline deadline)
(if (interrupted? state)
{:outcome :interrupted}
(let [owner (attempt (await (ensure-browser state deadline)))]
(cond
(not (failure? owner))
(await (open-case-page state case owner deadline))
(interrupted? state)
{:outcome :interrupted}
:else
(let [evidence (cause-evidence owner :browser-launch)
outcome (failure-outcome state deadline evidence)]
(record-failure state (:id case) :browser-launch evidence)
(if (and (= :timed-out outcome)
(not= ::browser/startup-cleanup (:type (ex-data owner))))
{:outcome outcome}
(do
(reset! (:abort-run? state) true)
{:outcome :abort})))))))
(defn- ^:async execute-slot
[state case page preparation-id warmup? deadline]
(let [response (await (run-attempt state page preparation-id deadline))
evidence (if (failure? response) (cause-evidence response :attempt) response)
accepted (attempt
(let [measured (measurement/attempt (:id case) preparation-id warmup? evidence)]
(record-attempt state measured)
measured))
measured (if (failure? accepted)
(let [invalid (assoc (cause-evidence accepted :invalid-output)
:phases (measurement/partial-phases (:phases evidence) preparation-id [:main :reset])
:partial {:response evidence})
failed (measurement/attempt (:id case) preparation-id warmup? invalid)]
(record-attempt state failed)
failed)
accepted)]
(cond
(and (failure? response) (interrupted? state)) :interrupted
(= :completed (:outcome measured)) :ok
:else (failure-outcome state deadline (if (failure? response)
(cause-evidence response :attempt)
response)))))
(defn- ^:async finish-slot
[state case page outcome final? deadline]
(if (and final? (= :ok outcome))
(await (screenshot-case state case page deadline))
(do
(when (not= :ok outcome)
(record-screenshot-skip state (:id case) :no-completed-measured-attempt))
outcome)))
(defn- ^:async run-fresh-slot
[state case page warmup? final? deadline]
(let [prepared (await (prepare-case state case page deadline warmup?))]
(if-let [evidence (:failure prepared)]
(do
(record-attempt state (measurement/attempt (:id case) (:preparation-id prepared) warmup?
(dissoc evidence :phases)))
(await (finish-slot state case page
(failure-outcome state deadline evidence) final? deadline)))
(let [preparation (:preparation prepared)]
(if (= :abort (check-gpu state preparation))
:abort
(let [outcome (await (execute-slot state case page (:preparation-id preparation)
warmup? deadline))]
(await (finish-slot state case page outcome final? deadline))))))))
(defn- ^:async run-fresh-page
[state case warmup? final? deadline]
(let [{:keys [page outcome]} (await (open-page state case deadline))]
(if page
(let [outcome (await (run-fresh-slot state case page warmup? final? deadline))]
(if (= :ok outcome)
(if (await (close-current-page state case)) outcome :failed)
outcome))
(or outcome :abandon))))
(defn- ^:async run-fresh-slots
[state case slots deadline]
(if-let [[warmup? & pending] (seq slots)]
(let [outcome (await (run-fresh-page state case warmup? (empty? pending) deadline))]
(if (= :ok outcome)
(run-fresh-slots state case pending deadline)
outcome))
:done))
(defn- ^:async run-fresh-case
[state case deadline]
(let [outcome (await (run-fresh-slots state case (case-slots (:opts state)) deadline))]
(ensure-screenshot-status state case :case-abandoned)
outcome))
(defn- ^:async run-reuse-slots
[state case page preparation-id slots deadline]
(if-let [[warmup? & pending] (seq slots)]
(let [_ (check-deadline deadline)
executed (await (execute-slot state case page preparation-id warmup? deadline))
outcome (await (finish-slot state case page executed (empty? pending) deadline))]
(if (= :ok outcome)
(run-reuse-slots state case page preparation-id pending deadline)
outcome))
:done))
(defn- ^:async abandon-preparation
[state case evidence deadline]
(record-failure state (:id case) :preparation evidence)
(ensure-screenshot-status state case :no-completed-measured-attempt)
(failure-outcome state deadline evidence))
(defn- ^:async run-reuse-prepared
[state case page preparation deadline]
(let [accepted (check-gpu state preparation)
outcome (if (= :abort accepted)
:abort
(await (run-reuse-slots state case page (:preparation-id preparation)
(case-slots (:opts state)) deadline)))]
(ensure-screenshot-status state case :case-abandoned)
(if (= :done outcome)
(if (await (close-current-page state case)) outcome :failed)
outcome)))
(defn- ^:async run-reuse-page
[state case page deadline]
(let [prepared (await (prepare-case state case page deadline false))]
(if-let [evidence (:failure prepared)]
(await (abandon-preparation state case evidence deadline))
(await (run-reuse-prepared state case page (:preparation prepared) deadline)))))
(defn- ^:async run-reuse-case
[state case deadline]
(let [{:keys [page outcome]} (await (open-page state case deadline))]
(cond
page (await (run-reuse-page state case page deadline))
(= :abort outcome) :abort
:else
(do
(ensure-screenshot-status state case :case-abandoned)
(or outcome :abandon)))))
(defn- fail-startup
"Records the startup failure and aborts the run. An interrupted run records
nothing. Returns false."
[state stage cause]
(when-not (interrupted? state)
(record-failure state nil stage (cause-evidence cause stage))
(reset! (:abort-run? state) true))
false)
(defn- ^:async launch-initial
[state deadline]
(let [owner (attempt (await (ensure-browser state deadline)))]
(if (failure? owner)
(fail-startup state :browser-launch owner)
true)))
(defn- ^:async record-provenance
[state deadline]
(let [git (attempt (await (build/git-provenance (:root state)
{:deadline deadline :signal (:signal state)})))]
(if (failure? git)
(fail-startup state :runner-git git)
(do
(swap! (:run-ref state) assoc-in [:metadata :diagnostics :runner-git] git)
(persist state)
true))))
(defn- ^:async start-server
[state deadline]
(let [remaining (process/remaining deadline)
host (attempt
(when (zero? remaining)
(throw (ex-info "server startup timed out"
{:type ::startup-timeout :phase :server-startup})))
(await (server/open (:selected-build state)
{:startup-timeout-ms remaining :signal (:signal state)})))]
(if (failure? host)
(fail-startup state :server-startup host)
(do
(reset! (:host-ref state) host)
true))))
(defn- session-active?
[state]
(and (not @(:abort-run? state)) (not (interrupted? state))))
(defn- get-case-failure
[run case-id]
(or (some :failure (reverse (filter #(= case-id (:case-id %)) (:attempts run))))
(some :failure (reverse (filter #(= case-id (:case-id %)) (:preparations run))))
(some #(when (= case-id (:case-id %)) %)
(reverse (get-in run [:metadata :diagnostics :failures])))))
(defn- ^:async run-case
[state case deadline]
(let [executed (attempt
(if (= :fresh (:context case))
(await (run-fresh-case state case deadline))
(await (run-reuse-case state case deadline))))
outcome (if (failure? executed)
(let [evidence (cause-evidence executed :case)]
(record-failure state (:id case) :case evidence)
(failure-outcome state deadline evidence))
executed)
unmeasured? (and (= :done outcome)
(not (result/has-measurements? @(:run-ref state) (:id case))))
outcome (if unmeasured?
(do
(record-failure state (:id case) :case
{:phase "case" :reason :no-measurements
:message "case has no measurements"})
:failed)
outcome)
saved-outcome (cond
(= :done outcome) :completed
(= :interrupted outcome) :interrupted
(= :timed-out outcome) :timed-out
:else :failed)
failure (when (not= :completed saved-outcome)
(get-case-failure @(:run-ref state) (:id case)))]
(swap! (:run-ref state) result/record-case-outcome (:id case)
(cond-> {:outcome saved-outcome}
failure (assoc :failure failure)))
(ensure-screenshot-status state case :case-abandoned)
(persist state)
(when (not= :done outcome)
(await (recycle-browser state)))
outcome))
(defn- ^:async run-cases
[state cases]
(when (and (seq cases) (session-active? state))
(let [case (first cases)
deadline (process/deadline (:timeout-ms (:opts state)))]
(write-log state log/info (str "▸ Case " (result/case-name (:id case))
" (" (name (:context case)) ")"))
(let [outcome (await (run-case state case deadline))]
(if (#{:abort :interrupted} outcome)
(do
(reset! (:abort-run? state) true)
outcome)
(run-cases state (rest cases)))))))
(defn- ^:async run-session
[state]
(let [deadline (process/deadline (:startup-timeout-ms (:opts state)))
provenance-ready? (await (record-provenance state deadline))
server-ready? (and provenance-ready? (await (start-server state deadline)))
browser-ready? (and server-ready? (session-active? state) (await (launch-initial state deadline)))]
(when (and browser-ready? (session-active? state))
(await (run-cases state (:cases state))))))
(defn- get-termination
[run interrupted harness-failed?]
(let [all-attempted? (every? (fn [[_ slots]]
(and (zero? (:warmups slots))
(zero? (:repetitions slots))))
(:remaining run))
failed? (or (some #(= :failed (:outcome %)) (:attempts run))
(some #(not= :completed (:outcome %)) (vals (:case-outcomes run))))
reason (cond
interrupted :interrupted
all-attempted? (if failed? :failed :completed)
:else :incomplete)
failures (get-in run [:metadata :diagnostics :failures])
attempt-failure (some #(when (:failure %) (:failure %))
(reverse (:attempts run)))
termination (cond-> {:reason reason}
interrupted
(assoc :signal (:signal interrupted))
(and (seq failures)
(or (not= :completed reason) harness-failed?))
(assoc :failure (last failures))
(and (not= :completed reason)
(empty? failures)
(some? attempt-failure))
(assoc :failure attempt-failure))]
termination))
(defn- complete-screenshots
[screenshots cases]
(let [defaults (into {} (map (fn [case]
[(result/case-name (:id case)) {:status :skipped :reason :not-run}]))
cases)]
(merge defaults screenshots)))
(defn- finish-run
[state]
(let [termination (get-termination @(:run-ref state) (interruption (:signal state))
@(:harness-failed? state))]
(when (:screenshots (:opts state))
(swap! (:run-ref state) update-in [:metadata :diagnostics :screenshots]
complete-screenshots (:cases state)))
(swap! (:run-ref state) result/finish-run (time/now) termination)
(persist state)
@(:run-ref state)))
(defn- ^:async release-resource
"Closes the resource in `reference`. Returns nil or the failure."
[reference close]
(attempt :catch-fatal
(when-let [resource @reference]
(reset! reference nil)
(await (close resource)))
nil))
(defn- ^:async cleanup
[state]
(let [page-failure (await (release-resource (:page-ref state) (get-in state [:services :close-page])))
browser-failure (await (release-resource (:owner-ref state) (get-in state [:services :close])))
server-failure (await (release-resource (:host-ref state) server/close))
failure (errors/combine
[{:phase "cleanup" :operation :close-page :error page-failure}
{:phase "cleanup" :operation :close-browser :error browser-failure}
{:phase "cleanup" :operation :close-server :error server-failure}])]
(when failure
(warn-safely state (str "run: cleanup failed: " (errors/message-of failure) "\n")))
failure))
(defn- remove-build
[root selected-build]
(artifacts/remove-builds root [(:id selected-build)]))
(defn- git-facts
[selected-build]
(merge {:sha nil :dirty nil}
(get-in selected-build [:manifest :provenance :git])))
(defn- playwright-version
[root]
(ex/ignoring
(let [require-package (if (exists? js/require)
js/require
(node-module/createRequire (path/join root "package.json")))]
(.-version (require-package "playwright/package.json")))))
(defn- environment
[root _selected-build]
(let [cpus (array-seq (os/cpus))]
{:node (.-version js/process)
:playwright (playwright-version root)
:browser nil
:platform (os/platform)
:release (os/release)
:arch (os/arch)
:cpu {:models (vec (distinct (map #(.-model %) cpus)))
:logical-count (count cpus)}
:launch (browser/launch-settings)}))
(defn- metadata
[root selected-build opts]
{:git (git-facts selected-build)
:build (cond-> {:functional (or (get-in selected-build [:manifest :functional]) {})
:provenance (assoc (get-in selected-build [:manifest :provenance])
:build-id (:id selected-build))}
(contains? (:manifest selected-build) :environment)
(assoc :environment (get-in selected-build [:manifest :environment])))
:environment (environment root selected-build)
:scored? true
:diagnostics (cond-> {:seed (:seed opts)
:filter (:filter opts)
:warmups (:warmups opts)
:repetitions (:repetitions opts)
:timeout-ms (:timeout-ms opts)
:startup-timeout-ms (:startup-timeout-ms opts)
:screenshots? (boolean (:screenshots opts))
:require-gpu? (boolean (:require-gpu opts))
:penpot-gpu (or (.-PENPOT_GPU (.-env js/process)) nil)
:runner-git {:sha nil :dirty nil}
:runtime-helper-identity :unverified
:screenshots {}
:failures []}
(:build-timeout-ms opts) (assoc :build-timeout-ms (:build-timeout-ms opts)))})
(defn- create-run-record
[root run-id selected-build cases opts]
(result/create-run
{:run-id run-id
:started-at (time/now)
:plan {:cases cases
:warmups (:warmups opts)
:repetitions (:repetitions opts)
:timeout-policy {:case-ms (:timeout-ms opts)}}
:metadata (metadata root selected-build opts)}))
(defn- create-run-state
[root home selected-build cases opts signal]
{:root root
:selected-build selected-build
:cases cases
:opts opts
:signal signal
:home home
:run-ref (atom (create-run-record root (:run-id home) selected-build cases opts))
:sink-failure-ref (atom nil)
:owner-ref (atom nil)
:page-ref (atom nil)
:host-ref (atom nil)
:abort-run? (atom false)
:harness-failed? (atom false)
:services (merge browser/service (:browser opts))})
(defn- record-session-failure
[state stage cause]
(let [recorded (attempt :catch-fatal
(record-failure state nil stage (cause-evidence cause stage)))]
(when (failure? recorded)
(warn-safely state (str "Could not record " (when (= :cleanup stage) "cleanup ")
"failure: " (errors/message-of recorded) "\n")))))
(defn- ^:async execute-session
"Runs the session and retains failures that require final reporting.
Args:
state: The session's execution state.
Returns:
The failure that ended execution, or nil. Interrupted sessions retain
fatal failures."
[state]
(let [ended (attempt :catch-fatal (await (run-session state)))]
(when (and (failure? ended) (or (not (interrupted? state)) (errors/fatal? ended)))
(record-session-failure state :runner ended)
ended)))
(defn- ^:async cleanup-session
[state]
(when-let [cause (await (cleanup state))]
(record-session-failure state :cleanup cause)
cause))
(defn- remove-run-build
[state saved? failures]
(when (and saved? (:remove (:opts state)) (not-any? process/unresolved? failures))
(attempt :catch-fatal
(remove-build (:root state) (:selected-build state))
nil)))
(defn- format-run-report
"Returns the report text or the failure."
[state run]
(let [report (attempt (format/format-run (summary/summarize run)))]
(when (failure? report)
(warn-safely state (str "Report failed: " (errors/message-of report) "\n")))
report))
(defn- log-run-completion
[state run]
(attempt :catch-fatal
(write-log state log/debug (str "Run " (name (get-in run [:termination :reason]))))
nil))
(defn- get-exit-code
[run causes harness-failed?]
(case (get-in run [:termination :reason])
:interrupted (outcome/interruption-code (get-in run [:termination :signal]))
:completed (if (or (seq causes) harness-failed?) 1 0)
1))
(defn- finish-execution
[state session-error cleanup-error]
(let [saved (attempt :catch-fatal (finish-run state))
run (if (failure? saved) @(:run-ref state) saved)
removal (remove-run-build state (not (failure? saved))
(filter failure? [session-error cleanup-error saved @(:sink-failure-ref state)]))
report (format-run-report state run)
logged (log-run-completion state run)
causes (->> [cleanup-error session-error saved removal report logged
@(:sink-failure-ref state)]
(filter failure?)
distinct
vec)
home (:home state)]
{:run run
:home home
:log-path (:log-path home)
:report (when-not (failure? report) report)
:errors causes
:exit-code (get-exit-code run causes @(:harness-failed? state))}))
(defn ^:async execute
"Executes a prepared selection. Owns host and browser.
- `root`: the frontend checkout root.
- `home`: the reserved output home.
- `selected-build`: the prepared build.
- `cases`: the selected cases.
- `options`: resolved execution settings, including optional local :browser services.
- `signal`: an optional abort signal.
A browser startup failure leaves a valid partial record.
Returns the run, home, report, log path, exit code and the failures in
:errors, a cleanup failure first. :errors includes any failed record or log
write, including a failure followed by a successful write. An interruption
is not a failure."
[root home selected-build cases options signal]
(binding [log/*log-path* (:log-path home)]
(let [state (create-run-state root home selected-build cases options signal)]
(persist state)
(let [session-error (await (execute-session state))
cleanup-error (await (cleanup-session state))]
(finish-execution state session-error cleanup-error)))))
(defn- ^:async get-selected-build
[root opts signal]
(let [{:keys [kind id default-features features build-timeout-ms]} (:build opts)]
(case kind
:existing (artifacts/load-build root id)
:prepare (await (build/prepare
{:root root
:selection {:default-features? default-features :features features}
:build-timeout-ms build-timeout-ms :signal signal})))))
(defn- get-cases
[selected-build opts]
(try
(catalog/collect selected-build {:master-seed (:seed opts) :filter (:filter opts)})
(catch :default cause
(throw (ex-info (errors/message-of cause)
(assoc (ex-data cause) :build-id (:id selected-build)
:log-path (:log-path selected-build))
cause)))))
(defn ^:async run
"Prepares or loads a build, collects matching cases and executes one run.
- `root`: the frontend checkout root.
- `options`: resolved run options with a tagged :build source.
- `signal`: an optional abort signal.
- `services`: local service overrides, with browser functions under :browser.
Returns an outcome with the reserved home, available evidence and failures."
[root options signal services]
(let [home (runs/create-home {:root root :run-id (str (random-uuid))
:output-dir (:output-dir options) :remove (:remove options)})
opts (cond-> (assoc (get-execution-options options) :browser (:browser services))
(= :prepare (get-in options [:build :kind]))
(assoc :build-timeout-ms (get-in options [:build :build-timeout-ms])))]
(binding [log/*log-path* (:log-path home)]
(try
(let [selected-build (await (get-selected-build root options signal))
cases (get-cases selected-build opts)]
(log/info (str " build " (:id selected-build)))
(log/info (str " selected " (count cases) " cases"))
(await (execute root home selected-build cases opts signal)))
(catch :default cause
(outcome/failed (-> cause errors/as-failure
(errors/with-data {:log-path (:log-path home)}))
{:home home :run nil :report nil}))))))

View File

@ -0,0 +1,309 @@
;; 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 benches.render-wasm.node.runner.server
"An owned loopback HTTP server for one immutable renderer build."
(:require
["fs" :as fs]
["http" :as http]
["path" :as path]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.node.files :as files]
[benches.render-wasm.node.settings :as settings]
[clojure.string :as str]))
(def ^:private mime
{".html" "text/html; charset=utf-8"
".js" "application/javascript"
".cjs" "application/javascript"
".mjs" "application/javascript"
".wasm" "application/wasm"
".css" "text/css; charset=utf-8"
".json" "application/json"
".svg" "image/svg+xml"
".png" "image/png"})
(def ^:private status-text
{400 "bad request"
403 "forbidden"
404 "not found"
405 "method not allowed"})
(def ^:private fs-error-status
{"ENOENT" 404
"ENOTDIR" 404
"EACCES" 403})
(def ^:private owners
"Maps each owned server to {:sockets Set :shutdown atom}."
(js/WeakMap.))
;; Request URL to file
(defn- rejection
"Returns an Error that `error-status` maps to `status`."
[status message]
(ex-info message {:status status}))
(defn- unsafe-pathname?
[pathname]
(or (str/includes? pathname "\u0000")
(str/includes? pathname "\\")
(some #{".."} (str/split pathname #"/"))))
(defn- parse-pathname
"Returns the decoded path of a request URL without its query and fragment.
Throws:
400 for a path without a leading slash and for a malformed escape.
403 for a path with a NUL, a backslash or a `..` segment."
[url]
(let [pathname (js/decodeURIComponent (first (str/split url #"[?#]" 2)))]
(cond
(not (str/starts-with? pathname "/")) (throw (rejection 400 "invalid request path"))
(unsafe-pathname? pathname) (throw (rejection 403 "forbidden request path"))
:else pathname)))
(defn- pathname->relative
[pathname]
(if (= pathname "/") "index.html" (subs pathname 1)))
(defn- contained
"Returns `file` when it lies within `root`, otherwise throws 403 with `message`."
[root message file]
(if (files/contained? root file)
file
(throw (rejection 403 message))))
(defn- request-file
"Returns the real path of the file that `url` selects below `root`.
Throws:
400 or 403 for a bad path.
403 for a path or a symlink that leaves `root`.
The filesystem error when the file cannot be resolved."
[root url]
(->> (parse-pathname url)
(pathname->relative)
(path/resolve root)
(contained root "forbidden request path")
(fs/realpathSync)
(contained root "forbidden artifact symlink")))
;; File to response
(defn- error-status
"Returns the HTTP status for a failure while serving a request."
[error]
(or (:status (ex-data error))
(when (instance? js/URIError error) 400)
(get fs-error-status (.-code ^js error) 500)))
(defn- error-response
[method status]
{:status status
:headers {"Content-Type" "text/plain; charset=utf-8"}
:body (when-not (= "HEAD" method)
(get status-text status "server error"))})
(defn- method-not-allowed
[method]
(assoc-in (error-response method 405) [:headers "Allow"] "GET, HEAD"))
(defn- file-response
[method file ^js stat]
{:status 200
:headers {"Content-Type" (get mime (path/extname file) "application/octet-stream")
"Content-Length" (.-size stat)}
:body (when (= "GET" method)
(fs/readFileSync file))})
(defn- get-file-response
[root method url]
(try
(let [file (request-file root url)
^js stat (fs/statSync file)]
(if (.isFile stat)
(file-response method file stat)
(error-response method 404)))
(catch :default error
(error-response method (error-status error)))))
(defn- respond
"Returns {:status :headers :body} for a request with `method` and `url`."
[root method url]
(if (contains? #{"GET" "HEAD"} method)
(get-file-response root method url)
(method-not-allowed method)))
(defn- write-response
[^js response {:keys [status headers body]}]
(.writeHead response status (clj->js headers))
(.end response body))
(defn- serve
[root ^js request ^js response]
(write-response response (respond root (.-method request) (.-url request))))
;; Shutdown
(defn- close-failure
"Returns `error` unless it is nil or reports a server that was not running."
[error]
(when-not (or (nil? error)
(= "ERR_SERVER_NOT_RUNNING" (.-code ^js error)))
error))
(defn- close-server
"Closes `server` and destroys its `sockets`.
Returns a promise that resolves once the server closed. A server that is
not running counts as closed."
[^js server ^js sockets]
(js/Promise.
(fn [resolve reject]
(let [settle (fn [error]
(if-let [failure (close-failure error)]
(reject failure)
(resolve nil)))]
(try
(.close server settle)
(run! #(.destroy ^js %) (array-seq (js/Array.from sockets)))
(catch :default error
(settle error)))))))
(defn close
"Closes an owned host once and destroys only that server's connections.
Args:
host: Map returned by `open`.
Returns:
Promise that resolves once the server closed. Repeated calls return the
same promise, including during startup."
[{:keys [server]}]
(let [{:keys [sockets shutdown]} (.get owners server)]
(or @shutdown
(reset! shutdown (close-server server sockets)))))
;; Startup
(defn- track-connection
[^js sockets shutdown ^js socket]
(.add sockets socket)
(.once socket "close" #(.delete sockets socket))
(when @shutdown
(.destroy socket)))
(defn- create-host
"Returns {:server :build} for a server of `build`'s files that does not
listen yet."
[build]
(let [root (fs/realpathSync (:dir build))
^js server (http/createServer (partial serve root))
sockets (js/Set.)
shutdown (atom nil)]
(.set owners server {:sockets sockets :shutdown shutdown})
(.on server "connection" (partial track-connection sockets shutdown))
{:server server :build build}))
(defn- aborted?
[^js signal]
(and signal (.-aborted signal)))
(defn- aborted-error
[]
(ex-info "renderer server startup aborted" {:type ::aborted}))
(defn- timeout-error
[timeout-ms]
(ex-info "renderer server startup timed out"
{:type ::startup-timeout :timeout-ms timeout-ms}))
(defn- subscribe-startup
"Routes the end of `server` startup to `succeed` or `fail`.
`succeed` receives the listening event. `fail` receives the first error
event of `server`, an `::aborted` error when `signal` (optional) aborts and
a `::startup-timeout` error after `timeout-ms`.
Returns a function that removes the subscriptions."
[^js server signal timeout-ms succeed fail]
(let [on-abort #(fail (aborted-error))
timer (js/setTimeout #(fail (timeout-error timeout-ms)) timeout-ms)]
(.once server "listening" succeed)
(.once server "error" fail)
(when signal
(.addEventListener ^js signal "abort" on-abort #js {:once true}))
(fn []
(js/clearTimeout timer)
(.removeListener server "listening" succeed)
(.removeListener server "error" fail)
(when signal
(.removeEventListener ^js signal "abort" on-abort)))))
(defn- ^:async listen
"Waits until `server` listens on 127.0.0.1 and an ephemeral port.
Aborting `close-signal` closes the server. Rejects as `subscribe-startup`
describes, and when `signal` is already aborted, without listening."
[^js server close-signal signal timeout-ms]
(when (aborted? signal)
(throw (aborted-error)))
;; Nothing awaits before the subscription, so an abort that follows the
;; call reaches it.
(let [^js startup (js/Promise.withResolvers)
release (subscribe-startup server signal timeout-ms
(.-resolve startup) (.-reject startup))]
(try
(.listen server #js {:host "127.0.0.1" :port 0 :signal close-signal})
(await (.-promise startup))
(finally
(release)))))
(defn- ^:async startup-failure
"Closes `host` after a failed startup.
Returns `error`, or an Error that also holds the failure of the close."
[host ^js controller error]
(.abort controller)
(try
(await (close host))
error
(catch :default close-error
(errors/combine
[{:phase "server-startup" :operation :listen :error error}
{:phase "cleanup" :operation :close-server :error close-error}]))))
(defn- base-url
[^js server]
(str "http://127.0.0.1:" (.-port (.address server))))
(defn ^:async open
"Starts serving build's directory on 127.0.0.1 and an ephemeral port.
Args:
build: Map with the directory to serve at `:dir`.
options: Optional map with `:startup-timeout-ms` (30000 by default) and
`:signal`, an AbortSignal.
Returns:
Promise of {:server NodeHttpServer :base-url string :build build}.
Throws:
Rejects on abort, timeout and listen errors, after closing the owned
server. A failed close joins the startup error."
([build] (open build {}))
([build {:keys [startup-timeout-ms signal]
:or {startup-timeout-ms settings/default-startup-timeout-ms}}]
(let [host (create-host build)
controller (js/AbortController.)]
(try
(await (listen (:server host) (.-signal controller) signal startup-timeout-ms))
(assoc host :base-url (base-url (:server host)))
(catch :default error
(let [failure (await (startup-failure host controller error))]
(throw failure)))))))

View File

@ -0,0 +1,14 @@
;; 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 benches.render-wasm.node.settings
"Shared operational settings and guidance.")
(def default-build-timeout-ms 300000)
(def default-startup-timeout-ms 30000)
(def gpu-guidance
"Start the container with ./manage.sh run-devenv --gpu and use pnpm run render-bench.")

View File

@ -0,0 +1,403 @@
;; 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 benches.render-wasm.node.storage.artifacts
"Managed renderer builds and output homes for benchmark runs and comparisons.
Here, 'managed' means that the runner owns the build directory and its
contents, which can be deleted by means of the `purge` command."
(:require
["fs" :as fs]
["path" :as path]
[app.common.schema :as sm]
[app.common.transit :as transit]
[benches.render-wasm.codec :as codec]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.node.files :as files]
[benches.render-wasm.node.time :as time]
[clojure.string :as str]))
(def schema:build
"Stored renderer build identity, lifecycle and provenance."
[:map
[:type [:= :renderer-build]]
[:id [:string {:min 1}]]
[:status [:enum :building :ready :failed]]
[:created-at :string]
[:updated-at :string]
[:environment {:optional true} [:map-of :string :string]]
[:failure {:optional true} errors/schema:diagnostic]
[:functional :map]
[:provenance :map]])
(def ^:private executable-dir
;; CJS boots use __dirname. Node's ESM test boots have an absolute argv[1]
;; instead; capture it before a caller changes cwd. Neither uses a cwd root.
(if (exists? js/__dirname)
js/__dirname
(let [script (aget js/process.argv 1)]
(when (and (string? script) (path/isAbsolute script))
(path/dirname script)))))
(defn frontend-root
"Finds the frontend checkout by walking up from the Node executable.
Supports target/performance/render-wasm/runner.cjs and the ESM
target/{performance-tests,tests}/test.js boots, independent of caller cwd."
[]
(when-not executable-dir
(throw (ex-info "cannot locate the Node executable directory"
{:type ::missing-executable-dir})))
(loop [dir (path/resolve executable-dir)]
(let [deps (path/join dir "deps.edn")
scenes (path/join dir "performance" "benches" "render_wasm")
parent (path/dirname dir)]
(cond
(and (fs/existsSync deps) (.isFile ^js (fs/statSync deps))
(fs/existsSync scenes) (.isDirectory ^js (fs/statSync scenes)))
dir
(= parent dir)
(throw (ex-info "cannot locate the frontend checkout"
{:type ::missing-frontend-root
:executable-dir executable-dir}))
:else
(recur parent)))))
(defn valid-id?
"True for IDs of up to 128 letters, digits, underscores or hyphens,
starting with a letter or digit."
[id]
(and (string? id)
(boolean (re-matches #"[A-Za-z0-9][A-Za-z0-9_-]{0,127}" id))))
(defn- check-id
[id]
(when-not (valid-id? id)
(throw (ex-info "invalid renderer build id" {:type ::invalid-id :id id})))
id)
(defn invalid-home
"Throws an ::invalid-home exception with `data` and an optional cause."
([message data]
(throw (ex-info message (assoc data :type ::invalid-home))))
([message data cause]
(throw (ex-info message (assoc data :type ::invalid-home) cause))))
(defn check-home-id
"Returns `id` when it is valid, or throws ::invalid-home with `message`."
[id message]
(when-not (valid-id? id)
(invalid-home message {:id id}))
id)
(defn check-output-dir
"Resolves an explicit user directory from the caller's cwd and checks that it
does not exist. Returns nil for nil. Does not create anything."
[dir]
(when (some? dir)
(when (str/blank? dir)
(invalid-home "output directory requires a nonempty path" {}))
(let [resolved (path/resolve dir)
exists? (try
(fs/lstatSync resolved #js {:throwIfNoEntry false})
(catch :default cause
(let [detail (errors/message-of cause)]
(invalid-home (str "cannot inspect output directory: " detail)
{:path resolved :cause detail}
cause))))]
(when exists?
(invalid-home "output directory already exists" {:path resolved}))
resolved)))
(defn- create-home*
[dir stem]
(let [log-path (path/join dir (str stem ".log"))]
(fs/mkdirSync dir #js {:recursive true})
(fs/writeFileSync log-path "" #js {:encoding "utf8" :flag "wx"})
{:dir dir
:log-path log-path
:record-path (path/join dir (str stem ".transit"))}))
(defn create-home
"Creates `dir` and its parents, reserves an empty log.
A filesystem failure throws ::invalid-home with `message` and `:path`, and
keeps the original cause.
Args:
dir: path to create.
stem: names the .log and .transit files.
message: used for errors
Returns:
Directory created, log path and record path."
[dir stem message]
(try
(create-home* dir stem)
(catch :default cause
(invalid-home message
{:path dir :cause (errors/message-of cause)}
cause))))
(defn- checked-path
[root file]
(let [resolved (fs/realpathSync file)]
(when-not (files/contained? root resolved)
(throw (ex-info "renderer artifact path escapes its managed directory"
{:type ::unsafe-path :path file})))
resolved))
(defn managed-root
"Returns the managed path under an existing frontend checkout.
Checks its nearest existing ancestor before creating or removing paths,
so a managed-root or ancestor symlink cannot escape the frontend root."
[root]
(let [frontend (fs/realpathSync root)
managed (path/resolve root "target" "performance" "render-wasm")]
(loop [existing managed]
(if (fs/existsSync existing)
(checked-path frontend existing)
(recur (path/dirname existing))))
managed))
(defn inside-builds?
"Checks the whole builds tree, resolving symlink ancestors of both paths."
[frontend-root output-path]
(files/contained? (files/get-real-path (path/join (managed-root frontend-root) "builds"))
(files/get-real-path output-path)))
(defn- build-reference
[root id manifest]
(let [dir (path/join (managed-root root) "builds" id)]
{:id id
:dir dir
:manifest-path (path/join dir "build.transit")
:log-path (path/join dir "build.log")
:cases-path (path/join dir "cases.cjs")
:browser-dir (path/join dir "browser")
:renderer-dir (path/join dir "renderer")
:index-path (path/join dir "index.html")
:manifest manifest}))
(defn create-build
"Allocates a UUID build and saves its :building build.transit and empty log.
- root: the frontend checkout.
- selection: {:default-features? boolean :features vector}
- provenance: {:git {:sha string-or-nil :dirty boolean-or-nil}}.
Returns a build reference.
"
[root selection provenance]
(let [id (str (random-uuid))
now (time/now)
manifest {:type :renderer-build
:id id
:status :building
:created-at now
:updated-at now
:functional {:mode :release
:default-features? (:default-features? selection)
:features (:features selection)
:cljs-profile-marks false}
:provenance provenance}
build (build-reference root id manifest)]
(fs/mkdirSync (path/dirname (:dir build)) #js {:recursive true})
(checked-path (fs/realpathSync (managed-root root)) (path/dirname (:dir build)))
(fs/mkdirSync (:dir build))
(fs/mkdirSync (:browser-dir build))
(fs/mkdirSync (:renderer-dir build))
(fs/writeFileSync (:log-path build) "" "utf8")
(files/atomic-write (:manifest-path build) (codec/encode-str manifest))
build))
(defn write-manifest
"Atomically replaces a manifest and returns the updated build reference.
Only :building builds can change. An unchanged terminal manifest is a
no-op; :ready and :failed builds cannot otherwise change.
Read the saved status so an old build reference cannot reopen a build."
[build manifest]
(let [current (transit/decode-str (fs/readFileSync (:manifest-path build) "utf8"))]
(when-not (and (= (:id build) (:id current) (:id manifest))
(sm/validate schema:build current)
(sm/validate schema:build manifest))
(throw (ex-info "invalid schema:build"
{:type ::invalid-manifest :id (:id build)})))
(if (contains? #{:ready :failed} (:status current))
(if (= current manifest)
(assoc build :manifest current)
(throw (ex-info "renderer build is already terminal"
{:type ::terminal-build
:id (:id build)
:status (:status current)})))
(let [manifest (assoc manifest :updated-at (time/now))]
(files/atomic-write (:manifest-path build) (codec/encode-str manifest))
(assoc build :manifest manifest)))))
(defn- require-file
[root file]
(let [resolved (checked-path root file)]
(when-not (.isFile ^js (fs/statSync resolved))
(throw (ex-info "renderer artifact is not a file"
{:type ::missing-artifact :path file})))
resolved))
(defn check-files
"Checks required files and selected directory containment; returns build.
build is a reference from create-build or load-build. This check neither
reads nor changes manifest status. The build helper must also check catalog
and browser identities before writing its terminal :ready manifest."
[build]
;; Build directories use <frontend>/target/performance/render-wasm/builds/<id>.
(let [root (path/resolve (:dir build) ".." ".." ".." ".." "..")
managed (fs/realpathSync (managed-root root))
dir (checked-path managed (:dir build))]
(require-file dir (:cases-path build))
(require-file dir (path/join (:browser-dir build) "browser.js"))
(require-file dir (path/join (:renderer-dir build) "render-wasm.js"))
(require-file dir (path/join (:renderer-dir build) "render-wasm.wasm"))
(require-file dir (path/join (:renderer-dir build) "shared.js"))
(require-file dir (:index-path build))
build))
(defn load-build
"Loads a ready schema:build and checks every required artifact.
IDs cannot contain path segments. The build, manifest and artifact paths
must remain inside the managed root and the selected build after resolving
symlinks. Building, failed, incomplete and unsupported builds are rejected."
[root id]
(check-id id)
(let [managed (fs/realpathSync (managed-root root))
build (build-reference root id nil)
dir (checked-path managed (:dir build))
file (require-file dir (:manifest-path build))
manifest (transit/decode-str (fs/readFileSync file "utf8"))]
(when-not (and (sm/validate schema:build manifest)
(= id (:id manifest)))
(throw (ex-info "invalid schema:build"
{:type ::unsupported-manifest :id id})))
(when-not (= :ready (:status manifest))
(throw (ex-info "renderer build is not ready"
{:type ::not-ready :id id :status (:status manifest)})))
(check-files (assoc build :manifest manifest))))
(def ^:private build-outputs
["build.transit" "build.log" "cases.cjs" "index.html"
"browser/browser.js" "renderer/render-wasm.js" "renderer/render-wasm.wasm"
"renderer/render-wasm.wasm.map" "renderer/shared.js" "renderer/cargo-build.json"
"renderer/worker/render.js"])
(defn- directory?
[file]
(and (fs/existsSync file) (.isDirectory (fs/statSync file))))
(defn- unlink-if-present
[file]
(when-not (directory? file)
(try
(fs/unlinkSync file)
(catch :default error
(when-not (contains? #{"ENOENT" "ENOTDIR"} (.-code error))
(throw error))))))
(defn- clean-build
[dir]
(when (directory? dir)
(doseq [file build-outputs]
(unlink-if-present (path/join dir file)))))
(defn- purge-builds
[root]
(when (directory? root)
(doseq [id (array-seq (fs/readdirSync root))]
(clean-build (path/join root id)))))
(defn- purge-runs
[root]
(loop [pending [root]
visited #{}]
(when-let [dir (peek pending)]
(let [pending (pop pending)
real (when (directory? dir) (fs/realpathSync dir))]
(if (or (nil? real) (contains? visited real))
(recur pending visited)
(do
(unlink-if-present (path/join dir "run.log"))
(let [names (array-seq (fs/readdirSync dir))]
(doseq [file names
:when (str/ends-with? (str/lower-case file) ".png")]
(unlink-if-present (path/join dir file)))
(unlink-if-present (path/join dir "run.transit"))
(recur (into pending (map #(path/join dir %) names))
(conj visited real)))))))))
(defn- purge-feature-comparisons
"Unlinks feature-comparison records and logs, configuration run evidence and
edge reports from the output directory.
Keeps directories, builds and unrelated files."
[root]
(loop [pending [root]
visited #{}]
(when-let [dir (peek pending)]
(let [pending (pop pending)
real (when (directory? dir) (fs/realpathSync dir))]
(if (or (nil? real) (contains? visited real))
(recur pending visited)
(let [parent (path/basename (path/dirname dir))
base (path/basename dir)
names (array-seq (fs/readdirSync dir))]
(when (= "feature-comparisons" parent)
(unlink-if-present (path/join dir "feature-comparison.log"))
(unlink-if-present (path/join dir "feature-comparison.transit")))
(when (= "configs" parent)
(unlink-if-present (path/join dir "run.log"))
(unlink-if-present (path/join dir "run.transit")))
(when (= "comparisons" base)
(doseq [file names
:when (str/ends-with? (str/lower-case file) ".transit")]
(unlink-if-present (path/join dir file))))
(doseq [file names
:when (str/ends-with? (str/lower-case file) ".png")]
(unlink-if-present (path/join dir file)))
(recur (into pending (map #(path/join dir %) names))
(conj visited real))))))))
(defn purge
"Unlinks known outputs from :builds, :runs, :comparisons or all of them (:all).
The caller must be idle: no build, server, run or comparison may still own
these paths. Follows directory symlinks; keeps directories and unrelated
files."
[root category]
(let [managed (managed-root root)]
(case category
:builds (purge-builds (path/join managed "builds"))
:runs (purge-runs (path/join managed "runs"))
:comparisons (purge-feature-comparisons
(path/join managed "feature-comparisons"))
:all (do
(purge-builds (path/join managed "builds"))
(purge-runs (path/join managed "runs"))
(purge-feature-comparisons
(path/join managed "feature-comparisons")))
(throw (ex-info "invalid renderer artifact category"
{:type ::invalid-category :category category})))))
(defn remove-builds
"Unlinks known outputs of selected build IDs. The caller must be idle."
[root ids]
(let [ids (mapv check-id ids)
managed (managed-root root)
builds (path/join managed "builds")]
(doseq [id ids]
(clean-build (path/join builds id)))))

View File

@ -0,0 +1,78 @@
;; 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 benches.render-wasm.node.storage.runs
"Manages saved run destinations, schema:run persistence and diagnostic logs.
Runs are saved under <frontend>/target/performance/render-wasm/runs.
--output-dir selects another destination. Purge cleans files reachable from
the default runs tree, including explicit destinations and directory links."
(:require
["fs" :as fs]
["path" :as path]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.node.files :as files]
[benches.render-wasm.node.storage.artifacts :as artifacts]
[benches.render-wasm.result :as result]
[clojure.string :as str]))
(defn create-home
"Creates one run home and its empty log. The record is written separately
after the plan exists. An explicit directory must not exist; a managed home
uses the frontend root regardless of cwd."
[{:keys [root run-id output-dir remove]}]
(artifacts/check-home-id run-id "invalid run id")
(let [dir (artifacts/check-output-dir
(or output-dir (path/join (artifacts/managed-root root) "runs" run-id)))]
(when (and remove (artifacts/inside-builds? root dir))
(artifacts/invalid-home "--remove cannot use an output directory inside builds" {:path dir}))
(assoc (artifacts/create-home dir "run" "cannot create run home") :run-id run-id)))
(defn get-record
"Reads and validates a saved run record.
Args:
file: Input path, resolved relative to cwd.
options: Optional :role identifying the input within an operation.
Returns:
The validated run record.
Throws:
An error retaining its message, type and cause, with :path, :operation
and the supplied :role in ex-data."
([file]
(get-record file {}))
([file {:keys [role]}]
(try
(when-not (and (string? file) (fs/existsSync file))
(throw (ex-info "run record is missing" {:type ::missing-run})))
(let [record (result/decode (fs/readFileSync file "utf8"))]
(when-not (= :run-record (:type record))
(throw (ex-info "expected a saved run record" {:type ::invalid-run})))
record)
(catch :default cause
(throw (errors/with-data
(errors/as-failure cause)
(cond-> {:path file :operation :read-record}
role (assoc :role role))))))))
(defn write-record!
"Validates schema:run and atomically replaces the saved file."
[home run]
(files/atomic-write (:record-path home) (result/encode run)))
(defn screenshot-file
"Returns the relative PNG filename for a case, replacing path separators."
[case-id]
(str (str/replace (result/case-name case-id) #"[/\\]" "-") ".png"))
(defn screenshot-path
"Returns the relative filename and absolute path under the run home."
[home case-id]
(let [file (screenshot-file case-id)]
{:file file :path (path/join (:dir home) file)}))

View File

@ -0,0 +1,12 @@
;; 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 benches.render-wasm.node.time)
(defn now
"Returns the current UTC time as an ISO 8601 string."
[]
(.toISOString (js/Date.)))

View File

@ -0,0 +1,25 @@
;; 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 benches.render-wasm.quantities
"Portable descriptions of raw measured quantities.")
(def schema:description
[:map {:closed true}
[:unit keyword?]
[:description {:optional true} string?]])
(def descriptions
{:command/elapsed
{:unit :ms :description "Command core start through its completion endpoint."}
:renderer/call-elapsed
{:unit :ms :description "Elapsed time of each synchronous renderer call."}
:renderer/cache-call-elapsed
{:unit :ms :description "Elapsed time of each synchronous cached renderer call."}
:renderer/prepared-scene-to-full
{:unit :ms :description "Prepared scene render request through the renderer return at Full."}
:camera/finalization-to-full
{:unit :ms :description "Camera finalization through the renderer return at Full."}})

View File

@ -0,0 +1,73 @@
;; 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 benches.render-wasm.random
"Seeded numbers for reproducible scene construction and report resampling.
A seed is an integer from 0 through 4294967295. Each call to `create` owns a
separate Mulberry32 state. Calling the returned function advances that state
and returns a number in [0,1). Equal seeds and equal numbers of calls produce
equal sequences, including seed zero."
(:require
[app.common.types.color :as clr]
[app.common.uuid :as uuid]))
(defn seed?
"Returns true for integers from 0 through 4294967295, inclusive."
[value]
(and (integer? value) (<= 0 value 4294967295)))
(defn create
"Returns a function that draws seeded numbers from [0, 1) using Mulberry32.
Each function owns its state. A seed outside the uint32 range throws
::invalid-seed; uint32 means an integer from 0 through 4294967295."
[seed]
(when-not (seed? seed)
(throw (ex-info "random seed must be an integer in [0, 2^32)"
{:type ::invalid-seed :seed seed})))
(let [state (volatile! (bit-or seed 0))]
(fn []
(let [s (bit-or (+ @state 0x6d2b79f5) 0)
_ (vreset! state s)
v (js/Math.imul (bit-xor s (unsigned-bit-shift-right s 15))
(bit-or s 1))
v (bit-xor v (+ v (js/Math.imul (bit-xor v (unsigned-bit-shift-right v 7))
(bit-or v 61))))
v (bit-xor v (unsigned-bit-shift-right v 14))]
(/ (unsigned-bit-shift-right v 0) 4294967296)))))
(defn round3
"Rounds to 3 decimal places"
[value]
(/ (js/Math.round (* 1000 value)) 1000))
(defn rng-float
"Draws a float in `[min, max)` from the random source."
[rng min max]
(+ min (* (rng) (- max min))))
(defn rng-int
"Draws an integer in `[min, max)` from the random source."
[rng min max]
(js/Math.floor (rng-float rng min max)))
(defn rng-uuid
"Draws a deterministic uuid from two 32-bit values of the random source."
[rng]
(uuid/custom (rng-int rng 0 4294967296)
(rng-int rng 0 4294967296)))
(defn rng-hex-color
"Draws a hex color from three RGB channels in [0,256)."
[rng]
(clr/rgb->hex [(rng-int rng 0 256) (rng-int rng 0 256) (rng-int rng 0 256)]))
(defn rng-opacity
"Draws opacity in [0.1,0.999], rounded to three decimal places."
[rng]
(min 0.999 (round3 (rng-float rng 0.1 1))))

View File

@ -0,0 +1,241 @@
;; 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 benches.render-wasm.report.analysis
"Calculates statistics and median changes for benchmark measurements.
summarize/summarize-metric passes one vector to `statistics`. An attempt is
one execution of a benchmark case. Each value represents one attempt, including
a batch average: the attempt's total divided by its number of inner calls.
compare/compare-runs passes baseline and candidate vectors to changes.
Those callers exclude warmups, failed attempts and invalid measurements,
and supply already validated finite values and resolved settings.
Report fields use maps: {:value number} contains a calculated number;
{:lower number :upper number} contains interval bounds; {:reason keyword}
explains why a valid input cannot supply that result. For example,
:insufficient-observations can include :minimum, the required sample size.
This contract preserves unavailable fields through Transit and lets
report.format display the reason. quantile and resolve-settings reject invalid
arguments at their boundaries.
Resampling draws a sample of the original size, allowing repeated values.
Repeating this process gives medians whose central :confidence fraction
defines a percentile confidence interval. These intervals describe the
supplied measurements; evolving caches limit their interpretation."
(:require
[app.common.schema :as sm]
[benches.render-wasm.random :as random]))
(def defaults
"Default settings for report calculations.
:draws is the number of replacement samples; :seed controls their generator.
:confidence is the fraction between interval bounds. :min-ci, :min-p95 and
:min-p99 set the required observation counts for those results."
{:draws 2000
:seed 0x70656e70
:confidence 0.95
:min-ci 10
:min-p95 100
:min-p99 500})
(def schema:settings
"Valid settings for sample draws, seed and report limits.
The seed is an integer in [0, 2^32); confidence lies strictly between 0 and 1.
Draw and minimum observation counts are positive integers."
[:map {:closed true}
[:draws [:int {:min 1 :max 2147483647}]]
[:seed [:fn random/seed?]]
[:confidence [:fn #(and (number? %) (js/Number.isFinite %) (< 0 % 1))]]
[:min-ci [:int {:min 1}]]
[:min-p95 [:int {:min 1}]]
[:min-p99 [:int {:min 1}]]])
(defn resolve-settings
"Returns defaults merged with supplied overrides, or defaults for nil.
Accepts a complete settings map too. Invalid values or unknown keys throw
::invalid-settings. Callers store this resolved map with their results."
[overrides]
(let [settings (merge defaults overrides)]
(when-not (sm/validate schema:settings settings)
(throw (ex-info "invalid analysis settings" {:type ::invalid-settings
:settings settings})))
settings))
(defn- finite-number?
"Returns true for numbers other than NaN and positive or negative infinity."
[value]
(and (number? value) (js/Number.isFinite value)))
(defn- observation-vector
"Returns a vector of supplied finite measurements, or an empty vector for nil.
A non-sequence or any non-finite/non-numeric entry throws ::invalid-observations.
Report callers exclude unusable raw entries before requesting calculations."
[values]
(when-not (and (or (nil? values) (sequential? values))
(every? finite-number? values))
(throw (ex-info "observations must be a sequence of finite numbers"
{:type ::invalid-observations :values values})))
(vec values))
(defn- sorted-quantile
"Returns an interpolated value from a nonempty vector sorted in ascending order.
Probability 0 selects the minimum, 1 the maximum and 0.5 the median.
At position (count - 1) * probability, a fractional index gives the weighted
average of its neighboring values. Callers validate the probability."
[sorted probability]
(let [position (* (dec (count sorted)) probability)
lower (js/Math.floor position)
upper (js/Math.ceil position)
weight (- position lower)]
(+ (* (- 1 weight) (nth sorted lower))
(* weight (nth sorted upper)))))
(defn quantile
"Returns an interpolated quantile as a number, or nil for an empty sequence.
Probability 0 selects the minimum, 1 the maximum and 0.5 the median.
For example, [1 2 3 4] at probability 0.25 gives 1.75.
Values must form a sequence of finite numbers. Probability must be a finite
number in [0, 1]. Invalid arguments throw ::invalid-observations or
::invalid-probability; report callers handle unavailable results separately."
[values probability]
(when-not (and (finite-number? probability) (<= 0 probability 1))
(throw (ex-info "quantile probability must be a finite number in [0, 1]"
{:type ::invalid-probability :probability probability})))
(let [values (observation-vector values)]
(when (seq values)
(sorted-quantile (vec (sort values)) probability))))
(defn- number-result
"Returns {:value value} for a finite calculated number.
Non-finite arithmetic yields {:reason :undefined-estimate}, so a report can
retain the field and explain why it has no number."
[value]
(if (finite-number? value)
{:value value}
{:reason :undefined-estimate}))
(defn- resampled-median
"Returns the median of one replacement sample drawn from a nonempty vector.
The sample has the same size as values; repeated indices are allowed.
rng is a function that draws a number from [0, 1) on each call."
[values rng]
(let [n (count values)
sample (vec (sort (repeatedly n #(nth values (js/Math.floor (* n (rng)))))))]
(sorted-quantile sample 0.5)))
(defn- interval
"Returns bounds containing the central confidence fraction of calculated draws.
For confidence 0.95, the bounds are the 0.025 and 0.975 quantiles.
Empty or non-finite draws yield {:reason :undefined-bootstrap-estimate};
callers retain that reason rather than discarding an undefined draw."
[values confidence]
(if (and (seq values) (every? finite-number? values))
(let [sorted (vec (sort values))
tail (/ (- 1 confidence) 2)
lower (sorted-quantile sorted tail)
upper (sorted-quantile sorted (- 1 tail))]
(if (and (finite-number? lower) (finite-number? upper))
{:lower lower :upper upper}
{:reason :undefined-bootstrap-estimate}))
{:reason :undefined-bootstrap-estimate}))
(defn- quantile-result
"Returns a quantile in {:value number} when the sorted sample reaches minimum.
Otherwise returns {:reason :insufficient-observations :minimum minimum}.
Probability selects the quantile, such as 0.95 for p95."
[sorted probability minimum]
(if (< (count sorted) minimum)
{:reason :insufficient-observations :minimum minimum}
(number-result (sorted-quantile sorted probability))))
(defn statistics
"Returns statistics for one vector of finite measurement values.
`values` must be an already validated vector of finite measurements, and
`settings` must be the complete map returned by `resolve-settings`.
Callers enforce these preconditions.
`:count` is the number of original measurements, including each batch average
once. Numeric fields contain {:value number}; unavailable fields contain
{:reason keyword}, with :minimum for an observation requirement.
:sample-sd is sqrt(sum((x - mean)^2) / (count - 1)).
:mad is the median absolute distance from the median, without scaling.
:p95 and :p99 are observed 0.95 and 0.99 quantiles. :median-ci contains
bounds from repeatedly drawing replacement samples and taking their medians.
Empty input retains every field with :no-valid-observations.
Insufficient counts and undefined arithmetic retain reasons."
[values settings]
(let [{:keys [draws seed confidence min-ci min-p95 min-p99]} settings
n (count values)]
(if (zero? n)
(let [absent {:reason :no-valid-observations}]
{:count 0 :mean absent :median absent :min absent :max absent
:sample-sd absent :mad absent :median-ci absent :p95 absent :p99 absent})
(let [sorted (vec (sort values))
mean (reduce (fn [total x] (+ total (/ x n))) 0 values)
median (sorted-quantile sorted 0.5)
rng (random/create seed)]
{:count n
:mean (number-result mean)
:median (number-result median)
:min (number-result (first sorted))
:max (number-result (peek sorted))
:sample-sd (if (= n 1)
{:reason :insufficient-observations :minimum 2}
(number-result (js/Math.sqrt
(reduce (fn [total x]
(+ total (/ (* (- x mean) (- x mean)) (dec n))))
0 values))))
:mad (number-result (sorted-quantile (vec (sort (map #(js/Math.abs (- % median)) values))) 0.5))
:median-ci (if (< n min-ci)
{:reason :insufficient-observations :minimum min-ci}
(interval (vec (repeatedly draws #(resampled-median values rng)))
confidence))
:p95 (quantile-result sorted 0.95 min-p95)
:p99 (quantile-result sorted 0.99 min-p99)}))))
(defn changes
"Returns candidate-minus-baseline changes between the two observed medians.
Inputs must be already validated vectors of finite measurements; settings
must be the complete map returned by resolve-settings. Callers enforce these
preconditions. :absolute and :percentage contain
{:value number}; :absolute-ci and :percentage-ci contain {:lower number :upper number}.
Any unavailable result carries {:reason keyword} instead.
Each interval draw samples both inputs independently, allowing repeated
measurements, then calculates the change between those two sample medians.
Both original counts must reach :min-ci. A zero observed baseline suppresses
percentages; any zero resampled baseline suppresses the whole percentage
interval. Empty inputs or undefined arithmetic retain reasons.
compare/compare-runs calls this once per shared metric."
[baseline candidate settings]
(let [{:keys [draws seed confidence min-ci]} settings]
(if (or (empty? baseline) (empty? candidate))
{:absolute {:reason :no-valid-observations}
:percentage {:reason :no-valid-observations}
:absolute-ci {:reason :no-valid-observations}
:percentage-ci {:reason :no-valid-observations}}
(let [b (sorted-quantile (vec (sort baseline)) 0.5)
c (sorted-quantile (vec (sort candidate)) 0.5)
supported (and (>= (count baseline) min-ci) (>= (count candidate) min-ci))
rng (random/create seed)
samples (when supported
(vec (repeatedly draws
(fn [] [(resampled-median baseline rng)
(resampled-median candidate rng)]))))
low-count {:reason :insufficient-observations :minimum min-ci}]
{:absolute (number-result (- c b))
:percentage (if (zero? b) {:reason :zero-baseline} (number-result (* 100 (/ (- c b) b))))
:absolute-ci (if supported
(interval (mapv (fn [[b c]] (- c b)) samples) confidence)
low-count)
:percentage-ci (cond
(zero? b) {:reason :zero-baseline}
(not supported) low-count
(some (fn [[b _]] (zero? b)) samples) {:reason :zero-bootstrap-baseline}
:else (interval (mapv (fn [[b c]] (* 100 (/ (- c b) b))) samples)
confidence))}))))

View File

@ -0,0 +1,281 @@
;; 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 benches.render-wasm.report.compare
"Offline comparisons of paired cases from two saved schema:run records.
Matching case identifiers select default pairs; explicit pairs only select
cases. Permissions acknowledge recorded differences without hiding them."
(:require
[benches.render-wasm.report.analysis :as analysis]
[benches.render-wasm.report.summarize :as summary]
[benches.render-wasm.result :as result]
[clojure.set :as set]
[clojure.string :as str]))
(defn resolve-case
"Resolves an identifier or exact name only from the saved plan.
Unknown or ambiguous selectors report the available names for that file."
[run selector]
(let [ids (mapv :id (get-in run [:plan :cases]))
matches (if (some #(= selector %) ids)
[selector]
(filterv #(= selector (result/case-name %)) ids))]
(if (= 1 (count matches))
(first matches)
(throw (ex-info (str "cannot resolve saved case " (pr-str selector)
"; available cases: " (str/join ", " (map result/case-name ids)))
{:type ::unknown-case :selector selector :available (mapv result/case-name ids)})))))
(defn selector-pair
"Resolves baseline and candidate selectors into an extensible pair list."
[baseline candidate [baseline-selector candidate-selector]]
[[(resolve-case baseline baseline-selector) (resolve-case candidate candidate-selector)]])
(defn- difference
"Returns one compatibility difference when recorded values disagree."
[field baseline candidate]
(when (not= baseline candidate)
{:field field :baseline baseline :candidate candidate :waived? false}))
(defn- preparations
"Returns chronological preparation evidence for an exact saved case."
[run id]
(filterv #(= id (:case-id %)) (:preparations run)))
(defn- preparation-facts
"Collects distinct recorded fingerprints or graphics, independent of prep IDs."
[run id field]
(set (map field (preparations run id))))
(defn- scene-settings
"Selects scene identity and generation inputs, separate from execution."
[definition]
(select-keys definition [:scene :scene-seed :params]))
(defn- execution-settings
"Selects view, context and preparation settings for execution compatibility."
[definition]
(select-keys definition [:view :context :preparation]))
(defn- evidence-differences
"Checks cross-run equality plus missing or varying preparation evidence."
[field baseline candidate]
(concat
(when-let [d (difference field baseline candidate)] [d])
(when (or (not= 1 (count baseline)) (not= 1 (count candidate))
(contains? baseline nil) (contains? candidate nil))
[{:field [field :incomplete-or-varying-evidence]
:baseline baseline :candidate candidate :waived? false}])))
(defn- feature-names?
"Checks the vector-of-strings shape of recorded or expected feature names."
[features]
(and (vector? features) (every? string? features)))
(defn- get-effective-feature-evidence
"Returns comparison values and eligibility for automatic feature acknowledgment.
Missing, nil and explicitly empty features remain distinct. Collections compare
as sets, but only saved vectors of strings qualify for automatic acknowledgment."
[run]
(let [features (get-in run [:metadata :build :functional :features] ::missing-features)]
{:value (if (coll? features) (set features) features)
:valid (feature-names? features)}))
(defn- pair-differences
"Checks saved inputs, graphics, environment, build, sampling and metric definitions."
[baseline candidate b c shared]
(let [bdef (some #(when (= b (:id %)) %) (get-in baseline [:plan :cases]))
cdef (some #(when (= c (:id %)) %) (get-in candidate [:plan :cases]))]
(vec
(concat
(keep identity
[(difference :scene (scene-settings bdef) (scene-settings cdef))
(difference :execution (execution-settings bdef) (execution-settings cdef))
(difference :environment (get-in baseline [:metadata :environment])
(get-in candidate [:metadata :environment]))
(difference :effective-features (:value (get-effective-feature-evidence baseline))
(:value (get-effective-feature-evidence candidate)))
(difference :functional-build (dissoc (get-in baseline [:metadata :build :functional]) :features)
(dissoc (get-in candidate [:metadata :build :functional]) :features))
(difference :build-environment (get-in baseline [:metadata :build :environment])
(get-in candidate [:metadata :build :environment]))
(difference :warmups (get-in baseline [:plan :warmups])
(get-in candidate [:plan :warmups]))
(difference :timeout-policy (get-in baseline [:plan :timeout-policy])
(get-in candidate [:plan :timeout-policy]))
(difference :scored? (get-in baseline [:metadata :scored?])
(get-in candidate [:metadata :scored?]))])
(evidence-differences :fingerprint (preparation-facts baseline b :fingerprint)
(preparation-facts candidate c :fingerprint))
(evidence-differences :effective-graphics (preparation-facts baseline b :effective-graphics)
(preparation-facts candidate c :effective-graphics))
(mapcat (fn [metric-id]
(let [bdefs (map #(dissoc % :description) (summary/definitions baseline b metric-id))
cdefs (map #(dissoc % :description) (summary/definitions candidate c metric-id))]
(evidence-differences [:metric metric-id] (set bdefs) (set cdefs))))
shared)))))
(defn- feature-change-evidence?
"Checks feature facts against saved effective features and requested selections.
Both selections must record one off-to-on requested toggle with equal default
policy. Effective transitive additions/removals remain unrestricted, but must
match the supplied facts."
[baseline candidate {:keys [feature baseline-features candidate-features added removed]}]
(let [b (get-effective-feature-evidence baseline)
c (get-effective-feature-evidence candidate)
b-request (get-in baseline [:metadata :build :provenance :feature-selection])
c-request (get-in candidate [:metadata :build :provenance :feature-selection])]
(and (:valid b) (:valid c)
(string? feature)
(every? feature-names?
[baseline-features candidate-features added removed])
(map? b-request) (map? c-request)
(boolean? (:default-features? b-request))
(boolean? (:default-features? c-request))
(= (:default-features? b-request) (:default-features? c-request))
(feature-names? (:features b-request))
(feature-names? (:features c-request))
(not (contains? (set (:features b-request)) feature))
(= (conj (set (:features b-request)) feature) (set (:features c-request)))
(= (:value b) (set baseline-features))
(= (:value c) (set candidate-features))
(= (set/difference (:value c) (:value b)) (set added))
(= (set/difference (:value b) (:value c)) (set removed)))))
(defn- expected-feature-change?
[baseline candidate expected]
(and (feature-change-evidence? baseline candidate expected)
(contains? (set (:added expected)) (:feature expected))))
(defn- acknowledge-difference
"Retains a mismatch and records its applicable rule, if any.
Automatic feature evidence and scoped permissions take precedence over all.
Fingerprint content is scene data; incomplete/varying evidence is not."
[{:keys [field] :as mismatch} permissions expected-feature-change?]
(let [rule (cond
(and (= :effective-features field) expected-feature-change?) :feature-toggle
(and (:allow-build-differences permissions)
(contains? #{:effective-features :functional-build :build-environment} field)) :build
(and (:allow-scene-differences permissions)
(contains? #{:scene :fingerprint} field)) :scene
(:allow-all-differences permissions) :all)]
(cond-> mismatch
rule (assoc :waived? true :rule rule))))
(defn- side-report
"Reports one side with saved definitions, setup evidence and sample accounting."
[run id shared analysis]
(summary/case-report run id shared analysis))
(defn- metric-changes
"Keeps diagnostic changes unavailable for differing or varying definitions.
Only all-differences permits comparing their valid raw values without conversion."
[baseline candidate b c id mismatches allow-all-differences analysis]
(if (and (not allow-all-differences)
(some #(contains? #{[:metric id] [[:metric id] :incomplete-or-varying-evidence]}
(:field %)) mismatches))
{:absolute {:reason :inconsistent-metric-definitions}
:percentage {:reason :inconsistent-metric-definitions}
:absolute-ci {:reason :inconsistent-metric-definitions}
:percentage-ci {:reason :inconsistent-metric-definitions}}
(analysis/changes (:values (summary/observations baseline b id))
(:values (summary/observations candidate c id)) analysis)))
(defn- compare-pair
"Compares shared numeric observations after resolving the compatibility policy."
[baseline candidate [b c] explicit? permissions expected-feature-change? on-unallowed-difference analysis]
(let [b (resolve-case baseline b)
c (resolve-case candidate c)
candidate-ids (set (summary/metric-ids candidate c))
shared (filterv #(contains? candidate-ids %) (summary/metric-ids baseline b))
mismatches (mapv #(acknowledge-difference % permissions expected-feature-change?)
(pair-differences baseline candidate b c shared))
allow-all-differences (:allow-all-differences permissions)]
(when (empty? shared)
(throw (ex-info "selected cases have no shared metric identifiers"
{:type ::no-shared-metrics :baseline b :candidate c})))
(when (and (not= :record on-unallowed-difference) (some #(not (:waived? %)) mismatches))
(throw (ex-info "incompatible saved runs"
{:type ::incompatible :baseline b :candidate c :mismatches mismatches})))
{:explicit? explicit?
:mismatches mismatches
:baseline (side-report baseline b shared analysis)
:candidate (side-report candidate c shared analysis)
:metrics (into {}
(map (fn [id]
[id (metric-changes baseline candidate b c id mismatches
allow-all-differences analysis)]))
shared)}))
(defn- provenance
"Retains run identity and facts without using Git/run/time as compatibility gates."
[run]
(select-keys run [:run-id :started-at :ended-at :metadata :termination]))
(defn compare-runs
"Creates a schema:comparison from constructed or decoded schema:run records.
Inputs must already satisfy schema:run. Encoding validates the returned
comparison at the boundary.
Default pairs use identical identifiers. Explicit pairs select cases without
changing compatibility checks.
- `:pairs` accepts an extensible vector of explicit [baseline candidate] pairs.
- `:allow-build-differences` acknowledges build settings and effective features.
- `:allow-scene-differences` acknowledges scene inputs and fingerprint content.
It never acknowledges incomplete or varying evidence or execution settings.
- `:allow-all-differences` acknowledges any compatibility mismatch and permits
mixed-definition statistics on valid raw observations, without conversions.
Scoped permissions combine by union. Every mismatch retains its values,
legacy `:waived?` field and, only when acknowledged, its `:rule`.
- `:expected-feature-change` is planner evidence with string `:feature` and
vectors `:baseline-features`, `:candidate-features`, `:added`, `:removed`.
Automatic acknowledgment requires saved effective vectors of strings, exact
sets/deltas with that feature added, and saved requested selections differing
only by that toggle, with the same recorded default-feature policy.
- `:edge` attaches checked feature-comparison context: `:from`, `:to`, and the five expected
feature fields. When both options are supplied, their feature facts must agree.
The ordinary encoding boundary validates the saved edge's shape.
- Internal `:on-unallowed-difference :record` returns diagnostic reports instead
of throwing for unacknowledged differences. It keeps strict statistics.
- `:analysis` resolves from method defaults, independent of each run's defaults."
([baseline candidate] (compare-runs baseline candidate {}))
([baseline candidate {:keys [pairs allow-build-differences allow-scene-differences
allow-all-differences expected-feature-change edge
on-unallowed-difference analysis]}]
(let [explicit? (some? pairs)
candidate-ids (set (map :id (get-in candidate [:plan :cases])))
pairs (or pairs (->> (get-in baseline [:plan :cases])
(map :id)
(filter #(contains? candidate-ids %))
(mapv #(vector % %))))
analysis (analysis/resolve-settings analysis)
permissions {:allow-build-differences (boolean allow-build-differences)
:allow-scene-differences (boolean allow-scene-differences)
:allow-all-differences (boolean allow-all-differences)}
expected? (expected-feature-change? baseline candidate expected-feature-change)]
(when (and (some? edge) (not (feature-change-evidence? baseline candidate edge)))
(throw (ex-info "edge context disagrees with saved run feature evidence"
{:type ::invalid-edge :edge edge})))
(when (and (some? edge) (some? expected-feature-change)
(not= expected-feature-change
(select-keys edge [:feature :baseline-features :candidate-features :added :removed])))
(throw (ex-info "edge context disagrees with expected feature facts"
{:type ::invalid-edge :edge edge :expected-feature-change expected-feature-change})))
(when (or (empty? pairs) (not (every? #(and (vector? %) (= 2 (count %))) pairs)))
(throw (ex-info "comparison needs saved case pairs"
{:type ::invalid-pairs
:baseline (mapv (comp result/case-name :id) (get-in baseline [:plan :cases]))
:candidate (mapv (comp result/case-name :id) (get-in candidate [:plan :cases]))})))
(cond-> {:type :comparison :analysis analysis
:baseline (provenance baseline) :candidate (provenance candidate)
:permissions permissions
:source-coverage summary/source-coverage :interpretation summary/interpretation
:pairs (mapv #(compare-pair baseline candidate % explicit? permissions expected?
on-unallowed-difference analysis) pairs)}
(some? edge) (assoc :edge edge)))))

View File

@ -0,0 +1,357 @@
;; 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 benches.render-wasm.report.format
"Text formatting for run summaries and saved comparisons.
Text leads with observed median, bootstrap interval and distinct counts.
Full statistics and acknowledged differences include units and phase identity."
(:require
[benches.render-wasm.errors :as errors]
[benches.render-wasm.result :as result]
[clojure.string :as str]))
(defn- number->text
"Formats safe integers in decimal form and other estimates with six significant digits."
[value]
(if (js/Number.isSafeInteger value)
(str value)
(.toPrecision value 6)))
(defn- estimate->text
"Formats an estimate, interval or suppression reason."
[estimate]
(cond
(contains? estimate :value) (number->text (:value estimate))
(contains? estimate :lower) (str "[" (number->text (:lower estimate)) ", "
(number->text (:upper estimate)) "]")
:else (str "unavailable (" (some-> (:reason estimate) name)
(when-some [n (:minimum estimate)] (str "; need " n)) ")")))
(defn- counts->text
"Prints valid, invalid, absent, failed, unattempted and excluded warmup counts."
[{:keys [valid invalid absent failed unattempted warmup invocations raw-values]}]
(str "valid=" valid " invalid=" invalid " absent=" absent " failed=" failed
" unattempted=" unattempted " warmup=" warmup
" invocations=" invocations " raw-values=" raw-values " n=" valid))
(defn metric->text
"Formats statistics across phase execution sums with their units and counts.
Args:
id: vector of phase, command or global scope, and metric type.
report: quantity descriptions, accounting counts, statistics and diagnostics.
Returns:
Text with observed estimates, support limits and raw-value counts."
[id {:keys [definitions counts statistics diagnostics]}]
(str (pr-str id) " sum per execution; median " (estimate->text (or (:median statistics) statistics))
" CI " (estimate->text (or (:median-ci statistics) statistics)) " | " (counts->text counts)
"\n definitions " (pr-str definitions)
(when (seq diagnostics) (str "\n diagnostics " (pr-str diagnostics)))
(if-let [reason (:reason statistics)]
(str "\n unavailable (" (name reason) ")")
(str "\n mean " (estimate->text (:mean statistics))
" min " (estimate->text (:min statistics)) " max " (estimate->text (:max statistics))
" sample SD " (estimate->text (:sample-sd statistics))
" MAD " (estimate->text (:mad statistics))
" p95 " (estimate->text (:p95 statistics)) " p99 " (estimate->text (:p99 statistics))))))
(defn- quoted-detail
[value]
(errors/format-value value 1024))
(defn- identifier->text
[value]
(errors/format-value value 1024))
(def ^:private process-detail-keys
[:command :pid :pgid :signals :profile-path :fatal :unresolved-process])
(defn format-error-entry
"Formats an error with its location and available process details.
Args:
entry: A projected error occurrence.
Returns:
Bounded error text and process details when a group may still be running."
[{:keys [phase operation message path] :as entry}]
(str (when operation (str (errors/format-value operation 1024) " "))
(quoted-detail phase)
(when (some? message) (str ": " (quoted-detail message)))
(when path (str "\n Path " (quoted-detail path)))
(when (:unresolved-process entry)
(apply str
(map (fn [[key value]] (str "\n " (name key) " " (identifier->text value)))
(select-keys entry process-detail-keys))))))
(defn- failure->text
[{:keys [failure reason]}]
(if failure
(if-let [n (:unacknowledged-differences failure)]
(str n " unacknowledged " (if (= n 1) "difference" "differences"))
(if-let [entries (seq (:errors failure))]
(let [header (apply dissoc failure process-detail-keys)
omitted (drop 8 entries)
unresolved (filter :unresolved-process omitted)]
(str (if (not= (errors/bounded-text (:message failure) 1024) (:message (first entries)))
(str (format-error-entry header) "\n ")
(when (not= (select-keys failure [:phase :operation :path])
(select-keys (first entries) [:phase :operation :path]))
(str "Context " (format-error-entry (dissoc header :message)) "\n ")))
(str/join "\n " (map format-error-entry (take 8 entries)))
(when (seq omitted)
(str "\n " (count omitted) " more errors recorded"))
(when-let [entry (first unresolved)]
(str "\n " (count unresolved) " unresolved process errors in the saved record; first: "
(format-error-entry entry)))))
(format-error-entry failure)))
(quoted-detail reason)))
(defn- same-diagnostic?
[left right]
(= (dissoc (errors/project-diagnostic left) :status :partial)
(dissoc (errors/project-diagnostic right) :status :partial)))
(defn- case-failures
[{:keys [case-id preparations failed-attempts]}]
(let [preparations (filter :failure preparations)
by-id (into {} (map (juxt :preparation-id identity)) preparations)]
(concat
(map (fn [preparation]
(assoc (select-keys preparation [:preparation-id :warmup? :failure])
:kind :preparation :case-id case-id))
preparations)
(keep (fn [{:keys [preparation-id failure phases] :as attempt}]
(let [preparation (get by-id preparation-id)
copied? (and preparation
(every? #(= :preparation (:phase %)) phases)
(same-diagnostic? failure (:failure preparation)))]
(when (and failure (not copied?))
(assoc (select-keys attempt [:attempt-id :preparation-id :warmup? :failure :partial])
:kind :attempt :case-id case-id))))
failed-attempts))))
(defn- run-failures
[cases metadata termination]
(let [case-entries (vec (mapcat case-failures cases))
by-case (group-by :case-id case-entries)
harness (keep-indexed
(fn [index failure]
(let [copied? (and (= :preparation (:stage failure))
(some #(and (= :preparation (:kind %))
(same-diagnostic? (:failure %) failure))
(get by-case (:case-id failure))))]
(when-not copied?
{:kind :harness :index index :stage (:stage failure)
:case-id (:case-id failure) :failure failure})))
(get-in metadata [:diagnostics :failures]))
recorded (into case-entries harness)
by-owner (group-by :case-id recorded)
outcomes (keep (fn [{:keys [case-id outcome]}]
(when-let [failure (:failure outcome)]
(when-not (some #(same-diagnostic? failure (:failure %)) (get by-owner case-id))
{:kind :case :case-id case-id :failure failure})))
cases)
entries (into recorded outcomes)]
(cond-> entries
(and (:failure termination)
(not-any? #(same-diagnostic? (:failure termination) (:failure %)) entries))
(conj {:kind :termination :failure (:failure termination)}))))
(defn- diagnostic->text
[failure]
(let [context (errors/project-context failure)
context (if (:unresolved-process failure) (apply dissoc context process-detail-keys) context)
details (merge (select-keys (first (:cause failure)) [:type :code :fn])
context
(select-keys failure [:reason :deadline-exceeded]))]
(str (failure->text {:failure failure})
(apply str (map (fn [[key value]]
(str "\n " (name key) " " (identifier->text value)))
(sort-by (comp name key) (filter (comp some? val) details)))))))
(defn- failure-entry->text
[{:keys [kind case-id preparation-id attempt-id index stage warmup? failure partial]}]
(str " " (case kind
:preparation (str "Preparation " (identifier->text preparation-id))
:attempt (str "Attempt " attempt-id " (preparation " (identifier->text preparation-id) ")")
:harness (str "Harness " index " " (identifier->text stage))
:case "Case outcome"
:termination "Termination")
(when (some? case-id) (str " (case " (identifier->text case-id) ")"))
(when warmup? " warmup")
": " (diagnostic->text failure)
(when (or partial (:partial failure)) "\n Partial evidence recorded")))
(defn- failures->text
[heading entries]
(when (seq entries)
(str "\n\n" heading "\n" (str/join "\n" (map failure-entry->text entries)))))
(defn- screenshots->text
[heading metadata failures cases]
(let [screenshots (get-in metadata [:diagnostics :screenshots])
names (frequencies (map result/case-name
(distinct (concat (map :case-id cases) (keep :case-id failures)))))
deadlines (group-by #(result/case-name (:case-id %))
(filter #(and (= :harness (:kind %))
(= :case-deadline (:stage %))
(some? (:case-id %))) failures))]
(when (seq screenshots)
(str "\n\n" heading "\n"
(str/join
"\n"
(map (fn [[case-name {:keys [status message reason file cause]}]]
(let [matches (when (and (= 1 (get names case-name)) (string? message) (seq cause))
(filter #(and (= message (get-in % [:failure :message]))
(= cause (get-in % [:failure :cause])))
(get deadlines case-name)))
related (when (= 1 (count matches)) (first matches))]
(str " " (identifier->text case-name) ": "
(if related
(str "failed; see Harness " (:index related))
(case status
:failed "warning"
:ok "ok"
:skipped "skipped"
(identifier->text status)))
(when (and message (not related)) (str "; " (quoted-detail message)))
(when reason (str "; reason " (identifier->text reason)))
(when file (str "\n File " (quoted-detail file))))))
(sort-by (comp str key) screenshots)))))))
(defn- case-status->text
"Displays pending slots, phase counts and failed execution identities."
[{:keys [remaining failed-attempts phases outcome]}]
(str " remaining " (pr-str remaining)
(when outcome (str "\n outcome " (pr-str (:outcome outcome))))
"\n phases " (pr-str phases)
" failed-warmups=" (count (filter :warmup? failed-attempts))
(when (seq failed-attempts)
(str "\n failed attempts "
(pr-str (mapv #(select-keys % [:attempt-id :preparation-id :warmup? :outcome]) failed-attempts))))))
(defn format-run
"Formats run statistics, recorded failures and screenshot diagnostics."
[{:keys [run-id metadata analysis termination source-coverage interpretation cases]}]
(let [failures (run-failures cases metadata termination)]
(str "Run " (pr-str run-id) " " (pr-str (dissoc termination :failure))
"\nGit " (pr-str (:git metadata))
"\nAnalysis " (pr-str analysis)
"\n" source-coverage "\n" interpretation
(when (some #(get-in % [:effective-graphics :software])
(mapcat :preparations cases)) "\nSOFTWARE RENDERER recorded")
(apply str (map (fn [{:keys [case-id metrics] :as case-report}]
(str "\n\nCase " (result/case-name case-id)
(case-status->text case-report)
(apply str (map (fn [[id metric]] (str "\n" (metric->text id metric))) metrics))))
cases))
(failures->text "Failures" failures)
(screenshots->text "Screenshots" metadata failures cases))))
(defn- side->text
"Displays one comparison side's definitions, preparation facts and observations."
[label {:keys [case-id definition preparations metrics] :as side}]
(str "\n" label " " (result/case-name case-id)
(case-status->text side)
"\n case " (pr-str definition)
"\n preparations " (pr-str (mapv #(select-keys % [:preparation-id :warmup? :fingerprint :effective-graphics])
preparations))
(apply str (map (fn [[id metric]] (str "\n" (metric->text id metric))) metrics))))
(defn- configuration-failure->text
[{:keys [slug run build] :as configuration}]
(str " Configuration " (quoted-detail slug) ": " (failure->text configuration)
(cond
run (str "\n Run record " (quoted-detail (:record-path run))
"\n Log " (quoted-detail (:log-path run)))
build (str "\n Build manifest " (quoted-detail (:manifest-path build))
"\n Log " (quoted-detail (:log-path build))))))
(defn- edge-failure->text
[{:keys [from to feature path] :as edge}]
(str " Comparison " (quoted-detail from) " -> " (quoted-detail to)
" (feature " (quoted-detail feature) "): " (failure->text edge)
(when path (str "\n Report " (quoted-detail path)))))
(defn format-feature-comparison-failures
"Formats failed configurations and comparisons with artifact references.
Args:
record: Validated feature-comparison record.
home: Comparison :record-path and :log-path.
Returns:
Text for up to eight failures in configuration and comparison order,
with an omitted count and references to the complete evidence. String
fields escape C0 controls (U+0000 through U+001F) and use at most
1024 UTF-16 units.
Returns nil when the record contains no failed entries."
[record home]
(let [configurations (filter #(= :failed (:status %)) (:configurations record))
edges (filter #(= :failed (:status %)) (:edges record))
total (+ (count configurations) (count edges))
entries (concat (map configuration-failure->text configurations)
(map edge-failure->text edges))]
(when (pos? total)
(str "Failures\n" (str/join "\n" (take 8 entries))
(when (> total 8) (str "\n " (- total 8) " more failures in the saved record"))
"\n Record " (quoted-detail (:record-path home))
"\n Log " (quoted-detail (:log-path home))))))
(defn format-comparison
"Formats selected permissions, acknowledged/unacknowledged differences and changes.
Both saved definitions remain visible. Edge labels describe actual feature
evidence, including equal effective sets, without predicting a timing effect."
[{:keys [baseline candidate analysis permissions edge source-coverage interpretation pairs]}]
(let [side-cases (fn [side]
(let [cases (map side pairs)
by-id (group-by :case-id cases)]
(map #(first (get by-id %)) (distinct (map :case-id cases)))))
baseline-cases (side-cases :baseline)
candidate-cases (side-cases :candidate)
baseline-failures (run-failures baseline-cases (:metadata baseline) (:termination baseline))
candidate-failures (run-failures candidate-cases (:metadata candidate) (:termination candidate))]
(str "Permissions " (pr-str permissions) "\n"
(when edge
(str "Edge " (:from edge) " -> " (:to edge) " feature " (:feature edge)
(when (= (set (:baseline-features edge)) (set (:candidate-features edge)))
" (effective feature sets identical)")
"\n effective features " (pr-str (select-keys edge [:baseline-features :candidate-features :added :removed]))
"\n"))
(when (some #(get-in % [:effective-graphics :software])
(mapcat (fn [pair] (concat (get-in pair [:baseline :preparations])
(get-in pair [:candidate :preparations]))) pairs))
"SOFTWARE RENDERER recorded\n")
"Baseline " (pr-str (:run-id baseline)) " Git " (pr-str (get-in baseline [:metadata :git]))
"\nBaseline termination " (pr-str (dissoc (:termination baseline) :failure))
"\nCandidate " (pr-str (:run-id candidate)) " Git " (pr-str (get-in candidate [:metadata :git]))
"\nCandidate termination " (pr-str (dissoc (:termination candidate) :failure))
"\nAnalysis " (pr-str analysis) "\n" source-coverage "\n" interpretation
(failures->text "Baseline failures" baseline-failures)
(screenshots->text "Baseline screenshots" (:metadata baseline) baseline-failures baseline-cases)
(failures->text "Candidate failures" candidate-failures)
(screenshots->text "Candidate screenshots" (:metadata candidate) candidate-failures candidate-cases)
(apply str
(map (fn [{:keys [baseline candidate mismatches metrics]}]
(str "\n\nPair " (result/case-name (:case-id baseline)) ","
(result/case-name (:case-id candidate))
(apply str
(map (fn [mismatch]
(str "\n" (if (:waived? mismatch) "ACKNOWLEDGED" "UNACKNOWLEDGED")
" DIFFERENCE " (pr-str mismatch))) mismatches))
(side->text "Baseline" baseline)
(side->text "Candidate" candidate)
(str/join "\n"
(map (fn [[id change]]
(str "\n" (pr-str id)
" median change " (estimate->text (:absolute change))
" CI " (estimate->text (:absolute-ci change))
"; percent " (estimate->text (:percentage change))
" CI " (estimate->text (:percentage-ci change)))) metrics))))
pairs)))))

View File

@ -0,0 +1,238 @@
;; 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 benches.render-wasm.report.summarize
"Offline analysis of raw command histories, grouped within phase executions."
(:require
[app.common.schema :as sm]
[benches.render-wasm.quantities :as quantities]
[benches.render-wasm.report.analysis :as analysis]))
(def source-coverage
"Recorded inputs and settings checked; runtime-helper identity is unverified. Git SHA and dirty state record provenance.")
(def interpretation
"Intervals are exploratory and conditional on available executions. Incomplete and timed-out cases can select which observations survive. Caches can correlate attempts; repeat whole runs to investigate findings. Full denotes renderer submission completion. Sums combine only one metric type within one phase execution. Finite clock resolution can produce valid zero durations.")
(defn case-attempts
"Returns chronological attempts for an exact saved case identifier."
[run case-id]
(filterv #(= case-id (:case-id %)) (:attempts run)))
(defn get-failed-attempts
"Returns failed warmup and measured attempts with their saved evidence."
[run case-id]
(filterv #(= :failed (:outcome %)) (case-attempts run case-id)))
(defn executions
"Returns actual phase executions with their eligibility and saved identities.
Args:
run: validated saved run.
case-id: exact saved case identifier.
phase: preparation, main or reset.
Returns:
Chronological phase histories with phase and preparation/attempt outcomes."
[run case-id phase]
(let [attempts (case-attempts run case-id)]
(if (= :preparation phase)
(let [definition (some #(when (= case-id (:id %)) %) (get-in run [:plan :cases]))]
(mapv (fn [prep]
(let [block (first (:phases prep))]
{:preparation-id (:preparation-id prep)
:events (:events block)
:warmup? (and (not= :reuse (:context definition)) (:warmup? prep))
:outcome (:outcome block)
:preparation-outcome (if (:failure prep) :failed :completed)}))
(filter #(and (= case-id (:case-id %)) (seq (:phases %))) (:preparations run))))
(into []
(keep (fn [attempt]
(when-let [block (some #(when (= phase (:phase %)) %) (:phases attempt))]
(merge (select-keys attempt [:attempt-id :preparation-id :warmup?])
{:outcome (:outcome block) :attempt-outcome (:outcome attempt)
:events (:events block)}))))
attempts))))
(defn phase-counts
"Counts actual phase executions, including empty histories and excluded work.
Args:
run: validated saved run.
case-id: exact saved case identifier.
Returns:
Execution, completion, failure and warmup counts for each phase."
[run case-id]
(into {}
(map (fn [phase]
(let [blocks (executions run case-id phase)]
[phase {:executions (count blocks)
:completed (count (filter #(= :completed (:outcome %)) blocks))
:failed (count (filter #(= :failed (:outcome %)) blocks))
:warmup (count (filter :warmup? blocks))}])))
[:preparation :main :reset]))
(defn metric-ids
"Returns [phase command-or-global metric-type] keys in first appearance order.
Args:
run: validated saved run.
case-id: exact saved case identifier.
Returns:
Distinct report keys, including quantities recorded during failed work."
[run case-id]
(vec (distinct
(for [phase [:preparation :main :reset]
block (executions run case-id phase)
event (:events block)
metric (keys (:metrics event))
scope [(:command event) :global]]
[phase scope metric]))))
(defn definitions
"Returns valid saved quantity descriptions for a report key.
Args:
run: validated saved run.
_case-id: exact saved case identifier.
id: vector of phase, command or global scope, and metric type.
Returns:
A vector containing the description, or an empty vector for invalid metadata."
[run _case-id [_phase _scope metric]]
(let [description (get-in run [:quantities metric])]
(if (sm/validate quantities/schema:description description) [description] [])))
(defn- invalid-reason
[value]
(cond
(not (js/Number.isFinite value)) :non-finite
(neg? value) :negative
:else nil))
(defn- execution-value
[block scope metric]
(let [events (->> (:events block)
(map-indexed vector)
(filter (fn [[_ event]]
(and (or (= :global scope) (= scope (:command event)))
(contains? (:metrics event) metric)))))
values (vec (mapcat #(get-in (second %) [:metrics metric]) events))
invalid (vec (for [[event-index event] events
[value-index value] (map-indexed vector (get-in event [:metrics metric]))
:let [reason (invalid-reason value)]
:when reason]
(merge (select-keys block [:attempt-id :preparation-id])
{:event-index event-index :value-index value-index
:value value :reason reason})))
total (when (and (seq values) (empty? invalid)) (reduce + 0 values))]
{:invocations (count events) :raw-values (count values)
:diagnostics (cond-> invalid
(and (some? total) (not (js/Number.isFinite total)))
(conj {:reason :non-finite-sum}))
:value total}))
(defn observations
"Sums one metric type per eligible phase execution.
Invocation and raw-value counts cover eligible executions, including invalid
values. Statistical n counts valid execution sums. Failed attempts exclude
both main and reset. Successful preparation survives later attempt failures.
Args:
run: validated saved run.
case-id: exact saved case identifier.
id: vector of phase, command or global scope, and metric type.
Returns:
Valid execution sums, accounting counts and invalidity diagnostics."
[run case-id [phase scope metric :as id]]
(let [description (get-in run [:quantities metric])
diagnostic (when (empty? (definitions run case-id id))
{:reason (if (contains? (:quantities run) metric)
:malformed-quantity :unknown-quantity)
:metric metric :description description})]
(reduce
(fn [out {:keys [warmup? outcome attempt-outcome preparation-outcome] :as block}]
(let [{:keys [value invocations raw-values diagnostics]} (execution-value block scope metric)
kind (cond
warmup? :warmup
(or (= :failed outcome) (= :failed attempt-outcome)
(= :failed preparation-outcome)) :failed
(zero? invocations) :absent
(or diagnostic (seq diagnostics)) :invalid
:else :valid)
eligible? (not (#{:warmup :failed} kind))]
(cond-> (update-in out [:counts kind] inc)
eligible? (update-in [:counts :invocations] + invocations)
eligible? (update-in [:counts :raw-values] + raw-values)
eligible? (update :diagnostics into diagnostics)
(= kind :valid) (update :values conj value))))
{:values [] :diagnostics (if diagnostic [diagnostic] [])
:counts {:valid 0 :invalid 0 :absent 0 :failed 0 :warmup 0
:invocations 0 :raw-values 0
:unattempted (if (= :preparation phase) 0
(get-in run [:remaining case-id :repetitions]))}}
(executions run case-id phase))))
(defn summarize-metric
"Computes statistics across execution sums for one phase, scope and metric type.
Args:
run: validated saved run.
case-id: exact saved case identifier.
id: vector of phase, command or global scope, and metric type.
analysis: resolved statistical settings.
Returns:
Quantity descriptions, counts, diagnostics and statistics across executions."
[run case-id id analysis]
(let [{:keys [values counts diagnostics]} (observations run case-id id)]
{:definitions (definitions run case-id id)
:statistic :sum :counts counts :diagnostics diagnostics
:statistics (analysis/statistics values analysis)}))
(defn case-report
"Reports saved case facts and phase quantities using one analysis method.
Args:
run: validated saved run.
id: exact saved case identifier.
ids: selected report keys.
analysis: resolved statistical settings.
Returns:
Case definition, raw preparation/failure evidence, counts and metric reports."
[run id ids analysis]
{:case-id id
:definition (some #(when (= id (:id %)) %) (get-in run [:plan :cases]))
:outcome (get-in run [:case-outcomes id])
:preparations (filterv #(= id (:case-id %)) (:preparations run))
:phases (phase-counts run id)
:remaining (get-in run [:remaining id])
:failed-attempts (get-failed-attempts run id)
:metrics (into {} (map (fn [metric-id]
[metric-id (summarize-metric run id metric-id analysis)])) ids)})
(defn summarize
"Reports a validated saved run using saved cases and quantity descriptions.
Args:
run: validated saved run.
overrides: optional statistical settings overriding the saved defaults.
Returns:
A report with provenance, termination and separate analyses for each case."
([run] (summarize run nil))
([run overrides]
(let [analysis (analysis/resolve-settings (merge (:analysis run) overrides))]
{:run-id (:run-id run) :metadata (:metadata run) :analysis analysis
:termination (:termination run)
:source-coverage source-coverage :interpretation interpretation
:cases (mapv (fn [{:keys [id]}]
(case-report run id (metric-ids run id) analysis))
(get-in run [:plan :cases]))})))

View File

@ -0,0 +1,696 @@
;; 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 benches.render-wasm.result
"Schemas for experiments and reports."
(:require
[app.common.schema :as sm]
[app.common.transit :as transit]
[benches.render-wasm.codec :as codec]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.features.plan :as feature-plan]
[benches.render-wasm.quantities :as quantities]
[benches.render-wasm.report.analysis :as analysis]))
(def schema:event
"One measured command invocation with its ordered raw values."
[:map {:closed true}
[:command qualified-keyword?]
[:metrics [:map-of qualified-keyword? [:vector {:min 1} number?]]]
[:trace {:optional true} :any]])
(def schema:phase
"Raw command history for one phase execution, including partial failures."
[:map {:closed true}
[:phase [:enum :preparation :main :reset]]
[:preparation-id some?]
[:outcome [:enum :completed :failed]]
[:events [:vector schema:event]]
[:diagnostics {:optional true} [:vector :any]]])
(def schema:fingerprint
"The content digest of the actual validated starting snapshot."
[:map {:closed true}
[:algorithm [:= :sha-256]]
[:digest [:re #"^[0-9a-f]{64}$"]]])
(def schema:preparation
"Setup diagnostics and callback measurements for one owned scene."
[:map {:closed true}
[:preparation-id some?]
[:case-id some?]
[:warmup? boolean?]
[:fingerprint {:optional true} schema:fingerprint]
[:effective-graphics {:optional true} map?]
[:diagnostics map?]
[:phases [:vector schema:phase]]
[:failure {:optional true} errors/schema:diagnostic]])
(def schema:attempt
"One chronological execution; failure consumes a slot and may retain evidence."
[:map {:closed true}
[:attempt-id [:int {:min 0}]]
[:case-id some?]
[:preparation-id [:maybe some?]]
[:warmup? boolean?]
[:outcome [:enum :completed :failed]]
[:phases [:vector schema:phase]]
[:failure {:optional true} errors/schema:diagnostic]
[:partial {:optional true} map?]])
(def schema:metadata
"Git, functional build settings, environment and diagnostic provenance."
[:map {:closed true}
[:git [:map [:sha [:maybe string?]] [:dirty [:maybe boolean?]]]]
[:build [:map
[:functional map?]
[:provenance {:optional true} map?]
[:environment {:optional true} [:map-of :string :string]]]]
[:environment map?]
[:scored? boolean?]
[:diagnostics {:optional true}
[:map [:failures {:optional true} [:vector errors/schema:diagnostic]]]]])
(def schema:termination
"A run's current state or final reason, with optional diagnostic details."
[:map
[:reason keyword?]
[:failure {:optional true} errors/schema:diagnostic]])
(def schema:case-outcome
[:map {:closed true}
[:outcome [:enum :completed :failed :timed-out :interrupted]]
[:failure {:optional true} errors/schema:diagnostic]])
(def schema:run
"schema:run stores saved case definitions, independent of any registry.
Build :functional holds compatibility settings; :provenance holds identities."
[:map {:closed true}
[:type [:= :run-record]]
[:run-id some?]
[:started-at string?]
[:ended-at [:maybe string?]]
[:plan [:map {:closed true}
[:cases [:vector [:map [:id some?]]]]
[:warmups [:int {:min 0}]]
[:repetitions [:int {:min 0}]]
[:timeout-policy [:map {:closed true} [:case-ms [:int {:min 1}]]]]]]
[:metadata schema:metadata]
[:analysis analysis/schema:settings]
[:quantities [:map-of qualified-keyword? :any]]
[:preparations [:vector schema:preparation]]
[:attempts [:vector schema:attempt]]
[:case-outcomes [:map-of :any schema:case-outcome]]
[:remaining [:map-of :any [:map {:closed true}
[:warmups [:int {:min 0}]]
[:repetitions [:int {:min 0}]]]]]
[:termination schema:termination]])
(def schema:estimate
"A finite computed estimate or an explicit suppression reason."
[:or
[:map {:closed true} [:value [:fn #(and (number? %) (js/Number.isFinite %))]]]
[:map {:closed true} [:reason keyword?] [:minimum {:optional true} [:int {:min 1}]]]])
(def schema:interval
"Finite ordered percentile bounds or an explicit suppression reason."
[:or
[:and
[:map {:closed true}
[:lower [:fn #(and (number? %) (js/Number.isFinite %))]]
[:upper [:fn #(and (number? %) (js/Number.isFinite %))]]]
[:fn #(<= (:lower %) (:upper %))]]
[:map {:closed true} [:reason keyword?] [:minimum {:optional true} [:int {:min 1}]]]])
(def schema:statistics
"Observed statistics and supported intervals for one metric."
[:or
[:map {:closed true}
[:count [:int {:min 0}]]
[:mean schema:estimate] [:median schema:estimate] [:min schema:estimate] [:max schema:estimate]
[:sample-sd schema:estimate] [:mad schema:estimate] [:p95 schema:estimate] [:p99 schema:estimate]
[:median-ci schema:interval]]
[:map {:closed true} [:reason keyword?] [:count [:int {:min 0}]]]])
(def schema:metric-report
"A sum within each phase execution and statistics across eligible executions."
[:map {:closed true}
[:definitions [:vector quantities/schema:description]]
[:statistic [:= :sum]]
[:counts [:map {:closed true}
[:valid [:int {:min 0}]] [:invalid [:int {:min 0}]] [:absent [:int {:min 0}]]
[:failed [:int {:min 0}]] [:unattempted [:int {:min 0}]] [:warmup [:int {:min 0}]]
[:invocations [:int {:min 0}]] [:raw-values [:int {:min 0}]]]]
[:diagnostics [:vector map?]]
[:statistics schema:statistics]])
(def schema:comparison-side
"One saved case's definition, preparations and shared metric reports."
[:map {:closed true}
[:case-id some?] [:definition [:map [:id some?]]]
[:outcome [:maybe schema:case-outcome]]
[:preparations [:vector schema:preparation]]
[:phases [:map-of :keyword [:map {:closed true}
[:executions nat-int?] [:completed nat-int?]
[:failed nat-int?] [:warmup nat-int?]]]]
[:remaining [:map {:closed true} [:warmups [:int {:min 0}]] [:repetitions [:int {:min 0}]]]]
[:failed-attempts [:vector schema:attempt]]
[:metrics [:map-of :any schema:metric-report]]])
(def schema:provenance
"Saved run identity, timestamps, metadata and termination for comparison."
[:map {:closed true}
[:run-id some?] [:started-at string?] [:ended-at [:maybe string?]]
[:metadata schema:metadata] [:termination schema:termination]])
(def schema:permissions
[:map {:closed true}
[:allow-build-differences boolean?]
[:allow-scene-differences boolean?]
[:allow-all-differences boolean?]])
(def schema:feature-comparison
"Saved feature comparison progress. Case data and options remain ordinary
extensible maps; configuration/edge states and retained references are typed."
[:map {:closed true}
[:type [:= :feature-comparison]]
[:comparison-id string?]
[:started-at string?]
[:updated-at string?]
[:ended-at {:optional true} string?]
[:status [:enum :planned :running :completed :failed :interrupted]]
[:options :map]
[:permissions schema:permissions]
[:cases [:vector [:map [:id some?]]]]
[:features [:vector string?]]
[:configurations
[:vector
[:map
[:slug string?]
[:selection [:map {:closed true}
[:default-features? boolean?]
[:features [:vector string?]]]]
[:status [:enum :planned :building :ready :running :completed :failed :unattempted]]
[:build {:optional true} [:map [:id string?] [:manifest-path string?] [:log-path string?]]]
[:run {:optional true} [:map [:run-id string?] [:record-path string?] [:log-path string?]
[:exit-code {:optional true} :int]]]
[:effective-features {:optional true} [:vector string?]]
[:failure {:optional true} errors/schema:diagnostic]
[:reason {:optional true} string?]]]]
[:edges
[:vector
[:map
[:from string?] [:to string?] [:feature string?]
[:status [:enum :planned :completed :failed :unattempted]]
[:path {:optional true} string?]
[:failure {:optional true}
[:or errors/schema:diagnostic
[:map {:closed true} [:unacknowledged-differences [:int {:min 1}]]]]]
[:reason {:optional true} string?]]]]
[:warnings [:vector string?]]])
(def schema:comparison
"Resolved analysis, selected permissions, every mismatch and per-side reports.
Every acknowledged difference has a rule; unacknowledged differences have none.
Optional edge context records requested toggles and Cargo's actual effective
sets/deltas, including aliases."
[:map {:closed true}
[:type [:= :comparison]]
[:analysis analysis/schema:settings]
[:baseline schema:provenance]
[:candidate schema:provenance]
[:permissions schema:permissions]
[:edge {:optional true}
[:map {:closed true}
[:from string?] [:to string?] [:feature string?]
[:baseline-features [:vector string?]] [:candidate-features [:vector string?]]
[:added [:vector string?]] [:removed [:vector string?]]]]
[:source-coverage string?]
[:interpretation string?]
[:pairs [:vector
[:map {:closed true}
[:explicit? boolean?]
[:mismatches [:vector
[:and
[:map {:closed true}
[:field :any] [:baseline :any] [:candidate :any] [:waived? boolean?]
[:rule {:optional true} [:enum :feature-toggle :build :scene :all]]]
[:fn #(= (:waived? %) (contains? % :rule))]]]]
[:baseline schema:comparison-side] [:candidate schema:comparison-side]
[:metrics [:map-of :any [:map {:closed true}
[:absolute schema:estimate] [:percentage schema:estimate]
[:absolute-ci schema:interval] [:percentage-ci schema:interval]]]]]]]])
(defn case-name
"Returns the saved case identifier as shown in commands and reports."
[id]
(if (keyword? id) (subs (str id) 1) (str id)))
(defn- require-valid!
"Rejects malformed external data with enough context for the caller."
[valid? message data]
(when-not valid?
(throw (ex-info message (assoc data :type ::invalid-record)))))
(defn- incompatible!
"Rejects runs whose recorded evidence cannot be merged."
[message data]
(throw (ex-info message (assoc data :type ::incompatible-runs))))
(defn- require-compatible!
"Requires equal values at `path`, naming the disagreeing field."
[base additional path label]
(let [base-value (get-in base path)
additional-value (get-in additional path)]
(when-not (= base-value additional-value)
(incompatible! (str "cannot merge runs with different " label)
{:field label
:path path
:base base-value
:additional additional-value}))))
(defn- initial-remaining
"Creates each saved case's fixed budget of unattempted slots."
[{:keys [cases warmups repetitions]}]
(into {} (map (fn [{:keys [id]}] [id {:warmups warmups :repetitions repetitions}])) cases))
(defn- consume-slot
"Consumes a warmup or measured slot; completed and failed attempts count alike."
[remaining {:keys [case-id warmup?]}]
(let [kind (if warmup? :warmups :repetitions)
path [case-id kind]
left (get-in remaining path)]
(require-valid! (and (some? left) (pos? left)) "no planned slot for attempt"
{:case-id case-id :slot kind})
(require-valid! (or warmup? (zero? (get-in remaining [case-id :warmups])))
"measured attempt precedes planned warmups" {:case-id case-id})
(update-in remaining path dec)))
(defn has-measurements?
"Reports whether a case has measured commands in its saved phase histories.
Args:
run: constructed or decoded run record.
case-id: exact saved case identifier.
Returns:
True when any preparation, main or reset block contains a command event."
[run case-id]
(boolean
(some (comp seq :events)
(mapcat :phases (filter #(= case-id (:case-id %))
(concat (:preparations run) (:attempts run)))))))
(defn get-unmeasured-cases
"Returns saved case ids whose phase histories contain no measured commands.
Args:
run: constructed or decoded run record.
Returns:
A vector of case identifiers in plan order."
[run]
(into []
(keep (fn [{:keys [id]}]
(when-not (has-measurements? run id)
id)))
(get-in run [:plan :cases])))
(defn- check-completion
"Requires all planned attempts to succeed before a run can be completed."
[run termination]
(when (= :completed (:reason termination))
(require-valid! (every? #(every? zero? (vals %)) (vals (:remaining run)))
"completed run still has unattempted slots" {})
(require-valid! (not-any? #(= :failed (:outcome %)) (:attempts run))
"completed run has failed attempts" {})
(require-valid! (not-any? :failure (:preparations run))
"completed run has failed preparations" {})
(require-valid! (every? #(= :completed (:outcome %)) (vals (:case-outcomes run)))
"completed run has failed cases" {})
(require-valid! (= (set (map :id (get-in run [:plan :cases]))) (set (keys (:case-outcomes run))))
"completed run needs every case outcome" {})
(require-valid! (empty? (get-unmeasured-cases run))
"completed case has no measurements" {:case-ids (get-unmeasured-cases run)})))
(defn- check-phases
[record allowed]
(let [phases (:phases record)]
(require-valid! (= (mapv :phase phases)
(filterv (set (map :phase phases)) allowed))
"phase order or identity is invalid" {})
(doseq [phase phases]
(require-valid! (= (:preparation-id record) (:preparation-id phase))
"phase references a different preparation" {})
(doseq [event (:events phase)]
(require-valid! (= 1 (count (get-in event [:metrics :command/elapsed])))
"command needs one elapsed value" {:command (:command event)})))
(when-not (or (:failure record) (= :failed (:outcome record)))
(require-valid! (every? #(= :completed (:outcome %)) phases)
"successful execution has a failed phase" {}))))
(defn- check-preparation
[preparation]
(require-valid! (sm/validate schema:preparation preparation) "invalid preparation" {})
(check-phases preparation [:preparation])
(when-not (:failure preparation)
(require-valid! (and (:fingerprint preparation) (:effective-graphics preparation)
(= [:preparation] (mapv :phase (:phases preparation))))
"completed preparation needs scene, graphics and phase evidence" {})))
(defn- check-attempt
[attempt preparation]
(require-valid! (sm/validate schema:attempt attempt) "invalid attempt" {})
(check-phases attempt [:main :reset])
(when (:preparation-id attempt)
(require-valid! (= (:case-id attempt) (:case-id preparation))
"attempt references an unknown or different preparation" {}))
(when (= :completed (:outcome attempt))
(require-valid! (and preparation (not (:failure preparation))
(not (:failure attempt))
(= :main (get-in attempt [:phases 0 :phase])))
"completed attempt needs successful preparation and main" {}))
(when (seq (:phases attempt))
(require-valid! (= :main (get-in attempt [:phases 0 :phase]))
"attempt phases must start with main" {})
(require-valid! (not (:failure preparation)) "attempt phases need successful preparation" {}))
(when (= :failed (get-in attempt [:phases 0 :outcome]))
(require-valid! (= 1 (count (:phases attempt))) "reset follows failed main" {})))
(defn- check-case-outcome
[run case-id outcome]
(require-valid! (contains? (:remaining run) case-id) "unknown case outcome" {})
(require-valid! (sm/validate schema:case-outcome outcome) "invalid case outcome" {})
(when (= :completed (:outcome outcome))
(require-valid! (and (not (:failure outcome))
(every? zero? (vals (get-in run [:remaining case-id])))
(not-any? #(and (= case-id (:case-id %)) (= :failed (:outcome %))) (:attempts run))
(not-any? #(and (= case-id (:case-id %)) (:failure %)) (:preparations run))
(has-measurements? run case-id))
"completed case has pending, failed or unmeasured work" {:case-id case-id})))
(defn check-run
"Validates schema:run and chronological accounting at a saved-data boundary."
[run]
(require-valid! (sm/validate schema:run run) "invalid schema:run" {})
(let [case-ids (mapv :id (get-in run [:plan :cases]))
preps (:preparations run)
prep-ids (mapv :preparation-id preps)
prep-map (into {} (map (juxt :preparation-id identity)) preps)
left (reduce consume-slot (initial-remaining (:plan run)) (:attempts run))]
(require-valid! (= (count case-ids) (count (set case-ids))) "duplicate saved case" {})
(require-valid! (= (count prep-ids) (count (set prep-ids))) "duplicate preparation" {})
(require-valid! (every? #(contains? (set case-ids) %) (map :case-id preps)) "unknown preparation case" {})
(require-valid! (= left (:remaining run)) "remaining slots disagree with attempts" {})
(doseq [[case-id outcome] (:case-outcomes run)]
(check-case-outcome run case-id outcome))
(doseq [prep preps]
(check-preparation prep))
(doseq [[attempt-id attempt] (map-indexed vector (:attempts run))]
(require-valid! (= attempt-id (:attempt-id attempt)) "attempts are not chronological" {})
(check-attempt attempt (get prep-map (:preparation-id attempt))))
(check-completion run (:termination run)))
run)
(defn create-run
"Creates an empty raw run from externally supplied identities and facts.
The plan defaults to 3 warmups, 10 repetitions and a 30000 ms case timeout.
For each case, all warmup slots precede measured slots. Failed attempts consume
their slot too; attempts retain chronological order across cases."
[{:keys [run-id started-at plan metadata analysis quantities]}]
(let [plan (merge {:warmups 3 :repetitions 10 :timeout-policy {:case-ms 30000}} plan)]
(check-run {:type :run-record
:run-id run-id :started-at started-at :ended-at nil
:plan plan :metadata metadata :analysis (analysis/resolve-settings analysis)
:quantities (merge quantities/descriptions quantities)
:preparations [] :attempts [] :case-outcomes {} :remaining (initial-remaining plan)
:termination {:reason :running}})))
(defn- check-open!
"Checks that a constructed or decoded run still accepts evidence."
[run]
(require-valid! (= :running (get-in run [:termination :reason])) "run already terminated" {}))
(defn record-preparation
"Checks and appends new setup evidence without consuming an attempt slot.
The run must come from create-run or decode; earlier evidence is trusted."
[run preparation]
(check-open! run)
(require-valid! (not (contains? (:case-outcomes run) (:case-id preparation))) "case already terminated" {})
(check-preparation preparation)
(require-valid! (contains? (:remaining run) (:case-id preparation)) "unknown preparation case" {})
(require-valid! (not-any? #(= (:preparation-id preparation) (:preparation-id %))
(:preparations run))
"duplicate preparation" {})
(update run :preparations conj preparation))
(defn record-attempt
"Checks and appends one new execution, consuming its scheduled slot.
The run must come from create-run or decode.
Missing preparation is allowed for failures before scene setup completes.
Earlier attempts are trusted; encode and decode check the complete record."
[run attempt]
(check-open! run)
(require-valid! (not (contains? (:case-outcomes run) (:case-id attempt))) "case already terminated" {})
(let [attempt (-> (merge {:preparation-id nil :phases []} attempt)
(assoc :attempt-id (count (:attempts run))))]
(check-attempt attempt (some #(when (= (:preparation-id attempt) (:preparation-id %)) %)
(:preparations run)))
(-> run
(update :attempts conj attempt)
(update :remaining consume-slot attempt))))
(defn finish-run
"Records externally supplied end time and termination details, retaining gaps."
[run ended-at termination]
(check-open! run)
(require-valid! (sm/validate schema:termination termination) "invalid termination" {})
(require-valid! (not= :running (:reason termination)) "termination needs an end reason" {})
(check-completion run termination)
(assoc run :ended-at ended-at :termination termination))
(defn record-case-outcome
"Records the terminal outcome of one case.
Args:
run: constructed or decoded run that accepts new evidence.
case-id: exact saved case identifier.
outcome: completion, failure, timeout or interruption with available cause.
Returns:
The run with its case outcome.
Throws:
ExceptionInfo for unknown cases, duplicate outcomes or malformed evidence."
[run case-id outcome]
(check-open! run)
(require-valid! (not (contains? (:case-outcomes run) case-id)) "duplicate case outcome" {})
(check-case-outcome run case-id outcome)
(assoc-in run [:case-outcomes case-id] outcome))
(defn merge-runs
"Returns a new validated run that appends `additional`'s measured attempts to
`base`.
Both runs must be completed and compatible. The merged plan keeps `base`'s
warmups and adds `additional`'s measured repetitions. Warmup attempts
and preparations used only by them are excluded from `additional`.
The merged record uses the base run's quantity descriptions.
The caller supplies the merged run id and may supply its end time."
[base additional {:keys [run-id ended-at]}]
(let [base (check-run base)
additional (check-run additional)]
(require-valid! (and (string? run-id) (seq run-id))
"merged run needs a run id" {})
(doseq [[side run] [[:base base] [:additional additional]]]
(when-not (= :completed (get-in run [:termination :reason]))
(incompatible! "only completed runs can be merged"
{:side side
:run-id (:run-id run)
:reason (get-in run [:termination :reason])})))
(when-not (and (string? (:ended-at base))
(string? (:ended-at additional)))
(incompatible! "completed runs need end times"
{:base (:ended-at base)
:additional (:ended-at additional)}))
(doseq [[path label] [[[:plan :cases] "case definitions"]
[[:plan :timeout-policy] "timeout policy"]
[[:plan :warmups] "warmup count"]
[[:analysis] "analysis settings"]
[[:metadata :git] "git provenance"]
[[:metadata :build] "build facts"]
[[:metadata :environment] "runtime environment"]
[[:metadata :scored?] "scored flag"]]]
(require-compatible! base additional path label))
(doseq [run [base additional]]
(require-valid! (every? #(sm/validate quantities/schema:description %)
(vals (:quantities run)))
"cannot merge malformed quantity descriptions" {:run-id (:run-id run)}))
(when-not (= (update-vals (:quantities base) #(dissoc % :description))
(update-vals (:quantities additional) #(dissoc % :description)))
(incompatible! "cannot merge runs with different quantity units" {}))
(let [base-attempts (:attempts base)
additional-measured (filterv #(not (:warmup? %)) (:attempts additional))
base-preparations (:preparations base)
base-preparation-ids (into #{} (map :preparation-id) base-preparations)
used-preparation-ids (into #{} (keep :preparation-id) additional-measured)
additional-preparations (filterv #(contains? used-preparation-ids
(:preparation-id %))
(:preparations additional))]
(when-let [duplicate (some #(contains? base-preparation-ids (:preparation-id %))
additional-preparations)]
(incompatible! "cannot merge runs with duplicate preparation ids"
{:preparation-id (:preparation-id duplicate)}))
(let [offset (count base-attempts)
attempts (into (vec base-attempts)
(map-indexed (fn [index attempt]
(assoc attempt :attempt-id (+ offset index)))
additional-measured))
plan (assoc (:plan base)
:repetitions (+ (get-in base [:plan :repetitions])
(get-in additional [:plan :repetitions])))
remaining (reduce consume-slot (initial-remaining plan) attempts)
ended-at (or ended-at
(let [base-end (:ended-at base)
additional-end (:ended-at additional)]
(if (neg? (compare base-end additional-end))
additional-end
base-end)))
metadata (update (:metadata base) :diagnostics
#(assoc (or % {})
:merge
{:base-run-id (:run-id base)
:additional-run-id (:run-id additional)
:additional-warmups-excluded
(count (filter :warmup? (:attempts additional)))
:merged-at ended-at}))]
(check-run {:type :run-record
:run-id run-id
:started-at (:started-at base)
:ended-at ended-at
:plan plan
:metadata metadata
:analysis (:analysis base)
:quantities (:quantities base)
:preparations (into (vec base-preparations) additional-preparations)
:attempts attempts
:case-outcomes (merge (:case-outcomes base) (:case-outcomes additional))
:remaining remaining
:termination {:reason :completed}})))))
(defn- check-feature-configuration
"Requires evidence that matches each saved configuration's progress."
[{:keys [status build run effective-features failure reason]}]
(when (#{:ready :running :completed} status)
(require-valid! (some? build) "feature comparison configuration needs a build" {:status status}))
(when (#{:ready :completed} status)
(require-valid! (some? effective-features)
"feature comparison configuration needs effective features" {:status status}))
(when (#{:running :completed} status)
(require-valid! (some? run) "feature comparison configuration needs a run" {:status status}))
(when (= :completed status)
(require-valid! (some? (:exit-code run))
"completed feature comparison configuration needs a run exit code" {})
(require-valid! (zero? (:exit-code run))
"completed feature comparison configuration needs a successful run" {}))
(when (= :failed status)
(require-valid! (or (some? failure) (some? reason))
"failed feature comparison configuration needs a failure or reason" {}))
(when (= :unattempted status)
(require-valid! (some? reason)
"unattempted feature comparison configuration needs a reason" {})))
(defn- check-feature-edge
"Requires a path for completed analysis and a reason for unattempted work."
[{:keys [status path failure reason]}]
(case status
:completed
(require-valid! (some? path) "completed feature comparison edge needs a report path" {})
:failed
(require-valid! (or (some? failure) (some? reason))
"failed feature comparison edge needs a failure or reason" {})
:unattempted
(require-valid! (some? reason)
"unattempted feature comparison edge needs a reason" {})
nil))
(defn check-feature-comparison
"Validates saved feature-comparison progress against its planned experiment.
Configurations and edges must match the pure planner after status evidence is
attached. Terminal records cannot retain active work, and completed work must
retain its build, run, effective-feature and report evidence."
[record]
(require-valid! (sm/validate schema:feature-comparison record)
"invalid schema:feature-comparison" {})
(let [features (:features record)
configurations (:configurations record)
edges (:edges record)
expected (feature-plan/plan features
{:max-features (count features)
:dry-run true
:confirm true})
terminal? (contains? #{:completed :failed :interrupted} (:status record))
active? (comp #{:planned :building :ready :running} :status)]
(require-valid! (= features (:features expected))
"feature comparison features disagree with normalized selection" {})
(require-valid! (= (mapv #(select-keys % [:slug :selection]) configurations)
(:configurations expected))
"feature comparison configurations disagree with planned features" {})
(require-valid! (= (mapv #(select-keys % [:from :to :feature]) edges)
(:edges expected))
"feature comparison edges disagree with planned configurations" {})
(require-valid! (= (count (:cases record)) (count (set (map :id (:cases record)))))
"duplicate feature comparison case" {})
(when terminal?
(require-valid! (some? (:ended-at record))
"terminal feature comparison needs an end time" {})
(require-valid! (not-any? active? configurations)
"terminal feature comparison still has active configurations" {})
(require-valid! (not-any? active? edges)
"terminal feature comparison still has active edges" {}))
(when-not terminal?
(require-valid! (nil? (:ended-at record))
"unfinished feature comparison has an end time" {}))
(when (= :planned (:status record))
(require-valid! (every? (comp #{:planned} :status) configurations)
"planned feature comparison has started configurations" {})
(require-valid! (every? (comp #{:planned} :status) edges)
"planned feature comparison has started edges" {}))
(when (= :completed (:status record))
(require-valid! (every? (comp #{:completed} :status) configurations)
"completed feature comparison has unfinished configurations" {})
(require-valid! (every? (comp #{:completed} :status) edges)
"completed feature comparison has unfinished edges" {}))
(doseq [configuration configurations]
(check-feature-configuration configuration))
(doseq [edge edges]
(check-feature-edge edge)))
record)
(defn check-record
"Validates a run, comparison or feature-comparison at a Transit boundary."
[record]
(case (:type record)
:run-record (check-run record)
:comparison (do
(require-valid! (sm/validate schema:comparison record) "invalid schema:comparison" {})
record)
:feature-comparison (check-feature-comparison record)
(throw (ex-info "unsupported benchmark format" {:type ::unsupported-format}))))
(defn encode
"Encodes a supported record with transit."
[record]
(codec/encode-str (check-record record)))
(defn decode
"Decodes from transit and validates a supported record."
[text]
(check-record (transit/decode-str text)))

View File

@ -0,0 +1,151 @@
;; 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 benches.render-wasm.runtime.camera
"Rendering and camera commands over a prepared snapshot.
Commands accept a context or its Promise and return a Promise of the next
context. Pan, zoom and restore update its view. Starred variants record raw
elapsed time and renderer evidence in the current execution phase.
Load waits for an animation frame and renders through Full. Pan and zoom
submit paced cached previews, settle, then render through Full. Restore
applies a view and renders immediately with SyncTiles. Render-sync uses the
blocking renderer path. Full denotes renderer submission completion.
Measured async commands finish at the final renderer return captured in
`:end-ms`. Input resolution and argument validation precede timing; output
validation and event recording follow it. Traces retain renderer calls,
cached previews, waits and protocol endpoints."
(:require
[app.common.schema :as sm]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.runtime.commands :refer [defcommand]]
[benches.render-wasm.runtime.protocol :as protocol]))
(defcommand render-sync
"Renders the prepared scene synchronously.
Args:
rtx: context or Promise of it.
Returns:
A Promise of the context after the blocking renderer call."
[rtx]
((get-in rtx [:hooks :render-sync]))
rtx)
(def ^:private valid-pan?
(sm/validator
[:map {:closed true}
[:dx ::sm/safe-number]
[:dy ::sm/safe-number]
[:steps [:int {:min 1}]]
[:settle-ms [:and ::sm/safe-number [:>= 0]]]]))
(def ^:private valid-zoom?
(sm/validator
[:map {:closed true}
[:factor [:and ::sm/safe-number [:> 0]]]
[:steps [:int {:min 1}]]
[:settle-ms [:and ::sm/safe-number [:>= 0]]]]))
(def ^:private valid-view? (sm/validator decl/schema:view))
(defn- pan-view
[view dx dy]
(-> view (update :x + dx) (update :y + dy)))
(defn- zoom-view
[view factor]
(update view :scale * factor))
(defn- render-evidence
[evidence]
{:end-ms (:end-ms evidence)
:metrics {:renderer/call-elapsed (mapv :duration-ms (:slices evidence))}
:trace evidence})
(defn- camera-evidence
[evidence]
(-> (render-evidence evidence)
(assoc-in [:metrics :renderer/cache-call-elapsed] (mapv :duration-ms (:cached-slices evidence)))
(assoc-in [:metrics :camera/finalization-to-full] [(:time-to-full-ms evidence)])))
(defn- run-camera
[rtx target {:keys [steps settle-ms]}]
(-> (protocol/start-camera! rtx {:settle-ms settle-ms})
(protocol/animate-view! {:to target :steps steps})
(protocol/finish-camera!)))
(defcommand pan
"Moves the view by `:dx` and `:dy` through `:steps` cached previews.
Waits `:settle-ms` milliseconds, ends the interaction and renders until Full.
Args:
rtx: context or Promise of it.
options: finite offsets, positive step count and nonnegative settling wait.
Returns:
A Promise of the context with its updated view."
{:async [complete fail]}
[rtx {:keys [dx dy] :as options}]
{:pre [(valid-pan? options) (valid-view? (pan-view (:view rtx) dx dy))]}
(let [target (pan-view (:view rtx) dx dy)]
(-> (run-camera rtx target options)
(.then #(complete (assoc rtx :view target) (camera-evidence %)))
(.catch fail))))
(defcommand load
"Renders the uploaded snapshot until the renderer returns Full.
Waits for an animation frame before the first renderer call.
Args:
rtx: context or Promise of it.
Returns:
A Promise of the context after renderer completion."
{:async [complete fail]}
[rtx]
(let [hooks (:hooks rtx)]
(-> (protocol/drain hooks {:flags 0 :origin ((:now hooks)) :immediate false})
(.then #(complete rtx (assoc-in (render-evidence %)
[:metrics :renderer/prepared-scene-to-full] [(:full-ms %)])))
(.catch fail))))
(defcommand zoom
"Multiplies view scale through paced cached previews, then renders through Full.
Args:
rtx: context or Promise of it.
options: positive finite factor, positive steps and nonnegative settle-ms.
Returns:
A Promise of the context with its updated scale."
{:async [complete fail]}
[rtx {:keys [factor] :as options}]
{:pre [(valid-zoom? options) (valid-view? (zoom-view (:view rtx) factor))]}
(let [target (zoom-view (:view rtx) factor)]
(-> (run-camera rtx target options)
(.then #(complete (assoc rtx :view target) (camera-evidence %)))
(.catch fail))))
(defcommand restore
"Restores a view and renders immediately through Full with SyncTiles.
Args:
rtx: context or Promise of it.
view: scale and offsets to restore. Its resolved viewport must match rtx.
Returns:
A Promise of the context with the restored view."
{:async [complete fail]}
[rtx view]
{:pre [(valid-view? view)
(= (decl/resolve-viewport view) (decl/resolve-viewport (:view rtx)))]}
(-> (protocol/restore (:hooks rtx) view)
(.then #(complete (assoc rtx :view view) (render-evidence %)))
(.catch fail)))

View File

@ -0,0 +1,90 @@
;; 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 benches.render-wasm.runtime.commands
"Declarations of renderer commands.
A command transforms a command context, a map of case data, a scene snapshot,
the current camera view and hooks. A scene is a recipe for shapes; a case
pairs a scene with its work. A snapshot contains concrete shapes and their
references. The view stores zoom scale and camera offsets. Hooks are functions
for clocks, scheduling and renderer operations. A measured command records
an event containing raw numeric values for named quantities, called metrics.")
(defmacro defcommand
"Defines a base command and its measured function with a `*` suffix.
Both accept a command context or its Promise and return a Promise of the next
context. A synchronous body returns that context. The measured function
requires an active command execution and records one event with
`:command/elapsed`, the body duration in milliseconds.
Options:
- `:measured-variant false` defines a composite's base function. A composite
combines commands and records events from the measured commands it calls.
- `:async [complete fail]` lets the body complete asynchronously. Successful
completion requires `complete`; `fail` reports a cause. The first call to
either callback determines the outcome. A returned Promise forwards its
rejection to `fail`.
`complete` accepts the next context and an optional map. `:end-ms` supplies
the clock reading at the end of the work. When the map omits `:end-ms`,
`complete` captures a reading on entry. `:metrics` maps namespaced metric
keywords to nonempty vectors of raw numbers.
`:trace` contains renderer details such as individual render calls and waits.
Argument destructuring and `:pre` conditions precede work and timing in every
build. A failed condition rejects the returned Promise. The condition map
accepts `:pre`. Output validation and event recording follow the endpoint."
[command & forms]
(let [[doc forms] (if (string? (first forms))
[(first forms) (next forms)]
[nil forms])
[options forms] (if (map? (first forms))
[(first forms) (next forms)]
[{} forms])
[argv & body] forms
[conditions body] (if (and (map? (first body)) (next body))
[(first body) (next body)]
[nil body])
measured (get options :measured-variant true)
handlers (:async options)
command-id (keyword (str (get-in &env [:ns :name])) (name command))
starred (symbol (str command "*"))
prepare (gensym "prepare")]
(when-not (and (simple-symbol? command)
(vector? argv)
(seq argv)
(boolean? measured)
(every? #{:measured-variant :async} (keys options))
(or (not (contains? options :async))
(and (vector? handlers)
(= 2 (count handlers))
(every? simple-symbol? handlers)
(apply distinct? handlers)))
(every? #{:pre} (keys conditions))
(or (nil? (:pre conditions)) (vector? (:pre conditions))))
(throw (ex-info "invalid command declaration" {:command command})))
`(let [~prepare (fn ~argv
~@(for [condition (:pre conditions)]
`(when-not ~condition
(throw (ex-info "command precondition failed"
{:type ::invalid-arguments
:command ~command-id
:condition '~condition}))))
(fn ~(or handlers []) ~@body))]
(defn ~command
~@(when doc [doc])
[rtx# & args#]
(invoke {:command ~command-id :measured false :composite ~(not measured)
:async ~(some? handlers)}
rtx# ~prepare args#))
~@(when measured
[`(defn ~starred
~(str "Measured variant of `" command "`." (when doc (str " " doc)))
[rtx# & args#]
(invoke {:command ~command-id :measured true :composite false
:async ~(some? handlers)}
rtx# ~prepare args#))]))))

View File

@ -0,0 +1,241 @@
;; 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 benches.render-wasm.runtime.commands
"Execution of renderer commands and collection of their measurements.
A command transforms a command context: a map containing case data (`:case`),
the prepared scene snapshot (`:scene`), the current camera view (`:view`) and
functions for clocks, scheduling and renderer operations (`:hooks`). A scene
is a recipe for shapes; a case pairs a scene with its work. A snapshot contains
the concrete shapes and their references. A view contains zoom scale, camera
offsets and optional viewport settings for canvas dimensions and pixel ratio.
The `:check` hook is a guard:
returning authorizes work, and throwing an error rejects it.
A command execution evaluates a function from one context to the next and
records measured command calls in completion order. Each call records an
event with the command name and its metrics. A metric names a quantity;
each event keeps its raw numeric
values in a vector. An optional trace contains details such as render calls
and waits. Each execution owns its event history and authorizes work while
active. Its outcome contains the next context or an error and earlier events."
(:require-macros [benches.render-wasm.runtime.commands])
(:require
[app.common.schema :as sm]
[benches.render-wasm.codec :as codec]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.errors :as errors :refer [attempt failure?]]))
(def ^:private schema:context
[:map {:closed true}
[:case [:map [:id qualified-keyword?]]]
[:scene [:map [:objects map?] [:refs map?]]]
[:view decl/schema:view]
[:hooks [:and
[:map-of :keyword fn?]
[:map
[:now fn?]
[:check fn?]
[:frame fn?]
[:sleep fn?]
[:render fn?]
[:render-sync fn?]
[:render-from-cache fn?]
[:set-view fn?]
[:set-view-start fn?]
[:set-view-end fn?]]]]])
(def ^:private valid-context? (sm/validator schema:context))
(def ^:private schema:completion
[:map {:closed true}
[:end-ms {:optional true} number?]
[:metrics {:optional true}
[:map-of qualified-keyword? [:vector {:min 1} number?]]]
[:trace {:optional true} :any]])
(def ^:private valid-completion? (sm/validator schema:completion))
(def ^:private owners (js/WeakMap.))
(defn- check-context
[rtx]
(when-not (valid-context? rtx)
(throw (ex-info "invalid command context"
{:type ::invalid-context
::sm/explain (sm/explain schema:context rtx)}))))
(defn- check-output
[rtx output]
(check-context output)
(when-not (identical? (get-in rtx [:hooks :check])
(get-in output [:hooks :check]))
(throw (ex-info "command changed execution ownership"
{:type ::ownership-lost})))
((get-in rtx [:hooks :check]))
output)
(defn- scope-context
[rtx events diagnostics active]
(check-context rtx)
(let [previous-check (get-in rtx [:hooks :check])
previous-owner (.get owners previous-check)
base-check (or (:base-check previous-owner) previous-check)
check (fn []
(when-not @active
(throw (ex-info "command execution has ended"
{:type ::inactive-execution})))
(base-check))
scoped (assoc-in rtx [:hooks :check] check)]
(.set owners check {:base-check base-check
:record #(do (check) (vswap! events conj %))
:diagnose #(when @active (vswap! diagnostics conj %))})
(check)
scoped))
(defn ^:async ^:no-doc execute
"Runs `run` with a command context and resolves to its outcome.
`run` returns the next context or its Promise. A successful outcome contains
`:outcome :completed`, `:context` and ordered `:events`. A failed outcome
contains `:outcome :failed`, `:error` and events recorded before failure.
Existing Errors retain their identity; other thrown values become Errors
that retain the value in `:thrown`.
The context's guard authorizes work during this execution. Passing a returned
context to another execution establishes a new authorization. Repeated calls
to asynchronous completion callbacks can supply `:diagnostics` while the
execution is active. Returned contexts must retain this execution's `:check`
guard. A changed guard rejects completion."
[rtx run]
(let [events (volatile! [])
diagnostics (volatile! [])
active (volatile! true)
result (try
(attempt :catch-fatal
(let [scoped (scope-context (await rtx) events diagnostics active)]
(check-output scoped (await (run scoped)))))
(finally
(vreset! active false)))]
(cond-> (if (failure? result)
{:outcome :failed :error result :events @events}
{:outcome :completed :context result :events @events})
(seq @diagnostics) (assoc :diagnostics @diagnostics))))
(defn- prepare-invocation
[{:keys [command measured] :as invocation} rtx prepare args]
(check-context rtx)
(let [{:keys [check now]} (:hooks rtx)
owner (.get owners check)]
(check)
(when (and measured (nil? owner))
(throw (ex-info "measured command requires an execution"
{:type ::missing-execution :command command})))
(assoc invocation
:input rtx
:core (apply prepare rtx args)
:now now
:record (:record owner)
:diagnose (:diagnose owner))))
(defn- thenable?
[output]
(and (some? output) (fn? (.-then output))))
(defn- run-core
[{:keys [command measured composite core now] :as invocation}]
(if composite
(assoc invocation :output (core))
(let [start (when measured (now))
output (core)
end (when measured (now))]
(when (thenable? output)
(.catch (js/Promise.resolve output) errors/as-failure)
(throw (ex-info "synchronous command returned asynchronous work"
{:type ::async-result :command command})))
(cond-> (assoc invocation :output output)
measured (assoc :metrics {:command/elapsed [(- end start)]})))))
(defn- finish-invocation
[{:keys [measured input output record] :as invocation}]
(check-output input output)
(when (contains? invocation :trace)
(try
(codec/check-value (:trace invocation))
(codec/encode-str (:trace invocation))
(catch :default cause
(throw (ex-info "command trace cannot cross Transit"
{:type ::invalid-trace} cause)))))
(when measured
(record (select-keys invocation [:command :metrics :trace])))
output)
(defn- complete-invocation
[{:keys [command measured now] :as invocation} output evidence start]
;; A completion callback can run after the renderer returns. A supplied
;; endpoint preserves the renderer return time across that scheduling delay.
(let [end (when measured
(if (and (map? evidence) (contains? evidence :end-ms))
(:end-ms evidence)
(now)))]
(when-not (and (valid-completion? evidence)
(not (contains? (:metrics evidence) :command/elapsed)))
(throw (ex-info "invalid command completion evidence"
{:type ::invalid-completion :command command
::sm/explain (sm/explain schema:completion evidence)})))
(cond-> (merge invocation evidence {:output output})
measured (assoc-in [:metrics :command/elapsed] [(- end start)]))))
(defn- claim-terminal
[terminal diagnose command signal]
(if-some [first-signal @terminal]
(do
(when diagnose
(diagnose {:type ::duplicate-terminal :command command
:first-signal first-signal :signal signal}))
false)
(do
(vreset! terminal signal)
true)))
(defn- run-async-core
[{:keys [command measured core now diagnose] :as invocation}]
(js/Promise.
(fn [resolve reject]
(let [terminal (volatile! nil)
start (volatile! nil)
complete (fn complete
([output] (complete output {}))
([output evidence]
(when (claim-terminal terminal diagnose command :complete)
(let [result (attempt :catch-fatal
(-> (complete-invocation invocation output evidence @start)
(finish-invocation)))]
(if (failure? result) (reject result) (resolve result))))
nil))
fail (fn [cause]
(when (claim-terminal terminal diagnose command :fail)
(reject (errors/as-failure cause)))
nil)
result (attempt :catch-fatal
(vreset! start (when measured (now)))
(let [returned (core complete fail)]
(when (thenable? returned)
(.catch (js/Promise.resolve returned) fail))))]
(when (failure? result)
(fail result))))))
(defn ^:async invoke
"Runs a command with a context or its Promise and returns the next context.
Resolves to that context as a Promise; errors reject it. A measured command
records one event with elapsed time, additional metric values and any trace."
[command rtx prepare args]
(let [invocation (prepare-invocation command (await rtx) prepare args)]
(if (:async command)
(await (run-async-core invocation))
(let [result (run-core invocation)]
(finish-invocation
(if (:composite command)
(assoc result :output (await (:output result)))
result))))))

View File

@ -0,0 +1,30 @@
;; 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 benches.render-wasm.runtime.protocol
"Forms for evaluating renderer operations as Promises.
A Promise represents a result that can complete with a value or reject with
a cause. These forms include synchronous errors in that result.")
(defmacro resolve-or-reject
"Evaluates `body` and returns a Promise of its result.
A synchronous throw rejects the Promise."
[& body]
`(try
(js/Promise.resolve (do ~@body))
(catch :default cause#
(js/Promise.reject cause#))))
(defmacro resolve-or-reject-with
"Evaluates `body` and returns a Promise of its result.
A synchronous throw rejects with `on-error`, evaluated with `cause` bound
to the thrown value."
[cause on-error & body]
`(try
(js/Promise.resolve (do ~@body))
(catch :default cause#
(let [~cause cause#]
(js/Promise.reject ~on-error)))))

View File

@ -0,0 +1,424 @@
;; 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 benches.render-wasm.runtime.protocol
"Rendering until completion and timing camera movement.
A frame type describes the progress of one renderer call. A render drain is
a sequence of calls that ends when the renderer returns Full. Each call
produces a slice: its timestamp, flags, frame type and duration. Partial and
ViewportReady schedule another call on the next animation frame. Full marks
renderer submission of the completed request. GPU execution and display
proceed asynchronously.
A view contains zoom `:scale` and offsets `:x` and `:y`. A cached preview
draws from existing tiles, the fixed regions of the scene that the renderer
caches. A camera session starts an interaction, applies views with cached
previews, waits for the requested settling time, ends the interaction and
renders until Full. Each preview waits for an animation frame. Explicit
pauses can extend the active interval between camera start and settling.
Camera finalization starts immediately before ending the interaction and
ends at the final renderer return.
## Hooks and errors
Hooks are functions that supply clock, scheduling and renderer operations:
- `:now` returns a clock reading in milliseconds.
- `:frame` resolves a Promise with the next animation frame's timestamp.
- `:sleep` accepts a requested wait in milliseconds and returns its Promise.
- `:check` is a guard. Returning authorizes work; throwing rejects it. It can
report cancellation, an expired deadline, loss of the WebGL2 graphics
context or replacement of the active renderer owner. An owner is the
execution authorized to use the renderer.
- `:render` accepts a timestamp and flags and returns a frame type.
- `:render-from-cache` draws one cached preview.
- `:set-view` accepts a view containing scale and both offsets.
- `:set-view-start` and `:set-view-end` start and end the interaction.
Guards run before work, after awaited frames and waits, and around render
calls. Guard and renderer errors reject the returned Promises. Errors keep
available traces of render calls and pauses under `:partial`.
Frame types come from `app.common.render-wasm.wasm`. Partial and ViewportReady
continue rendering, Full completes it, and other values reject with
`::unexpected-frame-type`. Each subsequent call receives the preceding frame
type as its flags. SyncTiles asks the renderer to update which tiles the
current view requires.
View restoration and camera finalization use that flag for their first call
and start immediately. A drain can also wait for an animation frame before
its first call. Later calls wait for animation frames.
## Render calls and measured durations
A trace records individual calls and waits. Durations use milliseconds and
preserve raw numeric values, including zero. A drain origin is the clock
reading from which its completion durations start.
- `:slices` contains one entry per renderer call:
- `:timestamp`: the animation frame's timestamp or the current clock
reading for an immediate call.
- `:flags`: the flags passed to the renderer.
- `:frame-type`: the returned frame type.
- `:duration-ms`: elapsed time during the call.
- `:cached-slices` contains one entry per cached preview:
- `:timestamp`: the animation frame's timestamp for the view update.
- `:duration-ms`: elapsed time during the cached render call.
- `:view-and-preview-ms`: elapsed time for the view update and preview.
- `:viewport-ready-ms` measures from the drain origin to the first call that
returns ViewportReady or Full. It equals `:full-ms` when the first such
call returns Full.
- `:full-ms` measures from the drain origin to the call that returns Full.
- `:end-ms` is the clock reading immediately after that final call returns.
- `:interact-ms` measures from immediately before camera start through the
final renderer return.
- `:active-ms` measures from camera start through the last cached preview and
its guard check, or through an explicit pause that follows the preview.
- `:authored-sleeps` contains requested and observed durations of explicit
pauses during the active interval.
- `:settling-requested-ms` is the requested settling wait.
- `:settling-actual-ms` measures from the end of the active interval to the
start of camera finalization.
- `:set-view-end-ms` measures the call that ends the interaction.
- `:time-to-viewport-ready-ms` measures from camera finalization to the first
ViewportReady or Full result. `:time-to-full-ms` ends at Full. Both include
the call that ends the interaction.
- `:last-input-to-full-ms` measures from immediately before the last view
update to the final renderer return. The initial input time equals camera
start; each view update replaces it."
(:require-macros
[benches.render-wasm.runtime.protocol :refer [resolve-or-reject resolve-or-reject-with]])
(:require
[app.common.render-wasm.wasm :as wasm]
[benches.render-wasm.errors :as errors]))
(defn- partial-error
"Returns an error with completed traces under `:partial`.
Trace fields already present in the error take precedence over `evidence`."
[cause evidence]
(errors/with-context cause (errors/message-of cause "renderer protocol failed")
{:partial evidence}))
(defn- next-render-timestamp
"Resolves to the current clock reading for an immediate render, or to the
next animation frame's timestamp."
[{:keys [now frame]} immediate?]
(if immediate?
(js/Promise.resolve (now))
(frame)))
(defn- render-slice
"Returns the updated drain state, frame type and endpoint for one render call.
The state includes the completed call in its `:slices` trace."
[{:keys [now check render]}
{:keys [flags] :as state}
timestamp]
(check)
(let [started-ms (now)
frame-type (render timestamp flags)
ended-ms (now)
slice {:timestamp timestamp
:flags flags
:frame-type frame-type
:duration-ms (- ended-ms started-ms)}]
(try
(check)
(catch :default cause
(throw (partial-error cause {:slices (conj (:slices state) slice)}))))
{:state (update state :slices conj slice)
:frame-type frame-type
:ended-ms ended-ms}))
(defn- valid-frame-type?
"Recognizes the progressive and final renderer frame types."
[frame-type]
(or (= frame-type wasm/FRAME_TYPE_PARTIAL)
(= frame-type wasm/FRAME_TYPE_VIEWPORT_READY)
(= frame-type wasm/FRAME_TYPE_FULL)))
(defn- viewport-ready-time
"Returns the elapsed time to the first ViewportReady or Full result."
[current-ready-ms frame-type ended-ms origin-ms]
(or current-ready-ms
(when (or (= frame-type wasm/FRAME_TYPE_VIEWPORT_READY)
(= frame-type wasm/FRAME_TYPE_FULL))
(- ended-ms origin-ms))))
(defn- advance-drain-state
"Updates the continuation flags and completion time for a returned frame type.
An unexpected type throws an error containing the completed render calls."
[{:keys [viewport-ready-ms] :as state}
frame-type
ended-ms
origin-ms]
(when-not (valid-frame-type? frame-type)
(throw (ex-info (str "unexpected frame type: " (pr-str frame-type))
{:type ::unexpected-frame-type
:frame-type frame-type
:partial (select-keys state [:slices :viewport-ready-ms])})))
(assoc state
:flags frame-type
:viewport-ready-ms
(viewport-ready-time viewport-ready-ms
frame-type
ended-ms
origin-ms)))
(defn- drain-complete?
"Returns whether the renderer reported Full completion."
[frame-type]
(= frame-type wasm/FRAME_TYPE_FULL))
(defn- drain-result
"Returns traces of render calls, the final clock reading and completion durations
measured from the drain origin."
[{:keys [slices viewport-ready-ms]}
ended-ms
origin-ms]
{:slices slices
:end-ms ended-ms
:viewport-ready-ms viewport-ready-ms
:full-ms (- ended-ms origin-ms)})
(defn drain
"Renders until a call returns Full and resolves to its traces and durations.
Options:
- `:flags` supplies the first render call's flags.
- `:origin` supplies the starting clock reading for completion durations.
- `:immediate` starts the first call immediately when true. Otherwise, the
first call waits for an animation frame. Subsequent calls wait for frames.
The result contains `:slices`, the traces of render calls; `:end-ms`, the final
renderer clock reading; and `:viewport-ready-ms` and `:full-ms`, durations
measured from `:origin`. Partial and ViewportReady continue with the returned
frame type as the next flags. A guard failure or unexpected frame type
rejects the Promise with completed traces in the error's `:partial` field."
[{:keys [check] :as hooks}
{:keys [flags origin immediate]}]
(resolve-or-reject
(check)
(letfn [(step [state immediate?]
(-> (next-render-timestamp hooks immediate?)
(.then ;; recurse or drain
(fn [timestamp]
(let [{:keys [frame-type ended-ms] :as rendered}
(render-slice hooks state timestamp)
next-state
(advance-drain-state (:state rendered)
frame-type
ended-ms
origin)]
(if (drain-complete? frame-type)
(drain-result next-state ended-ms origin)
(step next-state false)))))
(.catch (fn [cause]
(throw (partial-error cause (select-keys state [:slices :viewport-ready-ms])))))))]
(step {:flags flags
:slices []
:viewport-ready-ms nil}
immediate))))
(defn restore
"Restores `view` and renders its tiles until Full.
The first call uses SyncTiles and starts immediately. Completion durations
begin after restoring the view and ending the interaction. Returns a Promise
with recorded render calls, completion durations and the final clock reading
`:end-ms`."
[{:keys [now check set-view set-view-start set-view-end] :as hooks} view]
(resolve-or-reject
(check)
(set-view-start)
(set-view view)
(set-view-end)
(drain hooks {:flags wasm/RENDER_FLAG_SYNC_TILES
:origin (now)
:immediate true})))
(defn start-camera!
"Starts a camera session from the context's current `:view` and `:hooks`.
Resolves to the session with its start time and requested settling wait.
The initial active interval covers the operation that starts the camera.
`:settle-ms` must give a finite, nonnegative duration in milliseconds."
[{:keys [hooks view]} {:keys [settle-ms]}]
(resolve-or-reject
(when-not (and (number? settle-ms)
(js/Number.isFinite settle-ms)
(<= 0 settle-ms))
(throw (ex-info "settle-ms must be a nonnegative finite number"
{:type ::invalid-settle-ms :settle-ms settle-ms})))
(let [{:keys [now check set-view-start]} hooks]
(check)
(let [started-ms (now)]
(set-view-start)
{:hooks hooks
:view view
:settle-ms settle-ms
:started-ms started-ms
:last-input-ms started-ms
:active-end-ms (now)
:cached-slices []
:authored-sleeps []}))))
(defn preview-view!
"Updates the view and draws one cached preview on the next animation frame.
Resolves to the session with the new view and measured preview durations.
Errors retain completed previews and pauses under `:partial`."
[session-p view]
(-> (js/Promise.resolve session-p)
(.then (fn [{:keys [hooks] :as session}]
(let [{:keys [now frame check render-from-cache set-view]} hooks
evidence (select-keys session [:cached-slices :authored-sleeps])]
(resolve-or-reject-with cause
(partial-error cause evidence)
(check)
(-> (frame)
(.then (fn [timestamp]
(check)
(let [input-ms (now)
call-start (now)]
(set-view view)
(let [cache-start (now)]
(render-from-cache)
(let [cache-end (now)
slices (conj (:cached-slices session)
{:timestamp timestamp
:duration-ms (- cache-end cache-start)
:view-and-preview-ms (- cache-end call-start)})]
(try
(check)
(catch :default cause
(throw (partial-error cause (assoc evidence :cached-slices slices)))))
(assoc session :view view :last-input-ms input-ms
:active-end-ms (now) :cached-slices slices))))))
(.catch (fn [cause] (throw (partial-error cause evidence)))))))))))
(defn- interpolate
"Interpolates $v(t) = (1-t)a + tb$ between finite endpoints.
Args:
a: starting value.
b: target value.
t: interpolation fraction in [0, 1].
Returns:
The interpolated value, preserving positive endpoints near zero."
[a b t]
(if (< t 0.5)
(+ a (* t (- b a)))
(+ b (* (- 1 t) (- a b)))))
(defn animate-view!
"Moves to `to` through `steps` linearly interpolated views in sequence.
Each update waits for an animation frame and draws one cached preview.
Resolves to the session with its final view. Steps must be a positive integer;
errors retain completed previews and pauses under `:partial`."
[session-p {:keys [to steps]}]
(-> (js/Promise.resolve session-p)
(.then (fn [{from :view :as session}]
(when-not (and (integer? steps) (pos? steps))
(throw (ex-info "camera steps must be a positive integer"
{:type ::invalid-steps :steps steps
:partial (select-keys session [:cached-slices :authored-sleeps])})))
(reduce (fn [session-p i]
(let [t (/ i steps)
view (if (= i steps)
to
{:scale (interpolate (:scale from) (:scale to) t)
:x (interpolate (:x from) (:x to) t)
:y (interpolate (:y from) (:y to) t)})]
(preview-view! session-p
view)))
(js/Promise.resolve session)
(range 1 (inc steps)))))))
(defn sleep!
"Pauses the camera session and records the requested and observed wait.
The wait must be finite and nonnegative. The pause extends the active
interval. Resolves to the updated session;
errors retain completed previews and pauses under `:partial`."
[session-p requested-ms]
(-> (js/Promise.resolve session-p)
(.then (fn [{:keys [hooks] :as session}]
(let [{:keys [now sleep check]} hooks
evidence (select-keys session [:cached-slices :authored-sleeps])]
(resolve-or-reject-with cause
(partial-error cause evidence)
(when-not (and (number? requested-ms)
(js/Number.isFinite requested-ms)
(<= 0 requested-ms))
(throw (ex-info "sleep duration must be a nonnegative finite number"
{:type ::invalid-sleep :requested-ms requested-ms})))
(check)
(let [started-ms (now)]
(-> (sleep requested-ms)
(.then (fn [_]
(check)
(let [ended-ms (now)]
(-> session
(assoc :active-end-ms ended-ms)
(update :authored-sleeps conj
{:requested-ms requested-ms
:actual-ms (- ended-ms started-ms)})))))
(.catch (fn [cause] (throw (partial-error cause evidence))))))))))))
(defn- finish-interaction
"Ends the interaction and resolves to camera durations and recorded render calls.
Errors retain completed previews and pauses under `:partial`."
[{:keys [now check set-view-end] :as hooks}
{:keys [settle-ms started-ms cached-slices last-input-ms active-end-ms authored-sleeps]}]
(check)
(let [finalization-start (now)
t0 (now)]
(set-view-end)
(let [set-view-end-ms (- (now) t0)]
(-> (drain hooks
{:flags wasm/RENDER_FLAG_SYNC_TILES
:origin finalization-start
:immediate true})
(.then
(fn [{:keys [slices end-ms viewport-ready-ms full-ms]}]
{:cached-slices cached-slices
:slices slices
:end-ms end-ms
:interact-ms (- end-ms started-ms)
:active-ms (- active-end-ms started-ms)
:authored-sleeps authored-sleeps
:settling-requested-ms settle-ms
:settling-actual-ms (- finalization-start active-end-ms)
:set-view-end-ms set-view-end-ms
:time-to-viewport-ready-ms viewport-ready-ms
:time-to-full-ms full-ms
:last-input-to-full-ms (- end-ms last-input-ms)}))
(.catch (fn [cause]
(throw (partial-error cause {:cached-slices cached-slices
:authored-sleeps authored-sleeps}))))))))
(defn finish-camera!
"Waits for settling, ends the interaction and renders until Full.
Resolves to cached previews, traces of final render calls, pauses and measured
camera durations. `:end-ms` is the clock reading at the final renderer return.
Errors retain completed previews and pauses under `:partial`."
[session-p]
(-> (js/Promise.resolve session-p)
(.then (fn [{:keys [hooks settle-ms] :as session}]
(let [evidence (select-keys session [:cached-slices :authored-sleeps])]
(resolve-or-reject-with cause
(partial-error cause evidence)
((:check hooks))
(-> ((:sleep hooks) settle-ms)
(.then (fn [_] (finish-interaction hooks session)))
(.catch (fn [cause] (throw (partial-error cause evidence)))))))))))

View File

@ -0,0 +1,59 @@
;; 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 benches.render-wasm.scenes.masks
"Fixed grid of masked groups with circle masks and ordered descendants."
(:require
[benches.render-wasm.builder :as sb]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.runtime.camera :as camera]))
(def ^:const ^:private default-params
{:width 1920 :height 1080})
(def ^:const ^:private schema:params
[:map {:closed true}
[:width [:int {:min 1920}]]
[:height [:int {:min 1080}]]])
(decl/defscene :masks
{:description "Twenty-four circle masks with ordered striped descendants"
:params-schema schema:params}
[params]
(let [params (merge default-params params)]
(sb/scene {:seed (:seed params)
:root {:x 0 :y 0 :width (:width params) :height (:height params)}}
(doseq [i (range 24)]
(let [x (+ 25 (* 310 (mod i 6)))
y (+ 25 (* 255 (quot i 6)))]
(sb/group [:mask i] {:masked-group true}
(sb/circle {:x x :y y :width 180 :height 180
:fills [{:fill-color "#ffffff"}]})
(doseq [j (range 8)]
(sb/rect {:x (+ x (* 28 j) -22)
:y (- y 12)
:width 25
:height 205
:fills [{:fill-color (if (even? j) "#1254b8" "#ff8b38")}]}))))))))
(def ^:const ^:private base-view {:scale 1 :x 0 :y 0})
(decl/defcase :masks/load
{:params default-params :view base-view :context :fresh :preparation {:render :none}}
[rtx]
(camera/load* rtx))
(decl/defcase :masks/pan
{:params default-params :view base-view :context :reuse
:reset #(camera/restore % (get-in % [:case :view]))}
[rtx]
(camera/pan* rtx {:steps 20 :dx 120 :dy 40 :settle-ms 100}))
(decl/defcase :masks/zoom
{:params default-params :view base-view :context :reuse
:reset #(camera/restore % (get-in % [:case :view]))}
[rtx]
(camera/zoom* rtx {:steps 20 :factor 1.6 :settle-ms 100}))

View File

@ -0,0 +1,78 @@
;; 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 benches.render-wasm.scenes.paths
"Curved paths"
(:require
[app.common.schema :as sm]
[app.common.types.path :as path]
[benches.render-wasm.builder :as sb]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.random :refer [rng-int]]
[benches.render-wasm.runtime.camera :as camera]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Config
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:const ^:private default-params
{:count 1000 :width 1920 :height 1080})
(def ^:const ^:private schema:params
[:map {:closed true}
[:count [:int {:min 1 :max 100000}]]
[:width ::sm/positive-safe-number]
[:height ::sm/positive-safe-number]])
(def ^:const ^:private base-view {:scale 1 :x 0 :y 0})
(defn- curved-content
[{:keys [width height]}]
(fn [rng]
(let [x (rng-int rng 0 width)
y (rng-int rng 0 height)
w (rng-int rng 20 100)
h (rng-int rng 20 100)]
(path/from-plain
[{:command :move-to :params {:x x :y y}}
{:command :curve-to
:params {:c1x (+ x w) :c1y y
:c2x (+ x w) :c2y (+ y h)
:x (+ x w) :y (+ y h)}}
{:command :line-to :params {:x x :y (+ y h)}}
{:command :close-path}]))))
(decl/defscene :paths
{:description "Curved paths"
:params-schema schema:params}
[params]
(let [params (merge default-params params)]
(sb/scene {:seed (:seed params)
:root {:x 0 :y 0 :width (:width params) :height (:height params)}
:defaults {:path {:content (curved-content params)
:fills (sb/gen-vector (sb/gen-fill))
:strokes (sb/gen-vector (sb/gen-stroke 3))}}}
(doseq [_ (range (:count params))]
(sb/path)))))
(decl/defcase :paths/load
{:params default-params :view base-view :context :fresh :preparation {:render :none}}
[rtx]
(camera/load* rtx))
(decl/defcase :paths/pan
{:params default-params :view base-view :context :reuse
:reset #(camera/restore % (get-in % [:case :view]))}
[rtx]
(camera/pan* rtx {:steps 20 :dx 160 :dy 40 :settle-ms 100}))
(decl/defcase :paths/zoom
{:params default-params :view base-view :context :reuse
:reset #(camera/restore % (get-in % [:case :view]))}
[rtx]
(camera/zoom* rtx {:steps 20 :factor 1.5 :settle-ms 100}))

View File

@ -0,0 +1,68 @@
;; 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 benches.render-wasm.scenes.plus
"Plus signs made of two open line subpaths"
(:require
[app.common.types.path :as path]
[benches.render-wasm.builder :as sb]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.random :refer [rng-int]]
[benches.render-wasm.runtime.camera :as camera]))
(def ^:private default-params
{:count 1000 :width 1920 :height 1080})
(def ^:private schema:params
[:map {:closed true}
[:count [:int {:min 1 :max 100000}]]
[:width [:int {:min 100}]]
[:height [:int {:min 100}]]])
(defn- plus-content
[{:keys [width height]}]
(fn [rng]
(let [x (rng-int rng 20 width)
y (rng-int rng 20 height)
r (rng-int rng 10 40)]
(path/from-plain
[{:command :move-to :params {:x (- x r) :y y}}
{:command :line-to :params {:x (+ x r) :y y}}
{:command :move-to :params {:x x :y (- y r)}}
{:command :line-to :params {:x x :y (+ y r)}}]))))
(decl/defscene :plus
{:description "Plus signs made of two open line subpaths"
:params-schema schema:params}
[params]
(let [params (merge default-params params)]
(sb/scene {:seed (:seed params)
:root {:x 0 :y 0 :width (:width params) :height (:height params)}
:defaults {:path {:content (plus-content params)
:fills []
:strokes (sb/gen-vector (sb/gen-stroke 4))}}}
(doseq [_ (range (:count params))]
(sb/path)))))
(def ^:private base-view {:scale 1 :x 0 :y 0})
(decl/defcase :plus/load
{:params default-params :view base-view :context :fresh :preparation {:render :none}}
[rtx]
(camera/load* rtx))
(decl/defcase :plus/pan
{:params default-params :view base-view :context :reuse
:reset #(camera/restore % (get-in % [:case :view]))}
[rtx]
(camera/pan* rtx {:steps 20 :dx 140 :dy 60 :settle-ms 100}))
(decl/defcase :plus/zoom
{:params default-params :view base-view :context :reuse
:reset #(camera/restore % (get-in % [:case :view]))}
[rtx]
(camera/zoom* rtx {:steps 20 :factor 1.4 :settle-ms 100}))

View File

@ -0,0 +1,118 @@
;; 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 benches.render-wasm.scenes.rects
"Rectangle scene, the smallest benchmark workload.
Distributes rectangles over the canvas with varied sizes, translucent
fills and centered strokes, all drawn from the scope's seeded generator.
The `defscene` body produces the scene snapshot; `defcase` declares
the scene and its standard cases. The three cases share parameters, so
they render the same scene."
(:require
[app.common.schema :as sm]
[benches.render-wasm.builder :as sb]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.runtime.camera :as camera]))
;; This namespace is also loaded by Node discovery. Keep operation helpers
;; portable: browser API/helper imports belong in browser adapters supplied
;; through rtx, not in this require list or its transitive dependencies.
;; See declarations' dependency contract before adding a capability.
(def ^:const ^:private default-params
{:count 1000
:width 1920
:height 1080
:min-size 20
:max-size 100})
(def ^:private schema:params
"Workload parameters. Sizes are finite. `:min-size` > `:max-size` is rejected"
[:and
[:map {:closed true}
[:count [:int {:min 1 :max 100000}]]
[:width ::sm/positive-safe-number]
[:height ::sm/positive-safe-number]
[:min-size ::sm/positive-safe-number]
[:max-size ::sm/positive-safe-number]]
[:fn {:error/message "min-size exceeds max-size"}
(fn [{:keys [min-size max-size]}]
(<= min-size max-size))]])
(defn- rect-defaults
"Attribute generators for one rectangle of the workload."
[{:keys [width height min-size max-size]}]
{:x (sb/gen-int 0 width)
:y (sb/gen-int 0 height)
:width (sb/gen-int min-size max-size)
:height (sb/gen-int min-size max-size)
:fills (sb/gen-vector (sb/gen-fill))
:strokes (sb/gen-vector (sb/gen-stroke 10))
:r1 (sb/gen-int 0 24)
:r2 (sb/gen-int 0 24)
:r3 (sb/gen-int 0 24)
:r4 (sb/gen-int 0 24)})
(decl/defscene :rects
{:description "Seeded rectangles with translucent fills and centered strokes"
:params-schema schema:params}
[params]
(let [params (merge default-params params)]
(sb/scene {:seed (:seed params)
:root {:x 0
:y 0
:width (:width params)
:height (:height params)}
:defaults {:rect (rect-defaults params)}}
(doseq [_ (range (:count params))]
(sb/rect)))))
(def ^:private base-view
{:scale 1 :x 0 :y 0})
(decl/defcase :rects/load
{:params default-params
:view base-view
:context :fresh
:preparation {:render :none}}
[rtx]
(camera/load* rtx))
(decl/defcase :rects/pan
{:params default-params
:view base-view
:context :reuse
:reset #(camera/restore % (get-in % [:case :view]))}
[rtx]
(camera/pan* rtx {:steps 20 :dx 200 :dy 0 :settle-ms 100}))
(decl/defcase :rects/zoom
{:params default-params
:view base-view
:context :reuse
:reset #(camera/restore % (get-in % [:case :view]))}
[rtx]
(camera/zoom* rtx {:steps 20 :factor 1.5 :settle-ms 100}))
(decl/defcase :rects/blocking-full
{:params default-params
:view base-view
:context :fresh
:preparation {:render :none}}
[rtx]
(camera/render-sync* rtx))
(decl/defcase :rects/mixed-pans
{:params default-params
:view base-view
:context :reuse
:reset #(camera/restore* % (get-in % [:case :view]))}
[rtx]
(-> rtx
(camera/pan* {:dx 120 :dy 0 :steps 12 :settle-ms 50})
(camera/pan {:dx 0 :dy 80 :steps 8 :settle-ms 50})
(camera/pan* {:dx -120 :dy 0 :steps 12 :settle-ms 50})))

View File

@ -0,0 +1,203 @@
;; 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 benches.render-wasm.snapshot
"Utilities shared by generated and saved-page scenes.
A scene snapshot is
`{:objects {uuid shape ...} :refs {label uuid ...}}`.
- `:objects` is a Penpot page object map.
- `:refs` is a snapshot-local lookup from custom labels to object uuids.
A scene snapshot is just static data. It is independent of camera, operations,
WASM buffers and renderer calls, and it never mutates after upload.
In order for benchmark scenes to follow editor semantics faithfully,
validation uses penpot's facilities. We do perform a manual check for
reachability from the root here because neither the editor validator nor file
loading guarantees it.
Authoring and scoped construction are ticket17; containers ticket18/19;
saved-page loading ticket21.
Which shape types may own children is container semantics owned by
ticket18/19; this contract only checks hierarchy consistency."
(:require
[app.common.files.helpers :as cfh]
[app.common.files.validate :as cfv]
[app.common.geom.shapes.tree-seq :as gts]
[app.common.schema :as sm]
[app.common.types.page :as ctp]
[app.common.uuid :as uuid]))
(def schema:snapshot
"Envelope schema for a scene snapshot."
[:map {:title "RendererBenchmarkSceneSnapshot"}
[:objects ctp/schema:objects]
[:refs [:map-of :any ::sm/uuid]]])
(defn- fail!
[hint data]
(throw (ex-info (str "Invalid scene snapshot: " hint)
(merge {:type ::invalid-snapshot
:hint hint}
data))))
(defn- check-schema!
[instance]
(when-not (sm/validate schema:snapshot instance)
(fail! "instance does not match the scene snapshot schema"
{::sm/explain (sm/explain schema:snapshot instance)})))
(defn- check-root!
[instance]
(let [root (get-in instance [:objects uuid/zero])]
(when-not (and (some? root) (cfh/root? root))
(fail! "root object must be a frame at uuid/zero"
{:id uuid/zero}))
(when-not (= uuid/zero (:parent-id root))
(fail! "root object :parent-id must be uuid/zero"
{:id uuid/zero
:parent-id (:parent-id root)}))
(when-not (= uuid/zero (:frame-id root))
(fail! "root object :frame-id must be uuid/zero"
{:id uuid/zero
:frame-id (:frame-id root)}))))
(defn- check-object-ids!
[objects]
(doseq [[id shape] objects]
(when-not (= id (:id shape))
(fail! "object key does not match the shape :id"
{:key id
:shape-id (:id shape)}))))
(defn- check-child-links!
"Guard the reachability walk against malformed child links.
`objects` is an object map keyed by id. A `shape` can have children
in `:shapes`. When present, `:shapes` must be a vector, and each listed
child that exists must point back to the listing shape."
[objects]
(doseq [[id shape] objects
:let [children (:shapes shape)]]
(when (some? children)
(when-not (vector? children)
(fail! "shape :shapes must be a vector"
{:id id}))
(doseq [child-id children
:let [child (get objects child-id)]
:when (some? child)]
(when-not (= id (:parent-id child))
(fail! "shape :shapes lists a child with a different :parent-id"
{:id id
:child child-id}))))))
(defn- check-reachability!
"Check the root graph through the cycle-safe descendant walk.
Runs `check-child-links!` first, which keeps the walk linear: one parent
per child and vector `:shapes` mean the walk cannot fan out over shared
children or loop on a cycle.
Detects missing child ids, objects reachable more than once (shared
children or cycles), detached subtrees and a root listed as its own
descendant. Runs before the production validator, whose walk follows
`:shapes` links and would loop on a reachable cycle."
[objects]
(check-child-links! objects)
(let [ids (cfh/get-children-ids objects uuid/zero)
seen (set ids)]
(when (contains? seen uuid/zero)
(fail! "root must not be a descendant of itself"
{:id uuid/zero}))
(doseq [id ids]
(when-not (contains? objects id)
(fail! "shape :shapes references a missing object"
{:child id})))
(when-let [dup (first (for [[id n] (frequencies ids) :when (> n 1)] id))]
(fail! "an object is reachable more than once"
{:id dup}))
(doseq [id (keys objects)
:when (not= id uuid/zero)]
(when-not (contains? seen id)
(fail! "object is not reachable from the root"
{:id id})))))
;; Captured-page policy (tickets 02/26; implementation pending): extraction
;; maps canonical component/detach-shape over every selected-page shape,
;; without resolving component libraries, then rejects remaining unsupported
;; references/resources before validation. Warn and record when detachment
;; changes shapes; do not carry document context or use editor detach flows.
;; Thus the empty file/libraries below describe a standalone snapshot, not
;; support for arbitrary component-bearing pages. Move reusable checks to
;; shared .cljc code for JVM extraction and CLJS loading; do not copy them.
(defn- check-referential-integrity!
"Run the production referential validation over the reachable graph.
`cfv/validate-shape` checks parent/child links, frame ids and geometry
with editor semantics. The file and page only carry the uuids the
validation error schema requires; scenes contain no component shapes.
Unreachable objects never reach this walk; `check-reachability!` owns
them."
[instance]
(let [file {:id uuid/zero :data {}}
page {:id uuid/zero :objects (:objects instance)}
errors (cfv/validate-shape uuid/zero file page {})]
(when-let [{:keys [hint] :as error} (first errors)]
(fail! hint (select-keys error [:code :shape-id :args])))))
(defn- check-refs!
[instance]
(let [objects (:objects instance)]
(doseq [[label target] (:refs instance)]
(when-not (contains? objects target)
(fail! "ref target is not present in :objects"
{:label label
:target target})))))
(defn validate!
"Validates a scene snapshot and returns it unchanged.
Throws `ex-info` with `{:type ::invalid-snapshot :hint ...}` when the
snapshot is invalid. Schema failures also carry `::sm/explain`.
Explicit `throw`s are used instead of assertions so the checks survive
elided assertions."
[instance]
(check-schema! instance)
(let [objects (:objects instance)]
(check-root! instance)
(check-object-ids! objects)
;; Reachability must run before the production validator, because that walk
;; follows `:shapes` links and would not terminate on a reachable cycle
(check-reachability! objects)
(check-referential-integrity! instance)
(check-refs! instance))
instance)
(defn upload-order
"Returns the instance objects as a vector in parent-before-child order.
First root, then `:shapes` in order.
Note that upload order carries no renderer semantics because a shape's child
list is uploaded from its own `:shapes` vector.
Assumes a validated instance (every child id exists)."
[instance]
(vec (gts/get-children-seq uuid/zero (:objects instance))))
(defn ref-id
"Resolves an author label to the referenced object uuid, or nil."
[instance label]
(get-in instance [:refs label]))

View File

@ -175,4 +175,92 @@
{:compiler-options
{:fn-invoke-direct true
:elide-asserts true
:anon-fn-naming-policy :off}}}}}
:anon-fn-naming-policy :off}}}
:performance-tests
{:target :esm
:output-dir "target/performance-tests"
:runtime :custom
:js-options {:js-provider :import}
:modules
{:test {:init-fn performance-tests.render-wasm.runner/init
:prepend-js ";if (typeof globalThis.navigator?.userAgent === 'undefined') { globalThis.navigator = {userAgent: ''}; };"}}
:release
{:compiler-options
{:fn-invoke-direct true
:elide-asserts true
:anon-fn-naming-policy :off}}}
:bench-render-wasm-runner
{:target :node-script
:output-to "target/performance/render-wasm/runner.cjs"
:output-dir "target/performance/render-wasm/runner/"
:main benches.render-wasm.node.cli.main/-main
:devtools {:autoload false}
:compiler-options
{:output-feature-set :es2020
:output-wrapper false
:warnings {:fn-deprecated false}}
:release
{:compiler-options
{:fn-invoke-direct true
:elide-asserts true
:anon-fn-naming-policy :off}}}
:bench-render-wasm-commands
{:target :node-library
:output-to "target/performance/render-wasm/commands.cjs"
:output-dir "target/performance/render-wasm/commands/"
:exports {:execute benches.render-wasm.node.cli.deferred/execute}
:devtools {:autoload false}
:compiler-options
{:output-feature-set :es2020
:output-wrapper false
:warnings {:fn-deprecated false}}
:release
{:compiler-options
{:fn-invoke-direct true
:elide-asserts true
:anon-fn-naming-policy :off}}}
:bench-render-wasm-browser
{:target :esm
:output-dir #shadow/env ["RENDER_BENCH_BROWSER_DIR" :default "target/performance/render-wasm/browser/"]
:closure-defines {benches.render-wasm.identity/build-id #shadow/env ["RENDER_BENCH_BUILD_ID" :default "unprepared"]}
;; Bundle npm dependencies for the dedicated static host.
:js-options {:js-provider :shadow}
:modules
{:browser
{:exports {default benches.render-wasm.browser.bridge/bridge}}}
:compiler-options
{:output-feature-set :es2020
:output-wrapper false
:warnings {:fn-deprecated false}}
:release
{:compiler-options
{:fn-invoke-direct true
:elide-asserts true
:anon-fn-naming-policy :off}}}
:bench-render-wasm-catalog
{:target :node-library
:output-to #shadow/env ["RENDER_BENCH_CASES_PATH" :default "target/performance/render-wasm/cases.cjs"]
:output-dir "target/performance/render-wasm/catalog/"
:exports {:collectCases benches.render-wasm.node.catalog.entry/collect-cases
:buildIdentity benches.render-wasm.node.catalog.entry/build-identity}
:closure-defines {benches.render-wasm.identity/build-id #shadow/env ["RENDER_BENCH_BUILD_ID" :default "unprepared"]}
:compiler-options {:output-feature-set :es2020
:warnings {:fn-deprecated false}}
:release {:compiler-options {:fn-invoke-direct true
:elide-asserts true
:anon-fn-naming-policy :off}}}}}

View File

@ -92,15 +92,6 @@
path? (conj [:path id (:content shape)])))))
cases))
(t/deftest tail-cases-flags-are-consistent
(doseq [{:keys [shape tail? svg? path?]} tail-cases]
(t/is (boolean? tail?) "tail? is a boolean")
(t/is (= tail? (boolean (or svg? path?))) "tail? matches the write flags")
(t/is (= svg? (some? (:svg-attrs shape))) "svg? matches the shape")
(t/is (= path? (boolean (and (contains? #{:path :bool} (:type shape))
(some? (:content shape)))))
"path? matches the shape")))
(t/deftest needs-shape-tail-matches-spec-table
(doseq [{:keys [shape tail?]} tail-cases]
(t/is (= tail? (serialize-shape/needs-shape-tail? shape))

View File

@ -0,0 +1,566 @@
;; 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.artifacts-test
(:require
["fs" :as fs]
["os" :as os]
["path" :as path]
["worker_threads" :as workers]
[app.common.transit :as transit]
[benches.render-wasm.codec :as codec]
[benches.render-wasm.identity :as identity]
[benches.render-wasm.node.catalog.loader :as catalog]
[benches.render-wasm.node.storage.artifacts :as artifacts]
[cljs.test :as t :include-macros true]))
(def ^:private temporary-root (atom nil))
(def ^:private selection {:default-features? true :features []})
(def ^:private provenance {:git {:sha "test-revision" :dirty false}})
(t/use-fixtures :each
{:before #(reset! temporary-root
(fs/mkdtempSync (path/join (os/tmpdir) "penpot-render-artifacts-")))
:after #(fs/rmSync @temporary-root #js {:recursive true :force true})})
(defn- create-build
[]
(artifacts/create-build @temporary-root selection provenance))
(defn- write-file
[file content]
(fs/mkdirSync (path/dirname file) #js {:recursive true})
(fs/writeFileSync file content "utf8"))
(defn- complete-files
[build]
(write-file (:cases-path build) "module.exports = {};")
(write-file (path/join (:browser-dir build) "browser.js") "export default {};")
(write-file (path/join (:renderer-dir build) "render-wasm.js") "export default {};")
(fs/writeFileSync (path/join (:renderer-dir build) "render-wasm.wasm")
(js/Buffer.from #js [0 97 115 109 1 0 0 0]))
(write-file (path/join (:renderer-dir build) "shared.js") "export default {};")
(write-file (:index-path build) "<!doctype html><title>selected build</title>")
build)
(defn- ready-build
[]
(let [build (complete-files (create-build))]
(artifacts/write-manifest build (assoc (:manifest build) :status :ready))))
(defn- failure
[f]
(try
(f)
nil
(catch :default error error)))
(t/deftest frontend-root-is-independent-of-caller-cwd
(let [expected (artifacts/frontend-root)
previous (.cwd js/process)]
(try
(.chdir js/process @temporary-root)
(t/is (= expected (artifacts/frontend-root)))
(t/is (.isFile (fs/statSync (path/join expected "deps.edn"))))
(t/is (.isDirectory (fs/statSync
(path/join expected "performance" "benches" "render_wasm"))))
(finally
(.chdir js/process previous)))))
(t/deftest create-build-retains-its-initial-release-evidence
(let [selected {:default-features? false :features ["feature-one" "feature-two"]}
build (artifacts/create-build @temporary-root selected provenance)
manifest (transit/decode-str (fs/readFileSync (:manifest-path build) "utf8"))]
(t/is (re-matches #"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
(:id build)))
(t/is (= (path/join (artifacts/managed-root @temporary-root) "builds" (:id build))
(:dir build)))
(t/is (= (path/join (:dir build) "build.transit") (:manifest-path build)))
(t/is (= (path/join (:dir build) "index.html") (:index-path build)))
(t/is (= manifest (:manifest build)))
(t/is (= #{:type :id :status :created-at :updated-at :functional :provenance}
(set (keys manifest))))
(t/is (= {:type :renderer-build :status :building}
(select-keys manifest [:type :status])))
(t/is (= {:mode :release :default-features? false
:features ["feature-one" "feature-two"] :cljs-profile-marks false}
(:functional manifest)))
(t/is (= provenance (:provenance manifest)))
(t/is (= (:created-at manifest) (.toISOString (js/Date. (:created-at manifest)))))
(t/is (= (:created-at manifest) (:updated-at manifest)))
(t/is (= "" (fs/readFileSync (:log-path build) "utf8")))
(t/is (= (path/join (:dir build) "cases.cjs") (:cases-path build)))
(t/is (.isDirectory (fs/statSync (:browser-dir build))))
(t/is (.isDirectory (fs/statSync (:renderer-dir build))))))
(t/deftest ready-build-loads-all-selected-paths
(let [build (ready-build)
loaded (artifacts/load-build @temporary-root (:id build))]
(t/is (= build loaded))
(t/is (= (path/join (:dir build) "index.html") (:index-path loaded)))))
(t/deftest complete-building-files-can-be-checked-before-publishing-ready
(let [build (complete-files (create-build))]
(t/is (= build (artifacts/check-files build)))
(t/is (= :building (:status (transit/decode-str
(fs/readFileSync (:manifest-path build) "utf8")))))
(t/is (= ::artifacts/not-ready
(:type (ex-data (failure #(artifacts/load-build @temporary-root (:id build)))))))))
(t/deftest incomplete-building-files-are-rejected-before-publishing-ready
(doseq [relative ["cases.cjs" "browser/browser.js" "renderer/render-wasm.js"
"renderer/render-wasm.wasm" "renderer/shared.js" "index.html"]]
(let [build (complete-files (create-build))]
(fs/unlinkSync (path/join (:dir build) relative))
(t/is (some? (failure #(artifacts/check-files build))) relative)
(t/is (= :building (:status (transit/decode-str
(fs/readFileSync (:manifest-path build) "utf8"))))))))
(t/deftest building-and-failed-builds-are-not-loadable-and-retain-evidence
(let [building (create-build)
failed (create-build)
log "renderer compilation failed\n"]
(fs/appendFileSync (:log-path failed) log "utf8")
(artifacts/write-manifest failed (assoc (:manifest failed) :status :failed
:failure {:phase "renderer" :message "compile failed"}))
(doseq [build [building failed]]
(t/is (= ::artifacts/not-ready
(:type (ex-data (failure #(artifacts/load-build @temporary-root (:id build))))))))
(t/is (= :failed (:status (transit/decode-str
(fs/readFileSync (:manifest-path failed) "utf8")))))
(t/is (= log (fs/readFileSync (:log-path failed) "utf8")))
(t/is (fs/existsSync (:dir building)))))
(t/deftest stale-references-cannot-reopen-terminal-builds
(doseq [status [:ready :failed]]
(let [build (create-build)
terminal (artifacts/write-manifest build (assoc (:manifest build) :status status))
saved (fs/readFileSync (:manifest-path build) "utf8")]
(t/is (= terminal (artifacts/write-manifest terminal (:manifest terminal))))
(doseq [next-status [:building :ready :failed]]
(let [attempt (assoc (:manifest terminal) :status next-status :extra "changed")]
(t/is (= ::artifacts/terminal-build
(:type (ex-data (failure #(artifacts/write-manifest build attempt))))))))
(t/is (= saved (fs/readFileSync (:manifest-path build) "utf8"))))))
(t/deftest unencodable-update-keeps-the-last-manifest-and-log
(let [build (create-build)
saved (fs/readFileSync (:manifest-path build) "utf8")]
(fs/appendFileSync (:log-path build) "saved diagnostic\n" "utf8")
(t/is (some? (failure #(artifacts/write-manifest
build (assoc (:manifest build) :callback (fn [] nil))))))
(t/is (= saved (fs/readFileSync (:manifest-path build) "utf8")))
(t/is (= "saved diagnostic\n" (fs/readFileSync (:log-path build) "utf8")))
(t/is (= #{"browser" "renderer" "build.log" "build.transit"}
(set (array-seq (fs/readdirSync (:dir build))))))))
(t/deftest malformed-builds-cannot-replace-saved-manifests
(doseq [side [:current :replacement]]
(let [build (create-build)
valid (:manifest build)
malformed (dissoc valid :functional)
proposed (if (= :replacement side) malformed valid)]
(when (= :current side)
(fs/writeFileSync (:manifest-path build) (codec/encode-str malformed) "utf8"))
(let [saved (fs/readFileSync (:manifest-path build) "utf8")
error (failure #(artifacts/write-manifest build proposed))]
(t/is (= ::artifacts/invalid-manifest (:type (ex-data error))) (name side))
(t/is (= saved (fs/readFileSync (:manifest-path build) "utf8")) (name side))))))
(def ^:private manifest-reader
(str "const {parentPort, workerData} = require('worker_threads');"
"const fs = require('fs'); const stop = new Int32Array(workerData.stop);"
"let reads = 0; const errors = [];"
"function read() { try { JSON.parse(fs.readFileSync(workerData.path, 'utf8'));"
"reads++; } catch (error) { if (errors.length < 5) errors.push(error.message); } }"
"read(); parentPort.postMessage('ready');"
"while (Atomics.load(stop, 0) === 0) read();"
"parentPort.postMessage({reads, errors});"))
(defn- worker-message
[^js worker]
(js/Promise.
(fn [resolve reject]
(letfn [(received [value]
(.removeListener worker "error" failed)
(resolve value))
(failed [error]
(.removeListener worker "message" received)
(reject error))]
(.once worker "message" received)
(.once worker "error" failed)))))
(t/deftest ^:async manifest-replacements-never-expose-a-partial-file
(let [build (create-build)
stop (js/SharedArrayBuffer. 4)
^js worker (workers/Worker. manifest-reader
#js {:eval true
:workerData #js {:path (:manifest-path build) :stop stop}})]
(try
(await (worker-message worker))
(let [result (worker-message worker)]
(dotimes [n 24]
(artifacts/write-manifest
build (assoc (:manifest build) :progress n
:diagnostic (apply str (repeat (+ 8192 (* n 1024)) "evidence ")))))
(js/Atomics.store (js/Int32Array. stop) 0 1)
(let [^js observed (await result)]
(t/is (> (.-reads observed) 1) "a separate thread read during replacement")
(t/is (empty? (array-seq (.-errors observed)))
"every observed manifest remained a complete JSON document")))
(t/is (= 23 (:progress (transit/decode-str
(fs/readFileSync (:manifest-path build) "utf8")))))
(finally
(await (.terminate worker))))))
(t/deftest malformed-manifests-and-incomplete-builds-are-rejected
(doseq [change [{:functional []} {:type :run-record} {:id "another-build"}
{:status :unknown}]]
(let [build (ready-build)]
(fs/writeFileSync (:manifest-path build) (codec/encode-str (merge (:manifest build) change))
"utf8")
(t/is (= ::artifacts/unsupported-manifest
(:type (ex-data (failure #(artifacts/load-build @temporary-root (:id build)))))))))
(doseq [relative ["cases.cjs" "browser/browser.js" "renderer/render-wasm.js"
"renderer/render-wasm.wasm" "renderer/shared.js" "index.html"]]
(let [build (ready-build)]
(fs/unlinkSync (path/join (:dir build) relative))
(t/is (some? (failure #(artifacts/load-build @temporary-root (:id build)))) relative))))
(t/deftest saved-builds-validate-current-shapes
(let [build (ready-build)
manifest (select-keys (:manifest build)
[:type :id :status :created-at :updated-at :functional :provenance])]
(fs/writeFileSync (:manifest-path build) (codec/encode-str manifest) "utf8")
(t/is (= manifest (:manifest (artifacts/load-build @temporary-root (:id build)))))
(fs/writeFileSync (:manifest-path build)
(codec/encode-str (dissoc manifest :functional)) "utf8")
(t/is (= ::artifacts/unsupported-manifest
(:type (ex-data (failure #(artifacts/load-build @temporary-root (:id build)))))))))
(t/deftest build-environment-roundtrips-without-inventing-missing-evidence
(t/is (not (contains? (:manifest (create-build)) :environment)))
(doseq [environment [{} {"CFLAGS" "" "RUSTFLAGS" "-C\u001fopt-level=3"}]]
(let [build (complete-files (create-build))
ready (artifacts/write-manifest build (assoc (:manifest build)
:status :ready :environment environment))
saved (artifacts/load-build @temporary-root (:id ready))]
(t/is (= environment (get-in saved [:manifest :environment]))))))
(t/deftest build-environment-rejects-non-string-keys-and-values
(doseq [environment [nil [] {:CFLAGS ""} {"CFLAGS" nil} {"CFLAGS" 0}]]
(let [build (create-build)
saved (fs/readFileSync (:manifest-path build) "utf8")
cause (failure #(artifacts/write-manifest build (assoc (:manifest build) :environment environment)))]
(t/is (= ::artifacts/invalid-manifest (:type (ex-data cause))))
(t/is (= saved (fs/readFileSync (:manifest-path build) "utf8"))))))
(t/deftest ids-cannot-select-or-delete-outside-paths
(let [build (ready-build)
outside (path/join @temporary-root "outside.txt")]
(write-file outside "keep this")
(doseq [id [nil "" "." ".." "../outside.txt" "safe/../../outside.txt"
"..\\outside.txt" "/tmp/outside" "id%2fother"]]
(t/is (= ::artifacts/invalid-id
(:type (ex-data (failure #(artifacts/load-build @temporary-root id))))))
(t/is (= ::artifacts/invalid-id
(:type (ex-data (failure #(artifacts/remove-builds
@temporary-root [(:id build) id])))))))
(t/is (= build (artifacts/load-build @temporary-root (:id build))))
(t/is (= "keep this" (fs/readFileSync outside "utf8")))))
(t/deftest safe-non-uuid-ids-can-load-existing-builds
(let [build (ready-build)
id "retained-build_1"
dir (path/join (path/dirname (:dir build)) id)]
(fs/renameSync (:dir build) dir)
(fs/writeFileSync (path/join dir "build.transit")
(codec/encode-str (assoc (:manifest build) :id id)) "utf8")
(t/is (= id (:id (artifacts/load-build @temporary-root id))))
(t/is (= dir (:dir (artifacts/load-build @temporary-root id))))))
(t/deftest build-and-artifact-symlinks-cannot-escape-the-managed-root
(let [build (ready-build)
outside (path/join @temporary-root "outside.txt")]
(write-file outside "private")
(fs/unlinkSync (:cases-path build))
(fs/symlinkSync outside (:cases-path build))
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/check-files build))))))
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/load-build @temporary-root (:id build))))))))
(let [build (ready-build)
outside (path/join @temporary-root "outside-build")]
(fs/renameSync (:dir build) outside)
(fs/symlinkSync outside (:dir build) "dir")
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/check-files build))))))
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/load-build @temporary-root (:id build)))))))))
(t/deftest category-purge-unlinks-only-known-outputs-and-keeps-directories
(doseq [category [:builds :runs :all]]
(let [root (fs/mkdtempSync (path/join @temporary-root (str (name category) "-")))
building (complete-files (artifacts/create-build root selection provenance))
failed (artifacts/create-build root selection provenance)
managed (artifacts/managed-root root)
run-dir (path/join managed "runs" "group" "saved-run")
outputs ["build.transit" "build.log" "cases.cjs" "index.html"
"browser/browser.js" "renderer/render-wasm.js"
"renderer/render-wasm.wasm" "renderer/render-wasm.wasm.map"
"renderer/shared.js" "renderer/cargo-build.json"
"renderer/worker/render.js"]
boot (path/join managed "runner.cjs")
outside (path/join root "explicit-output" "run.transit")]
(artifacts/write-manifest failed (assoc (:manifest failed) :status :failed))
(doseq [file outputs]
(write-file (path/join (:dir building) file) "build output"))
(doseq [file ["run.log" "run.transit" "shot.png" "SHOT.PNG"]]
(write-file (path/join run-dir file) "run output"))
(doseq [dir ["run.log" "run.transit" "directory.PNG"]]
(write-file (path/join managed "runs" dir "keep.txt") "keep directory"))
(write-file (path/join managed "runs" "no-marker" "only.PNG") "screenshot")
(write-file (path/join run-dir "record.transit") "unrelated run file")
(write-file (path/join (:dir building) "custom" "build.log") "unrelated build file")
(write-file boot "boot executable")
(write-file outside "unmanaged")
(artifacts/purge root category)
(doseq [dir [(:dir building) (:dir failed) (:browser-dir building)
(:renderer-dir building) run-dir]]
(t/is (.isDirectory (fs/statSync dir))))
(doseq [file outputs]
(t/is (= (= category :runs) (fs/existsSync (path/join (:dir building) file)))))
(t/is (= (= category :runs) (fs/existsSync (:manifest-path failed))))
(doseq [file ["run.log" "run.transit" "shot.png" "SHOT.PNG"]]
(t/is (= (= category :builds) (fs/existsSync (path/join run-dir file)))))
(t/is (= (= category :builds)
(fs/existsSync (path/join managed "runs" "no-marker" "only.PNG"))))
(doseq [dir ["run.log" "run.transit" "directory.PNG"]]
(t/is (fs/existsSync (path/join managed "runs" dir "keep.txt"))))
(t/is (fs/existsSync (path/join run-dir "record.transit")))
(t/is (fs/existsSync (path/join (:dir building) "custom" "build.log")))
(t/is (= "boot executable" (fs/readFileSync boot "utf8")))
(t/is (= "unmanaged" (fs/readFileSync outside "utf8")))
(artifacts/purge root category))))
(t/deftest comparison-purge-unlinks-only-feature-comparison-evidence
(doseq [category [:comparisons :all]]
(let [root (fs/mkdtempSync (path/join @temporary-root (str (name category) "-")))
managed (artifacts/managed-root root)
build-file (path/join managed "builds" "saved" "build.transit")
ab-dir (path/join managed "feature-comparisons" "ab-1")
config-dir (path/join ab-dir "configs" "00")
edge-dir (path/join ab-dir "comparisons")
run-dir (path/join managed "runs" "saved")
outside (path/join root "unmanaged" "feature-comparison.transit")
outputs [["feature-comparison.log" ab-dir]
["feature-comparison.transit" ab-dir]
["run.log" config-dir]
["run.transit" config-dir]
["shot.PNG" config-dir]
["00__10.transit" edge-dir]]
kept [["notes.txt" ab-dir]
["keep.txt" config-dir]
["keep.txt" edge-dir]]]
(doseq [[file dir] outputs
:let [path (path/join dir file)]]
(write-file path "comparison output"))
(doseq [[file dir] kept]
(write-file (path/join dir file) "unrelated"))
(write-file (path/join managed "feature-comparisons" "keep.txt")
"unrelated root")
(write-file (path/join managed "feature-comparisons" "ab-1" "directory.PNG"
"keep.txt")
"keep directory")
(write-file build-file "retained build")
(write-file (path/join run-dir "run.transit") "standalone run")
(write-file outside "unmanaged")
(artifacts/purge root category)
(doseq [[file dir] outputs]
(t/is (not (fs/existsSync (path/join dir file))) (str category " " file)))
(doseq [[file dir] kept]
(t/is (fs/existsSync (path/join dir file)) (str category " " file)))
(t/is (= "unrelated root"
(fs/readFileSync (path/join managed "feature-comparisons" "keep.txt")
"utf8")))
(t/is (fs/existsSync (path/join ab-dir "directory.PNG" "keep.txt")))
(t/is (.isDirectory (fs/statSync ab-dir)))
(t/is (.isDirectory (fs/statSync config-dir)))
(t/is (.isDirectory (fs/statSync edge-dir)))
(t/is (= (= category :comparisons)
(fs/existsSync (path/join run-dir "run.transit")))
(str category " standalone run"))
(t/is (= (= category :comparisons) (fs/existsSync build-file))
(str category " retained build"))
(when (= category :comparisons)
(t/is (= "standalone run"
(fs/readFileSync (path/join run-dir "run.transit") "utf8")))
(t/is (= "retained build" (fs/readFileSync build-file "utf8"))))
(t/is (= "unmanaged" (fs/readFileSync outside "utf8"))))))
(t/deftest purge-follows-category-and-nested-directory-symlinks-and-stops-cycles
(let [managed (artifacts/managed-root @temporary-root)]
(fs/mkdirSync managed #js {:recursive true})
(doseq [category [:builds :runs]]
(let [outside (path/join @temporary-root (str "outside-" (name category)))
detached (path/join @temporary-root (str "detached-" (name category)))
output (if (= category :builds) "build.transit" "run.transit")
saved (path/join outside "keep.txt")
link (path/join managed (name category))]
(write-file saved "keep symlink target")
(write-file (path/join outside "saved" output) "output, not a parsed record")
(write-file (path/join detached output) "linked output")
(write-file (path/join detached "keep.txt") "unrelated")
(fs/symlinkSync outside (path/join outside "saved" "cycle") "dir")
(fs/symlinkSync detached (path/join outside "linked") "dir")
(fs/symlinkSync outside link "dir")
(artifacts/purge @temporary-root category)
(t/is (.isSymbolicLink (fs/lstatSync link)))
(t/is (.isSymbolicLink (fs/lstatSync (path/join outside "saved" "cycle"))))
(t/is (.isSymbolicLink (fs/lstatSync (path/join outside "linked"))))
(t/is (not (fs/existsSync (path/join outside "saved" output))))
(t/is (not (fs/existsSync (path/join detached output))))
(t/is (fs/existsSync (path/join detached "keep.txt")))
(t/is (= "keep symlink target" (fs/readFileSync saved "utf8")))))))
(t/deftest managed-root-symlink-cannot-create-or-delete-outside-the-frontend
(let [root (path/join @temporary-root "frontend")
managed (path/join root "target" "performance" "render-wasm")
outside (path/join @temporary-root "outside-managed")
build-log (path/join outside "builds" "retained" "build.log")
run-file (path/join outside "runs" "retained" "record.transit")]
(write-file build-log "outside build")
(write-file run-file "outside run")
(fs/mkdirSync (path/dirname managed) #js {:recursive true})
(fs/symlinkSync outside managed "dir")
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/managed-root root))))))
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/create-build root selection provenance))))))
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/purge root :all))))))
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/remove-builds root ["retained"]))))))
(t/is (= "outside build" (fs/readFileSync build-log "utf8")))
(t/is (= "outside run" (fs/readFileSync run-file "utf8")))
(t/is (= #{"retained"} (set (array-seq (fs/readdirSync (path/join outside "builds"))))))))
(t/deftest escaping-target-ancestor-is-rejected-before-the-managed-path-exists
(let [root (path/join @temporary-root "frontend-ancestor")
outside (path/join @temporary-root "outside-target")]
(fs/mkdirSync root)
(fs/mkdirSync outside)
(fs/symlinkSync outside (path/join root "target") "dir")
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/create-build root selection provenance))))))
(t/is (empty? (array-seq (fs/readdirSync outside))))))
(t/deftest create-build-rejects-an-escaping-builds-parent-before-writing-files
(let [managed (artifacts/managed-root @temporary-root)
outside (path/join @temporary-root "outside-builds")
saved (path/join outside "retained" "build.log")]
(write-file saved "keep external build")
(fs/mkdirSync managed #js {:recursive true})
(fs/symlinkSync outside (path/join managed "builds") "dir")
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/create-build
@temporary-root selection provenance))))))
(t/is (= "keep external build" (fs/readFileSync saved "utf8")))
(t/is (= #{"retained"} (set (array-seq (fs/readdirSync outside)))))))
(t/deftest remove-builds-cleans-selected-ids-and-keeps-directories-and-unrelated-files
(let [removed (ready-build)
kept (ready-build)
managed (artifacts/managed-root @temporary-root)
run (path/join managed "runs" "saved" "run.transit")
outside (path/join @temporary-root "outside" "keep.txt")
browser (path/join @temporary-root "linked-browser")
link (path/join managed "builds" "outside-link")]
(write-file run "keep run")
(write-file outside "keep target")
(write-file (path/join (:dir removed) "screenshot.PNG") "keep build screenshot")
(write-file (path/join (path/dirname outside) "build.transit") "linked build output")
(write-file (path/join browser "browser.js") "linked browser output")
(write-file (path/join browser "keep.js") "unrelated")
(fs/symlinkSync browser (path/join (path/dirname outside) "browser") "dir")
(fs/symlinkSync (path/dirname outside) link "dir")
(artifacts/remove-builds @temporary-root [(:id removed) "outside-link" "absent-build"])
(t/is (.isDirectory (fs/statSync (:dir removed))))
(t/is (not (fs/existsSync (:manifest-path removed))))
(t/is (not (fs/existsSync (:cases-path removed))))
(t/is (fs/existsSync (path/join (:dir removed) "screenshot.PNG")))
(t/is (.isSymbolicLink (fs/lstatSync link)))
(t/is (not (fs/existsSync (path/join link "build.transit"))))
(t/is (not (fs/existsSync (path/join browser "browser.js"))))
(t/is (fs/existsSync (path/join browser "keep.js")))
(t/is (= kept (artifacts/load-build @temporary-root (:id kept))))
(t/is (= "keep run" (fs/readFileSync run "utf8")))
(t/is (= "keep target" (fs/readFileSync outside "utf8")))))
(t/deftest inside-builds-resolves-symlink-ancestors-with-missing-output-components
(let [managed (artifacts/managed-root @temporary-root)
builds (path/join managed "builds")
outside (path/join @temporary-root "external-builds")
alias (path/join @temporary-root "build-alias")]
(t/is (artifacts/inside-builds? @temporary-root (path/join builds "future" "run")))
(fs/mkdirSync managed #js {:recursive true})
(fs/mkdirSync outside)
(fs/symlinkSync outside builds "dir")
(fs/symlinkSync builds alias "dir")
(doseq [output [builds outside (path/join builds "new" "run")
(path/join outside "new" "run") (path/join alias "new" "run")]]
(t/is (artifacts/inside-builds? @temporary-root output)))
(t/is (not (artifacts/inside-builds? @temporary-root (str outside "-neighbor/run"))))
(t/is (not (artifacts/inside-builds? @temporary-root (path/join managed "runs" "new"))))))
(defn- descriptor
[id]
{:id id
:scene :artifact-only
:scene-description "A descriptor supplied only by this build"
:scene-seed 42
:params {}
:view {:scale 1 :x 0 :y 0}
:context :fresh
:preparation {}})
(defn- write-catalog
[build loaded-id response]
(write-file
(:cases-path build)
(str "const identity = " (js/JSON.stringify (codec/encode-str {:build-id loaded-id})) ";"
"const response = " (js/JSON.stringify (codec/encode-str response)) ";"
"module.exports = {buildIdentity() {return identity;},"
"collectCases(encodedSelection) {return response;}};")))
(t/deftest catalogs-use-the-selected-commonjs-module-with-isolated-scope
(let [first-build (ready-build)
second-build (ready-build)
first-case (descriptor :artifact-only/first)
second-case (descriptor :artifact-only/second)
selection {:master-seed 42}]
(write-catalog first-build (:id first-build)
{:build-id (:id first-build) :cases [first-case]})
(write-catalog second-build (:id second-build)
{:build-id (:id second-build) :cases [second-case]})
(t/is (= [first-case] (catalog/collect first-build selection)))
(t/is (= [second-case] (catalog/collect second-build selection)))
(t/is (= [first-case] (catalog/collect first-build selection)))))
(t/deftest catalog-rejects-both-loaded-and-response-identity-mismatches
(doseq [boundary [:loaded :response]]
(let [build (ready-build)]
(write-catalog build (if (= boundary :loaded) "wrong-build" (:id build))
{:build-id (if (= boundary :response) "wrong-build" (:id build))
:cases [(descriptor :artifact-only/load)]})
(t/is (= ::identity/mismatch
(:type (ex-data (failure #(catalog/collect build {:master-seed 42})))))))))
(t/deftest catalog-rejects-malformed-descriptors-and-empty-responses
(doseq [cases [[(assoc (descriptor :artifact-only/load) :scene-seed -1)] [] nil]]
(let [build (ready-build)]
(write-catalog build (:id build) {:build-id (:id build) :cases cases})
(t/is (some? (failure #(catalog/collect build {:master-seed 42})))))))

View File

@ -0,0 +1,214 @@
;; 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.boolean-test
(:require
[app.common.geom.point :as gpt]
[app.common.geom.shapes.intersect :as gint]
[app.common.types.path :as path]
[app.common.types.path.segment :as segm]
[app.common.uuid :as uuid]
[benches.render-wasm.builder :as b]
[benches.render-wasm.snapshot :as snapshot]
[cljs.test :as t :include-macros true]
[performance-tests.render-wasm.test-helpers :as helpers]))
(t/deftest difference-of-overlapping-rects-keeps-hierarchy-and-bounds
(let [red {:fill-color "#ff0000" :fill-opacity 1}
blue {:fill-color "#0000ff" :fill-opacity 1}
instance (b/scene {:seed 1}
(b/bool :l-shape {:bool-type :difference :name "L"}
(b/rect {:x 0 :y 0 :width 100 :height 100 :fills [red]})
(b/rect {:x 50 :y 50 :width 100 :height 100 :fills [blue]})))
bool (first (helpers/children-of instance uuid/zero))
children (helpers/children-of instance (:id bool))]
(t/is (= :bool (:type bool)))
(t/is (= :difference (:bool-type bool)))
(t/is (= (:id bool) (snapshot/ref-id instance :l-shape)))
(t/is (= 2 (count children)))
(t/is (= [(:id (first children)) (:id (second children))] (:shapes bool)))
(t/is (= [(:id bool) (:id bool)]
[(:parent-id (first children)) (:parent-id (second children))]))
(t/is (= [uuid/zero uuid/zero]
[(:frame-id (first children)) (:frame-id (second children))]))
(t/is (= [0 0 100 100] (helpers/selrect bool)))))
(t/deftest difference-excludes-points-in-removed-overlap
(let [instance (b/scene {:seed 1}
(b/bool {:bool-type :difference}
(b/rect {:x 0 :y 0 :width 100 :height 100})
(b/rect {:x 50 :y 50 :width 100 :height 100})))
bool (first (helpers/children-of instance uuid/zero))
lines (segm/path->lines bool)]
(t/is (true? (gint/is-point-inside-nonzero? (gpt/point 25 25) lines))
"a point in the kept region stays inside the content")
(t/is (false? (gint/is-point-inside-nonzero? (gpt/point 75 75) lines))
"a point in the subtracted overlap is excluded from the content")))
(t/deftest union-derives-canonical-bounds
(let [instance (b/scene {:seed 1}
(b/bool {:bool-type :union}
(b/rect {:x 0 :y 0 :width 100 :height 100})
(b/rect {:x 50 :y 50 :width 100 :height 100})))
bool (first (helpers/children-of instance uuid/zero))]
(t/is (= :union (:bool-type bool)))
(t/is (= [0 0 150 150] (helpers/selrect bool)))))
(t/deftest difference-picks-the-first-child-as-style-head
(let [red {:fill-color "#ff0000" :fill-opacity 1}
blue {:fill-color "#0000ff" :fill-opacity 1}
blur {:id (uuid/custom 3 3) :type :layer-blur :value 4 :hidden false}
difference (b/scene {:seed 1}
(b/bool {:bool-type :difference}
(b/rect {:x 0 :y 0 :width 10 :height 10
:fills [red] :blur blur})
(b/rect {:x 5 :y 0 :width 10 :height 10
:fills [blue]})))
diff-bool (first (helpers/children-of difference uuid/zero))]
(t/is (= [red] (:fills diff-bool)))
(t/is (= blur (:blur diff-bool)))))
(t/deftest supplied-styles-override-inherited
(let [instance (b/scene {:seed 1}
(b/bool {:bool-type :union :fills []}
(b/rect {:x 0 :y 0 :width 10 :height 10
:fills [{:fill-color "#ff0000" :fill-opacity 1}]})
(b/rect {:x 5 :y 0 :width 10 :height 10
:fills [{:fill-color "#0000ff" :fill-opacity 1}]})))
bool (first (helpers/children-of instance uuid/zero))]
(t/is (= [] (:fills bool)))))
(t/deftest nil-styles-do-not-count-as-supplied
(let [red {:fill-color "#ff0000" :fill-opacity 1}
blue {:fill-color "#0000ff" :fill-opacity 1}
instance (b/scene {:seed 1}
(b/bool {:bool-type :union :fills nil}
(b/rect {:x 0 :y 0 :width 10 :height 10 :fills [red]})
(b/rect {:x 5 :y 0 :width 10 :height 10 :fills [blue]})))
bool (first (helpers/children-of instance uuid/zero))]
(t/is (= [blue] (:fills bool)))))
(t/deftest single-child-bool-uses-the-child-path
(let [instance (b/scene {:seed 1}
(b/bool {:bool-type :difference}
(b/rect {:x 10 :y 20 :width 30 :height 40})))
bool (first (helpers/children-of instance uuid/zero))]
(t/is (= 1 (count (:shapes bool))))
(t/is (= [10 20 30 40] (helpers/selrect bool)))
(t/is (= (:selrect bool) (path/calc-selrect (:content bool))))))
(t/deftest nested-bools-finalize-inside-out
(let [instance (b/scene {:seed 1}
(b/bool {:bool-type :union}
(b/bool {:bool-type :difference}
(b/rect {:x 0 :y 0 :width 100 :height 100})
(b/rect {:x 50 :y 50 :width 100 :height 100}))
(b/rect {:x 200 :y 0 :width 50 :height 50})))
outer (first (helpers/children-of instance uuid/zero))
inner (first (helpers/children-of instance (:id outer)))]
(t/is (= :bool (:type inner)))
(t/is (= [0 0 100 100] (helpers/selrect inner)))
(t/is (= [0 0 250 100] (helpers/selrect outer)))
(t/is (= (:selrect outer) (path/calc-selrect (:content outer))))))
(t/deftest bool-inside-frame-keeps-frame-ids
(let [instance (b/scene {:seed 1}
(b/frame {:x 0 :y 0 :width 200 :height 200}
(b/bool {:bool-type :union}
(b/rect {:x 10 :y 10 :width 10 :height 10})
(b/rect {:x 30 :y 10 :width 10 :height 10}))))
frame (first (helpers/children-of instance uuid/zero))
bool (first (helpers/children-of instance (:id frame)))
rects (helpers/children-of instance (:id bool))]
(t/is (= (:id frame) (:parent-id bool)))
(t/is (= (:id frame) (:frame-id bool)))
(t/is (= [(:id frame) (:id frame)] (mapv :frame-id rects)))
(t/is (= [(:id bool) (:id bool)] (mapv :parent-id rects)))))
(t/deftest bool-inside-group-derives-group-bounds
(let [instance (b/scene {:seed 1}
(b/group {}
(b/bool {:bool-type :union}
(b/rect {:x 10 :y 20 :width 30 :height 40})
(b/rect {:x 50 :y 80 :width 10 :height 10}))))
group (first (helpers/children-of instance uuid/zero))
bool (first (helpers/children-of instance (:id group)))]
(t/is (= :bool (:type bool)))
(t/is (= [10 20 50 70] (helpers/selrect group)))
(t/is (= uuid/zero (:frame-id bool)))))
(t/deftest empty-bool-is-rejected
(let [data (helpers/failure-data #(b/scene {:seed 1} (b/bool {:bool-type :union})))]
(t/is (= ::b/empty-bool (:type data)))))
(t/deftest caught-empty-bool-still-fails-finish
(let [state (helpers/run-scope {:seed 1}
(fn []
(try
(b/bool {:bool-type :union})
(catch :default _ nil))))]
(t/is (= ::b/unfinished-container
(:type (helpers/failure-data #(b/finish! state)))))))
(t/deftest bool-geometry-attrs-are-rejected
(doseq [attrs [{:bool-type :union :x 1}
{:bool-type :union :selrect {:x 0 :y 0 :width 1 :height 1}}
{:bool-type :union :content []}]]
(let [data (helpers/failure-data #(b/scene {:seed 1} (b/bool attrs (b/rect))))]
(t/is (= ::b/bool-geometry (:type data)) (str attrs))
(t/is (contains? #{:x :selrect :content} (:key data)) (str attrs)))))
(t/deftest bool-transform-attrs-are-rejected
(doseq [attrs [{:bool-type :union :rotation 45}
{:bool-type :union :transform "m"}
{:bool-type :union :transform-inverse "m"}
{:bool-type :union :flip-x true}
{:bool-type :union :flip-y true}]]
(let [data (helpers/failure-data #(b/scene {:seed 1} (b/bool attrs (b/rect))))]
(t/is (= ::b/bool-transform (:type data)) (str attrs)))))
(t/deftest bool-type-is-required-and-validated
(t/is (= ::b/bool-type
(:type (helpers/failure-data #(b/scene {:seed 1} (b/bool {:name "B"} (b/rect)))))))
(let [data (helpers/failure-data #(b/scene {:seed 1} (b/bool {:bool-type :merge} (b/rect))))]
(t/is (= ::b/bool-type (:type data)))
(t/is (= :merge (:bool-type data)))
(t/is (= #{:union :difference :intersection :exclude} (set (:supported data))))))
(t/deftest frame-child-is-rejected
(let [data (helpers/failure-data
#(b/scene {:seed 1}
(b/bool {:bool-type :union}
(b/frame {:x 0 :y 0 :width 10 :height 10}
(b/rect)))))]
(t/is (= ::b/frame-in-bool (:type data)))
(t/is (uuid? (:child data)))))
(t/deftest bool-generation-ignores-the-renderer-override
(let [original path/wasm:calc-bool-content]
(try
(set! path/wasm:calc-bool-content
(fn [& _]
(throw (ex-info "renderer override ran during generation"
{:type ::unexpected-renderer-call}))))
(let [instance (b/scene {:seed 1}
(b/bool {:bool-type :difference}
(b/rect {:x 0 :y 0 :width 100 :height 100})
(b/rect {:x 50 :y 50 :width 100 :height 100})))
bool (first (helpers/children-of instance uuid/zero))]
(t/is (= [0 0 100 100] (helpers/selrect bool))))
(finally
(set! path/wasm:calc-bool-content original)))))
(t/deftest non-map-bool-attrs-are-rejected
(t/is (= ::b/invalid-attrs
(:type (helpers/failure-data #(b/scene {:seed 1} (b/bool :hero (b/rect)))))))
(let [state (helpers/run-scope {:seed 1}
(fn []
(try
(b/bool :hero (b/rect))
(catch :default _ nil))))]
(t/is (= 1 (count (get-in @state [:objects uuid/zero :shapes]))))))

View File

@ -0,0 +1,328 @@
;; 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.browser-test
(:require
["fs" :as fs]
["http" :as http]
["net" :as net]
["os" :as os]
["path" :as path]
["playwright" :as playwright]
[app.common.transit :as transit]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.identity :as identity]
[benches.render-wasm.node.process :as process]
[benches.render-wasm.node.runner.browser :as browser]
[benches.render-wasm.node.runner.server :as server]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]))
(def ^:private temporary-root (atom nil))
(def ^:private build-id "owned-browser-fixture")
(def ^:private viewport {:width 320 :height 240 :dpr 1})
(t/use-fixtures :each
{:before #(reset! temporary-root (fs/mkdtempSync (path/join (os/tmpdir) "penpot-browser-test-")))
:after #(fs/rmSync @temporary-root #js {:recursive true :force true})})
(t/deftest gpu-container-selects-the-vulkan-launch-backend
(let [env (.-env js/process)
previous (.-PENPOT_GPU env)]
(try
(doseq [value [nil "0" "true" "1"]]
(if (nil? value)
(js-delete env "PENPOT_GPU")
(set! (.-PENPOT_GPU env) value))
(let [args (set (:args (browser/launch-settings)))]
(t/is (= (= value "1") (contains? args "--use-angle=vulkan")))
(t/is (= (= value "1") (contains? args "--use-gl=angle")))
(t/is (contains? args "--enable-unsafe-swiftshader"))))
(finally
(if (undefined? previous)
(js-delete env "PENPOT_GPU")
(set! (.-PENPOT_GPU env) previous))))))
(defn- ^:async open-host
[actual-id ready?]
(let [identity-text (.stringify js/JSON (transit/encode-str {:build-id actual-id}))]
(fs/writeFileSync (path/join @temporary-root "index.html")
(str "<!doctype html><title>browser ownership</title><script>"
"window.__benchBridge={buildIdentity:()=>" identity-text "};"
"window.__benchReady=" (if ready? "true" "false") ";</script>")))
(await (server/open {:id build-id :dir @temporary-root} {:startup-timeout-ms 3000})))
(defn- request-status
[base-url request-path]
(js/Promise.
(fn [resolve reject]
(let [address (js/URL. base-url)
req (http/get #js {:hostname (.-hostname address) :port (.-port address)
:path request-path :agent false}
(fn [^js response]
(.once response "error" reject)
(.once response "end" #(resolve (.-statusCode response)))
(.resume response)))]
(.once ^js req "error" reject)
(.setTimeout ^js req 3000 #(.destroy ^js req (js/Error. "HTTP test timed out")))))))
(defn- socket-status
[url]
(js/Promise.
(fn [resolve _]
(let [address (js/URL. url)
^js socket (net/createConnection #js {:host (.-hostname address)
:port (js/Number (.-port address))})]
(.once socket "connect" #(do (.destroy socket) (resolve :open)))
(.once socket "error" #(do (.destroy socket) (resolve (.-code ^js %))))
(.setTimeout socket 3000 #(do (.destroy socket) (resolve :timeout)))))))
(defn- ^:async rejected
[promise]
(try
(await promise)
(t/is false "expected browser work to reject")
nil
(catch :default cause cause)))
(t/deftest ^:async page-and-context-close-failures-both-survive
(let [page-error (js/Error. "page disposal failed")
context-error (js/Error. "context disposal failed")
owner {:page #js {:isClosed (constantly false)
:close #(js/Promise.reject page-error)}
:context #js {:close #(js/Promise.reject context-error)}}
cause (await (rejected (browser/close-page owner)))]
(t/is (= [page-error context-error] (mapv :error (errors/get-errors cause))))))
(t/deftest ^:async browser-disposal-collects-failures-and-repeated-close-shares-the-result
(doseq [stop-fails? [false true]]
(let [page-error (js/Error. "page disposal failed")
context-error (js/Error. "context disposal failed")
process-error (js/Error. "process disposal failed")
disconnect-error (js/Error. "CDP disconnect failed")
controller (js/AbortController.)
running (js/Promise.
(fn [_ reject]
(.addEventListener (.-signal controller) "abort"
#(reject (if stop-fails?
process-error
(ex-info "cancelled" {:type :process-cancelled}))))))
owner {:controller controller :process running :shutdown (atom nil)
:user-data-dir @temporary-root
:page #js {:isClosed (constantly false) :close #(js/Promise.reject page-error)}
:context #js {:close #(js/Promise.reject context-error)}
:connection (atom (js/Promise.resolve
#js {:isConnected (constantly true)
:close #(if stop-fails?
(js/Promise.reject disconnect-error)
(js/Promise.resolve nil))}))}
closing (browser/close owner)]
(t/is (identical? closing (browser/close owner)))
(if stop-fails?
(let [cause (await (rejected closing))]
(t/is (= [page-error context-error process-error disconnect-error]
(mapv :error (errors/get-errors cause)))))
(t/is (nil? (await closing)))))))
(t/deftest ^:async unresolved-cancellation-retains-the-browser-profile
(let [profile (path/join @temporary-root "profile.info")
controller (js/AbortController.)
failure (errors/combine
[{:error (ex-info "Command cancelled" {:type :process-cancelled})}
{:error (errors/fatal-error "Command group may still be running"
{:type :process-unresolved :unresolved-process true
:pid 4242 :pgid 4242 :signals ["SIGTERM" "SIGKILL"]} nil)}])
running (js/Promise. (fn [_ reject]
(.addEventListener (.-signal controller) "abort" #(reject failure))))
owner {:controller controller :process running :shutdown (atom nil)
:connection (atom nil) :user-data-dir @temporary-root}]
(fs/writeFileSync profile "retained")
(let [cause (try (await (browser/close owner)) nil (catch :default cause cause))]
(t/is (errors/fatal? cause))
(t/is (process/unresolved? cause))
(t/is (= @temporary-root (:profile-path (errors/evidence :cleanup cause))))
(t/is (= "retained" (when (fs/existsSync profile) (fs/readFileSync profile "utf8")))))))
(t/deftest ^:async process-stop-retains-other-errors-after-cancellation
(let [controller (js/AbortController.)
probe (ex-info "Probe failed" {:type :process-probe})
failure (errors/combine [{:error (ex-info "Command cancelled" {:type :process-cancelled})}
{:error probe}])
running (js/Promise. (fn [_ reject]
(.addEventListener (.-signal controller) "abort" #(reject failure))))
owner {:controller controller :process running :shutdown (atom nil)
:connection (atom nil) :user-data-dir @temporary-root}
cause (try (await (browser/close owner)) nil (catch :default cause cause))]
(t/is (= [probe] (mapv :error (errors/get-errors cause))))))
(t/deftest ^:async browser-startup-retains-a-failure-of-its-disposal
(let [run-process process/run
close browser/close
cleanup-error (js/Error. "startup disposal failed")]
(await
(mock/with-mocks*
{process/run (fn [options]
(run-process (assoc options :command "/bin/sh" :args ["-c" "exit 17"])))
browser/close (fn [owner]
(.then (close owner)
(fn [_] (throw cleanup-error))
(fn [_] (throw cleanup-error))))}
(let [cause (await (rejected (browser/launch {:startup-timeout-ms 10000})))
entries (errors/get-errors cause)]
(t/is (= ::browser/startup-cleanup (:type (ex-data cause))))
(t/is (= ["Command exited unsuccessfully" "startup disposal failed"]
(mapv #(errors/message-of (:error %)) entries))))))))
(defn- ^:async check-stopped
[{:keys [endpoint user-data-dir]}]
(t/is (not (fs/existsSync user-data-dir)) "owned Chromium profile was removed")
(t/is (= "ECONNREFUSED" (await (socket-status endpoint))) "the owned CDP listener is gone"))
(defn- ^:async close-fixture
[owner page host]
(try
(when page
(try
(await (browser/close-page page))
(catch :default _ nil)))
(when owner
(try
(await (browser/close owner))
(catch :default _ nil)))
(finally
(await (server/close host)))))
(t/deftest ^:async startup-process-details-survive-browser-failure-wrappers
(let [run-process process/run]
(doseq [exit-code [0 17]]
(await
(mock/with-mocks*
{process/run (fn [options]
(run-process (assoc options :command "/bin/sh"
:args ["-c" (str "exit " exit-code)])))}
(let [cause (await (rejected (browser/launch {:startup-timeout-ms 10000})))
saved (-> (errors/evidence :browser-startup cause)
transit/encode-str
transit/decode-str)
command (if (zero? exit-code)
(.executablePath (.-chromium playwright))
"/bin/sh")]
(t/is (= {:command command :exit-code exit-code}
(select-keys saved [:command :exit-code])))
(t/is (= ::browser/endpoint-exit (:type (ex-data cause))))
(t/is (= "Chromium exited before its CDP endpoint became ready" (errors/message-of cause)))
(t/is (= 1 (count (errors/get-errors cause))))))))))
(t/deftest ^:async successful-close-removes-the-profile-and-cdp-listener
(let [host (await (open-host build-id true))
owner-ref (atom nil)
page-ref (atom nil)]
(try
(let [owner (await (browser/launch {:startup-timeout-ms 30000}))
_ (reset! owner-ref owner)
page (await (browser/new-page owner host {:viewport viewport
:startup-timeout-ms 30000}))]
(reset! page-ref page)
(t/is (= 200 (await (request-status (:endpoint owner) "/json/version"))))
(t/is (= viewport
(js->clj (await (.evaluate ^js (:page page)
"({width:innerWidth,height:innerHeight,dpr:devicePixelRatio})"))
:keywordize-keys true)))
(await (browser/close-page page))
(reset! page-ref nil)
(await (browser/close owner))
(await (check-stopped owner))
(t/is (not (.isConnected ^js (:browser owner))))
(t/is (= 200 (await (request-status (:base-url host) "/"))) "browser shutdown leaves its host live")
(await (server/close host))
(t/is (= "ECONNREFUSED" (await (socket-status (:base-url host))))))
(finally
(await (close-fixture @owner-ref @page-ref host))))))
(t/deftest ^:async identity-mismatch-rejects-and-the-caller-closes-the-owned-browser
(let [host (await (open-host "wrong-compiled-build" true))
owner-ref (atom nil)]
(try
(let [owner (await (browser/launch {:startup-timeout-ms 30000}))
_ (reset! owner-ref owner)
cause (await (rejected (browser/new-page
owner host
{:viewport viewport :startup-timeout-ms 30000})))
facts (ex-data cause)]
(t/is (= ::identity/mismatch (:type facts)))
(t/is (= "wrong-compiled-build" (:actual-id facts)))
(await (browser/close owner))
(reset! owner-ref nil)
(await (check-stopped owner)))
(finally
(await (close-fixture @owner-ref nil host))))))
(t/deftest ^:async abort-stops-a-synchronously-blocked-page
(let [host (await (open-host build-id true))
controller (js/AbortController.)
owner-ref (atom nil)
page-ref (atom nil)
started-ref (atom nil)
started (js/Promise. (fn [resolve _] (reset! started-ref resolve)))]
(try
(let [owner (await (browser/launch {:startup-timeout-ms 30000
:signal (.-signal controller)}))
_ (reset! owner-ref owner)
page-owner (await (browser/new-page
owner host
{:viewport viewport
:startup-timeout-ms 30000
:signal (.-signal controller)}))
^js page (:page page-owner)]
(reset! page-ref page-owner)
(await (.exposeFunction page "__blockingStarted" (fn [] (@started-ref))))
(let [blocking (.evaluate page "window.__blockingStarted(); while (true) {}")]
(.catch blocking (fn [_] nil))
(await (browser/within (fn [] started)
{:deadline (process/deadline 5000) :phase :test-blocking-start}))
(let [cause (await (rejected (browser/within #(.evaluate page "1")
{:deadline (process/deadline 1000)
:phase :test-blocked-page})))]
(t/is (= ::browser/timeout (:type (ex-data cause)))))
(.abort controller)
(reset! page-ref nil)
(await (browser/close owner))
(reset! owner-ref nil)
(await (check-stopped owner))
(t/is (not (.isConnected ^js (:browser owner))))
(t/is (instance? js/Error (await (rejected blocking))))))
(finally
(.abort controller)
(await (close-fixture @owner-ref @page-ref host))))))
(t/deftest ^:async abort-during-readiness-cleans-the-starting-browser
(let [host (await (open-host build-id false))
controller (js/AbortController.)
owner-ref (atom nil)
requested (js/Promise.
(fn [resolve _]
(.once ^js (:server host) "request" (fn [_] (resolve nil)))))]
(try
(let [owner (await (browser/launch {:startup-timeout-ms 30000
:signal (.-signal controller)}))
_ (reset! owner-ref owner)
page (browser/new-page owner host {:viewport viewport
:startup-timeout-ms 30000
:signal (.-signal controller)})]
(.catch page (fn [_] nil))
(await (browser/within (fn [] requested)
{:deadline (process/deadline 30000) :phase :test-host-request}))
(.abort controller (ex-info "caller cancelled browser readiness" {:type ::caller-abort}))
(let [cause (await (rejected page))]
(t/is (= ::caller-abort (:type (ex-data cause)))))
(await (browser/close owner))
(reset! owner-ref nil)
(await (check-stopped owner)))
(finally
(.abort controller)
(await (close-fixture @owner-ref nil host))))))

View File

@ -0,0 +1,306 @@
;; 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.build-test
"Real preparation failures in temporary frontend/render-wasm checkouts.
A scripted compiler keeps these tests independent of Cargo and Skia while
exercising the process, filesystem and failure-retention boundary. Release
integration checks cover compiler success and Cargo feature forwarding."
(:require
["fs" :as fs]
["os" :as os]
["path" :as path]
[app.common.transit :as transit]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.node.process :as process]
[benches.render-wasm.node.runner.build :as build]
[cljs.test :as t :include-macros true]
[clojure.string :as str]
[frontend-tests.helpers.mock :as mock]))
(def ^:private temporary-root (atom nil))
(defn- frontend-root
[]
(path/join @temporary-root "frontend"))
(defn- renderer-root
[]
(path/join @temporary-root "render-wasm"))
(t/use-fixtures :each
{:before (fn []
(reset! temporary-root
(fs/mkdtempSync (path/join (os/tmpdir) "penpot-render-build-test-")))
(fs/mkdirSync (frontend-root))
(fs/mkdirSync (renderer-root)))
:after #(fs/rmSync @temporary-root #js {:recursive true :force true})})
(def ^:private renderer-wrapper
(str "#!/usr/bin/env node\n"
"const fs = require('node:fs');\n"
"const path = require('node:path');\n"
"const {spawnSync} = require('node:child_process');\n"
"const dir = path.dirname(process.env.FRONTEND_DEST);\n"
"const observation = {\n"
" cwd: process.cwd(),\n"
" manifest: fs.readFileSync(path.join(dir, 'build.transit'), 'utf8'),\n"
" log: fs.readFileSync(path.join(dir, 'build.log'), 'utf8'),\n"
" created: JSON.parse(fs.readFileSync(path.join(dir, 'created.json'), 'utf8'))\n"
"};\n"
"fs.writeFileSync(path.join(dir, 'renderer-observation.json'), JSON.stringify(observation));\n"
"const result = spawnSync('flock', [\n"
" '--exclusive', path.join(__dirname, 'build.lock'),\n"
" process.execPath, path.join(__dirname, 'compiler.cjs')\n"
"], {stdio: 'inherit'});\n"
"if (result.error) throw result.error;\n"
"process.exitCode = result.status === null ? 1 : result.status;\n"))
(def ^:private failing-compiler
(str "const fs = require('node:fs');\n"
"const path = require('node:path');\n"
"fs.writeFileSync(path.join(process.env.FRONTEND_DEST, 'partial.js'), 'partial renderer output\\n');\n"
"process.stdout.write('compiler stdout before failure\\n');\n"
"process.stderr.write('compiler stderr before failure\\n');\n"
"process.exitCode = 23;\n"))
(def ^:private sleeping-compiler
(str "const fs = require('node:fs');\n"
"const path = require('node:path');\n"
"fs.writeFileSync(path.join(process.env.FRONTEND_DEST, 'lock-acquired.txt'), 'held');\n"
"process.stdout.write('compiler lock acquired\\n');\n"
"process.stderr.write('waiting in compiler\\n');\n"
"setInterval(() => {}, 1000);\n"))
(defn- install-renderer
[compiler]
(fs/writeFileSync (path/join (renderer-root) "build") renderer-wrapper #js {:mode 493})
(fs/writeFileSync (path/join (renderer-root) "compiler.cjs") compiler "utf8"))
(defn- read-manifest
[build-ref]
(transit/decode-str (fs/readFileSync (:manifest-path build-ref) "utf8")))
(defn- record-created
[build-ref]
;; These reads must succeed before the executable can read created.json.
(let [observation {:id (:id build-ref)
:manifest (fs/readFileSync (:manifest-path build-ref) "utf8")
:log (fs/readFileSync (:log-path build-ref) "utf8")
:renderer-started? (fs/existsSync (path/join (:dir build-ref) "renderer-observation.json"))}]
(fs/writeFileSync (path/join (:dir build-ref) "created.json")
(js/JSON.stringify (clj->js observation)) "utf8")))
(defn- retained-build
[cause]
(let [id (:build-id (ex-data cause))
dir (path/join (frontend-root) "target" "performance" "render-wasm" "builds" id)]
{:id id :dir dir
:manifest-path (path/join dir "build.transit")
:log-path (path/join dir "build.log")
:renderer-dir (path/join dir "renderer")}))
(defn- ^:async rejected
[promise]
(try
(let [result (await promise)]
(t/is false (str "Expected preparation to fail, got " (pr-str result)))
nil)
(catch :default cause cause)))
(defn- complete
[promise done]
(.then promise
(fn [_] (done))
(fn [cause]
(t/is false (str "Unexpected test error: " cause))
(done))))
(t/deftest ^:async renderer-facts-retain-string-environment-keys-and-missing-evidence
(let [bin (path/join @temporary-root "bin")
original-path (.. js/process -env -PATH)]
(fs/mkdirSync bin)
(fs/writeFileSync (path/join bin "clojure") "#!/bin/sh\nexit 24\n" #js {:mode 493})
(try
(set! (.. js/process -env -PATH) (str bin path/delimiter original-path))
(doseq [environment [::missing {} {"RUSTFLAGS" "-C\u001fopt-level=3" "CFLAGS" ""}]]
(let [facts (cond-> {:features [] :profile {:opt_level "3" :debug_assertions false}}
(not= ::missing environment) (assoc :environment environment))]
(install-renderer
(str "const fs = require('node:fs');\n"
"fs.writeFileSync(process.env.RENDER_WASM_BUILD_INFO, "
(js/JSON.stringify (js/JSON.stringify (clj->js facts))) ");\n"))
(let [cause (await (rejected (build/prepare {:root (frontend-root)
:build-timeout-ms 10000
:on-created record-created})))
manifest (read-manifest (retained-build cause))]
(t/is (= :cljs (:phase (ex-data cause))))
(t/is (= 24 (:exit-code (ex-data (ex-cause cause)))))
(if (= ::missing environment)
(t/is (not (contains? manifest :environment)))
(t/is (= environment (:environment manifest)))))))
(finally
(set! (.. js/process -env -PATH) original-path)))))
(t/deftest ^:async explicit-selection-preserves-disabled-cargo-defaults
(let [bin (path/join @temporary-root "bin")
original-path (.. js/process -env -PATH)]
(fs/mkdirSync bin)
(fs/writeFileSync (path/join bin "clojure") "#!/bin/sh\nexit 24\n" #js {:mode 493})
(try
(set! (.. js/process -env -PATH) (str bin path/delimiter original-path))
(install-renderer
(str "const fs = require('node:fs');\n"
"fs.writeFileSync(process.env.RENDER_WASM_BUILD_INFO, "
(js/JSON.stringify
(js/JSON.stringify
(clj->js {:features ["a"]
:profile {:opt_level "3" :debug_assertions false}})))
");\n"))
(let [cause (await (rejected (build/prepare
{:root (frontend-root)
:selection {:default-features? false :features ["a"]}
:build-timeout-ms 10000
:on-created record-created})))
manifest (read-manifest (retained-build cause))]
(t/is (= :cljs (:phase (ex-data cause))))
(t/is (= {:default-features? false :features ["a"]}
(get-in manifest [:provenance :feature-selection])))
(t/is (= ["a"] (get-in manifest [:functional :features])))
(t/is (false? (get-in manifest [:functional :default-features?]))))
(finally
(set! (.. js/process -env -PATH) original-path)))))
(t/deftest real-git-outside-a-repository-has-unknown-provenance
(t/async done
(complete
((^:async fn []
(let [git (await (build/git-provenance (frontend-root)
{:deadline (process/deadline 10000)}))]
(t/is (= {:sha nil :dirty nil} git)))))
done)))
(t/deftest ^:async git-provenance-propagates-unresolved-process-failures
(let [failure (errors/fatal-error "Git process may still be running"
{:type :process-unresolved :unresolved-process true
:command "git" :pid 4242 :pgid 4242 :signals ["SIGTERM" "SIGKILL"]} nil)]
(await
(mock/with-mocks*
{process/run (fn [_] (js/Promise.reject failure))}
(let [cause (try
(await (build/git-provenance (frontend-root) {:deadline (process/deadline 1000)}))
nil
(catch :default cause cause))]
(t/is (errors/fatal? cause))
(t/is (process/unresolved? cause)))))))
(t/deftest nonzero-renderer-retains-created-evidence-output-and-failed-manifest
(t/async done
(complete
((^:async fn []
(install-renderer failing-compiler)
(let [cause (await (rejected (build/prepare {:root (frontend-root)
:build-timeout-ms 10000
:on-created record-created})))
data (ex-data cause)
child-data (ex-data (ex-cause cause))
retained (retained-build cause)
manifest (read-manifest retained)
log (fs/readFileSync (:log-path retained) "utf8")
observation (js->clj (js/JSON.parse
(fs/readFileSync (path/join (:dir retained) "renderer-observation.json") "utf8"))
:keywordize-keys true)
created (:created observation)
initial (transit/decode-str (:manifest created))
at-renderer (transit/decode-str (:manifest observation))]
(t/is (= ::build/preparation-failed (:type data)))
(t/is (= :renderer (:phase data)))
(t/is (= (:log-path retained) (:log-path data)))
(t/is (= :process-exit (:type child-data)))
(t/is (= 23 (:exit-code child-data)))
(t/is (= (path/join (renderer-root) "build") (:command child-data)))
(t/is (= (renderer-root) (:cwd observation)))
(t/is (= (:id retained) (:id created) (:id manifest)
(:id initial) (:id at-renderer)))
(t/is (= :building (:status initial) (:status at-renderer)))
(t/is (false? (:renderer-started? created)))
(t/is (= "" (:log created)))
(t/is (str/includes? (:log observation) "Renderer compiling\n"))
(t/is (= :failed (:status manifest)))
(t/is (= "renderer" (get-in manifest [:failure :phase])))
(t/is (= "Command exited unsuccessfully" (get-in manifest [:failure :message])))
(t/is (= [:run-command] (mapv :operation (get-in manifest [:failure :errors]))))
(t/is (= :process-exit (get-in manifest [:failure :cause 0 :type])))
(t/is (= {:sha nil :dirty nil} (get-in manifest [:provenance :git])))
(t/is (str/includes? log "compiler stdout before failure\n"))
(t/is (str/includes? log "compiler stderr before failure\n"))
(t/is (= "partial renderer output\n"
(fs/readFileSync (path/join (:renderer-dir retained) "partial.js") "utf8"))))))
done)))
(t/deftest failed-manifest-save-makes-the-preparation-error-fatal
(t/async done
(complete
((^:async fn []
(install-renderer
(str failing-compiler
"fs.unlinkSync(path.join(path.dirname(process.env.FRONTEND_DEST), 'build.transit'));\n"))
(let [cause (await (rejected (build/prepare {:root (frontend-root)
:build-timeout-ms 10000
:on-created record-created})))
data (ex-data cause)
entries (errors/get-errors cause)]
(t/is (errors/fatal? cause))
(t/is (string? (:manifest-error data)))
(t/is (= :process-exit (:type (ex-data (:error (first entries))))))
(t/is (= 2 (count entries)))
(t/is (= :write-manifest (:operation (second entries))))
(t/is (= (:manifest-path (retained-build cause)) (:path (second entries))))
(t/is (str/includes? (errors/message-of (:error (second entries))) "ENOENT")))))
done)))
(t/deftest timed-out-lock-holder-retains-its-attempt-and-retry-gets-a-new-id
(t/async done
(complete
((^:async fn []
(install-renderer sleeping-compiler)
(let [cause (await (rejected (build/prepare {:root (frontend-root)
:build-timeout-ms 3000
:on-created record-created})))
retained (retained-build cause)
manifest (read-manifest retained)
before (fs/readFileSync (:manifest-path retained) "utf8")
log (fs/readFileSync (:log-path retained) "utf8")]
(t/is (= ::build/preparation-failed (:type (ex-data cause))))
(t/is (= :renderer (:phase (ex-data cause))))
(t/is (= (:log-path retained) (:log-path (ex-data cause))))
(t/is (= :process-timeout (:type (ex-data (ex-cause cause)))))
(t/is (= (:id retained) (:id manifest)))
(t/is (= :failed (:status manifest)))
(t/is (= "renderer" (get-in manifest [:failure :phase])))
(t/is (= :process-timeout (get-in manifest [:failure :cause 0 :type])))
(t/is (= "held" (fs/readFileSync (path/join (:renderer-dir retained) "lock-acquired.txt") "utf8")))
(t/is (str/includes? log "compiler lock acquired\n"))
(t/is (str/includes? log "waiting in compiler\n"))
;; The retry must acquire the same flock before its compiler can exit.
(install-renderer failing-compiler)
(let [retry-cause (await (rejected (build/prepare {:root (frontend-root)
:build-timeout-ms 10000
:on-created record-created})))
retry (retained-build retry-cause)
retry-log (fs/readFileSync (:log-path retry) "utf8")]
(t/is (not= (:id retained) (:id retry)))
(t/is (= :renderer (:phase (ex-data retry-cause))))
(t/is (= 23 (:exit-code (ex-data (ex-cause retry-cause)))))
(t/is (= :failed (:status (read-manifest retry))))
(t/is (str/includes? retry-log "compiler stderr before failure\n"))
(t/is (= before (fs/readFileSync (:manifest-path retained) "utf8")))
(t/is (= log (fs/readFileSync (:log-path retained) "utf8")))
(t/is (= #{(:id retained) (:id retry)}
(set (array-seq (fs/readdirSync (path/dirname (:dir retained)))))))))))
done)))

View File

@ -0,0 +1,288 @@
;; 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.builder-test
"Tests canonical scene construction and the shared seeded number generator.
Fixed random sequences preserve scene draws during generator extraction."
(:require
[app.common.uuid :as uuid]
[benches.render-wasm.builder :as b]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.random :as random]
[benches.render-wasm.scenes.rects]
[benches.render-wasm.snapshot :as snapshot]
[cljs.test :as t :include-macros true]
[performance-tests.render-wasm.test-helpers :as helpers]))
(defn- build
([] (build {}))
([params] ((:build (decl/registered-scene :rects)) (merge {:seed 42 :count 5} params))))
(defn- add-tile
"Helper defined outside a scene scope, called from inside one."
[index]
(b/rect [:tile index] {:x (* 10 index)}))
(t/deftest repeatable-builds-are-identical
(t/is (= (build) (build)))
(t/is (not= (build) (build {:seed 43}))))
(t/deftest shared-generator-retains-scene-draws-and-independent-state
(doseq [[seed expected] [[0 [1144304738 1416247 958946056 627933444 2007157716]]
[42 [2581720956 1925393290 3661312704 2876485805 750819978]]
[4294967295 [3850105811 813802916 3073704848 4054706436 3630262831]]]]
(let [first-source (random/create seed)
second-source (random/create seed)]
(t/is (= expected (mapv #(* 4294967296 %) (repeatedly 5 first-source))))
(t/is (= expected (mapv #(* 4294967296 %) (repeatedly 5 second-source))))
(t/is (every? #(and (<= 0 %) (< % 1)) (repeatedly 1000 first-source)))))
(let [rng (:rng @(b/start {:seed 42}))]
(t/is (= [2581720956 1925393290 3661312704 2876485805 750819978]
(mapv #(* 4294967296 %) (repeatedly 5 rng))))))
(t/deftest shared-generator-rejects-invalid-seeds
(doseq [seed [-1 4294967296 1.5 nil false "42" js/NaN js/Infinity]]
(t/is (= ::random/invalid-seed
(:type (helpers/failure-data #(random/create seed)))))))
(t/deftest rectangle-workload-keeps-default-count
(let [instance (build {:seed 42 :count 1000})
rects (helpers/root-shapes instance)]
(t/is (= 1000 (count rects)))
(t/is (< 1 (count (distinct (map :x rects)))))))
(t/deftest default-workload-is-canonical
(let [instance (build {:count 25})
rects (helpers/root-shapes instance)]
(t/is (= 25 (count rects)))
(t/is (every? #(= :rect (:type %)) rects))
(t/is (every? (fn [rect]
(and (= 1 (count (:fills rect)))
(= 1 (count (:strokes rect)))
(= 10 (:stroke-width (first (:strokes rect))))
(= :center (:stroke-alignment (first (:strokes rect))))))
rects))
(t/is (every? (fn [rect]
(and (re-matches #"^#[0-9a-f]{6}$" (:fill-color (first (:fills rect))))
(<= 0.1 (:fill-opacity (first (:fills rect))) 0.999)
(every? #(<= 0 % 24) [(:r1 rect) (:r2 rect) (:r3 rect) (:r4 rect)])))
rects))))
(t/deftest geometry-is-page-relative-and-in-range
(let [params {:seed 7 :count 40 :width 800 :height 600 :min-size 5 :max-size 15}
instance (build params)
rects (helpers/root-shapes instance)]
(t/is (every? #(<= 0 (:x %) 800) rects))
(t/is (every? #(<= 0 (:y %) 600) rects))
(t/is (every? #(<= 5 (:width %) 15) rects))
(t/is (every? #(<= 5 (:height %) 15) rects))
(t/is (= 800 (get-in instance [:objects uuid/zero :width])))
(t/is (= 600 (get-in instance [:objects uuid/zero :height])))))
(t/deftest labels-are-snapshot-local
(let [instance (b/scene {:seed 1}
(b/rect)
(b/rect :hero {})
(b/rect [:tile 1] {}))
refs (:refs instance)
children (helpers/child-ids instance uuid/zero)]
(t/is (= #{:hero [:tile 1]} (set (keys refs))))
(t/is (= (get refs :hero) (second children)))
(t/is (= (get refs [:tile 1]) (nth children 2)))))
(t/deftest attrs-override-generated-values
(let [instance (b/scene {:seed 1
:defaults {:rect {:x (b/gen-int 0 100)}}}
(b/rect {:x 7 :fills []}))
rect (first (helpers/root-shapes instance))]
(t/is (= 7 (:x rect)))
(t/is (= [] (:fills rect)))))
(t/deftest shapes-use-their-defaults-before-drawing-ids
(doseq [[type low high construct] [[:rect 0.1 0.2 #(b/rect %)]
[:path 0.3 0.4 #(b/path %)]
[:circle 0.5 0.6 #(b/circle %)]]
attrs [{} {:opacity 0.75}]]
(let [rng (random/create 7)
opacity (random/round3 (random/rng-float rng low high))
id (random/rng-uuid rng)
instance (b/scene {:seed 7
:defaults {:rect {:opacity (b/gen-float 0.1 0.2)}
:path {:opacity (b/gen-float 0.3 0.4)}
:circle {:opacity (b/gen-float 0.5 0.6)}}}
(construct attrs))
shape (first (helpers/root-shapes instance))]
(t/is (= {:type type :id id :opacity (get attrs :opacity opacity)}
(select-keys shape [:type :id :opacity]))))))
(t/deftest scene-and-shape-expressions-evaluate-once-in-order
(let [events (atom [])
instance (b/scene (do (swap! events conj :params) {:seed 1})
(b/rect (do (swap! events conj :label) :hero)
(do (swap! events conj :attrs) {:x 11})))
hero (get-in instance [:objects (get (:refs instance) :hero)])]
(t/is (= [:params :label :attrs] @events))
(t/is (= 11 (:x hero)))))
(t/deftest ordinary-forms-work-inside-the-scope
(let [instance (b/scene {:seed 1}
(let [first-id (b/rect)]
(t/is (uuid? first-id)))
(doseq [index (range 3)]
(add-tile index))
(b/rect :last {:x 99}))
refs (:refs instance)
rects (helpers/root-shapes instance)]
(t/is (= 5 (count rects)))
(t/is (= [0 10 20]
(mapv #(:x (get-in instance [:objects (get refs [:tile %])]))
(range 3))))
(t/is (= 99 (:x (get-in instance [:objects (get refs :last)]))))))
(t/deftest duplicate-labels-are-rejected
(let [data (helpers/failure-data
#(b/scene {:seed 1}
(b/rect :dup {})
(b/rect :dup {})))]
(t/is (= ::b/duplicate-label (:type data)))
(t/is (= :dup (:label data)))))
(t/deftest failed-builds-leak-no-state
(let [clean (build)]
(helpers/failure-data #(b/scene {:seed 99}
(b/rect :dup {})
(b/rect :dup {})))
(t/is (= clean (build)))))
(t/deftest rect-outside-scope-is-rejected
(let [data (helpers/failure-data #(b/rect))]
(t/is (= ::b/outside-scope (:type data)))))
(t/deftest nested-scopes-are-rejected
(let [data (helpers/failure-data
#(b/scene {:seed 1}
(b/scene {:seed 2}
(b/rect))))]
(t/is (= ::b/nested-scope (:type data)))))
(t/deftest invalid-seeds-are-rejected
(t/is (= ::b/invalid-seed
(:type (helpers/failure-data #(b/scene {} (b/rect))))))
(t/is (= ::b/invalid-seed
(:type (helpers/failure-data #(b/scene {:seed -1} (b/rect))))))
(t/is (= ::b/invalid-seed
(:type (helpers/failure-data #(b/scene {:seed 4294967296} (b/rect)))))))
(t/deftest runtime-attrs-maps-are-resolved
(let [attrs {:x 11}
instance (b/scene {:seed 1}
(b/rect attrs))
rect (first (helpers/root-shapes instance))]
(t/is (= 11 (:x rect)))))
(t/deftest single-label-form-is-supported
(let [instance (b/scene {:seed 1}
(b/rect :solo))
id (get (:refs instance) :solo)]
(t/is (uuid? id))
(t/is (= [id] (helpers/child-ids instance uuid/zero)))))
(t/deftest built-snapshot-feeds-the-scene-api
(let [instance (b/scene {:seed 1}
(b/rect :hero {}))
order (snapshot/upload-order instance)
hero-id (snapshot/ref-id instance :hero)]
(t/is (= [uuid/zero hero-id] (mapv :id order)))))
(t/deftest invalid-labels-are-rejected
(let [data (helpers/failure-data #(b/scene {:seed 1} (b/rect "hero" {})))]
(t/is (= ::b/invalid-label (:type data)))))
(t/deftest protected-keys-cannot-be-overridden
(let [other (uuid/custom 9 9)
instance (b/scene {:seed 1}
(b/rect {:type :circle :id other :parent-id other}))
rect (first (helpers/root-shapes instance))]
(t/is (= :rect (:type rect)))
(t/is (not= other (:id rect)))
(t/is (= uuid/zero (:parent-id rect)))))
(t/deftest root-protected-keys-cannot-be-overridden
(let [other (uuid/custom 9 8)
instance (b/scene {:seed 1
:root {:id other :type :rect :width 10}}
(b/rect))
root (get-in instance [:objects uuid/zero])]
(t/is (= uuid/zero (:id root)))
(t/is (= :frame (:type root)))
(t/is (= 10 (:width root)))))
(t/deftest attrs-override-generated-fills
(let [fill {:fill-color "#ffffff" :fill-opacity 1}
instance (b/scene {:seed 1}
(b/rect {:fills [fill]}))
rect (first (helpers/root-shapes instance))]
(t/is (= [fill] (:fills rect)))))
(t/deftest generator-helpers-produce-expected-values
(let [instance (b/scene {:seed 3
:defaults {:rect {:opacity (b/gen-float 0.5 0.6)
:x (b/gen-one-of [4 5])
:r1 (b/gen-int 3 5)
:fills (b/gen-vector (b/gen-fill))}}}
(b/rect))
rect (first (helpers/root-shapes instance))]
(t/is (<= 0.5 (:opacity rect) 0.6))
(t/is (contains? #{4 5} (:x rect)))
(t/is (<= 3 (:r1 rect) 4))
(t/is (re-matches #"^#[0-9a-f]{6}$" (:fill-color (first (:fills rect)))))))
(t/deftest empty-one-of-is-rejected
(let [data (helpers/failure-data #(b/gen-one-of []))]
(t/is (= ::b/no-values (:type data)))))
(t/deftest circle-creates-ellipse-type
(let [instance (b/scene {:seed 1}
(b/circle {:x 10 :y 20 :width 30 :height 40}))
circle (first (helpers/root-shapes instance))]
(t/is (= :circle (:type circle)))
(t/is (= [10 20 30 40] [(:x circle) (:y circle) (:width circle) (:height circle)]))
(t/is (= 1 (count (helpers/child-ids instance uuid/zero))))))
(t/deftest circle-uses-circle-defaults
(let [instance (b/scene {:seed 3
:defaults {:circle {:x (b/gen-int 10 12)}}}
(b/circle))
circle (first (helpers/root-shapes instance))]
(t/is (contains? #{10 11} (:x circle)))))
(t/deftest circle-ignores-rect-defaults
(let [instance (b/scene {:seed 1
:defaults {:rect {:x 999}}}
(b/circle))
circle (first (helpers/root-shapes instance))]
(t/is (not= 999 (:x circle)))))
(t/deftest circle-geometry-is-page-relative-and-in-range
(let [instance (b/scene {:seed 7
:defaults {:circle {:x (b/gen-int 0 800)
:y (b/gen-int 0 600)
:width (b/gen-int 5 15)
:height (b/gen-int 5 15)}}}
(doseq [_ (range 40)]
(b/circle)))
circles (helpers/root-shapes instance)]
(t/is (= 40 (count circles)))
(t/is (every? #(<= 0 (:x %) 800) circles))
(t/is (every? #(<= 0 (:y %) 600) circles))
(t/is (every? #(<= 5 (:width %) 15) circles))
(t/is (every? #(<= 5 (:height %) 15) circles))))
(t/deftest circle-preserves-radius-attrs
(let [instance (b/scene {:seed 1}
(b/circle {:x 0 :y 0 :width 20 :height 20 :rx 5 :ry 7}))
circle (first (helpers/root-shapes instance))]
(t/is (= [5 7] [(:rx circle) (:ry circle)]))))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,278 @@
;; 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.cli-test
(:require
[app.common.schema :as sm]
[benches.render-wasm.node.cli.request :as request]
[benches.render-wasm.node.cli.resolve :as cli]
[cljs.test :as t :include-macros true]
[clojure.string :as str]))
(def ^:private measurement-defaults
{:seed 42 :filter nil :warmups 3 :repetitions 10
:timeout-ms 30000 :startup-timeout-ms 30000 :output-dir nil
:screenshots false :require-gpu false})
(def ^:private comparison-defaults
{:allow-build-differences false :allow-scene-differences false
:allow-all-differences false})
(defn- resolved
[command options]
{:command command :console {:level :info :color :auto} :options options})
(defn- failure
[argv]
(try
(cli/parse-resolve argv)
nil
(catch :default cause cause)))
(t/deftest commands-resolve-complete-requests
(doseq [[argv expected]
[[["build"]
(resolved "build" {:default-features true :features [] :build-timeout-ms 300000})]
[["run"]
(resolved "run" (assoc measurement-defaults :remove false
:build {:kind :prepare :default-features true
:features [] :build-timeout-ms 300000}))]
[["run" "--build" "ready-build"]
(resolved "run" (assoc measurement-defaults :remove false
:build {:kind :existing :id "ready-build"}))]
[["ab" "--features" "cache" "--dry-run"]
(resolved "ab" (merge measurement-defaults comparison-defaults
{:features ["cache"] :max-features 3 :confirm false
:dry-run true :build-timeout-ms 300000}))]
[["compare" "base" "candidate"]
(resolved "compare" (assoc comparison-defaults :baseline-path "base"
:candidate-path "candidate" :case-pair nil))]
[["compare" "base" "candidate" "--allow-build-differences"
"--allow-scene-differences" "--allow-all-differences"]
(resolved "compare" {:baseline-path "base" :candidate-path "candidate" :case-pair nil
:allow-build-differences true :allow-scene-differences true
:allow-all-differences true})]
[["ab" "--features" "cache" "--filter" "rects" "--require-gpu" "--screenshots"
"--allow-build-differences" "--allow-scene-differences" "--allow-all-differences"]
(resolved "ab" (assoc measurement-defaults :filter "rects" :require-gpu true
:screenshots true :features ["cache"] :max-features 3
:confirm false :dry-run false :build-timeout-ms 300000
:allow-build-differences true :allow-scene-differences true
:allow-all-differences true))]
[["merge" "base" "extra"]
(resolved "merge" {:base-path "base" :additional-path "extra" :output-dir nil})]
[["merge" " ./base " " ./extra " "--output-dir" " ./merged "]
(resolved "merge" {:base-path " ./base " :additional-path " ./extra "
:output-dir " ./merged "})]
[["purge" "comparisons"] (resolved "purge" {:target :comparisons})]
[["--help"] (resolved "help" {:topic nil})]]]
(t/testing (pr-str argv)
(t/is (= expected (cli/parse-resolve argv))))))
(t/deftest console-flags-compose-with-every-command
(doseq [argv [["build"] ["run"] ["ab" "--features" "cache" "--dry-run"]
["compare" "base" "candidate"] ["merge" "base" "extra"]
["purge" "runs"] ["--help"]]
[flags console] [[["-q" "--no-color"] {:level :warn :color :never}
[["-v" "--color"] {:level :debug :color :always}]]]]
(t/is (= console (:console (cli/parse-resolve (into argv flags))))))
(doseq [argv [["run" "-qv"] ["build" "--quiet" "--verbose"]
["compare" "--help" "-qv"] ["--help" "-qv"]]]
(let [error (failure argv)]
(t/is (= ::cli/invalid-args (:type (ex-data error))))
(t/is (str/includes? (ex-message error) "mutually exclusive")))))
(t/deftest explicit-build-and-measurement-values-survive-resolution
(t/is (= (resolved "build" {:default-features false :features ["a" "b"]
:build-timeout-ms 1000})
(cli/parse-resolve ["--" "build" "--no-default-features"
"--features=b,a,b" "--build-timeout-ms" "1e3"])))
(t/is (= {:command "run" :console {:level :info :color :never}
:options {:build {:kind :prepare :default-features false
:features ["a" "b"] :build-timeout-ms 5000}
:seed 0 :filter " rects " :warmups 0 :repetitions 2
:timeout-ms 1000 :startup-timeout-ms 2000 :output-dir " ./runs "
:screenshots true :remove true :require-gpu true}}
(cli/parse-resolve
["run" "--no-default-features" "--features" " b,a,b "
"--build-timeout-ms" "5000" "--seed" "0" "--filter" " rects "
"--warmups" "0" "--repetitions" "2" "--timeout-ms" "1000"
"--startup-timeout-ms" "2000" "--output-dir" " ./runs "
"--screenshots" "--remove" "--require-gpu" "--no-color"]))))
(t/deftest retained-build-conflicts-use-supplied-flags
(doseq [flags [["--features" "cache"] ["--default-features"]
["--no-default-features"] ["--build-timeout-ms=300000"]]
argv [(into ["run" "--build" "ready"] flags)
(into (into ["run"] flags) ["--build=ready"])]]
(t/is (str/includes? (ex-message (failure argv)) "--build cannot be combined")))
(t/is (= (resolved "run" (assoc measurement-defaults :remove false :warmups 0
:build {:kind :existing :id "ready"}))
(cli/parse-resolve ["run" "--build" "ready" "--warmups" "0"]))))
(t/deftest parsers-reject-options-owned-by-other-commands
(doseq [[argv flags]
[[["build"] ["--warmups" "--cases" "--confirm"]]
[["run"] ["--cases" "--dry-run" "--allow-build-differences"]]
[["ab" "--features" "cache" "--dry-run"]
["--build" "--remove" "--default-features" "--no-default-features" "--cases"]]
[["compare" "base" "candidate"] ["--output-dir" "--seed" "--features"]]
[["merge" "base" "extra"] ["--warmups" "--cases" "--allow-all-differences"]]
[["purge" "runs"] ["--features" "--seed" "--only-runs"]]]
flag flags]
(doseq [args [(conj argv flag) (conj argv "--help" flag)]]
(let [error (failure args)]
(t/is (= ::cli/invalid-args (:type (ex-data error))))
(t/is (str/includes? (ex-message error) "Unknown option"))))))
(t/deftest command-first-syntax-and-positional-arguments-are-checked
(doseq [argv [[] ["unknown" "--help"] ["--help" "run"] ["-q" "build"]
["build" "extra"] ["run" "extra"]
["compare"] ["compare" "one"] ["compare" "one" "two" "three"]
["compare" "" "candidate"] ["merge" "base"] ["merge" "base" " "]
["run" "--build" "../escape"] ["run" "--output-dir" " "]]]
(t/testing (pr-str argv)
(t/is (= ::cli/invalid-args (:type (ex-data (failure argv)))))))
(doseq [target ["builds" "runs" "comparisons" "all"]]
(t/is (= (resolved "purge" {:target (keyword target)})
(cli/parse-resolve ["purge" target]))))
(doseq [args [[] ["unknown"] ["runs" "builds"]]]
(let [error (failure (into ["purge"] args))]
(t/is (= ::cli/invalid-args (:type (ex-data error))))
(t/is (str/includes? (ex-message error) "runner purge builds|runs|comparisons|all")))))
(t/deftest argument-errors-include-command-and-validation-details
(let [data (ex-data (failure ["unknown" "--help"]))]
(t/is (= "unknown" (:command data)))
(t/is (= "runner COMMAND [options]" (:usage data))))
(doseq [[argv path] [[["compare" "" "candidate"] [:options :baseline-path]]
[["merge" "base" " "] [:options :additional-path]]]]
(let [data (ex-data (failure argv))]
(t/is (= ::cli/invalid-args (:type data)))
(t/is (= ::request/invalid-request (:cause-type data)))
(t/is (some #(= path (:in %)) (:errors (::sm/explain data)))))))
(t/deftest literal-paths-selectors-and-option-looking-values-are-preserved
(t/is (= (resolved "compare" (assoc comparison-defaults :baseline-path " ./base "
:candidate-path " ./candidate "
:case-pair [" rects/load " "other/load"]))
(cli/parse-resolve ["compare" " ./base " " ./candidate "
"--cases" " rects/load ,other/load"])))
(t/is (= (resolved "compare" (assoc comparison-defaults :baseline-path "--seed"
:candidate-path "-candidate" :case-pair nil))
(cli/parse-resolve ["compare" "--" "--seed" "-candidate"])))
(doseq [text ["" " " "--allow-all-differences" "--" "-h"]]
(t/is (= (resolved "run" (assoc measurement-defaults :filter text :remove false
:build {:kind :prepare :default-features true
:features [] :build-timeout-ms 300000}))
(cli/parse-resolve ["run" "--filter" text])))))
(t/deftest malformed-selector-pairs-fail-before-record-loading
(doseq [text ["" "one" "one,two,three" ",two" "one," " ,two" "one, "]]
(let [error (failure ["compare" "base" "candidate" (str "--cases=" text)])]
(t/is (= ::cli/invalid-args (:type (ex-data error))))
(t/is (str/includes? (ex-message error) "--cases"))))
(t/is (= ::cli/invalid-args
(:type (ex-data (failure ["compare" "base" "candidate" "--cases"]))))))
(t/deftest numeric-values-use-integer-and-timer-bounds
(doseq [[flag values]
[["--seed" ["0" "4294967295"]]
["--warmups" ["0" "9007199254740991"]]
["--repetitions" ["1" "9007199254740991"]]
["--timeout-ms" ["1" "2147483647"]]
["--startup-timeout-ms" ["1" "2147483647"]]
["--build-timeout-ms" ["1" " 1000 " "1e3" "0x3e8" "0o1750" "0b1111101000" "2147483647"]]]
value values]
(t/is (= (js/Number value)
(let [options (:options (cli/parse-resolve ["run" flag value]))]
(if (= flag "--build-timeout-ms")
(get-in options [:build :build-timeout-ms])
(get options (keyword (subs flag 2))))))))
(doseq [[flag values]
[["--seed" ["-1" "4294967296" "1.5"]]
["--warmups" ["-1" "9007199254740992" "0.5"]]
["--repetitions" ["0" "9007199254740992" "1.5"]]
["--timeout-ms" ["0" "2147483648" "1.5"]]
["--startup-timeout-ms" ["0" "2147483648"]]
["--build-timeout-ms" ["0" "2147483648" "1.5" "1000ms"]]]
value (concat values ["" " " "NaN" "Infinity"])]
(t/testing (str flag " " value)
(t/is (= ::cli/invalid-args (:type (ex-data (failure ["run" flag value]))))))))
(t/deftest feature-selection-and-ab-policy-use-domain-rules
(doseq [command ["build" "run" "ab"]
[feature message] [["stats" "diagnostic Cargo features"]
["profile" "diagnostic Cargo features"]
["profile-macros" "diagnostic Cargo features"]
["profile-raf" "diagnostic Cargo features"]
["" "invalid Cargo feature selection"]
["a,,b" "invalid Cargo feature selection"]
["a/b" "invalid Cargo feature selection"]]]
(let [argv (cond-> [command "--features" feature] (= command "ab") (conj "--dry-run"))
error (failure argv)]
(t/is (= ::cli/invalid-args (:type (ex-data error))))
(t/is (str/includes? (ex-message error) message))))
(doseq [argv [["ab"] ["ab" "--features" "cache"]
["ab" "--features" "cache" "--filter" " "]
["ab" "--features" "a,b,c,d" "--dry-run"]
["ab" "--features" "a,b,c,d" "--max-features" "4" "--filter" "rects"]]]
(t/is (= ::cli/invalid-args (:type (ex-data (failure argv))))))
(doseq [limit ["" " " "0" "7" "1.5" "NaN" "Infinity" "3x"]]
(t/is (= ::cli/invalid-args
(:type (ex-data (failure ["ab" "--features" "cache" "--dry-run"
"--max-features" limit]))))))
(doseq [flags [["--confirm"] ["--dry-run"] ["--filter" "rects"]]]
(t/is (= ["a" "b" "c"]
(get-in (cli/parse-resolve (into ["ab" "--features" "c,b,a,c"] flags))
[:options :features]))))
(let [error (failure ["ab" "--features" "cache"])]
(t/is (str/includes? (ex-message (ex-cause error)) "--confirm"))))
(t/deftest help-skips-required-command-arguments-and-keeps-syntax-checks
(doseq [command ["build" "run" "ab" "compare" "merge" "purge"]]
(t/is (= (resolved "help" {:topic command})
(cli/parse-resolve [command "--help"]))))
(t/is (= (assoc (resolved "help" {:topic "compare"})
:console {:level :debug :color :never})
(cli/parse-resolve ["compare" "--help" "--verbose" "--no-color"])))
(doseq [argv [["--help" "--unknown"] ["run" "--help" "--warmups" "bad"]
["compare" "--help" "--cases="] ["ab" "--help" "--features"]]]
(t/is (= ::cli/invalid-args (:type (ex-data (failure argv)))))))
(t/deftest help-shows-command-options-and-schema-defaults
(doseq [[topic flags]
[["build" #{"--[no-]default-features" "--features" "--build-timeout-ms"}]
["run" #{"--build" "--[no-]default-features" "--features" "--build-timeout-ms"
"--seed" "--filter" "--warmups" "--repetitions" "--timeout-ms"
"--startup-timeout-ms" "--output-dir" "--screenshots" "--require-gpu" "--remove"}]
["ab" #{"--features" "--max-features" "--confirm" "--dry-run" "--build-timeout-ms"
"--seed" "--filter" "--warmups" "--repetitions" "--timeout-ms"
"--startup-timeout-ms" "--output-dir" "--screenshots" "--require-gpu"
"--allow-build-differences" "--allow-scene-differences" "--allow-all-differences"}]
["compare" #{"--cases" "--allow-build-differences"
"--allow-scene-differences" "--allow-all-differences"}]
["merge" #{"--output-dir"}]
["purge" #{}]]]
(let [text (cli/get-help topic)]
(t/is (str/includes? text (str "Usage: runner " topic)))
(t/is (= (into #{"--help" "--quiet" "--verbose" "--[no-]color"} flags)
(set (re-seq #"--(?:\[no-\])?[a-z][a-z-]*" text))))))
(t/is (= ["build" "run" "ab" "compare" "merge" "purge"]
(mapv second (re-seq #"(?m)^ runner ([a-z]+)" (cli/get-help nil)))))
(doseq [[topic pattern] [["build" #"--build-timeout-ms MS\s+300000"]
["run" #"--warmups N\s+3"]
["run" #"--seed N\s+42"]
["run" #"--repetitions N\s+10"]
["run" #"--timeout-ms MS\s+30000\s+Case deadline"]
["ab" #"--timeout-ms MS\s+30000\s+Case deadline"]
["run" #"--features FEATURES\s+\[\]"]
["run" #"--\[no-\]default-features\s+true"]
["run" #"--build-timeout-ms MS\s+300000"]
["ab" #"--max-features N\s+3"]
["merge" #"--output-dir DIR\s+nil"]
["compare" #"--cases A,B\s+nil"]]]
(t/is (re-find pattern (cli/get-help topic)))))

View File

@ -0,0 +1,520 @@
;; 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.commands-test
(:require
[app.common.render-wasm.wasm :as wasm]
[app.common.schema :as sm]
[benches.render-wasm.builder :as builder]
[benches.render-wasm.cases :as cases]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.runtime.camera :as camera]
[benches.render-wasm.runtime.commands :as cmd :refer [defcommand]]
[cljs.test :as t]))
(defcommand move
"Adds the supplied offsets to the command context's view."
[rtx {:keys [dx dy]}]
(-> rtx
(update-in [:view :x] + dx)
(update-in [:view :y] + dy)))
(defcommand selected-renders
{:measured-variant false}
[rtx]
(-> rtx
(camera/render-sync*)
(move {:dx 10 :dy 5})
(camera/render-sync)
(camera/render-sync*)))
(defcommand unexpected-promise
[rtx cause]
(if cause
(js/Promise.reject cause)
(js/Promise.resolve rtx)))
(defcommand reject-offset
[rtx offset]
{:pre [(pos? offset)]}
((get-in rtx [:hooks :render-sync]))
(update-in rtx [:view :x] + offset))
(defcommand replace-owner
[rtx]
(assoc-in rtx [:hooks :check] (fn [])))
(defcommand async-work
{:async [complete fail]}
[rtx work]
(work rtx complete fail))
(defcommand selected-async
{:async [complete fail] :measured-variant false}
[rtx]
(-> (camera/render-sync* rtx)
(move {:dx 10 :dy 5})
(camera/render-sync*)
(.then complete)
(.catch fail)))
(defn- context
[clock]
{:case {:id :commands/example}
:scene (builder/scene {:seed 1})
:view {:scale 1 :x 0 :y 0}
:hooks {:now #(deref clock)
:check (fn [])
:frame #(js/Promise.resolve (vswap! clock + 16))
:sleep #(do (vswap! clock + %) (js/Promise.resolve nil))
:render (fn [_ _] (vswap! clock + 7) wasm/FRAME_TYPE_FULL)
:render-sync #(vswap! clock + 7)
:render-from-cache #(vswap! clock + 2)
:set-view (fn [_])
:set-view-start (fn [])
:set-view-end #(vswap! clock + 3)}})
(t/deftest ^:async measurement-uses-core-time
(let [clock (volatile! 0)
rtx (assoc-in (context clock) [:hooks :render-sync]
#(do
(vswap! clock + 7)
(js/queueMicrotask (fn [] (vswap! clock + 500)))))
{:keys [outcome events context]}
(await
(cmd/execute
rtx
(fn [rtx]
(let [input (-> (js/Promise.resolve rtx)
(.then (fn [rtx]
(vswap! clock + 1000)
rtx)))]
(-> (camera/render-sync* input)
(.then (fn [rtx]
(vswap! clock + 2000)
rtx)))))))]
(t/is (= :completed outcome))
(t/is (= (:view rtx) (:view context)))
(t/is (= [{:command ::camera/render-sync
:metrics {:command/elapsed [7]}}]
events))
(t/is (= 3507 @clock))))
(t/deftest ^:async composite-preserves-view-and-selected-observations
(doseq [promised [false true]]
(let [clock (volatile! 0)
cost (volatile! 0)
rtx (assoc-in (context clock) [:hooks :render-sync]
#(vswap! clock + (vswap! cost + 7)))
{:keys [outcome context events]}
(await (cmd/execute rtx #(selected-renders
(if promised (js/Promise.resolve %) %))))]
(t/is (= :completed outcome))
(t/is (= {:scale 1 :x 10 :y 5} (:view context)))
(t/is (= [{:command ::camera/render-sync
:metrics {:command/elapsed [7]}}
{:command ::camera/render-sync
:metrics {:command/elapsed [21]}}]
events))
(t/is (= 42 @clock)))))
(t/deftest measured-command-documentation
(t/is (.startsWith (:doc (meta #'camera/render-sync*))
"Measured variant of `render-sync`."))
(t/is (= "Measured variant of `unexpected-promise`."
(:doc (meta #'unexpected-promise*)))))
(t/deftest ^:async zero-duration-is-a-raw-observation
(let [rtx (context (volatile! 0))
result (await (cmd/execute rtx #(move* % {:dx 2 :dy 3})))]
(t/is (= :completed (:outcome result)))
(t/is (= {:scale 1 :x 2 :y 3} (get-in result [:context :view])))
(t/is (= [{:command ::move :metrics {:command/elapsed [0]}}]
(:events result)))))
(t/deftest ^:async async-completion-keeps-endpoints-and-raw-evidence
(doseq [command [async-work async-work*]
supplied [false true]]
(let [clock (volatile! 0)
rtx (context clock)
metrics {:renderer/call-elapsed [1 2 4]}
trace {:steps [1 2 3]}
result (await
(cmd/execute
rtx
(fn [rtx]
(command
(-> (js/Promise.resolve rtx)
(.then #(do (vswap! clock + 1000) %)))
(fn [rtx complete _fail]
(vswap! clock + 7)
(let [end @clock
evidence {:metrics metrics :trace trace}
output (assoc-in rtx [:view :x] 10)]
(if supplied
(-> (js/Promise.resolve nil)
(.then (fn [_]
(vswap! clock + 500)
(complete output (assoc evidence :end-ms end)))))
(do
(complete output evidence)
(js/queueMicrotask #(vswap! clock + 500))))))))))]
(t/is (= :completed (:outcome result)))
(t/is (= 10 (get-in result [:context :view :x])))
(t/is (= 1507 @clock))
(t/is (= (if (= command async-work*)
[{:command ::async-work
:metrics (assoc metrics :command/elapsed [7])
:trace trace}]
[])
(:events result)))))
(let [result (await (cmd/execute (context (volatile! 0)) selected-async))]
(t/is (= :completed (:outcome result)))
(t/is (= {:scale 1 :x 10 :y 5} (get-in result [:context :view])))
(t/is (= [::camera/render-sync ::camera/render-sync]
(mapv :command (:events result))))))
(t/deftest ^:async invalid-trace-stops-the-command-and-retains-earlier-measurements
(doseq [trace [{:handle (fn [] nil)} (repeat 0) (nth (iterate vector 0) 40)]]
(let [run (fn [rtx]
(async-work* rtx (fn [rtx complete _] (complete rtx {:trace trace}))))
result (await (cmd/execute (context (volatile! 0))
(fn [rtx] (.then (camera/render-sync* rtx) run))))]
(t/is (= :failed (:outcome result)))
(t/is (= "command trace cannot cross Transit" (errors/message-of (:error result))))
(t/is (= [::camera/render-sync] (mapv :command (:events result)))))))
(t/deftest ^:async async-completion-retains-invalid-numeric-observations
(let [result (await
(cmd/execute
(context (volatile! 0))
#(async-work* % (fn [rtx complete _fail]
(complete rtx {:end-ms js/Infinity
:metrics {:sample/raw [-1 0 js/NaN js/Infinity]}})))))
values (get-in result [:events 0 :metrics :sample/raw])]
(t/is (= :completed (:outcome result)))
(t/is (= [js/Infinity] (get-in result [:events 0 :metrics :command/elapsed])))
(t/is (= [-1 0] (subvec values 0 2)))
(t/is (js/Number.isNaN (nth values 2)))
(t/is (= js/Infinity (nth values 3)))))
(t/deftest ^:async async-commands-use-the-first-terminal-signal
(let [result (await
(cmd/execute
(context (volatile! 0))
#(async-work* % (fn [rtx complete fail]
(complete (assoc-in rtx [:view :x] 10))
(complete nil)
(fail (js/Error. "late failure"))
(throw (js/Error. "late throw"))))))]
(t/is (= :completed (:outcome result)))
(t/is (= 10 (get-in result [:context :view :x])))
(t/is (= [{:command ::async-work :metrics {:command/elapsed [0]}}] (:events result)))
(t/is (= [:complete :fail :fail] (mapv :signal (:diagnostics result)))))
(let [result (await
(cmd/execute
(context (volatile! 0))
#(async-work* % (fn [rtx complete _fail]
(js/queueMicrotask (fn [] (complete (assoc-in rtx [:view :x] 20))))
rtx))))]
(t/is (= :completed (:outcome result)))
(t/is (= 20 (get-in result [:context :view :x]))))
(let [unhandled (atom [])
observe #(swap! unhandled conj %)]
(.on js/process "unhandledRejection" observe)
(try
(let [result (await
(cmd/execute
(context (volatile! 0))
#(async-work* % (fn [rtx complete _fail]
(complete rtx)
(js/Promise.reject (js/Error. "rejected after completion"))))))]
(await (js/Promise. #(js/setImmediate %)))
(t/is (= :completed (:outcome result)))
(t/is (= [{:command ::async-work :metrics {:command/elapsed [0]}}] (:events result)))
(t/is (= [] @unhandled)))
(finally
(.removeListener js/process "unhandledRejection" observe)))))
(t/deftest ^:async async-failures-retain-causes-and-earlier-events
(doseq [mode [:throw :reject :fail]]
(let [cause (ex-info "renderer failed" {:partial {:slices [{:duration-ms 3}]}})
result (await
(cmd/execute
(context (volatile! 0))
#(-> %
(camera/render-sync*)
(async-work* (fn [rtx complete fail]
(case mode
:throw (throw cause)
:reject (js/Promise.reject cause)
:fail (do (fail cause) (complete rtx))))))))]
(t/is (= :failed (:outcome result)))
(t/is (identical? cause (:error result)))
(t/is (= [{:command ::camera/render-sync :metrics {:command/elapsed [7]}}]
(:events result))))))
(t/deftest ^:async malformed-async-completion-fails-before-recording
(doseq [[output evidence expected]
[[identity {:end-ms nil} ::cmd/invalid-completion]
[identity {:metrics {:sample/raw []}} ::cmd/invalid-completion]
[identity {:metrics {:sample/raw ["bad"]}} ::cmd/invalid-completion]
[identity {:metrics {:command/elapsed [123]}} ::cmd/invalid-completion]
[#(assoc-in % [:view :scale] 0) {} ::cmd/invalid-context]
[#(assoc-in % [:hooks :check] (fn [])) {} ::cmd/ownership-lost]]]
(let [result (await (cmd/execute (context (volatile! 0))
#(async-work* % (fn [rtx complete _fail]
(complete (output rtx) evidence)))))]
(t/is (= :failed (:outcome result)))
(t/is (= expected (:type (ex-data (:error result)))))
(t/is (= [] (:events result))))))
(t/deftest ^:async async-completion-checks-the-owner-guard
(doseq [cause [(ex-info "deadline expired" {:phase :deadline})
(ex-info "owner superseded" {::errors/stale true})]]
(let [blocked (volatile! nil)
rtx (assoc-in (context (volatile! 0)) [:hooks :check]
#(when @blocked (throw @blocked)))
result (await (cmd/execute rtx #(async-work* % (fn [rtx complete _fail]
(vreset! blocked cause)
(complete rtx)))))]
(t/is (= :failed (:outcome result)))
(t/is (identical? cause (:error result)))
(t/is (= [] (:events result))))))
(t/deftest ^:async late-completion-cannot-record-in-another-execution
(let [callbacks (volatile! nil)
pending (volatile! nil)
first-run (await
(cmd/execute
(context (volatile! 0))
(fn [rtx]
(vreset! pending
(.catch (async-work* rtx (fn [rtx complete fail]
(vreset! callbacks [rtx complete fail])))
errors/as-failure))
rtx)))
[rtx complete fail] @callbacks
next-run (await (cmd/execute (:context first-run)
(fn [next-rtx]
(complete rtx)
(fail (js/Error. "late failure"))
(camera/render-sync* next-rtx))))
cause (await @pending)]
(t/is (= :completed (:outcome first-run)))
(t/is (= [] (:events first-run)))
(t/is (= ::cmd/inactive-execution (:type (ex-data cause))))
(t/is (= :completed (:outcome next-run)))
(t/is (= [{:command ::camera/render-sync :metrics {:command/elapsed [7]}}]
(:events next-run)))))
(t/deftest ^:async camera-gestures-return-the-view-and-renderer-evidence
(doseq [[command options target command-id measured]
[[camera/pan {:dx 10 :dy 5 :steps 2 :settle-ms 5} {:scale 1 :x 10 :y 5} ::camera/pan false]
[camera/pan* {:dx 10 :dy 5 :steps 2 :settle-ms 5} {:scale 1 :x 10 :y 5} ::camera/pan true]
[camera/zoom {:factor 2 :steps 2 :settle-ms 5} {:scale 2 :x 0 :y 0} ::camera/zoom false]
[camera/zoom* {:factor 2 :steps 2 :settle-ms 5} {:scale 2 :x 0 :y 0} ::camera/zoom true]]]
(let [clock (volatile! 0)
view (volatile! nil)
remaining (volatile! [wasm/FRAME_TYPE_PARTIAL wasm/FRAME_TYPE_FULL])
rtx (update (context clock) :hooks merge
{:set-view #(vreset! view %)
:render (fn [_ _]
(vswap! clock + 7)
(let [frame-type (first @remaining)]
(vswap! remaining rest)
(when (= wasm/FRAME_TYPE_FULL frame-type)
(js/queueMicrotask #(vswap! clock + 500)))
frame-type))})
result (await (cmd/execute rtx #(command % options)))]
(t/is (= :completed (:outcome result)))
(t/is (= target @view (get-in result [:context :view])))
(t/is (= 574 @clock))
(if measured
(do
(t/is (= command-id (get-in result [:events 0 :command])))
(t/is (= {:command/elapsed [74]
:renderer/call-elapsed [7 7]
:renderer/cache-call-elapsed [2 2]
:camera/finalization-to-full [33]}
(get-in result [:events 0 :metrics])))
(t/is (= 74 (get-in result [:events 0 :trace :end-ms]))))
(t/is (= [] (:events result))))
(vreset! clock 0)
(let [invalid (await (cmd/execute rtx #(command % (assoc options :steps 0))))]
(t/is (= :failed (:outcome invalid)))
(t/is (= ::cmd/invalid-arguments (:type (ex-data (:error invalid)))))
(t/is (= [] (:events invalid)))
(t/is (zero? @clock))))))
(t/deftest ^:async load-and-restore-finish-at-the-renderer-endpoint
(doseq [[command restore measured] [[camera/load false false] [camera/load* false true]
[camera/restore true false] [camera/restore* true true]]]
(let [clock (volatile! 0)
view (volatile! nil)
target {:scale 2 :x 30 :y -10}
rtx (update (context clock) :hooks merge
{:set-view #(vreset! view %)
:render (fn [_ _]
(vswap! clock + 7)
(js/queueMicrotask #(vswap! clock + 500))
wasm/FRAME_TYPE_FULL)})
result (await (cmd/execute rtx #(if restore
(command (js/Promise.resolve %) target)
(command (js/Promise.resolve %)))))]
(t/is (= :completed (:outcome result)))
(t/is (= (if restore target (:view rtx)) (get-in result [:context :view])))
(when restore (t/is (= target @view)))
(t/is (= (if restore 510 523) @clock))
(when measured
(let [event (first (:events result))]
(t/is (= (if restore ::camera/restore ::camera/load) (:command event)))
(t/is (= [(if restore 10 23)] (get-in event [:metrics :command/elapsed])))
(t/is (= [7] (get-in event [:metrics :renderer/call-elapsed])))
(t/is (= (if restore 10 23) (get-in event [:trace :end-ms])))
(when-not restore
(t/is (= [23] (get-in event [:metrics :renderer/prepared-scene-to-full])))))))))
(t/deftest ^:async invalid-camera-targets-fail-before-changing-renderer-state
(doseq [[command options view]
[[camera/pan* {:dx sm/max-safe-int :dy 0 :steps 1 :settle-ms 0} {:scale 1 :x 1 :y 0}]
[camera/zoom* {:factor sm/max-safe-int :steps 1 :settle-ms 0} {:scale 2 :x 0 :y 0}]
[camera/restore* {:scale 1 :x 10 :y 0 :viewport {:width 800}} {:scale 1 :x 0 :y 0}]]]
(let [clock (volatile! 0)
applied (volatile! view)
rtx (-> (context clock)
(assoc :view view)
(assoc-in [:hooks :set-view] #(vreset! applied %)))
result (await (cmd/execute rtx #(command % options)))]
(t/is (= :failed (:outcome result)))
(t/is (= ::cmd/invalid-arguments (:type (ex-data (:error result)))))
(t/is (= view @applied))
(t/is (zero? @clock)))))
(t/deftest ^:async declared-reference-cases-execute-selected-work-and-reset-the-view
(let [clock (volatile! 0)
descriptor (first (cases/collect-cases {:master-seed 42 :filter "rects/blocking-full"}))
result (await (cmd/execute (assoc (context clock) :case descriptor)
(:run! (decl/registered-case (:id descriptor)))))]
(t/is (= :completed (:outcome result)))
(t/is (= [{:command ::camera/render-sync :metrics {:command/elapsed [7]}}] (:events result))))
(let [clock (volatile! 0)
descriptor (first (cases/collect-cases {:master-seed 42 :filter "rects/mixed-pans"}))
registered (decl/registered-case (:id descriptor))
applied (volatile! (:view descriptor))
initial (-> (context clock) (assoc :case descriptor)
(assoc-in [:hooks :set-view] #(vreset! applied %)))]
(loop [rtx initial remaining 2]
(when (pos? remaining)
(let [main (await (cmd/execute rtx (:run! registered)))
_ (t/is (= {:scale 1 :x 0 :y 80} @applied (get-in main [:context :view])))
reset (await (cmd/execute (:context main) (:reset registered)))]
(t/is (= :completed (:outcome main) (:outcome reset)))
(t/is (= [::camera/pan ::camera/pan] (mapv :command (:events main))))
(t/is (= [::camera/restore] (mapv :command (:events reset))))
(t/is (= (:view descriptor) @applied (get-in reset [:context :view])))
(recur (:context reset) (dec remaining)))))))
(t/deftest ^:async small-positive-zoom-scales-stay-positive-and-reach-the-target
(doseq [[scale factor] [[1 1e-20] [js/Number.MIN_VALUE 1]]]
(let [applied (volatile! {:scale scale :x 0 :y 0})
minimum (volatile! scale)
rtx (-> (context (volatile! 0))
(assoc :view @applied)
(assoc-in [:hooks :set-view]
#(do (vreset! applied %)
(vswap! minimum min (:scale %)))))
result (await (cmd/execute rtx #(camera/zoom* % {:factor factor :steps 2 :settle-ms 0})))]
(t/is (= :completed (:outcome result)))
(t/is (pos? @minimum))
(t/is (= (* scale factor) (:scale @applied) (get-in result [:context :view :scale]))))))
(t/deftest ^:async failed-work-retains-earlier-observations
(let [unhandled (atom [])
observe #(swap! unhandled conj %)]
(.on js/process "unhandledRejection" observe)
(try
(doseq [cause [nil (js/Error. "rejected core")]]
(let [rtx (context (volatile! 0))
result (await (cmd/execute rtx #(-> %
(camera/render-sync*)
(unexpected-promise* cause))))]
(t/is (= :failed (:outcome result)))
(t/is (= ::cmd/async-result (:type (ex-data (:error result)))))
(t/is (= [{:command ::camera/render-sync
:metrics {:command/elapsed [7]}}]
(:events result)))))
(await (js/Promise. #(js/setImmediate %)))
(t/is (= [] @unhandled))
(finally
(.removeListener js/process "unhandledRejection" observe)))))
(t/deftest ^:async execution-failures-preserve-causes-and-earlier-observations
(doseq [cause [(js/Error. "renderer failed")
"renderer disconnected"
{:reason :renderer-disconnected}
(errors/fatal-error "recording failed" {} (js/Error. "storage"))]]
(let [clock (volatile! 0)
rtx (assoc-in (context clock) [:hooks :render-sync]
#(if (zero? @clock)
(vswap! clock + 7)
(throw cause)))
result (await (cmd/execute rtx #(-> %
(camera/render-sync*)
(camera/render-sync*))))
error (:error result)]
(t/is (= :failed (:outcome result)))
(t/is (errors/failure? error))
(if (errors/failure? cause)
(t/is (identical? cause error))
(t/is (= cause (:thrown (ex-data error)))))
(t/is (= [{:command ::camera/render-sync
:metrics {:command/elapsed [7]}}]
(:events result))))))
(t/deftest ^:async invalid-boundaries-fail-the-execution
(doseq [[input command expected]
[[#(assoc-in % [:view :scale] 0) camera/render-sync* ::cmd/invalid-context]
[#(assoc % :module #js {}) camera/render-sync* ::cmd/invalid-context]
[#(update % :hooks dissoc :render-sync) camera/render-sync* ::cmd/invalid-context]
[#(update % :hooks dissoc :frame) camera/render-sync* ::cmd/invalid-context]
[#(assoc-in % [:hooks :sleep] 0) camera/render-sync* ::cmd/invalid-context]
[identity #(reject-offset* % -1) ::cmd/invalid-arguments]]]
(let [clock (volatile! 0)
now-reads (volatile! 0)
rtx (assoc-in (context clock) [:hooks :now]
#(do (vswap! now-reads inc) @clock))
result (await (cmd/execute rtx #(command (input %))))]
(t/is (= :failed (:outcome result)))
(t/is (= expected (:type (ex-data (:error result)))))
(t/is (= [] (:events result)))
(t/is (zero? @now-reads))
(t/is (zero? @clock)))))
(t/deftest ^:async commands-enforce-execution-ownership
(let [rtx (context (volatile! 0))
result (await (cmd/execute rtx replace-owner*))
cause (await (.then (camera/render-sync* rtx) (fn [_] nil) identity))]
(t/is (= :failed (:outcome result)))
(t/is (= ::cmd/ownership-lost (:type (ex-data (:error result)))))
(t/is (= [] (:events result)))
(t/is (= ::cmd/missing-execution (:type (ex-data cause))))))
(t/deftest ^:async completed-context-starts-a-new-execution
(let [first-result (await (cmd/execute (context (volatile! 0))
#(move* % {:dx 10 :dy 5})))
rtx (:context first-result)
late-error (await (.then (camera/render-sync* rtx)
(fn [_] nil)
identity))
next-result (await (cmd/execute rtx camera/render-sync*))]
(t/is (= ::cmd/inactive-execution (:type (ex-data late-error))))
(t/is (= :completed (:outcome next-result)))
(t/is (= {:scale 1 :x 10 :y 5} (get-in next-result [:context :view])))
(t/is (= [{:command ::camera/render-sync
:metrics {:command/elapsed [7]}}]
(:events next-result)))))

View File

@ -0,0 +1,278 @@
;; 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.compare-test
(:require
["fs" :as fs]
["node:stream" :as stream]
["os" :as os]
["path" :as path]
[app.common.transit :as transit]
[benches.render-wasm.node.cli.execution :as execution]
[benches.render-wasm.node.log :as log]
[benches.render-wasm.node.report.compare :as compare]
[benches.render-wasm.report.compare :as report-compare]
[benches.render-wasm.result :as result]
[cljs.test :as t :include-macros true]
[clojure.string :as str]))
(def ^:private temporary-root (atom nil))
(t/use-fixtures :each
{:before #(reset! temporary-root
(fs/mkdtempSync (path/join (os/tmpdir) "penpot-render-compare-")))
:after #(fs/rmSync @temporary-root #js {:recursive true :force true})})
(defn- metric
[value]
{:command :fixture/work :metrics {:command/elapsed [value]}})
(defn- case-definition
[case-id scene]
{:id case-id
:scene scene
:scene-description "comparison scene"
:scene-seed 7
:params {:count 10}
:view {:scale 1 :x 0 :y 0}
:context :fresh
:preparation {}})
(defn- make-run
[run-id case-id value & {:keys [environment functional fingerprint scene case-settings]
:or {environment {:node "v24" :browser "Fake"}
functional {:mode :release}
fingerprint (apply str (repeat 64 "a"))
scene :case}}]
(let [preparation-id (str run-id "-preparation")
run (result/create-run
{:run-id run-id
:started-at "2026-10-26T00:00:00.000Z"
:plan {:cases [(merge (case-definition case-id scene) case-settings)]
:warmups 0
:repetitions 1
:timeout-policy {:case-ms 30000}}
:metadata {:git {:sha run-id :dirty false}
:build {:functional functional}
:environment environment
:scored? true}})
run (result/record-preparation
run
{:preparation-id preparation-id
:case-id case-id :warmup? false
:fingerprint {:algorithm :sha-256
:digest fingerprint}
:effective-graphics {:webgl2 true}
:diagnostics {}
:phases [{:phase :preparation :preparation-id preparation-id
:outcome :completed :events []}]})
run (result/record-attempt
run
{:case-id case-id
:preparation-id preparation-id
:warmup? false
:outcome :completed
:phases [{:phase :main :preparation-id preparation-id
:outcome :completed :events [(metric value)]}]})]
(-> run
(result/record-case-outcome case-id {:outcome :completed})
(result/finish-run "2026-10-26T00:01:00.000Z" {:reason :completed}))))
(defn- write-run
[file run]
(fs/writeFileSync file (result/encode run) "utf8")
file)
(defn- failure
[f]
(try
(f)
nil
(catch :default cause cause)))
(t/deftest compares-saved-records-from-the-caller-cwd
(write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :case/load 10))
(write-run (path/join @temporary-root "candidate.transit")
(make-run "candidate" :case/load 12))
(let [previous (.cwd js/process)]
(try
(.chdir js/process @temporary-root)
(let [{:keys [comparison text]} (compare/execute "baseline.transit"
"candidate.transit" {})]
(t/is (= :comparison (:type comparison)))
(t/is (= {:allow-build-differences false
:allow-scene-differences false
:allow-all-differences false}
(:permissions comparison)))
(t/is (empty? (get-in comparison [:pairs 0 :mismatches])))
(t/is (= 2 (get-in comparison [:pairs 0 :metrics [:main :fixture/work :command/elapsed] :absolute :value])))
(t/is (str/includes? text "Baseline"))
(t/is (str/includes? text "Candidate"))
(t/is (str/includes? text "case/load"))
(t/is (str/includes? text "median change")))
(finally
(.chdir js/process previous)))))
(t/deftest cases-selects-one-explicit-baseline-candidate-pair
(let [baseline (write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :a/pan 10))
candidate (write-run (path/join @temporary-root "candidate.transit")
(make-run "candidate" :b/pan 12))
{:keys [text]} (compare/execute baseline candidate
{:case-pair ["a/pan" "b/pan"]})]
(t/is (str/includes? text "Pair a/pan,b/pan"))))
(t/deftest cases-requires-scene-permission-for-different-scenes
(let [baseline (write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :a/load 10))
candidate (write-run (path/join @temporary-root "candidate.transit")
(make-run "candidate" :b/load 12 :scene :other
:fingerprint (apply str (repeat 64 "b"))))
selector {:case-pair ["a/load" "b/load"]}]
(t/is (= ::report-compare/incompatible
(:type (ex-data (failure #(compare/execute baseline candidate selector))))))
(t/is (= ::report-compare/incompatible
(:type (ex-data (failure #(compare/execute baseline candidate
(assoc selector :allow-build-differences true)))))))
(let [{:keys [comparison text]}
(compare/execute baseline candidate (assoc selector :allow-scene-differences true))]
(t/is (= {:allow-build-differences false
:allow-scene-differences true
:allow-all-differences false}
(:permissions comparison)))
(t/is (= #{:scene :fingerprint}
(set (map :field (get-in comparison [:pairs 0 :mismatches])))))
(t/is (every? #(and (:waived? %) (= :scene (:rule %)))
(get-in comparison [:pairs 0 :mismatches])))
(t/is (str/includes? text "ACKNOWLEDGED DIFFERENCE"))
(t/is (str/includes? text "Pair a/load,b/load")))))
(t/deftest build-permission-allows-build-differences-only
(let [baseline (write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :case/load 10))
candidate (write-run (path/join @temporary-root "candidate.transit")
(make-run "candidate" :case/load 12
:functional {:mode :debug :features ["fixture"]}))]
(doseq [options [{} {:allow-scene-differences true}]]
(t/is (= ::report-compare/incompatible
(:type (ex-data (failure #(compare/execute baseline candidate options)))))))
(let [{:keys [comparison text]}
(compare/execute baseline candidate {:allow-build-differences true})]
(t/is (= {:allow-build-differences true
:allow-scene-differences false
:allow-all-differences false}
(:permissions comparison)))
(t/is (= #{:functional-build :effective-features}
(set (map :field (get-in comparison [:pairs 0 :mismatches])))))
(t/is (every? #(and (:waived? %) (= :build (:rule %)))
(get-in comparison [:pairs 0 :mismatches])))
(t/is (str/includes? text ":rule :build")))))
(t/deftest build-and-scene-permissions-combine
(let [baseline (write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :case/load 10))
candidate (write-run (path/join @temporary-root "candidate.transit")
(make-run "candidate" :case/load 12 :scene :other
:functional {:mode :debug}))]
(doseq [options [{:allow-build-differences true} {:allow-scene-differences true}]]
(t/is (= ::report-compare/incompatible
(:type (ex-data (failure #(compare/execute baseline candidate options)))))))
(let [{:keys [comparison]}
(compare/execute baseline candidate {:allow-build-differences true
:allow-scene-differences true})]
(t/is (= {:allow-build-differences true
:allow-scene-differences true
:allow-all-differences false}
(:permissions comparison)))
(t/is (= {:scene :scene :functional-build :build}
(into {} (map (juxt :field :rule))
(get-in comparison [:pairs 0 :mismatches])))))))
(t/deftest comparison-failure-identifies-incompatible-fields
(let [baseline (write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :case/load 10))
candidate (write-run (path/join @temporary-root "candidate.transit")
(make-run "candidate" :case/load 12
:environment {:node "v25" :browser "Fake"}))
cause (failure #(compare/execute baseline candidate {}))
text (atom "")
stderr (stream/Writable.
#js {:write (fn [chunk _encoding done]
(swap! text str (.toString chunk))
(done))})]
(try
(t/is (some? cause))
(t/is (= 1 (execution/fail cause {:color :never} {:stderr stderr})))
(t/is (str/includes? @text "incompatible saved runs"))
(t/is (str/includes? @text ":environment"))
(finally
(log/configure {})))))
(t/deftest scoped-permissions-keep-view-and-runtime-checks-strict
(let [baseline (write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :case/load 10))]
(doseq [[field settings]
[[:execution {:case-settings {:view {:scale 2 :x 0 :y 0}}}]
[:environment {:environment {:node "v25" :browser "Fake"}}]]]
(let [candidate (write-run (path/join @temporary-root "candidate.transit")
(apply make-run "candidate" :case/load 12 (mapcat identity settings)))
cause (failure #(compare/execute baseline candidate
{:allow-build-differences true
:allow-scene-differences true}))]
(t/is (= ::report-compare/incompatible (:type (ex-data cause))))
(t/is (= [field] (mapv :field (:mismatches (ex-data cause)))))
(t/is (every? (comp not :waived?) (:mismatches (ex-data cause))))))))
(t/deftest all-permission-allows-runtime-differences-and-displays-them
(let [baseline (write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :case/load 10))
candidate (write-run (path/join @temporary-root "candidate.transit")
(make-run "candidate" :case/load 12
:environment {:node "v25" :browser "Fake"}))]
(t/is (= ::report-compare/incompatible
(:type (ex-data (failure #(compare/execute baseline candidate {}))))))
(t/is (= ::report-compare/incompatible
(:type (ex-data (failure #(compare/execute baseline candidate {:force true}))))))
(let [{:keys [comparison text]}
(compare/execute baseline candidate {:allow-all-differences true})]
(t/is (= {:allow-build-differences false
:allow-scene-differences false
:allow-all-differences true}
(:permissions comparison)))
(t/is (= [{:field :environment
:baseline {:node "v24" :browser "Fake"}
:candidate {:node "v25" :browser "Fake"}
:waived? true :rule :all}]
(get-in comparison [:pairs 0 :mismatches])))
(t/is (str/starts-with? text "Permissions "))
(t/is (str/includes? text "ACKNOWLEDGED DIFFERENCE"))
(t/is (str/includes? text ":rule :all")))))
(t/deftest unreadable-records-fail-at-the-boundary
(let [baseline (write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :case/load 10))
candidate (write-run (path/join @temporary-root "candidate.transit")
(make-run "candidate" :case/load 12))]
(let [file (path/join @temporary-root "comparison.transit")
record (:comparison (compare/execute baseline candidate {}))]
(fs/writeFileSync file (result/encode record))
(t/is (thrown-with-msg? js/Error #"expected a saved run record"
(compare/execute baseline file {}))))
(fs/writeFileSync (path/join @temporary-root "broken.transit") "not transit")
(t/is (thrown? js/Error (compare/execute baseline
(path/join @temporary-root "broken.transit")
{})))
(fs/writeFileSync (path/join @temporary-root "malformed.transit")
(transit/encode-str (dissoc (make-run "malformed" :case/load 12) :plan)))
(doseq [options [{} {:allow-all-differences true}]]
(t/is (= ::result/invalid-record
(:type (ex-data (failure #(compare/execute baseline
(path/join @temporary-root "malformed.transit")
options)))))))
(t/is (thrown? js/Error (compare/execute baseline
(path/join @temporary-root "missing.transit")
{})))))

View File

@ -0,0 +1,251 @@
;; 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.containers-test
(:require
[app.common.uuid :as uuid]
[benches.render-wasm.builder :as builder]
[benches.render-wasm.random :as random]
[cljs.test :as t :include-macros true]
[performance-tests.render-wasm.test-helpers :as helpers]))
(t/deftest frame-attaches-children-in-order
(let [instance (builder/scene {:seed 1}
(builder/frame {:x 0 :y 0 :width 100 :height 100 :name "F"}
(builder/rect {:x 10 :y 10 :width 5 :height 5})
(builder/rect {:x 20 :y 20 :width 5 :height 5})))
frame (first (helpers/children-of instance uuid/zero))
rects (helpers/children-of instance (:id frame))]
(t/is (= :frame (:type frame)))
(t/is (= [(:id frame)] (helpers/child-ids instance uuid/zero)))
(t/is (= 2 (count rects)))
(t/is (every? #(= (:id frame) (:parent-id %)) rects))
(t/is (every? #(= (:id frame) (:frame-id %)) rects))
(t/is (= [10 20] (mapv :x rects)))
(t/is (= [10 20] (mapv :y rects)))
(t/is (= [100 100] [(:width frame) (:height frame)]))))
(t/deftest nested-frames-keep-frame-ids
(let [instance (builder/scene {:seed 1}
(builder/frame {:x 0 :y 0 :width 100 :height 100 :name "Outer"}
(builder/frame {:x 10 :y 10 :width 50 :height 50 :name "Inner"}
(builder/rect {:x 20 :y 20 :width 5 :height 5}))))
outer (first (helpers/children-of instance uuid/zero))
inner (first (helpers/children-of instance (:id outer)))
rect (first (helpers/children-of instance (:id inner)))]
(t/is (= uuid/zero (:frame-id outer)))
(t/is (= (:id outer) (:parent-id inner)))
(t/is (= (:id outer) (:frame-id inner)))
(t/is (= (:id inner) (:parent-id rect)))
(t/is (= (:id inner) (:frame-id rect)))
(t/is (= [20 20] [(:x rect) (:y rect)]))))
(t/deftest group-derives-bounds-from-children
(let [instance (builder/scene {:seed 1}
(builder/group {}
(builder/rect {:x 10 :y 20 :width 30 :height 40})
(builder/rect {:x 50 :y 80 :width 10 :height 10})))
group (first (helpers/children-of instance uuid/zero))
rects (helpers/children-of instance (:id group))]
(t/is (= :group (:type group)))
(t/is (= [10 20 50 70]
[(:x group) (:y group) (:width group) (:height group)]))
(t/is (every? #(= uuid/zero (:frame-id %)) rects))
(t/is (every? #(= (:id group) (:parent-id %)) rects))))
(t/deftest nested-groups-derive-inside-out
(let [instance (builder/scene {:seed 1}
(builder/group {:name "Outer"}
(builder/rect {:x 0 :y 0 :width 10 :height 10})
(builder/group {:name "Inner"}
(builder/rect {:x 100 :y 100 :width 10 :height 10}))))
outer (first (helpers/children-of instance uuid/zero))
inner (second (helpers/children-of instance (:id outer)))]
(t/is (= :group (:type inner)))
(t/is (= [100 100 10 10]
[(:x inner) (:y inner) (:width inner) (:height inner)]))
(t/is (= [0 0 110 110]
[(:x outer) (:y outer) (:width outer) (:height outer)]))))
(t/deftest group-inside-frame-points-at-the-frame
(let [instance (builder/scene {:seed 1}
(builder/frame {:x 0 :y 0 :width 200 :height 200}
(builder/group {}
(builder/rect {:x 10 :y 10 :width 10 :height 10}))))
frame (first (helpers/children-of instance uuid/zero))
group (first (helpers/children-of instance (:id frame)))
rect (first (helpers/children-of instance (:id group)))]
(t/is (= (:id frame) (:frame-id group)))
(t/is (= (:id frame) (:frame-id rect)))
(t/is (= (:id group) (:parent-id rect)))))
(t/deftest masked-group-takes-the-first-child-geometry
(let [instance (builder/scene {:seed 1}
(builder/group {:masked-group true}
(builder/rect {:x 10 :y 20 :width 30 :height 40})
(builder/rect {:x 0 :y 0 :width 500 :height 500})))
group (first (helpers/children-of instance uuid/zero))]
(t/is (true? (:masked-group group)))
(t/is (= [10 20 30 40]
[(:x group) (:y group) (:width group) (:height group)]))))
(t/deftest container-labels-are-snapshot-local
(let [instance (builder/scene {:seed 1}
(builder/frame :hero {:x 0 :y 0 :width 10 :height 10}
(builder/group [:g 1] {}
(builder/rect))))
refs (:refs instance)]
(t/is (= #{:hero [:g 1]} (set (keys refs))))
(t/is (= (get refs :hero) (first (helpers/child-ids instance uuid/zero))))
(t/is (= (get refs [:g 1]) (first (helpers/child-ids instance (get refs :hero)))))
(t/is (= 1 (count (helpers/child-ids instance uuid/zero))))))
(t/deftest container-expressions-evaluate-once-in-order
(let [events (atom [])
instance (builder/scene {:seed 1}
(builder/frame [:frame (do (swap! events conj :label) 3)]
(do (swap! events conj :attrs)
{:x 0 :y 0 :width 20 :height 20})
(swap! events conj :body)
(builder/rect {:x 10})))
frame-id (get (:refs instance) [:frame 3])]
(t/is (= [:attrs :label :body] @events))
(t/is (= [10] (mapv :x (helpers/children-of instance frame-id))))))
(t/deftest invalid-container-attrs-retain-seeded-construction-order
(doseq [construct [#(builder/frame :hero :invalid)
#(builder/group :hero :invalid)
#(builder/bool :hero :invalid)]]
(let [rng (random/create 7)
failed-id (random/rng-uuid rng)
next-id (random/rng-uuid rng)
failure (volatile! nil)
state (helpers/run-scope
{:seed 7}
(fn []
(vreset! failure (helpers/failure-data construct))
(builder/rect)))
instance (builder/finish! state)]
(t/is (= {:type ::builder/invalid-attrs :id failed-id} @failure))
(t/is (= [next-id] (helpers/child-ids instance uuid/zero))))))
(t/deftest empty-group-is-rejected
(let [data (helpers/failure-data #(builder/scene {:seed 1} (builder/group {})))]
(t/is (= ::builder/empty-group (:type data)))))
(t/deftest group-geometry-attrs-are-rejected
(let [data (helpers/failure-data
#(builder/scene {:seed 1}
(builder/group {:x 1}
(builder/rect))))]
(t/is (= ::builder/group-geometry (:type data)))
(t/is (= :x (:key data)))))
(t/deftest frame-requires-bounds
(let [data (helpers/failure-data
#(builder/scene {:seed 1}
(builder/frame {:name "F"}
(builder/rect))))]
(t/is (= ::builder/frame-bounds (:type data)))))
(t/deftest runtime-attrs-expressions-are-accepted
(let [frame-attrs {:x 0 :y 0 :width 10 :height 10}
instance (builder/scene {:seed 1}
(builder/frame frame-attrs
(builder/rect)))
frame (first (helpers/children-of instance uuid/zero))]
(t/is (= :frame (:type frame)))
(t/is (= 10 (:width frame)))))
(t/deftest non-map-attrs-are-rejected
(t/is (= ::builder/invalid-attrs
(:type (helpers/failure-data
#(builder/scene {:seed 1}
(builder/frame :hero (builder/rect)))))))
(let [state (helpers/run-scope {:seed 1}
(fn []
(try
(builder/frame :hero (builder/rect))
(catch :default _ nil))))]
(t/is (= 1 (count (helpers/child-ids @state uuid/zero))))))
(t/deftest non-numeric-frame-bounds-are-rejected
(let [data (helpers/failure-data
#(builder/scene {:seed 1}
(builder/frame {:x 0 :y 0 :width "100" :height 10}
(builder/rect))))]
(t/is (= ::builder/frame-bounds (:type data)))
(t/is (= :width (:invalid data)))))
(t/deftest duplicate-container-labels-are-rejected
(let [data (helpers/failure-data
#(builder/scene {:seed 1}
(builder/frame :dup {:x 0 :y 0 :width 10 :height 10}
(builder/rect))
(builder/group :dup {}
(builder/rect))))]
(t/is (= ::builder/duplicate-label (:type data)))
(t/is (= :dup (:label data)))))
(t/deftest frame-inside-group-keeps-frame-ids
(let [instance (builder/scene {:seed 1}
(builder/group {}
(builder/frame {:x 0 :y 0 :width 50 :height 50}
(builder/rect))))
group (first (helpers/children-of instance uuid/zero))
frame (first (helpers/children-of instance (:id group)))
rect (first (helpers/children-of instance (:id frame)))]
(t/is (= (:id group) (:parent-id frame)))
(t/is (= uuid/zero (:frame-id frame)))
(t/is (= (:id frame) (:parent-id rect)))
(t/is (= (:id frame) (:frame-id rect)))))
(t/deftest group-of-container-derives-bounds
(let [instance (builder/scene {:seed 1}
(builder/group {}
(builder/group {}
(builder/rect {:x 5 :y 5 :width 10 :height 10}))))
outer (first (helpers/children-of instance uuid/zero))
inner (first (helpers/children-of instance (:id outer)))]
(t/is (= [5 5 10 10]
[(:x outer) (:y outer) (:width outer) (:height outer)]))
(t/is (= [5 5 10 10]
[(:x inner) (:y inner) (:width inner) (:height inner)]))
(t/is (= 1 (count (helpers/child-ids instance (:id outer)))))))
(t/deftest empty-frame-is-allowed
(let [instance (builder/scene {:seed 1}
(builder/frame {:x 0 :y 0 :width 10 :height 10}))
frame (first (helpers/children-of instance uuid/zero))]
(t/is (= [] (helpers/child-ids instance (:id frame))))))
(t/deftest caught-empty-group-still-fails-finish
(let [state (helpers/run-scope {:seed 1}
(fn []
(try
(builder/group {})
(catch :default _ nil))))]
(t/is (= ::builder/unfinished-container
(:type (helpers/failure-data #(builder/finish! state)))))))
(t/deftest container-failure-restores-parent-and-marks-unfinished
(let [state (helpers/run-scope {:seed 1}
(fn []
(builder/rect {:x 0})
(try
(builder/frame {:x 0 :y 0 :width 10 :height 10}
(builder/rect :dup {:x 1})
(builder/rect :dup {:x 2}))
(catch :default _ nil))
(builder/rect {:x 3})))
root-children (helpers/child-ids @state uuid/zero)
last-child (get-in @state [:objects (last root-children)])]
(t/is (= 3 (count root-children)))
(t/is (= :rect (:type last-child)))
(t/is (= 3 (:x last-child)))
(t/is (= 1 (count (:unfinished @state))))
(t/is (= ::builder/unfinished-container
(:type (helpers/failure-data #(builder/finish! state)))))))

View File

@ -0,0 +1,445 @@
;; 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.contracts-test
(:require
[app.common.schema :as sm]
[app.common.transit :as transit]
[benches.render-wasm.cases :as cases]
[benches.render-wasm.declarations :as decl]
[cljs.test :as t :include-macros true]))
(defrecord Point [x y])
(def ^:private body-calls
(atom []))
(defn- touch!
[rtx]
(swap! body-calls conj :touched)
rtx)
(decl/defscene :contracts-scene
{:description "Synthetic scene for contract tests"
:params-schema [:map {:closed true}]}
[params]
params)
(decl/defcase :contracts-scene/case
{:params {}
:view {:scale 1 :x 0 :y 0}
:context :fresh}
[ctx]
(touch! ctx))
(defn- failure-data
[f]
(try
(f)
nil
(catch :default cause
(ex-data cause))))
(defn- rect-cases
([] (rect-cases 42 "rects/"))
([master-seed filter]
(cases/collect-cases {:master-seed master-seed :filter filter})))
(defn- with-synthetic-scene
"Registers a synthetic scene and case under `scene-id`, runs `f`, then
unregisters both so the live registry survives."
[scene-id case-params f]
(decl/register-scene! {:id scene-id
:ns "contracts-test"
:description "synthetic"
:params-schema [:map {:closed true} [:count [:int {:min 1 :max 100000}]]]
:build (fn [params] params)})
(decl/register-case! {:id (keyword (name scene-id) "case")
:scene scene-id
:ns "contracts-test"
:params case-params
:view {:scale 1 :x 0 :y 0}
:context :fresh})
(try
(f)
(finally
(decl/unregister-case! (keyword (name scene-id) "case"))
(decl/unregister-scene! scene-id))))
(t/deftest defscene-registers-metadata-and-build
(let [scene (decl/registered-scene :rects)]
(t/is (= :rects (:id scene)))
(t/is (= "benches.render-wasm.scenes.rects" (:ns scene)))
(t/is (= "Seeded rectangles with translucent fills and centered strokes"
(:description scene)))
(t/is (vector? (:params-schema scene)))
(t/is (fn? (:build scene)))))
(t/deftest rects-cases-are-collected-in-declaration-order
(let [collected (rect-cases)]
(t/is (= [:rects/load :rects/pan :rects/zoom :rects/blocking-full :rects/mixed-pans] (mapv :id collected)))))
(t/deftest collected-cases-survive-the-wire
(doseq [scene-id [:rects :paths :plus :masks]]
(let [collected (cases/collect-cases {:master-seed 42
:filter (str (name scene-id) "/")})
decoded (transit/decode-str (transit/encode-str collected))]
(t/is (= collected decoded))
(doseq [case-desc decoded]
(t/is (= case-desc (decl/check-collected-case case-desc)))
(t/is (not-any? #(contains? case-desc %)
[:run! :ns])
(str (:id case-desc)))))))
(t/deftest declared-preparation-survives-collection-and-wire
(with-synthetic-scene :contracts-prepared {:count 3}
(fn []
(let [preparation {:render :none :settle-ms 25}
registered (decl/registered-case :contracts-prepared/case)]
(decl/register-case! (assoc registered
:preparation (assoc preparation :callback identity)
:reset identity))
(let [collected (cases/collect-cases {:master-seed 42 :filter "contracts-prepared/"})
decoded (transit/decode-str (transit/encode-str collected))]
(t/is (= [:contracts-prepared/case] (mapv :id decoded)))
(t/is (= collected decoded))
(t/is (= preparation (:preparation (first decoded))))
(t/is (= (first decoded) (decl/check-collected-case (first decoded)))))
(decl/register-case! (assoc registered :preparation {:asset {:loader identity}}))
(t/is (= ::cases/invalid-case
(:type (failure-data #(cases/collect-cases {:master-seed 42 :filter "contracts-prepared/"})))))))))
(t/deftest rects-cases-carry-declared-view-context-and-preparation
(let [collected (rect-cases)]
(t/is (= {:rects/load :fresh :rects/pan :reuse :rects/zoom :reuse
:rects/blocking-full :fresh :rects/mixed-pans :reuse}
(into {} (map (juxt :id :context)) collected)))
(doseq [case-desc collected]
(t/is (= {:count 1000 :width 1920 :height 1080 :min-size 20 :max-size 100}
(:params case-desc))
(str (:id case-desc)))
(t/is (= {:scale 1 :x 0 :y 0} (:view case-desc)) (str (:id case-desc)))
(t/is (= (if (#{:rects/load :rects/blocking-full} (:id case-desc)) {:render :none} {})
(:preparation case-desc)) (str (:id case-desc))))))
(t/deftest rects-cases-carry-scene-metadata-and-seed
(let [collected (rect-cases)]
(doseq [case-desc collected]
(t/is (= :rects (:scene case-desc)))
(t/is (= "Seeded rectangles with translucent fills and centered strokes"
(:scene-description case-desc)))
(t/is (= 2602546180 (:scene-seed case-desc)) (str (:id case-desc)))))
(let [collected (rect-cases 43 "rects/")]
(t/is (every? #(= 2993218129 (:scene-seed %)) collected))))
(t/deftest derive-seed-depends-on-scene-and-params-only
(let [params {:count 1000 :width 1920 :height 1080 :min-size 20 :max-size 100}
base (decl/derive-seed 42 :rects params)]
(t/is (= 2602546180 base))
(t/is (= base (decl/derive-seed 42 :rects (into {} (reverse (seq params))))))
(t/is (= 2993218129 (decl/derive-seed 43 :rects params)))
(t/is (not= base (decl/derive-seed 42 :rects (assoc params :count 10))))
(t/is (not= base (decl/derive-seed 42 :paths params)))))
(t/deftest derive-seed-rejects-keys-it-cannot-tell-apart
(t/is (= ::decl/invalid-params
(:type (failure-data #(decl/derive-seed 42 :rects {"count" 1})))))
(t/is (= ::decl/invalid-params
(:type (failure-data #(decl/derive-seed 42 :rects {:rects/count 1})))))
(t/is (= :rects (:scene (failure-data #(decl/derive-seed 42 :rects {"count" 1}))))))
(t/deftest unknown-params-are-rejected
(with-synthetic-scene :contracts-unknown-params {:count 1 :bogus 2}
(fn []
(let [data (failure-data
#(cases/collect-cases {:master-seed 42
:filter "contracts-unknown-params/"}))]
(t/is (= ::cases/invalid-case (:type data)))
(t/is (some? (::sm/explain data)))))))
(t/deftest out-of-range-count-is-rejected
(with-synthetic-scene :contracts-bad-count {:count 0}
(fn []
(let [data (failure-data
#(cases/collect-cases {:master-seed 42
:filter "contracts-bad-count/"}))]
(t/is (= ::cases/invalid-case (:type data)))
(t/is (some? (::sm/explain data)))))))
(t/deftest rects-workload-ranges-are-rejected
(let [schema (:params-schema (decl/registered-scene :rects))
good {:count 1000 :width 1920 :height 1080 :min-size 20 :max-size 100}]
(t/is (true? (sm/validate schema good)))
(t/testing "reversed size range"
(t/is (false? (sm/validate schema (assoc good :min-size 100 :max-size 20)))))
(t/testing "non-finite sizes"
(doseq [bad [(assoc good :width js/Infinity)
(assoc good :height js/NaN)
(assoc good :min-size js/Infinity)
(assoc good :max-size js/NaN)]]
(t/is (false? (sm/validate schema bad)) (pr-str bad))))))
(t/deftest view-coordinates-are-finite
(let [good {:scale 1 :x 0 :y 0}]
(t/is (true? (sm/validate decl/schema:view good)))
(doseq [bad [(assoc good :scale js/Infinity)
(assoc good :x js/NaN)
(assoc good :y js/Infinity)
(assoc good :viewport {:width js/NaN})]]
(t/is (false? (sm/validate decl/schema:view bad)) (pr-str bad)))))
(t/deftest transit-rejects-unencodable-values
(t/is (false? (decl/transit-round-trips? {:v (fn [] 1)})))
(t/is (false? (decl/transit-round-trips? {:point (->Point 1 2)}))))
(t/deftest transit-wire-preserves-json-lossy-shapes
(doseq [value [{:foo 1 "foo" 2}
{:a/b 1 :c/b 2}
{:id :rects/load :scene :rects}
{:tags [:fresh :rects/load]}
{:v js/Infinity}
{:v js/-Infinity}]]
(t/is (= value (-> value transit/encode-str transit/decode-str))
(pr-str value))))
(t/deftest transit-wire-preserves-non-finite-numbers
(let [value {:nan js/NaN :positive js/Infinity :negative js/-Infinity}
decoded (-> value transit/encode-str transit/decode-str)
case-desc (assoc (first (rect-cases)) :params value)]
(t/is (number? (:nan decoded)))
(t/is (js/Number.isNaN (:nan decoded)))
(t/is (= js/Infinity (:positive decoded)))
(t/is (= js/-Infinity (:negative decoded)))
(t/is (decl/transit-round-trips? value))
(t/is (nil? (failure-data #(decl/check-collected-case case-desc))))))
(t/deftest unencodable-collected-values-are-rejected
(let [good (first (rect-cases))]
(doseq [bad [(assoc good :params {:run (fn [] 1)})
(assoc good :params {:point (->Point 1 2)})]]
(t/is (= ::decl/non-serializable-case
(:type (failure-data #(decl/check-collected-case bad))))
(pr-str bad)))))
(t/deftest duplicate-scene-id-from-another-namespace-is-rejected
(t/is (= ::decl/duplicate-scene
(:type (failure-data
#(decl/register-scene! {:id :rects
:ns "another-namespace"
:description "x"
:params-schema [:map]
:build (fn [params] params)}))))))
(t/deftest scene-replacement-preserves-order-and-unregister-removes
(let [ids [:contracts-first :contracts-middle :contracts-last]
scene {:ns "contracts-test" :description "first"
:params-schema [:map] :build identity}
get-scenes #(filterv (comp (set ids) :id) (decl/scenes))]
(try
(doseq [id ids]
(decl/register-scene! (assoc scene :id id)))
(decl/register-scene! (assoc scene :id :contracts-middle :description "second"))
(t/is (= ids (mapv :id (get-scenes))))
(t/is (= ["first" "second" "first"] (mapv :description (get-scenes))))
(decl/unregister-scene! :contracts-middle)
(t/is (= [:contracts-first :contracts-last] (mapv :id (get-scenes))))
(t/is (nil? (decl/registered-scene :contracts-middle)))
(finally
(doseq [id ids]
(decl/unregister-scene! id))))))
(t/deftest duplicate-case-id-from-another-namespace-is-rejected
(try
(decl/register-scene! {:id :contracts-dup
:ns "contracts-test"
:description "x"
:params-schema [:map]
:build (fn [params] params)})
(decl/register-case! {:id :contracts-dup/case
:scene :contracts-dup
:ns "contracts-test"
:params {}
:view {:scale 1 :x 0 :y 0}
:context :fresh})
(t/is (= ::decl/duplicate-case
(:type (failure-data
#(decl/register-case! {:id :contracts-dup/case
:scene :contracts-dup
:ns "another-namespace"
:params {}
:view {:scale 1 :x 0 :y 0}
:context :fresh})))))
(finally
(decl/unregister-case! :contracts-dup/case)
(decl/unregister-scene! :contracts-dup))))
(t/deftest unknown-scene-reference-is-rejected
(try
(decl/register-case! {:id :contracts-missing/case
:scene :contracts-missing
:ns "contracts-test"
:params {}
:view {:scale 1 :x 0 :y 0}
:context :fresh})
(t/is (= ::cases/unknown-scene
(:type (failure-data
#(cases/collect-cases {:master-seed 42
:filter "contracts-missing/"})))))
(finally
(decl/unregister-case! :contracts-missing/case))))
(t/deftest case-id-must-be-namespaced-by-its-scene
(try
(decl/register-scene! {:id :contracts-other
:ns "contracts-test"
:description "x"
:params-schema [:map]
:build (fn [params] params)})
(decl/register-case! {:id :contracts-other/case
:scene :rects
:ns "contracts-test"
:params {}
:view {:scale 1 :x 0 :y 0}
:context :fresh})
(t/is (= ::cases/invalid-case
(:type (failure-data
#(cases/collect-cases {:master-seed 42
:filter "contracts-other/"})))))
(finally
(decl/unregister-case! :contracts-other/case)
(decl/unregister-scene! :contracts-other))))
(t/deftest scene-without-params-schema-is-rejected
(try
(decl/register-scene! {:id :contracts-noschema
:ns "contracts-test"
:description "x"
:build (fn [params] params)})
(t/is (= ::decl/invalid-scene
(:type (failure-data #(cases/collect-cases {:master-seed 42})))))
(finally
(decl/unregister-scene! :contracts-noschema))))
(t/deftest malformed-params-schema-is-rejected
(try
(decl/register-scene! {:id :contracts-badschema
:ns "contracts-test"
:description "x"
:params-schema [:map [:count]]
:build (fn [params] params)})
(let [data (failure-data #(cases/collect-cases {:master-seed 42}))]
(t/is (= ::decl/invalid-scene (:type data)))
(t/is (= :contracts-badschema (:id data))))
(finally
(decl/unregister-scene! :contracts-badschema))))
(t/deftest qualified-scene-id-is-rejected
(try
(decl/register-scene! {:id :contracts-ns/scene
:ns "contracts-test"
:description "x"
:params-schema [:map]
:build (fn [params] params)})
(t/is (= ::decl/invalid-scene
(:type (failure-data #(cases/collect-cases {:master-seed 42})))))
(finally
(decl/unregister-scene! :contracts-ns/scene))))
(t/deftest invalid-declarations-fail-outside-the-filter
(with-synthetic-scene :contracts-global {:count 1 :bogus 2}
(fn []
(t/is (= ::cases/invalid-case
(:type (failure-data #(cases/collect-cases {:master-seed 42
:filter "rects/"}))))))))
(t/deftest incomplete-case-declaration-is-rejected
(try
(decl/register-scene! {:id :contracts-incomplete
:ns "contracts-test"
:description "x"
:params-schema [:map]
:build (fn [params] params)})
(decl/register-case! {:id :contracts-incomplete/case
:scene :contracts-incomplete
:ns "contracts-test"
:params {}
:context :fresh})
(t/is (= ::cases/invalid-case
(:type (failure-data #(cases/collect-cases {:master-seed 42
:filter "contracts-incomplete/"})))))
(finally
(decl/unregister-case! :contracts-incomplete/case)
(decl/unregister-scene! :contracts-incomplete))))
(t/deftest records-do-not-survive-the-wire
(let [point (->Point 1 2)]
(t/is (false? (decl/transit-round-trips? point)))
(t/is (false? (decl/transit-round-trips? {:point point})))))
(t/deftest case-replacement-preserves-order-and-unregister-removes
(let [ids [:contracts-replacecase/first :contracts-replacecase/middle :contracts-replacecase/last]
bench-case {:scene :contracts-replacecase :ns "contracts-test"
:params {} :view {:scale 1 :x 0 :y 0} :context :fresh}
get-cases #(filterv (comp (set ids) :id) (decl/cases))]
(try
(decl/register-scene! {:id :contracts-replacecase :ns "contracts-test"
:description "x" :params-schema [:map] :build identity})
(doseq [id ids]
(decl/register-case! (assoc bench-case :id id)))
(decl/register-case! (assoc bench-case :id :contracts-replacecase/middle :view {:scale 2 :x 0 :y 0}))
(t/is (= ids (mapv :id (get-cases))))
(t/is (= [1 2 1] (mapv #(get-in % [:view :scale]) (get-cases))))
(decl/unregister-case! :contracts-replacecase/middle)
(t/is (= [:contracts-replacecase/first :contracts-replacecase/last] (mapv :id (get-cases))))
(t/is (nil? (decl/registered-case :contracts-replacecase/middle)))
(finally
(doseq [id ids]
(decl/unregister-case! id))
(decl/unregister-scene! :contracts-replacecase)))))
(t/deftest invalid-master-seed-is-rejected
(doseq [seed [-1 4294967296 "42" nil 0.5 js/NaN js/Infinity js/-Infinity]]
(t/is (= ::cases/invalid-master-seed
(:type (failure-data #(cases/collect-cases {:master-seed seed}))))
(str seed))))
(t/deftest filter-matches-case-ids
(t/is (= [:rects/pan]
(mapv :id (cases/collect-cases {:master-seed 42 :filter "rects/pan"}))))
(t/is (= 5 (count (cases/collect-cases {:master-seed 42 :filter "rects/"})))))
(t/deftest empty-selection-is-rejected
(t/is (= ::cases/no-cases
(:type (failure-data #(cases/collect-cases {:master-seed 42
:filter "missing"}))))))
(t/deftest defcase-registers-function-without-sending-it
(reset! body-calls [])
(let [collected (cases/collect-cases {:master-seed 42 :filter "contracts-scene/"})]
(t/is (= [:contracts-scene/case] (mapv :id collected)))
(t/is (not (contains? (first collected) :run!)))
(t/is (decl/transit-round-trips? (first collected))))
(t/is (empty? @body-calls))
(let [entry (decl/registered-case :contracts-scene/case)]
(t/is (= "performance-tests.render-wasm.contracts-test" (:ns entry)))
(t/is (fn? (:run! entry)))
(t/is (= :runtime ((:run! entry) :runtime)))
(t/is (= [:touched] @body-calls))))
(t/deftest defcase-rejects-run-function-in-options
(t/is (= :benches.render-wasm.declarations/invalid-run-function
(:type (failure-data
#(decl/defcase :contracts-scene/unchecked
{:run! (fn [x y] [x y])})))))
(t/is (nil? (decl/registered-case :contracts-scene/unchecked))))
(t/deftest defcase-rejects-unqualified-id
(t/is (= :benches.render-wasm.declarations/invalid-run-function
(:type (failure-data
#(decl/defcase :bare
{:params {}
:view {:scale 1 :x 0 :y 0}
:context :fresh}))))))

View File

@ -0,0 +1,248 @@
;; 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.errors-test
(:require
[app.common.schema :as sm]
[app.common.transit :as transit]
[benches.render-wasm.errors :as errors :refer [attempt failure?]]
[cljs.test :as t :include-macros true]
[clojure.string :as str]))
(t/deftest attempt-returns-the-value-of-its-body
(t/is (= 3 (attempt 1 2 3)))
(t/is (nil? (attempt nil)))
(t/is (= {:error :not-a-failure} (attempt {:error :not-a-failure}))))
(t/deftest composed-failures-retain-occurrence-order-and-fatal-propagation
(let [upload (js/Error. "upload failed")
end (js/Error. "ending load failed")
close (js/Error. "browser disposal failed")
save (js/Error. "saving evidence failed")
initial (errors/combine [{:phase "upload" :operation :serialize-shapes :error upload}
{:phase "upload" :operation :end-loading :error end}])
wrapped (errors/fatal-error "preparation failed"
{::errors/stale true :partial {:setup {:upload-ms 7}}}
(errors/with-context initial "preparation failed"
{:phase "preparation" :path "scene.transit"}))
later (errors/combine [{:phase "cleanup" :operation :close-browser :error close}
{:phase "storage" :operation :save-evidence :error save}])
later (errors/fatal-error "cleanup failed"
{:partial {:setup {:upload-ms 9}}
:command "/bin/chromium" :exit-code 17}
(errors/with-data later {:partial {:render {:frames 2}}}))
combined (errors/combine [{:error wrapped} {:error later}])
entries (errors/get-errors combined)
deep (nth (iterate #(ex-info "wrapped" {} %) combined) 12)
wire (-> (errors/evidence :runner deep)
transit/encode-str transit/decode-str errors/project-diagnostic)]
(t/is (= [upload end close save] (mapv :error entries)))
(t/is (= [:serialize-shapes :end-loading :close-browser :save-evidence]
(mapv :operation entries)))
(t/is (= "scene.transit" (:path (first entries))))
(t/is (= {:setup {:upload-ms 7}} (:partial (first entries))))
(t/is (errors/stale? combined))
(t/is (= (mapv errors/message-of [upload end close save]) (mapv :message (:errors wire))))
(t/is (= {:command "/bin/chromium" :exit-code 17
:partial {:setup {:upload-ms 9} :render {:frames 2}}}
(select-keys (nth (:errors wire) 2) [:command :exit-code :partial])))
(t/is (sm/validate errors/schema:diagnostic wire))
(t/is (errors/fatal? deep))
(t/is (errors/fatal? (errors/combine [{:error upload} {:error later}])))
(t/is (try
(attempt (throw combined))
false
(catch :default cause
(= [upload end close save] (mapv :error (errors/get-errors cause))))))))
(t/deftest ordinary-failures-project-operation-and-path-context
(let [cause (ex-info "record unavailable" {:operation :read-record :path "saved/run.transit"})
wire (-> (errors/evidence :storage cause) transit/encode-str transit/decode-str)]
(t/is (= {:operation :read-record :path "saved/run.transit"}
(select-keys wire [:operation :path])))
(t/is (sm/validate errors/schema:diagnostic wire))))
(t/deftest attempt-returns-the-error-its-body-throws
(let [cause (ex-info "boom" {:a 1})
result (attempt (throw cause))]
(t/is (failure? result))
(t/is (identical? cause result))))
(t/deftest attempt-turns-a-thrown-value-into-an-error-that-keeps-its-text
(let [text (attempt (throw "device disconnected"))
object (attempt (throw #js {:code 7}))]
(t/is (failure? text))
(t/is (= "device disconnected" (ex-message text)))
(t/is (failure? object))
(t/is (str/includes? (ex-message object) "code"))))
(t/deftest attempt-throws-fatal-failures-again
(let [cause (errors/fatal-error "write failed" {:path "a"} (js/Error. "disk"))]
(t/is (thrown-with-msg? js/Error #"write failed"
(attempt (throw cause))))
(t/is (identical? cause (attempt :catch-fatal (throw cause))))))
(t/deftest attempt-treats-a-fatal-cause-deep-in-the-chain-as-fatal
(let [fatal (errors/fatal-error "write failed" {} nil)
outer (ex-info "outer" {} (ex-info "middle" {} fatal))]
(t/is (true? (errors/fatal? outer)))
(t/is (thrown-with-msg? js/Error #"outer" (attempt (throw outer))))))
(t/deftest fatal-marker-is-not-assumed-from-other-data
(t/is (false? (errors/fatal? (ex-info "x" {:type :anything}))))
(t/is (false? (errors/fatal? nil)))
(t/is (false? (errors/fatal? "text"))))
(t/deftest ^:async attempt-awaits-promises-in-an-async-function
(let [value (attempt (await (js/Promise.resolve 7)))
failure (attempt (await (js/Promise.reject (ex-info "rejected" {}))))
bound (let [x (attempt :catch-fatal (await (js/Promise.resolve 1)) 2)] x)]
(t/is (= 7 value))
(t/is (= "rejected" (ex-message failure)))
(t/is (= 2 bound))))
(t/deftest ^:async attempt-keeps-fatal-rejections-fatal
(let [cause (errors/fatal-error "write failed" {} nil)
outer (try
(attempt (await (js/Promise.reject cause)))
nil
(catch :default thrown thrown))]
(t/is (identical? cause outer))))
(t/deftest with-data-passes-non-failures-through
(t/is (= 1 (errors/with-data 1 {:a 1})))
(t/is (nil? (errors/with-data nil {:a 1})))
(t/is (= {:a 1} (errors/with-data {:a 1} {:a 2}))))
(t/deftest with-data-adds-keys-under-the-failure-data
(let [cause (ex-info "boom" {:log-path "inner"})
result (errors/with-data cause {:log-path "outer" :build-id "b"})]
(t/is (= "boom" (ex-message result)))
(t/is (= {:log-path "inner" :build-id "b"} (ex-data result)))
(t/is (identical? cause (ex-cause result)))))
(t/deftest message-of-falls-back-to-the-string-form
(t/is (= "boom" (errors/message-of (ex-info "boom" {}))))
(t/is (= "text" (errors/message-of "text"))))
(t/deftest context-keeps-deeper-measurements-and-native-cause-details
(let [native (ex-info "render failed" {:fn "_render" :code 17})
cause (ex-info "interact failed" {:phase "interact" :partial {:slices [1 2]}} native)
error (errors/with-context cause "setup failed"
{:phase "setup" :partial {:slices [1] :setup {:upload-ms 3}}})
result (errors/evidence :setup error)]
(t/is (= {:phase "setup" :partial {:slices [1 2] :setup {:upload-ms 3}}}
(ex-data error)))
(t/is (= "setup failed" (:message result)))
(t/is (some #(= {:message "render failed" :fn "_render" :code 17} %)
(:cause result)))))
(t/deftest context-keeps-native-errors-as-diagnostic-causes
(let [error (errors/with-context (js/Error. "device lost") "restore failed" {:phase "restore"})]
(t/is (some #(= "device lost" (:message %)) (errors/describe-cause error)))))
(t/deftest hostile-cause-renders-plain-data
(let [hostile (ex-info "boom"
{:fn (fn [] 1) :code #js {:evil 1} :type :wasm-error
:hint (str (apply str (repeat 499 "x")) "😀")}
(js/Error. "inner boom"))
result (errors/evidence :aborted hostile)
decoded (-> result transit/encode-str transit/decode-str)
outer (first (:cause decoded))]
(t/is (= "aborted" (:phase decoded)))
(t/is (= "boom" (:message decoded)))
(t/is (string? (:fn outer)))
(t/is (string? (:code outer)))
(t/is (= (apply str (repeat 499 "x")) (:hint outer)))
(t/is (some #(= "inner boom" (:message %)) (:cause decoded)))))
(t/deftest diagnostics-retain-bounded-identity-and-process-details
(let [long-text (str (apply str (repeat 1023 "x")) "😀")
context {:expected-id long-text :actual-id "other-build"
:command "compiler" :exit-code 23 :signal "SIGTERM"
:endpoint "ws://127.0.0.1/devtools/browser/owner"}
cause (ex-info "failed" context)
decoded (-> (errors/evidence :identity cause)
errors/project-diagnostic
transit/encode-str
transit/decode-str)]
(t/is (= (assoc context :expected-id (apply str (repeat 1023 "x")))
(select-keys decoded (keys context))))))
(t/deftest unprintable-values-produce-portable-diagnostics
(let [evil (js-obj)]
(js/Object.defineProperty evil "evil"
#js {:enumerable true
:get (fn [] (throw (js/Error. "evil")))})
(let [data (errors/evidence :upload (ex-info "evil boom" {:fn evil}))
cause (errors/evidence :upload evil)
decoded (-> [data cause] transit/encode-str transit/decode-str)]
(t/is (= "unrenderable value" (get-in decoded [0 :cause 0 :fn])))
(t/is (= "unrenderable cause" (get-in decoded [1 :cause 0 :message]))))))
(t/deftest diagnostic-text-is-bounded-without-splitting-surrogate-pairs
(let [message (str (apply str (repeat 1023 "x")) "😀" "tail")
cause (ex-info message {:command message})
data (-> (errors/evidence :main cause) transit/encode-str transit/decode-str)
hostile (js/Error.)]
(set! (.-message hostile) #js {:toString #(throw (js/Error. "cannot print"))})
(t/is (= (apply str (repeat 1023 "x")) (:message data)))
(t/is (<= (count (:command data)) 1024))
(t/is (sm/validate errors/schema:diagnostic (errors/evidence :main hostile)))))
(t/deftest malformed-partial-data-retains-the-failure-and-representable-evidence
(let [cycle #js {}]
(aset cycle "self" cycle)
(doseq [invalid [(fn [] nil) cycle (js/AbortController.)]
composed? [false true]]
(let [failure (ex-info "phase failed" {:partial {:safe {:elapsed [0 js/Infinity js/NaN]}
:handle invalid}})
failure (if composed?
(errors/combine [{:error failure} {:error (js/Error. "cleanup failed")}])
failure)
data (errors/evidence :main failure)
wire (try
(-> data transit/encode-str transit/decode-str)
(catch :default _ nil))]
(t/is (= "phase failed" (:message wire)))
(t/is (= "phase failed" (get-in wire [:cause 0 :message])))
(t/is (= 0 (get-in wire [:partial :safe :elapsed 0])))
(t/is (= js/Infinity (get-in wire [:partial :safe :elapsed 1])))
(t/is (js/Number.isNaN (get-in wire [:partial :safe :elapsed 2])))
(t/is (= 1 (count (filter #(= :encode-diagnostic (:operation %)) (:errors wire)))))))))
(t/deftest unbounded-partial-data-produces-an-encoding-diagnostic
(let [data (errors/evidence :main (ex-info "phase failed" {:partial {:trace (repeat 0)}}))
wire (-> data transit/encode-str transit/decode-str)]
(t/is (= "phase failed" (:message wire)))
(t/is (some #(= :encode-diagnostic (:operation %)) (:errors wire)))))
(t/deftest malformed-partial-shapes-and-cyclic-causes-produce-portable-diagnostics
(doseq [partial [nil false 42 "partial"]]
(let [data (errors/evidence :main (ex-info "phase failed" {:partial partial}))]
(t/is (sm/validate errors/schema:diagnostic data))
(t/is (some #(= :encode-diagnostic (:operation %)) (:errors data)))))
(let [cycle (js/Error. "cyclic failure")]
(set! (.-cause cycle) cycle)
(let [data (-> (errors/evidence :main cycle) transit/encode-str transit/decode-str)]
(t/is (= "cyclic failure" (:message data)))
(t/is (<= (count (:cause data)) 3)))))
(t/deftest malformed-optional-metadata-retains-a-valid-failure-diagnostic
(doseq [flag [42 (fn [] nil)]]
(let [cause (errors/combine [{:error (ex-info "main failed" {:deadline-exceeded flag})}])
data (errors/evidence :main cause)
wire (try (-> data transit/encode-str transit/decode-str) (catch :default _ nil))]
(t/is (= "main failed" (:message wire)))
(t/is (sm/validate errors/schema:diagnostic wire)))))
(t/deftest non-finite-failure-details-cross-transit-as-numbers
(let [failure (ex-info "bad measurement" {:code js/NaN :hint js/Infinity :type js/-Infinity})
decoded (-> (errors/evidence :render failure) transit/encode-str transit/decode-str)
detail (first (:cause decoded))]
(t/is (js/Number.isNaN (:code detail)))
(t/is (= js/Infinity (:hint detail)))
(t/is (= js/-Infinity (:type detail)))))

View File

@ -0,0 +1,346 @@
;; 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.execution-test
(:require
["fs" :as fs]
["node:module" :refer [createRequire]]
["node:stream" :as stream]
["node:timers/promises" :as timers]
["os" :as os]
["path" :as path]
[app.common.transit :as transit]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.node.cli.execution :as execution]
[benches.render-wasm.node.cli.request :as request]
[benches.render-wasm.node.cli.resolve :as cli]
[benches.render-wasm.node.log :as log]
[cljs.test :as t :include-macros true]
[clojure.string :as str]))
(def temporary-root (atom nil))
(defn- within
[promise]
(let [timer (volatile! nil)]
(-> (js/Promise.race
#js [promise
(js/Promise.
(fn [_ reject]
(vreset! timer (js/setTimeout #(reject (js/Error. "Test stalled")) 5000))))])
(.finally #(js/clearTimeout @timer)))))
(t/deftest ^:async real-signals-reach-the-callback-and-remover-restores-listeners
(let [before-int (.listenerCount js/process "SIGINT")
before-term (.listenerCount js/process "SIGTERM")
received (atom [])
next-signal (volatile! nil)
remove (execution/on-signals
(fn [signal]
(swap! received conj signal)
(@next-signal signal)))
receive (fn [signal]
(within
(js/Promise.
(fn [resolve _]
(vreset! next-signal resolve)
(.kill js/process (.-pid js/process) signal)))))]
(try
(t/is (= "SIGINT" (await (receive "SIGINT"))))
(t/is (= "SIGTERM" (await (receive "SIGTERM"))))
(t/is (= ["SIGINT" "SIGTERM"] @received))
(finally
(remove)))
(remove)
(t/is (= before-int (.listenerCount js/process "SIGINT")))
(t/is (= before-term (.listenerCount js/process "SIGTERM")))))
(t/use-fixtures :each
{:before #(reset! temporary-root (fs/mkdtempSync (path/join (os/tmpdir) "benchmark-execution-")))
:after (fn []
(log/configure {})
(fs/rmSync @temporary-root #js {:recursive true :force true}))})
(defn- capture
[]
(let [output (atom "")
errors (atom "")
writer (fn [text]
(stream/Writable.
#js {:write (fn [chunk _encoding done]
(swap! text str (.toString chunk))
(done))}))]
{:stdout (writer output) :stderr (writer errors) :output output :errors errors}))
(defn- resolved
[command options]
(request/apply-defaults {:command command :console {:color :never} :options options}))
(defn- listeners
[]
[(.listenerCount js/process "SIGINT") (.listenerCount js/process "SIGTERM")])
(t/deftest ^:async sync-and-async-handlers-publish-once-and-release-listeners
(doseq [async [false true]]
(let [local (capture)
before (listeners)
request (cli/parse-resolve ["compare" "--help" "--no-color"])
handler (fn [_ {:keys [topic]}]
(log/info "Preparing help")
{:exit-code 0 :errors [] :log-path nil :report (cli/get-help topic)})
handler (if async
(^:async fn [context options]
(await (timers/setTimeout 1))
(handler context options))
handler)]
(t/is (= 0 (await (execution/execute request {"help" handler}
(assoc local :get-root #(throw (js/Error. "root unavailable")))))))
(t/is (= (str (cli/get-help "compare") "\n") @(:output local)))
(t/is (= "Preparing help\n" @(:errors local)))
(t/is (= before (listeners))))))
(t/deftest ^:async handlers-use-local-root-and-services-with-complete-options
(let [local (capture)
file (path/join @temporary-root "input.txt")]
(fs/writeFileSync file "saved input" "utf8")
(t/is (= 0
(await
(execution/execute
(cli/parse-resolve ["run" "--build" "ready" "--warmups" "0"])
{"run" (fn [{:keys [get-root services]} {:keys [warmups build]}]
{:exit-code 0 :errors [] :log-path nil
:report (str (:id build) ":" warmups ":"
((:read-text services) (path/join (get-root) "input.txt")))})}
(assoc local :get-root (constantly @temporary-root)
:services {:read-text #(fs/readFileSync % "utf8")})))))
(t/is (= "ready:0:saved input\n" @(:output local)))))
(t/deftest ^:async malformed-and-unsupported-requests-fail-before-output-allocation
(let [valid (resolved "run" {:build {:kind :existing :id "ready"}})]
(doseq [[received field handlers]
[[(dissoc valid :console) "console" nil]
[(update valid :options dissoc :warmups) "warmups" nil]
[(assoc (update valid :options dissoc :warmups)
:console {:level :warn :color :always}) "warmups" nil]
[(assoc-in valid [:options :extra] true) "extra" nil]
[(assoc-in valid [:console :color] :invalid) "color" nil]
[(assoc-in valid [:console :stdout] #js {:write #(throw (js/Error. "untrusted stream"))})
"stdout" nil]
[valid "unsupported-command" {}]]]
(let [local (capture)
output (path/join @temporary-root (str (random-uuid)))
before (listeners)
handler (fn [_ _]
(fs/mkdirSync output)
{:exit-code 0 :errors [] :log-path nil})]
(t/is (= 1 (await (execution/execute received (or handlers {"run" handler})
(assoc local :get-root #(fs/mkdirSync output))))))
(t/is (str/includes? @(:errors local) field))
(t/is (not (str/includes? @(:errors local) "\u001b[")))
(t/is (not (fs/existsSync output)))
(t/is (= before (listeners)))))))
(t/deftest ^:async received-command-policies-reject-before-output-allocation
(doseq [[command options message]
[["build" {:features ["stats"]} "diagnostic-features"]
["run" {:build {:kind :prepare :features ["profile"]}} "diagnostic-features"]
["ab" {:features ["profile-raf"] :dry-run true} "diagnostic-features"]
["ab" {:features ["cache"]} "confirmation-required"]
["ab" {:features ["cache"] :filter " "} "confirmation-required"]
["ab" {:features ["a" "b"] :max-features 1 :dry-run true} "too-many-features"]]]
(let [local (capture)
output (path/join @temporary-root (str (random-uuid)))
request (request/check (resolved command options))]
(t/is (= 1 (await (execution/execute
request
{command (fn [_ _]
(fs/mkdirSync output)
{:exit-code 0 :errors [] :log-path nil})}
(assoc local :get-root #(fs/mkdirSync output))))))
(t/is (str/includes? @(:errors local) message))
(t/is (not (fs/existsSync output))))))
(t/deftest ^:async gpu-policy-uses-checked-console-and-dry-run-ab-needs-no-gpu
(let [env (.-env js/process)
old (.-PENPOT_GPU env)]
(try
(js-delete env "PENPOT_GPU")
(doseq [[command options expected]
[["run" {:build {:kind :existing :id "ready"} :require-gpu true} 1]
["ab" {:features ["cache"] :confirm true :require-gpu true} 1]
["ab" {:features ["cache"] :dry-run true :require-gpu true} 0]]]
(let [local (capture)
request (assoc (resolved command options) :console {:level :warn :color :always})]
(t/is (= expected
(await (execution/execute
request {command (fn [_ _] {:exit-code 0 :errors [] :log-path nil
:report "dry plan"})} local))))
(if (zero? expected)
(t/is (= "dry plan\n" @(:output local)))
(do
(t/is (str/includes? @(:errors local) "PENPOT_GPU=1"))
(t/is (str/includes? @(:errors local) "\u001b["))))))
(finally
(if (nil? old) (js-delete env "PENPOT_GPU") (set! (.-PENPOT_GPU env) old))))))
(t/deftest ^:async thrown-and-rejected-failures-use-checked-console-and-release-listeners
(doseq [[handler expected] [[(fn [_ _] (throw (js/Error. "failed work")))
"✖ failed work\n"]
[(^:async fn [_ _]
(await (timers/setTimeout 1))
(throw (js/Error. "failed work")))
"✖ failed work\n"]
[(^:async fn [_ _]
(await (timers/setTimeout 1))
(throw (errors/fatal-error "failed work" {} (js/Error. "storage"))))
"✖ failed work\n fatal true\n"]]]
(let [local (capture)
before (listeners)
request (assoc (resolved "help" {:topic nil}) :console {:level :warn :color :never})]
(t/is (= 1 (await (execution/execute
request {"help" (fn [context options]
(log/info "hidden progress")
(handler context options))} local))))
(t/is (= expected @(:errors local)))
(t/is (= before (listeners))))))
(t/deftest ^:async final-publication-uses-the-outcomes-log-and-numeric-status
(let [local (capture)
file (path/join @temporary-root "command.log")]
(t/is (= 7 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code 7 :errors [] :log-path file :report "saved report"})}
local))))
(t/is (= "saved report\n" (fs/readFileSync file "utf8")))
(t/is (= "saved report\n" @(:output local)))
(let [silent (capture)]
(t/is (= 0 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code 0 :errors [] :log-path nil})} silent))))
(t/is (= "" @(:output silent))))))
(t/deftest ^:async final-errors-share-the-command-log-and-print-one-tail
(let [local (capture)
file (path/join @temporary-root "failures.log")]
(fs/writeFileSync file "saved evidence\n" "utf8")
(t/is (= 1 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _]
{:exit-code 1 :log-path file
:errors [(js/Error. "first failure") (js/Error. "second failure")]})}
local))))
(t/is (= 2 (count (re-seq #"✖" @(:errors local)))))
(t/is (= 1 (count (re-seq #" tail\n" @(:errors local)))))
(t/is (str/includes? @(:errors local) "saved evidence"))
(t/is (str/includes? (fs/readFileSync file "utf8") "second failure"))))
(t/deftest ^:async publication-failure-fails-the-command-and-preserves-interruption-status
(doseq [[code expected] [[0 1] [130 130] [143 143]]]
(let [local (capture)
written (atom [])
broken #js {:write (fn [text]
(swap! written conj text)
(throw (js/Error. "stdout closed")))}]
(t/is (= expected (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code code :errors [] :log-path nil :report "result"})}
(assoc local :stdout broken)))))
(t/is (= ["result\n"] @written))
(t/is (= "✖ stdout closed\n" @(:errors local)))))
(let [local (capture)]
(t/is (= 1 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code 0 :errors [] :log-path @temporary-root :report "result"})}
local))))
(t/is (str/includes? @(:errors local) "cannot append benchmark log"))
(t/is (str/includes? @(:errors local) "Could not append log"))))
(t/deftest ^:async real-signals-retain-partial-evidence-and-fatal-errors
(doseq [[first-signal second-signal code] [["SIGINT" "SIGTERM" 130] ["SIGTERM" "SIGINT" 143]]]
(let [local (capture)
before (listeners)
file (path/join @temporary-root first-signal)
handler (fn [{:keys [signal]} _]
(js/Promise.
(fn [resolve reject]
(.addEventListener
signal "abort"
(fn []
(-> (timers/setTimeout 1)
(.then (fn []
(t/is (.emit js/process second-signal))
(fs/writeFileSync file "partial evidence" "utf8")
(resolve {:exit-code 1 :log-path nil :report "partial report"
:errors [(js/Error. "aborted work")
(errors/fatal-error "fatal storage" {} (js/Error. "disk"))]})))
(.catch reject)))
#js {:once true})
(.kill js/process (.-pid js/process) first-signal))))]
(t/is (= code (await (within (execution/execute (resolved "help" {:topic nil})
{"help" handler} local)))))
(t/is (= "partial evidence" (fs/readFileSync file "utf8")))
(t/is (= "partial report\n" @(:output local)))
(t/is (= "✖ fatal storage\n fatal true\n" @(:errors local)))
(t/is (= before (listeners))))))
(t/deftest failure-adapters-preserve-usage-validation-and-decoder-diagnostics
(doseq [[get-cause message]
[[#(cli/parse-resolve ["purge"]) "Usage: runner purge"]
[#(request/check (update (resolved "run" {:build {:kind :existing :id "ready"}})
:options dissoc :warmups)) "warmups"]
[#(transit/decode-str "invalid transit") nil]
[#((createRequire (path/join @temporary-root "entry.cjs"))
(path/join @temporary-root "missing.cjs")) "missing.cjs"]]]
(let [local (capture)
cause (try
(get-cause)
(t/is false "expected a boundary failure")
(catch :default cause cause))]
(t/is (= 1 (execution/fail cause {} local)))
(t/is (str/includes? @(:errors local) (or message (errors/message-of cause))))
(t/is (= 1 (count (re-seq #"✖" @(:errors local))))))))
(t/deftest ^:async failure-text-escapes-controls-and-survives-a-throwing-printer
(let [local (capture)
hostile (reify IPrintWithWriter
(-pr-writer [_ _ _] (throw (js/Error. "cannot print path"))))
failure (ex-info (str "work failed\n\u001b" (apply str (repeat 2000 "x")))
{:path hostile :reason "reason\u0007\u007f\u0085"
:mismatches [{:field [:metric (keyword "metric" "unsafe\u001b")]}]})]
(t/is (= 1 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] (throw failure))} local))))
(t/is (str/includes? @(:errors local) "work failed"))
(t/is (str/includes? @(:errors local) "unrenderable value"))
(t/is (str/includes? @(:errors local) "\\u001b"))
(t/is (str/includes? @(:errors local) "\\u0007"))
(t/is (every? #(<= (count %) 1100) (str/split-lines @(:errors local))))
(t/is (every? #(let [code (.charCodeAt % 0)]
(or (= code 10) (and (>= code 32) (or (< code 127) (> code 159)))))
(seq @(:errors local))))))
(t/deftest ^:async error-reporting-failure-uses-a-bounded-fallback
(doseq [[code expected] [[1 1] [130 130]]]
(let [local (capture)
fallback (atom [])
broken #js {:write (fn [text]
(if (str/starts-with? text "Could not report")
(swap! fallback conj text)
(throw (js/Error. (str "\u001b" (apply str (repeat 1100 "😀")))))))}]
(t/is (= expected (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code code :log-path nil :errors [(js/Error. "work failed")]})}
(assoc local :stderr broken)))))
(t/is (= 1 (count @fallback)))
(t/is (< (count (first @fallback)) 1100))
(t/is (str/includes? (first @fallback) "\\u001b"))
(t/is (not (re-find #"[\uD800-\uDBFF](?![\uDC00-\uDFFF])" (first @fallback))))))
(let [broken #js {:write #(throw (js/Error. "closed"))}]
(t/is (= 1 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code 0 :errors [] :log-path nil :report "result"})}
{:stdout broken :stderr broken}))))))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,100 @@
;; 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.fingerprint-test
"Tests content identity with production records, paths and collection types.
Validated scene snapshots retain child order even when maps reorder."
(:require
[app.common.geom.matrix :as matrix]
[app.common.geom.point :as point]
[app.common.transit :as transit]
[app.common.types.objects-map :as objects-map]
[app.common.types.path.impl :as path]
[app.common.types.shape :as shape]
[app.common.uuid :as uuid]
[benches.render-wasm.codec :as codec]
[benches.render-wasm.fingerprint :as fingerprint]
[benches.render-wasm.snapshot :as snapshot]
[cljs.test :as t :include-macros true]
[clojure.string :as str]
[linked.map :as linked-map]
[linked.set :as linked-set]))
(def a #uuid "00000000-0000-0000-0000-000000000001")
(def b #uuid "00000000-0000-0000-0000-000000000002")
(defn- starting-snapshot
"Builds and validates two canonical rectangles under the root frame."
[]
(snapshot/validate!
{:objects {uuid/zero (shape/setup-shape {:id uuid/zero :type :frame :name "Root"
:x 0 :y 0 :width 100 :height 100
:parent-id uuid/zero :frame-id uuid/zero :shapes [a b]})
a (shape/setup-shape {:id a :type :rect :name "A"
:x 0 :y 0 :width 10 :height 10 :parent-id uuid/zero :frame-id uuid/zero})
b (shape/setup-shape {:id b :type :rect :name "B"
:x 20 :y 20 :width 10 :height 10 :parent-id uuid/zero :frame-id uuid/zero})}
:refs {:a a :b b}}))
(t/deftest unordered-representations-have-one-fingerprint
(let [scene (starting-snapshot)
first (assoc scene :extra {:map (array-map :a 1 :b 2)
:compound (array-map [:a 1] {:x 1 :y 2} [:b 2] #{:one :two})
:set (set (range 20))})
second (-> scene
(update :objects #(into {} (reverse (seq %))))
(assoc :refs (array-map :b b :a a)
:extra {:set (set (reverse (range 20)))
:compound (array-map [:b 2] #{:two :one} [:a 1] (array-map :y 2 :x 1))
:map (array-map :b 2 :a 1)}))]
(t/is (= (fingerprint/snapshot-text first) (fingerprint/snapshot-text second)))
(t/is (= (fingerprint/fingerprint first) (fingerprint/fingerprint second)))))
(t/deftest ordered-collections-and-child-order-change-the-fingerprint
(let [scene (starting-snapshot)]
(t/is (not= (fingerprint/fingerprint scene)
(fingerprint/fingerprint (assoc-in scene [:objects uuid/zero :shapes] [b a]))))
(t/is (not= (fingerprint/fingerprint (assoc scene :extra [1 2]))
(fingerprint/fingerprint (assoc scene :extra [2 1]))))
(t/is (not= (fingerprint/fingerprint (assoc scene :extra (into linked-set/empty-linked-set [1 2])))
(fingerprint/fingerprint (assoc scene :extra (into linked-set/empty-linked-set [2 1])))))
(t/is (not= (fingerprint/fingerprint (assoc scene :extra (into linked-map/empty-linked-map [[:a 1] [:b 2]])))
(fingerprint/fingerprint (assoc scene :extra (into linked-map/empty-linked-map [[:b 2] [:a 1]])))))
(t/is (not= (fingerprint/fingerprint (assoc scene :extra (point/point 1 2)))
(fingerprint/fingerprint (assoc scene :extra {:x 1 :y 2}))))))
(t/deftest unrepresentable-transit-content-never-gets-a-misleading-fingerprint
(let [scene (starting-snapshot)]
(t/is (= ::codec/unrepresentable-value
(try
(fingerprint/fingerprint (assoc scene :extra {"__proto__" "value"}))
nil
(catch :default cause (:type (ex-data cause))))))))
(t/deftest objects-map-materializes-before-hashing-and-production-tags-survive
(let [scene (starting-snapshot)
wrapped (update scene :objects objects-map/wrap)
data (assoc scene :extra {:point (point/point 1 2)
:matrix (matrix/matrix)
:path (path/path-data [{:command :move-to :params {:x 1 :y 2}}
{:command :line-to :params {:x 3 :y 4}}])})
text (fingerprint/snapshot-text data)]
(t/is (= (fingerprint/fingerprint scene) (fingerprint/fingerprint wrapped)))
(t/is (not (str/includes? (fingerprint/snapshot-text wrapped) "penpot/objects-map")))
(t/is (str/includes? text "~#shape"))
(t/is (str/includes? text "~#point"))
(t/is (str/includes? text "~#matrix"))
(t/is (str/includes? text "~#penpot/path-data"))
(t/is (= (fingerprint/fingerprint data)
(fingerprint/fingerprint (transit/decode-str (transit/encode-str data)))))))
(t/deftest digest-matches-known-sha256-vector
;; The verbose Transit map for {:objects {} :refs {}} is canonical text.
(t/is (= "{\"~:objects\":{},\"~:refs\":{}}"
(fingerprint/snapshot-text {:objects {} :refs {}})))
(t/is (= {:algorithm :sha-256
:digest "ceb57067506683f5a0e80615bc9dd6f6d16325e333864da705fa4916fbdfe52d"}
(fingerprint/fingerprint {:objects {} :refs {}}))))

View File

@ -0,0 +1,86 @@
;; 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.geometry-scenes-test
(:require
[app.common.types.path :as path]
[app.common.uuid :as uuid]
[benches.render-wasm.cases :as cases]
[benches.render-wasm.declarations :as decl]
;; These 3 register the scenes, do not remove
[benches.render-wasm.scenes.masks]
[benches.render-wasm.scenes.paths]
[benches.render-wasm.scenes.plus]
[benches.render-wasm.snapshot :as snapshot]
[cljs.test :as t :include-macros true]
[performance-tests.render-wasm.test-helpers :as helpers]))
(t/deftest curved-paths-use-canonical-content-and-stable-order
(let [params {:count 12 :width 1920 :height 1080 :seed 42}
build (:build (decl/registered-scene :paths))
scene (build params)
again (build params)
shapes (helpers/children-of scene uuid/zero)
first (first shapes)
content (vec (:content first))]
(t/is (= 12 (count shapes)))
(t/is (= scene again) "same scene parameters and seed reproduce content and ids")
(t/is (path/content? (:content first)))
(t/is (= [:move-to :curve-to :line-to :close-path]
(mapv :command content)))
(t/is (pos? (:width (:selrect first))))
(t/is (pos? (:height (:selrect first))))
(t/is (= (mapv :id shapes) (mapv :id (rest (snapshot/upload-order scene)))))
(t/is (= scene (snapshot/validate! scene)))))
(t/deftest plus-paths-have-two-crossing-open-lines
(let [build (:build (decl/registered-scene :plus))
scene (build {:count 8 :width 1920 :height 1080 :seed 42})
first (first (helpers/children-of scene uuid/zero))
segments (vec (:content first))
[a b c d] segments]
(t/is (= 8 (count (helpers/children-of scene uuid/zero))))
(t/is (= [:move-to :line-to :move-to :line-to]
(mapv :command segments)))
(t/is (= (get-in a [:params :y]) (get-in b [:params :y]))
"the first line is horizontal")
(t/is (< (get-in a [:params :x]) (get-in b [:params :x])))
(t/is (= (get-in c [:params :x]) (get-in d [:params :x]))
"the second line is vertical")
(t/is (< (get-in c [:params :y]) (get-in d [:params :y])))
(t/is (seq (:strokes first)))
(t/is (= scene (snapshot/validate! scene)))))
(t/deftest mask-child-order-and-descendants-are-reachable
(let [build (:build (decl/registered-scene :masks))
scene (build {:width 1920 :height 1080 :seed 42})
groups (helpers/children-of scene uuid/zero)
group (first groups)
children (helpers/children-of scene (:id group))
mask (first children)
uploaded (snapshot/upload-order scene)]
(t/is (= 24 (count groups)))
(t/is (= 241 (count (:objects scene))) "root, groups and their nine children")
(t/is (= (:id group) (get-in scene [:refs [:mask 0]])))
(t/is (true? (:masked-group group)))
(t/is (= :circle (:type mask)) "the first child is the mask")
(t/is (= 9 (count children)))
(t/is (every? #(= (:id group) (:parent-id %)) children))
(t/is (= (:selrect mask) (:selrect group)))
(t/is (= uuid/zero (:id (first uploaded))))
(t/is (= (:id group) (:id (second uploaded))))
(t/is (= (:id mask) (:id (nth uploaded 2))))
(t/is (= (count (:objects scene)) (count uploaded)))
(t/is (= scene (snapshot/validate! scene)))))
(t/deftest camera-cases-share-each-scene-seed
(doseq [scene-id [:paths :plus :masks]]
(let [selected (->> (cases/collect-cases {:master-seed 42})
(filter #(= scene-id (:scene %)))
vec)]
(t/is (= #{"load" "pan" "zoom"} (set (map (comp name :id) selected))))
(t/is (= 1 (count (set (map :scene-seed selected)))))
(t/is (every? #(not (contains? % :run!)) selected)))))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,161 @@
;; 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.log-test
(:require
["fs" :as fs]
["node:stream" :as stream]
["node:timers/promises" :as timers]
["os" :as os]
["path" :as path]
[benches.render-wasm.node.log :as log]
[cljs.test :as t :include-macros true]
[clojure.string :as str]))
(def temporary-root (atom nil))
(t/use-fixtures :each
{:before #(reset! temporary-root (fs/mkdtempSync (path/join (os/tmpdir) "bench-log-")))
:after (fn []
(log/configure {})
(fs/rmSync @temporary-root #js {:recursive true :force true}))})
(defn- capture
[]
(let [text (atom "")
out (stream/Writable.
#js {:write (fn [chunk _encoding done]
(swap! text str (.toString chunk))
(done))})]
{:stream out :text text}))
(t/deftest levels-filter-console-and-retain-every-line-in-the-file
(doseq [[level expected] [[:warn "! warning\n✖ failure\n"]
[:info "Phase\n attempt 1/1 completed\n! warning\n✖ failure\n"]
[:debug "Phase\n attempt 1/1 completed\n· raw child\n! warning\n✖ failure\n"]]]
(let [stdout (capture)
stderr (capture)
file (path/join @temporary-root (name level))]
(log/configure {:level level :color :never
:stdout (:stream stdout) :stderr (:stream stderr)})
(binding [log/*log-path* file]
(log/info "Phase")
(log/info " attempt 1/1 completed")
(log/debug "raw child")
(log/warn "warning")
(log/error {:message "failure" :tail? false})
(log/result "Run saved"))
(t/is (= "Run saved\n" @(:text stdout)))
(t/is (= (str/replace expected "✖ failure\n" (str "✖ failure\n log " file "\n"))
@(:text stderr)))
(t/is (= (str "Phase\n attempt 1/1 completed\nraw child\nwarning\n✖ failure\n log "
file "\nRun saved\n")
(fs/readFileSync file "utf8"))))))
(t/deftest colors-apply-to-console-headings-and-preserve-indented-progress
(doseq [[mode tty no-color term colored?]
[[:always false "1" "dumb" true] [:never true "" "xterm" false]
[:auto false "" "xterm" false] [:auto true "" "xterm" true]
[:auto true "1" "xterm" false] [:auto true "" "dumb" false]]]
(let [stdout (capture)
stderr (capture)
file (path/join @temporary-root (str (random-uuid)))
env (.-env js/process)
old-no-color (.-NO_COLOR env)
old-term (.-TERM env)]
(try
(set! (.-NO_COLOR env) no-color)
(set! (.-TERM env) term)
(set! (.-isTTY ^js (:stream stderr)) tty)
(log/configure {:color mode :stdout (:stream stdout) :stderr (:stream stderr)})
(binding [log/*log-path* file]
(log/info "Phase\n progress")
(log/result "Output /tmp/run"))
(t/is (= colored? (str/includes? @(:text stderr) "\u001b[")))
(t/is (= "Phase\n progress\n"
(str/replace @(:text stderr) #"\u001b\[[0-9;]*m" "")))
(t/is (= "Output /tmp/run\n" @(:text stdout)))
(t/is (= "Phase\n progress\nOutput /tmp/run\n" (fs/readFileSync file "utf8")))
(finally
(if (nil? old-no-color) (js-delete env "NO_COLOR") (set! (.-NO_COLOR env) old-no-color))
(if (nil? old-term) (js-delete env "TERM") (set! (.-TERM env) old-term)))))))
(t/deftest ^:async nested-tasks-route-lines-to-their-own-files-on-success-and-failure
(let [parent (path/join @temporary-root "comparison.log")
child (path/join @temporary-root "build.log")
output (capture)]
(log/configure {:level :warn :stdout (:stream output) :stderr (:stream output)})
(binding [log/*log-path* parent]
(log/debug "comparison before")
(await ((^:async fn []
(binding [log/*log-path* child]
(await (timers/setImmediate))
(log/debug "child success")))))
(log/debug "comparison between")
(try
(await ((^:async fn []
(binding [log/*log-path* child]
(await (timers/setImmediate))
(log/debug "child failure")
(throw (js/Error. "failed"))))))
(catch :default _ (log/debug "comparison after"))))
(t/is (= "comparison before\ncomparison between\ncomparison after\n"
(fs/readFileSync parent "utf8")))
(t/is (= "child success\nchild failure\n" (fs/readFileSync child "utf8")))))
(t/deftest console-diagnostics-escape-controls-and-retain-the-log-evidence
(let [file (path/join @temporary-root "controls.log")
output (capture)
message (str "work failed\u001b\u0007" (apply str (repeat 2000 "x")))]
(log/configure {:color :never :stderr (:stream output)})
(binding [log/*log-path* file]
(log/warn message))
(t/is (= (str message "\n") (fs/readFileSync file "utf8")))
(t/is (str/includes? @(:text output) "\\u001b\\u0007"))
(t/is (< (count @(:text output)) 1100))
(doseq [separator ["" "\n"]]
(reset! (:text output) "")
(fs/writeFileSync file (str (apply str (repeat 2000 "\u0007")) separator "FATAL: compiler failed\n") "utf8")
(log/error {:message "tail evidence" :tail-log file})
(t/is (str/includes? @(:text output) "FATAL: compiler failed"))
(t/is (every? #(let [code (.charCodeAt % 0)]
(or (= code 10) (and (>= code 32) (or (< code 127) (> code 159)))))
(seq @(:text output)))))))
(t/deftest error-names-tail-source-and-bounds-evidence-before-appending
(let [file (path/join @temporary-root "comparison.log")
child (path/join @temporary-root "build.log")
output (capture)]
(fs/writeFileSync child (str "excluded\n" (apply str (repeat 9000 "x")) "\n"
(str/join "\n" (map #(str "line-" %) (range 30)))) "utf8")
(log/configure {:color :never :stderr (:stream output)})
(binding [log/*log-path* file]
(log/error {:message "build failed" :fields [[:phase :renderer] [:build "id"]]
:tail-log child}))
(let [expected (str "✖ build failed\n phase :renderer\n build id\n"
" log " file "\n tail-log " child "\n tail\n"
(str/join "\n" (map #(str " line-" %) (range 10 30))) "\n")]
(t/is (= expected @(:text output)))
(t/is (= expected (fs/readFileSync file "utf8"))))
(fs/writeFileSync child (apply str (repeat 10000 "z")) "utf8")
(reset! (:text output) "")
(binding [log/*log-path* file]
(log/error {:message "bounded" :tail-log child}))
(t/is (= 8192 (count (re-find #"z+$" (str/trim @(:text output))))))
(fs/writeFileSync file "last line\n" "utf8")
(reset! (:text output) "")
(binding [log/*log-path* file]
(log/error {:message "same source"}))
(t/is (= (str "✖ same source\n log " file "\n tail\n last line\n")
@(:text output)))))
(t/deftest error-reporting-survives-unreadable-tail-and-file-append-failure
(let [output (capture)]
(log/configure {:color :never :stderr (:stream output)})
(binding [log/*log-path* @temporary-root]
(log/error {:message "original failure" :tail-log (path/join @temporary-root "missing")}))
(t/is (str/starts-with? @(:text output) "✖ original failure\n"))
(t/is (str/includes? @(:text output) @temporary-root))))

View File

@ -0,0 +1,219 @@
;; 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.merge-test
(:require
[benches.render-wasm.report.summarize :as summary]
[benches.render-wasm.result :as result]
[cljs.test :as t :include-macros true]))
(defn- metric
[value]
{:command :fixture/work :metrics {:command/elapsed [value]}})
(defn- case-definition
[case-id]
{:id case-id
:scene :case
:scene-description "merge scene"
:scene-seed 7
:params {:count 10}
:view {:scale 1 :x 0 :y 0}
:context :fresh
:preparation {}})
(defn- preparation
[preparation-id case-id]
{:preparation-id preparation-id
:case-id case-id :warmup? false
:fingerprint {:algorithm :sha-256
:digest (apply str (repeat 64 "a"))}
:effective-graphics {:webgl2 true}
:diagnostics {}
:phases [{:phase :preparation :preparation-id preparation-id :outcome :completed :events []}]})
(defn- attempt
[case-id preparation-id warmup?]
{:case-id case-id
:preparation-id preparation-id
:warmup? warmup?
:outcome :completed
:phases [{:phase :main :preparation-id preparation-id
:outcome :completed :events [(metric 1.0)]}]})
(defn- make-run
[{:keys [run-id cases warmups repetitions git-sha build environment analysis
timeout-ms started-at ended-at termination preparations]
:or {git-sha "same-sha"
build {:mode :release}
environment {:node "v24"}
timeout-ms 30000
started-at "2026-10-26T00:00:00.000Z"
ended-at "2026-10-26T00:01:00.000Z"
termination {:reason :completed}}}]
(let [run (result/create-run
{:run-id run-id
:started-at started-at
:plan {:cases (mapv case-definition cases)
:warmups warmups
:repetitions repetitions
:timeout-policy {:case-ms timeout-ms}}
:metadata {:git {:sha git-sha :dirty false}
:build {:functional build}
:environment environment
:scored? true}
:analysis analysis})
run (reduce
(fn [run case-id]
(let [options (get preparations case-id)
warmup-id (or (:warmup options)
(str run-id "-" (name case-id)))
measured-id (or (:measured options) warmup-id)]
(-> run
(result/record-preparation (preparation warmup-id case-id))
(cond-> (not= warmup-id measured-id)
(result/record-preparation (preparation measured-id case-id)))
(as-> run
(reduce (fn [run _]
(result/record-attempt
run (attempt case-id warmup-id true)))
run (range warmups))
(reduce (fn [run _]
(result/record-attempt
run (attempt case-id measured-id false)))
run (range repetitions))))))
run cases)
run (if (= :completed (:reason termination))
(reduce #(result/record-case-outcome %1 %2 {:outcome :completed}) run cases)
run)]
(result/finish-run run ended-at termination)))
(t/deftest merges-measured-attempts-and-updates-the-plan
(let [base (make-run {:run-id "base"
:cases [:case/load :case/pan]
:warmups 1
:repetitions 2
:started-at "2026-10-26T00:00:00.000Z"
:ended-at "2026-10-26T00:01:00.000Z"})
additional (make-run {:run-id "additional"
:cases [:case/load :case/pan]
:warmups 1
:repetitions 3
:started-at "2026-10-26T00:02:00.000Z"
:ended-at "2026-10-26T00:03:00.000Z"})
merged (result/merge-runs base additional {:run-id "merged"})]
(t/is (= "merged" (:run-id merged)))
(t/is (= 1 (get-in merged [:plan :warmups])))
(t/is (= 5 (get-in merged [:plan :repetitions])))
(t/is (= 12 (count (:attempts merged))))
(t/is (= (range 12) (map :attempt-id (:attempts merged))))
(t/is (= [true false false true false false false false false false false false]
(map :warmup? (:attempts merged))))
(t/is (every? #(every? zero? (vals %)) (vals (:remaining merged))))
(t/is (= "2026-10-26T00:03:00.000Z" (:ended-at merged)))
(t/is (= 2 (get-in merged [:metadata :diagnostics :merge
:additional-warmups-excluded])))
(t/is (= merged (result/check-run merged)))
(t/is (= (:attempts merged)
(:attempts (result/decode (result/encode merged)))))))
(t/deftest excludes-additional-warmup-only-preparations
(let [base (make-run {:run-id "base"
:cases [:case/load]
:warmups 1
:repetitions 1})
additional (make-run {:run-id "additional"
:cases [:case/load]
:warmups 1
:repetitions 1
:preparations
{:case/load {:warmup "warm-prep"
:measured "measured-prep"}}})
merged (result/merge-runs base additional {:run-id "merged"})
preparation-ids (map :preparation-id (:preparations merged))]
(t/is (some #{"base-load"} preparation-ids))
(t/is (some #{"measured-prep"} preparation-ids))
(t/is (not (some #{"warm-prep"} preparation-ids)))
(t/is (= 3 (count (:attempts merged))))
(t/is (= "measured-prep" (:preparation-id (last (:attempts merged)))))))
(t/deftest rejects-incompatible-runs
(let [base (make-run {:run-id "base"
:cases [:case/load]
:warmups 1
:repetitions 1})]
(doseq [[label options message]
[["case" {:cases [:case/pan]} #"different case definitions"]
["timeout" {:timeout-ms 1000} #"different timeout policy"]
["warmups" {:warmups 0} #"different warmup count"]
["analysis" {:analysis {:draws 1000}} #"different analysis settings"]
["git" {:git-sha "other"} #"different git provenance"]
["build" {:build {:mode :debug}} #"different build facts"]
["environment" {:environment {:node "v25"}}
#"different runtime environment"]]]
(t/testing label
(let [additional (make-run (merge {:run-id "additional"
:cases [:case/load]
:warmups 1
:repetitions 1}
options))]
(t/is (thrown-with-msg? js/Error message
(result/merge-runs base additional
{:run-id "merged"}))))))))
(t/deftest rejects-non-completed-runs
(let [completed (make-run {:run-id "base"
:cases [:case/load]
:warmups 1
:repetitions 1})
interrupted (make-run {:run-id "additional"
:cases [:case/load]
:warmups 1
:repetitions 1
:termination {:reason :interrupted}})]
(t/is (thrown-with-msg? js/Error #"only completed runs can be merged"
(result/merge-runs completed interrupted
{:run-id "merged"})))
(t/is (thrown-with-msg? js/Error #"only completed runs can be merged"
(result/merge-runs interrupted completed
{:run-id "merged"})))))
(t/deftest rejects-duplicate-preparation-ids
(let [base (make-run {:run-id "base"
:cases [:case/load]
:warmups 1
:repetitions 1
:preparations
{:case/load {:warmup "shared"
:measured "shared"}}})
additional (make-run {:run-id "additional"
:cases [:case/load]
:warmups 1
:repetitions 1
:preparations
{:case/load {:warmup "shared"
:measured "shared"}}})]
(t/is (thrown-with-msg? js/Error #"duplicate preparation ids"
(result/merge-runs base additional
{:run-id "merged"})))))
(t/deftest quantity-prose-does-not-change-merge-compatibility
(let [base (make-run {:run-id "base" :cases [:case/load] :warmups 0 :repetitions 1})
additional (-> (make-run {:run-id "additional" :cases [:case/load] :warmups 0 :repetitions 1})
(assoc-in [:quantities :command/elapsed :description] "Elapsed command work."))
merged (result/merge-runs base additional {:run-id "merged"})]
(t/is (= 2 (get-in merged [:plan :repetitions])))
(t/is (= merged (result/decode (result/encode merged))))
(let [malformed (assoc-in additional [:quantities :command/elapsed :description] 42)
report (summary/summarize malformed)]
(t/is (= :malformed-quantity
(get-in report [:cases 0 :metrics [:main :fixture/work :command/elapsed] :diagnostics 0 :reason])))
(t/is (thrown-with-msg? js/Error #"malformed quantity descriptions"
(result/merge-runs base malformed {:run-id "invalid"}))))
(t/is (thrown-with-msg? js/Error #"different quantity units"
(result/merge-runs base
(assoc-in additional [:quantities :command/elapsed :unit] :seconds)
{:run-id "invalid"})))))

View File

@ -0,0 +1,47 @@
;; 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.outcome-test
(:require
[benches.render-wasm.node.outcome :as outcome]
[cljs.test :as t :include-macros true]))
(t/deftest failed-reports-the-cause-with-exit-code-1-and-its-log-path
(let [cause (ex-info "boom" {:log-path "build.log"})
result (outcome/failed cause {:home :h})]
(t/is (= [cause] (:errors result)))
(t/is (= 1 (:exit-code result)))
(t/is (= "build.log" (:log-path result)))
(t/is (= :h (:home result)))))
(t/deftest failed-prefers-the-log-path-given-in-data
(let [result (outcome/failed (ex-info "boom" {:log-path "build.log"}) {:log-path "run.log"})]
(t/is (= "run.log" (:log-path result)))))
(t/deftest add-error-appends-and-keeps-interruption-exit-codes
(doseq [[before after] [[0 1] [1 1] [130 130] [143 143]]]
(let [first-cause (js/Error. "first")
second-cause (js/Error. "second")
result (-> {:errors [first-cause] :exit-code before}
(outcome/add-error second-cause))]
(t/is (= [first-cause second-cause] (:errors result)))
(t/is (= after (:exit-code result))))))
(t/deftest publish-returns-the-outcome-when-printing-succeeds
(let [printed (atom nil)
result (outcome/publish {:exit-code 0} #(reset! printed :done))]
(t/is (= :done @printed))
(t/is (= {:exit-code 0} result))))
(t/deftest publish-adds-a-failed-print-to-the-outcome
(let [cause (js/Error. "stream closed")
result (outcome/publish {:exit-code 0} #(throw cause))]
(t/is (= [cause] (:errors result)))
(t/is (= 1 (:exit-code result)))))
(t/deftest interruption-code-maps-signal-names
(t/is (= 143 (outcome/interruption-code "SIGTERM")))
(t/is (= 130 (outcome/interruption-code "SIGINT"))))

View File

@ -0,0 +1,615 @@
;; 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.process-test
"Real Node/POSIX processes, files and Linux flock ownership."
(:require
["child_process" :as child-process]
["fs" :as fs]
["node:module" :as node-module]
["os" :as os]
["path" :as path]
[app.common.transit :as transit]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.node.process :as process]
[cljs.test :as t :include-macros true]
[clojure.string :as str]))
(def ^:private temp-root (atom nil))
(def ^:private test-grace-ms 100)
(defn- file
[name]
(path/join @temp-root name))
(defn- pause
[ms]
(js/Promise. (fn [resolve _] (js/setTimeout resolve ms))))
(defn- within
"A broken lifecycle must fail the test rather than stall the test runner."
([promise] (within promise 5000))
([promise timeout-ms]
(let [timer (volatile! nil)]
(-> (js/Promise.race
#js [promise
(js/Promise.
(fn [_ reject]
(vreset! timer
(js/setTimeout
#(reject (ex-info "Process test stalled" {:type :test-watchdog}))
timeout-ms))))])
(.finally #(js/clearTimeout @timer))))))
(defn- ^:async with-live-group
[faults f]
(let [^js native-fs ((node-module/createRequire (path/join (.cwd js/process) "package.json")) "fs")
kill (.-kill js/process)
read-dir (.-readdirSync native-fs)
read-file (.-readFileSync native-fs)
group (atom nil)
signals (atom [])
probed (atom false)
fake-pid "2147483646"]
(set! (.-kill js/process)
(fn [pid signal]
(if (neg? pid)
(do
(reset! group (- pid))
(when-not (:keep-child faults)
(try
(.call kill js/process pid signal)
(catch :default cause
(when-not (= "ESRCH" (.-code cause)) (throw cause)))))
(when (string? signal)
(swap! signals conj {:signal signal :time-ms (js/Date.now)}))
(when-let [cause (get faults signal)] (throw cause))
true)
(.call kill js/process pid signal))))
(set! (.-readdirSync native-fs)
(fn [location & args]
(if (and (= "/proc" location) @group)
(if (and (:probe faults) (compare-and-set! probed false true))
(throw (:probe faults))
(.concat (apply read-dir location args) #js [fake-pid]))
(apply read-dir location args))))
(set! (.-readFileSync native-fs)
(fn [location & args]
(if (and @group (= location (str "/proc/" fake-pid "/stat")))
(str fake-pid " (unresolved) S 0 " @group " " @group " 0")
(apply read-file location args))))
(node-module/syncBuiltinESMExports)
(try
(await (f signals))
(finally
(when (and (:keep-child faults) @group)
(try
(.call kill js/process (- @group) "SIGKILL")
(catch :default cause
(when-not (= "ESRCH" (.-code cause)) (throw cause)))))
(set! (.-kill js/process) kill)
(set! (.-readdirSync native-fs) read-dir)
(set! (.-readFileSync native-fs) read-file)
(node-module/syncBuiltinESMExports)))))
(defn- ^:async wait-for
[pred]
(let [limit (process/deadline 3000)]
(loop []
(when-not (pred)
(when (zero? (process/remaining limit))
(throw (ex-info "Process test did not become ready" {:type :test-watchdog})))
(await (pause 10))
(recur)))))
(defn- process-status
[pid]
(try
(let [stat (fs/readFileSync (str "/proc/" pid "/stat") "utf8")
fields (str/split (str/trim (subs stat (inc (.lastIndexOf stat ")")))) #"\s+")]
{:state (first fields)
:group (js/Number (nth fields 2))})
(catch :default cause
(if (contains? #{"ENOENT" "ESRCH"} (.-code cause))
nil
(throw cause)))))
(defn- running?
[pid]
(when-let [status (process-status pid)]
;; An orphan can remain as a zombie until init reaps it. It owns no pipes
;; or locks and cannot run; kill(pid, 0) alone does not distinguish it.
(not (contains? #{"Z" "X" "x"} (:state status)))))
(defn- read-pid
[name]
(js/Number (fs/readFileSync (file name) "utf8")))
(t/use-fixtures
:each
{:before
(fn []
(reset! temp-root
(fs/mkdtempSync (path/join (os/tmpdir) "penpot-render-wasm-process-test-"))))
:after
(fn []
(t/async done
(let [cleanup (^:async fn []
(try
(let [pids (->> (array-seq (fs/readdirSync @temp-root))
(filter #(str/ends-with? % ".pid"))
(mapv read-pid))]
;; Also clean up when an assertion or watchdog exposes a regression.
;; Signal individual recorded PIDs so a missing detached flag cannot
;; make test teardown kill the test runner's own process group.
(doseq [pid pids]
(when (running? pid)
(try
(.kill js/process pid "SIGKILL")
(catch :default cause
(when-not (= "ESRCH" (.-code cause))
(throw cause))))))
(await (wait-for #(not-any? running? pids))))
(finally
(try
(fs/rmSync @temp-root #js {:recursive true :force true})
(finally
(reset! temp-root nil))))))]
(.then (cleanup)
(fn [_] (done))
(fn [cause]
(try
(t/is false (str "Process test cleanup failed: " cause))
(finally
(done))))))))})
(defn- node-source
[pid-file source]
(str "const fs = require('node:fs');\n"
"fs.writeFileSync(" (pr-str pid-file) ", String(process.pid));\n"
source))
(defn- node-options
[source overrides]
(merge {:command (.-execPath js/process)
:args ["-e" (node-source "root.pid" source)]
:cwd @temp-root
:deadline (process/deadline 3000)
:termination-grace-ms test-grace-ms}
overrides))
(t/deftest ^:async unresolved-group-termination-is-bounded-and-fatal
(await
(with-live-group
{}
(^:async fn [signals]
(let [started (js/Date.now)
cause (try
(await (within (process/run (node-options "process.exit(0);" {:termination-grace-ms 20})) 8000))
nil
(catch :default cause cause))
data (ex-data cause)]
(t/is (errors/fatal? cause))
(t/is (= :process-unresolved (:type data)))
(t/is (= (.-execPath js/process) (:command data)))
(t/is (integer? (:pid data)))
(t/is (= (:pid data) (:pgid data)))
(t/is (= ["SIGTERM" "SIGKILL"] (:signals data)))
(t/is (= ["SIGTERM" "SIGKILL"] (mapv :signal @signals)))
(let [killed-at (some #(when (= "SIGKILL" (:signal %)) (:time-ms %)) @signals)]
(t/is (and (number? killed-at) (>= (- (js/Date.now) killed-at) 5000))))
(t/is (< (- (js/Date.now) started) 8000))
(t/is (not (running? (read-pid "root.pid")))))))))
(t/deftest ^:async unresolved-owner-retains-a-live-lock-and-all-failures
(let [lock-file (file "unresolved.lock")
messages ["output handler failed" "TERM denied" "probe unavailable" "KILL denied"]]
(await
(with-live-group
{"SIGTERM" (js/Error. (nth messages 1))
:probe (js/Error. (nth messages 2))
"SIGKILL" (js/Error. (nth messages 3))
:keep-child true}
(^:async fn [signals]
(let [options (node-options ""
{:command "flock"
:args ["--exclusive" lock-file (.-execPath js/process) "-e"
(node-source "root.pid" "process.stdout.write('ready'); setInterval(() => {}, 1000);")]
:deadline (process/deadline 30000)
:termination-grace-ms 20
:on-output (fn [& _] (throw (js/Error. (first messages))))})
cause (try
(await (within (process/run options) 8000))
nil
(catch :default cause cause))
entries (errors/get-errors cause)
wire (-> (errors/evidence :process cause) transit/encode-str transit/decode-str)]
(t/is (process/unresolved? cause))
(t/is (errors/fatal? cause))
(t/is (= [:process-output :process-signal :process-probe :process-signal :process-unresolved]
(mapv #(-> % :error ex-data :type) entries)))
(doseq [[entry message] (map vector (:errors wire) messages)]
(t/is (some #(= message (:message %)) (:cause entry))))
(t/is (true? (:unresolved-process wire)))
(t/is (true? (:fatal wire)))
(t/is (= ["SIGTERM" "SIGKILL"] (mapv :signal @signals)))
(t/is (running? (read-pid "root.pid")))
(t/is (= 1 (.-status (child-process/spawnSync "flock" #js ["--nonblock" lock-file "true"]
#js {:timeout 1000}))))))))
(await (wait-for #(zero? (.-status (child-process/spawnSync "flock" #js ["--nonblock" lock-file "true"]
#js {:timeout 1000})))))))
(t/deftest ^:async input-stream-errors-have-one-occurrence-each-with-line-output
(let [^js native ((node-module/createRequire (path/join (.cwd js/process) "package.json")) "child_process")
spawn (.-spawn native)]
(set! (.-spawn native)
(fn [& args]
(let [^js child (apply spawn args)]
(.once (.-stdout child) "data"
(fn [_]
(.emit (.-stdout child) "error" (js/Error. "first read failed"))
(.emit (.-stdout child) "error" (js/Error. "second read failed"))))
child)))
(node-module/syncBuiltinESMExports)
(try
(let [cause (try
(await (within (process/run
(node-options "process.stdout.write('ready'); setInterval(() => {}, 1000);"
{:on-line (fn [& _] nil)}))))
nil
(catch :default cause cause))
wire (errors/evidence :process cause)]
(t/is (= [:process-output :process-output]
(mapv #(-> % :error ex-data :type) (errors/get-errors cause))))
(doseq [[entry message] (map vector (:errors wire) ["first read failed" "second read failed"])]
(t/is (some #(= message (:message %)) (:cause entry)))))
(finally
(set! (.-spawn native) spawn)
(node-module/syncBuiltinESMExports)))))
(defn- ^:async rejected
[promise]
(try
(let [result (await (within promise))]
(t/is false (str "Expected a process failure, got " (pr-str result)))
nil)
(catch :default cause
(t/is (some? (ex-data cause)) "failures carry ex-info data")
(ex-data cause))))
(defn- stubborn-descendant
[]
(let [grandchild (node-source
"grandchild.pid"
(str "process.on('SIGTERM', () => fs.writeFileSync('grandchild.term', 'seen'));\n"
"fs.writeFileSync('grandchild.ready', 'ready');\n"
"setInterval(() => {}, 1000);\n"))]
(node-source
"descendant.pid"
(str "process.on('SIGTERM', () => fs.writeFileSync('descendant.term', 'seen'));\n"
"require('node:child_process').spawn(process.execPath,\n"
" ['-e', " (pr-str grandchild) "], {stdio: ['ignore', 'inherit', 'inherit']});\n"
"const ready = setInterval(() => {\n"
" if (fs.existsSync('grandchild.ready')) {\n"
" clearInterval(ready); fs.writeFileSync('descendant.ready', 'ready');\n"
" process.stdout.write('descendant-ready\\n');\n"
" }\n"
"}, 10);\n"
"setInterval(() => fs.appendFileSync('heartbeat', '.'), 10);\n"))))
(defn- spawn-descendant
[]
(str "const child = require('node:child_process').spawn(process.execPath,\n"
" ['-e', " (pr-str (stubborn-descendant)) "],\n"
" {stdio: ['ignore', 'inherit', 'inherit']});\n"))
(defn- ^:async assert-tree-stopped
[]
(t/is (not (running? (read-pid "root.pid"))) "the owner has stopped")
(t/is (not (running? (read-pid "descendant.pid"))) "the descendant has stopped at settlement")
(t/is (not (running? (read-pid "grandchild.pid"))) "the grandchild has stopped at settlement")
(t/is (= "seen" (fs/readFileSync (file "descendant.term") "utf8"))
"the owned group receives SIGTERM before SIGKILL")
(t/is (= "seen" (fs/readFileSync (file "grandchild.term") "utf8"))
"SIGTERM also reaches the grandchild")
(let [heartbeat (when (fs/existsSync (file "heartbeat"))
(fs/readFileSync (file "heartbeat") "utf8"))]
(await (pause 30))
(t/is (= heartbeat (when (fs/existsSync (file "heartbeat"))
(fs/readFileSync (file "heartbeat") "utf8")))
"no descendant writes after the promise settles")))
(t/deftest ^:async deadlines-use-date-now-and-clamp-at-zero
(let [before (js/Date.now)
limit (process/deadline 20)]
(t/is (<= (+ before 20) limit (+ (js/Date.now) 20)))
(t/is (<= 0 (process/remaining limit) 20))
(await (pause 30))
(t/is (zero? (process/remaining limit)))
(t/is (zero? (process/remaining (- before 1000))))))
(t/deftest ^:async uses-real-cwd-literal-args-env-and-closed-stdin
(let [source (str "process.stdin.resume();\n"
"process.stdin.on('end', () => {\n"
" const fact = {cwd: process.cwd(), args: process.argv.slice(1),\n"
" value: process.env.RENDERER_PROCESS_TEST, path: process.env.PATH || null};\n"
" fs.writeFileSync('fact.json', JSON.stringify(fact));\n"
" process.stdout.write(JSON.stringify(fact));\n"
" process.stderr.write('stdin-ended\\n');\n"
"});\n")
args ["space ; $(touch unexpected)" "--literal"]]
(doseq [env [{:RENDERER_PROCESS_TEST "map value"}
#js {:RENDERER_PROCESS_TEST "map value"}]]
(let [options (node-options source {:env env})
result (await (within (process/run (update options :args into args))))
fact (js->clj (js/JSON.parse (:stdout result)) :keywordize-keys true)]
(t/is (= 0 (:exit-code result)))
(t/is (= "stdin-ended\n" (:stderr result)))
(t/is (= {:cwd @temp-root
:args args
:value "map value"
:path (or (.-PATH (.-env js/process)) nil)}
fact))
(t/is (= (:stdout result) (fs/readFileSync (file "fact.json") "utf8")))
(t/is (not (fs/existsSync (file "unexpected"))) "arguments do not pass through a shell")))))
(t/deftest ^:async routes-both-streams-before-the-child-can-finish
(let [seen (atom {:stdout "" :stderr ""})
source (str "process.stdout.write('ready\\n');\n"
"process.stderr.write('warning\\n');\n"
"const timer = setInterval(() => {\n"
" if (fs.existsSync('ack')) {\n"
" clearInterval(timer); fs.writeFileSync('done', 'yes');\n"
" process.stdout.write('complete\\n');\n"
" }\n"
"}, 10);\n")
result (await
(within
(process/run
(node-options
source
{:on-output
(fn [text stream]
(swap! seen update stream str text)
(when (str/includes? (:stdout @seen) "ready\n")
(fs/writeFileSync (file "ack") "yes")))}))))]
(t/is (= {:exit-code 0 :stdout "ready\ncomplete\n" :stderr "warning\n"} result))
(t/is (= (select-keys result [:stdout :stderr]) @seen))
(t/is (= "yes" (fs/readFileSync (file "done") "utf8")))))
(t/deftest ^:async lines-keep-streams-separate-and-flush-final-fragments
(let [seen (atom {:stdout [] :stderr []})
source (str "process.stdout.write('out-'); process.stderr.write('err-');"
"setTimeout(() => { process.stdout.write('done\\r');"
"process.stderr.write('done\\nlast-err');"
"setTimeout(() => process.stdout.write('\\nlast-out'), 20); }, 20);")
result (await (process/run
(node-options source
{:capture-output? false
:on-line (fn [line stream]
(swap! seen update stream conj line))})))]
(t/is (= {:stdout ["out-done" "last-out"] :stderr ["err-done" "last-err"]} @seen))
(t/is (= {:exit-code 0 :stdout "" :stderr ""} result))))
(t/deftest ^:async decodes-utf8-across-real-pipe-chunks
(let [source (str "const bytes = Buffer.from('Ω😀');\n"
"process.stdout.write(bytes.subarray(0, 1));\n"
"setTimeout(() => process.stdout.write(bytes.subarray(1)), 20);\n")
result (await (within (process/run (node-options source {}))))]
(t/is (= "Ω😀" (:stdout result)))))
(t/deftest ^:async streams-large-output-without-retaining-it
(let [sizes (atom {:stdout 0 :stderr 0})
source (str "process.stdout.write('x'.repeat(256 * 1024));\n"
"process.stderr.write('y'.repeat(128 * 1024));\n")
result (await
(within
(process/run
(node-options source
{:capture-output? false
:on-output (fn [text stream]
(swap! sizes update stream + (count text)))}))))]
(t/is (= {:exit-code 0 :stdout "" :stderr ""} result))
(t/is (= {:stdout (* 256 1024) :stderr (* 128 1024)} @sizes))))
(t/deftest ^:async nonzero-exit-keeps-code-command-and-output
(let [options (node-options
"process.stdout.write('fact\\n'); process.stderr.write('failed\\n'); process.exitCode = 17;"
{})
data (await (rejected (process/run options)))]
(t/is (= :process-exit (:type data)))
(t/is (= (:command options) (:command data)))
(t/is (= 17 (:exit-code data)))
(t/is (nil? (:signal data)))
(t/is (= "fact\n" (:stdout data)))
(t/is (= "failed\n" (:stderr data)))))
(t/deftest ^:async spawn-failure-keeps-os-evidence
(let [command (file "no-such-command")
data (await (rejected (process/run (node-options "" {:command command}))))]
(t/is (= :process-spawn (:type data)))
(t/is (= command (:command data)))
(t/is (= "ENOENT" (:code data)))
(t/is (nil? (:exit-code data)))
(t/is (nil? (:signal data)))))
(t/deftest ^:async expired-deadline-does-not-start-work
(let [data (await (rejected
(process/run
(node-options "fs.writeFileSync('started', 'yes');"
{:deadline (- (js/Date.now) 1)}))))]
(t/is (= :process-timeout (:type data)))
(t/is (= (.-execPath js/process) (:command data)))
(t/is (not (fs/existsSync (file "started"))))
(t/is (not (fs/existsSync (file "root.pid"))))))
(t/deftest ^:async already-aborted-signal-does-not-start-work
(let [controller (js/AbortController.)]
(.abort controller "already-cancelled")
(let [data (await (rejected
(process/run
(node-options "fs.writeFileSync('started', 'yes');"
{:signal (.-signal controller)}))))]
(t/is (= :process-cancelled (:type data)))
(t/is (= "already-cancelled" (:reason data)))
(t/is (not (fs/existsSync (file "started"))))
(t/is (not (fs/existsSync (file "root.pid")))))))
(t/deftest ^:async timeout-kills-the-owner-and-a-term-resistant-descendant
(let [limit (process/deadline 1000)
data (await
(rejected
(process/run
(node-options (str (spawn-descendant) "setInterval(() => {}, 1000);")
{:deadline limit}))))]
(t/is (= :process-timeout (:type data)))
(t/is (= limit (:deadline data)))
(t/is (= "SIGTERM" (:signal data)))
(t/is (fs/existsSync (file "descendant.ready")))
(await (assert-tree-stopped))))
(t/deftest ^:async cancellation-kills-the-whole-owned-group
(let [controller (js/AbortController.)
output (atom "")
groups (atom nil)
data (await
(rejected
(process/run
(node-options
(str (spawn-descendant) "setInterval(() => {}, 1000);")
{:signal (.-signal controller)
:on-output
(fn [text stream]
(when (= :stdout stream)
(swap! output str text)
(when (str/includes? @output "descendant-ready\n")
(reset! groups
{:root-pid (read-pid "root.pid")
:root (:group (process-status (read-pid "root.pid")))
:descendant (:group (process-status (read-pid "descendant.pid")))
:grandchild (:group (process-status (read-pid "grandchild.pid")))
:runner (:group (process-status (.-pid js/process)))})
(.abort controller "cancel-tree"))))}))))]
(t/is (= :process-cancelled (:type data)))
(t/is (= "cancel-tree" (:reason data)))
(t/is (= "SIGTERM" (:signal data)))
(t/is (= (:root-pid @groups) (:root @groups) (:descendant @groups) (:grandchild @groups))
"the root leads the group containing all generations")
(t/is (not= (:runner @groups) (:root @groups)) "the group is separate from the runner")
(await (assert-tree-stopped))))
(t/deftest ^:async default-cancellation-produces-portable-error-evidence
(let [controller (js/AbortController.)
cause (try
(await (within (process/run
(node-options "process.stdout.write('ready'); setInterval(() => {}, 1000);"
{:signal (.-signal controller)
:on-output (fn [& _] (.abort controller))}))))
nil
(catch :default cause cause))
evidence (errors/evidence :process cause)]
(t/is (= :process-cancelled (:type (ex-data cause))))
(t/is (= evidence (-> evidence transit/encode-str transit/decode-str)))))
(t/deftest ^:async output-callback-failure-cleans-the-owned-group
(doseq [callback [:on-output :on-line]]
(let [seen (atom "")
data (await
(rejected
(process/run
(node-options
(str (spawn-descendant) "setInterval(() => {}, 1000);")
{callback
(fn [text stream]
(when (= :stdout stream)
(swap! seen str text)
(when (str/includes? @seen "descendant-ready")
(throw (js/Error. "Output consumer failed")))))}))))]
(t/is (= :process-output (:type data)))
(t/is (= :stdout (:stream data)))
(t/is (= "SIGTERM" (:signal data)))
(t/is (str/includes? (:stdout data) "descendant-ready\n"))
(await (assert-tree-stopped)))))
(t/deftest ^:async successful-owner-exit-cleans-descendants-that-hold-the-pipes
(let [source (str (spawn-descendant)
"child.unref();\n"
"const timer = setInterval(() => {\n"
" if (fs.existsSync('descendant.ready')) {\n"
" clearInterval(timer); process.stdout.write('root-complete\\n');\n"
" }\n"
"}, 10);\n")
result (await (within (process/run (node-options source {}))))]
(t/is (= 0 (:exit-code result)))
(t/is (str/includes? (:stdout result) "root-complete\n"))
(t/is (str/includes? (:stdout result) "descendant-ready\n"))
(await (assert-tree-stopped))))
(t/deftest ^:async nonzero-owner-exit-also-cleans-descendants-that-hold-the-pipes
(let [source (str (spawn-descendant)
"child.unref();\n"
"const timer = setInterval(() => {\n"
" if (fs.existsSync('descendant.ready')) {\n"
" clearInterval(timer); process.stderr.write('root-failed\\n');\n"
" process.exitCode = 17;\n"
" }\n"
"}, 10);\n")
data (await (rejected (process/run (node-options source {}))))]
(t/is (= :process-exit (:type data)))
(t/is (= 17 (:exit-code data)))
(t/is (= "root-failed\n" (:stderr data)))
(await (assert-tree-stopped))))
(t/deftest ^:async lock-wait-times-out-and-cancelled-holder-releases-inherited-flock
(let [lock (file "build.lock")
controller (js/AbortController.)
holder (node-source
"holder.pid"
(str "process.on('SIGTERM', () => fs.writeFileSync('holder.term', 'seen'));\n"
"fs.writeFileSync('holder.ready', 'yes');\n"
"setInterval(() => {}, 1000);\n"))
held (rejected
(process/run
(node-options ""
{:command "flock"
:args ["--exclusive" lock (.-execPath js/process) "-e" holder]
:signal (.-signal controller)})))]
(try
(await (wait-for #(fs/existsSync (file "holder.ready"))))
(let [limit (process/deadline 150)
data (await
(rejected
(process/run
(node-options ""
{:command "flock"
:args ["--exclusive" lock (.-execPath js/process) "-e"
(node-source "waiter.pid" "fs.writeFileSync('waiter-acquired', 'yes');")]
:deadline limit}))))]
(t/is (= :process-timeout (:type data)))
(t/is (= limit (:deadline data)))
(t/is (not (fs/existsSync (file "waiter-acquired"))))
(t/is (running? (read-pid "holder.pid")) "the first owner still holds the lock"))
(.abort controller "release-lock")
(let [data (await held)]
(t/is (= :process-cancelled (:type data)))
(t/is (not (running? (read-pid "holder.pid")))))
(let [result (await
(within
(process/run
(node-options ""
{:command "flock"
:args ["--exclusive" "--nonblock" lock (.-execPath js/process) "-e"
(node-source "probe.pid" "fs.writeFileSync('probe-acquired', 'yes');")]}))))]
(t/is (= 0 (:exit-code result)))
(t/is (= "yes" (fs/readFileSync (file "probe-acquired") "utf8")))
(t/is (not (fs/existsSync (file "waiter-acquired"))))
(t/is (= "seen" (fs/readFileSync (file "holder.term") "utf8"))))
(finally
(.abort controller "test-cleanup")
(await held)))))

View File

@ -0,0 +1,417 @@
;; 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.protocol-test
"Tests render drains and camera sessions with a controllable clock.
A drain renders until Full completion. A camera session moves a view through
cached previews, a settling wait and final rendering to Full."
(:require
[app.common.render-wasm.wasm :as wasm]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.runtime.protocol :as protocol]
[cljs.test :as t :include-macros true]))
(defn- fake-clock
"Returns a controllable clock in milliseconds.
`:now` reads it; `:advance!` adds the supplied duration."
[start]
(let [t (atom start)]
{:now (fn [] @t)
:advance! (fn [ms] (swap! t + ms) @t)}))
(defn- drain-hooks
"Returns hooks that simulate rendering and record flags in `calls`.
`:render` returns the frame types from `script` in order. Each `:frame` adds
16 ms to the clock; `:sleep` adds the requested wait. Both return Promises."
[{:keys [clock script calls check]}]
(let [remaining (atom (vec script))]
{:now (:now clock)
:frame (fn []
((:advance! clock) 16)
(js/Promise.resolve ((:now clock))))
:sleep (fn [ms]
((:advance! clock) ms)
(js/Promise.resolve nil))
:check (or check (fn [] nil))
:render (fn [_timestamp flags]
(swap! calls conj flags)
(let [next (first @remaining)]
(swap! remaining rest)
next))
:render-from-cache (fn [] 0)
:set-view (fn [_view] nil)
:set-view-start (fn [] nil)
:set-view-end (fn [] nil)}))
(t/deftest ^:async completion-keeps-the-renderer-endpoint
(doseq [camera [false true]]
(let [clock (fake-clock 1000)
hooks (assoc (drain-hooks {:clock clock :script [] :calls (atom [])})
:render (fn [_ _]
((:advance! clock) 7)
(js/queueMicrotask #((:advance! clock) 500))
wasm/FRAME_TYPE_FULL))
result (await
(if camera
(-> (protocol/start-camera! {:hooks hooks :view {:scale 1 :x 0 :y 0}}
{:settle-ms 0})
(protocol/animate-view! {:to {:scale 1 :x 10 :y 5} :steps 1})
(protocol/finish-camera!))
(protocol/drain hooks {:flags 0 :origin 1000 :immediate true})))
end (if camera 1023 1007)]
(t/is (= end (:end-ms result)))
(t/is (= (+ end 500) ((:now clock))))
(if camera
(do
(t/is (= 23 (:interact-ms result)))
(t/is (= 7 (:last-input-to-full-ms result)))
(t/is (= 7 (:time-to-full-ms result))))
(t/is (= 7 (:full-ms result)))))))
(t/deftest immediate-full-reports-equal-boundaries
(t/async done
(let [clock (fake-clock 1000)
calls (atom [])
hooks (drain-hooks {:clock clock :script [2] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 1000 :immediate true})
(.then (fn [{:keys [slices viewport-ready-ms full-ms]}]
(t/is (= 1 (count slices)))
(t/is (= {:timestamp 1000
:flags 0
:frame-type 2
:duration-ms 0}
(first slices)))
(t/is (= full-ms viewport-ready-ms)
"immediate Full supplies both boundaries")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest progressive-frames-propagate-flags
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [1 3 2] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate false})
(.then (fn [{:keys [slices viewport-ready-ms full-ms]}]
(t/is (= [0 1 3] @calls)
"the previous frame type becomes the next flags")
(t/is (= [1 3 2] (mapv :frame-type slices)))
(t/is (< viewport-ready-ms full-ms)
"ViewportReady lands before Full")
(t/is (= (:timestamp (nth slices 1)) (+ 16 16))
"later frames wait for rAF")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest partial-jumps-straight-to-full
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [1 2] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [{:keys [slices viewport-ready-ms full-ms]}]
(t/is (= [0 1] @calls))
(t/is (= [1 2] (mapv :frame-type slices)))
(t/is (= viewport-ready-ms full-ms)
"Full as the first completion supplies both boundaries")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest unknown-frame-types-reject
(t/async done
;; One async context chaining the bad values: a `t/async` inside
;; `doseq` would never run (the doseq returns nil to the runner).
(letfn [(step [remaining]
(if (empty? remaining)
(done)
(let [bad (first remaining)
clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [bad] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [_]
(t/is false (str "must reject for frame type " bad))
(done)))
(.catch (fn [cause]
(t/is (= :benches.render-wasm.runtime.protocol/unexpected-frame-type
(:type (ex-data cause)))
(str "frame type " bad))
(step (rest remaining))))))))]
(step [0 7 js/NaN]))))
(t/deftest cancellation-stops-further-renders
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
fired (atom 0)
hooks (drain-hooks {:clock clock
:script [1 1 1 1]
:calls calls
:check (fn []
(swap! fired inc)
(when (> @fired 2)
(throw (ex-info "canceled" {:phase "test"}))))})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [_]
(t/is false "must reject once check throws")
(done)))
(.catch (fn [cause]
(t/is (= "canceled" (ex-message cause)))
(t/is (= 1 (count @calls)) "cancellation stops before the next render")
(t/is (= 3 @fired) "guard fires once before and once after the render")
(done)))))))
(t/deftest progressive-failure-retains-completed-call-evidence
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [1 7] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [_] (t/is false "unknown frame must fail") (done)))
(.catch (fn [cause]
(t/is (= [1 7] (mapv :frame-type (get-in (ex-data cause) [:partial :slices]))))
(t/is (= [0 0] (mapv :duration-ms (get-in (ex-data cause) [:partial :slices]))))
(done)))))))
(t/deftest ^:async unreadable-error-message-keeps-render-evidence
(let [clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [1] :calls calls})
render (:render hooks)
cause (ex-info "unreadable" {} (js/Error. "device lost"))
_ (js/Object.defineProperty cause "message"
#js {:get (fn [] (throw (js/Error. "message getter failed")))})
hooks (assoc hooks :render (fn [timestamp flags]
(if (seq @calls)
(throw cause)
(render timestamp flags))))
result (try
(await (protocol/drain hooks {:flags 0 :origin 0 :immediate true}))
nil
(catch :default error error))]
(t/is (= [1] (mapv :frame-type (get-in (ex-data result) [:partial :slices]))))
(t/is (some #(= "device lost" (:message %)) (errors/describe-cause result)))))
(t/deftest camera-failure-retains-completed-previews-and-authored-pauses
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
previews (atom 0)
hooks (assoc (drain-hooks {:clock clock :script [2] :calls calls})
:render-from-cache (fn []
(if (= 1 (swap! previews inc))
((:advance! clock) 3)
(throw (ex-info "preview failed" {})))))
view {:scale 1 :x 0 :y 0}]
(-> (protocol/start-camera! {:hooks hooks :view view} {:settle-ms 0})
(protocol/preview-view! view)
(protocol/sleep! 7)
(protocol/preview-view! (assoc view :x 1))
(.then (fn [_] (t/is false "second preview must fail") (done)))
(.catch (fn [cause]
(t/is (= [3] (mapv :duration-ms (get-in (ex-data cause) [:partial :cached-slices]))))
(t/is (= [{:requested-ms 7 :actual-ms 7}]
(get-in (ex-data cause) [:partial :authored-sleeps])))
(done)))))))
(t/deftest rejected-pause-retains-earlier-camera-evidence
(t/async done
(let [clock (fake-clock 0)
sleeps (atom 0)
hooks (assoc (drain-hooks {:clock clock :script [2] :calls (atom [])})
:render-from-cache (fn [] ((:advance! clock) 3))
:sleep (fn [ms]
(if (= 1 (swap! sleeps inc))
(do ((:advance! clock) ms) (js/Promise.resolve nil))
(js/Promise.reject (ex-info "pause failed" {})))))
view {:scale 1 :x 0 :y 0}]
(-> (protocol/start-camera! {:hooks hooks :view view} {:settle-ms 0})
(protocol/preview-view! view)
(protocol/sleep! 7)
(protocol/sleep! 13)
(.then (fn [_] (t/is false "second pause must fail") (done)))
(.catch (fn [cause]
(t/is (= "pause failed" (ex-message cause)))
(t/is (= [3] (mapv :duration-ms (get-in (ex-data cause) [:partial :cached-slices]))))
(t/is (= [{:requested-ms 7 :actual-ms 7}]
(get-in (ex-data cause) [:partial :authored-sleeps])))
(done)))))))
(t/deftest restore-drains-with-sync-tiles-immediately
(t/async done
(let [clock (fake-clock 500)
calls (atom [])
order (atom [])
hooks (assoc (drain-hooks {:clock clock :script [1 3 2] :calls calls})
:set-view-start (fn [] (swap! order conj :start) nil)
:set-view (fn [view]
(swap! order conj [:view view])
nil)
:set-view-end (fn [] (swap! order conj :end) nil))
view {:scale 1 :x 10 :y 20}]
(-> (protocol/restore hooks view)
(.then (fn [{:keys [slices]}]
(t/is (= [:start [:view view] :end] @order)
"camera resets before the drain")
(t/is (= [4 1 3] @calls)
"restore drains with SyncTiles first")
(t/is (= [1 3 2] (mapv :frame-type slices)))
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest camera-session-times-gesture-settle-and-final-drain
(t/async done
(let [clock (fake-clock 0)
renders (atom [])
cached (atom [])
views (atom [])
hooks {:now (:now clock)
:frame (fn []
((:advance! clock) 16)
(js/Promise.resolve ((:now clock))))
:sleep (fn [ms]
((:advance! clock) ms)
(js/Promise.resolve nil))
:check (fn [] nil)
:render (fn [_timestamp flags]
(swap! renders conj flags)
2)
:render-from-cache (fn []
(swap! cached conj true)
((:advance! clock) 3)
nil)
:set-view (fn [view]
(swap! views conj view)
nil)
:set-view-start (fn [] nil)
:set-view-end (fn []
((:advance! clock) 5)
nil)}
frames [{:scale 1 :x 0 :y 0} {:scale 1 :x 100 :y 0}]]
(-> (protocol/start-camera! {:hooks hooks :view (first frames)} {:settle-ms 100})
(protocol/preview-view! (first frames))
(protocol/preview-view! (second frames))
(protocol/finish-camera!)
(.then (fn [m]
(t/is (= frames @views) "every gesture frame sets the view")
(t/is (= 2 (count @cached)) "one cached preview per frame")
(t/is (= 2 (count (:cached-slices m))))
(t/is (every? #(contains? % :timestamp) (:cached-slices m)))
(t/is (every? #(contains? % :duration-ms) (:cached-slices m))
"cached slices keep their own shape")
(t/is (= 100 (:settling-requested-ms m)))
(t/is (= 100 (:settling-actual-ms m))
"fake sleep advances the clock exactly")
(t/is (= 5 (:set-view-end-ms m)))
(t/is (= [4] @renders) "final drain starts with SyncTiles")
(t/is (>= (:time-to-full-ms m) (:set-view-end-ms m))
"finalization includes _set_view_end")
(t/is (= (:time-to-viewport-ready-ms m) (:time-to-full-ms m))
"immediate final drain gives equal boundaries")
(t/is (= 108 (:last-input-to-full-ms m))
"last input at 35, Full at 143: settle + set_view_end + drain")
(t/is (= [wasm/FRAME_TYPE_FULL] (mapv :frame-type (:slices m)))
"frame is full")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest camera-session-chains-promises-and-records-authored-sleep
(t/async done
(let [clock (fake-clock 0)
views (atom [])
events (atom [])
hooks (assoc (drain-hooks {:clock clock :script [2] :calls (atom [])})
:frame (fn []
(swap! events conj :frame)
((:advance! clock) 16)
(js/Promise.resolve ((:now clock))))
:set-view (fn [view]
(swap! views conj view)
((:advance! clock) 2))
:render-from-cache (fn []
(swap! events conj :cached)
((:advance! clock) 3))
:set-view-end (fn []
(swap! events conj :end)
((:advance! clock) 5)))
base {:scale 1 :x 0 :y 0}
target {:scale 2 :x 100 :y 40}]
(-> (protocol/start-camera! {:hooks hooks :view base} {:settle-ms 100})
(protocol/animate-view! {:to target :steps 3})
(protocol/sleep! 25)
(protocol/finish-camera!)
(.then (fn [m]
(t/is (apply < (map :x @views))
"intermediate camera views advance toward the target")
(t/is (= target (last @views)))
(t/is (= [:frame :cached :frame :cached :frame :cached :end] @events))
(t/is (= 3 (count (:cached-slices m))))
(t/is (= [5 5 5] (mapv :view-and-preview-ms (:cached-slices m)))
"call span also includes the view update")
(t/is (= [3 3 3] (mapv :duration-ms (:cached-slices m))))
(t/is (= [{:requested-ms 25 :actual-ms 25}] (:authored-sleeps m)))
(t/is (= 88 (:active-ms m))
"three rAF waits, view/cache calls and authored pause")
(t/is (= 100 (:settling-actual-ms m)))
(t/is (= 193 (:interact-ms m))
"active, settle and camera end are one span")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest camera-session-rejects-hook-errors
(t/async done
(let [clock (fake-clock 0)
hooks (assoc (drain-hooks {:clock clock :script [2] :calls (atom [])})
:render-from-cache (fn [] (throw (ex-info "cache failed" {:phase "preview"}))))]
(-> (protocol/start-camera! {:hooks hooks :view {:scale 1 :x 0 :y 0}}
{:settle-ms 100})
(protocol/preview-view! {:scale 1 :x 10 :y 0})
(protocol/finish-camera!)
(.then (fn [_]
(t/is false "preview error must reject")
(done)))
(.catch (fn [cause]
(t/is (= "cache failed" (ex-message cause)))
(done)))))))
(t/deftest invalid-settle-ms-rejects
(t/async done
(let [hooks (drain-hooks {:clock (fake-clock 0) :script [2] :calls (atom [])})]
(-> (protocol/start-camera! {:hooks hooks :view {:scale 1 :x 0 :y 0}}
{:settle-ms -1})
(.then (fn [_]
(t/is false "invalid settle-ms must reject")
(done)))
(.catch (fn [cause]
(t/is (= ::protocol/invalid-settle-ms (:type (ex-data cause))))
(done)))))))
(t/deftest invalid-sleep-rejects-with-camera-evidence
(t/async done
(let [hooks (drain-hooks {:clock (fake-clock 0) :script [2] :calls (atom [])})]
(-> (protocol/start-camera! {:hooks hooks :view {:scale 1 :x 0 :y 0}}
{:settle-ms 0})
(protocol/sleep! -1)
(.then (fn [_]
(t/is false "invalid sleep must reject")
(done)))
(.catch (fn [cause]
(t/is (= ::protocol/invalid-sleep (:type (ex-data cause))))
(t/is (= {:cached-slices [] :authored-sleeps []}
(:partial (ex-data cause))))
(done)))))))

View File

@ -0,0 +1,624 @@
;; 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.report-test
(:require
[app.common.transit :as transit]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.measurement :as measurement]
[benches.render-wasm.report.analysis :as method]
[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]
[clojure.string :as str]))
(def saved-case
{:id :saved/pan :scene :saved :scene-seed 42 :params {:count 10}
:view {:scale 1 :x 0 :y 0} :context :reuse :preparation {}})
(def metadata
{:git {:sha "abc" :dirty false}
:build {:functional {:mode :release :features []}}
:environment {:browser "Chromium" :node "v24"} :scored? true})
(defn- event
[command values]
{:command command :metrics {:command/elapsed values}})
(defn- block
[phase prep events]
{:phase phase :preparation-id prep :outcome :completed :events events})
(defn- preparation
[id events]
{:preparation-id id :case-id :saved/pan :warmup? false
:fingerprint {:algorithm :sha-256 :digest (apply str (repeat 64 "a"))}
:effective-graphics {:renderer "hardware"}
:diagnostics {:upload-ms 123}
:phases [(block :preparation id events)]})
(defn- new-run
[warmups repetitions context]
(result/create-run {:run-id "run" :started-at "start"
:plan {:cases [(assoc saved-case :context context)]
:warmups warmups :repetitions repetitions}
:metadata metadata :analysis {:draws 50}}))
(defn- add-attempt
[run prep warmup? outcome phases]
(result/record-attempt
run (measurement/attempt :saved/pan prep warmup?
(cond-> {:status (if (= :completed outcome) "ok" "failed")
:phases phases}
(= :failed outcome) (assoc :phase (name (:phase (last phases)))
:message "renderer failed")))))
(defn- sample-run
[values]
(reduce (fn [run value]
(add-attempt run :prep false :completed
[(block :main :prep [(event :test/pan [value])])]))
(result/record-preparation (new-run 0 (count values) :reuse)
(preparation :prep [])) values))
(defn- error-type
[f]
(try (f) nil (catch :default cause (:type (ex-data cause)))))
(def main-pan [:main :test/pan :command/elapsed])
(def main-global [:main :global :command/elapsed])
(defn- approximately?
[a b]
(< (js/Math.abs (- a b)) 1e-9))
(t/deftest mathematical-vectors
(let [settings (method/resolve-settings {:min-p95 1 :min-p99 1})
stats (method/statistics [1 2 3 4 10] settings)]
(t/is (= 4 (get-in stats [:mean :value])))
(t/is (= 3 (get-in stats [:median :value])))
(t/is (= [1 10] [(get-in stats [:min :value]) (get-in stats [:max :value])]))
(t/is (approximately? (js/Math.sqrt 12.5) (get-in stats [:sample-sd :value])))
(t/is (= 1 (get-in stats [:mad :value])))
(t/is (approximately? 8.8 (get-in stats [:p95 :value])))
(t/is (approximately? 9.76 (get-in stats [:p99 :value])))
(t/is (= 1.75 (method/quantile [4 2 1 3] 0.25)))
(t/is (= 2.5 (method/quantile [1 2 3 4] 0.5)))
(t/is (= [1 4] [(method/quantile [4 1 3 2] 0) (method/quantile [4 1 3 2] 1)]))))
(t/deftest numeric-arguments-throw-while-unavailable-results-retain-reasons
(doseq [probability [-0.1 1.1 js/NaN js/Infinity js/-Infinity nil false "0.5"]]
(t/is (= ::method/invalid-probability
(error-type #(method/quantile [1 2] probability)))))
(doseq [values [[js/NaN] [js/Infinity] [js/-Infinity] [nil] ["1"] 42 {} #{1 2}]]
(t/is (= ::method/invalid-observations (error-type #(method/quantile values 0.5)))))
(doseq [override [{:draws 0} {:seed -1} {:seed 4294967296} {:confidence 1}
{:confidence js/NaN} {:min-ci 0} {:unknown 1}]]
(t/is (= ::method/invalid-settings (error-type #(method/resolve-settings override)))))
(t/is (nil? (method/quantile [] 0.5)))
(t/is (nil? (method/quantile nil 0.5)))
(t/is (= {:reason :no-valid-observations} (:median (method/statistics [] method/defaults)))))
(t/deftest calculated-numbers-and-overflow-have-independent-result-maps
(let [stats (method/statistics [4] method/defaults)
changes (method/changes [1e-308] [1e308] (method/resolve-settings {:draws 5 :min-ci 1}))]
(t/is (= {:value 4} (:median stats)))
(t/is (= {:value 0} (:mad stats)))
(t/is (= {:reason :insufficient-observations :minimum 2} (:sample-sd stats)))
(t/is (= {:value 1e308} (:absolute changes)))
(t/is (= {:reason :undefined-estimate} (:percentage changes)))
(t/is (= {:lower 1e308 :upper 1e308} (:absolute-ci changes)))
(t/is (= {:reason :undefined-bootstrap-estimate} (:percentage-ci changes)))))
(t/deftest empty-singleton-and-support-thresholds
(let [settings (method/resolve-settings nil)]
(t/is (= :no-valid-observations (get-in (method/statistics [] settings) [:median :reason])))
(t/is (= :insufficient-observations (get-in (method/statistics [7] settings) [:sample-sd :reason])))
(t/is (= 0 (get-in (method/statistics [7] settings) [:mad :value])))
(t/is (= :insufficient-observations (get-in (method/statistics (vec (repeat 9 7)) settings) [:median-ci :reason])))
(t/is (= {:lower 7 :upper 7} (:median-ci (method/statistics (vec (repeat 10 7)) settings))))
(t/is (= :insufficient-observations (get-in (method/statistics (vec (repeat 99 7)) settings) [:p95 :reason])))
(t/is (= {:value 7} (:p95 (method/statistics (vec (repeat 100 7)) settings))))
(t/is (= :insufficient-observations (get-in (method/statistics (vec (repeat 499 7)) settings) [:p99 :reason])))
(t/is (= {:value 7} (:p99 (method/statistics (vec (repeat 500 7)) settings))))))
(t/deftest deterministic-bootstrap-and-independent-difference-vectors
(let [settings (method/resolve-settings {:draws 100 :seed 123 :confidence 0.8})
stats (method/statistics (vec (range 1 11)) settings)
changes (method/changes (vec (range 1 11)) (vec (range 6 16)) settings)]
(t/is (= 5.5 (get-in stats [:median :value])))
(t/is (= {:lower 3.5 :upper 7} (:median-ci stats)))
(t/is (= 5 (get-in changes [:absolute :value])))
(t/is (approximately? 2.45 (get-in changes [:absolute-ci :lower])))
(t/is (= 8 (get-in changes [:absolute-ci :upper])))
(t/is (approximately? 31.201923076923077 (get-in changes [:percentage-ci :lower])))
(t/is (approximately? 215.35714285714295 (get-in changes [:percentage-ci :upper])))
(t/is (= stats (method/statistics (vec (range 1 11)) settings)))))
(t/deftest zero-baseline-percentages-and-undefined-draws
(let [settings (method/resolve-settings {:draws 100 :min-ci 1 :seed 0})]
(t/is (= :zero-baseline (get-in (method/changes [0 0] [1 2] settings) [:percentage :reason])))
(t/is (= :zero-baseline (get-in (method/changes [0 0] [1 2] settings) [:percentage-ci :reason])))
(t/is (= :zero-bootstrap-baseline (get-in (method/changes [0 1] [2 3] settings) [:percentage-ci :reason])))
(t/is (= :no-valid-observations (get-in (method/changes [] [1] settings) [:absolute :reason])))))
(t/deftest repeated-commands-keep-invocations-values-and-statistical-n-distinct
(let [phases [(block :main :prep
[(assoc-in (event :test/pan [2]) [:metrics :renderer/call-elapsed] [1 2])
(assoc-in (event :test/zoom [4]) [:metrics :renderer/call-elapsed] [4])
(assoc-in (event :test/pan [3]) [:metrics :renderer/call-elapsed] [3])])]
run (-> (new-run 1 2 :reuse)
(result/record-preparation (preparation :prep []))
(add-attempt :prep true :completed phases)
(add-attempt :prep false :completed phases)
(add-attempt :prep false :completed phases)
(result/record-case-outcome :saved/pan {:outcome :completed})
(result/finish-run "end" {:reason :completed})
result/encode result/decode)
report (first (:cases (summary/summarize run)))
pan (get-in report [:metrics [:main :test/pan :renderer/call-elapsed]])
global (get-in report [:metrics [:main :global :renderer/call-elapsed]])]
(t/is (= 3 (count (get-in run [:attempts 1 :phases 0 :events]))))
(t/is (= {:valid 2 :invalid 0 :absent 0 :failed 0 :warmup 1 :unattempted 0
:invocations 4 :raw-values 6} (:counts pan)))
(t/is (= 2 (get-in pan [:statistics :count])))
(t/is (= 6 (get-in pan [:statistics :median :value])))
(t/is (= 10 (get-in global [:statistics :median :value])))
(t/is (= 6 (get-in global [:counts :invocations])))
(t/is (= 8 (get-in global [:counts :raw-values])))
(t/is (str/includes? (format/format-run (summary/summarize run)) "invocations=4 raw-values=6 n=2"))))
(t/deftest phase-eligibility-keeps-preparation-after-reset-failure
(let [run (-> (new-run 1 3 :reuse)
(result/record-preparation (preparation :prep [(event :test/setup [8])]))
(add-attempt :prep true :completed [(block :main :prep []) (block :reset :prep [])])
(add-attempt :prep false :completed
[(block :main :prep [(event :test/pan [2])])
(block :reset :prep [(event :test/restore [3])])])
(add-attempt :prep false :failed
[(block :main :prep [(event :test/pan [100])])
(assoc (block :reset :prep [(event :test/restore [200])]) :outcome :failed)])
(result/finish-run "end" {:reason :failed})
result/encode result/decode)
report (first (:cases (summary/summarize run)))
prep (get-in report [:metrics [:preparation :test/setup :command/elapsed]])]
(t/is (= [0 1 2] (mapv :attempt-id (:attempts run))))
(t/is (= {:warmups 0 :repetitions 1} (:remaining report)))
(t/is (= 8 (get-in prep [:statistics :median :value])))
(t/is (= 1 (get-in prep [:statistics :count])))
(t/is (= :insufficient-observations (get-in prep [:statistics :median-ci :reason])))
(t/is (= 0 (get-in prep [:counts :warmup])))
(t/is (= [2] (:values (summary/observations run :saved/pan main-pan))))
(t/is (= [3] (:values (summary/observations run :saved/pan [:reset :test/restore :command/elapsed]))))
(t/is (= {:executions 3 :completed 3 :failed 0 :warmup 1} (get-in report [:phases :main])))
(t/is (= {:executions 3 :completed 2 :failed 1 :warmup 1} (get-in report [:phases :reset])))))
(t/deftest fresh-preparations-follow-their-own-slot-eligibility
(let [run (-> (new-run 1 2 :fresh)
(result/record-preparation (assoc (preparation :warm [(event :test/setup [900])]) :warmup? true))
(add-attempt :warm true :completed [(block :main :warm [])])
(result/record-preparation (preparation :first [(event :test/setup [5])]))
(add-attempt :first false :completed [(block :main :first [])])
(result/record-preparation (preparation :second [(event :test/setup [7])]))
(add-attempt :second false :failed [(assoc (block :main :second []) :outcome :failed)]))
obs (summary/observations run :saved/pan [:preparation :test/setup :command/elapsed])]
(t/is (= [5 7] (:values obs)))
(t/is (= 1 (get-in obs [:counts :warmup])))
(t/is (= 2 (get-in obs [:counts :valid])))))
(t/deftest fresh-warmup-preparation-is-excluded-before-main-delivery
(let [run (-> (new-run 1 1 :fresh)
(result/record-preparation
(assoc (preparation :prep [(event :test/setup [9])]) :warmup? true))
result/encode result/decode)
report (get-in (summary/summarize run)
[:cases 0 :metrics [:preparation :test/setup :command/elapsed]])]
(t/is (= 1 (get-in report [:counts :warmup])))
(t/is (= :no-valid-observations (get-in report [:statistics :median :reason])))))
(t/deftest failed-preparation-excludes-a-completed-callback-from-observations
(let [run (-> (new-run 0 1 :reuse)
(result/record-preparation
(assoc (preparation :prep [(event :test/setup [9])])
:failure {:phase "preparation" :message "initial Full failed"})))
report (first (:cases (summary/summarize run)))
metric (get-in report [:metrics [:preparation :test/setup :command/elapsed]])]
(t/is (= {:executions 1 :completed 1 :failed 0 :warmup 0} (get-in report [:phases :preparation])))
(t/is (= 1 (get-in metric [:counts :failed])))
(t/is (= :no-valid-observations (get-in metric [:statistics :median :reason])))))
(t/deftest invalid-members-propagate-only-to-dependent-quantities
(let [bad (assoc-in (event :test/pan [0]) [:metrics :renderer/call-elapsed] [1 js/NaN])
good (assoc-in (event :test/zoom [0]) [:metrics :renderer/call-elapsed] [2])
run (-> (new-run 0 1 :reuse)
(result/record-preparation (preparation :prep []))
(add-attempt :prep false :completed [(block :main :prep [bad good])]))
report (first (:cases (summary/summarize run)))]
(t/is (= [0] (:values (summary/observations run :saved/pan main-global))))
(t/is (= [2] (:values (summary/observations run :saved/pan [:main :test/zoom :renderer/call-elapsed]))))
(doseq [scope [:test/pan :global]]
(let [metric (get-in report [:metrics [:main scope :renderer/call-elapsed]])]
(t/is (= 1 (get-in metric [:counts :invalid])))
(t/is (= :non-finite (get-in metric [:diagnostics 0 :reason])))
(t/is (= :no-valid-observations (get-in metric [:statistics :median :reason])))))))
(t/deftest raw-special-values-and-partial-failures-survive-transit
(let [values [0 -1 js/NaN js/Infinity js/-Infinity]
initial (-> (new-run 0 6 :reuse)
(result/record-preparation (preparation :prep [])))
run (-> (reduce (fn [run value]
(add-attempt run :prep false :completed
[(block :main :prep [(event :test/pan [value])])]))
initial values)
(result/record-attempt
(measurement/attempt :saved/pan :prep false
{:status "failed" :phase "main" :message "render failed"
:cause [{:message "WASM trap"}]
:partial {:clock js/NaN}
:phases [(assoc (block :main :prep [(event :test/pan [99])])
:outcome :failed)]}))
(result/finish-run "end" {:reason :failed})
result/encode result/decode)
raw (mapv #(get-in % [:phases 0 :events 0 :metrics :command/elapsed 0]) (:attempts run))
obs (summary/observations run :saved/pan main-pan)]
(t/is (= [0 -1] (subvec raw 0 2)))
(t/is (js/Number.isNaN (nth raw 2)))
(t/is (= [js/Infinity js/-Infinity] (subvec raw 3 5)))
(t/is (= [{:message "WASM trap"}] (get-in run [:attempts 5 :failure :cause])))
(t/is (js/Number.isNaN (get-in run [:attempts 5 :partial :clock])))
(t/is (= 99 (nth raw 5)))
(t/is (= [0] (:values obs)))
(t/is (= 1 (get-in obs [:counts :failed])))
(t/is (= 4 (get-in obs [:counts :invalid])))))
(t/deftest empty-blocks-support-preparation-only-and-reset-only-cases
(doseq [phase [:preparation :reset]]
(let [run (-> (new-run 0 1 :reuse)
(result/record-preparation
(preparation :prep (if (= :preparation phase) [(event :test/work [2])] [])))
(add-attempt :prep false :completed
[(block :main :prep [])
(block :reset :prep (if (= :reset phase) [(event :test/work [2])] []))])
(result/record-case-outcome :saved/pan {:outcome :completed})
(result/finish-run "end" {:reason :completed})
result/encode result/decode)]
(t/is (= [2] (:values (summary/observations run :saved/pan [phase :global :command/elapsed]))))))
(let [empty-run (-> (new-run 0 1 :reuse)
(result/record-preparation (preparation :prep []))
(add-attempt :prep false :completed [(block :main :prep [])]))]
(t/is (= ::result/invalid-record
(error-type #(result/finish-run empty-run "end" {:reason :completed})))))
(let [failed (-> (new-run 0 1 :reuse)
(result/record-preparation {:preparation-id :prep :case-id :saved/pan :warmup? false
:diagnostics {} :phases []
:failure {:phase "preparation" :message "setup failed"}})
(result/finish-run "end" {:reason :preparation-error}))]
(t/is (str/includes? (format/format-run (summary/summarize (result/decode (result/encode failed))))
"setup failed"))))
(t/deftest comparison-reports-preparation-failures-once-per-source
(let [failure {:phase "preparation" :message "setup failed"
:errors [{:phase "upload" :operation :serialize-shapes :message "setup failed"}
{:phase "upload" :operation :end-loading :message "ending load failed"}]}
run (-> (new-run 0 2 :fresh)
(result/record-preparation (preparation :ready []))
(add-attempt :ready false :completed [(block :main :ready [(event :test/work [2])])])
(result/record-preparation {:preparation-id :failed :case-id :saved/pan :warmup? false
:diagnostics {} :phases [] :failure failure})
(result/record-attempt (measurement/attempt :saved/pan :failed false
(assoc failure :status "failed" :phases [])))
(result/record-case-outcome :saved/pan {:outcome :failed :failure failure})
(result/finish-run "end" {:reason :failed :failure failure})
(assoc-in [:metadata :diagnostics :screenshots]
{"saved/pan" {:status :skipped :reason :no-completed-measured-attempt}})
result/encode result/decode)
candidate (-> run
(assoc :run-id "candidate")
(assoc-in [:metadata :diagnostics :screenshots]
{"saved/pan" {:status :ok :file "candidate-pan.png"}})
result/encode result/decode)
compared (-> (compare/compare-runs run candidate
{:allow-all-differences true})
result/encode result/decode)
text (format/format-comparison compared)]
(t/is (= 2 (count (re-seq #"setup failed" text))))
(t/is (= 2 (count (re-seq #"ending load failed" text))))
(t/is (str/includes? text "median change"))
(t/is (= 2 (count (re-seq #"Preparation :failed" text))))
(t/is (str/includes? text "Baseline screenshots\n \"saved/pan\": skipped; reason :no-completed-measured-attempt"))
(t/is (str/includes? text "Candidate screenshots\n \"saved/pan\": ok\n File \"candidate-pan.png\""))))
(t/deftest collected-failures-print-a-long-message-once-and-retain-enclosing-context
(let [message (str "failure-marker " (apply str (repeat 1400 "x")))
failure {:phase "preparation" :operation :prepare-scene :path "scene.transit" :message message
:errors [{:phase "upload" :operation :serialize-shapes
:message (subs message 0 1024)}]}
run (-> (new-run 0 1 :fresh)
(result/record-preparation {:preparation-id :failed :case-id :saved/pan :warmup? false
:diagnostics {} :phases [] :failure failure})
(result/finish-run "end" {:reason :failed :failure failure})
result/encode result/decode)
text (format/format-run (summary/summarize run))]
(t/is (= 1 (count (re-seq #"failure-marker" text))))
(t/is (str/includes? text ":prepare-scene \"preparation\""))
(t/is (str/includes? text "Path \"scene.transit\""))
(t/is (str/includes? text ":serialize-shapes \"upload\""))))
(t/deftest unresolved-process-details-survive-the-report-entry-limit
(let [failure (errors/evidence
:cleanup
(errors/combine
(conj (mapv (fn [index] {:phase "cleanup" :error (js/Error. (str "cleanup " index))})
(range 8))
{:phase "cleanup" :operation :stop-process
:error (errors/fatal-error "Compiler group may still be running"
{:unresolved-process true :pid 4242 :pgid 4242
:signals ["SIGTERM" "SIGKILL"]} nil)})))
run (-> (new-run 0 1 :fresh)
(result/finish-run "end" {:reason :failed :failure failure})
result/encode result/decode)
text (format/format-run (summary/summarize run))]
(t/is (str/includes? text "pgid 4242"))
(t/is (str/includes? text "SIGKILL"))
(t/is (str/includes? text "unresolved-process true"))
(t/is (str/includes? text "1 more errors recorded"))))
(t/deftest equal-diagnostics-keep-failures-from-distinct-preparations
(let [failure {:phase "renderer" :message "shared renderer failure"}]
(doseq [phases [[]
[(block :main :ready [])
(assoc (block :reset :ready []) :outcome :failed)]]]
(t/testing (str "attempt phases " (mapv :phase phases))
(let [run (-> (new-run 0 2 :fresh)
(result/record-preparation (preparation :ready []))
(result/record-attempt
(measurement/attempt :saved/pan :ready false
(assoc failure :status "failed" :phases phases)))
(result/record-preparation {:preparation-id :failed :case-id :saved/pan :warmup? false
:diagnostics {} :phases [] :failure failure})
(result/record-case-outcome :saved/pan {:outcome :failed :failure failure})
(result/finish-run "end" {:reason :failed :failure failure})
result/encode result/decode)
text (format/format-run (summary/summarize run))]
(t/is (str/includes? text "Preparation :failed (case :saved/pan): \"renderer\": \"shared renderer failure\""))
(t/is (str/includes? text "Attempt 0 (preparation :ready) (case :saved/pan): \"renderer\": \"shared renderer failure\""))
(t/is (= 2 (count (re-seq #"shared renderer failure" text)))))))))
(t/deftest equal-diagnostics-keep-failures-from-distinct-cases
(let [failure {:phase "preparation" :message "shared setup failure"}]
(doseq [harness? [false true]]
(t/testing (if harness? "harness failure in another case" "outcome failure in another case")
(let [run (-> (result/create-run
{:run-id "run" :started-at "start"
:plan {:cases [saved-case (assoc saved-case :id :saved/other)]
:warmups 0 :repetitions 1}
:metadata (assoc-in metadata [:diagnostics :failures]
(if harness?
[(assoc failure :case-id :saved/other :stage :preparation)]
[]))
:analysis {:draws 50}})
(result/record-preparation {:preparation-id :failed :case-id :saved/pan :warmup? false
:diagnostics {} :phases [] :failure failure})
(result/record-case-outcome :saved/pan {:outcome :failed :failure failure})
(result/record-case-outcome :saved/other {:outcome :failed :failure failure})
(result/finish-run "end" {:reason :failed :failure failure})
result/encode result/decode)
text (format/format-run (summary/summarize run))]
(t/is (str/includes? text "Preparation :failed (case :saved/pan): \"preparation\": \"shared setup failure\""))
(t/is (str/includes? text (str (if harness? "Harness 0 :preparation" "Case outcome")
" (case :saved/other): \"preparation\": \"shared setup failure\"")))
(t/is (= 2 (count (re-seq #"shared setup failure" text)))))))))
(t/deftest ambiguous-screenshot-matches-report-the-warning-and-each-harness-failure
(let [failure {:phase "screenshot" :message "screenshot deadline exceeded"
:cause [{:message "browser deadline"}]
:stage :case-deadline :case-id :saved/pan}]
(doseq [[label case-ids failures]
[["multiple deadlines" [:saved/pan] [failure failure]]
["distinct IDs with the same displayed name" [:saved/pan "saved/pan"] [failure]]]]
(t/testing label
(let [run (-> (result/create-run
{:run-id "run" :started-at "start"
:plan {:cases (mapv #(assoc saved-case :id %) case-ids)
:warmups 0 :repetitions 1}
:metadata (assoc metadata :diagnostics
{:failures failures
:screenshots {"saved/pan" {:status :failed :file "saved-pan.png"
:message (:message failure)
:cause (:cause failure)}}})
:analysis {:draws 50}})
(result/finish-run "end" {:reason :failed :failure failure})
result/encode result/decode)
text (format/format-run (summary/summarize run))]
(t/is (str/includes? text "\"saved/pan\": warning; \"screenshot deadline exceeded\""))
(t/is (str/includes? text "File \"saved-pan.png\""))
(doseq [index (range (count failures))]
(t/is (str/includes? text (str "Harness " index " :case-deadline (case :saved/pan): \"screenshot\": \"screenshot deadline exceeded\""))))
(t/is (= (inc (count failures)) (count (re-seq #"screenshot deadline exceeded" text)))))))))
(t/deftest offline-comparison-uses-one-analysis-and-saved-quantity-units
(let [base (-> (sample-run [1 2 3]) result/encode result/decode)
candidate (-> (sample-run [3 4 5])
(assoc-in [:analysis :seed] 99)
(assoc-in [:quantities :command/elapsed :description] "Reworded description")
result/encode result/decode)
comparison (-> (compare/compare-runs base candidate {:analysis {:draws 20}})
result/encode result/decode)]
(t/is (= 2 (get-in comparison [:pairs 0 :metrics main-pan :absolute :value])))
(t/is (= 20 (get-in comparison [:analysis :draws])))
(t/is (empty? (get-in comparison [:pairs 0 :mismatches])))
(t/is (str/includes? (format/format-comparison comparison) "median change 2"))
(let [changed (assoc-in candidate [:quantities :command/elapsed :unit] :seconds)]
(t/is (= ::compare/incompatible (error-type #(compare/compare-runs base changed))))
(t/is (= 2 (get-in (compare/compare-runs base changed {:allow-all-differences true})
[:pairs 0 :metrics main-pan :absolute :value]))))))
(t/deftest unknown-and-malformed-quantities-produce-diagnostics
(doseq [[description reason] [[nil :unknown-quantity] [{:unit 2} :malformed-quantity]]]
(let [run (cond-> (sample-run [1])
(nil? description) (update :quantities dissoc :command/elapsed)
(some? description) (assoc-in [:quantities :command/elapsed] description))
report (get-in (summary/summarize (result/decode (result/encode run)))
[:cases 0 :metrics main-pan])]
(t/is (= reason (get-in report [:diagnostics 0 :reason])))
(t/is (= :no-valid-observations (get-in report [:statistics :median :reason]))))))
(defn- feature-run
[features selection]
(-> (sample-run [1])
(assoc-in [:metadata :build :functional :features] features)
(assoc-in [:metadata :build :provenance :feature-selection] selection)))
(t/deftest automatic-feature-permission-checks-effective-and-requested-evidence
(let [baseline (feature-run ["base" "old"] {:default-features? false :features ["base"]})
candidate (feature-run ["base" "toggle" "transitive"]
{:default-features? false :features ["base" "toggle"]})
expected {:feature "toggle" :baseline-features ["base" "old"]
:candidate-features ["base" "toggle" "transitive"]
:added ["toggle" "transitive"] :removed ["old"]}
edge (assoc expected :from "0" :to "1")
report (compare/compare-runs baseline candidate {:expected-feature-change expected :edge edge})]
(t/is (= :feature-toggle (get-in report [:pairs 0 :mismatches 0 :rule])))
(t/is (= report (result/decode (result/encode report))))
(doseq [invalid [nil (assoc expected :feature "base") (assoc expected :feature :toggle)
(assoc expected :baseline-features ["base"])
(assoc expected :candidate-features ["base" "toggle"])
(assoc expected :added ["toggle"]) (assoc expected :removed [])
(assoc expected :added #{"toggle" "transitive"})
(dissoc expected :baseline-features)]]
(t/is (= ::compare/incompatible
(error-type #(compare/compare-runs baseline candidate {:expected-feature-change invalid})))))
(doseq [selection [nil {} {:default-features? true :features ["base" "toggle"]}
{:default-features? false :features ["toggle"]}
{:default-features? false :features #{"base" "toggle"}}]]
(t/is (= ::compare/incompatible
(error-type #(compare/compare-runs
baseline (assoc-in candidate [:metadata :build :provenance :feature-selection] selection)
{:expected-feature-change expected})))))
(doseq [bad-edge [(dissoc edge :removed) (assoc edge :added []) (assoc edge :feature :toggle)]]
(t/is (= ::compare/invalid-edge
(error-type #(compare/compare-runs baseline candidate {:edge bad-edge})))))
(t/is (= ::compare/invalid-edge
(error-type #(compare/compare-runs baseline candidate
{:edge edge :expected-feature-change (assoc expected :added [])}))))))
(t/deftest missing-nil-and-empty-effective-features-keep-distinct-meaning
(doseq [before [::missing nil []] after [::missing nil []]]
(let [with-features (fn [features]
(cond-> (sample-run [1])
(= ::missing features) (update-in [:metadata :build :functional] dissoc :features)
(not= ::missing features) (assoc-in [:metadata :build :functional :features] features)))
baseline (-> (with-features before) result/encode result/decode)
candidate (-> (with-features after) result/encode result/decode)
compared (compare/compare-runs baseline candidate {:allow-build-differences true})]
(t/is (= (if (= before after) [] [:effective-features])
(mapv :field (get-in compared [:pairs 0 :mismatches])))))))
(t/deftest comparison-boundaries-validate-permissions-rules-and-intervals
(let [base (sample-run [1])
candidate (assoc-in (sample-run [2]) [:metadata :build :functional :mode] :debug)
compared (compare/compare-runs base candidate {:allow-build-differences true})
path [:pairs 0 :metrics main-pan]]
(doseq [bad [(dissoc compared :permissions)
(assoc-in compared [:permissions :allow-build-differences] :yes)
(update compared :permissions dissoc :allow-scene-differences)
(assoc-in compared [:pairs 0 :mismatches 0 :rule] :other)
(assoc-in compared [:pairs 0 :mismatches 0 :waived?] false)
(update-in compared [:pairs 0 :mismatches 0] dissoc :rule)
(assoc-in compared (conj path :absolute :value) js/NaN)
(assoc-in compared (conj path :absolute-ci) {:lower 2 :upper 1})
(assoc-in compared [:pairs 0 :baseline :metrics main-pan :counts :valid] -1)]]
(t/is (= ::result/invalid-record (error-type #(result/decode (transit/encode-str bad))))))))
(t/deftest scene-permission-requires-complete-consistent-preparation-evidence
(let [base (sample-run [1])
changed (result/record-preparation base
(assoc-in (preparation :other []) [:fingerprint :digest]
(apply str (repeat 64 "b"))))
report (compare/compare-runs base changed {:allow-all-differences true})]
(t/is (= ::compare/incompatible
(error-type #(compare/compare-runs base changed {:allow-scene-differences true}))))
(t/is (some #(= [:fingerprint :incomplete-or-varying-evidence] (:field %))
(get-in report [:pairs 0 :mismatches])))))
(t/deftest matching-missing-preparation-facts-produce-incomplete-evidence
(let [run (-> (new-run 0 1 :reuse)
(result/record-preparation
(-> (preparation :prep [(event :test/setup [9])])
(dissoc :fingerprint :effective-graphics)
(assoc :failure {:phase "preparation" :message "setup failed"}))))]
(doseq [options [{} {:allow-scene-differences true}]]
(t/is (= ::compare/incompatible (error-type #(compare/compare-runs run run options)))))
(let [report (compare/compare-runs run run {:allow-all-differences true})]
(t/is (= #{[:fingerprint :incomplete-or-varying-evidence]
[:effective-graphics :incomplete-or-varying-evidence]}
(set (map :field (get-in report [:pairs 0 :mismatches]))))))))
(t/deftest saved-boundaries-refuse-corrupt-identities-and-phase-history
(let [run (sample-run [1])]
(doseq [bad [(assoc-in run [:attempts 0 :attempt-id] 3)
(assoc-in run [:attempts 0 :phases] [])
(assoc-in run [:preparations 0 :phases] [])
(assoc-in run [:preparations 0 :failure] {:phase "preparation" :message "setup failed"})
(update-in run [:attempts 0] assoc :outcome :failed
:phases [(assoc (block :main :prep []) :outcome :failed)
(block :reset :prep [])])
(assoc-in run [:attempts 0 :preparation-id] :unknown)
(assoc-in run [:attempts 0 :phases 0 :preparation-id] :unknown)
(assoc-in run [:attempts 0 :phases 0 :events 0 :metrics :command/elapsed] [])
(assoc-in run [:attempts 0 :phases 0 :events 0 :metrics :command/elapsed] [1 2])
(assoc-in run [:remaining :saved/pan :repetitions] 2)]]
(t/is (= ::result/invalid-record
(error-type #(result/decode (transit/encode-str bad))))))))
(t/deftest saved-failures-reject-malformed-diagnostics
(let [diagnostic {:phase "main" :message "renderer failed"}
failed-preparation (-> (new-run 0 1 :reuse)
(result/record-preparation (assoc (preparation :prep []) :failure diagnostic))
(result/finish-run "end" {:reason :failed})
result/encode result/decode)
failed-attempt (-> (new-run 0 1 :reuse)
(result/record-preparation (preparation :prep []))
(add-attempt :prep false :failed [(assoc (block :main :prep []) :outcome :failed)])
(result/record-case-outcome :saved/pan {:outcome :failed :failure diagnostic})
(result/finish-run "end" {:reason :failed :failure diagnostic})
(assoc-in [:metadata :diagnostics :failures] [diagnostic])
result/encode result/decode)]
(doseq [[run path] [[failed-preparation [:preparations 0 :failure]]
[failed-attempt [:attempts 0 :failure]]
[failed-attempt [:case-outcomes :saved/pan :failure]]
[failed-attempt [:termination :failure]]
[failed-attempt [:metadata :diagnostics :failures 0]]]
invalid [{:phase 42 :message "bad location"}
{:phase "main" :message nil}
{:phase "main" :message "bad cause" :cause [42]}
{:phase "main" :message "bad error collection"
:errors [{:phase "cleanup" :message "nested collection" :errors []}]}]]
(t/is (= ::result/invalid-record
(error-type #(result/decode (transit/encode-str (assoc-in run path invalid)))))))))
(t/deftest case-outcomes-validate-completion-and-close-the-recording-boundary
(let [run (sample-run [1])
completed (result/record-case-outcome run :saved/pan {:outcome :completed})
ended (result/finish-run completed "end" {:reason :completed})]
(t/is (= ended (result/decode (result/encode ended))))
(doseq [bad [(assoc ended :case-outcomes {})
(-> run
(assoc-in [:plan :repetitions] 2)
(assoc-in [:remaining :saved/pan :repetitions] 1)
(assoc-in [:case-outcomes :saved/pan] {:outcome :completed}))
(assoc-in ended [:case-outcomes :unknown] {:outcome :completed})]]
(t/is (= ::result/invalid-record (error-type #(result/decode (transit/encode-str bad))))))
(t/is (= ::result/invalid-record
(error-type #(result/record-preparation completed (preparation :other [])))))
(let [stopped (-> run
(assoc-in [:plan :repetitions] 2)
(assoc-in [:remaining :saved/pan :repetitions] 1)
(result/record-case-outcome :saved/pan {:outcome :failed}))]
(t/is (thrown-with-msg? js/Error #"case already terminated"
(add-attempt stopped :prep false :completed [(block :main :prep [])]))))))

View File

@ -0,0 +1,208 @@
;; 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.request-test
(:require
[app.common.schema :as sm]
[benches.render-wasm.node.cli.request :as request]
[cljs.test :as t :include-macros true]
[performance-tests.render-wasm.test-helpers :refer [failure-data]]))
(def ^:private console-defaults
{:level :info :color :auto})
(def ^:private measurement-defaults
{:seed 42 :filter nil :warmups 3 :repetitions 10
:timeout-ms 30000 :startup-timeout-ms 30000 :output-dir nil
:screenshots false :require-gpu false})
(def ^:private comparison-defaults
{:allow-build-differences false :allow-scene-differences false
:allow-all-differences false})
(def ^:private existing-run
{:command "run" :console console-defaults
:options (assoc measurement-defaults
:build {:kind :existing :id "ready-build"}
:remove false)})
(def ^:private default-cases
[[{:command "build"}
{:command "build" :console console-defaults
:options {:default-features true :features [] :build-timeout-ms 300000}}]
[{:command "run" :console {} :options {:build {:kind :prepare}}}
{:command "run" :console console-defaults
:options (assoc measurement-defaults
:build {:kind :prepare :default-features true
:features [] :build-timeout-ms 300000}
:remove false)}]
[{:command "run" :options {:build {:kind :existing :id "ready-build"}}}
existing-run]
[{:command "ab" :options {:features ["render-cache"]}}
{:command "ab" :console console-defaults
:options (merge measurement-defaults comparison-defaults
{:features ["render-cache"] :max-features 3
:confirm false :dry-run false :build-timeout-ms 300000})}]
[{:command "compare"
:options {:baseline-path "base.transit" :candidate-path "candidate.transit"}}
{:command "compare" :console console-defaults
:options (assoc comparison-defaults
:baseline-path "base.transit"
:candidate-path "candidate.transit" :case-pair nil)}]
[{:command "merge"
:options {:base-path "base.transit" :additional-path "extra.transit"}}
{:command "merge" :console console-defaults
:options {:base-path "base.transit" :additional-path "extra.transit"
:output-dir nil}}]
[{:command "purge" :options {:target :runs}}
{:command "purge" :console console-defaults :options {:target :runs}}]
[{:command "help"}
{:command "help" :console console-defaults :options {:topic nil}}]])
(t/deftest defaults-produce-complete-command-requests
(doseq [[partial expected] default-cases]
(t/testing (pr-str partial)
(t/is (= expected (request/check (request/apply-defaults partial)))))))
(t/deftest explicit-values-and-literal-text-survive-resolution
(let [partial {:command "run" :console {:level :warn :color :never}
:options {:seed 0 :warmups 0 :filter " " :output-dir " ./runs "
:build {:kind :prepare :default-features false
:features []}}}
expected {:command "run" :console {:level :warn :color :never}
:options (assoc measurement-defaults
:seed 0 :warmups 0 :filter " " :output-dir " ./runs "
:build {:kind :prepare :default-features false
:features [] :build-timeout-ms 300000}
:remove false)}]
(t/is (= expected (request/check (request/apply-defaults partial)))))
(let [partial {:command "compare"
:options {:baseline-path " ./base " :candidate-path " ./candidate "
:case-pair [" rects/load " "other/load"]}}]
(t/is (= (assoc partial :console console-defaults
:options (merge comparison-defaults (:options partial)))
(request/check (request/apply-defaults partial)))))
(t/is (= existing-run
(request/check
(request/apply-defaults (assoc-in existing-run [:options :warmups] nil))))))
(t/deftest required-selections-and-paths-have-no-defaults
(doseq [partial [{}
{:command "unknown"}
{:command "run"}
{:command "run" :options {:build {}}}
{:command "run" :options {:build {:kind :unknown}}}
{:command "run" :options {:build {:kind :existing}}}
{:command "ab"}
{:command "compare"}
{:command "compare" :options {:baseline-path "base"}}
{:command "compare" :options {:candidate-path "candidate"}}
{:command "merge" :options {:base-path "base"}}
{:command "merge" :options {:additional-path "additional"}}
{:command "purge"}]]
(t/testing (pr-str partial)
(t/is (= ::request/invalid-request
(:type (failure-data #(request/check (request/apply-defaults partial)))))))))
(t/deftest receiver-requires-complete-data-and-reports-the-field
(doseq [[_ complete] default-cases]
(t/is (= ::request/invalid-request
(:type (failure-data #(request/check (dissoc complete :console)))))))
(doseq [incomplete [(dissoc existing-run :options)
(update existing-run :console dissoc :level)
(update existing-run :options dissoc :warmups)
(assoc-in existing-run [:options :warmups] nil)
(assoc-in existing-run [:options :remove] nil)
(assoc-in existing-run [:console :level] nil)]]
(let [data (failure-data #(request/check incomplete))]
(t/is (= ::request/invalid-request (:type data)))
(t/is (seq (:errors (::sm/explain data))))))
(let [data (failure-data #(request/check (assoc-in existing-run [:options :warmups] -1)))]
(t/is (some #(= [:options :warmups] (:in %)) (:errors (::sm/explain data))))))
(t/deftest receiver-rejects-extra-fields-at-every-map-boundary
(doseq [[path value] [[[:extra] true]
[[:console :stream] "stdout"]
[[:options :browser] {}]
[[:options :build :features] []]
[[:options :build :default-features] false]
[[:options :build :build-timeout-ms] 1000]]]
(let [input (assoc-in existing-run path value)
resolved (request/apply-defaults input)]
(t/is (= input resolved))
(t/is (= ::request/invalid-request
(:type (failure-data #(request/check resolved)))))))
(doseq [partial [{:command "run" :options {:build {:kind :prepare :id "ready-build"}}}
{:command "ab" :options {:features ["cache"] :default-features false}}
{:command "ab" :options {:features ["cache"] :remove false}}
{:command "ab" :options {:features ["cache"] :build {:kind :prepare}}}]]
(t/is (= ::request/invalid-request
(:type (failure-data #(request/check (request/apply-defaults partial))))))))
(t/deftest numeric-constraints-match-seeds-counts-and-node-timers
(doseq [[field values] [[:seed [0 4294967295]]
[:warmups [0 js/Number.MAX_SAFE_INTEGER]]
[:repetitions [1 js/Number.MAX_SAFE_INTEGER]]
[:timeout-ms [1 2147483647]]
[:startup-timeout-ms [1 2147483647]]]]
(doseq [value values]
(let [input (assoc-in existing-run [:options field] value)]
(t/is (= input (request/check input))))))
(doseq [[field values] [[:seed [-1 4294967296 1.5 "42" js/NaN js/Infinity]]
[:warmups [-1 (inc js/Number.MAX_SAFE_INTEGER) 0.5 "3"]]
[:repetitions [0 (inc js/Number.MAX_SAFE_INTEGER) 1.5]]
[:timeout-ms [0 2147483648 1.5 "30000"]]
[:startup-timeout-ms [0 2147483648]]]]
(doseq [value values]
(t/testing (str field " " value)
(let [input (assoc-in existing-run [:options field] value)
resolved (request/apply-defaults input)]
(when (string? value)
(t/is (= value (get-in resolved [:options field]))))
(t/is (= ::request/invalid-request
(:type (failure-data #(request/check resolved)))))))))
(doseq [command ["build" "ab"]
value [0 2147483648 1.5]]
(let [partial {:command command :options {:features ["cache"] :build-timeout-ms value}}]
(t/is (= ::request/invalid-request
(:type (failure-data #(request/check (request/apply-defaults partial)))))))))
(t/deftest command-values-use-their-domain-constraints
(doseq [partial [{:command "run" :options {:build {:kind :existing :id "../build"}}}
{:command "run" :options {:build {:kind :existing :id ""}}}
{:command "build" :options {:features ["bad feature"]}}
{:command "build" :options {:features [nil]}}
{:command "ab" :options {:features []}}
{:command "ab" :options {:features ["cache"] :max-features 0}}
{:command "ab" :options {:features ["cache"] :max-features 7}}
{:command "ab" :options {:features ["cache"] :max-features 1.5}}
{:command "compare" :options {:baseline-path " " :candidate-path "c"}}
{:command "merge" :options {:base-path "b" :additional-path ""}}
{:command "merge" :options {:base-path "b" :additional-path "a"
:output-dir " "}}
{:command "purge" :options {:target :unknown}}
{:command "help" :options {:topic "unknown"}}
{:command "help" :console {:level :error}}
{:command "help" :console {:color true}}]]
(t/testing (pr-str partial)
(let [resolved (request/apply-defaults partial)]
(t/is (= ::request/invalid-request
(:type (failure-data #(request/check resolved))))))))
(doseq [pair [["only"] ["one" "two" "three"] ["" "two"] ["one" " "]]]
(t/is (= ::request/invalid-request
(:type (failure-data
#(request/check
(request/apply-defaults
{:command "compare" :options {:baseline-path "b" :candidate-path "c"
:case-pair pair}})))))))
(doseq [filter-text [nil "" " " " rects "]]
(let [input (assoc-in existing-run [:options :filter] filter-text)]
(t/is (= input (request/check (request/apply-defaults input))))))
(doseq [target [:builds :runs :comparisons :all]]
(t/is (= target
(get-in (request/check
(request/apply-defaults {:command "purge" :options {:target target}}))
[:options :target])))))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,229 @@
;; 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.runner
(:require
[app.common.logging :as l]
[cljs.test :as t]
[clojure.string :as str]
[clojure.tools.cli :refer [parse-opts]]
[goog.object :as gobj]
[performance-tests.render-wasm.artifacts-test]
[performance-tests.render-wasm.boolean-test]
[performance-tests.render-wasm.browser-test]
[performance-tests.render-wasm.build-test]
[performance-tests.render-wasm.builder-test]
[performance-tests.render-wasm.cli-test]
[performance-tests.render-wasm.commands-test]
[performance-tests.render-wasm.compare-test]
[performance-tests.render-wasm.containers-test]
[performance-tests.render-wasm.contracts-test]
[performance-tests.render-wasm.errors-test]
[performance-tests.render-wasm.execution-test]
[performance-tests.render-wasm.feature-comparison-test]
[performance-tests.render-wasm.fingerprint-test]
[performance-tests.render-wasm.geometry-scenes-test]
[performance-tests.render-wasm.lifecycle-test]
[performance-tests.render-wasm.log-test]
[performance-tests.render-wasm.merge-test]
[performance-tests.render-wasm.outcome-test]
[performance-tests.render-wasm.process-test]
[performance-tests.render-wasm.protocol-test]
[performance-tests.render-wasm.report-test]
[performance-tests.render-wasm.request-test]
[performance-tests.render-wasm.run-test]
[performance-tests.render-wasm.runs-test]
[performance-tests.render-wasm.scene-snapshot-test]
[performance-tests.render-wasm.server-test]))
(enable-console-print!)
(defmethod t/report [:cljs.test/default :begin-test-var] [m]
(let [v (:var m)]
(println (str " ▸ " (:ns (meta v)) "/" (:name (meta v))))))
(defmethod t/report [:cljs.test/default :end-run-tests] [m]
(if (cljs.test/successful? m)
(.exit js/process 0)
(.exit js/process 1)))
(def test-namespaces
['performance-tests.render-wasm.artifacts-test
'performance-tests.render-wasm.boolean-test
'performance-tests.render-wasm.browser-test
'performance-tests.render-wasm.build-test
'performance-tests.render-wasm.builder-test
'performance-tests.render-wasm.cli-test
'performance-tests.render-wasm.commands-test
'performance-tests.render-wasm.compare-test
'performance-tests.render-wasm.containers-test
'performance-tests.render-wasm.contracts-test
'performance-tests.render-wasm.errors-test
'performance-tests.render-wasm.execution-test
'performance-tests.render-wasm.feature-comparison-test
'performance-tests.render-wasm.fingerprint-test
'performance-tests.render-wasm.geometry-scenes-test
'performance-tests.render-wasm.lifecycle-test
'performance-tests.render-wasm.log-test
'performance-tests.render-wasm.merge-test
'performance-tests.render-wasm.outcome-test
'performance-tests.render-wasm.process-test
'performance-tests.render-wasm.protocol-test
'performance-tests.render-wasm.report-test
'performance-tests.render-wasm.request-test
'performance-tests.render-wasm.run-test
'performance-tests.render-wasm.runs-test
'performance-tests.render-wasm.scene-snapshot-test
'performance-tests.render-wasm.server-test])
(assert (every? find-ns-obj test-namespaces)
"test-namespaces contains a namespace that isn't required in runner.cljs")
;; This runner mirrors frontend-tests.runner for the bench-only
;; :performance-tests target. Both runners need forwarded CLI args, focused
;; namespace/var execution, fixture preservation, and app log-level
;; setup. The logic stays local to avoid cross-target test classpath
;; coupling.
(def ^:private log-levels
#{:trace :debug :info :warn :error})
(def cli-options
[["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. performance-tests.render-wasm.contracts-test"]
["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error"
:parse-fn keyword
:validate [log-levels "must be one of trace, debug, info, warn, error"]]
["-h" "--help"]])
(defn- argv
[]
(let [args (->> (.-argv js/process)
(array-seq)
(drop 2))]
;; `pnpm run test:renderer-benchmarks -- --focus ...` forwards the separator
;; to the node process, so drop one leading `--` before handing args to
;; tools.cli.
(cond-> args
(= "--" (first args)) rest)))
(defn- usage
[summary]
(str "Usage: pnpm run test:renderer-benchmarks -- [options]\n\n"
"Options:\n"
summary "\n\n"
"Focus examples:\n"
" pnpm run test:renderer-benchmarks -- --focus performance-tests.render-wasm.contracts-test\n"
" pnpm run test:renderer-benchmarks -- --focus performance-tests.render-wasm.contracts-test/some-test-var\n\n"
"Log level example (quiets app logging during the run):\n"
" pnpm run test:renderer-benchmarks -- --focus performance-tests.render-wasm.contracts-test --log-level warn"))
(defn- fail!
[message]
(js/console.error message)
(.exit js/process 1))
(defn- parse-focus
[focus]
(let [[ns-name test-name & extra] (str/split focus #"/")]
(cond
(or (str/blank? ns-name) (seq extra))
(fail! (str "Invalid --focus value: " focus))
(some? test-name)
{:ns (symbol ns-name) :test test-name}
:else
{:ns (symbol ns-name)})))
(defn- fixture-value
[ns-obj fixture-name]
(let [value (gobj/get ns-obj (munge fixture-name))]
(when-not (undefined? value)
value)))
(defn- ns-test-vars
[ns-sym]
(when-let [ns-obj (find-ns-obj ns-sym)]
(->> (js-keys ns-obj)
(keep (fn [key]
(some-> (gobj/get ns-obj key)
(.-cljs$lang$var))))
(filter (comp :test meta))
(sort-by (comp :line meta)))))
(defn- ns-fixtures
[ns-sym vars]
(when-let [ns-obj (find-ns-obj ns-sym)]
(let [ns-key (or (some-> vars first meta :ns) ns-sym)
once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures")
each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")]
{:once (when once-fixtures {ns-key once-fixtures})
:each (when each-fixtures {ns-key each-fixtures})})))
(defn- selected-tests
[{:keys [ns test]}]
(when-not (some #{ns} test-namespaces)
(fail! (str "Unknown test namespace: " ns)))
(let [vars (vec (ns-test-vars ns))]
(when (empty? vars)
(fail! (str "No tests found in namespace: " ns)))
(if test
(let [test-sym (symbol test)
test-var (some #(when (= test-sym (:name (meta %))) %) vars)]
(if test-var
{:vars [test-var]
:fixtures (ns-fixtures ns [test-var])}
(fail! (str "Unknown test var: " ns "/" test))))
{:vars vars
:fixtures (ns-fixtures ns vars)})))
(defn- merge-fixtures
[fixtures]
{:once (apply merge (keep :once fixtures))
:each (apply merge (keep :each fixtures))})
(defn- run-test-vars!
[tests]
(let [vars (vec (mapcat :vars tests))
fixtures (merge-fixtures (map :fixtures tests))
env (assoc (t/empty-env)
:once-fixtures (:once fixtures)
:each-fixtures (:each fixtures))
summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})]
(t/set-env! env)
(t/run-block
(concat (t/test-vars-block vars)
[(fn []
(vswap! summary
(partial merge-with +)
(:report-counters (t/get-current-env))))
(fn []
(t/report @summary)
(t/report (assoc @summary :type :end-run-tests)))]))))
(defn- run-focused-test!
[focus]
(run-test-vars! [(selected-tests (parse-focus focus))]))
(defn init
[]
(let [{:keys [options errors summary]} (parse-opts (argv) cli-options)]
(cond
(seq errors)
(fail! (str/join "\n" errors))
(:help options)
(do
(println (usage summary))
(.exit js/process 0))
:else
(do
(l/setup! {:app (or (:log-level options) :warn)})
(if (:focus options)
(run-focused-test! (:focus options))
(run-test-vars! (map #(selected-tests {:ns %}) test-namespaces)))))))

View File

@ -0,0 +1,165 @@
;; 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.runs-test
(:require
["fs" :as fs]
["os" :as os]
["path" :as path]
[app.common.transit :as transit]
[benches.render-wasm.node.storage.artifacts :as artifacts]
[benches.render-wasm.node.storage.runs :as runs]
[benches.render-wasm.result :as result]
[cljs.test :as t :include-macros true]
[clojure.string :as str]))
(def ^:private temporary-root (atom nil))
(t/use-fixtures :each
{:before #(reset! temporary-root
(fs/mkdtempSync (path/join (os/tmpdir) "penpot-render-runs-")))
:after #(fs/rmSync @temporary-root #js {:recursive true :force true})})
(defn- failure
[f]
(try
(f)
nil
(catch :default error error)))
(defn- sample-run
[run-id]
(result/create-run
{:run-id run-id
:started-at "2026-10-26T00:00:00.000Z"
:plan {:cases [{:id :rects/load}]
:warmups 0
:repetitions 1
:timeout-policy {:case-ms 30000}}
:metadata {:git {:sha "abc" :dirty false}
:build {:functional {:mode :release}}
:environment {:node "v24.0.0"}
:scored? true}}))
(t/deftest managed-home-lives-under-the-frontend-root
(let [home (runs/create-home {:root @temporary-root :run-id "run-1"})]
(t/is (= (path/join (artifacts/managed-root @temporary-root) "runs" "run-1")
(:dir home)))
(t/is (.isDirectory (fs/statSync (:dir home))))
(t/is (= "" (fs/readFileSync (:log-path home) "utf8")))
(t/is (not (fs/existsSync (:record-path home))))))
(t/deftest explicit-home-must-not-exist-and-resolves-from-caller-cwd
(let [previous (.cwd js/process)
relative "explicit-run"]
(try
(.chdir js/process @temporary-root)
(let [home (runs/create-home {:root @temporary-root
:run-id "run-2"
:output-dir relative})]
(t/is (= (path/join @temporary-root relative) (:dir home)))
(t/is (.isDirectory (fs/statSync (:dir home))))
(t/is (not (fs/existsSync (path/join (artifacts/managed-root @temporary-root)
"runs" "run-2")))))
(finally
(.chdir js/process previous))))
(let [directory (path/join @temporary-root "already-there")]
(fs/mkdirSync directory)
(fs/writeFileSync (path/join directory "sentinel") "keep")
(let [error (failure #(runs/create-home {:root @temporary-root
:run-id "run-3"
:output-dir directory}))]
(t/is (= ::artifacts/invalid-home (:type (ex-data error))))
(t/is (= "output directory already exists" (ex-message error)))
(t/is (= "keep" (fs/readFileSync (path/join directory "sentinel") "utf8"))))))
(t/deftest record-writes-are-atomic-and-invalid-records-leave-the-saved-file
(let [home (runs/create-home {:root @temporary-root :run-id "run-4"})
run (sample-run "run-4")]
(runs/write-record! home run)
(t/is (= run (result/decode (fs/readFileSync (:record-path home) "utf8"))))
(t/is (empty? (filter #(str/ends-with? % ".tmp")
(array-seq (fs/readdirSync (:dir home))))))
(let [error (failure #(runs/write-record! home (assoc-in run [:metadata :scored?] "yes")))]
(t/is (= ::result/invalid-record (:type (ex-data error))))
(t/is (= run (result/decode (fs/readFileSync (:record-path home) "utf8")))))))
(t/deftest record-read-failures-retain-file-context-and-cause
(let [file (path/join @temporary-root "invalid.transit")]
(fs/writeFileSync file (transit/encode-str (dissoc (sample-run "invalid") :plan)))
(let [error (failure #(runs/get-record file))]
(t/is (= {:path file :operation :read-record :type ::result/invalid-record}
(select-keys (ex-data error) [:path :operation :type])))
(t/is (= ::result/invalid-record (:type (ex-data (ex-cause error)))))
(t/is (= (ex-message error) (ex-message (ex-cause error))))))
(let [error (failure #(runs/get-record @temporary-root))]
(t/is (= {:path @temporary-root :operation :read-record}
(select-keys (ex-data error) [:path :operation])))
(t/is (= "EISDIR" (.-code (ex-cause error))))))
(t/deftest screenshot-paths-stay-in-the-run-home
(let [home (runs/create-home {:root @temporary-root :run-id "run-5"})
shot (runs/screenshot-path home :rects/load)]
(t/is (= "rects-load.png" (:file shot)))
(t/is (= (path/join (:dir home) "rects-load.png") (:path shot)))
(t/is (= (path/dirname (:record-path home)) (path/dirname (:path shot))))))
(t/deftest malformed-ids-and-empty-paths-fail-at-the-home-boundary
(doseq [id ["../escape" "" "a/b" "a\\b"]]
(t/is (thrown-with-msg? js/Error #"invalid run id"
(runs/create-home {:root @temporary-root :run-id id}))))
(t/is (thrown-with-msg? js/Error #"nonempty path"
(artifacts/check-output-dir " "))))
(t/deftest home-creation-reports-an-invalid-parent
(let [parent (path/join @temporary-root "file")
dir (path/join parent "run")]
(fs/writeFileSync parent "occupied")
(let [error (failure #(runs/create-home {:root @temporary-root
:run-id "run-6" :output-dir dir}))]
(t/is (= ::artifacts/invalid-home (:type (ex-data error))))
(t/is (= (path/resolve dir) (:path (ex-data error))))
(t/is (= "ENOTDIR" (.-code (ex-cause error))))
(t/is (= (ex-message (ex-cause error)) (:cause (ex-data error))))
(t/is (str/includes? (ex-message error) "cannot inspect output directory"))
(t/is (str/includes? (ex-message error) "ENOTDIR")))))
(t/deftest home-creation-wraps-log-failures
(let [dir (path/join @temporary-root "occupied-log")]
(fs/mkdirSync dir)
(fs/writeFileSync (path/join dir "run.log") "")
(let [error (failure #(artifacts/create-home dir "run" "cannot create run home"))]
(t/is (= ::artifacts/invalid-home (:type (ex-data error))))
(t/is (= "cannot create run home" (ex-message error)))
(t/is (= dir (:path (ex-data error))))
(t/is (= "EEXIST" (.-code (ex-cause error))))
(t/is (= (ex-message (ex-cause error)) (:cause (ex-data error)))))))
(t/deftest explicit-home-creates-missing-parents
(let [out (path/join @temporary-root "missing" "parents" "run")
home (runs/create-home {:root @temporary-root :run-id "nested"
:output-dir out})]
(t/is (.isDirectory (fs/statSync out)))
(t/is (= "" (fs/readFileSync (:log-path home) "utf8")))
(t/is (thrown-with-msg? js/Error #"already exists"
(runs/create-home {:root @temporary-root :run-id "again"
:output-dir out})))))
(t/deftest remove-rejects-output-inside-builds-before-creating-it
(let [builds (path/join (artifacts/managed-root @temporary-root) "builds")
alias (path/join @temporary-root "build-alias")]
(fs/mkdirSync builds #js {:recursive true})
(fs/symlinkSync builds alias "dir")
(doseq [out [(path/join builds "direct")
(path/join alias "missing" "nested")]]
(t/is (thrown-with-msg? js/Error #"--remove.*inside builds"
(runs/create-home {:root @temporary-root :run-id "blocked"
:output-dir out :remove true})))
(t/is (not (fs/existsSync out))))
(let [out (path/join @temporary-root "builds-other" "allowed")]
(runs/create-home {:root @temporary-root :run-id "allowed"
:output-dir out :remove true})
(t/is (fs/existsSync (path/join out "run.log"))))))

View File

@ -0,0 +1,316 @@
;; 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.scene-snapshot-test
(:require
[app.common.schema :as sm]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[benches.render-wasm.snapshot :as snapshot]
[cljs.test :as t :include-macros true]))
(defn- sample-snapshot
"Simple tiny scene: root frame with one rectangle."
[]
(let [rect-id (uuid/custom 1 1)
rect (cts/setup-shape {:id rect-id :type :rect :name "Rect"
:x 32 :y 48 :width 200 :height 120})
root (cts/setup-shape {:id uuid/zero :type :frame :name "Root Frame"
:x 0 :y 0 :width 1920 :height 1080
:shapes [rect-id]})]
{:objects {uuid/zero root, rect-id rect}
:refs {:rect rect-id}}))
(defn- nested-instance
"Root with two frames; frame A with two rectangles, frame B with one."
[]
(let [a-id (uuid/custom 2 1)
b-id (uuid/custom 2 2)
a1-id (uuid/custom 2 3)
a2-id (uuid/custom 2 4)
b1-id (uuid/custom 2 5)
frame-a (cts/setup-shape {:id a-id :type :frame :name "A"
:x 0 :y 0 :width 100 :height 100
:parent-id uuid/zero :frame-id uuid/zero
:shapes [a1-id a2-id]})
frame-b (cts/setup-shape {:id b-id :type :frame :name "B"
:x 200 :y 0 :width 100 :height 100
:parent-id uuid/zero :frame-id uuid/zero
:shapes [b1-id]})
rect-a1 (cts/setup-shape {:id a1-id :type :rect :name "A1"
:x 10 :y 10 :width 10 :height 10
:parent-id a-id :frame-id a-id})
rect-a2 (cts/setup-shape {:id a2-id :type :rect :name "A2"
:x 30 :y 10 :width 10 :height 10
:parent-id a-id :frame-id a-id})
rect-b1 (cts/setup-shape {:id b1-id :type :rect :name "B1"
:x 210 :y 10 :width 10 :height 10
:parent-id b-id :frame-id b-id})
root (cts/setup-shape {:id uuid/zero :type :frame :name "Root Frame"
:x 0 :y 0 :width 1920 :height 1080
:parent-id uuid/zero :frame-id uuid/zero
:shapes [a-id b-id]})]
{:objects {uuid/zero root
a-id frame-a
b-id frame-b
a1-id rect-a1
a2-id rect-a2
b1-id rect-b1}
:refs {:a a-id
:a1 a1-id
:b b-id}}))
(defn- validation-error
"Returns the ex-data of the validation failure, or nil when it passes."
[instance]
(try
(snapshot/validate! instance)
nil
(catch :default cause
(ex-data cause))))
(t/deftest canonical-example-is-valid
(t/testing "validate! returns the same instance"
(let [instance (sample-snapshot)]
(t/is (identical? instance (snapshot/validate! instance)))))
(t/testing "the envelope schema accepts the example"
(t/is (true? (sm/validate snapshot/schema:snapshot (sample-snapshot))))))
(t/deftest nested-hierarchy-is-valid
(let [instance (nested-instance)]
(t/is (identical? instance (snapshot/validate! instance)))))
(t/deftest root-only-instance-is-valid
(let [root (cts/setup-shape {:id uuid/zero :type :frame :name "Root Frame"
:x 0 :y 0 :width 100 :height 100
:parent-id uuid/zero :frame-id uuid/zero
:shapes []})
instance {:objects {uuid/zero root} :refs {}}]
(t/is (identical? instance (snapshot/validate! instance)))
(t/is (= [uuid/zero] (mapv :id (snapshot/upload-order instance))))))
(t/deftest upload-order-starts-at-root
(let [instance (sample-snapshot)
ids (mapv :id (snapshot/upload-order instance))]
(t/is (= [uuid/zero (snapshot/ref-id instance :rect)] ids))))
(t/deftest upload-order-is-parent-before-child
(let [instance (nested-instance)
order (snapshot/upload-order instance)
names (mapv :name order)
position (into {} (map-indexed (fn [index shape] [(:id shape) index]) order))]
(t/is (= ["Root Frame" "A" "A1" "A2" "B" "B1"] names))
(t/is (every? (fn [shape]
(or (= uuid/zero (:id shape))
(< (position (:parent-id shape))
(position (:id shape)))))
order))))
(t/deftest upload-order-follows-flex-reverse
(let [frame-id (uuid/custom 8 1)
a-id (uuid/custom 8 2)
b-id (uuid/custom 8 3)
frame (cts/setup-shape {:id frame-id :type :frame :name "Flex"
:x 0 :y 0 :width 200 :height 100
:parent-id uuid/zero :frame-id uuid/zero
:layout :flex :layout-flex-dir :row-reverse
:shapes [a-id b-id]})
shape-a (cts/setup-shape {:id a-id :type :rect :name "A"
:x 0 :y 0 :width 10 :height 10
:parent-id frame-id :frame-id frame-id})
shape-b (cts/setup-shape {:id b-id :type :rect :name "B"
:x 20 :y 0 :width 10 :height 10
:parent-id frame-id :frame-id frame-id})
root (cts/setup-shape {:id uuid/zero :type :frame :name "Root Frame"
:x 0 :y 0 :width 1920 :height 1080
:parent-id uuid/zero :frame-id uuid/zero
:shapes [frame-id]})
instance {:objects {uuid/zero root
frame-id frame
a-id shape-a
b-id shape-b}
:refs {}}]
(t/is (identical? instance (snapshot/validate! instance)))
(t/is (= ["Root Frame" "Flex" "B" "A"]
(mapv :name (snapshot/upload-order instance))))))
(t/deftest ref-id-resolves-labels
(let [instance (sample-snapshot)]
(t/is (= (uuid/custom 1 1) (snapshot/ref-id instance :rect)))
(t/is (nil? (snapshot/ref-id instance :missing)))))
(t/deftest missing-root-is-rejected
(let [instance (sample-snapshot)
data (validation-error (update instance :objects dissoc uuid/zero))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= uuid/zero (:id data)))))
(t/deftest non-frame-root-is-rejected
(let [instance (sample-snapshot)
data (validation-error (assoc-in instance [:objects uuid/zero :type] :rect))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= uuid/zero (:id data)))))
(t/deftest root-with-non-zero-parent-id-is-rejected
(let [instance (sample-snapshot)
data (validation-error
(assoc-in instance [:objects uuid/zero :parent-id] (uuid/custom 9 9)))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= uuid/zero (:id data)))))
(t/deftest object-key-must-match-shape-id
(let [instance (sample-snapshot)
rect (get-in instance [:objects (snapshot/ref-id instance :rect)])
data (validation-error (assoc-in instance [:objects (uuid/custom 7 7)] rect))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= (:id rect) (:shape-id data)))))
(t/deftest missing-child-is-rejected
(let [instance (sample-snapshot)
bogus (uuid/custom 3 3)
data (validation-error
(assoc-in instance [:objects uuid/zero :shapes] [bogus]))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= bogus (:child data)))))
(t/deftest child-not-listed-by-parent-is-rejected
(let [instance (sample-snapshot)
rect-id (snapshot/ref-id instance :rect)
data (validation-error (assoc-in instance [:objects uuid/zero :shapes] []))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= "object is not reachable from the root" (:hint data)))
(t/is (= rect-id (:id data)))))
(t/deftest mismatched-parent-id-is-rejected
(let [instance (sample-snapshot)
rect-id (snapshot/ref-id instance :rect)
data (validation-error
(assoc-in instance [:objects rect-id :parent-id] (uuid/custom 9 7)))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= "shape :shapes lists a child with a different :parent-id" (:hint data)))
(t/is (= rect-id (:child data)))))
(t/deftest duplicate-child-ids-are-rejected
(let [instance (sample-snapshot)
rect-id (snapshot/ref-id instance :rect)
data (validation-error
(assoc-in instance [:objects uuid/zero :shapes] [rect-id rect-id]))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= "an object is reachable more than once" (:hint data)))
(t/is (= rect-id (:id data)))))
(t/deftest shape-listing-itself-is-rejected
(let [instance (sample-snapshot)
data (validation-error
(assoc-in instance [:objects uuid/zero :shapes] [uuid/zero]))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= "root must not be a descendant of itself" (:hint data)))
(t/is (= uuid/zero (:id data)))))
(t/deftest detached-cycle-is-rejected
(let [c1-id (uuid/custom 5 1)
c2-id (uuid/custom 5 2)
shape1 (cts/setup-shape {:id c1-id :type :rect :name "C1"
:x 0 :y 0 :width 10 :height 10
:parent-id c2-id :frame-id c1-id
:shapes [c2-id]})
shape2 (cts/setup-shape {:id c2-id :type :rect :name "C2"
:x 0 :y 0 :width 10 :height 10
:parent-id c1-id :frame-id c2-id
:shapes [c1-id]})
data (validation-error
(assoc (sample-snapshot) :objects
(assoc (get (sample-snapshot) :objects)
c1-id shape1
c2-id shape2)))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= "object is not reachable from the root" (:hint data)))
(t/is (contains? #{c1-id c2-id} (:id data)))))
(t/deftest reachable-cycle-is-rejected
(let [a-id (uuid/custom 4 1)
b-id (uuid/custom 4 2)
shape-a (cts/setup-shape {:id a-id :type :rect :name "A"
:x 0 :y 0 :width 10 :height 10
:parent-id uuid/zero :frame-id uuid/zero
:shapes [b-id]})
shape-b (cts/setup-shape {:id b-id :type :rect :name "B"
:x 0 :y 0 :width 10 :height 10
:parent-id a-id :frame-id uuid/zero
:shapes [a-id]})
instance (-> (sample-snapshot)
(assoc-in [:objects uuid/zero :shapes] [a-id])
(assoc-in [:objects a-id] shape-a)
(assoc-in [:objects b-id] shape-b))
data (validation-error instance)]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= "shape :shapes lists a child with a different :parent-id" (:hint data)))
(t/is (= a-id (:child data)))))
(t/deftest non-vector-children-are-rejected
(let [instance (sample-snapshot)
rect-id (snapshot/ref-id instance :rect)
data (validation-error (assoc-in instance [:objects rect-id :shapes] 5))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= "shape :shapes must be a vector" (:hint data)))
(t/is (= rect-id (:id data)))))
(t/deftest frame-id-mismatch-is-rejected
(let [instance (nested-instance)
a1-id (snapshot/ref-id instance :a1)
data (validation-error
(assoc-in instance [:objects a1-id :frame-id] uuid/zero))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= :invalid-frame (:code data)))
(t/is (= a1-id (:shape-id data)))))
(t/deftest missing-ref-target-is-rejected
(let [instance (sample-snapshot)
data (validation-error
(assoc-in instance [:refs :rect] (uuid/custom 6 6)))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= :rect (:label data)))))
(t/deftest non-uuid-ref-value-is-rejected
(let [data (validation-error (assoc-in (sample-snapshot) [:refs :rect] "not-a-uuid"))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (some? (::sm/explain data)))))
(t/deftest missing-refs-key-is-rejected
(let [data (validation-error (dissoc (sample-snapshot) :refs))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (some? (::sm/explain data)))))
(t/deftest non-canonical-shape-is-rejected
(let [instance (sample-snapshot)
rect-id (snapshot/ref-id instance :rect)
data (validation-error (assoc-in instance [:objects rect-id] {:id rect-id}))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (some? (::sm/explain data)))))
(t/deftest malformed-envelopes-are-rejected
(t/testing "empty objects map"
(let [data (validation-error {:objects {} :refs {}})]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (= uuid/zero (:id data)))))
(t/testing "nil instance"
(let [data (validation-error nil)]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (some? (::sm/explain data)))))
(t/testing "nil shape value"
(let [instance (sample-snapshot)
rect-id (snapshot/ref-id instance :rect)
data (validation-error (assoc-in instance [:objects rect-id] nil))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (some? (::sm/explain data)))))
(t/testing "nil ref value"
(let [data (validation-error (assoc-in (sample-snapshot) [:refs :rect] nil))]
(t/is (= ::snapshot/invalid-snapshot (:type data)))
(t/is (some? (::sm/explain data))))))

View File

@ -0,0 +1,233 @@
;; 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.server-test
(:require
["fs" :as fs]
["http" :as http]
["net" :as net]
["os" :as os]
["path" :as path]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.node.runner.server :as server]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]))
(def ^:private temporary-root (atom nil))
(def ^:private wasm-bytes [0 97 115 109 1 0 0 0])
(defn- write-file
[file content]
(fs/mkdirSync (path/dirname file) #js {:recursive true})
(fs/writeFileSync file content))
(t/use-fixtures :each
{:before (fn []
(let [root (fs/mkdtempSync (path/join (os/tmpdir) "penpot-render-http-"))
dir (path/join root "selected")]
(reset! temporary-root root)
(write-file (path/join dir "index.html") "<!doctype html><title>selected</title>")
(write-file (path/join dir "browser" "browser.js") "export const selected = true;")
(write-file (path/join dir "cases.cjs") "module.exports = {};")
(write-file (path/join dir "renderer" "render-wasm.wasm")
(js/Buffer.from (to-array wasm-bytes)))
(write-file (path/join root "selected-outside" "secret.txt") "private sibling")
(write-file (path/join root "outside.txt") "private file")))
:after #(fs/rmSync @temporary-root #js {:recursive true :force true})})
(defn- build
[]
{:id "selected-http-build" :dir (path/join @temporary-root "selected")})
(defn- request
[host request-path method]
(js/Promise.
(fn [resolve reject]
(let [address (js/URL. (:base-url host))
req (http/request
;; Supplying a raw path avoids URL normalization hiding traversal.
#js {:hostname (.-hostname address)
:port (.-port address)
:path request-path
:method method
:agent false}
(fn [response]
(let [chunks (atom [])]
(.on ^js response "data" #(swap! chunks conj %))
(.once ^js response "error" reject)
(.once ^js response "end"
#(resolve {:status (.-statusCode response)
:headers (js->clj (.-headers response))
:body (js/Buffer.concat (to-array @chunks))})))))]
(.once ^js req "error" reject)
(.setTimeout ^js req 3000 #(.destroy ^js req (js/Error. "test request timed out")))
(.end ^js req)))))
(defn- ^:async request-failure
[host]
(try
(await (request host "/" "GET"))
nil
(catch :default error error)))
(t/deftest ^:async serves-only-the-selected-build-with-head-and-artifact-mime-types
(let [selected (build)
host (await (server/open selected))]
(try
(t/is (= selected (:build host)))
(t/is (re-matches #"http://127\.0\.0\.1:[0-9]+" (:base-url host)))
(let [index (await (request host "/?unused=query" "GET"))
browser (await (request host "/browser/browser.js" "GET"))
cjs (await (request host "/cases.cjs" "GET"))
wasm (await (request host "/renderer/render-wasm.wasm" "GET"))
head (await (request host "/renderer/render-wasm.wasm" "HEAD"))]
(t/is (= 200 (:status index)))
(t/is (= "<!doctype html><title>selected</title>" (.toString (:body index) "utf8")))
(t/is (= "text/html; charset=utf-8" (get-in index [:headers "content-type"])))
(t/is (= "export const selected = true;" (.toString (:body browser) "utf8")))
(t/is (= "application/javascript" (get-in browser [:headers "content-type"])))
(t/is (= "application/javascript" (get-in cjs [:headers "content-type"])))
(t/is (= "application/wasm" (get-in wasm [:headers "content-type"])))
(t/is (zero? (js/Buffer.compare (:body wasm) (js/Buffer.from (to-array wasm-bytes)))))
(t/is (= 200 (:status head)))
(t/is (= "8" (get-in head [:headers "content-length"])))
(t/is (zero? (.-length (:body head)))))
(t/is (= 404 (:status (await (request host "/selected-outside/secret.txt" "GET")))))
(t/is (= 405 (:status (await (request host "/" "POST")))))
(finally
(await (server/close host))))))
(t/deftest ^:async rejects-raw-traversal-and-outside-file-and-directory-symlinks
(let [selected (build)
dir (:dir selected)]
(fs/symlinkSync (path/join @temporary-root "outside.txt") (path/join dir "escape.txt"))
(fs/symlinkSync (path/join @temporary-root "selected-outside") (path/join dir "linked") "dir")
(let [host (await (server/open selected))]
(try
(doseq [request-path ["/../selected-outside/secret.txt"
"/%2e%2e/selected-outside/secret.txt"
"/%2e%2e%2fselected-outside%2fsecret.txt"
"/%5c..%5cselected-outside%5csecret.txt"
"/escape.txt" "/linked/secret.txt"]]
(let [response (await (request host request-path "GET"))]
(t/is (= 403 (:status response)) request-path)
(t/is (= "forbidden" (.toString (:body response) "utf8")) request-path)))
(t/is (= 400 (:status (await (request host "/%zz" "GET")))))
(t/is (= 403 (:status (await (request host "/%00" "GET")))))
(t/is (= 404 (:status (await (request host "/missing-file" "GET")))))
(t/is (= 404 (:status (await (request host "/browser" "GET")))))
(finally
(await (server/close host)))))))
(defn- connect
[host]
(js/Promise.
(fn [resolve reject]
(let [address (js/URL. (:base-url host))
socket (net/createConnection #js {:host "127.0.0.1" :port (js/Number (.-port address))})]
(.once ^js socket "error" reject)
(.once ^js socket "connect" #(resolve socket))))))
(defn- bounded
[promise]
(js/Promise.
(fn [resolve reject]
(let [timer (js/setTimeout #(reject (js/Error. "test shutdown timed out")) 3000)]
(.then promise
(fn [value] (js/clearTimeout timer) (resolve value))
(fn [error] (js/clearTimeout timer) (reject error)))))))
(t/deftest ^:async close-destroys-owned-connections-and-keeps-other-servers-live
(let [host (await (server/open (build)))]
(try
(let [other (await (server/open (build)))]
(try
(let [socket (await (connect host))
closed (js/Promise. (fn [resolve _] (.once ^js socket "close" resolve)))]
(try
(await (bounded (js/Promise.all (to-array [(server/close host) (server/close host)]))))
(await (bounded closed))
(t/is (some? (await (request-failure host))))
(let [response (await (request other "/" "GET"))]
(t/is (= 200 (:status response)))
(t/is (= "<!doctype html><title>selected</title>" (.toString (:body response) "utf8"))))
(await (server/close host))
(finally
(.destroy ^js socket))))
(finally
(await (server/close other)))))
(finally
(await (server/close host))))))
(defn- ^:async startup-failure
[opening]
(try
(let [host (await opening)]
(await (server/close host))
nil)
(catch :default error error)))
(t/deftest ^:async startup-abort-retains-a-server-close-failure
(let [controller (js/AbortController.)
close server/close
close-error (js/Error. "server disposal failed")]
(.abort controller)
(await (mock/with-mocks*
{server/close (fn [host]
(.then (close host) (fn [_] (throw close-error))))}
(let [cause (await (bounded (startup-failure (server/open (build) {:signal (.-signal controller)}))))
entries (errors/get-errors cause)]
(t/is (= ::server/aborted (:type (ex-data cause))))
(t/is (= ["renderer server startup aborted" "server disposal failed"]
(mapv #(errors/message-of (:error %)) entries))))))))
(t/deftest ^:async startup-rejects-already-aborted-and-in-flight-signals
(let [already (js/AbortController.)]
(.abort already)
(let [error (await (startup-failure (server/open (build) {:signal (.-signal already)})))]
(t/is (= ::server/aborted (:type (ex-data error))))))
(let [controller (js/AbortController.)
opening (server/open (build) {:signal (.-signal controller)})]
(.abort controller)
(let [error (await (startup-failure opening))]
(t/is (= ::server/aborted (:type (ex-data error))))))
(let [host (await (server/open (build) {:startup-timeout-ms 3000}))]
(try
(t/is (= 200 (:status (await (request host "/" "GET")))))
(finally
(await (server/close host))))))
(defn- ^:async with-listen
"Calls f while http.Server#listen runs `listen` and returns the promise f returns."
[listen f]
(let [proto (.-prototype http/Server)]
(set! (.-listen proto) listen)
(try
(await (f))
(finally
(js-delete proto "listen")))))
(t/deftest ^:async startup-timeout-rejects-with-the-configured-limit
(let [never-listens (fn [& _] nil)
error (await (with-listen never-listens
#(startup-failure (server/open (build) {:startup-timeout-ms 50}))))]
(t/is (= ::server/startup-timeout (:type (ex-data error))))
(t/is (= 50 (:timeout-ms (ex-data error))))))
(t/deftest ^:async startup-rejects-with-the-listen-failure
(let [thrown (js/Error. "listen threw")
emitted (js/Error. "server emitted error")
throws (fn [& _] (throw thrown))
emits (fn [& _] (this-as ^js self (js/process.nextTick #(.emit self "error" emitted))))]
(doseq [[failure listen] [[thrown throws] [emitted emits]]]
(let [error (await (with-listen listen #(startup-failure (server/open (build)))))]
(t/is (identical? failure error) (ex-message failure))))))
(t/deftest ^:async startup-rejects-when-the-build-directory-is-missing
(let [missing (path/join @temporary-root "missing")
error (await (startup-failure (server/open {:id "missing-build" :dir missing})))]
(t/is (= "ENOENT" (.-code ^js error)))))

View File

@ -0,0 +1,49 @@
(ns performance-tests.render-wasm.test-helpers
"Shared helpers for renderer benchmark scene tests.
These helpers assert nothing about shapes or the renderer. They only
capture errors, read root children in order, and run raw scopes so each
test namespace tells a complete story without copying bodies."
(:require
[app.common.uuid :as uuid]
[benches.render-wasm.builder :as builder]))
(defn failure-data
"Returns the ex-data of the thrown failure, or nil when `f` passes."
[f]
(try
(f)
nil
(catch :default cause
(ex-data cause))))
(defn child-ids
"Ids listed in the :shapes vector of `id`."
[snapshot id]
(get-in snapshot [:objects id :shapes]))
(defn children-of
"Child shapes of `id` in order."
[snapshot id]
(mapv (:objects snapshot) (child-ids snapshot id)))
(defn root-shapes
"Root children of a scene snapshot in creation order."
[snapshot]
(children-of snapshot uuid/zero))
(defn run-scope
"Runs `f` inside a raw scope and returns the scope state, so tests can
inspect partial builds without calling finish!."
[params f]
(let [state (builder/start params)]
(binding [builder/*state* state
builder/*defaults* {}]
(f))
state))
(defn selrect
"Selrect of `shape` as an [x y width height] tuple."
[shape]
[(:x (:selrect shape)) (:y (:selrect shape))
(:width (:selrect shape)) (:height (:selrect shape))])

Some files were not shown because too many files have changed in this diff Show More