From 373116fe33ca3de1e0fd45378cf16884d708041e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 8 Oct 2026 11:41:29 +0000 Subject: [PATCH] :tada: Add the renderer facade and wire both pools in main The malli-validated facade dispatches each render to the browser or the headless driver by its wasm flag, sharing one leased worker across the headless renders of a scope. The production wiring declares both pools sized to the worker concurrency; the wiring itself is asserted as data because booting it would spawn a real render worker off the test bundle. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/exporter/core.md | 2 + exporter/src/exporter/main.cljs | 10 +- exporter/src/exporter/renderer.cljs | 87 ++++++++ exporter/test/exporter_tests/main_test.cljs | 43 ++-- .../test/exporter_tests/renderer_test.cljs | 206 ++++++++++++++++++ exporter/test/exporter_tests/runner.cljs | 2 + 6 files changed, 332 insertions(+), 18 deletions(-) create mode 100644 exporter/src/exporter/renderer.cljs create mode 100644 exporter/test/exporter_tests/renderer_test.cljs diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md index 6944749c27..5b42bb11cc 100644 --- a/.serena/memories/exporter/core.md +++ b/.serena/memories/exporter/core.md @@ -44,6 +44,8 @@ on this side: the row of the job, its cancel and its result are the backend's. - `exporter.wasm.pool` mirrors the browser service for `worker_threads`: the running instance is `{:pool :timeout-ms}` (the pool plus the silence timeout its renders await), `with-worker`/`render-on`/`terminate` take the pool first, the worker factory is injected through config, and the creation handshake (`await-ready`) is its own unit with `:worker-not-ready`/`boot-error` paths. - `exporter.renderer.browser` merges the bitmap/pdf/svg flows into one `render [ctx params on-object]` (branching by type, `on-object` always awaited, unknown types raise); `ctx` carries what the job params do not freeze (`:browser-pool`, `:base-uri`, `:public-uri`, `:svgo?`). Dead xml predicates from the old tree did not survive the merge. - `exporter.renderer.wasm` drives headless renders off the wasm pool instance (`{:pool :timeout-ms}`): `with-scope` leases one worker and serializes its renders, and the cancel source arrives injected (`:cancel-signal`/`:cancelled?`/`:on-cancel` fns) instead of reaching the jobs registry. +- `exporter.renderer` is the malli-validated facade over both drivers: `render [env params on-object]` dispatches by `:is-wasm`, `with-scope [env exports f]` leases one headless worker for the whole call. `env` is flat (`:browser-pool :base-uri :public-uri :svgo? :wasm-pool :cancel-source`); the browser ctx is derived by `select-keys`. Params validation raises `:data-validation`. +- `exporter.main` wires both pools (`:exporter.browser/pool` and `:exporter.wasm.pool/pool`, each sized to the worker concurrency). No test boots the production wiring: the wasm pool warms one worker eagerly and the worker script in a test process would be the test bundle itself; the wiring is asserted as data and the lifecycle rides fake configs. - Async test vars prove their chain only when really awaited: a test passing with 0 assertions means the chain broke upstream. ## Render details diff --git a/exporter/src/exporter/main.cljs b/exporter/src/exporter/main.cljs index 000b161461..4e3948e065 100644 --- a/exporter/src/exporter/main.cljs +++ b/exporter/src/exporter/main.cljs @@ -18,17 +18,19 @@ [app.common.logging :as l] [app.config :as cf] [app.consumer.config :as ccfg] - ;; Loaded for its init-key/halt-key methods, which is what makes the - ;; browser pool part of the system below. + ;; Loaded for their init-key/halt-key methods, which is what makes + ;; the pools part of the system below. [exporter.browser] - [exporter.utils.system :as system])) + [exporter.utils.system :as system] + [exporter.wasm.pool])) (l/setup! {:exporter :info}) (def system-config "The production wiring. Env-derived values are read here, at the wiring layer; the components themselves take plain data." - {:exporter.browser/pool {:max (ccfg/concurrency)}}) + {:exporter.browser/pool {:max (ccfg/concurrency)} + :exporter.wasm.pool/pool {:max (ccfg/concurrency)}}) ;; The running system map, or nil when nothing is started. ;; Counterpart of the backend's `app.system/system` var. diff --git a/exporter/src/exporter/renderer.cljs b/exporter/src/exporter/renderer.cljs new file mode 100644 index 0000000000..fb2c8a196d --- /dev/null +++ b/exporter/src/exporter/renderer.cljs @@ -0,0 +1,87 @@ +;; 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 exporter.renderer + "Common renderer interface, validated with malli from day one. + + Dispatches one render at a time to the browser or the headless + driver by the `:is-wasm` flag of the params. `env` carries + everything the params do not freeze: the browser context + (`:browser-pool`, `:base-uri`, `:public-uri`, `:svgo?`), the wasm + pool instance (`:wasm-pool`, the `{:pool :timeout-ms}` map the pool + service owns) and the injected cancel source (`:cancel-source`, + the `:cancel-signal`/`:cancelled?`/`:on-cancel` fns)." + (:require + [app.common.schema :as sm] + [exporter.renderer.browser :as browser] + [exporter.renderer.wasm :as wasm])) + +(def schema:type + [:enum :png :jpeg :webp :pdf :svg]) + +(def schema:object + [:map + [:id :string] + [:name :string] + [:suffix :string] + [:filename :string] + [:share-id {:optional true} ::sm/uuid]]) + +(def schema:objects + [:vector {:min 1} schema:object]) + +(def schema:render-params + [:map + [:file-id ::sm/uuid] + [:page-id ::sm/uuid] + [:scale ::sm/number] + [:token :string] + [:type schema:type] + [:objects schema:objects] + [:is-wasm {:optional true} ::sm/boolean] + [:job-id {:optional true} ::sm/uuid]]) + +(def ^:private check-params + (sm/check-fn schema:render-params :hint "invalid render params")) + +(def ^:private check-callback + (sm/check-fn ::sm/fn :hint "on-object must be a fn")) + +(defn- browser-ctx + [env] + (select-keys env [:browser-pool :base-uri :public-uri :svgo?])) + +(defn headless? + "Whether `params` renders with render-wasm rather than a browser." + [{:keys [is-wasm]}] + (boolean is-wasm)) + +(defn ^:async render + [env params on-object] + (check-params params) + (check-callback on-object) + (if (headless? params) + (await (wasm/render (:wasm-pool env) (:cancel-source env) params on-object)) + (await (browser/render (browser-ctx env) params on-object)))) + +(defn ^:async with-scope + "Runs `f`, a fn of a render fn with the same signature as `render`. + Exports that render headless share one worker for the whole call + instead of acquiring one per render; the browser backend keeps + rendering them one checkout at a time." + [env exports f] + (if (some headless? exports) + (await (wasm/with-scope (:wasm-pool env) (:cancel-source env) + (:job-id (first exports)) + (fn [leased] + (f (fn [params on-object] + (check-params params) + (check-callback on-object) + (if (headless? params) + (leased params on-object) + (browser/render (browser-ctx env) params on-object))))))) + (await (f (fn [params on-object] + (render env params on-object)))))) diff --git a/exporter/test/exporter_tests/main_test.cljs b/exporter/test/exporter_tests/main_test.cljs index edba45701a..3bf973dfa6 100644 --- a/exporter/test/exporter_tests/main_test.cljs +++ b/exporter/test/exporter_tests/main_test.cljs @@ -10,6 +10,7 @@ (:require ["node:process" :as proc] [app.common.logging :as l] + [app.consumer.config :as ccfg] [cljs.test :as t :include-macros true] [exporter.browser :as browser] [exporter.main :as main])) @@ -39,15 +40,24 @@ (defn- fake-config [calls] - {:exporter.browser/pool {:max 1 - :create-browser (fn [] - (swap! calls conj :create-browser) - (js/Promise.resolve (stub-browser calls)))}}) + {:exporter.browser/pool {:max 1 + :create-browser (fn [] + (swap! calls conj :create-browser) + (js/Promise.resolve (stub-browser calls)))} + :exporter.wasm.pool/pool {:max 1 + :min 0 + :create-worker (fn [] + (swap! calls conj :create-worker) + (js/Promise.resolve #js {}))}}) (defn- pool-of [] (:exporter.browser/pool @main/system)) +(defn- wasm-pool-of + [] + (:exporter.wasm.pool/pool @main/system)) + (t/deftest ^:async install-process-handlers-registers-handlers (try (let [listeners (fn [event] (.listenerCount proc/default event)) @@ -74,22 +84,27 @@ (catch :default cause (t/is false (str "unexpected failure: " (ex-message cause)))))) -(t/deftest ^:async hot-reload-start-boots-production-wiring - (try - ;; No browser launches: the pool starts empty (min 0) and nothing - ;; checks one out, so this boots the real config with no Chromium. - (let [started (js/Promise. (fn [resolve _] (main/on-after-load (fn [] (resolve :done)))))] - (t/is (= :done (await started)))) - (t/is (some? (pool-of))) - (t/is (= :stopped (await (main/stop)))) - (catch :default cause - (t/is false (str "unexpected failure: " (ex-message cause)))))) +(t/deftest production-wiring-declares-both-pools + (t/testing "the wiring names the browser and the wasm pools, sized to the worker concurrency" + (t/is (= {:max (ccfg/concurrency)} + (:exporter.browser/pool main/system-config))) + (t/is (= {:max (ccfg/concurrency)} + (:exporter.wasm.pool/pool main/system-config))))) + +;; NOTE: no test boots the production wiring: the wasm pool warms one +;; worker eagerly (min 1), and in this process the worker script would +;; be the test bundle itself. The hook path stays covered through the +;; fake configs below; the production map above is data, asserted +;; without booting it. (t/deftest ^:async start-boots-and-stop-halts (try (let [calls (atom [])] (t/is (= :started (await (main/start-custom (fake-config calls))))) (t/is (some? (pool-of))) + (t/testing "the wasm pool rides the same lifecycle" + (t/is (some? (:pool (wasm-pool-of)))) + (t/is (= 300000 (:timeout-ms (wasm-pool-of))))) (await (browser/exec (pool-of) {} (fn [_] :ok))) (t/is (= :stopped (await (main/stop)))) (t/is (nil? @main/system)) diff --git a/exporter/test/exporter_tests/renderer_test.cljs b/exporter/test/exporter_tests/renderer_test.cljs new file mode 100644 index 0000000000..232c74e778 --- /dev/null +++ b/exporter/test/exporter_tests/renderer_test.cljs @@ -0,0 +1,206 @@ +;; 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 exporter-tests.renderer-test + "The renderer facade: malli validation and dispatch to the browser + or headless drivers, against a stub browser pool and a stub wasm + pool." + (:require + ["generic-pool" :as gp] + ["node:events" :as events] + [app.common.transit :as transit] + [app.common.uuid :as uuid] + [app.config :as cf] + [cljs.test :as t :include-macros true] + [exporter.renderer :as renderer])) + +;; --- stub browser side (bitmap only: navigate once, shoot objects) --- + +(defn- stub-locator + [calls] + #js {:waitFor (fn [_opts] (js/Promise.resolve nil)) + :screenshot (fn [opts] + (swap! calls conj [:screenshot (unchecked-get opts "path")]) + (js/Promise.resolve nil))}) + +(defn- stub-page + [calls] + #js {:goto (fn [url _opts] + (swap! calls conj [:goto (str url)]) + (js/Promise.resolve nil)) + :waitForTimeout (fn [ms] (swap! calls conj [:sleep ms]) (js/Promise.resolve nil)) + :waitForFunction (fn [_src _arg _opts] (js/Promise.resolve nil)) + :locator (fn [sel] (swap! calls conj [:select sel]) (stub-locator calls)) + :$$ (fn [sel] + (swap! calls conj [:select-all sel]) + (js/Promise.resolve #js [])) + :evaluate (fn [_f & _args] (js/Promise.resolve nil))}) + +(defn- stub-context + [page] + #js {:newPage (fn [] (js/Promise.resolve page)) + :close (fn [] (js/Promise.resolve nil))}) + +(defn- stub-browser + [page] + #js {:newContext (fn [_opts] (js/Promise.resolve (stub-context page))) + :isConnected (fn [] true) + :close (fn [] (js/Promise.resolve nil))}) + +(defn- stub-browser-pool + [calls] + (let [page (stub-page calls)] + (gp/createPool #js {:create (fn [] (js/Promise.resolve (stub-browser page))) + :destroy (fn [browser] (.close ^js browser)) + :validate (fn [_] true)} + #js {:max 1 :min 0}))) + +;; --- stub wasm side (one worker answering object+done) --- + +(defn- stub-worker + [calls] + (let [^js emitter (events/EventEmitter.)] + (unchecked-set emitter "postMessage" + (fn [msg] + (swap! calls conj [:post (unchecked-get msg "type")]) + (js/setTimeout + (fn [] + (.emit emitter "message" #js {:type "object" + :payload (transit/encode-str {:id "w1" :path "/tmp/w1.png"})}) + (.emit emitter "message" #js {:type "done"})) + 0))) + (unchecked-set emitter "terminate" + (fn [] + (swap! calls conj :terminate) + (js/Promise.resolve nil))) + emitter)) + +(defn- stub-wasm-pool + [calls worker] + #js {:acquire (fn [] (swap! calls conj :acquire) (js/Promise.resolve worker)) + :release (fn [_] (swap! calls conj :release) (js/Promise.resolve nil)) + :destroy (fn [_] (swap! calls conj :destroy) (js/Promise.resolve nil))}) + +(defn- stub-cancel-source + [calls] + {:cancel-signal (fn [job-id] (swap! calls conj [:cancel-signal job-id]) nil) + :cancelled? (fn [job-id] (swap! calls conj [:cancelled?-asked job-id]) false) + :on-cancel (fn [job-id f] (swap! calls conj [:on-cancel job-id f]) nil)}) + +(defn- test-env + [calls] + (let [worker (stub-worker calls)] + {:browser-pool (stub-browser-pool calls) + :base-uri (cf/get-internal-uri) + :public-uri (cf/get :public-uri) + :svgo? false + :wasm-pool {:pool (stub-wasm-pool calls worker) :timeout-ms 5000} + :cancel-source (stub-cancel-source calls)})) + +(defn- test-params + [type opts] + (merge {:file-id (uuid/next) + :page-id (uuid/next) + :token "token" + :scale 1 + :type type + :objects [{:id "a" :name "a" :suffix ".png" :filename "a.png"}]} + opts)) + +(t/deftest headless-reads-the-wasm-flag + (t/is (true? (renderer/headless? {:is-wasm true :type :png}))) + (t/is (false? (renderer/headless? {:type :png}))) + (t/is (false? (renderer/headless? {:is-wasm false :type :png})))) + +(t/deftest ^:async render-routes-bitmaps-to-the-browser + (try + (let [calls (atom []) + seen (atom [])] + (await (renderer/render (test-env calls) + (test-params :png nil) + (fn [object] (swap! seen conj object) nil))) + (t/is (= ["a"] (mapv :id @seen))) + (t/is (some #(and (vector? %) (= :goto (first %))) @calls)) + (t/is (not-any? #(and (vector? %) (= :post (first %))) @calls))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause)))))) + +(t/deftest ^:async render-routes-headless-to-the-worker + (try + (let [calls (atom []) + job-id (uuid/next) + seen (atom [])] + (await (renderer/render (test-env calls) + (test-params :png {:is-wasm true :job-id job-id}) + (fn [object] (swap! seen conj object) nil))) + (t/is (= [{:id "w1" :path "/tmp/w1.png"}] @seen)) + (t/is (some #{[:post "render"]} @calls)) + (t/is (some #{[:cancel-signal job-id]} @calls)) + (t/is (not-any? #(and (vector? %) (= :goto (first %))) @calls))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause)))))) + +(t/deftest ^:async render-rejects-invalid-params + (try + (let [calls (atom [])] + (try + (await (renderer/render (test-env calls) + (dissoc (test-params :png nil) :objects) + (fn [_] nil))) + (t/is false "render should have rejected params without objects") + (catch :default cause + (t/is (= :data-validation (-> cause ex-data :code)))))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause)))))) + +(t/deftest ^:async render-rejects-a-non-fn-callback + (try + (let [calls (atom [])] + (try + (await (renderer/render (test-env calls) (test-params :png nil) :not-a-fn)) + (t/is false "render should have rejected a non-fn on-object") + (catch :default cause + (t/is (= :data-validation (-> cause ex-data :code)))))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause)))))) + +(t/deftest ^:async with-scope-shares-one-worker-across-headless-renders + (try + (let [calls (atom []) + job-id (uuid/next) + env (test-env calls) + seen (atom [])] + (await (renderer/with-scope env + [(test-params :png {:is-wasm true :job-id job-id}) + (test-params :png {:is-wasm true :job-id job-id}) + (test-params :png {:job-id job-id})] + (^:async fn [render*] + (await (render* (test-params :png {:is-wasm true :job-id job-id}) + (fn [object] (swap! seen conj object) nil))) + (await (render* (test-params :png {:is-wasm true :job-id job-id}) + (fn [object] (swap! seen conj object) nil))) + (await (render* (test-params :png {:job-id job-id}) + (fn [object] (swap! seen conj object) nil)))))) + (t/is (= 1 (count (filter #{:acquire} @calls)))) + (t/is (= 1 (count (filter #{:release} @calls)))) + (t/is (= 3 (count @seen)))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause)))))) + +(t/deftest ^:async with-scope-without-headless-renders-directly + (try + (let [calls (atom []) + env (test-env calls) + seen (atom [])] + (await (renderer/with-scope env + [(test-params :png nil)] + (fn [render*] + (render* (test-params :png nil) + (fn [object] (swap! seen conj object) nil))))) + (t/is (= ["a"] (mapv :id @seen))) + (t/is (not-any? #{:acquire} @calls))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause)))))) diff --git a/exporter/test/exporter_tests/runner.cljs b/exporter/test/exporter_tests/runner.cljs index 203967052d..a827f182a5 100644 --- a/exporter/test/exporter_tests/runner.cljs +++ b/exporter/test/exporter_tests/runner.cljs @@ -18,6 +18,7 @@ [exporter-tests.main-test] [exporter-tests.renderer-browser-test] [exporter-tests.renderer-svg-test] + [exporter-tests.renderer-test] [exporter-tests.renderer-wasm-test] [exporter-tests.shell-test] [exporter-tests.system-test] @@ -45,6 +46,7 @@ 'exporter-tests.consumer-config-test 'exporter-tests.renderer-browser-test 'exporter-tests.renderer-svg-test + 'exporter-tests.renderer-test 'exporter-tests.renderer-wasm-test 'exporter-tests.shell-test 'exporter-tests.system-test