From ab6d77954a45d698e4dfdb794bb892290872c1db Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 7 Oct 2026 17:34:55 +0000 Subject: [PATCH] :tada: Start the parallel exporter tree with system and browser New code grows under `exporter.*` instead of extending `app.*`: `exporter.main` holds the future entry point, `exporter.utils.system` is an async-aware Integrant-style lifecycle (`init-key`/`halt-key` multimethods, `ref`s, dependency ordering, `^:async` + `await`, no promesa chains), and `exporter.browser` ports the whole browser namespace as a pool service with an injectable factory. Each piece ships with its tests, registered in the test runner. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/exporter/core.md | 7 + exporter/src/exporter/browser.cljs | 246 ++++++++++++++++++ exporter/src/exporter/main.cljs | 12 + exporter/src/exporter/utils/system.cljs | 243 +++++++++++++++++ .../test/exporter_tests/browser_test.cljs | 108 ++++++++ exporter/test/exporter_tests/main_test.cljs | 16 ++ exporter/test/exporter_tests/runner.cljs | 8 +- exporter/test/exporter_tests/system_test.cljs | 215 +++++++++++++++ 8 files changed, 854 insertions(+), 1 deletion(-) create mode 100644 exporter/src/exporter/browser.cljs create mode 100644 exporter/src/exporter/main.cljs create mode 100644 exporter/src/exporter/utils/system.cljs create mode 100644 exporter/test/exporter_tests/browser_test.cljs create mode 100644 exporter/test/exporter_tests/main_test.cljs create mode 100644 exporter/test/exporter_tests/system_test.cljs diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md index a0be587c19..06445a6f2d 100644 --- a/.serena/memories/exporter/core.md +++ b/.serena/memories/exporter/core.md @@ -34,6 +34,13 @@ on this side: the row of the job, its cancel and its result are the backend's. - Deploy: the compose service is one `penpot-exporter` (the same image, `CMD ["node", "app.js"]`); scale by replicas, K is per process. Nginx has no `/api/export` route anymore: the frontend talks to the backend's RPC only (`create-export-assets-job`), and downloads ride `/assets/by-id/?share-id=`/auth of the job resource. - Useful beats while debugging: the boot log names `:workers K`; each poller logs `running job`/`job settled`; the runner logs `export job settled` with `:outcome`, and a cancel logs `job cancelled by backend` on the watchdog. +## The new tree (refactor in progress) + +- New code grows under `exporter.*`, in parallel to the legacy `app.*` tree: `exporter.main` will replace `app.core` once every piece is ported; do not extend `app.*`. +- Lifecycle is `exporter.utils.system`: async-aware Integrant-style init/halt (`init-key`/`halt-key` multimethods, `ref`s, `weavejester.dependency` ordering, `^:async` + `cljs.core/await`, no promesa chains); tests are `^:async deftest` with `await` directly in the body. +- First ported service is `exporter.browser` (whole `app.browser` at once): the running instance is the raw pool, the public API takes it first (`exec` fails fast on a nil pool), the browser factory is injected through config for hermetic tests, and the new tree carries no promesa. +- Async test vars prove their chain only when really awaited: a test passing with 0 assertions means the chain broke upstream. + ## Render details - Headless engines (wasm/Skia) lease one render worker for the whole run (`rd/with-scope`); browser renders go one DOM page per partition of 50. diff --git a/exporter/src/exporter/browser.cljs b/exporter/src/exporter/browser.cljs new file mode 100644 index 0000000000..c572b08384 --- /dev/null +++ b/exporter/src/exporter/browser.cljs @@ -0,0 +1,246 @@ +;; 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.browser + "The browser pool service and the page API its pages speak. + + The pool is a system component (`:exporter.browser/pool`): `init-key` + builds a `generic-pool` of Chromium browsers, `halt-key` drains it. + Everything else here is the plain page API the renderers use; none of + it touches the pool, so none of it needs the system. + + There are no promesa chains in this namespace: Playwright and + `generic-pool` already speak native promises, and `await` covers the + rest." + (:refer-clojure :exclude [eval]) + (:require + ["generic-pool" :as gp] + ["generic-pool/lib/errors.js" :as gpe] + ["playwright" :as pw] + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.util.object :as obj] + [exporter.utils.system :as system])) + +(l/set-level! :trace) + +(def ^:private TimeoutError gpe/TimeoutError) + +;; --- PAGE API + +(def default-timeout 30000) +(def default-viewport-width 1920) +(def default-viewport-height 1080) +(def default-user-agent + (str "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/99.0.3729.169 Safari/537.36")) + +(defn create-cookies + [uri {:keys [name token] :or {name "auth-token"}}] + (let [domain (str (:host uri) + (when (:port uri) + (str ":" (:port uri))))] + #js [#js {:domain domain + :path "/" + :name name + :value token}])) + +(defn nav + ([page url] (nav page url nil)) + ([page url {:keys [wait-until timeout] :or {wait-until "networkidle" timeout 20000}}] + (.goto ^js page (str url) #js {:waitUntil wait-until :timeout timeout}))) + +(defn sleep + [page ms] + (.waitForTimeout ^js page ms)) + +(defn- ^:async wait-for-fonts-async + [page timeout] + (try + (await (.waitForFunction ^js page + "async () => { + if (!document.fonts) return true; + try { + await Promise.all(Array.from(document.fonts, (face) => face.load())); + } catch (e) {} + await document.fonts.ready; + return document.fonts.status === 'loaded'; + }" + nil + #js {:timeout timeout})) + (catch :default cause + (l/warn :hint "wait-for-fonts timed out; continuing anyway" + :cause (ex-message cause)) + nil))) + +(defn wait-for-fonts + "Wait until the browser has finished loading all fonts. + + Checking `document.fonts.status === 'loaded'` on its own is not enough: + the render page injects its `@font-face` rules asynchronously and the + browser only loads a face lazily, when some painted text actually uses + it. A face that is declared but not yet requested stays in the `unloaded` + state, which does NOT keep `document.fonts.status` at `loading`, so the + check can pass before any real font glyphs are available. The export is + then captured with a (usually wider) fallback font, and auto-width text + laid out with the real font metrics overflows its bounds and gets clipped. + + To avoid that we explicitly request every declared face and await + `document.fonts.ready` before checking the status." + ([page] (wait-for-fonts page nil)) + ([page {:keys [timeout] :or {timeout 15000}}] + (wait-for-fonts-async page timeout))) + +(defn wait-for + ([locator] (wait-for locator nil)) + ([locator {:keys [state timeout] :or {state "visible" timeout 10000}}] + (.waitFor ^js locator #js {:state state :timeout timeout}))) + +(defn screenshot + ([frame] (screenshot frame {})) + ([frame {:keys [full-page? omit-background? type quality path] + :or {type "png" full-page? false omit-background? false quality 95}}] + (let [options (-> (obj/new) + (obj/set! "type" (name type)) + (obj/set! "omitBackground" omit-background?) + (cond-> path (obj/set! "path" path)) + (cond-> (= "jpeg" type) (obj/set! "quality" quality)) + (cond-> full-page? (-> (obj/set! "fullPage" true) + (obj/set! "clip" nil))))] + (.screenshot ^js frame options)))) + +(defn emulate-media + [page {:keys [media]}] + (.emulateMedia ^js page #js {:media media}) + page) + +(defn pdf + ([page] (pdf page {})) + ([page {:keys [scale path page-ranges] + :or {page-ranges "1" + scale 1}}] + (.pdf ^js page #js {:path path + :scale scale + :pageRanges page-ranges + :printBackground true + :preferCSSPageSize true}))) + +(defn eval + [frame f] + (.evaluate ^js frame f)) + +(defn select + [frame selector] + (.locator ^js frame selector)) + +(defn select-all + [frame selector] + (.$$ ^js frame selector)) + +;; --- POOL SERVICE + +(def defaults + "Static pool defaults. The sizing (`::max`) mirrors the worker + concurrency default; the wiring layer passes the env-derived value." + {::max 2 + ::acquire-timeout 10000 + ::idle-timeout 10000 + ::launch-args ["--allow-insecure-localhost" "--font-render-hinting=none"]}) + +(defn- default-create-browser + [launch-args] + (fn [] + (.launch pw/chromium #js {:args (clj->js launch-args)}))) + +(defn- ^:async create-instance + [create-browser id-counter] + (let [browser (await (create-browser)) + id (swap! id-counter inc)] + (l/info :origin "factory" :action "create" :browser-id id) + (unchecked-set browser "__id" id) + browser)) + +(defn- destroy-instance + [browser] + (l/info :origin "factory" :action "destroy" + :browser-id (unchecked-get browser "__id")) + (.close ^js browser)) + +(defn- validate-instance + [browser] + (l/info :origin "factory" :action "validate" + :browser-id (unchecked-get browser "__id")) + (.isConnected ^js browser)) + +(defn- make-factory + [create-browser] + (let [id-counter (atom 0)] + #js {:create (fn [] (create-instance create-browser id-counter)) + :destroy destroy-instance + :validate validate-instance})) + +(defmethod system/init-key ::pool + [_ cfg] + (let [opts (merge defaults (d/without-nils cfg)) + create-browser (or (::create-browser opts) + (default-create-browser (::launch-args opts)))] + (l/info :hint "initializing browser pool" :max (::max opts)) + (gp/createPool (make-factory create-browser) + #js {:max (::max opts) + :min 0 + :testOnBorrow true + :evictionRunIntervalMillis 5000 + :numTestsPerEvictionRun 5 + :acquireTimeoutMillis (::acquire-timeout opts) + :idleTimeoutMillis (::idle-timeout opts)}))) + +(defn- ^:async drain-pool + [pool] + (await (.drain ^js pool)) + (await (.clear ^js pool)) + nil) + +(defmethod system/halt-key ::pool + [_ pool] + (when pool + (l/info :hint "finalizing browser pool") + (drain-pool pool))) + +(defn- throw-browser-error + [cause] + (if (instance? TimeoutError cause) + (ex/raise :type :internal + :code :timeout + :hint (ex-message cause) + :cause cause) + (throw cause))) + +(defn ^:async exec + "Check out a browser, run `handle` with a fresh page, and put the + browser back. `handle` takes the page and may return a plain value or + a promise. On failure the browser is destroyed instead of reused, and + a Playwright timeout surfaces as an `:timeout` error." + [pool context-opts handle] + (when-not pool + (throw (ex-info "browser pool is not started" {:reason ::not-started}))) + (let [browser (await (.acquire ^js pool))] + (try + (let [context (await (.newContext ^js browser context-opts)) + _ (l/trace :hint "exec:handle:start" :browser-id (unchecked-get browser "__id")) + page (await (.newPage ^js context)) + result (await (handle page))] + (await (.close ^js context)) + (l/trace :hint "exec:handle:end" :browser-id (unchecked-get browser "__id")) + (await (.release ^js pool browser)) + result) + (catch :default cause + (try + (await (.destroy ^js pool browser)) + (catch :default destroy-cause + (l/warn :hint "browser destroy failed" + :cause (ex-message destroy-cause)))) + (throw-browser-error cause))))) diff --git a/exporter/src/exporter/main.cljs b/exporter/src/exporter/main.cljs new file mode 100644 index 0000000000..4f0d55f115 --- /dev/null +++ b/exporter/src/exporter/main.cljs @@ -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 exporter.main + "Entry point of the new exporter tree. + + This namespace will replace `app.core` once every piece of `app.*` + has been ported over. New code grows under `exporter.*`, in parallel + to the legacy `app.*` tree, until the old one can be removed.") diff --git a/exporter/src/exporter/utils/system.cljs b/exporter/src/exporter/utils/system.cljs new file mode 100644 index 0000000000..2f90b9159d --- /dev/null +++ b/exporter/src/exporter/utils/system.cljs @@ -0,0 +1,243 @@ +;; 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.utils.system + "Async-aware Integrant-style system lifecycle for the new tree. + + The shape mirrors `integrant.core`, trimmed down: a config map of + qualified keys to values, `ref`s between them, and two multimethods, + `init-key` and `halt-key`, that turn values into running components + and back. The dependency graph rides `weavejester.dependency`, the + same library Integrant uses. + + The difference is async: `init` and `halt` are `^:async`, every step + is awaited, and an `init-key` method may return a plain value or a + promise. `init` never throws synchronously; every failure, including + invalid config, surfaces as a rejected promise. + + Deliberately left out from Integrant: refsets, profiles, vars, + expand/converge, suspend/resume, composite keys, EDN reading and + namespace loading." + (:require + [clojure.set :as set] + [clojure.string :as str] + [clojure.walk :as walk] + [weavejester.dependency :as dep])) + +(defrecord Ref [key]) + +(defn ref + "Create a reference to a top-level key in a config map. The ref + resolves to the running instance, once its target has started." + [key] + (when-not (qualified-keyword? key) + (throw (ex-info (str "Invalid reference: " key ". Must be a qualified keyword.") + {:reason ::invalid-ref + :ref key}))) + (->Ref key)) + +(defn ref? + "Return true if its argument is a ref." + [x] + (instance? Ref x)) + +(defn- derived-from? + [key candidate] + (or (= key candidate) (isa? key candidate))) + +(defn find-derived + "Return a seq of all entries in a map, m, where the key is the + candidate key or derived from it. If there are no matching keys, nil + is returned." + [m k] + (seq (filter #(or (= (key %) k) (derived-from? (key %) k)) m))) + +(defn- find-derived-1 + [m k] + (let [kvs (find-derived m k)] + (when (next kvs) + (throw (ex-info (str "Ambiguous key: " k ". Found multiple candidates: " + (str/join ", " (map key kvs))) + {:reason ::ambiguous-key + :key k + :matching (mapv key kvs)}))) + (first kvs))) + +(defn- depth-search + [pred? coll] + (filter pred? (tree-seq coll? seq coll))) + +(defn- find-derived-refs + [config v] + (->> (depth-search ref? v) + (map :key) + (mapcat #(map key (find-derived config %))))) + +(defn- dependency-graph + [config] + (reduce-kv (fn [g k v] + (reduce #(dep/depend %1 k %2) g (find-derived-refs config v))) + (dep/graph) + config)) + +(defn- key-comparator + [graph] + (dep/topo-comparator #(compare (str %1) (str %2)) graph)) + +(defn- config-keyset + [config ks] + (set (mapcat #(map key (find-derived config %)) ks))) + +(defn- validate-init + "Check the config and the requested keys, and return the relevant + keys in init order: requested, plus their transitive dependencies. + A circular dependency surfaces the `weavejester.dependency` ex-info." + [config ks] + (doseq [k (keys config)] + (when-not (qualified-keyword? k) + (throw (ex-info (str "Invalid config key: " k ". Keys must be qualified keywords.") + {:reason ::invalid-config + :key k})))) + (doseq [k ks] + (when-not (seq (find-derived config k)) + (throw (ex-info (str "Unknown key: " k ". It matches nothing in the config.") + {:reason ::unknown-key + :key k})))) + (let [graph (dependency-graph config) + keyset (config-keyset config ks) + relevant (select-keys config (set/union keyset + (dep/transitive-dependencies-set graph keyset))) + ref-keys (distinct (map :key (depth-search ref? (vals relevant)))) + missing (filterv #(nil? (find-derived relevant %)) ref-keys)] + (when (seq missing) + (throw (ex-info (str "Missing definitions for refs: " (str/join ", " missing)) + {:reason ::missing-refs + :config config + :missing-refs missing}))) + (doseq [k ref-keys] + (find-derived-1 relevant k)) + (let [comparator (key-comparator graph)] + (vec (sort comparator (keys relevant)))))) + +(defn- resolve-refs + [sys v] + (walk/postwalk #(if (ref? %) (val (find-derived-1 sys (:key %))) %) v)) + +(defmulti init-key + "Turn a config value associated with a key into a running component. + May return a plain value or a promise; the system awaits it either way." + {:arglists '([key config])} + (fn [key _config] key)) + +(defmethod init-key :default + [k _config] + (throw (ex-info (str "No init-key method for " k) + {:reason ::missing-init-key + :key k}))) + +(defmulti halt-key + "Stop the running component associated with a key. Receives the + running instance returned by `init-key`, so anything halt needs must + travel inside it. May return a plain value or a promise; the system + awaits it either way. Must be idempotent." + {:arglists '([key instance])} + (fn [key _instance] key)) + +(defmethod halt-key :default + [_instance-key _instance] + nil) + +(defn ^:private ^:async build-key + [sys k v] + (try + (assoc sys k (await (init-key k (resolve-refs sys v)))) + (catch :default cause + (throw (ex-info (str "Error on key " k " when building system") + {:reason ::init-failed + :key k + :value v + :system sys} + cause))))) + +(defn- halt-order + [sys ks] + (let [config (or (::origin (meta sys)) {}) + graph (dependency-graph config) + keyset (config-keyset config ks) + comparator (key-comparator graph)] + (->> (set/union keyset (dep/transitive-dependents-set graph keyset)) + (filter #(contains? sys %)) + (sort comparator) + (reverse) + (vec)))) + +(defn ^:private ^:async halt-keys + "Halt every key in ks, collecting failures instead of stopping at the + first one. Returns a vector of `{:key :cause}` maps." + [sys ks] + (loop [failures [] + ks ks] + (if (seq ks) + (let [k (first ks) + failures (try + (await (halt-key k (sys k))) + failures + (catch :default cause + (conj failures {:key k :cause cause})))] + (recur failures (rest ks))) + failures))) + +(defn ^:private ^:async halt-silently + [sys] + (when (map? sys) + (await (halt-keys sys (halt-order sys (keys sys))))) + nil) + +(defn ^:async init + "Turn a config map into a running system map. Keys start in + dependency order, each one awaited, with refs resolved to the running + instances. With two args, only those keys start, plus whatever they + depend on. + + Returns a promise of the system map. If a component fails to start, + what already started is halted in reverse order, and the promise + rejects with the original failure carrying `:reason ::init-failed`, + the offending `:key`, and the partial `:system`." + ([config] + (init config (keys config))) + ([config ks] + (assert (map? config) "init needs a config map") + (let [order (validate-init config ks)] + (try + (loop [sys (with-meta {} {::origin config}) + ks order] + (if (seq ks) + (recur (await (build-key sys (first ks) (config (first ks)))) + (rest ks)) + sys)) + (catch :default cause + (await (halt-silently (:system (ex-data cause)))) + (throw cause)))))) + +(defn ^:async halt + "Halt a running system map in reverse dependency order, awaiting each + component. With two args, halt those keys plus whatever depends on them. + + Returns a promise of nil. Every component is given its chance to stop: + failures are collected, and once everything ran, the promise rejects + with `:reason ::halt-failed` and the `:failures` vector." + ([sys] + (halt sys (keys sys))) + ([sys ks] + (when-not (and (map? sys) (::origin (meta sys))) + (throw (ex-info "halt needs a system map built by init." + {:reason ::not-a-system}))) + (let [failures (await (halt-keys sys (halt-order sys ks)))] + (when (seq failures) + (throw (ex-info (str "Failed to halt " (count failures) " component(s): " + (str/join ", " (map :key failures))) + {:reason ::halt-failed + :failures failures})))))) diff --git a/exporter/test/exporter_tests/browser_test.cljs b/exporter/test/exporter_tests/browser_test.cljs new file mode 100644 index 0000000000..e286c4e192 --- /dev/null +++ b/exporter/test/exporter_tests/browser_test.cljs @@ -0,0 +1,108 @@ +;; 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.browser-test + "The browser pool service: lifecycle through the system, and the + acquire/use/release contract of exec, against a fake browser factory." + (:require + ["generic-pool/lib/errors.js" :as gpe] + [cljs.test :as t :include-macros true] + [exporter.browser :as browser] + [exporter.utils.system :as system])) + +(defn- stub-page + [] + #js {:marker :stub-page}) + +(defn- stub-context + [calls page] + #js {:newPage (fn [] (swap! calls conj :new-page) (js/Promise.resolve page)) + :close (fn [] (swap! calls conj :close-context) (js/Promise.resolve nil))}) + +(defn- stub-browser + [calls] + (let [page (stub-page)] + #js {:newContext (fn [_opts] + (swap! calls conj :new-context) + (js/Promise.resolve (stub-context calls page))) + :isConnected (fn [] true) + :close (fn [] + (swap! calls conj :close-browser) + (js/Promise.resolve nil))})) + +(defn- fake-create-browser + [calls] + (fn [] + (swap! calls conj :create-browser) + (js/Promise.resolve (stub-browser calls)))) + +(defn- test-config + [calls] + {:exporter.browser/pool {:exporter.browser/max 1 + :exporter.browser/create-browser (fake-create-browser calls)}}) + +(t/deftest ^:async pool-starts-and-stops-through-the-system + (try + (let [calls (atom []) + sys (await (system/init (test-config calls)))] + (t/is (some? (:exporter.browser/pool sys))) + (t/is (nil? (await (system/halt sys))))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause)))))) + +(t/deftest ^:async exec-acquires-runs-handle-and-releases + (try + (let [calls (atom []) + sys (await (system/init (test-config calls))) + pool (:exporter.browser/pool sys) + seen (atom nil) + result (await (browser/exec pool {} (fn [page] (reset! seen page) :handle-result)))] + (t/is (= :handle-result result)) + (t/is (= :stub-page (.-marker @seen))) + (t/is (= [:create-browser :new-context :new-page :close-context] @calls)) + (t/is (not (some #{:close-browser} @calls))) + (t/testing "a second exec reuses the released browser: nothing is created again" + (await (browser/exec pool {} (fn [_] :again))) + (t/is (= 1 (count (filter #{:create-browser} @calls))))) + (await (system/halt sys))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause)))))) + +(t/deftest ^:async exec-destroys-the-browser-on-failure + (try + (let [calls (atom []) + sys (await (system/init (test-config calls))) + pool (:exporter.browser/pool sys)] + (try + (await (browser/exec pool {} (fn [_] (js/Promise.reject (ex-info "render broke" {}))))) + (t/is false "exec should have rejected when the handle fails") + (catch :default cause + (t/is (= "render broke" (ex-message cause))) + (t/is (some #{:close-browser} @calls)))) + (await (system/halt sys))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause)))))) + +(t/deftest ^:async exec-without-pool-fails-fast + (try + (await (browser/exec nil {} (fn [_] :never))) + (t/is false "exec should have rejected without a pool") + (catch :default cause + (t/is (= ::browser/not-started (-> cause ex-data :reason)))))) + +(t/deftest ^:async exec-translates-playwright-timeouts + (try + (let [calls (atom []) + sys (await (system/init (test-config calls))) + pool (:exporter.browser/pool sys)] + (try + (await (browser/exec pool {} (fn [_] (js/Promise.reject (gpe/TimeoutError. "timeout exceeded"))))) + (t/is false "exec should have rejected on a playwright timeout") + (catch :default cause + (t/is (= :timeout (-> cause ex-data :code))))) + (await (system/halt sys))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause)))))) diff --git a/exporter/test/exporter_tests/main_test.cljs b/exporter/test/exporter_tests/main_test.cljs new file mode 100644 index 0000000000..1bf136bf2d --- /dev/null +++ b/exporter/test/exporter_tests/main_test.cljs @@ -0,0 +1,16 @@ +;; 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.main-test + "The new `exporter.*` tree loads: requiring `exporter.main` resolves + its namespace." + (:require + [cljs.test :as t :include-macros true] + [exporter.main])) + +(t/deftest new-tree-loads + (t/testing "exporter.main resolves once required" + (t/is (some? (find-ns 'exporter.main))))) diff --git a/exporter/test/exporter_tests/runner.cljs b/exporter/test/exporter_tests/runner.cljs index 0ba5f77196..c0781a2288 100644 --- a/exporter/test/exporter_tests/runner.cljs +++ b/exporter/test/exporter_tests/runner.cljs @@ -11,11 +11,14 @@ [clojure.string :as str] [clojure.tools.cli :refer [parse-opts]] [exporter-tests.api-test] + [exporter-tests.browser-test] [exporter-tests.consumer-config-test] [exporter-tests.consumer-plan-test] [exporter-tests.jobs-test] + [exporter-tests.main-test] [exporter-tests.renderer-svg-test] [exporter-tests.shell-test] + [exporter-tests.system-test] [exporter-tests.wasm-pool-test] [exporter-tests.worker-test] [goog.object :as gobj])) @@ -30,13 +33,16 @@ (println (str "UNHANDLED-REJECTION: " reason)))) (def test-namespaces - ['exporter-tests.consumer-plan-test + ['exporter-tests.browser-test + 'exporter-tests.consumer-plan-test 'exporter-tests.jobs-test + 'exporter-tests.main-test 'exporter-tests.api-test 'exporter-tests.worker-test 'exporter-tests.consumer-config-test 'exporter-tests.renderer-svg-test 'exporter-tests.shell-test + 'exporter-tests.system-test 'exporter-tests.wasm-pool-test]) (assert (every? find-ns-obj test-namespaces) diff --git a/exporter/test/exporter_tests/system_test.cljs b/exporter/test/exporter_tests/system_test.cljs new file mode 100644 index 0000000000..03cee34937 --- /dev/null +++ b/exporter/test/exporter_tests/system_test.cljs @@ -0,0 +1,215 @@ +;; 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.system-test + "The async system lifecycle: dependency-ordered init and halt, with + refs resolved to the running instances." + (:require + [cljs.test :as t :include-macros true] + [exporter.utils.system :as system])) + +;; Each init method records its key on the tracker carried by its own +;; config, and returns a running instance that carries the tracker back, +;; so the matching halt method can record on the same atom. +(defn- track-init + [tracker k] + (swap! tracker conj k) + {:tracker tracker :component k}) + +(defn- track-halt + [tracker k] + (swap! tracker conj [:halted k]) + nil) + +(defmethod system/init-key :order-test/a [_ {:keys [tracker]}] (track-init tracker :order-test/a)) +(defmethod system/init-key :order-test/b [_ {:keys [tracker]}] (track-init tracker :order-test/b)) +(defmethod system/init-key :order-test/c [_ {:keys [tracker]}] (track-init tracker :order-test/c)) +(defmethod system/halt-key :order-test/a [_ {:keys [tracker]}] (track-halt tracker :order-test/a)) +(defmethod system/halt-key :order-test/b [_ {:keys [tracker]}] (track-halt tracker :order-test/b)) +(defmethod system/halt-key :order-test/c [_ {:keys [tracker]}] (track-halt tracker :order-test/c)) + +(defn- order-config + [tracker] + {:order-test/a {:tracker tracker} + :order-test/b {:tracker tracker :dep (system/ref :order-test/a)} + :order-test/c {:tracker tracker :dep (system/ref :order-test/b)}}) + +(defmethod system/init-key :refs-test/db [_ {:keys [tracker]}] + (swap! tracker conj :refs-test/db) + {:tracker tracker :component :refs-test/db :conn :fake-conn}) + +(defmethod system/init-key :refs-test/api [_ {:keys [tracker dep]}] + (swap! tracker conj [:refs-test/api-sees dep]) + {:tracker tracker :component :refs-test/api}) + +(defmethod system/init-key :async-test/db [_ _] + (js/Promise. (fn [resolve _] (js/setTimeout (fn [] (resolve :async-conn)) 5)))) + +(defmethod system/init-key :async-test/api [_ {:keys [dep]}] + {:component :async-test/api :dep dep}) + +(defmethod system/init-key :fail-test/a [_ {:keys [tracker]}] (track-init tracker :fail-test/a)) +(defmethod system/init-key :fail-test/b [_ {:keys [tracker fail?]}] + (swap! tracker conj :fail-test/b) + (if fail? + (js/Promise.reject (ex-info "cannot connect" {})) + {:tracker tracker :component :fail-test/b})) +(defmethod system/halt-key :fail-test/a [_ {:keys [tracker]}] (track-halt tracker :fail-test/a)) + +(defmethod system/init-key :haltfail-test/a [_ {:keys [tracker]}] (track-init tracker :haltfail-test/a)) +(defmethod system/init-key :haltfail-test/b [_ {:keys [tracker boom?]}] + (assoc (track-init tracker :haltfail-test/b) :boom? boom?)) +(defmethod system/halt-key :haltfail-test/a [_ {:keys [tracker]}] (track-halt tracker :haltfail-test/a)) +(defmethod system/halt-key :haltfail-test/b [_ {:keys [tracker boom?]}] + (swap! tracker conj [:haltfail-test/b-ran boom?]) + (when boom? + (throw (ex-info "cannot stop" {}))) + nil) + +(derive :derive-test/child :derive-test/parent) +(derive :derive-test/other :derive-test/parent) + +(defmethod system/init-key :derive-test/child [_ _] :child-running) +(defmethod system/init-key :derive-test/other [_ _] :other-running) +(defmethod system/init-key :derive-test/consumer [_ {:keys [dep]}] dep) +(defmethod system/init-key :derive-test/consumer2 [_ {:keys [dep]}] dep) + +(defmethod system/init-key :circ-test/a [_ _] :circ-a-running) +(defmethod system/init-key :circ-test/b [_ _] :circ-b-running) + +(defmethod system/init-key :nohalt-test/a [_ _] :nohalt-a-running) + +(t/deftest ^:async inits-keys-in-dependency-order + (try + (let [tracker (atom []) + sys (await (system/init (order-config tracker)))] + (t/is (= [:order-test/a :order-test/b :order-test/c] @tracker)) + (t/is (= #{:order-test/a :order-test/b :order-test/c} (set (keys sys))))) + (catch :default cause + (t/is false (str "unexpected init failure: " (ex-message cause)))))) + +(t/deftest ^:async resolves-refs-to-running-instances + (try + (let [tracker (atom []) + sys (await (system/init {:refs-test/db {:tracker tracker} + :refs-test/api {:tracker tracker + :dep (system/ref :refs-test/db)}}))] + (t/is (= [:refs-test/db + [:refs-test/api-sees {:tracker tracker + :component :refs-test/db + :conn :fake-conn}]] + @tracker)) + (t/is (= :refs-test/api (:component (:refs-test/api sys))))) + (catch :default cause + (t/is false (str "unexpected init failure: " (ex-message cause)))))) + +(t/deftest ^:async awaits-promised-instances + (try + (let [sys (await (system/init {:async-test/db {} + :async-test/api {:dep (system/ref :async-test/db)}}))] + (t/is (= :async-conn (:async-test/db sys))) + (t/is (= :async-conn (:dep (:async-test/api sys))))) + (catch :default cause + (t/is false (str "unexpected init failure: " (ex-message cause)))))) + +(t/deftest ^:async missing-ref-fails-init + (try + (await (system/init {:missing-test/a {:dep (system/ref :missing-test/ghost)}})) + (t/is false "init should have rejected on a missing ref") + (catch :default cause + (t/is (= ::system/missing-refs (-> cause ex-data :reason))) + (t/is (= [:missing-test/ghost] (-> cause ex-data :missing-refs)))))) + +(t/deftest ^:async failed-init-halts-what-already-started + (let [tracker (atom [])] + (try + (await (system/init {:fail-test/a {:tracker tracker} + :fail-test/b {:tracker tracker + :fail? true + :dep (system/ref :fail-test/a)}})) + (t/is false "init should have rejected when a component fails") + (catch :default cause + (t/is (= ::system/init-failed (-> cause ex-data :reason))) + (t/is (= :fail-test/b (-> cause ex-data :key))) + (t/is (= [:fail-test/a :fail-test/b [:halted :fail-test/a]] @tracker)))))) + +(t/deftest ^:async halts-in-reverse-dependency-order + (try + (let [tracker (atom []) + sys (await (system/init (order-config tracker))) + result (await (system/halt sys))] + (t/is (nil? result)) + (t/is (= [[:halted :order-test/c] + [:halted :order-test/b] + [:halted :order-test/a]] + (drop 3 @tracker)))) + (catch :default cause + (t/is false (str "unexpected halt failure: " (ex-message cause)))))) + +(t/deftest ^:async halt-continues-past-a-failing-component + (try + (let [tracker (atom []) + sys (await (system/init {:haltfail-test/a {:tracker tracker} + :haltfail-test/b {:tracker tracker + :boom? true + :dep (system/ref :haltfail-test/a)}}))] + (try + (await (system/halt sys)) + (t/is false "halt should have rejected when a component fails to stop") + (catch :default cause + (t/is (= ::system/halt-failed (-> cause ex-data :reason))) + (t/is (= [:haltfail-test/b] (mapv :key (-> cause ex-data :failures)))))) + (t/is (= [:haltfail-test/a + :haltfail-test/b + [:haltfail-test/b-ran true] + [:halted :haltfail-test/a]] + @tracker))) + (catch :default cause + (t/is false (str "unexpected init failure: " (ex-message cause)))))) + +(t/deftest ^:async init-subset-pulls-its-dependencies + (try + (let [tracker (atom []) + sys (await (system/init (order-config tracker) [:order-test/c]))] + (t/is (= [:order-test/a :order-test/b :order-test/c] @tracker)) + (t/is (= #{:order-test/a :order-test/b :order-test/c} (set (keys sys))))) + (catch :default cause + (t/is false (str "unexpected init failure: " (ex-message cause)))))) + +(t/deftest ^:async ref-to-a-parent-resolves-the-derived-child + (try + (let [sys (await (system/init {:derive-test/child {} + :derive-test/consumer {:dep (system/ref :derive-test/parent)}}))] + (t/is (= :child-running (:derive-test/consumer sys)))) + (catch :default cause + (t/is false (str "unexpected init failure: " (ex-message cause)))))) + +(t/deftest ^:async ref-to-a-parent-with-two-children-is-ambiguous + (try + (await (system/init {:derive-test/child {} + :derive-test/other {} + :derive-test/consumer2 {:dep (system/ref :derive-test/parent)}})) + (t/is false "init should have rejected on an ambiguous ref") + (catch :default cause + (t/is (= ::system/ambiguous-key (-> cause ex-data :reason)))))) + +(t/deftest ^:async circular-dependencies-fail-init + (try + (await (system/init {:circ-test/a {:dep (system/ref :circ-test/b)} + :circ-test/b {:dep (system/ref :circ-test/a)}})) + (t/is false "init should have rejected on a circular dependency") + (catch :default cause + (t/is (= :weavejester.dependency/circular-dependency + (-> cause ex-data :reason)))))) + +(t/deftest ^:async key-without-halt-method-halts-cleanly + (try + (let [sys (await (system/init {:nohalt-test/a {}})) + result (await (system/halt sys))] + (t/is (= :nohalt-a-running (:nohalt-test/a sys))) + (t/is (nil? result))) + (catch :default cause + (t/is false (str "unexpected failure: " (ex-message cause))))))