mirror of
https://github.com/penpot/penpot.git
synced 2026-07-29 01:16:14 +00:00
* ✨ Add systematic component tests via a composable test model Introduce a framework for systematically testing Penpot component behaviour (synchronisation/propagation, swaps, variant switches, nesting), plus a first suite of cases built on it. A test is expressed as a COMPOSITION OF OPERATIONS over a "situation" (an in-memory file value plus named role bindings). Operations are reified as data and composed by two combinators — `in-sequence` (threads the situation) and `one-of`/`optional` (alternatives, enumerated into concrete variants). So one written case stands for a whole matrix of variants, and coverage grows by composition rather than by copying tests. Operations drive the REAL production change pipeline, and event-operations dispatch the REAL workspace events and await settlement, so the production watcher's automatic propagation is what is exercised — the tests reflect genuine app behaviour, not a reimplementation. Structure (frontend/test/frontend_tests/composable_tests/): - core — the domain-agnostic engine: situation, the operation and enumeration protocols, the combinators, and the runners. - comp/nodes — the component operations (create/instantiate/reset, nesting, swap, the variant ops, child add/remove/move, change, undo, library sync). - comp/setups — component-shaped starting configurations. - interpreter — runs a case against the real frontend store: sync-ops apply directly, event-ops dispatch real events and await settlement (absorbing sync-file's delayed status RPC, which would otherwise leak an error into subsequent tests). - comp/sync-test — the cases (B-F, H, I, K, L, M). This is test-only code with a single consumer — the frontend test suite (the layer that runs the real app) — so it lives entirely under the frontend test tree as .cljs, not under app/common. The framework and its cases are documented in the project memory frontend/composable-component-tests, added alongside. Co-authored-by: Claude <noreply@anthropic.com> * 🐛 Guard WASM mock teardown against an empty snapshot `teardown-wasm-mocks!` unconditionally restored from the `originals` atom. When run without a matching setup (double teardown, or `with-wasm-mocks*` misused around an async test body), the snapshot is empty and every WASM API function was `set!` to nil — permanently, for the remainder of the test run. Any later code calling one of them (e.g. a leaked debounced resize-wasm-text event firing during a subsequent test namespace) then crashed with "initialized? is not a function". Make the restore a no-op when there is nothing to restore. Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
243 lines
8.6 KiB
Clojure
243 lines
8.6 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC
|
|
|
|
(ns frontend-tests.helpers.wasm
|
|
"Test helpers for mocking WASM API boundary functions.
|
|
|
|
In the Node.js test environment the WASM binary is not available,
|
|
but `render-wasm/v1` is active when the test store uses a profile
|
|
with `:renderer :wasm` (see `frontend-tests.helpers.state`), so
|
|
geometry/text events take the WASM code path.
|
|
This namespace provides lightweight mock implementations that let
|
|
the Clojure-side logic execute normally while stubbing out every
|
|
call that would touch the WASM heap.
|
|
|
|
Each mock tracks how many times it was called via `call-counts`.
|
|
Use `(call-count :propagate-modifiers)` in test assertions to
|
|
verify the WASM code path was exercised."
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.render-wasm.api :as wasm.api]
|
|
[app.render-wasm.api.fonts :as wasm.fonts]))
|
|
|
|
;; --- Call tracking ---------------------------------------------------
|
|
|
|
(def ^:private call-counts
|
|
"Atom holding a map of mock-name → number of calls since last reset."
|
|
(atom {}))
|
|
|
|
(defn- track!
|
|
"Increment the call count for `mock-name`."
|
|
[mock-name]
|
|
(swap! call-counts update mock-name (fnil inc 0)))
|
|
|
|
(defn call-count
|
|
"Return how many times mock `mock-name` was called since setup.
|
|
`mock-name` is a keyword, e.g. `:propagate-modifiers`."
|
|
[mock-name]
|
|
(get @call-counts mock-name 0))
|
|
|
|
(defn reset-call-counts!
|
|
"Reset all call counts to zero."
|
|
[]
|
|
(reset! call-counts {}))
|
|
|
|
;; --- Mock implementations --------------------------------------------
|
|
|
|
(defn- mock-propagate-modifiers
|
|
"Passthrough mock for `wasm.api/propagate-modifiers`.
|
|
|
|
Receives `entries` — a vector of `[uuid {:transform matrix, :kind kw}]`
|
|
pairs produced by `parse-geometry-modifiers` — and returns a vector
|
|
of `[uuid matrix]` pairs that `apply-wasm-modifiers` converts to a
|
|
transforms map via `(into {} result)`.
|
|
|
|
This effectively tells the caller \"apply exactly the transform that
|
|
was requested\", which is what the real WASM engine does for simple
|
|
moves / resizes without constraints."
|
|
[entries _pixel-precision]
|
|
(track! :propagate-modifiers)
|
|
(when (d/not-empty? entries)
|
|
(into []
|
|
(map (fn [[id data]]
|
|
(d/vec2 id (:transform data))))
|
|
entries)))
|
|
|
|
(defn- mock-clean-modifiers
|
|
[]
|
|
(track! :clean-modifiers)
|
|
nil)
|
|
|
|
(defn- mock-set-structure-modifiers
|
|
[_entries]
|
|
(track! :set-structure-modifiers)
|
|
nil)
|
|
|
|
(defn- mock-set-modifiers
|
|
[_modifiers]
|
|
(track! :set-modifiers)
|
|
nil)
|
|
|
|
(defn- mock-set-shape-grow-type
|
|
[_grow-type]
|
|
(track! :set-shape-grow-type)
|
|
nil)
|
|
|
|
(defn- mock-initialized?
|
|
[]
|
|
(track! :initialized?)
|
|
true)
|
|
|
|
;; The functions below used to short-circuit in tests because they guarded on
|
|
;; `wasm/context-initialized?` (always false without a real WASM binary). They
|
|
;; now guard on `initialized?`, which the mock forces to `true`, so they would
|
|
;; reach the real WASM heap. Stub them as no-ops to preserve that behavior.
|
|
|
|
(defn- mock-use-shape
|
|
[_id]
|
|
(track! :use-shape)
|
|
nil)
|
|
|
|
(defn- mock-calculate-position-data
|
|
[_shape]
|
|
(track! :calculate-position-data)
|
|
nil)
|
|
|
|
(defn- mock-request-render
|
|
[_requester]
|
|
(track! :request-render)
|
|
nil)
|
|
|
|
(defn- mock-set-shape-text-content
|
|
[_shape-id _content]
|
|
(track! :set-shape-text-content)
|
|
nil)
|
|
|
|
(defn- mock-set-shape-text-images
|
|
([_shape-id _content]
|
|
(track! :set-shape-text-images)
|
|
nil)
|
|
([_shape-id _content _thumbnail?]
|
|
(track! :set-shape-text-images)
|
|
nil))
|
|
|
|
(defn- mock-get-text-dimensions
|
|
([]
|
|
(track! :get-text-dimensions)
|
|
{:x 0 :y 0 :width 100 :height 20 :max-width 100})
|
|
([_id]
|
|
(track! :get-text-dimensions)
|
|
{:x 0 :y 0 :width 100 :height 20 :max-width 100}))
|
|
|
|
(defn- mock-font-stored?
|
|
[_font-data _emoji?]
|
|
(track! :font-stored?)
|
|
true)
|
|
|
|
(defn- mock-make-font-data
|
|
[font]
|
|
(track! :make-font-data)
|
|
{:wasm-id 0
|
|
:weight (or (:font-weight font) "400")
|
|
:style (or (:font-style font) "normal")
|
|
:emoji? false})
|
|
|
|
(defn- mock-get-content-fonts
|
|
[_content]
|
|
(track! :get-content-fonts)
|
|
[])
|
|
|
|
;; --- Persistent mock installation via `set!` --------------------------
|
|
;;
|
|
;; These use `set!` to directly mutate the module-level JS vars, making
|
|
;; the mocks persist across async boundaries. They are intended to be
|
|
;; used with `t/use-fixtures :each` which correctly sequences `:after`
|
|
;; to run only after the async test's `done` callback fires.
|
|
|
|
(def ^:private originals
|
|
"Stores the original WASM function values so they can be restored."
|
|
(atom {}))
|
|
|
|
(defn setup-wasm-mocks!
|
|
"Install WASM mocks via `set!` that persist across async boundaries.
|
|
Also resets call counts. Call `teardown-wasm-mocks!` to restore."
|
|
[]
|
|
;; Reset call tracking
|
|
(reset-call-counts!)
|
|
;; Save originals
|
|
(reset! originals
|
|
{:initialized? wasm.api/initialized?
|
|
:use-shape wasm.api/use-shape
|
|
:calculate-position-data wasm.api/calculate-position-data
|
|
:request-render wasm.api/request-render
|
|
:clean-modifiers wasm.api/clean-modifiers
|
|
:set-structure-modifiers wasm.api/set-structure-modifiers
|
|
:propagate-modifiers wasm.api/propagate-modifiers
|
|
:set-modifiers wasm.api/set-modifiers
|
|
:set-shape-grow-type wasm.api/set-shape-grow-type
|
|
:set-shape-text-content wasm.api/set-shape-text-content
|
|
:set-shape-text-images wasm.api/set-shape-text-images
|
|
:get-text-dimensions wasm.api/get-text-dimensions
|
|
:font-stored? wasm.fonts/font-stored?
|
|
:make-font-data wasm.fonts/make-font-data
|
|
:get-content-fonts wasm.fonts/get-content-fonts})
|
|
;; Install mocks
|
|
(set! wasm.api/initialized? mock-initialized?)
|
|
(set! wasm.api/use-shape mock-use-shape)
|
|
(set! wasm.api/calculate-position-data mock-calculate-position-data)
|
|
(set! wasm.api/request-render mock-request-render)
|
|
(set! wasm.api/clean-modifiers mock-clean-modifiers)
|
|
(set! wasm.api/set-structure-modifiers mock-set-structure-modifiers)
|
|
(set! wasm.api/propagate-modifiers mock-propagate-modifiers)
|
|
(set! wasm.api/set-modifiers mock-set-modifiers)
|
|
(set! wasm.api/set-shape-grow-type mock-set-shape-grow-type)
|
|
(set! wasm.api/set-shape-text-content mock-set-shape-text-content)
|
|
(set! wasm.api/set-shape-text-images mock-set-shape-text-images)
|
|
(set! wasm.api/get-text-dimensions mock-get-text-dimensions)
|
|
(set! wasm.fonts/font-stored? mock-font-stored?)
|
|
(set! wasm.fonts/make-font-data mock-make-font-data)
|
|
(set! wasm.fonts/get-content-fonts mock-get-content-fonts))
|
|
|
|
(defn teardown-wasm-mocks!
|
|
"Restore the original WASM functions saved by `setup-wasm-mocks!`.
|
|
No-op when there is nothing to restore (`originals` empty): restoring from an
|
|
empty snapshot would `set!` every WASM function to nil, breaking any later
|
|
code that calls them (e.g. a leaked debounced event firing during a
|
|
subsequent test namespace)."
|
|
[]
|
|
(when-let [orig (not-empty @originals)]
|
|
(set! wasm.api/initialized? (:initialized? orig))
|
|
(set! wasm.api/use-shape (:use-shape orig))
|
|
(set! wasm.api/calculate-position-data (:calculate-position-data orig))
|
|
(set! wasm.api/request-render (:request-render orig))
|
|
(set! wasm.api/clean-modifiers (:clean-modifiers orig))
|
|
(set! wasm.api/set-structure-modifiers (:set-structure-modifiers orig))
|
|
(set! wasm.api/propagate-modifiers (:propagate-modifiers orig))
|
|
(set! wasm.api/set-modifiers (:set-modifiers orig))
|
|
(set! wasm.api/set-shape-grow-type (:set-shape-grow-type orig))
|
|
(set! wasm.api/set-shape-text-content (:set-shape-text-content orig))
|
|
(set! wasm.api/set-shape-text-images (:set-shape-text-images orig))
|
|
(set! wasm.api/get-text-dimensions (:get-text-dimensions orig))
|
|
(set! wasm.fonts/font-stored? (:font-stored? orig))
|
|
(set! wasm.fonts/make-font-data (:make-font-data orig))
|
|
(set! wasm.fonts/get-content-fonts (:get-content-fonts orig)))
|
|
(reset! originals {}))
|
|
|
|
(defn with-wasm-mocks*
|
|
"Calls `(thunk)` with all WASM API boundary functions replaced by
|
|
safe mocks, restoring the originals when the thunk returns.
|
|
|
|
NOTE: Teardown happens synchronously when `thunk` returns. For
|
|
async tests (e.g. those using `tohs/run-store-async`), use
|
|
`setup-wasm-mocks!` / `teardown-wasm-mocks!` via
|
|
`t/use-fixtures :each` instead."
|
|
[thunk]
|
|
(setup-wasm-mocks!)
|
|
(try
|
|
(thunk)
|
|
(finally
|
|
(teardown-wasm-mocks!))))
|