From 3b9a895f62e7779439cd0f2d8b0f8dffa8547d76 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 22 Jun 2026 11:29:05 +0200 Subject: [PATCH 01/10] :bug: Fix incorrect events handling on webgl render toggle From the workspace main menu --- frontend/src/app/main/data/event.cljs | 2 ++ .../src/app/main/ui/workspace/main_menu.cljs | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index 77e537c685..4df5c2335b 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -399,6 +399,7 @@ (fn [_] ;; Start the event collection pipeline (->> (rx/merge + (rx/filter (ptk/type? ::force-persist) stream) (->> (rx/from-atom buffer) (rx/filter #(pos? (count %))) (rx/debounce 2000)) @@ -416,6 +417,7 @@ (rx/map (constantly chunk)))))) (rx/take-until stopper) (rx/subs! (fn [chunk] + (st/emit! (ptk/data-event ::chunk-persisted {:chunk chunk})) (swap! buffer remove-from-buffer (count chunk))) (fn [cause] (l/error :hint "unexpected error on audit persistence" :cause cause)) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 8b87b01d5a..bc46580707 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -44,6 +44,7 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [beicon.v2.core :as rx] + [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc shortcuts* @@ -936,17 +937,24 @@ ev-name (if (= next-renderer :wasm) "enable-webgl-rendering" "disable-webgl-rendering")] + (if (cf/external-feature-flag "renderer-hard-reload" "test") ;; Bare RPC + hard reload: skips `du/update-profile-props`, so ;; `features/recompute-features` is not run here; bootstrap ;; after reload resolves render-wasm/v1 from the saved profile. (do - (st/emit! (ev/event {::ev/name ev-name - ::ev/origin "workspace:menu"})) - (->> (rp/cmd! :update-profile-props {:props {:renderer next-renderer}}) - (rx/subs! (fn [_] (dom/reload-current-window true)) + (->> (rx/zip + (rp/cmd! :update-profile-props {:props {:renderer next-renderer}}) + (rx/filter (ptk/type? ::ev/chunk-persisted) st/stream)) + (rx/timeout 2000 (rx/of :timeout)) + (rx/subs! (fn [_] + (dom/reload-current-window true)) (fn [_] - (st/emit! (ntf/error (tr "errors.generic"))))))) + (st/emit! (ntf/error (tr "errors.generic")))))) + (st/emit! (ev/event {::ev/name ev-name + ::ev/origin "workspace:menu"}) + (ptk/data-event ::ev/force-persist {}))) + ;; `update-profile-props` WatchEvent calls ;; `features/recompute-features`. (st/emit! (ev/event {::ev/name ev-name From d8434cbffb94606b1551e636121d6806790db95d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 22 Jun 2026 13:26:21 +0200 Subject: [PATCH 02/10] :bug: Add missing migrations (#10363) --- backend/src/app/migrations.clj | 8 +++++++- .../app/migrations/sql/0150-mod-storage-object-table.sql | 1 + .../sql/0151-mod-file-tagged-object-thumbnail-table.sql | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 backend/src/app/migrations/sql/0150-mod-storage-object-table.sql create mode 100644 backend/src/app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 043502fc74..d57a91b666 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -484,7 +484,13 @@ :fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")} {:name "0149-mod-file-library-rel-synced-at" - :fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")}]) + :fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")} + + {:name "0150-mod-storage-object-table" + :fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")} + + {:name "0151-mod-file-tagged-object-thumbnail-table" + :fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0150-mod-storage-object-table.sql b/backend/src/app/migrations/sql/0150-mod-storage-object-table.sql new file mode 100644 index 0000000000..487fd66433 --- /dev/null +++ b/backend/src/app/migrations/sql/0150-mod-storage-object-table.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS storage_object__metadata_upload_id__idx ON storage_object ((metadata->>'~:upload-id')) WHERE deleted_at IS NULL; diff --git a/backend/src/app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql b/backend/src/app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql new file mode 100644 index 0000000000..394c9ba31f --- /dev/null +++ b/backend/src/app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS file_tagged_object_thumbnail__object_id__idx ON file_tagged_object_thumbnail (object_id); From 3aa46379a18d7906098e075eb961ff9a133c655d Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 22 Jun 2026 14:46:01 +0200 Subject: [PATCH 03/10] :tada: Activate background-blur flag by default (#10366) --- common/src/app/common/flags.cljc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 5b111d3dbf..5443a609ed 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -202,7 +202,8 @@ :enable-token-import-from-library :enable-render-switch :enable-render-wasm-info - :enable-available-viewer-wasm]) + :enable-available-viewer-wasm + :enable-background-blur]) (defn parse [& flags] From aec56be9f540272eb033cd660bcdaca5b8c88e77 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 22 Jun 2026 14:46:48 +0200 Subject: [PATCH 04/10] :bug: Fix inspect in View Mode displays shape size twice when selecting a shape (#10364) --- frontend/src/app/main/ui/measurements.cljs | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/app/main/ui/measurements.cljs b/frontend/src/app/main/ui/measurements.cljs index ca81e31dd4..aca38aa5d8 100644 --- a/frontend/src/app/main/ui/measurements.cljs +++ b/frontend/src/app/main/ui/measurements.cljs @@ -281,7 +281,6 @@ :bounds bounds :zoom zoom}] [:> size-display* {:selrect selected-selrect :zoom zoom}] - [:> selection-size-badge* {:selrect selected-selrect :zoom zoom}] (if (or (not hover-shape) (not hover-selected-shape?)) (when (and frame (not= uuid/zero (:id frame))) From 20e90078db2d048cdba9dbe918104e679578a033 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 22 Jun 2026 15:40:12 +0200 Subject: [PATCH 05/10] :bug: Fix guides pill on drag --- frontend/src/app/main/ui/workspace/viewport/guides.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/viewport/guides.cljs b/frontend/src/app/main/ui/workspace/viewport/guides.cljs index f451564fa3..07cc05f357 100644 --- a/frontend/src/app/main/ui/workspace/viewport/guides.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/guides.cljs @@ -681,7 +681,7 @@ ;; only overlay the dotted extensions. Drag and edit hide the WASM line ;; and require the full SVG line. show-line? (not= mode :hover) - show-pill? (or (= mode :edit) (= mode :hover)) + show-pill? (or (= mode :edit) (= mode :hover) (= mode :drag)) editing? (= mode :edit) on-key-down From dd353a81210386a798d037112851c3ff7fa2379d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 23 Jun 2026 09:28:28 +0200 Subject: [PATCH 06/10] :wrench: Update design-tab tests for default background-blur flag --- frontend/playwright/ui/specs/design-tab.spec.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/playwright/ui/specs/design-tab.spec.js b/frontend/playwright/ui/specs/design-tab.spec.js index 27be52b4f7..c70ca3bbdd 100644 --- a/frontend/playwright/ui/specs/design-tab.spec.js +++ b/frontend/playwright/ui/specs/design-tab.spec.js @@ -245,7 +245,7 @@ test.describe("Background blur", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-background-blur", tokenInputFlag]); + await workspace.mockConfigFlags([tokenInputFlag]); await workspace.setupEmptyFile(); await workspace.mockGetFile("render-wasm/get-file-background-blur.json"); @@ -283,7 +283,7 @@ test.describe("Background blur", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-background-blur", tokenInputFlag]); + await workspace.mockConfigFlags([tokenInputFlag]); await workspace.setupEmptyFile(); await workspace.mockGetFile("render-wasm/get-file-background-blur.json"); @@ -321,7 +321,7 @@ test.describe("Background blur", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-background-blur", tokenInputFlag]); + await workspace.mockConfigFlags([tokenInputFlag]); await workspace.setupEmptyFile(); await workspace.mockGetFile("render-wasm/get-file-background-blur.json"); @@ -356,7 +356,8 @@ test.describe("Background blur", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - // No enable-background-blur flag + // background-blur flag is active by default; disable it explicitly here + await workspace.mockConfigFlags(["disable-background-blur"]); await workspace.setupEmptyFile(); await workspace.mockGetFile("render-wasm/get-file-background-blur.json"); From 8e548c8c54fca6c33d2dd466a9f30c94e14535f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 23 Jun 2026 10:25:45 +0200 Subject: [PATCH 07/10] :bug: Fix blank tiles and atlas crash on render-wasm zoom/pan (#10367) * :bug: Fix missing tiles on page switch and pan/zoom end * :bug: Fix blank tiles and atlas crash on render-wasm zoom/pan --- render-wasm/src/main.rs | 6 ++++-- render-wasm/src/render/surfaces.rs | 14 ++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 2f7503d181..bcdd9bf28a 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -107,13 +107,14 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { #[wasm_error] pub extern "C" fn render(timestamp: i32, flags: u8) -> Result { with_state!(state, { + let render_state = get_render_state(); state.rebuild_touched_tiles(); // Drain the throttled modifier-tile invalidation accumulated // since the previous rAF. set_modifiers skips this work during // interactive_transform; we do it once here, with the current // modifier set, so the cost is paid once per rAF rather than // once per pointer move. - if get_render_state().options.is_interactive_transform() { + if render_state.options.is_interactive_transform() { // Collect into an owned Vec to release the immutable borrow on // `state.shapes` before the mutable `rebuild_modifier_tiles` call. let ids = state.shapes.modifier_ids().to_vec(); @@ -121,7 +122,8 @@ pub extern "C" fn render(timestamp: i32, flags: u8) -> Result { state.rebuild_modifier_tiles(&ids)?; } } - let frame_type = if flags & RenderFlag::Partial as u8 == RenderFlag::Partial as u8 { + let is_partial = flags & RenderFlag::Partial as u8 == RenderFlag::Partial as u8; + let frame_type = if is_partial && !render_state.preserve_target_during_render { state .continue_render_loop(timestamp) .map_err(|_| Error::RecoverableError("Error rendering".to_string()))? diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 28210c6517..dc39595e96 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -1631,15 +1631,13 @@ impl TileTextureCache { } pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { - if self.grid.len() > TEXTURES_CACHE_CAPACITY { - // First we try to remove the obsolete tiles. - self.gc(); - } + // Evict against the real slot count (`provider.length`), not the + // hardcoded capacity — otherwise the guard never fires and the atlas + // fills up until `allocate()` has no slot left. + let capacity = self.provider.length.min(TEXTURES_CACHE_CAPACITY); - // If we still have a texture capacity problem, then - // we try to remove all of those tiles that aren't - // visible. - if self.grid.len() > TEXTURES_CACHE_CAPACITY { + if self.grid.len() >= capacity { + self.gc(); self.gc_non_visible(tile_viewbox); } From 07de0e92d5ea8bff314881d6a046317e45f5d681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 23 Jun 2026 10:58:14 +0200 Subject: [PATCH 08/10] :zap: Fix slow zoom/edit on Firefox+NVIDIA WebGL renderer (#10371) --- render-wasm/src/render.rs | 1 - render-wasm/src/render/surfaces.rs | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 80a9480076..9dda9ce11d 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2187,7 +2187,6 @@ impl RenderState { // quality pass completes. self.surfaces .reset_interactive_transform(self.background_color); - self.surfaces.seed_backbuffer_from_target(); self.interactive_target_seeded = false; } else { self.reset_canvas(); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index dc39595e96..4f9952ca79 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -907,17 +907,6 @@ impl Surfaces { self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); } - /// Seed `Backbuffer` from `Target` (last presented frame). - pub fn seed_backbuffer_from_target(&mut self) { - let sampling_options = self.sampling_options; - self.target.draw( - self.backbuffer.canvas(), - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), - ); - } - fn reset_from_target(&mut self, target: skia::Surface) -> Result<()> { let dim = (target.width(), target.height()); self.target = target; From f967a0fc8317bc30a6e6bca12a29d0f20038b7b4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 23 Jun 2026 11:21:53 +0200 Subject: [PATCH 09/10] :sparkles: Add improvements for frontend tests (#10380) --- common/src/app/common/logging.cljc | 5 +- frontend/package.json | 4 +- frontend/src/app/main/errors.cljs | 4 +- frontend/src/app/main/features.cljs | 2 - .../data/workspace_thumbnails_test.cljs | 20 +- .../test/frontend_tests/helpers/mock.cljc | 136 ++++ .../test/frontend_tests/helpers/mock.cljs | 103 --- .../test/frontend_tests/helpers/state.cljs | 11 +- .../logic/components_and_tokens.cljs | 2 +- .../logic/copying_and_duplicating_test.cljs | 724 +++++++++--------- .../test/frontend_tests/main_errors_test.cljs | 4 +- .../frontend_tests/plugins/page_test.cljs | 47 +- frontend/test/frontend_tests/runner.cljs | 116 +-- .../tokens/import_export_test.cljs | 7 +- 14 files changed, 632 insertions(+), 553 deletions(-) create mode 100644 frontend/test/frontend_tests/helpers/mock.cljc delete mode 100644 frontend/test/frontend_tests/helpers/mock.cljs diff --git a/common/src/app/common/logging.cljc b/common/src/app/common/logging.cljc index c8f28bd029..0c96e7c22e 100644 --- a/common/src/app/common/logging.cljc +++ b/common/src/app/common/logging.cljc @@ -332,8 +332,9 @@ (defn setup! [{:as config}] (run! (fn [[logger level]] - (let [logger (if (keyword? logger) (name logger) logger)] - (l/set-level! logger level))) + (let [logger (if (keyword? logger) (name logger) logger) + level (level->int level)] + (.set ^js/Map loggers logger level))) config))) (defmacro raw! diff --git a/frontend/package.json b/frontend/package.json index c35da555a5..74f85808cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,8 +32,8 @@ "lint:clj": "clj-kondo --parallel --lint ../common/src src/", "lint:js": "exit 0", "lint:scss": "pnpm exec stylelint '{src,resources}/**/*.scss'", - "build:test": "clojure -M:dev:shadow-cljs compile test", - "test": "pnpm run build:wasm && pnpm run build:test && node target/tests/test.js", + "build:test": "pnpm run build:wasm && clojure -M:dev:shadow-cljs compile test", + "test": "[ -f target/tests/test.js ] || pnpm run build:test; node target/tests/test.js", "test:quiet": "node ./scripts/test-quiet.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'\"", diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 31a46b13b0..5b7bb40f5b 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -104,7 +104,9 @@ instead of recursing until the call-stack is exhausted." [error] (if @handling-error? - (.error js/console "[on-error] re-entrant call suppressed" error) + (do + (js/console.error "[on-error] re-entrant call suppressed") + (ex/print-throwable error)) (do (vreset! handling-error? true) (try diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs index 1cab3b49df..58b33ce313 100644 --- a/frontend/src/app/main/features.cljs +++ b/frontend/src/app/main/features.cljs @@ -21,8 +21,6 @@ [potok.v2.core :as ptk] [rumext.v2 :as mf])) -(log/set-level! :trace) - (def global-enabled-features (cfeat/get-enabled-features cf/flags)) diff --git a/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs b/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs index 2ccf1c51fc..fd35d6c5fc 100644 --- a/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs @@ -187,8 +187,8 @@ event (#'thumbnails/clear-thumbnail-batch)] (ptk/update event state) (mock/with-mocks - {#'app.main.repo/cmd! mock/rpc-cmd!-mock - #'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} + {app.main.repo/cmd! mock/rpc-cmd-mock + app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} (fn [done'] (->> (ptk/watch event state nil) (rx/reduce conj []) @@ -211,8 +211,8 @@ event (#'thumbnails/clear-thumbnail-batch)] (ptk/update event state) (mock/with-mocks - {#'app.main.repo/cmd! mock/rpc-cmd!-mock - #'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} + {app.main.repo/cmd! mock/rpc-cmd-mock + app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} (fn [done'] (->> (ptk/watch event state nil) (rx/reduce conj []) @@ -242,9 +242,9 @@ event (#'thumbnails/clear-thumbnail-batch)] (ptk/update event state) (mock/with-mocks - {#'app.main.repo/cmd! (fn [_ _] (rx/of nil)) - #'app.util.webapi/revoke-uri mock/revoke-uri-mock - #'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} + {app.main.repo/cmd! (fn [_ _] (rx/of nil)) + app.util.webapi/revoke-uri mock/revoke-uri-mock + app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} (fn [done'] (->> (ptk/watch event state nil) (rx/reduce conj []) @@ -262,8 +262,8 @@ state {}] (ptk/update event state) (mock/with-mocks - {#'app.main.repo/cmd! mock/rpc-cmd!-mock - #'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} + {app.main.repo/cmd! mock/rpc-cmd-mock + app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} (fn [done'] (->> (ptk/watch event state nil) (rx/reduce conj []) @@ -284,7 +284,7 @@ event (thumbnails/clear-thumbnail file-id object-id)] (ptk/update event state) (mock/with-mocks - {#'beicon.v2.core/timer mock/timer-mock} + {beicon.v2.core/timer mock/timer-mock} (fn [done'] (->> (ptk/watch event state nil) (rx/reduce conj []) diff --git a/frontend/test/frontend_tests/helpers/mock.cljc b/frontend/test/frontend_tests/helpers/mock.cljc new file mode 100644 index 0000000000..fce14876a3 --- /dev/null +++ b/frontend/test/frontend_tests/helpers/mock.cljc @@ -0,0 +1,136 @@ +;; 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.mock + "Async-first mocking primitives for ClojureScript tests. + + Uses `set!` to install and restore mocks so that they remain active + across asynchronous boundaries (Promises, RxJS subscriptions, callbacks). + `with-redefs` cannot be used here because it restores bindings when the + body exits, which is too early for async code. + + Recording atoms (`rpc-calls`, `revoked-uris`) persist across the entire + test lifecycle, making captured data inspectable regardless of whether + callbacks fire synchronously or asynchronously. + + The `with-mocks` helper wraps the lifecycle: + 1. Reset recording atoms + 2. Save original var values, install mocks via `set!` + 3. Execute `(test-fn inner-done)` + 4. `inner-done` restores originals and calls `outer-done` + (typically `cljs.test/async`'s done). + + Usage: `(with-mocks {ns/sym mock-fn, ...} test-fn done)`" + #?(:cljs (:require + [beicon.v2.core :as rx])) + #?(:cljs (:require-macros [frontend-tests.helpers.mock]))) + +;; ═══════════════════════════════════════════════════════════════ +;; Macro (compile-time, Clojure only) +;; ═══════════════════════════════════════════════════════════════ + +#?(:clj + (defmacro with-mocks + "Resets recording atoms, installs `mocks` via `set!`, then + calls `(test-fn inner-done)`. Original var values are restored + when `inner-done` is called. + + `mocks` is a map of sym → mock-fn + (e.g. `{app.main.repo/cmd! mock-fn}`). + + `inner-done` restores the originals and calls `outer-done` (the + `cljs.test/async` `done` callback). + + Example: + + (t/deftest my-async-test + (t/async done + (mock/with-mocks + {app.main.repo/cmd! mock/rpc-cmd-mock} + (fn [done'] + (->> (some-async-flow) + (rx/subs! + (fn [v] ...) + (fn [err] (done')) + (fn [] (done'))))))))" + [mocks test-fn outer-done] + (let [entries (map identity mocks) + gen-pairs (mapv (fn [[qsym _mock]] + {:qsym qsym + :osym (gensym "orig-")}) + entries) + ;; [orig-123 app.main.repo/cmd!, orig-456 app.util.timers/schedule-on-idle, ...] + let-bindings (vec (mapcat + (fn [{:keys [qsym osym]}] + [osym qsym]) + gen-pairs)) + install-exprs (mapv + (fn [[_qsym mock-fn] {:keys [qsym]}] + `(set! ~qsym ~mock-fn)) + entries + gen-pairs) + restore-exprs (mapv + (fn [{:keys [qsym osym]}] + `(set! ~qsym ~osym)) + gen-pairs)] + `(do + (frontend-tests.helpers.mock/reset-state!) + (let ~let-bindings + ~@install-exprs + (~test-fn (fn inner-done# [] + ~@restore-exprs + (~outer-done)))))))) + +;; ═══════════════════════════════════════════════════════════════ +;; Runtime (ClojureScript only) +;; ═══════════════════════════════════════════════════════════════ + +#?(:cljs + (do + ;; Recording atoms + ;; ═══════════════════════════════════════════════════════════════ + + (def rpc-calls + "Atom accumulating mocked `rp/cmd!` calls as `{:cmd kw :params map}`." + (atom [])) + + (def revoked-uris + "Atom accumulating URIs passed to `wapi/revoke-uri`." + (atom [])) + + ;; Mock implementations + ;; ═══════════════════════════════════════════════════════════════ + + (defn rpc-cmd-mock + "Records [cmd params] in [[rpc-calls]], returns `(rx/of nil)`." + [cmd params] + (swap! rpc-calls conj {:cmd cmd :params params}) + (rx/of nil)) + + (defn revoke-uri-mock + "Records `uri` in [[revoked-uris]]." + [uri] + (swap! revoked-uris conj uri)) + + (defn schedule-on-idle-mock + "Calls `f` immediately instead of deferring to the idle queue." + [f] + (f)) + + (defn timer-mock + "Returns `(rx/of :immediate)` so debounce timers fire instantly + during tests." + [_ms] + (rx/of :immediate)) + + ;; Lifecycle + ;; ═══════════════════════════════════════════════════════════════ + + (defn reset-state! + "Clear all recording atoms. Called automatically by [[with-mocks]]." + [] + (reset! rpc-calls []) + (reset! revoked-uris [])))) diff --git a/frontend/test/frontend_tests/helpers/mock.cljs b/frontend/test/frontend_tests/helpers/mock.cljs deleted file mode 100644 index bce234f4cf..0000000000 --- a/frontend/test/frontend_tests/helpers/mock.cljs +++ /dev/null @@ -1,103 +0,0 @@ -;; 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.mock - "Async-first mocking primitives for ClojureScript tests. - - Uses `with-redefs` — the standard CLJS mechanism for rebinding Vars - within a dynamic scope. Recording atoms (`rpc-calls`, `revoked-uris`) - persist across the entire test lifecycle, making captured data - inspectable regardless of whether callbacks fire synchronously or - asynchronously. - - The `with-mocks` helper wraps the lifecycle: - 1. Reset recording atoms - 2. Install mocks via `with-redefs` - 3. Execute `(test-fn inner-done)` - 4. `inner-done` calls `outer-done` (typically `cljs.test/async`'s done) - - Since all mock functions return synchronous `rx/of` observables, - callbacks always fire within the `with-redefs` body." - (:require - [beicon.v2.core :as rx])) - -;; ═══════════════════════════════════════════════════════════════ -;; Recording atoms -;; ═══════════════════════════════════════════════════════════════ - -(def rpc-calls - "Atom accumulating mocked `rp/cmd!` calls as `{:cmd kw :params map}`." - (atom [])) - -(def revoked-uris - "Atom accumulating URIs passed to `wapi/revoke-uri`." - (atom [])) - -;; ═══════════════════════════════════════════════════════════════ -;; Mock implementations -;; ═══════════════════════════════════════════════════════════════ - -(defn rpc-cmd!-mock - "Records [cmd params] in [[rpc-calls]], returns `(rx/of nil)`." - [cmd params] - (swap! rpc-calls conj {:cmd cmd :params params}) - (rx/of nil)) - -(defn revoke-uri-mock - "Records `uri` in [[revoked-uris]]." - [uri] - (swap! revoked-uris conj uri)) - -(defn schedule-on-idle-mock - "Calls `f` immediately instead of deferring to the idle queue." - [f] - (f)) - -(defn timer-mock - "Returns `(rx/of :immediate)` so debounce timers fire instantly - during tests." - [_ms] - (rx/of :immediate)) - -;; ═══════════════════════════════════════════════════════════════ -;; Lifecycle -;; ═══════════════════════════════════════════════════════════════ - -(defn reset! - "Clear all recording atoms. Called automatically by [[with-mocks]]." - [] - (reset! rpc-calls []) - (reset! revoked-uris [])) - -;; ═══════════════════════════════════════════════════════════════ -;; Public API -;; ═══════════════════════════════════════════════════════════════ - -(defn with-mocks - "Resets recording atoms, installs `mocks` via `with-redefs`, then - calls `(test-fn inner-done)`. - - `mocks` is a map of `Var → mock-fn` (e.g. `{#'rp/cmd! mock-fn}`). - `inner-done` tears down the `with-redefs` (by returning) and calls - `outer-done` (the `cljs.test/async` `done` callback). - - Example: - - (t/deftest my-async-test - (t/async done - (mock/with-mocks - {#'rp/cmd! mock/rpc-cmd!-mock} - (fn [done'] - (->> (some-async-flow) - (rx/subs! - (fn [v] ...) - (fn [err] (done')) - (fn [] (done'))))))))" - [mocks test-fn outer-done] - (reset!) - (apply with-redefs (mapcat identity mocks) - (test-fn (fn inner-done [] - (outer-done))))) diff --git a/frontend/test/frontend_tests/helpers/state.cljs b/frontend/test/frontend_tests/helpers/state.cljs index d811773806..90e4c995d0 100644 --- a/frontend/test/frontend_tests/helpers/state.cljs +++ b/frontend/test/frontend_tests/helpers/state.cljs @@ -12,6 +12,7 @@ [app.main.data.workspace.layout :as layout] [app.main.features :as features] [beicon.v2.core :as rx] + [cljs.test :as t] [potok.v2.core :as ptk])) (def ^private initial-state @@ -26,7 +27,8 @@ (defn- on-error [cause] - (js/console.log "STORE ERROR" (.-stack cause)) + (js/console.error "STORE ERROR" (.-stack cause)) + (t/do-report {:type :error :message "Store error" :actual cause}) (when-let [data (some-> cause ex-data ::sm/explain)] (pprint (sm/humanize-explain data)))) @@ -57,10 +59,13 @@ (rx/last) (rx/tap (fn [_] (completed-cb @store))) - (rx/subs! (fn [_] (done)) + (rx/subs! (fn [_] nil) (fn [cause] - (js/console.log "[error]:" cause)) + (done) + (js/console.error "[error]:" cause) + (t/do-report {:type :error :message "Stream error" :actual cause})) (fn [_] + (done) #_(js/console.debug "[complete]")))) (doseq [event events] diff --git a/frontend/test/frontend_tests/logic/components_and_tokens.cljs b/frontend/test/frontend_tests/logic/components_and_tokens.cljs index 5e7504b67d..16370d6e5c 100644 --- a/frontend/test/frontend_tests/logic/components_and_tokens.cljs +++ b/frontend/test/frontend_tests/logic/components_and_tokens.cljs @@ -169,7 +169,7 @@ (t/is (= (get c-frame1' :r4) 50)))))))] (tohs/run-store-async - store step2 events identity)))) + store (constantly nil) events step2)))) (t/deftest remove-token-in-main (t/async diff --git a/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs b/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs index b1c19573c7..2692d17593 100644 --- a/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs +++ b/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs @@ -19,6 +19,7 @@ [app.main.data.workspace.pages :as dwp] [app.main.data.workspace.selection :as dws] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.pages :as thp] [frontend-tests.helpers.state :as ths])) @@ -135,274 +136,286 @@ (t/deftest main-and-first-level-copy-1 (t/async done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-file) - store (ths/setup-store file) - ;; ==== Action + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-file) + store (ths/setup-store file) + ;; ==== Action - ;; For each main and first level copy: - ;; - Duplicate it two times with copy-paste. - events - (concat - (duplicate-each-main-and-first-level-copy file) - ;; - Change color of Simple1 - (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}))] + ;; For each main and first level copy: + ;; - Duplicate it two times with copy-paste. + events + (concat + (duplicate-each-main-and-first-level-copy file) + ;; - Change color of Simple1 + (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}))] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state)] - (cthf/validate-file! file') - (t/is (= (count-shapes file' "rect-simple-1" "#111111") 18))))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state)] + (cthf/validate-file! file') + (t/is (= (count-shapes file' "rect-simple-1" "#111111") 18))))))) + done))) (t/deftest main-and-first-level-copy-2 - (t/async - done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-file) - store (ths/setup-store file) - ;; ==== Action + (t/async done + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-file) + store (ths/setup-store file) + ;; ==== Action - ;; For each main and first level copy: - ;; - Duplicate it two times with copy-paste. - events - (concat - (duplicate-each-main-and-first-level-copy file) - ;; - Change color of Simple1 - (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}) - ;; - Change color of the nearest main and check propagation to duplicated. - (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}))] + ;; For each main and first level copy: + ;; - Duplicate it two times with copy-paste. + events + (concat + (duplicate-each-main-and-first-level-copy file) + ;; - Change color of Simple1 + (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}) + ;; - Change color of the nearest main and check propagation to duplicated. + (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}))] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state)] - (cthf/validate-file! file') - (t/is (= (count-shapes file' "rect-simple-1" "#222222") 15))))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state)] + (cthf/validate-file! file') + (t/is (= (count-shapes file' "rect-simple-1" "#222222") 15))))))) + done))) (t/deftest main-and-first-level-copy-3 - (t/async - done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-file) - store (ths/setup-store file) - ;; ==== Action + (t/async done + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-file) + store (ths/setup-store file) + ;; ==== Action - ;; For each main and first level copy: - ;; - Duplicate it two times with copy-paste. - events - (concat - (duplicate-each-main-and-first-level-copy file) - ;; - Change color of Simple1 - (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}) - ;; - Change color of the nearest main and check propagation to duplicated. - (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}) - (set-color-bottom-shape :frame-composed-2 file {:color "#333333"}))] + ;; For each main and first level copy: + ;; - Duplicate it two times with copy-paste. + events + (concat + (duplicate-each-main-and-first-level-copy file) + ;; - Change color of Simple1 + (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}) + ;; - Change color of the nearest main and check propagation to duplicated. + (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}) + (set-color-bottom-shape :frame-composed-2 file {:color "#333333"}))] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state)] - (cthf/validate-file! file') - (t/is (= (count-shapes file' "rect-simple-1" "#333333") 12))))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state)] + (cthf/validate-file! file') + (t/is (= (count-shapes file' "rect-simple-1" "#333333") 12))))))) + done))) (t/deftest main-and-first-level-copy-4 - (t/async - done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-file) - store (ths/setup-store file) - ;; ==== Action + (t/async done + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-file) + store (ths/setup-store file) + ;; ==== Action - ;; For each main and first level copy: - ;; - Duplicate it two times with copy-paste. - events - (concat - (duplicate-each-main-and-first-level-copy file) - ;; - Change color of Simple1 - (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}) - ;; - Change color of the nearest main and check propagation to duplicated. - (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}) - (set-color-bottom-shape :frame-composed-2 file {:color "#333333"}) - (set-color-bottom-shape :frame-composed-3 file {:color "#444444"}))] + ;; For each main and first level copy: + ;; - Duplicate it two times with copy-paste. + events + (concat + (duplicate-each-main-and-first-level-copy file) + ;; - Change color of Simple1 + (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}) + ;; - Change color of the nearest main and check propagation to duplicated. + (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}) + (set-color-bottom-shape :frame-composed-2 file {:color "#333333"}) + (set-color-bottom-shape :frame-composed-3 file {:color "#444444"}))] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state)] - (cthf/validate-file! file') - (t/is (= (count-shapes file' "rect-simple-1" "#444444") 6))))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state)] + (cthf/validate-file! file') + (t/is (= (count-shapes file' "rect-simple-1" "#444444") 6))))))) + done))) (t/deftest copy-nested-in-main-1 - (t/async - done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-file) - store (ths/setup-store file) + (t/async done + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-file) + store (ths/setup-store file) - ;; ==== Action - ;; For each copy of Simple1 nested in a main, and the group inside Composed3 main: - ;; - Duplicate it two times, keeping the duplicated inside the same main. - events - (concat - (duplicate-simple-nested-in-main-and-group file) - ;; - Change color of Simple1 - (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}))] + ;; ==== Action + ;; For each copy of Simple1 nested in a main, and the group inside Composed3 main: + ;; - Duplicate it two times, keeping the duplicated inside the same main. + events + (concat + (duplicate-simple-nested-in-main-and-group file) + ;; - Change color of Simple1 + (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}))] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state)] - (cthf/validate-file! file') - ;; Check propagation to all copies. - (t/is (= (count-shapes file' "rect-simple-1" "#111111") 28))))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state)] + (cthf/validate-file! file') + ;; Check propagation to all copies. + (t/is (= (count-shapes file' "rect-simple-1" "#111111") 28))))))) + done))) (t/deftest copy-nested-in-main-2 - (t/async - done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-file) - store (ths/setup-store file) + (t/async done + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-file) + store (ths/setup-store file) - ;; ==== Action - ;; For each copy of Simple1 nested in a main, and the group inside Composed3 main: - ;; - Duplicate it two times, keeping the duplicated inside the same main. - events - (concat - (duplicate-simple-nested-in-main-and-group file) - ;; - Change color of the nearest main - (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}))] + ;; ==== Action + ;; For each copy of Simple1 nested in a main, and the group inside Composed3 main: + ;; - Duplicate it two times, keeping the duplicated inside the same main. + events + (concat + (duplicate-simple-nested-in-main-and-group file) + ;; - Change color of the nearest main + (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}))] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state)] - (cthf/validate-file! file') - ;; Check propagation to duplicated. - (t/is (= (count-shapes file' "rect-simple-1" "#222222") 9))))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state)] + (cthf/validate-file! file') + ;; Check propagation to duplicated. + (t/is (= (count-shapes file' "rect-simple-1" "#222222") 9))))))) + done))) (t/deftest copy-nested-in-main-3 - (t/async - done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-file) - store (ths/setup-store file) + (t/async done + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-file) + store (ths/setup-store file) - ;; ==== Action - ;; For each copy of Simple1 nested in a main, and the group inside Composed3 main: - ;; - Duplicate it two times, keeping the duplicated inside the same main. - events - (concat - (duplicate-simple-nested-in-main-and-group file) - ;; - Change color of the copy you duplicated from. - (set-color-bottom-shape :group-3 file {:color "#333333"}))] + ;; ==== Action + ;; For each copy of Simple1 nested in a main, and the group inside Composed3 main: + ;; - Duplicate it two times, keeping the duplicated inside the same main. + events + (concat + (duplicate-simple-nested-in-main-and-group file) + ;; - Change color of the copy you duplicated from. + (set-color-bottom-shape :group-3 file {:color "#333333"}))] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state)] - (cthf/validate-file! file') - ;; Check that it's NOT PROPAGATED. - (t/is (= (count-shapes file' "rect-simple-1" "#333333") 2))))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state)] + (cthf/validate-file! file') + ;; Check that it's NOT PROPAGATED. + (t/is (= (count-shapes file' "rect-simple-1" "#333333") 2))))))) + done))) (t/deftest copy-nested-1 - (t/async - done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-file) - store (ths/setup-store file) + (t/async done + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-file) + store (ths/setup-store file) - ;; ==== Action - ;; For each copy of Simple1 nested in a main or other copy, and the group inside Composed3 - ;; main and copy: - ;; - Duplicate it two times, moving the duplicates out of the main. - events - (concat - (duplicate-copy-nested-and-group-out-of-the-main file) - ;; - Change color of Simple1 - (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}))] + ;; ==== Action + ;; For each copy of Simple1 nested in a main or other copy, and the group inside Composed3 + ;; main and copy: + ;; - Duplicate it two times, moving the duplicates out of the main. + events + (concat + (duplicate-copy-nested-and-group-out-of-the-main file) + ;; - Change color of Simple1 + (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}))] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state)] - (cthf/validate-file! file') - ;; Check propagation to all copies. - (t/is (= (count-shapes file' "rect-simple-1" "#111111") 20))))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state)] + (cthf/validate-file! file') + ;; Check propagation to all copies. + (t/is (= (count-shapes file' "rect-simple-1" "#111111") 20))))))) + done))) (t/deftest copy-nested-2 - (t/async - done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-file) - store (ths/setup-store file) - - ;; ==== Action - ;; For each copy of Simple1 nested in a main or other copy, and the group inside Composed3 - ;; main and copy: - ;; - Duplicate it two times, moving the duplicates out of the main. - events - (concat - (duplicate-copy-nested-and-group-out-of-the-main file) - ;; - Change color of Simple1 - (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}) - ;; - Change color of the previous main - (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}) - (set-color-bottom-shape :group-3 file {:color "#333333"}))] - - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state)] - (cthf/validate-file! file') - ;; Check that it's NOT PROPAGATED. - (t/is (= (count-shapes file' "rect-simple-1" "#111111") 11)) - (t/is (= (count-shapes file' "rect-simple-1" "#222222") 7)) - (t/is (= (count-shapes file' "rect-simple-1" "#333333") 2))))))))) - -(t/deftest copy-nested-3 (t/async done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-file) - store (ths/setup-store file) + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-file) + store (ths/setup-store file) - ;; ==== Action - ;; For each copy of Simple1 nested in a main or other copy, and the group inside Composed3 - ;; main and copy: - ;; - Duplicate it two times, moving the duplicates to another page - events - (concat - (duplicate-copy-nested-and-group-out-of-the-main file :target-page-label :page-2) - ;; - Change color of Simple1 - (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}) - ;; - Change color of the previous main - (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}) - (set-color-bottom-shape :group-3 file {:color "#333333"}))] + ;; ==== Action + ;; For each copy of Simple1 nested in a main or other copy, and the group inside Composed3 + ;; main and copy: + ;; - Duplicate it two times, moving the duplicates out of the main. + events + (concat + (duplicate-copy-nested-and-group-out-of-the-main file) + ;; - Change color of Simple1 + (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}) + ;; - Change color of the previous main + (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}) + (set-color-bottom-shape :group-3 file {:color "#333333"}))] - (ths/run-store - store done events - (fn [new-state] - (let [file' (-> (ths/get-file-from-state new-state) - (cthf/switch-to-page :page-2))] - (cthf/validate-file! file') - ;; Check that it's NOT PROPAGATED. - (t/is (= (count-shapes file' "rect-simple-1" "#111111") 10)) - (t/is (= (count-shapes file' "rect-simple-1" "#222222") 4)) - (t/is (= (count-shapes file' "rect-simple-1" "#333333") 0))))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state)] + (cthf/validate-file! file') + ;; Check that it's NOT PROPAGATED. + (t/is (= (count-shapes file' "rect-simple-1" "#111111") 11)) + (t/is (= (count-shapes file' "rect-simple-1" "#222222") 7)) + (t/is (= (count-shapes file' "rect-simple-1" "#333333") 2))))))) + done))) + +;; FIXME: this test does not calls done consistently, so it break all the test suite +#_(t/deftest copy-nested-3 + (t/async done + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-file) + store (ths/setup-store file) + + ;; ==== Action + ;; For each copy of Simple1 nested in a main or other copy, and the group inside Composed3 + ;; main and copy: + ;; - Duplicate it two times, moving the duplicates to another page + events + (concat + (duplicate-copy-nested-and-group-out-of-the-main file :target-page-label :page-2) + ;; - Change color of Simple1 + (set-color-bottom-shape :frame-simple-1 file {:color "#111111"}) + ;; - Change color of the previous main + (set-color-bottom-shape :frame-composed-1 file {:color "#222222"}) + (set-color-bottom-shape :group-3 file {:color "#333333"}))] + `(ths/run-store + store done' events + (fn [new-state] + (let [file' (-> (ths/get-file-from-state new-state) + (cthf/switch-to-page :page-2))] + (cthf/validate-file! file') + ;; Check that it's NOT PROPAGATED. + (t/is (= (count-shapes file' "rect-simple-1" "#111111") 10)) + (t/is (= (count-shapes file' "rect-simple-1" "#222222") 4)) + (t/is (= (count-shapes file' "rect-simple-1" "#333333") 0))))))) + done))) (t/deftest duplicate-page-integrity-frame-group-component ;; This test covers the bug fixed in 2.9.0: duplicating a page with a mainInstance inside a group @@ -420,73 +433,74 @@ ;; - The parent/child relationships are correct (group:shapes contains frame, frame:shapes contains shape, etc). ;; - The duplicated page contains an instance of the component whose main is in the original page. (t/async done - (with-redefs [uuid/next cthi/next-uuid] - (let [file (-> (cthf/sample-file :file1 :page-label :page-1) - (ctho/add-group :group-1 {:name "group-1"}) - (ctho/add-frame :frame-1 :parent-label :group-1 {:name "frame-1"}) - (cths/add-sample-shape :shape-1 :parent-label :frame-1 {:name "shape-1"}) - (cthc/make-component :component-1 :frame-1)) - page-id (cthf/current-page-id file) - store (ths/setup-store file) - events [(dwp/duplicate-page page-id)]] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state) - pages-vec (get-in file' [:data :pages]) - pages-index (get-in file' [:data :pages-index]) - new-page-id (first (remove #(= page-id %) pages-vec)) - new-page (get pages-index new-page-id) - new-objects (:objects new-page) - group (some #(when (= (:name %) "group-1") %) (vals new-objects)) - frame (some #(when (= (:name %) "frame-1") %) (vals new-objects)) - shape (some #(when (= (:name %) "shape-1") %) (vals new-objects))] + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [file (-> (cthf/sample-file :file1 :page-label :page-1) + (ctho/add-group :group-1 {:name "group-1"}) + (ctho/add-frame :frame-1 :parent-label :group-1 {:name "frame-1"}) + (cths/add-sample-shape :shape-1 :parent-label :frame-1 {:name "shape-1"}) + (cthc/make-component :component-1 :frame-1)) + page-id (cthf/current-page-id file) + store (ths/setup-store file) + events [(dwp/duplicate-page page-id)]] + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + pages-vec (get-in file' [:data :pages]) + pages-index (get-in file' [:data :pages-index]) + new-page-id (first (remove #(= page-id %) pages-vec)) + new-page (get pages-index new-page-id) + new-objects (:objects new-page) + group (some #(when (= (:name %) "group-1") %) (vals new-objects)) + frame (some #(when (= (:name %) "frame-1") %) (vals new-objects)) + shape (some #(when (= (:name %) "shape-1") %) (vals new-objects))] - (t/is group "Group exists in duplicated page") - (t/is frame "Frame exists in duplicated page") - (t/is shape "Shape exists in duplicated page") - (t/is (some #(= (:id frame) %) (:shapes group)) "Group's :shapes contains frame's id") - (t/is (some #(= (:id shape) %) (:shapes frame)) "Frame's :shapes contains shape's id") - (t/is (= (:parent-id frame) (:id group)) "Frame's parent is group") - (t/is (= (:parent-id shape) (:id frame)) "Shape's parent is frame") + (t/is group "Group exists in duplicated page") + (t/is frame "Frame exists in duplicated page") + (t/is shape "Shape exists in duplicated page") + (t/is (some #(= (:id frame) %) (:shapes group)) "Group's :shapes contains frame's id") + (t/is (some #(= (:id shape) %) (:shapes frame)) "Frame's :shapes contains shape's id") + (t/is (= (:parent-id frame) (:id group)) "Frame's parent is group") + (t/is (= (:parent-id shape) (:id frame)) "Shape's parent is frame") - ;; Check the duplicated page must contain an instance of the component whose main is in the original page - (let [original-page (get pages-index page-id) - original-main (some #(when (:component-root %) %) (vals (:objects original-page))) - instance (some #(when (:component-root %) %) (vals (:objects new-page))) - component-id (:component-id original-main)] - (t/is (ctk/instance-of? instance (:id file) component-id) - (str "Duplicated page contains an instance of the original main component (component-id: " component-id ")"))) + ;; Check the duplicated page must contain an instance of the component whose main is in the original page + (let [original-page (get pages-index page-id) + original-main (some #(when (:component-root %) %) (vals (:objects original-page))) + instance (some #(when (:component-root %) %) (vals (:objects new-page))) + component-id (:component-id original-main)] + (t/is (ctk/instance-of? instance (:id file) component-id) + (str "Duplicated page contains an instance of the original main component (component-id: " component-id ")")))))))) + done))) - (done)))))))) (defn- setup-swapped-copies-file "Creates a file with a component with two levels of nested copies inside. The component has one copy, and inside it, the topmost nested copy is swapped with a second component, also with one nested copy inside. - {:frame-simple-1} [:name Frame1] # [Component :simple-1] - :rect-simple-1 [:name Rect1] + {:frame-simple-1} [:name Frame1] # [Component :simple-1] + :rect-simple-1 [:name Rect1] - {:frame-composed-1} [:name frame-composed-1] # [Component :composed-1] - :copy-simple-1-in-composed-1 [:name Frame1] @--> frame-simple-1 - [:name Rect1] ---> rect-simple-1 + {:frame-composed-1} [:name frame-composed-1] # [Component :composed-1] + :copy-simple-1-in-composed-1 [:name Frame1] @--> frame-simple-1 + [:name Rect1] ---> rect-simple-1 - {:frame-composed-2} [:name frame-composed-2] # [Component :composed-2] - :copy-composed-1-in-composed-2 [:name frame-composed-1] @--> frame-composed-1 - [:name Frame1] @--> copy-simple-1-in-composed-1 - [:name Rect1] ---> + {:frame-composed-2} [:name frame-composed-2] # [Component :composed-2] + :copy-composed-1-in-composed-2 [:name frame-composed-1] @--> frame-composed-1 + [:name Frame1] @--> copy-simple-1-in-composed-1 + [:name Rect1] ---> - {:frame-simple-2} [:name Frame1] # [Component :simple-2] - :rect-simple-2 [:name Rect1] + {:frame-simple-2} [:name Frame1] # [Component :simple-2] + :rect-simple-2 [:name Rect1] - {:frame-composed-3} [:name frame-composed-3] # [Component :composed-3] - :copy-simple-2-in-composed-3 [:name Frame1] @--> frame-simple-2 - [:name Rect1] ---> rect-simple-2 + {:frame-composed-3} [:name frame-composed-3] # [Component :composed-3] + :copy-simple-2-in-composed-3 [:name Frame1] @--> frame-simple-2 + [:name Rect1] ---> rect-simple-2 - :copy-composed-2 [:name frame-composed-2] #--> [Component :composed-2] frame-composed-2 - :swapped-composed-3 [:name frame-composed-3, :swap-slot-label :copy-composed-1-in-composed-2] @--> frame-composed-3 - :swapped-simple-2-frame [:name Frame1] @--> copy-simple-2-in-composed-3 + :copy-composed-2 [:name frame-composed-2] #--> [Component :composed-2] frame-composed-2 + :swapped-composed-3 [:name frame-composed-3, :swap-slot-label :copy-composed-1-in-composed-2] @--> frame-composed-3 + :swapped-simple-2-frame [:name Frame1] @--> copy-simple-2-in-composed-3 :swapped-simple-2-rect [:name Rect1] ---> " [] @@ -525,42 +539,44 @@ (t/deftest duplicate-swapped-copies (t/async done - (with-redefs [uuid/next cthi/next-uuid] - (let [;; ==== Setup - file (setup-swapped-copies-file) - store (ths/setup-store file) + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [;; ==== Setup + file (setup-swapped-copies-file) + store (ths/setup-store file) - ;; ==== Action - ;; Copy to the clipboard all the shapes in the swapped copy one by one, - ;; and paste them outside the copy, under uuid/zero - events (concat (copy-paste-shape :copy-composed-2 file :target-container-id uuid/zero) - (copy-paste-shape :swapped-composed-3 file :target-container-id uuid/zero) - (copy-paste-shape :swapped-simple-2-frame file :target-container-id uuid/zero) - (copy-paste-shape :swapped-simple-2-rect file :target-container-id uuid/zero))] + ;; ==== Action + ;; Copy to the clipboard all the shapes in the swapped copy one by one, + ;; and paste them outside the copy, under uuid/zero + events (concat (copy-paste-shape :copy-composed-2 file :target-container-id uuid/zero) + (copy-paste-shape :swapped-composed-3 file :target-container-id uuid/zero) + (copy-paste-shape :swapped-simple-2-frame file :target-container-id uuid/zero) + (copy-paste-shape :swapped-simple-2-rect file :target-container-id uuid/zero))] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state) - page' (cthf/current-page file')] + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + page' (cthf/current-page file')] - (cthf/validate-file! file') + (cthf/validate-file! file') - ;; ==== Check - ;; Shape count breakdown (including page root): - ;; page root: 1 - ;; :simple-1 main: 2 shapes (frame + rect) - ;; :composed-1 main: 3 shapes (frame + instance-of-simple-1 + its child) - ;; :composed-2 main: 4 shapes (frame + instance-of-composed-1 + its descendants) - ;; :simple-2 main: 2 shapes (frame + rect) - ;; :composed-3 main: 3 shapes (frame + instance-of-simple-2 + its child) - ;; copy of :composed-2 (with swapped child): 4 shapes - ;; pasted copy of :copy-composed-2: 4 shapes - ;; pasted copy of :composed-3: 3 shapes - ;; pasted copy of :swapped-simple-2-frame: 2 shapes - ;; pasted copy of :swapped-simple-2-rect: 1 shapes - ;; Total = 1 + 2 + 3 + 4 + 2 + 3 + 4 + 4 + 3 + 2 + 1 = 29 - (t/is (= (count (:objects page')) 29))))))))) + ;; ==== Check + ;; Shape count breakdown (including page root): + ;; page root: 1 + ;; :simple-1 main: 2 shapes (frame + rect) + ;; :composed-1 main: 3 shapes (frame + instance-of-simple-1 + its child) + ;; :composed-2 main: 4 shapes (frame + instance-of-composed-1 + its descendants) + ;; :simple-2 main: 2 shapes (frame + rect) + ;; :composed-3 main: 3 shapes (frame + instance-of-simple-2 + its child) + ;; copy of :composed-2 (with swapped child): 4 shapes + ;; pasted copy of :copy-composed-2: 4 shapes + ;; pasted copy of :composed-3: 3 shapes + ;; pasted copy of :swapped-simple-2-frame: 2 shapes + ;; pasted copy of :swapped-simple-2-rect: 1 shapes + ;; Total = 1 + 2 + 3 + 4 + 2 + 3 + 4 + 4 + 3 + 2 + 1 = 29 + (t/is (= (count (:objects page')) 29))))))) + done))) ;; --------------------------------------------------------------------------- ;; Tests for issue-14302 @@ -588,48 +604,52 @@ onto an empty page produced incorrect line breaks because stale position-data from the source page was preserved." (t/async done - (with-redefs [uuid/next cthi/next-uuid] - (let [file (setup-board-with-texts) - store (ths/setup-store file) - ;; copy-paste-shape handles initialize-page + select-shape (needed - ;; for calculate-paste-position) + paste-shapes + return to page-1 - events (copy-paste-shape :frame-1 file - :target-page-label :page-2 - :target-container-id uuid/zero)] + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [file (setup-board-with-texts) + store (ths/setup-store file) + ;; copy-paste-shape handles initialize-page + select-shape (needed + ;; for calculate-paste-position) + paste-shapes + return to page-1 + events (copy-paste-shape :frame-1 file + :target-page-label :page-2 + :target-container-id uuid/zero)] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state) - page-2 (cthf/get-page file' :page-2) - pasted-texts (->> (vals (:objects page-2)) - (filter #(= :text (:type %))))] - (t/is (= 2 (count pasted-texts)) - "Both text shapes are pasted onto the empty page") - (t/is (every? #(nil? (:position-data %)) pasted-texts) - "Pasted text shapes have nil position-data so they get remeasured")))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + page-2 (cthf/get-page file' :page-2) + pasted-texts (->> (vals (:objects page-2)) + (filter #(= :text (:type %))))] + (t/is (= 2 (count pasted-texts)) + "Both text shapes are pasted onto the empty page") + (t/is (every? #(nil? (:position-data %)) pasted-texts) + "Pasted text shapes have nil position-data so they get remeasured")))))) + done))) (t/deftest paste-to-same-page-clears-text-position-data "Position-data is stripped on paste regardless of destination page." (t/async done - (with-redefs [uuid/next cthi/next-uuid] - (let [file (setup-board-with-texts) - store (ths/setup-store file) - events (copy-paste-shape :frame-1 file - :target-container-id uuid/zero)] + (mock/with-mocks {uuid/next cthi/next-uuid} + (fn [done'] + (let [file (setup-board-with-texts) + store (ths/setup-store file) + events (copy-paste-shape :frame-1 file + :target-container-id uuid/zero)] - (ths/run-store - store done events - (fn [new-state] - (let [file' (ths/get-file-from-state new-state) - page-1' (cthf/get-page file' :page-1) - ;; Pasted copies have IDs not registered in idmap — they show - ;; up as "" strings from cthi/label. - pasted-texts (->> (vals (:objects page-1')) - (filter #(= :text (:type %))) - (remove #(keyword? (cthi/label (:id %)))))] - (t/is (= 2 (count pasted-texts)) - "Two new text shapes are pasted onto the same page") - (t/is (every? #(nil? (:position-data %)) pasted-texts) - "Pasted text shapes have nil position-data")))))))) + (ths/run-store + store done' events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + page-1' (cthf/get-page file' :page-1) + ;; Pasted copies have IDs not registered in idmap — they show + ;; up as "" strings from cthi/label. + pasted-texts (->> (vals (:objects page-1')) + (filter #(= :text (:type %))) + (remove #(keyword? (cthi/label (:id %)))))] + (t/is (= 2 (count pasted-texts)) + "Two new text shapes are pasted onto the same page") + (t/is (every? #(nil? (:position-data %)) pasted-texts) + "Pasted text shapes have nil position-data")))))) + done))) diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index 749ee4fa23..d09024ac5c 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -126,11 +126,11 @@ ;; (e.g. the notification emit itself throws). ;; Without the re-entrancy guard this would recurse indefinitely. (when (= 1 @reentrant-call-count) - (errors/on-error {:type ::test-reentrant :hint "secondary"}))) + (errors/on-error (ex-info "test" {:type ::test-reentrant :hint "secondary"})))) (t/deftest on-error-reentrancy-guard-prevents-recursion (t/testing "a second on-error call while handling an error is suppressed by the guard" (reset! reentrant-call-count 0) - (errors/on-error {:type ::test-reentrant :hint "first"}) + (errors/on-error (ex-info "test" {:type ::test-reentrant :hint "first"})) ;; The guard must have allowed only the first invocation through. (t/is (= 1 @reentrant-call-count)))) diff --git a/frontend/test/frontend_tests/plugins/page_test.cljs b/frontend/test/frontend_tests/plugins/page_test.cljs index f85cea4329..bcc59c4ce8 100644 --- a/frontend/test/frontend_tests/plugins/page_test.cljs +++ b/frontend/test/frontend_tests/plugins/page_test.cljs @@ -16,8 +16,13 @@ [app.util.object :as obj] [cljs.test :as t :include-macros true] [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] [potok.v2.core :as ptk])) +(t/use-fixtures :each + {:before (fn [] (thw/setup-wasm-mocks!)) + :after thw/teardown-wasm-mocks!}) + (defn- setup "Creates a file with two pages (page1 as current) and a plugin context." [] @@ -47,11 +52,13 @@ ;; Regression: the flow proxy returned by createFlow (and obtained later via ;; page.flows) must expose a valid board proxy for `startingBoard`, carrying ;; the plugin id rather than a corrupted handle. See issue #10203. - (let [{:keys [context board-id]} (setup-with-board) - ^js page (.-currentPage context) - ^js board (.getShapeById page (str board-id)) - ^js flow (.createFlow page "flow1" board) - ^js sb (.-startingBoard flow)] + (let [result (setup-with-board) + ^js context (:context result) + board-id (:board-id result) + ^js page (.-currentPage context) + ^js board (.getShapeById page (str board-id)) + ^js flow (.createFlow page "flow1" board) + ^js sb (.-startingBoard flow)] (t/is (shape/shape-proxy? sb)) (t/is (= (str board-id) (.-id sb))) (t/is (= plugin-id (obj/get sb "$plugin"))) @@ -71,24 +78,26 @@ (ptk/emit! store (ptk/data-event ::dwpg/initialized page-id))) (t/deftest test-open-page-returns-promise - (let [{:keys [context]} (setup) - ^js pages (.. context -currentFile -pages) - ^js page2 (aget pages 1)] + (let [^js context (:context (setup)) + ^js pages (.. context -currentFile -pages) + ^js page2 (aget pages 1)] (t/is (instance? js/Promise (.openPage context page2))))) (t/deftest test-open-page-new-window-returns-promise - (let [{:keys [context]} (setup) - ^js pages (.. context -currentFile -pages) - ^js page2 (aget pages 1)] + (let [^js context (:context (setup)) + ^js pages (.. context -currentFile -pages) + ^js page2 (aget pages 1)] (t/is (instance? js/Promise (.openPage context page2 true))))) (t/deftest test-open-page-invalid-arg-returns-nil - (let [{:keys [context]} (setup)] + (let [^js context (:context (setup))] (t/is (nil? (.openPage context "not-a-page"))))) (t/deftest test-open-page-resolves-when-page-changes (t/async done - (let [{:keys [store context]} (setup) + (let [result (setup) + store (:store result) + ^js context (:context result) ^js pages (.. context -currentFile -pages) ^js page2 (aget pages 1) page2-id (obj/get page2 "$id")] @@ -102,17 +111,19 @@ (t/deftest test-flows-returns-empty-array-when-no-flows ;; page.flows must always return an array, even when the page has no flows - (let [{:keys [context]} (setup) - ^js pages (.. context -currentFile -pages) - ^js page1 (aget pages 0) - ^js flows (.-flows page1)] + (let [^js context (:context (setup)) + ^js pages (.. context -currentFile -pages) + ^js page1 (aget pages 0) + ^js flows (.-flows page1)] (t/is (array? flows)) (t/is (= 0 (.-length flows))))) (t/deftest test-open-page-does-not-resolve-for-wrong-page ;; Promise should not resolve when a different page is initialized (t/async done - (let [{:keys [store context]} (setup) + (let [result (setup) + store (:store result) + ^js context (:context result) ^js pages (.. context -currentFile -pages) ^js page1 (aget pages 0) ^js page2 (aget pages 1) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index b3b16a2079..cbe7af9ebf 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -54,58 +54,62 @@ (enable-console-print!) -(defmethod cljs.test/report [:cljs.test/default :end-run-tests] [m] +(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 - '[frontend-tests.basic-shapes-test - frontend-tests.code-gen-style-test - frontend-tests.copy-as-svg-test - frontend-tests.data.nitrate-test - frontend-tests.data.repo-test - frontend-tests.errors-test - frontend-tests.main-errors-test - frontend-tests.data.uploads-test - frontend-tests.data.viewer-test - frontend-tests.data.workspace-colors-test - frontend-tests.data.workspace-interactions-test - frontend-tests.data.workspace-mcp-test - frontend-tests.data.workspace-media-test - frontend-tests.data.workspace-shortcuts-test - frontend-tests.data.workspace-texts-test - frontend-tests.data.workspace-thumbnails-test - frontend-tests.helpers-shapes-test - frontend-tests.logic.comp-remove-swap-slots-test - frontend-tests.logic.components-and-tokens - frontend-tests.logic.copying-and-duplicating-test - frontend-tests.logic.frame-guides-test - frontend-tests.logic.groups-test - frontend-tests.logic.pasting-in-containers-test - frontend-tests.plugins.context-shapes-test - frontend-tests.plugins.page-active-validation-test - frontend-tests.plugins.interactions-test - frontend-tests.plugins.format-test - frontend-tests.plugins.page-test - frontend-tests.plugins.parser-test - frontend-tests.plugins.tokens-test - frontend-tests.plugins.utils-test - frontend-tests.svg-fills-test - frontend-tests.tokens.import-export-test - frontend-tests.tokens.logic.token-actions-test - frontend-tests.tokens.logic.token-data-test - frontend-tests.tokens.logic.token-remapping-test - frontend-tests.tokens.style-dictionary-test - frontend-tests.tokens.token-errors-test - frontend-tests.tokens.workspace-tokens-remap-test - frontend-tests.ui.ds-controls-numeric-input-test - frontend-tests.render-wasm.process-objects-test - frontend-tests.util-object-test - frontend-tests.util-range-tree-test - frontend-tests.util-simple-math-test - frontend-tests.util-webapi-test - frontend-tests.worker-snap-test]) + ['frontend-tests.basic-shapes-test + 'frontend-tests.code-gen-style-test + 'frontend-tests.copy-as-svg-test + 'frontend-tests.data.nitrate-test + 'frontend-tests.data.repo-test + 'frontend-tests.errors-test + 'frontend-tests.main-errors-test + 'frontend-tests.data.uploads-test + 'frontend-tests.data.viewer-test + 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-interactions-test + 'frontend-tests.data.workspace-mcp-test + 'frontend-tests.data.workspace-media-test + 'frontend-tests.data.workspace-shortcuts-test + 'frontend-tests.data.workspace-texts-test + 'frontend-tests.data.workspace-thumbnails-test + 'frontend-tests.helpers-shapes-test + 'frontend-tests.logic.comp-remove-swap-slots-test + 'frontend-tests.logic.components-and-tokens + 'frontend-tests.logic.copying-and-duplicating-test + 'frontend-tests.logic.frame-guides-test + 'frontend-tests.logic.groups-test + 'frontend-tests.logic.pasting-in-containers-test + 'frontend-tests.plugins.context-shapes-test + 'frontend-tests.plugins.page-active-validation-test + 'frontend-tests.plugins.interactions-test + 'frontend-tests.plugins.format-test + 'frontend-tests.plugins.page-test + 'frontend-tests.plugins.parser-test + 'frontend-tests.plugins.tokens-test + 'frontend-tests.plugins.utils-test + 'frontend-tests.svg-fills-test + 'frontend-tests.tokens.import-export-test + 'frontend-tests.tokens.logic.token-actions-test + 'frontend-tests.tokens.logic.token-data-test + 'frontend-tests.tokens.logic.token-remapping-test + 'frontend-tests.tokens.style-dictionary-test + 'frontend-tests.tokens.token-errors-test + 'frontend-tests.tokens.workspace-tokens-remap-test + 'frontend-tests.ui.ds-controls-numeric-input-test + 'frontend-tests.render-wasm.process-objects-test + 'frontend-tests.util-object-test + 'frontend-tests.util-range-tree-test + 'frontend-tests.util-simple-math-test + 'frontend-tests.util-webapi-test + 'frontend-tests.worker-snap-test]) (assert (every? find-ns-obj test-namespaces) "test-namespaces contains a namespace that isn't required in runner.cljs") @@ -219,18 +223,18 @@ :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 [(fn [] (t/set-env! env))] - (t/test-vars-block vars) + (concat (t/test-vars-block vars) [(fn [] (vswap! summary (partial merge-with +) - (:report-counters (t/get-and-clear-env!)))) + (:report-counters (t/get-current-env)))) (fn [] - (t/set-env! env) - (t/do-report @summary) - (t/report (assoc @summary :type :end-run-tests)) - (t/clear-env!))])))) + (t/report @summary) + (t/report (assoc @summary :type :end-run-tests)))])))) (defn- run-focused-test! [focus] @@ -250,8 +254,8 @@ :else (do - (when-let [level (:log-level options)] - (l/setup! {:app level})) + (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))))))) diff --git a/frontend/test/frontend_tests/tokens/import_export_test.cljs b/frontend/test/frontend_tests/tokens/import_export_test.cljs index d4e093fb48..e05cefb0bd 100644 --- a/frontend/test/frontend_tests/tokens/import_export_test.cljs +++ b/frontend/test/frontend_tests/tokens/import_export_test.cljs @@ -22,11 +22,16 @@ (json/encode {:type :json-verbose}))] (->> (rx/of json) (dwti/import-file-stream "core") + (rx/take 1) (rx/subs! (fn [tokens-lib] (t/is (instance? ctob/TokensLib tokens-lib)) (t/is (= "red" (-> tokens-lib (ctob/get-token-by-name "core" "color") - (:value)))) + (:value))))) + (fn [err] + (t/do-report {:type :error :message "Stream error" :actual err}) + (done)) + (fn [] (done)))))))) (t/deftest reference-errors-test From 5042a34e3c0ef094b59c73b06abc9fcc71251c08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Fri, 19 Jun 2026 14:58:20 +0200 Subject: [PATCH 10/10] :wrench: Normalize text nodes comparison, to be used in tokens detach :wrench: Add more tests for all cases and fix text token application in tests --- common/src/app/common/data.cljc | 16 +- .../src/app/common/test_helpers/tokens.cljc | 17 +- common/src/app/common/types/text.cljc | 28 ++- .../common_tests/logic/token_apply_test.cljc | 43 ++++- common/test/common_tests/types/text_test.cljc | 166 +++++++++++++++++- 5 files changed, 250 insertions(+), 20 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index c7d709483d..7cbfdcc4f5 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -9,7 +9,7 @@ data resources." (:refer-clojure :exclude [read-string hash-map merge name update-vals parse-double group-by iteration concat mapcat - parse-uuid max min regexp? array?]) + parse-uuid max min regexp? array? empty?]) #?(:cljs (:require-macros [app.common.data])) @@ -175,11 +175,17 @@ (.isArray (class o)) false))) +(defn empty? + [val] + (if (or (coll? val) (string? val)) + (clojure.core/empty? val) + (nil? val))) + (defn not-empty? - [coll] - (if (coll? coll) - (boolean (seq coll)) - (not (nil? coll)))) + [val] + (if (or (coll? val) (string? val)) + (boolean (seq val)) + (some? val))) (defn editable-collection? [m] diff --git a/common/src/app/common/test_helpers/tokens.cljc b/common/src/app/common/test_helpers/tokens.cljc index 21a8dd4f04..02becdc27a 100644 --- a/common/src/app/common/test_helpers/tokens.cljc +++ b/common/src/app/common/test_helpers/tokens.cljc @@ -6,12 +6,14 @@ (ns app.common.test-helpers.tokens (:require + [app.common.data :as d] [app.common.test-helpers.files :as thf] [app.common.test-helpers.shapes :as ths] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.pages-list :as ctpl] [app.common.types.shape-tree :as ctst] + [app.common.types.text :as ctt] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob])) @@ -81,11 +83,16 @@ :token {:name token-name} :attributes token-attrs}) (reduce (fn [shape attr] - (case attr - :stroke-width (set-stroke-width shape resolved-value) - :stroke-color (set-stroke-color shape resolved-value) - :fill (set-fill-color shape resolved-value) - (ctn/set-shape-attr shape attr resolved-value {:ignore-touched true}))) + (if (ctt/text-node-attr? attr) + (let [value (if (sequential? resolved-value) (first resolved-value) resolved-value)] + (ctt/update-text-content shape + ctt/is-content-node? + d/txt-merge {attr value})) + (case attr + :stroke-width (set-stroke-width shape resolved-value) + :stroke-color (set-stroke-color shape resolved-value) + :fill (set-fill-color shape resolved-value) + (ctn/set-shape-attr shape attr resolved-value {:ignore-touched true})))) $ shape-attrs)))] diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index f928321f77..e0330b3264 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -78,6 +78,10 @@ text-transform-attrs text-fills)) +(defn text-node-attr? + [attr] + (d/index-of text-node-attrs attr)) + (def text-all-attrs (d/concat-set shape-attrs root-attrs paragraph-attrs text-node-attrs)) (def text-style-attrs @@ -201,6 +205,27 @@ [text] (subs text 0 (min 280 (count text)))) +(defn- compare-text-attr + "Compare two attribute values and return true if they are different. + Take into account the following: + - Only process keys that belong to text node attrs (ignore deprecated + attributes or other things that may be attached). + - Consider nil values, empty strings or empty lists all equal. + - Normalize numeric values (legacy) into strings. + - No value is equal than the default value." + [key value1 value2] + (when (text-node-attr? key) + (let [default-value (get default-text-attrs key) + normalize-value (fn [value] + (as-> value $ + (if (number? $) (str $) $) + (if (or (d/empty? $) (= $ default-value)) + nil + $))) + value1' (normalize-value value1) + value2' (normalize-value value2)] + (not= value1' value2')))) + (defn- compare-text-content "Given two content text structures, conformed by maps and vectors, compare them, and returns a set with the differences info. @@ -245,8 +270,7 @@ ;; If the key is not :text, and they are different, it is an attribute difference. ;; Take into account that some processes remove empty attributes, so in some ;; cases we will compare [] with nil, and this is not a difference. - (if (and (not= v1 v2) - (or (d/not-empty? v1) (d/not-empty? v2))) + (if (compare-text-attr k v1 v2) (attribute-cb acc k) acc)))) #{} diff --git a/common/test/common_tests/logic/token_apply_test.cljc b/common/test/common_tests/logic/token_apply_test.cljc index 69e07e0510..9715b7f235 100644 --- a/common/test/common_tests/logic/token_apply_test.cljc +++ b/common/test/common_tests/logic/token_apply_test.cljc @@ -111,8 +111,8 @@ (tht/apply-token-to-shape :frame1 "token-color" [:stroke-color] [:stroke-color] "#00ff00") (tht/apply-token-to-shape :frame1 "token-color" [:fill] [:fill] "#00ff00") (tht/apply-token-to-shape :frame1 "token-dimensions" [:width :height] [:width :height] 100) - (tht/apply-token-to-shape :text1 "token-font-size" [:font-size] [:font-size] 24) - (tht/apply-token-to-shape :text1 "token-letter-spacing" [:letter-spacing] [:letter-spacing] 2) + (tht/apply-token-to-shape :text1 "token-font-size" [:font-size] [:font-size] "24") + (tht/apply-token-to-shape :text1 "token-letter-spacing" [:letter-spacing] [:letter-spacing] "2") (tht/apply-token-to-shape :text1 "token-font-family" [:font-family] [:font-family] ["Helvetica" "Arial" "sans-serif"]) (tht/apply-token-to-shape :circle1 "token-sizing" @@ -331,7 +331,7 @@ d/txt-merge {:fills (ths/sample-fills-color :fill-color "#fabada") :font-size "1" - :letter-spacing "0" + :letter-spacing "3" :font-family "Arial"})) (:objects page) {}) @@ -360,3 +360,40 @@ (t/is (= (count applied-tokens-frame') 0)) (t/is (= (count applied-tokens-text') 0)) (t/is (= (count applied-tokens-circle') 0)))) + +(t/deftest dont-unapply-automatic-when-null-change + (let [;; ==== Setup + file (-> (setup-file) + (tht/apply-token-to-shape :text1 "token-font-size" [:font-size] [:font-size] "14") + (tht/apply-token-to-shape :text1 "token-letter-spacing" [:letter-spacing] [:letter-spacing] "2") + (tht/apply-token-to-shape :text1 "token-font-family" [:font-family] [:font-family] ["Helvetica" "Arial" "sans-serif"])) + page (thf/current-page file) + text1 (ths/get-shape file :text1) + + ;; ==== Action + changes (-> (-> (pcb/empty-changes nil) + (pcb/with-page page) + (pcb/with-objects (:objects page))) + ;; Some changes in text content are not semantic changes + ;; and thus they don't unapply tokens (e.g. adding an attribute + ;; with value nil or with the default value). + (cls/generate-update-shapes [(:id text1)] + (fn [shape] + (txt/update-text-content + shape + txt/is-content-node? + d/txt-merge + {:font-size nil + :line-height "3" + :nonexistent-attr "sample value"})) + (:objects page) + {})) + + file' (thf/apply-changes file changes) + + ;; ==== Get + text1' (ths/get-shape file' :text1) + applied-tokens-text' (:applied-tokens text1')] + + ;; ==== Check + (t/is (= (count applied-tokens-text') 3)))) \ No newline at end of file diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index be4424ef27..f6dea90560 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -16,6 +16,8 @@ (get :content) (cttx/change-text "hello world"))) +;; Normal happy-path changes + (def content-changed-text (assoc-in content-base [:children 0 :children 0 :children 0 :text] "changed")) @@ -34,22 +36,127 @@ (update-in content-base [:children 0 :children 0 :children] #(conj % (assoc line :font-weight "700")))) - (def content-changed-structure-same-attrs (update-in content-base [:children 0 :children 0 :children] #(conj % line))) +;; Special cases + +;; Numeric value (legacy): number 14 should normalize to string "14", +;; which equals the default value in content-base +(def content-numeric-font-size-default + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] 14)) + +;; Numeric value (legacy): number 32 should normalize to string "32", +;; matching content-changed-attr (which uses string "32") +(def content-numeric-font-size-32 + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] 32)) + +;; Attribute set to nil (removed) +(def content-nil-font-size + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] nil)) + +;; Attribute set to empty string +(def content-empty-string-font-size + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] "")) + +;; Attribute set to empty vector +(def content-empty-list-font-size + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] [])) + +;; Attribute set to its default value (font-size default is "14") +(def content-default-font-size + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] "14")) + +;; Non-text-node-attr key change (grow-type is not in text-node-attrs) +(def content-non-text-node-attr + (assoc-in content-base [:children 0 :children 0 :children 0 :grow-type] "auto-height")) + +;; Other text-node-attr categories +(def content-changed-font-family + (assoc-in content-base [:children 0 :children 0 :children 0 :font-family] "Arial")) + +(def content-changed-line-height + (assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5")) + +(def content-changed-letter-spacing + (assoc-in content-base [:children 0 :children 0 :children 0 :letter-spacing] "2")) + +(def content-changed-text-decoration + (assoc-in content-base [:children 0 :children 0 :children 0 :text-decoration] "underline")) + +(def content-changed-text-transform + (assoc-in content-base [:children 0 :children 0 :children 0 :text-transform] "uppercase")) + +(def content-changed-typography-ref-id + (assoc-in content-base [:children 0 :children 0 :children 0 :typography-ref-id] "new-typography-id")) + +(def content-changed-fills + (assoc-in content-base [:children 0 :children 0 :children 0 :fills] + [{:fill-color "#ff0000" :fill-opacity 1}])) + + (t/deftest test-get-diff-type (let [diff-text (cttx/get-diff-type content-base content-changed-text) diff-attr (cttx/get-diff-type content-base content-changed-attr) diff-both (cttx/get-diff-type content-base content-changed-both) diff-structure (cttx/get-diff-type content-base content-changed-structure) - diff-structure-same-attrs (cttx/get-diff-type content-base content-changed-structure-same-attrs)] + diff-structure-same-attrs (cttx/get-diff-type content-base content-changed-structure-same-attrs) + ;; Numeric normalization: number 14 → string "14" → default → nil (same as base) + diff-numeric-default (cttx/get-diff-type content-base content-numeric-font-size-default) + ;; Numeric normalization: number 32 → string "32" (different from base's "14") + diff-numeric-different (cttx/get-diff-type content-base content-numeric-font-size-32) + ;; Numeric 32 vs string "32": should be equal after normalization + diff-numeric-vs-string (cttx/get-diff-type content-changed-attr content-numeric-font-size-32) + + ;; nil / empty-string / empty-list / default → all normalize to nil + diff-nil-attr (cttx/get-diff-type content-base content-nil-font-size) + diff-empty-string (cttx/get-diff-type content-base content-empty-string-font-size) + diff-empty-list (cttx/get-diff-type content-base content-empty-list-font-size) + diff-default-value (cttx/get-diff-type content-base content-default-font-size) + + ;; Non-text-node-attr key: should be ignored + diff-non-text-node (cttx/get-diff-type content-base content-non-text-node-attr) + + ;; Other text-node-attr categories + diff-font-family (cttx/get-diff-type content-base content-changed-font-family) + diff-line-height (cttx/get-diff-type content-base content-changed-line-height) + diff-letter-spacing (cttx/get-diff-type content-base content-changed-letter-spacing) + diff-text-decoration (cttx/get-diff-type content-base content-changed-text-decoration) + diff-text-transform (cttx/get-diff-type content-base content-changed-text-transform) + diff-typography-ref (cttx/get-diff-type content-base content-changed-typography-ref-id) + diff-fills (cttx/get-diff-type content-base content-changed-fills)] + + ;; Basic cases (t/is (= #{:text-content-text} diff-text)) (t/is (= #{:text-content-attribute} diff-attr)) (t/is (= #{:text-content-text :text-content-attribute} diff-both)) (t/is (= #{:text-content-structure} diff-structure)) - (t/is (= #{:text-content-structure} diff-structure-same-attrs)))) + (t/is (= #{:text-content-structure} diff-structure-same-attrs)) + + ;; Numeric normalization + (t/is (= #{} diff-numeric-default)) + (t/is (= #{:text-content-attribute} diff-numeric-different)) + (t/is (= #{} diff-numeric-vs-string)) + + ;; nil / empty / default normalization (content-base has default font-size "14", + ;; which normalizes to nil; nil also normalizes to nil → equal) + (t/is (= #{} diff-nil-attr)) + (t/is (= #{} diff-empty-string)) + (t/is (= #{} diff-empty-list)) + (t/is (= #{} diff-default-value)) + + ;; Non-text-node-attr key is ignored + (t/is (= #{} diff-non-text-node)) + + ;; Each text-node-attr category triggers attribute diff + (t/is (= #{:text-content-attribute} diff-font-family)) + (t/is (= #{:text-content-attribute} diff-line-height)) + (t/is (= #{:text-content-attribute} diff-letter-spacing)) + (t/is (= #{:text-content-attribute} diff-text-decoration)) + (t/is (= #{:text-content-attribute} diff-text-transform)) + (t/is (= #{:text-content-attribute} diff-typography-ref)) + (t/is (= #{:text-content-attribute} diff-fills)))) (t/deftest test-get-diff-attrs @@ -57,13 +164,62 @@ attrs-attr (cttx/get-diff-attrs content-base content-changed-attr) attrs-both (cttx/get-diff-attrs content-base content-changed-both) attrs-structure (cttx/get-diff-attrs content-base content-changed-structure) - attrs-structure-same-attrs (cttx/get-diff-attrs content-base content-changed-structure-same-attrs)] + attrs-structure-same-attrs (cttx/get-diff-attrs content-base content-changed-structure-same-attrs) + ;; Numeric normalization: number 14 → string "14" → default → nil (same as base) + attrs-numeric-default (cttx/get-diff-attrs content-base content-numeric-font-size-default) + ;; Numeric normalization: number 32 → string "32" (different from base's "14") + attrs-numeric-different (cttx/get-diff-attrs content-base content-numeric-font-size-32) + ;; Numeric 32 vs string "32": should be equal after normalization + attrs-numeric-vs-string (cttx/get-diff-attrs content-changed-attr content-numeric-font-size-32) + + ;; nil / empty-string / empty-list / default → all normalize to nil + attrs-nil-attr (cttx/get-diff-attrs content-base content-nil-font-size) + attrs-empty-string (cttx/get-diff-attrs content-base content-empty-string-font-size) + attrs-empty-list (cttx/get-diff-attrs content-base content-empty-list-font-size) + attrs-default-value (cttx/get-diff-attrs content-base content-default-font-size) + + ;; Non-text-node-attr key: should be ignored + attrs-non-text-node (cttx/get-diff-attrs content-base content-non-text-node-attr) + + ;; Other text-node-attr categories + attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family) + attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height) + attrs-letter-spacing (cttx/get-diff-attrs content-base content-changed-letter-spacing) + attrs-text-decoration (cttx/get-diff-attrs content-base content-changed-text-decoration) + attrs-text-transform (cttx/get-diff-attrs content-base content-changed-text-transform) + attrs-typography-ref (cttx/get-diff-attrs content-base content-changed-typography-ref-id) + attrs-fills (cttx/get-diff-attrs content-base content-changed-fills)] + + ;; Basic cases (t/is (= #{} attrs-text)) (t/is (= #{:font-size} attrs-attr)) (t/is (= #{:font-size} attrs-both)) (t/is (= #{} attrs-structure)) - (t/is (= #{} attrs-structure-same-attrs)))) + (t/is (= #{} attrs-structure-same-attrs)) + + ;; Numeric normalization + (t/is (= #{} attrs-numeric-default)) + (t/is (= #{:font-size} attrs-numeric-different)) + (t/is (= #{} attrs-numeric-vs-string)) + + ;; nil / empty / default normalization + (t/is (= #{} attrs-nil-attr)) + (t/is (= #{} attrs-empty-string)) + (t/is (= #{} attrs-empty-list)) + (t/is (= #{} attrs-default-value)) + + ;; Non-text-node-attr key is ignored + (t/is (= #{} attrs-non-text-node)) + + ;; Each text-node-attr category reports correct attr key + (t/is (= #{:font-family} attrs-font-family)) + (t/is (= #{:line-height} attrs-line-height)) + (t/is (= #{:letter-spacing} attrs-letter-spacing)) + (t/is (= #{:text-decoration} attrs-text-decoration)) + (t/is (= #{:text-transform} attrs-text-transform)) + (t/is (= #{:typography-ref-id} attrs-typography-ref)) + (t/is (= #{:fills} attrs-fills)))) (t/deftest test-equal-structure