Dr. Dominik Jain 2c15dcdb84
Add systematic component tests via a composable test model (#10529)
*  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>
2026-07-10 12:17:43 +02:00

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!))))