From f656266e5c38622fb8f01c925d93bf8a7c698e1d Mon Sep 17 00:00:00 2001 From: raguirref Date: Wed, 8 Apr 2026 11:30:19 -0600 Subject: [PATCH 01/39] :sparkles: Fix builder bool and media handling Fixes three concrete builder issues in common/files/builder:\n- Use bool type from shape when selecting style source for difference bools\n- Persist :strokes correctly (fix typo :stroks)\n- Validate add-file-media params after assigning default id\n\nAlso adds regression tests in common-tests.files-builder-test and registers them in runner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: raguirref --- common/src/app/common/files/builder.cljc | 12 ++-- .../test/common_tests/files_builder_test.cljc | 72 +++++++++++++++++++ common/test/common_tests/runner.cljc | 2 + dev_server.pid | 1 + 4 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 common/test/common_tests/files_builder_test.cljc create mode 100644 dev_server.pid diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 4354986b8d..84c0381f0d 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -356,7 +356,7 @@ :code :empty-children :hint "expected a group with at least one shape for creating a bool")) - (let [head (if (= type :difference) + (let [head (if (= (:bool-type bool-shape) :difference) (first children) (last children)) fills (if (and (contains? head :svg-attrs) (empty? (:fills head))) @@ -364,7 +364,7 @@ (get head :fills))] (-> bool-shape (assoc :fills fills) - (assoc :stroks (get head :strokes)))))) + (assoc :strokes (get head :strokes)))))) (defn add-bool [state params] @@ -573,10 +573,10 @@ file-id (get state ::current-file-id) - {:keys [id width height name]} - (-> params - (update :id default-uuid) - (check-add-file-media params))] + {:keys [id width height name]} + (-> params + (update :id default-uuid) + (check-add-file-media))] (-> state (update ::blobs assoc media-id blob) diff --git a/common/test/common_tests/files_builder_test.cljc b/common/test/common_tests/files_builder_test.cljc new file mode 100644 index 0000000000..23dd6c78cc --- /dev/null +++ b/common/test/common_tests/files_builder_test.cljc @@ -0,0 +1,72 @@ +;; 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 common-tests.files-builder-test + (:require + [app.common.files.builder :as fb] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +(defn- stroke + [color] + [{:stroke-style :solid + :stroke-alignment :inner + :stroke-width 1 + :stroke-color color + :stroke-opacity 1}]) + +(t/deftest add-bool-uses-difference-head-style + (let [file-id (uuid/next) + page-id (uuid/next) + group-id (uuid/next) + child-a (uuid/next) + child-b (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-group {:id group-id :name "Group A"}) + (fb/add-shape {:id child-a + :type :rect + :name "A" + :x 0 + :y 0 + :width 10 + :height 10 + :strokes (stroke "#ff0000")}) + (fb/add-shape {:id child-b + :type :rect + :name "B" + :x 20 + :y 0 + :width 10 + :height 10 + :strokes (stroke "#00ff00")}) + (fb/close-group) + (fb/add-bool {:group-id group-id + :type :difference})) + bool (fb/get-shape state group-id)] + (t/is (= :bool (:type bool))) + (t/is (= (stroke "#ff0000") (:strokes bool))))) + +(t/deftest add-file-media-validates-and-persists-media + (let [file-id (uuid/next) + page-id (uuid/next) + image-id (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-file-media {:id image-id + :name "Image" + :width 128 + :height 64} + (fb/map->BlobWrapper {:mtype "image/png" + :size 42 + :blob nil}))) + media (get-in state [::fb/file-media image-id])] + (t/is (= image-id (::fb/last-id state))) + (t/is (= "Image" (:name media))) + (t/is (= 128 (:width media))) + (t/is (= 64 (:height media))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index b8a9fc8934..489e71f7ef 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -11,6 +11,7 @@ [common-tests.colors-test] [common-tests.data-test] [common-tests.files-changes-test] + [common-tests.files-builder-test] [common-tests.files-migrations-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] @@ -82,6 +83,7 @@ 'common-tests.colors-test 'common-tests.data-test 'common-tests.files-changes-test + 'common-tests.files-builder-test 'common-tests.files-migrations-test 'common-tests.geom-align-test 'common-tests.geom-bounds-map-test diff --git a/dev_server.pid b/dev_server.pid new file mode 100644 index 0000000000..a8cd695ffa --- /dev/null +++ b/dev_server.pid @@ -0,0 +1 @@ +31390 From 94c6045dd99f2c09a3a26a492a60c9f21a1dcd25 Mon Sep 17 00:00:00 2001 From: raguirref Date: Wed, 8 Apr 2026 11:30:31 -0600 Subject: [PATCH 02/39] :fire: Remove accidental dev_server.pid Remove unrelated local pid file that was accidentally included in previous commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: raguirref --- dev_server.pid | 1 - 1 file changed, 1 deletion(-) delete mode 100644 dev_server.pid diff --git a/dev_server.pid b/dev_server.pid deleted file mode 100644 index a8cd695ffa..0000000000 --- a/dev_server.pid +++ /dev/null @@ -1 +0,0 @@ -31390 From e46b34efc7122178fc16b6789cc55d92a90519d5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 15:41:38 +0200 Subject: [PATCH 03/39] :paperclip: Fix formatting issues --- common/src/app/common/files/builder.cljc | 8 ++++---- common/test/common_tests/runner.cljc | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 84c0381f0d..cc3dd11879 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -573,10 +573,10 @@ file-id (get state ::current-file-id) - {:keys [id width height name]} - (-> params - (update :id default-uuid) - (check-add-file-media))] + {:keys [id width height name]} + (-> params + (update :id default-uuid) + (check-add-file-media))] (-> state (update ::blobs assoc media-id blob) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 489e71f7ef..6df8243077 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -10,8 +10,8 @@ [common-tests.buffer-test] [common-tests.colors-test] [common-tests.data-test] - [common-tests.files-changes-test] [common-tests.files-builder-test] + [common-tests.files-changes-test] [common-tests.files-migrations-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] From c39609b99154a5654dacd46d9c5d81fe1142c271 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 10:48:30 +0200 Subject: [PATCH 04/39] :recycle: Use shared singleton containers for React portals (#8957) Refactor use-portal-container to allocate one persistent
per logical category (:modal, :popup, :tooltip, :default) instead of creating a new div for every component instance. This keeps the DOM clean with at most four fixed portal containers and eliminates the arbitrary growth of empty
elements on document.body while preserving the removeChild race condition fix. --- .../src/app/main/ui/ds/tooltip/tooltip.cljs | 2 +- frontend/src/app/main/ui/hooks.cljs | 38 ++++++++++++++----- frontend/src/app/main/ui/modal.cljs | 2 +- .../tokens/management/context_menu.cljs | 2 +- .../tokens/management/node_context_menu.cljs | 2 +- .../tokens/themes/theme_selector.cljs | 2 +- 6 files changed, 33 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 4751d81dcf..05246f7f23 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -160,7 +160,7 @@ tooltip-ref (mf/use-ref nil) - container (hooks/use-portal-container) + container (hooks/use-portal-container :tooltip) id (d/nilv id internal-id) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 42560cd8fe..ae8ebd30d5 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -380,17 +380,35 @@ state)) +(defn- get-or-create-portal-container + "Returns the singleton container div for the given category, creating + and appending it to document.body on first access." + [category] + (let [body (dom/get-body) + id (str "portal-container-" category)] + (or (dom/query body (str "#" id)) + (let [container (dom/create-element "div")] + (dom/set-attribute! container "id" id) + (dom/append-child! body container) + container)))) + (defn use-portal-container - "Creates a dedicated div container for React portals. The container - is appended to document.body on mount and removed on cleanup, preventing - removeChild race conditions when multiple portals target the same body." - [] - (let [container (mf/use-memo #(dom/create-element "div"))] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) - container)) + "Returns a shared singleton container div for React portals, identified + by a logical category. Available categories: + + :modal — modal dialogs + :popup — popups, dropdowns, context menus + :tooltip — tooltips + :default — general portal use (default) + + All portals in the same category share one
on document.body, + keeping the DOM clean and avoiding removeChild race conditions." + ([] + (use-portal-container :default)) + ([category] + (let [category (name category)] + (mf/with-memo [category] + (get-or-create-portal-container category))))) (defn use-dynamic-grid-item-width ([] (use-dynamic-grid-item-width nil)) diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 5df1cc3daa..6e9b1df7d4 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -84,7 +84,7 @@ (mf/defc modal-container* {::mf/props :obj} [] - (let [container (hooks/use-portal-container)] + (let [container (hooks/use-portal-container :modal)] (when-let [modal (mf/deref ref:modal)] (mf/portal (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index ab0dc6326d..c870baf9fb 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -517,7 +517,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] (mf/use-effect (mf/deps is-open?) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index d37e628d02..f150240cf1 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -36,7 +36,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (hooks/use-portal-container) + container (hooks/use-portal-container :popup) delete-node (mf/use-fn (mf/deps mdata) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index a8687c9719..d688588e2f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -114,7 +114,7 @@ :is-open? true :rect rect)))))) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] [:div {:on-click on-open-dropdown :disabled (not can-edit?) From 62f34546079a9f5c738e08cf2ec9fbaba6125feb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:33:10 +0200 Subject: [PATCH 05/39] :wrench: Backport ci configuration changes from develop --- .github/workflows/build-docker-devenv.yml | 6 +- .github/workflows/build-docker.yml | 18 ++--- .github/workflows/commit-checker.yml | 3 + .github/workflows/plugins-deploy-api-doc.yml | 2 +- .github/workflows/plugins-deploy-package.yml | 2 +- .github/workflows/plugins-deploy-packages.yml | 2 +- .../workflows/plugins-deploy-styles-doc.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/tests-mcp.yml | 6 +- .github/workflows/tests.yml | 69 +++++++------------ frontend/scripts/test-e2e | 2 +- 11 files changed, 50 insertions(+), 64 deletions(-) diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index d48e401a86..3ba45267a5 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -19,16 +19,16 @@ jobs: uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Build and push DevEnv Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'penpotapp/devenv' with: diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index ff6375b13e..18ac6aec9f 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -63,10 +63,10 @@ jobs: popd - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} @@ -76,14 +76,14 @@ jobs: # images from DockerHub for unregistered users. # https://docs.docker.com/docker-hub/usage/ - name: Login to DockerHub Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Extract metadata (tags, labels) id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: frontend @@ -95,7 +95,7 @@ jobs: bundle_version=${{ steps.bundles.outputs.bundle_version }} - name: Build and push Backend Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'backend' BUNDLE_PATH: './bundle-backend' @@ -110,7 +110,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Frontend Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'frontend' BUNDLE_PATH: './bundle-frontend' @@ -125,7 +125,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Exporter Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'exporter' BUNDLE_PATH: './bundle-exporter' @@ -140,7 +140,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Storybook Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'storybook' BUNDLE_PATH: './bundle-storybook' @@ -155,7 +155,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push MCP Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'mcp' BUNDLE_PATH: './bundle-mcp' diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index f7126a40cb..a80e6e4cc0 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -6,12 +6,14 @@ on: - edited - reopened - synchronize + - ready_for_review pull_request_target: types: - opened - edited - reopened - synchronize + - ready_for_review push: branches: - main @@ -20,6 +22,7 @@ on: jobs: check-commit-message: + if: ${{ !github.event.pull_request.draft }} name: Check Commit Message runs-on: ubuntu-latest steps: diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 815553749d..51be85e45e 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -62,7 +62,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index f8f558569d..137ba6f7fa 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -62,7 +62,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/plugins-deploy-packages.yml b/.github/workflows/plugins-deploy-packages.yml index 01f9284972..943e4b790d 100644 --- a/.github/workflows/plugins-deploy-packages.yml +++ b/.github/workflows/plugins-deploy-packages.yml @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v6 - id: filter - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@v4 with: filters: | colors_to_tokens: diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 9fbcac880e..47f0d1cc24 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -60,7 +60,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 538cd9d5a0..21c0eb6de2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,7 +93,7 @@ jobs: # --- Create GitHub release --- - name: Create GitHub release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 9f2a4ed589..0ab2909b72 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -10,6 +10,7 @@ on: types: - opened - synchronize + - ready_for_review paths: - 'mcp/**' @@ -24,8 +25,9 @@ on: - 'mcp/**' jobs: - test: - name: "Test" + test-mcp: + if: ${{ !github.event.pull_request.draft }} + name: "Test MCP" runs-on: penpot-runner-02 container: penpotapp/devenv:latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 472fc36656..afcffb0ae7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,7 @@ on: types: - opened - synchronize + - ready_for_review push: branches: - develop @@ -20,6 +21,7 @@ concurrency: jobs: lint: + if: ${{ !github.event.pull_request.draft }} name: "Linter" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -79,6 +81,7 @@ jobs: pnpm run lint test-common: + if: ${{ !github.event.pull_request.draft }} name: "Common Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -93,6 +96,7 @@ jobs: ./scripts/test test-plugins: + if: ${{ !github.event.pull_request.draft }} name: Plugins Runtime Linter & Tests runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -143,6 +147,7 @@ jobs: run: pnpm run build:styles-example test-frontend: + if: ${{ !github.event.pull_request.draft }} name: "Frontend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -164,6 +169,7 @@ jobs: ./scripts/test-components test-render-wasm: + if: ${{ !github.event.pull_request.draft }} name: "Render WASM Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -188,6 +194,7 @@ jobs: ./test test-backend: + if: ${{ !github.event.pull_request.draft }} name: "Backend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -227,6 +234,7 @@ jobs: clojure -M:dev:test --reporter kaocha.report/documentation test-library: + if: ${{ !github.event.pull_request.draft }} name: "Library Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -241,6 +249,7 @@ jobs: ./scripts/test build-integration: + if: ${{ !github.event.pull_request.draft }} name: "Build Integration Bundle" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -255,14 +264,14 @@ jobs: ./scripts/build - name: Store Bundle Cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public - test-integration-1: - name: "Integration Tests 1/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 1/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -272,7 +281,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -280,10 +289,10 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="1/4"; + ./scripts/test-e2e --shard="1/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-1 @@ -292,7 +301,8 @@ jobs: retention-days: 3 test-integration-2: - name: "Integration Tests 2/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 2/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -302,7 +312,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -310,10 +320,10 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="2/4"; + ./scripts/test-e2e --shard="2/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-2 @@ -322,7 +332,8 @@ jobs: retention-days: 3 test-integration-3: - name: "Integration Tests 3/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 3/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -332,7 +343,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -340,43 +351,13 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="3/4"; + ./scripts/test-e2e --shard="3/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-3 path: frontend/test-results/ overwrite: true retention-days: 3 - - test-integration-4: - name: "Integration Tests 4/4" - runs-on: penpot-runner-02 - container: penpotapp/devenv:latest - needs: build-integration - - steps: - - name: Checkout Repository - uses: actions/checkout@v6 - - - name: Restore Cache - uses: actions/cache/restore@v4 - with: - key: "integration-bundle-${{ github.sha }}" - path: frontend/resources/public - - - name: Run Tests - working-directory: ./frontend - run: | - ./scripts/test-e2e --shard="4/4"; - - - name: Upload test result - uses: actions/upload-artifact@v4 - if: always() - with: - name: integration-tests-result-4 - path: frontend/test-results/ - overwrite: true - retention-days: 3 diff --git a/frontend/scripts/test-e2e b/frontend/scripts/test-e2e index dd25bed989..fca7cf941e 100755 --- a/frontend/scripts/test-e2e +++ b/frontend/scripts/test-e2e @@ -5,4 +5,4 @@ SCRIPT_DIR=$(dirname $0); set -ex $SCRIPT_DIR/setup; -pnpm run test:e2e -x --workers=2 --reporter=list "$@"; +pnpm run test:e2e -x --workers=1 --reporter=list "$@"; From 18f0ad246f44f442fa4b02fbd433c1bc2bf96cb4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 13:24:54 +0000 Subject: [PATCH 06/39] :bug: Fix parse-long crash when index query param is duplicated in URL lambdaisland/uri's query-string->map uses :multikeys :duplicates by default: a key that appears once yields a plain string, but the same key repeated yields a vector. cljs.core/parse-long only accepts strings and therefore threw "Expected string, got: object" whenever a URL contained a duplicate 'index' parameter. Add rt/get-query-param to app.main.router. The helper returns the scalar value of a query param key, taking the last element when the value is a sequential (i.e. the key was repeated). Use it at every call site that feeds a query-param value into parse-long, in both app.main.ui (page*) and app.main.data.viewer. --- frontend/src/app/main/data/viewer.cljs | 14 +++++++------- frontend/src/app/main/router.cljs | 10 ++++++++++ frontend/src/app/main/ui.cljs | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index c2d42d680c..c1f6083d13 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -204,7 +204,7 @@ (watch [_ state _] (let [route (:route state) qparams (:query-params route) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) frame-id (some-> (:frame-id qparams) uuid/parse)] (rx/merge (rx/of (case (:zoom qparams) @@ -301,7 +301,7 @@ (update [_ state] (let [params (rt/get-params state) page-id (some-> (:page-id params) uuid/parse) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) frames (dm/get-in state [:viewer :pages page-id :frames]) index (min (or index 0) (max 0 (dec (count frames)))) @@ -325,7 +325,7 @@ (let [params (rt/get-params state) page-id (some-> (:page-id params) uuid/parse) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) frames (dm/get-in state [:viewer :pages page-id :frames]) index (min (or index 0) (max 0 (dec (count frames)))) @@ -399,7 +399,7 @@ ptk/WatchEvent (watch [_ state _] (let [params (rt/get-params state) - index (some-> params :index parse-long)] + index (some-> (rt/get-query-param params :index) parse-long)] (when (pos? index) (rx/of (dcmt/close-thread) @@ -415,7 +415,7 @@ ptk/WatchEvent (watch [_ state _] (let [params (rt/get-params state) - index (some-> params :index parse-long) + index (some-> (rt/get-query-param params :index) parse-long) page-id (some-> params :page-id uuid/parse) total (count (get-in state [:viewer :pages page-id :frames]))] @@ -530,7 +530,7 @@ (let [route (:route state) qparams (:query-params route) page-id (some-> (:page-id qparams) uuid/parse) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) frames (get-in state [:viewer :pages page-id :frames]) frame (get frames index)] (cond-> state @@ -744,7 +744,7 @@ (let [route (:route state) qparams (:query-params route) page-id (some-> (:page-id qparams) uuid/parse) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) objects (get-in state [:viewer :pages page-id :objects]) frame-id (get-in state [:viewer :pages page-id :frames index :id]) diff --git a/frontend/src/app/main/router.cljs b/frontend/src/app/main/router.cljs index 1e234e8af1..405c8b6664 100644 --- a/frontend/src/app/main/router.cljs +++ b/frontend/src/app/main/router.cljs @@ -136,6 +136,16 @@ [state] (dm/get-in state [:route :params :query])) +(defn get-query-param + "Safely extracts a scalar value for a query param key from a params + map. When the same key appears multiple times in a URL, + query-string->map returns a vector for that key; this function + always returns a single (last) element in that case, so downstream + consumers such as parse-long always receive a plain string or nil." + [params k] + (let [v (get params k)] + (if (sequential? v) (peek v) v))) + (defn nav-back [] (ptk/reify ::nav-back diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 3b00fe0d50..1a03943ba4 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -277,7 +277,7 @@ :viewer (let [params (get params :query) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) share-id (some-> (:share-id params) uuid/parse*) section (or (some-> (:section params) keyword) :interactions) From 6c90ba1582e4b5bab58232dae438a4fd701032eb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 12:43:13 +0000 Subject: [PATCH 07/39] :bug: Fix move-files allowing same project as target when multiple files selected The 'Move to' menu in the dashboard file context menu only filtered out the first selected file's project from the available target list. When multiple files from different projects were selected, the other files' projects still appeared as valid targets, causing a 400 'cant-move-to-same-project' backend error. Now all selected files' project IDs are collected and excluded from the available target projects. --- frontend/src/app/main/ui/dashboard/file_menu.cljs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index dfecbc779b..06f7b29c36 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -78,7 +78,8 @@ current-team (get teams current-team-id) other-teams (remove #(= (:id %) current-team-id) (vals teams)) - current-projects (remove #(= (:id %) (:project-id file)) + file-project-ids (into #{} (map :project-id) files) + current-projects (remove #(contains? file-project-ids (:id %)) (:projects current-team)) on-new-tab From 7b0ea5968dc18e65c08e8262ba0199920f498214 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:22 +0000 Subject: [PATCH 08/39] :ambulance: Fix typo :podition in swap-shapes grid cell The key :podition was used instead of :position when updating the id-from cell in swap-shapes, silently discarding the position value and leaving the cell's :position as nil after every swap. Signed-off-by: Andrey Antukh --- common/src/app/common/types/shape/layout.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 384029a688..8ed7306c61 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -1439,7 +1439,7 @@ (update-in [:layout-grid-cells id-from] assoc :shapes (:shapes cell-to) - :podition (:position cell-to)) + :position (:position cell-to)) (update-in [:layout-grid-cells id-to] assoc :shapes (:shapes cell-from) From 08ca56166714a29bf7a4e2ea1435c7370091954c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:30 +0000 Subject: [PATCH 09/39] :bug: Add better nil handling in interpolate-gradient when offset exceeds stops When no gradient stop satisfies (<= offset (:offset %)), d/index-of-pred returns nil. The previous code called (dec nil) in the start binding before the nil check, throwing a NullPointerException/ClassCastException. Guard the start binding with a cond that handles nil before attempting dec. Signed-off-by: Andrey Antukh --- common/src/app/common/types/color.cljc | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index c4532c4ac0..ae56250d96 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -720,8 +720,10 @@ (defn- offset-spread [from to num] - (->> (range 0 num) - (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) + (if (<= num 1) + [from] + (->> (range 0 num) + (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2))))) (defn uniform-spread? "Checks if the gradient stops are spread uniformly" @@ -750,6 +752,9 @@ (defn interpolate-gradient [stops offset] (let [idx (d/index-of-pred stops #(<= offset (:offset %))) - start (if (= idx 0) (first stops) (get stops (dec idx))) + start (cond + (nil? idx) (last stops) + (= idx 0) (first stops) + :else (get stops (dec idx))) end (if (nil? idx) (last stops) (get stops idx))] (interpolate-color start end offset))) From ff41d08e3c726a08b447066630d17ecc6769a22a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:35 +0000 Subject: [PATCH 10/39] :bug: Fix stale accumulator in get-children-in-instance recursion get-children-rec passed the original children vector to each recursive call instead of the updated one that already includes the current shape. This caused descendant results to be accumulated from the wrong starting point, losing intermediate shapes. Pass children' (which includes the current shape) into every recursive call. Signed-off-by: Andrey Antukh --- common/src/app/common/types/container.cljc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 324528854b..8bb0e7d969 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -106,8 +106,9 @@ (let [shape (get objects id)] (if (and (ctk/instance-head? shape) (seq children)) children - (into (conj children shape) - (mapcat #(get-children-rec children %) (:shapes shape))))))] + (let [children' (conj children shape)] + (into children' + (mapcat #(get-children-rec children' %) (:shapes shape)))))))] (get-children-rec [] id))) (defn get-component-shape From c30c85ff077179627401fc831b229362b5ca3064 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:50 +0000 Subject: [PATCH 11/39] :bug: Remove duplicate font-weight-keys in typography-keys union font-weight-keys was listed twice in the set/union call for typography-keys, a copy-paste error. The duplicate entry has no functional effect (sets deduplicate), but it is misleading and suggests a missing key such as font-style-keys in its place. Signed-off-by: Andrey Antukh --- common/src/app/common/types/token.cljc | 1 - 1 file changed, 1 deletion(-) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index e3e541da33..c3bb2b266d 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -345,7 +345,6 @@ (def typography-keys (set/union font-family-keys font-size-keys font-weight-keys - font-weight-keys letter-spacing-keys line-height-keys text-case-keys From 8253738f01c9709fef18e35a5aac412690a438ee Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:34:38 +0000 Subject: [PATCH 12/39] :bug: Fix reversed `get` args in convert-dtcg-shadow-composite \`(get "type" shadow)\` always returns nil because the map and key arguments were swapped. The correct call is \`(get shadow "type")\`, which allows the legacy innerShadow detection to work correctly. Update the test expectation accordingly. Signed-off-by: Andrey Antukh --- common/src/app/common/types/tokens_lib.cljc | 2 +- common/test/common_tests/types/tokens_lib_test.cljc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 5c392f2db9..8ab9c6bcd0 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -1637,7 +1637,7 @@ Will return a value that matches this schema: [value] (let [process-shadow (fn [shadow] (if (map? shadow) - (let [legacy-shadow-type (get "type" shadow)] + (let [legacy-shadow-type (get shadow "type")] (-> shadow (set/rename-keys {"x" :offset-x "offsetX" :offset-x diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index 150ffcfb08..e8c8a52ae5 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -1918,7 +1918,7 @@ (let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-type")] (t/is (some? token)) (t/is (= :shadow (:type token))) - (t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset false}] + (t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset true}] (:value token))))) (t/testing "shadow token with description" From 8b08c8ecc974eb805fcd59bfa7008eb6c6a58a4e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:35:21 +0000 Subject: [PATCH 13/39] :bug: Fix wrong mapcat call in collect-main-shapes `(mapcat collect-main-shapes children objects)` passes `objects` as a second parallel collection instead of threading it as the second argument to `collect-main-shapes` for each child. Fix by using an anonymous fn: `(mapcat #(collect-main-shapes % objects) children)`. Signed-off-by: Andrey Antukh --- common/src/app/common/types/container.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 8bb0e7d969..b72ca11179 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -441,7 +441,7 @@ (if (ctk/main-instance? shape) [shape] (if-let [children (cfh/get-children objects (:id shape))] - (mapcat collect-main-shapes children objects) + (mapcat #(collect-main-shapes % objects) children) []))) (defn get-component-from-shape From 2b67e114b6ce894d3d74d6106c4ef4e630fda687 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:37:01 +0000 Subject: [PATCH 14/39] :bug: Fix inside-layout? passing id instead of shape to frame-shape? `(cfh/frame-shape? current-id)` passes a UUID to the single-arity overload of `frame-shape?`, which expects a shape map; it always returns false. Fix by passing `current` (the resolved shape) instead. Update the test to assert the correct behaviour. Signed-off-by: Andrey Antukh --- common/src/app/common/types/shape/layout.cljc | 2 +- common/test/common_tests/types/shape_layout_test.cljc | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 8ed7306c61..caea9d5f91 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -262,7 +262,7 @@ (or (nil? current) (= current-id parent-id)) false - (cfh/frame-shape? current-id) + (cfh/frame-shape? current) (:layout current) :else diff --git a/common/test/common_tests/types/shape_layout_test.cljc b/common/test/common_tests/types/shape_layout_test.cljc index d677ed5d09..62935b21dc 100644 --- a/common/test/common_tests/types/shape_layout_test.cljc +++ b/common/test/common_tests/types/shape_layout_test.cljc @@ -186,13 +186,9 @@ flex (make-flex-frame :parent-id root-id) child (make-shape :parent-id (:id flex))] - ;; Note: inside-layout? calls (cfh/frame-shape? current-id) with a UUID id, - ;; but frame-shape? checks (:type uuid) which is nil for a UUID value. - ;; The function therefore always returns false regardless of structure. - ;; These tests document the actual (not the intended) behavior. - (t/testing "returns false when child is under a flex frame" + (t/testing "returns true when child is under a flex frame" (let [objects {root-id root (:id flex) flex (:id child) child}] - (t/is (not (layout/inside-layout? objects child))))) + (t/is (layout/inside-layout? objects child)))) (t/testing "returns false for root shape" (let [objects {root-id root (:id flex) flex (:id child) child}] From 6da39bc9c74539697b8d07162fa03d329976a6e7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:41:00 +0000 Subject: [PATCH 15/39] :bug: Fix ObjectsMap CLJS negative cache keyed on 'key' fn instead of 'k' In the CLJS -lookup implementation, when a key is absent from data the negative cache entry was stored under 'key' (the built-in map-entry key function) rather than the 'k' parameter. As a result every subsequent lookup of any missing key bypassed the cache and repeated the full lookup path, making the negative-cache optimization entirely ineffective. Signed-off-by: Andrey Antukh --- common/src/app/common/types/objects_map.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc index d08330765c..3604961f11 100644 --- a/common/src/app/common/types/objects_map.cljc +++ b/common/src/app/common/types/objects_map.cljc @@ -278,7 +278,7 @@ (set! (.-cache this) (c/-assoc cache k v)) v) (do - (set! (.-cache this) (assoc cache key nil)) + (set! (.-cache this) (assoc cache k nil)) nil)))) (-lookup [this k not-found] From 30931839b5d136f1e543665061a47d7f4fee27c6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:43:32 +0000 Subject: [PATCH 16/39] :bug: Fix reversed d/in-range? args in CLJS Fills -nth with default In the ClojureScript Fills deftype, the two-arity -nth implementation called (d/in-range? i size) but the signature is (d/in-range? size i). This meant -nth always fell through to the default value for any valid index when called with an explicit default, since i < size is the condition but the args were swapped. The no-default -nth sibling on line 378 and both CLJ nth impls on lines 286 and 291 had the correct argument order. Signed-off-by: Andrey Antukh --- common/src/app/common/types/fills/impl.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index 06475d183f..b429c67b9c 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -380,7 +380,7 @@ nil)) (-nth [_ i default] - (if (d/in-range? i size) + (if (d/in-range? size i) (read-fill dbuffer mbuffer i) default)) From caac452cd4ddc45b368bfda8764453baf81da6de Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:44:58 +0000 Subject: [PATCH 17/39] :bug: Fix wrong extremity point in calculate-extremities for line-to In the :line-to branch of calculate-extremities, move-p (the subpath start point) was being added to the extremities set instead of from-p (the actual previous point). For all line segments beyond the first one in a subpath this produced an incorrect bounding-box start point. The :curve-to branch correctly used from-p; align :line-to to match. Signed-off-by: Andrey Antukh --- common/src/app/common/types/path/segment.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index bcbbe8eeda..45fc1ba2bb 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -812,7 +812,7 @@ :line-to (recur (cond-> points (and from-p to-p) - (-> (conj! move-p) + (-> (conj! from-p) (conj! to-p))) (not-empty (subvec content 1)) to-p From db7c6465681435e180bf0d4c148fd2e324d64aa9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:48:07 +0000 Subject: [PATCH 18/39] :sparkles: Add missing tests for session bug fixes and uniform-spread? Add indexed-access-with-default in fill_test.cljc to cover the two-arity (nth fills i default) form on both valid and out-of-range indices, directly exercising the CLJS Fills -nth path fixed in 593cf125. Add segment-content->selrect-multi-line in path_data_test.cljc to cover content->selrect on a subpath with multiple consecutive line-to commands where move-p diverges from from-p, confirming the bounding box matches both the expected coordinates and the reference implementation; this guards the calculate-extremities fix in bb5a04c7. Add types-uniform-spread? in colors_test.cljc to cover app.common.types.color/uniform-spread?, which had no dedicated tests. Exercises the uniform case (via uniform-spread), the two-stop edge case, wrong-offset detection, and wrong-color detection. Signed-off-by: Andrey Antukh --- common/test/common_tests/colors_test.cljc | 21 ++++++++++++++++ common/test/common_tests/types/fill_test.cljc | 15 +++++++++++ .../common_tests/types/path_data_test.cljc | 25 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index de505fd540..aa1edd450a 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -426,6 +426,27 @@ {:color "#ffffff" :opacity 1.0 :offset 1.0}]] (t/is (false? (c/uniform-spread? stops))))) +(t/deftest types-uniform-spread? + (t/testing "uniformly spread stops are detected as uniform" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "two-stop gradient is uniform by definition" + (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} + {:color "#0000ff" :opacity 1.0 :offset 1.0}]] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "stops with wrong offset are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#888888" :opacity 0.5 :offset 0.3} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops))))) + (t/testing "stops with correct offset but wrong color are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#aaaaaa" :opacity 0.5 :offset 0.5} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops)))))) + (t/deftest ac-interpolate-gradient (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} {:color "#ffffff" :opacity 1.0 :offset 1.0}]] diff --git a/common/test/common_tests/types/fill_test.cljc b/common/test/common_tests/types/fill_test.cljc index 308778bcc1..f9968e8aed 100644 --- a/common/test/common_tests/types/fill_test.cljc +++ b/common/test/common_tests/types/fill_test.cljc @@ -207,3 +207,18 @@ fill1 (nth fills1 1)] (t/is (nil? fill1)) (t/is (equivalent-fill? fill0 sample-fill-6)))) + +(t/deftest indexed-access-with-default + (t/testing "nth with default returns fill for valid index" + ;; Regression: CLJS -nth with default had reversed d/in-range? args, + ;; so it always fell through to the default even for valid indices. + (let [fills (types.fills/from-plain [sample-fill-6]) + sentinel ::not-found + result (nth fills 0 sentinel)] + (t/is (not= sentinel result)) + (t/is (equivalent-fill? result sample-fill-6)))) + (t/testing "nth with default returns default for out-of-range index" + (let [fills (types.fills/from-plain [sample-fill-6]) + sentinel ::not-found] + (t/is (= sentinel (nth fills 1 sentinel))) + (t/is (= sentinel (nth fills -1 sentinel)))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index e4d2881b18..6dc7fa5207 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -973,6 +973,31 @@ (t/is (mth/close? 10.0 (:x2 rect) 0.1)) (t/is (mth/close? 10.0 (:y2 rect) 0.1)))) +(t/deftest segment-content->selrect-multi-line + ;; Regression: calculate-extremities used move-p instead of from-p in + ;; the :line-to branch. For a subpath with multiple consecutive line-to + ;; commands, the selrect must still match the reference implementation. + (let [;; A subpath that starts away from the origin and has three + ;; line-to segments so that move-p diverges from from-p for the + ;; later segments. + segments [{:command :move-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 15.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 8.0}} + {:command :line-to :params {:x 10.0 :y 12.0}}] + content (path/content segments) + rect (path.segment/content->selrect content) + ref-pts (calculate-extremities segments)] + + ;; Bounding box must enclose all four vertices exactly. + (t/is (some? rect)) + (t/is (mth/close? 5.0 (:x1 rect) 0.1)) + (t/is (mth/close? 0.0 (:y1 rect) 0.1)) + (t/is (mth/close? 20.0 (:x2 rect) 0.1)) + (t/is (mth/close? 12.0 (:y2 rect) 0.1)) + + ;; Must agree with the reference implementation. + (t/is (= ref-pts (calculate-extremities content))))) + (t/deftest segment-content-center (let [content (path/content sample-content-square) center (path.segment/content-center content)] From 1e0f10814ed65b32d09cf6943983b3210c3425a6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:04:04 +0000 Subject: [PATCH 19/39] :fire: Remove duplicate gradient helpers from app.common.colors The five functions interpolate-color, offset-spread, uniform-spread?, uniform-spread, and interpolate-gradient duplicated the canonical implementations in app.common.types.color. The copies in colors.cljc also contained two bugs: a division-by-zero in offset-spread when num=1, and a crash on nil idx in interpolate-gradient. All production callers already use app.common.types.color. The duplicate tests that exercised the old copies are removed; their coverage is absorbed into expanded tests under the types-* suite, including a new nil-idx guard test and a single-stop no-crash test. Signed-off-by: Andrey Antukh --- common/src/app/common/colors.cljc | 59 -------------- common/test/common_tests/colors_test.cljc | 94 ++++++++++++----------- 2 files changed, 50 insertions(+), 103 deletions(-) diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index e16acf94a3..ab7c7e2a76 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -487,62 +487,3 @@ b (+ (* bh 100) (* bv 10))] (compare a b))) -(defn interpolate-color - [c1 c2 offset] - (cond - (<= offset (:offset c1)) (assoc c1 :offset offset) - (>= offset (:offset c2)) (assoc c2 :offset offset) - - :else - (let [tr-offset (/ (- offset (:offset c1)) (- (:offset c2) (:offset c1))) - [r1 g1 b1] (hex->rgb (:color c1)) - [r2 g2 b2] (hex->rgb (:color c2)) - a1 (:opacity c1) - a2 (:opacity c2) - r (+ r1 (* (- r2 r1) tr-offset)) - g (+ g1 (* (- g2 g1) tr-offset)) - b (+ b1 (* (- b2 b1) tr-offset)) - a (+ a1 (* (- a2 a1) tr-offset))] - {:color (rgb->hex [r g b]) - :opacity a - :r r - :g g - :b b - :alpha a - :offset offset}))) - -(defn- offset-spread - [from to num] - (->> (range 0 num) - (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) - -(defn uniform-spread? - "Checks if the gradient stops are spread uniformly" - [stops] - (let [cs (count stops) - from (first stops) - to (last stops) - expect-vals (offset-spread (:offset from) (:offset to) cs) - - calculate-expected - (fn [expected-offset stop] - (and (mth/close? (:offset stop) expected-offset) - (let [ec (interpolate-color from to expected-offset)] - (and (= (:color ec) (:color stop)) - (= (:opacity ec) (:opacity stop))))))] - (->> (map calculate-expected expect-vals stops) - (every? true?)))) - -(defn uniform-spread - "Assign an uniform spread to the offset values for the gradient" - [from to num-stops] - (->> (offset-spread (:offset from) (:offset to) num-stops) - (mapv (fn [offset] - (interpolate-color from to offset))))) - -(defn interpolate-gradient - [stops offset] - (let [idx (d/index-of-pred stops #(<= offset (:offset %))) - start (if (= idx 0) (first stops) (get stops (dec idx))) - end (if (nil? idx) (last stops) (get stops idx))] - (interpolate-color start end offset))) diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index aa1edd450a..21f6af5bef 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -387,44 +387,41 @@ (t/is (= 0.25 (c/reduce-range 0.3 4))) (t/is (= 0.0 (c/reduce-range 0.0 10)))) -;; --- Gradient helpers +;; --- Gradient helpers (app.common.types.color) -(t/deftest ac-interpolate-color - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0}] - ;; At c1's offset → c1 with updated offset - (let [result (c/interpolate-color c1 c2 0.0)] +(t/deftest types-interpolate-color + (t/testing "at c1 offset returns c1 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.0)] (t/is (= "#000000" (:color result))) - (t/is (= 0.0 (:opacity result)))) - ;; At c2's offset → c2 with updated offset - (let [result (c/interpolate-color c1 c2 1.0)] + (t/is (= 0.0 (:opacity result))))) + (t/testing "at c2 offset returns c2 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 1.0)] (t/is (= "#ffffff" (:color result))) - (t/is (= 1.0 (:opacity result)))) - ;; At midpoint → gray - (let [result (c/interpolate-color c1 c2 0.5)] + (t/is (= 1.0 (:opacity result))))) + (t/testing "at midpoint returns interpolated gray" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.5)] (t/is (= "#7f7f7f" (:color result))) (t/is (mth/close? (:opacity result) 0.5))))) -(t/deftest ac-uniform-spread - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (c/uniform-spread c1 c2 3)] - (t/is (= 3 (count stops))) - (t/is (= 0.0 (:offset (first stops)))) - (t/is (mth/close? 0.5 (:offset (second stops)))) - (t/is (= 1.0 (:offset (last stops)))))) - -(t/deftest ac-uniform-spread? - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (c/uniform-spread c1 c2 3)] - ;; A uniformly spread result should pass the predicate - (t/is (true? (c/uniform-spread? stops)))) - ;; Manual non-uniform stops should not pass - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#888888" :opacity 0.5 :offset 0.3} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (c/uniform-spread? stops))))) +(t/deftest types-uniform-spread + (t/testing "produces correct count and offsets" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (= 3 (count stops))) + (t/is (= 0.0 (:offset (first stops)))) + (t/is (mth/close? 0.5 (:offset (second stops)))) + (t/is (= 1.0 (:offset (last stops)))))) + (t/testing "single stop returns a vector of one element (no division by zero)" + (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} + stops (colors/uniform-spread c1 c1 1)] + (t/is (= 1 (count stops)))))) (t/deftest types-uniform-spread? (t/testing "uniformly spread stops are detected as uniform" @@ -447,16 +444,25 @@ {:color "#ffffff" :opacity 1.0 :offset 1.0}]] (t/is (false? (colors/uniform-spread? stops)))))) -(t/deftest ac-interpolate-gradient - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - ;; At start - (let [result (c/interpolate-gradient stops 0.0)] - (t/is (= "#000000" (:color result)))) - ;; At end - (let [result (c/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result)))) - ;; In the middle - (let [result (c/interpolate-gradient stops 0.5)] - (t/is (= "#7f7f7f" (:color result)))))) +(t/deftest types-interpolate-gradient + (t/testing "at start offset returns first stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.0)] + (t/is (= "#000000" (:color result))))) + (t/testing "at end offset returns last stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result))))) + (t/testing "at midpoint returns interpolated gray" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.5)] + (t/is (= "#7f7f7f" (:color result))))) + (t/testing "offset beyond last stop returns last stop color (nil idx guard)" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 0.5}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result)))))) From 6d1d0445884c21312aadc46fd3893e84b805d9a3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:16:19 +0000 Subject: [PATCH 20/39] :recycle: Move app.common.types.color tests to their own namespace Tests that exercise app.common.types.color were living inside common-tests.colors-test alongside the app.common.colors tests. Move them to common-tests.types.color-test so the test namespace mirrors the source namespace structure, consistent with the rest of the types/ test suite. The [app.common.types.color :as colors] require is removed from colors_test.cljc; the new file is registered in runner.cljc. Signed-off-by: Andrey Antukh --- common/test/common_tests/colors_test.cljc | 162 ----------------- common/test/common_tests/runner.cljc | 2 + .../test/common_tests/types/color_test.cljc | 166 ++++++++++++++++++ 3 files changed, 168 insertions(+), 162 deletions(-) create mode 100644 common/test/common_tests/types/color_test.cljc diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index 21f6af5bef..7d6b0f0e3d 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -9,91 +9,8 @@ #?(:cljs [goog.color :as gcolors]) [app.common.colors :as c] [app.common.math :as mth] - [app.common.types.color :as colors] [clojure.test :as t])) -(t/deftest valid-hex-color - (t/is (false? (colors/valid-hex-color? nil))) - (t/is (false? (colors/valid-hex-color? ""))) - (t/is (false? (colors/valid-hex-color? "#"))) - (t/is (false? (colors/valid-hex-color? "#qqqqqq"))) - (t/is (true? (colors/valid-hex-color? "#aaa"))) - (t/is (false? (colors/valid-hex-color? "#aaaa"))) - (t/is (true? (colors/valid-hex-color? "#fabada")))) - -(t/deftest valid-rgb-color - (t/is (false? (colors/valid-rgb-color? nil))) - (t/is (false? (colors/valid-rgb-color? ""))) - (t/is (false? (colors/valid-rgb-color? "()"))) - (t/is (true? (colors/valid-rgb-color? "(255, 30, 30)"))) - (t/is (true? (colors/valid-rgb-color? "rgb(255, 30, 30)")))) - -(t/deftest rgb-to-str - (t/is (= "rgb(1,2,3)" (colors/rgb->str [1 2 3]))) - (t/is (= "rgba(1,2,3,4)" (colors/rgb->str [1 2 3 4])))) - -(t/deftest rgb-to-hsv - ;; (prn (colors/rgb->hsv [1 2 3])) - ;; (prn (gcolors/rgbToHsv 1 2 3)) - (t/is (= [210.0 0.6666666666666666 3.0] (colors/rgb->hsv [1.0 2.0 3.0]))) - #?(:cljs (t/is (= (colors/rgb->hsv [1 2 3]) (vec (gcolors/rgbToHsv 1 2 3)))))) - -(t/deftest hsv-to-rgb - (t/is (= [1 2 3] - (colors/hsv->rgb [210 0.6666666666666666 3]))) - #?(:cljs - (t/is (= (colors/hsv->rgb [210 0.6666666666666666 3]) - (vec (gcolors/hsvToRgb 210 0.6666666666666666 3)))))) - -(t/deftest rgb-to-hex - (t/is (= "#010203" (colors/rgb->hex [1 2 3])))) - -(t/deftest hex-to-rgb - (t/is (= [0 0 0] (colors/hex->rgb "#kkk"))) - (t/is (= [1 2 3] (colors/hex->rgb "#010203")))) - -(t/deftest format-hsla - (t/is (= "210, 50%, 0.78%, 1" (colors/format-hsla [210.0 0.5 0.00784313725490196 1]))) - (t/is (= "220, 5%, 30%, 0.8" (colors/format-hsla [220.0 0.05 0.3 0.8])))) - -(t/deftest format-rgba - (t/is (= "210, 199, 12, 0.08" (colors/format-rgba [210 199 12 0.08]))) - (t/is (= "210, 199, 12, 1" (colors/format-rgba [210 199 12 1])))) - -(t/deftest rgb-to-hsl - (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3]))) - #?(:cljs (t/is (= (colors/rgb->hsl [1 2 3]) - (vec (gcolors/rgbToHsl 1 2 3)))))) - -(t/deftest hsl-to-rgb - (t/is (= [1 2 3] (colors/hsl->rgb [210.0 0.5 0.00784313725490196]))) - (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3]))) - #?(:cljs (t/is (= (colors/hsl->rgb [210 0.5 0.00784313725490196]) - (vec (gcolors/hslToRgb 210 0.5 0.00784313725490196)))))) - -(t/deftest expand-hex - (t/is (= "aaaaaa" (colors/expand-hex "a"))) - (t/is (= "aaaaaa" (colors/expand-hex "aa"))) - (t/is (= "aaaaaa" (colors/expand-hex "aaa"))) - (t/is (= "aaaa" (colors/expand-hex "aaaa")))) - -(t/deftest prepend-hash - (t/is "#aaa" (colors/prepend-hash "aaa")) - (t/is "#aaa" (colors/prepend-hash "#aaa"))) - -(t/deftest remove-hash - (t/is "aaa" (colors/remove-hash "aaa")) - (t/is "aaa" (colors/remove-hash "#aaa"))) - -(t/deftest color-string-pred - (t/is (true? (colors/color-string? "#aaa"))) - (t/is (true? (colors/color-string? "(10,10,10)"))) - (t/is (true? (colors/color-string? "rgb(10,10,10)"))) - (t/is (true? (colors/color-string? "magenta"))) - (t/is (false? (colors/color-string? nil))) - (t/is (false? (colors/color-string? ""))) - (t/is (false? (colors/color-string? "kkkkkk")))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; app.common.colors tests ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -387,82 +304,3 @@ (t/is (= 0.25 (c/reduce-range 0.3 4))) (t/is (= 0.0 (c/reduce-range 0.0 10)))) -;; --- Gradient helpers (app.common.types.color) - -(t/deftest types-interpolate-color - (t/testing "at c1 offset returns c1 color" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - result (colors/interpolate-color c1 c2 0.0)] - (t/is (= "#000000" (:color result))) - (t/is (= 0.0 (:opacity result))))) - (t/testing "at c2 offset returns c2 color" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - result (colors/interpolate-color c1 c2 1.0)] - (t/is (= "#ffffff" (:color result))) - (t/is (= 1.0 (:opacity result))))) - (t/testing "at midpoint returns interpolated gray" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - result (colors/interpolate-color c1 c2 0.5)] - (t/is (= "#7f7f7f" (:color result))) - (t/is (mth/close? (:opacity result) 0.5))))) - -(t/deftest types-uniform-spread - (t/testing "produces correct count and offsets" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (colors/uniform-spread c1 c2 3)] - (t/is (= 3 (count stops))) - (t/is (= 0.0 (:offset (first stops)))) - (t/is (mth/close? 0.5 (:offset (second stops)))) - (t/is (= 1.0 (:offset (last stops)))))) - (t/testing "single stop returns a vector of one element (no division by zero)" - (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} - stops (colors/uniform-spread c1 c1 1)] - (t/is (= 1 (count stops)))))) - -(t/deftest types-uniform-spread? - (t/testing "uniformly spread stops are detected as uniform" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (colors/uniform-spread c1 c2 3)] - (t/is (true? (colors/uniform-spread? stops))))) - (t/testing "two-stop gradient is uniform by definition" - (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} - {:color "#0000ff" :opacity 1.0 :offset 1.0}]] - (t/is (true? (colors/uniform-spread? stops))))) - (t/testing "stops with wrong offset are not uniform" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#888888" :opacity 0.5 :offset 0.3} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (colors/uniform-spread? stops))))) - (t/testing "stops with correct offset but wrong color are not uniform" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#aaaaaa" :opacity 0.5 :offset 0.5} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (colors/uniform-spread? stops)))))) - -(t/deftest types-interpolate-gradient - (t/testing "at start offset returns first stop color" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}] - result (colors/interpolate-gradient stops 0.0)] - (t/is (= "#000000" (:color result))))) - (t/testing "at end offset returns last stop color" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}] - result (colors/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result))))) - (t/testing "at midpoint returns interpolated gray" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}] - result (colors/interpolate-gradient stops 0.5)] - (t/is (= "#7f7f7f" (:color result))))) - (t/testing "offset beyond last stop returns last stop color (nil idx guard)" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 0.5}] - result (colors/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result)))))) - diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 6df8243077..2d9a216cbc 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -54,6 +54,7 @@ [common-tests.text-test] [common-tests.time-test] [common-tests.types.absorb-assets-test] + [common-tests.types.color-test] [common-tests.types.components-test] [common-tests.types.container-test] [common-tests.types.fill-test] @@ -126,6 +127,7 @@ 'common-tests.text-test 'common-tests.time-test 'common-tests.types.absorb-assets-test + 'common-tests.types.color-test 'common-tests.types.components-test 'common-tests.types.container-test 'common-tests.types.fill-test diff --git a/common/test/common_tests/types/color_test.cljc b/common/test/common_tests/types/color_test.cljc new file mode 100644 index 0000000000..9a3ab00ac9 --- /dev/null +++ b/common/test/common_tests/types/color_test.cljc @@ -0,0 +1,166 @@ +;; 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 common-tests.types.color-test + (:require + [app.common.math :as mth] + [app.common.types.color :as colors] + [clojure.test :as t])) + +;; --- Predicates + +(t/deftest valid-hex-color + (t/is (false? (colors/valid-hex-color? nil))) + (t/is (false? (colors/valid-hex-color? ""))) + (t/is (false? (colors/valid-hex-color? "#"))) + (t/is (false? (colors/valid-hex-color? "#qqqqqq"))) + (t/is (true? (colors/valid-hex-color? "#aaa"))) + (t/is (false? (colors/valid-hex-color? "#aaaa"))) + (t/is (true? (colors/valid-hex-color? "#fabada")))) + +(t/deftest valid-rgb-color + (t/is (false? (colors/valid-rgb-color? nil))) + (t/is (false? (colors/valid-rgb-color? ""))) + (t/is (false? (colors/valid-rgb-color? "()"))) + (t/is (true? (colors/valid-rgb-color? "(255, 30, 30)"))) + (t/is (true? (colors/valid-rgb-color? "rgb(255, 30, 30)")))) + +;; --- Conversions + +(t/deftest rgb-to-str + (t/is (= "rgb(1,2,3)" (colors/rgb->str [1 2 3]))) + (t/is (= "rgba(1,2,3,4)" (colors/rgb->str [1 2 3 4])))) + +(t/deftest rgb-to-hsv + (t/is (= [210.0 0.6666666666666666 3.0] (colors/rgb->hsv [1.0 2.0 3.0])))) + +(t/deftest hsv-to-rgb + (t/is (= [1 2 3] + (colors/hsv->rgb [210 0.6666666666666666 3])))) + +(t/deftest rgb-to-hex + (t/is (= "#010203" (colors/rgb->hex [1 2 3])))) + +(t/deftest hex-to-rgb + (t/is (= [0 0 0] (colors/hex->rgb "#kkk"))) + (t/is (= [1 2 3] (colors/hex->rgb "#010203")))) + +(t/deftest format-hsla + (t/is (= "210, 50%, 0.78%, 1" (colors/format-hsla [210.0 0.5 0.00784313725490196 1]))) + (t/is (= "220, 5%, 30%, 0.8" (colors/format-hsla [220.0 0.05 0.3 0.8])))) + +(t/deftest format-rgba + (t/is (= "210, 199, 12, 0.08" (colors/format-rgba [210 199 12 0.08]))) + (t/is (= "210, 199, 12, 1" (colors/format-rgba [210 199 12 1])))) + +(t/deftest rgb-to-hsl + (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3])))) + +(t/deftest hsl-to-rgb + (t/is (= [1 2 3] (colors/hsl->rgb [210.0 0.5 0.00784313725490196]))) + (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3])))) + +(t/deftest expand-hex + (t/is (= "aaaaaa" (colors/expand-hex "a"))) + (t/is (= "aaaaaa" (colors/expand-hex "aa"))) + (t/is (= "aaaaaa" (colors/expand-hex "aaa"))) + (t/is (= "aaaa" (colors/expand-hex "aaaa")))) + +(t/deftest prepend-hash + (t/is "#aaa" (colors/prepend-hash "aaa")) + (t/is "#aaa" (colors/prepend-hash "#aaa"))) + +(t/deftest remove-hash + (t/is "aaa" (colors/remove-hash "aaa")) + (t/is "aaa" (colors/remove-hash "#aaa"))) + +(t/deftest color-string-pred + (t/is (true? (colors/color-string? "#aaa"))) + (t/is (true? (colors/color-string? "(10,10,10)"))) + (t/is (true? (colors/color-string? "rgb(10,10,10)"))) + (t/is (true? (colors/color-string? "magenta"))) + (t/is (false? (colors/color-string? nil))) + (t/is (false? (colors/color-string? ""))) + (t/is (false? (colors/color-string? "kkkkkk")))) + +;; --- Gradient helpers + +(t/deftest interpolate-color + (t/testing "at c1 offset returns c1 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.0)] + (t/is (= "#000000" (:color result))) + (t/is (= 0.0 (:opacity result))))) + (t/testing "at c2 offset returns c2 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 1.0)] + (t/is (= "#ffffff" (:color result))) + (t/is (= 1.0 (:opacity result))))) + (t/testing "at midpoint returns interpolated gray" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.5)] + (t/is (= "#7f7f7f" (:color result))) + (t/is (mth/close? (:opacity result) 0.5))))) + +(t/deftest uniform-spread + (t/testing "produces correct count and offsets" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (= 3 (count stops))) + (t/is (= 0.0 (:offset (first stops)))) + (t/is (mth/close? 0.5 (:offset (second stops)))) + (t/is (= 1.0 (:offset (last stops)))))) + (t/testing "single stop returns a vector of one element (no division by zero)" + (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} + stops (colors/uniform-spread c1 c1 1)] + (t/is (= 1 (count stops)))))) + +(t/deftest uniform-spread? + (t/testing "uniformly spread stops are detected as uniform" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "two-stop gradient is uniform by definition" + (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} + {:color "#0000ff" :opacity 1.0 :offset 1.0}]] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "stops with wrong offset are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#888888" :opacity 0.5 :offset 0.3} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops))))) + (t/testing "stops with correct offset but wrong color are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#aaaaaa" :opacity 0.5 :offset 0.5} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops)))))) + +(t/deftest interpolate-gradient + (t/testing "at start offset returns first stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.0)] + (t/is (= "#000000" (:color result))))) + (t/testing "at end offset returns last stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result))))) + (t/testing "at midpoint returns interpolated gray" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.5)] + (t/is (= "#7f7f7f" (:color result))))) + (t/testing "offset beyond last stop returns last stop color (nil idx guard)" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 0.5}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result)))))) From a2e6abcb72b13843c82a7d88f6e56e166f259e3f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:03:39 +0000 Subject: [PATCH 21/39] :bug: Fix spurious argument to dissoc in patch-object The patch-object function was calling (dissoc object key value) when handling nil values. Since dissoc treats each argument after the map as a key to remove, this was also removing nil as a key from the map. The correct call is (dissoc object key). --- common/src/app/common/data.cljc | 2 +- common/test/common_tests/data_test.cljc | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 4cb6cedc60..7f59c14ad6 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -377,7 +377,7 @@ (assoc object key nil) (nil? value) - (dissoc object key value) + (dissoc object key) :else (assoc object key value))) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 726fc8f377..873f0bb7d1 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -445,6 +445,8 @@ (t/is (= {:a {:x 10 :y 2}} (d/patch-object {:a {:x 1 :y 2}} {:a {:x 10}}))) ;; nested nil removes nested key (t/is (= {:a {:y 2}} (d/patch-object {:a {:x 1 :y 2}} {:a {:x nil}}))) + ;; nil value removes only the specified key, not other keys + (t/is (= {nil 0 :b 2} (d/patch-object {nil 0 :a 1 :b 2} {:a nil}))) ;; transducer arity (1-arg returns a fn) (let [f (d/patch-object {:a 99})] (t/is (= {:a 99 :b 2} (f {:a 1 :b 2}))))) From 057c6ddc0df47331de9d7dd7977317e8e54cb1e6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:04:36 +0000 Subject: [PATCH 22/39] :bug: Fix deep-mapm double-applying mfn on leaf entries The deep-mapm function was applying the mapping function twice on leaf entries (non-map, non-vector values): once when destructuring the entry, and again on the already-transformed result in the else branch. Now mfn is applied exactly once per entry. --- common/src/app/common/data.cljc | 7 ++----- common/test/common_tests/data_test.cljc | 17 ++++++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 7f59c14ad6..93d66780f5 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -602,12 +602,9 @@ (let [do-map (fn [entry] (let [[k v] (mfn entry)] - (cond - (or (vector? v) (map? v)) + (if (or (vector? v) (map? v)) [k (deep-mapm mfn v)] - - :else - (mfn [k v]))))] + [k v])))] (cond (map? m) (into {} (map do-map) m) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 873f0bb7d1..7cad2da911 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -538,17 +538,20 @@ (into [] (d/distinct-xf :id) [{:id 1 :v "a"} {:id 2 :v "x"} {:id 2 :v "b"}])))) (t/deftest deep-mapm-test - ;; Note: mfn is called twice on leaf entries (once initially, once again - ;; after checking if the value is a map/vector), so a doubling fn applied - ;; to value 1 gives 1*2*2=4. - (t/is (= {:a 4 :b {:c 8}} + ;; mfn is applied once per entry + (t/is (= {:a 2 :b {:c 4}} (d/deep-mapm (fn [[k v]] [k (if (number? v) (* v 2) v)]) {:a 1 :b {:c 2}}))) - ;; Keyword renaming: keys are also transformed — and applied twice. - ;; Use an idempotent key transformation (uppercase once = uppercase twice). + ;; Keyword renaming: keys are transformed once per entry (let [result (d/deep-mapm (fn [[k v]] [(keyword (str (name k) "!")) v]) {:a 1})] - (t/is (contains? result (keyword "a!!"))))) + (t/is (contains? result (keyword "a!")))) + ;; Vectors inside maps are recursed into + (t/is (= {:items [{:x 10}]} + (d/deep-mapm (fn [[k v]] [k (if (number? v) (* v 10) v)]) + {:items [{:x 1}]}))) + ;; Plain scalar at top level map + (t/is (= {:a "hello"} (d/deep-mapm identity {:a "hello"})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Numeric helpers From 92dd5d9954d981bfddd5bff5e2bb6300d21c365f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:05:35 +0000 Subject: [PATCH 23/39] :bug: Fix index-of-pred early termination on nil elements The index-of-pred function used (nil? c) to detect end-of-collection, which caused premature termination when the collection contained nil values. Rewrite using (seq coll) / (next s) pattern to correctly distinguish between nil elements and end-of-sequence. --- common/src/app/common/data.cljc | 11 ++++------- common/test/common_tests/data_test.cljc | 11 +++++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 93d66780f5..5f4b4a0cf2 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -291,15 +291,12 @@ (defn index-of-pred [coll pred] - (loop [c (first coll) - coll (rest coll) + (loop [s (seq coll) index 0] - (if (nil? c) - nil - (if (pred c) + (when s + (if (pred (first s)) index - (recur (first coll) - (rest coll) + (recur (next s) (inc index)))))) (defn index-of diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 7cad2da911..f0487ed71d 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -372,12 +372,19 @@ (t/is (= 0 (d/index-of-pred [1 2 3] odd?))) (t/is (= 1 (d/index-of-pred [2 3 4] odd?))) (t/is (nil? (d/index-of-pred [2 4 6] odd?))) - (t/is (nil? (d/index-of-pred [] odd?)))) + (t/is (nil? (d/index-of-pred [] odd?))) + ;; works correctly when collection contains nil elements + (t/is (= 2 (d/index-of-pred [nil nil 3] some?))) + (t/is (= 0 (d/index-of-pred [nil 1 2] nil?))) + ;; works correctly when collection contains false elements + (t/is (= 1 (d/index-of-pred [false true false] true?)))) (t/deftest index-of-test (t/is (= 0 (d/index-of [:a :b :c] :a))) (t/is (= 2 (d/index-of [:a :b :c] :c))) - (t/is (nil? (d/index-of [:a :b :c] :z)))) + (t/is (nil? (d/index-of [:a :b :c] :z))) + ;; works when searching for nil in a collection + (t/is (= 1 (d/index-of [:a nil :c] nil)))) (t/deftest replace-by-id-test (let [items [{:id 1 :v "a"} {:id 2 :v "b"} {:id 3 :v "c"}] From 1cc860807e2246a16bd00a99991fef04cdce3366 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:06:13 +0000 Subject: [PATCH 24/39] :zap: Use seq/next idiom in enumerate instead of empty?/rest Replace (empty? items) + (rest items) with (seq items) + (next items) in enumerate. The seq/next pattern is idiomatic Clojure and avoids the overhead of empty? which internally calls seq and then negates. --- common/src/app/common/data.cljc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 5f4b4a0cf2..2da21aadb0 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -252,13 +252,13 @@ ([items] (enumerate items 0)) ([items start] (loop [idx start - items items + items (seq items) res (transient [])] - (if (empty? items) - (persistent! res) + (if items (recur (inc idx) - (rest items) - (conj! res [idx (first items)])))))) + (next items) + (conj! res [idx (first items)])) + (persistent! res))))) (defn group-by ([kf coll] (group-by kf identity [] coll)) From d73ab3ec92b4af8a445db1545f54af9de614a512 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:11:46 +0000 Subject: [PATCH 25/39] :bug: Fix safe-subvec 3-arity evaluating (count v) before nil check The 3-arity of safe-subvec called (count v) in a let binding before checking (some? v). While (count nil) returns 0 in Clojure and does not crash, the nil guard was dead code. Restructure to check (some? v) first with an outer when, then compute size inside the guarded block. --- common/src/app/common/data.cljc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 2da21aadb0..6b2cc5d3b4 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1149,11 +1149,11 @@ (> start 0) (< start (count v))) (subvec v start))) ([v start end] - (let [size (count v)] - (when (and (some? v) - (>= start 0) (< start size) - (>= end 0) (<= start end) (<= end size)) - (subvec v start end))))) + (when (some? v) + (let [size (count v)] + (when (and (>= start 0) (< start size) + (>= end 0) (<= start end) (<= end size)) + (subvec v start end)))))) (defn append-class [class current-class] From 29ea1cc49548d1a63c33bbdf44447f0d3c45e914 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:12:38 +0000 Subject: [PATCH 26/39] :books: Fix misleading without-obj docstring The docstring claimed the function removes nil values in addition to the specified object, but the implementation only removes elements equal to the given object. Fix the docstring in both data.cljc and the local copy in files/changes.cljc. --- common/src/app/common/data.cljc | 2 +- common/src/app/common/files/changes.cljc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 6b2cc5d3b4..19d4828d13 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -393,7 +393,7 @@ (subvec v (inc index)))) (defn without-obj - "Clear collection from specified obj and without nil values." + "Return a vector with all elements equal to `o` removed." [coll o] (into [] (filter #(not= % o)) coll)) diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 8673ef81e3..c9793434b7 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -439,7 +439,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- without-obj - "Clear collection from specified obj and without nil values." + "Return a vector with all elements equal to `o` removed." [coll o] (into [] (filter #(not= % o)) coll)) From eca9b63d688c87e62a58bfdba9a683c91061ce29 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:13:20 +0000 Subject: [PATCH 27/39] :zap: Remove redundant map lookups in map-diff The :else branch of diff-attr was calling (get m1 key) and (get m2 key) again, but v1 and v2 were already bound to those exact values. Reuse the existing bindings to avoid the extra lookups. --- common/src/app/common/data.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 19d4828d13..1494132f1d 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -952,7 +952,7 @@ (assoc diff key (map-diff v1 v2)) :else - (assoc diff key [(get m1 key) (get m2 key)]))))] + (assoc diff key [v1 v2]))))] (->> keys (reduce diff-attr {})))) From 69e25a4998e006108cc17f6f2817cbc41403be82 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:14:00 +0000 Subject: [PATCH 28/39] :books: Fix typo in namespace docstring ('if' -> 'of') --- common/src/app/common/data.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 1494132f1d..e1efba5de6 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS INC (ns app.common.data - "A collection if helpers for working with data structures and other + "A collection of helpers for working with data structures and other data resources." (:refer-clojure :exclude [read-string hash-map merge name update-vals parse-double group-by iteration concat mapcat From da8e44147c72c5b19b7e0295bd97b730fec8d9b9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:15:39 +0000 Subject: [PATCH 29/39] :sparkles: Remove redundant str call in format-number format-precision already returns a string, so wrapping its result in an additional (str ...) call was unnecessary. --- common/src/app/common/data.cljc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index e1efba5de6..70f775b4c3 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1117,8 +1117,7 @@ ([value {:keys [precision] :or {precision 2}}] (let [value (if (string? value) (parse-double value) value)] (when (num? value) - (let [value (format-precision value precision)] - (str value)))))) + (format-precision value precision))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Util protocols From 83da487b24cf969d99cdba0ba292e406c93f0df6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:16:49 +0000 Subject: [PATCH 30/39] :bug: Fix append-class producing leading space for empty class When called with an empty string as the base class, append-class was producing " bar" (with a leading space) because (some? "") returns true. Use (seq class) instead to treat both nil and empty string as absent, avoiding invalid CSS class strings with leading whitespace. --- common/src/app/common/data.cljc | 5 +++-- common/test/common_tests/data_test.cljc | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 70f775b4c3..715bd97544 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1156,5 +1156,6 @@ (defn append-class [class current-class] - (str (if (some? class) (str class " ") "") - current-class)) + (if (seq class) + (str class " " current-class) + current-class)) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index f0487ed71d..d85e835af9 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -791,7 +791,8 @@ (t/deftest append-class-test (t/is (= "foo bar" (d/append-class "foo" "bar"))) (t/is (= "bar" (d/append-class nil "bar"))) - (t/is (= " bar" (d/append-class "" "bar")))) + ;; empty string is treated like nil — no leading space + (t/is (= "bar" (d/append-class "" "bar")))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Additional helpers (5th batch) From bba3610b7beb9e0f0063a9923fc92bfdbba03d54 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:17:48 +0000 Subject: [PATCH 31/39] :recycle: Rename shadowed 'fn' parameter to 'pred' in removev The removev function used 'fn' as its predicate parameter name, which shadows clojure.core/fn. Rename to 'pred' for clarity and to follow the naming convention used elsewhere in the namespace. --- common/src/app/common/data.cljc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 715bd97544..86a733ee28 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -420,9 +420,9 @@ coll))) (defn removev - "Returns a vector of the items in coll for which (fn item) returns logical false" - [fn coll] - (filterv (comp not fn) coll)) + "Returns a vector of the items in coll for which (pred item) returns logical false" + [pred coll] + (filterv (comp not pred) coll)) (defn filterm "Filter values of a map that satisfy a predicate" From 95d4d42c911e12294def92b09ce398de99801162 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:18:55 +0000 Subject: [PATCH 32/39] :bug: Add missing string? guard to num-string? on JVM The CLJS branch of num-string? checked (string? v) first, but the JVM branch did not. Passing non-string values (nil, keywords, etc.) would rely on exception handling inside parse-double for control flow. Add the string? check for consistency and to avoid using exceptions for normal control flow. --- common/src/app/common/data.cljc | 3 ++- common/test/common_tests/data_test.cljc | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 86a733ee28..e9bb3a918f 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -782,7 +782,8 @@ (not (js/isNaN v)) (not (js/isNaN (parse-double v)))) - :clj (not= (parse-double v :nan) :nan))) + :clj (and (string? v) + (not= (parse-double v :nan) :nan)))) (defn read-string [v] diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index d85e835af9..3228ec0298 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -841,6 +841,9 @@ (t/is (d/num-string? "-7")) (t/is (not (d/num-string? "hello"))) (t/is (not (d/num-string? nil))) + ;; non-string types always return false + (t/is (not (d/num-string? 42))) + (t/is (not (d/num-string? :keyword))) ;; In CLJS, js/isNaN("") → false (empty string coerces to 0), so "" is numeric #?(:clj (t/is (not (d/num-string? "")))) #?(:cljs (t/is (d/num-string? "")))) From b26ef158ef7f9abec223486504aa6e0ecf73704a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:21:30 +0000 Subject: [PATCH 33/39] :books: Fix typos in vec2, zip-all, and map-perm docstrings --- common/src/app/common/data.cljc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index e9bb3a918f..258b506895 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -143,7 +143,7 @@ (oassoc-in o (cons k ks) v))) (defn vec2 - "Creates a optimized vector compatible type of length 2 backed + "Creates an optimized vector compatible type of length 2 backed internally with MapEntry impl because it has faster access method for its fields." [o1 o2] @@ -401,7 +401,7 @@ (map vector col1 col2)) (defn zip-all - "Return a zip of both collections, extended to the lenght of the longest one, + "Return a zip of both collections, extended to the length of the longest one, and padding the shorter one with nils as needed." [col1 col2] (let [diff (- (count col1) (count col2))] @@ -440,7 +440,7 @@ Optional parameters: `pred?` A predicate that if not satisfied won't process the pair - `target?` A collection that will be used as seed to be stored + `target` A collection that will be used as seed to be stored Example: (map-perm vector [1 2 3 4]) => [[1 2] [1 3] [1 4] [2 3] [2 4] [3 4]]" From 176edadb6f22ded8c7f1b0458b8b03f948d076c0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 22:57:29 +0000 Subject: [PATCH 34/39] :bug: Fix nan? returning false for ##NaN on JVM Clojure's = uses .equals on doubles, and Double.equals(Double.NaN) returns true, so (not= v v) was always false for NaN. Use Double/isNaN with a number? guard instead. --- common/src/app/common/data.cljc | 2 +- common/test/common_tests/data_test.cljc | 22 +++++++--------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 258b506895..4c03438ed2 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -718,7 +718,7 @@ (defn nan? [v] #?(:cljs (js/isNaN v) - :clj (not= v v))) + :clj (and (number? v) (Double/isNaN v)))) (defn- impl-parse-integer [v] diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 3228ec0298..ad5d6a21a2 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -565,16 +565,13 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (t/deftest nan-test - ;; Note: nan? behaves differently per platform: - ;; - CLJS: uses js/isNaN, returns true for ##NaN - ;; - CLJ: uses (not= v v); Clojure's = uses .equals on doubles, - ;; so (= ##NaN ##NaN) is true and nan? returns false for ##NaN. - ;; Either way, nan? returns false for regular numbers and nil. + (t/is (d/nan? ##NaN)) (t/is (not (d/nan? 0))) (t/is (not (d/nan? 1))) (t/is (not (d/nan? nil))) - ;; Platform-specific: JS nan? correctly detects NaN - #?(:cljs (t/is (d/nan? ##NaN)))) + ;; CLJS js/isNaN coerces non-numbers; JVM Double/isNaN is number-only + #?(:cljs (t/is (d/nan? "hello"))) + #?(:clj (t/is (not (d/nan? "hello"))))) (t/deftest safe-plus-test (t/is (= 5 (d/safe+ 3 2))) @@ -618,18 +615,13 @@ (t/is (nil? (d/parse-uuid nil)))) (t/deftest coalesce-str-test - ;; On JVM: nan? uses (not= v v), which is false for all normal values. - ;; On CLJS: nan? uses js/isNaN, which is true for non-numeric strings. - ;; coalesce-str returns default when value is nil or nan?. (t/is (= "default" (d/coalesce-str nil "default"))) ;; Numbers always stringify on both platforms (t/is (= "42" (d/coalesce-str 42 "default"))) - ;; ##NaN: nan? is true in CLJS, returns default; - ;; nan? is false in CLJ, so str(##NaN)="NaN" is returned. - #?(:cljs (t/is (= "default" (d/coalesce-str ##NaN "default")))) - #?(:clj (t/is (= "NaN" (d/coalesce-str ##NaN "default")))) + ;; ##NaN returns default on both platforms now that nan? is fixed on JVM + (t/is (= "default" (d/coalesce-str ##NaN "default"))) ;; Strings: in CLJS js/isNaN("hello")=true so "default" is returned; - ;; in CLJ nan? is false so (str "hello")="hello" is returned. + ;; in CLJ nan? is false for strings so (str "hello")="hello" is returned. #?(:cljs (t/is (= "default" (d/coalesce-str "hello" "default")))) #?(:clj (t/is (= "hello" (d/coalesce-str "hello" "default"))))) From 2e97f0183817fc2a422975f0a983874edd68d5c2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 22:58:13 +0000 Subject: [PATCH 35/39] :bug: Fix safe-subvec 2-arity rejecting start=0 The guard used (> start 0) instead of (>= start 0), so (safe-subvec v 0) returned nil instead of the full vector. --- common/src/app/common/data.cljc | 2 +- common/test/common_tests/data_test.cljc | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 4c03438ed2..75f103e496 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1146,7 +1146,7 @@ "Wrapper around subvec so it doesn't throw an exception but returns nil instead" ([v start] (when (and (some? v) - (> start 0) (< start (count v))) + (>= start 0) (< start (count v))) (subvec v start))) ([v start end] (when (some? v) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index ad5d6a21a2..b8283ca49e 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -325,6 +325,8 @@ (t/is (= [2 3] (d/safe-subvec [1 2 3 4] 1 3))) ;; single arg — from index to end (t/is (= [2 3 4] (d/safe-subvec [1 2 3 4] 1))) + ;; start=0 returns the full vector + (t/is (= [1 2 3 4] (d/safe-subvec [1 2 3 4] 0))) ;; out-of-range returns nil (t/is (nil? (d/safe-subvec [1 2 3] 5))) (t/is (nil? (d/safe-subvec [1 2 3] 0 5))) From f5271dabeed15990f9e6b9e68482c9c26ecf4496 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 15 Apr 2026 23:37:04 +0200 Subject: [PATCH 36/39] :bug: Fix error handling issues (#8962) * :ambulance: Fix RangeError from re-entrant error handling in errors.cljs Two complementary changes to prevent 'RangeError: Maximum call stack size exceeded' when an error fires while the potok store error pipeline is still on the call stack: 1. Re-entrancy guard on on-error: a volatile flag (handling-error?) is set true for the duration of each on-error invocation. Any nested call (e.g. from a notification emit that itself throws) is suppressed with a console.error instead of recursing indefinitely. 2. Async notification in flash: the st/emit!(ntf/show ...) call is now wrapped in ts/schedule (setTimeout 0) so the notification event is pushed to the store on the next event-loop tick, outside the error-handler call stack. This matches the pattern already used by the :worker-error, :svg-parser and :comment-error handlers. * :bug: Add unit tests for app.main.errors Test coverage for the error-handling module: - stale-asset-error?: 6 cases covering keyword-constant and protocol-dispatch mismatch signatures, plus negative cases - exception->error-data: plain JS Error, ex-info with/without :hint - on-error dispatch: map errors routed via ptk/handle-error, JS exceptions wrapped into error-data before dispatch - Re-entrancy guard: verifies that a second on-error call issued from within a handle-error method is suppressed (exactly one handler invocation) --------- Signed-off-by: Andrey Antukh --- frontend/src/app/main/errors.cljs | 48 +++++-- .../test/frontend_tests/main_errors_test.cljs | 136 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 3 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 frontend/test/frontend_tests/main_errors_test.cljs diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 223e916261..37177aec7d 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -33,6 +33,12 @@ ;; Will contain last uncaught exception (def last-exception nil) +;; Re-entrancy guard: prevents on-error from calling itself recursively. +;; If an error occurs while we are already handling an error (e.g. the +;; notification emit itself throws), we log it and bail out immediately +;; instead of recursing until the call-stack overflows. +(def ^:private handling-error? (volatile! false)) + ;; --- Stale-asset error detection and auto-reload ;; ;; When the browser loads JS modules from different builds (e.g. shared.js from @@ -80,12 +86,24 @@ (assoc ::trace (.-stack cause))))) (defn on-error - "A general purpose error handler." + "A general purpose error handler. + + Protected by a re-entrancy guard: if an error is raised while this + function is already on the call stack (e.g. the notification emit + itself fails), we print it to the console and return immediately + instead of recursing until the call-stack is exhausted." [error] - (if (map? error) - (ptk/handle-error error) - (let [data (exception->error-data error)] - (ptk/handle-error data)))) + (if @handling-error? + (.error js/console "[on-error] re-entrant call suppressed" error) + (do + (vreset! handling-error? true) + (try + (if (map? error) + (ptk/handle-error error) + (let [data (exception->error-data error)] + (ptk/handle-error data))) + (finally + (vreset! handling-error? false)))))) ;; Inject dependency to remove circular dependency (set! app.main.worker/on-error on-error) @@ -138,7 +156,14 @@ :report report})))) (defn flash - "Show error notification banner and emit error report" + "Show error notification banner and emit error report. + + The notification is scheduled asynchronously (via tm/schedule) to + avoid pushing a new event into the potok store while the store's own + error-handling pipeline is still on the call stack. Emitting + synchronously from inside an error handler creates a re-entrant + event-processing cycle that can exhaust the JS call stack + (RangeError: Maximum call stack size exceeded)." [& {:keys [type hint cause] :or {type :handled}}] (when (ex/exception? cause) (when-let [event-name (case type @@ -150,11 +175,12 @@ :report report :hint (ex/get-hint cause))))) - (st/emit! - (ntf/show {:content (or ^boolean hint (tr "errors.generic")) - :type :toast - :level :error - :timeout 5000}))) + (ts/schedule + #(st/emit! + (ntf/show {:content (or ^boolean hint (tr "errors.generic")) + :type :toast + :level :error + :timeout 5000})))) (defmethod ptk/handle-error :network [error] diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs new file mode 100644 index 0000000000..5dc1747658 --- /dev/null +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -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.main-errors-test + "Unit tests for app.main.errors. + + Tests cover: + - stale-asset-error? – pure predicate + - exception->error-data – pure transformer + - on-error re-entrancy guard – prevents recursive invocations + - flash schedules async emit – ntf/show is not emitted synchronously" + (:require + [app.main.errors :as errors] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +;; --------------------------------------------------------------------------- +;; stale-asset-error? +;; --------------------------------------------------------------------------- + +(t/deftest stale-asset-error-nil + (t/testing "nil cause returns nil/falsy" + (t/is (not (errors/stale-asset-error? nil))))) + +(t/deftest stale-asset-error-keyword-cst-undefined + (t/testing "error with $cljs$cst$ and 'is undefined' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is undefined")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-keyword-cst-null + (t/testing "error with $cljs$cst$ and 'is null' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is null")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-protocol-dispatch-undefined + (t/testing "error with $cljs$core$I and 'Cannot read properties of undefined' is recognised" + (let [err (js/Error. "Cannot read properties of undefined (reading '$cljs$core$IFn$_invoke$arity$1$')")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-not-a-function + (t/testing "error with $cljs$cst$ and 'is not a function' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is not a function")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-unrelated-message + (t/testing "ordinary error without stale-asset signature is NOT recognised" + (let [err (js/Error. "Cannot read properties of undefined (reading 'foo')")] + (t/is (not (errors/stale-asset-error? err)))))) + +(t/deftest stale-asset-error-only-cst-no-undefined + (t/testing "error with $cljs$cst$ but no undefined/null/not-a-function keyword is not recognised" + (let [err (js/Error. "foo$cljs$cst$bar exploded")] + (t/is (not (errors/stale-asset-error? err)))))) + +;; --------------------------------------------------------------------------- +;; exception->error-data +;; --------------------------------------------------------------------------- + +(t/deftest exception->error-data-plain-error + (t/testing "plain JS Error is converted to a data map with :hint and ::instance" + (let [err (js/Error. "something went wrong") + data (errors/exception->error-data err)] + (t/is (= "something went wrong" (:hint data))) + (t/is (identical? err (::errors/instance data)))))) + +(t/deftest exception->error-data-ex-info + (t/testing "ex-info error preserves existing :hint and attaches ::instance" + (let [err (ex-info "original" {:hint "my-hint" :type :network}) + data (errors/exception->error-data err)] + (t/is (= "my-hint" (:hint data))) + (t/is (= :network (:type data))) + (t/is (identical? err (::errors/instance data)))))) + +(t/deftest exception->error-data-ex-info-no-hint + (t/testing "ex-info without :hint falls back to ex-message" + (let [err (ex-info "fallback message" {:type :validation}) + data (errors/exception->error-data err)] + (t/is (= "fallback message" (:hint data)))))) + +;; --------------------------------------------------------------------------- +;; on-error dispatches to ptk/handle-error +;; +;; We use a dedicated test-only error type so we can add/remove a +;; defmethod without touching the real handlers. +;; --------------------------------------------------------------------------- + +(def ^:private test-handled (atom nil)) + +(defmethod ptk/handle-error ::test-dispatch + [err] + (reset! test-handled err)) + +(t/deftest on-error-dispatches-map-error + (t/testing "on-error dispatches a map error to ptk/handle-error using its :type" + (reset! test-handled nil) + (errors/on-error {:type ::test-dispatch :hint "hello"}) + (t/is (= ::test-dispatch (:type @test-handled))) + (t/is (= "hello" (:hint @test-handled))))) + +(t/deftest on-error-wraps-exception-then-dispatches + (t/testing "on-error wraps a JS Error into error-data before dispatching" + (reset! test-handled nil) + (let [err (ex-info "wrapped" {:type ::test-dispatch})] + (errors/on-error err) + (t/is (= ::test-dispatch (:type @test-handled))) + (t/is (identical? err (::errors/instance @test-handled)))))) + +;; --------------------------------------------------------------------------- +;; on-error re-entrancy guard +;; +;; The guard is implemented via the `handling-error?` volatile inside +;; app.main.errors. We can verify its effect by registering a +;; handle-error method that itself calls on-error and checking that +;; only one invocation gets through. +;; --------------------------------------------------------------------------- + +(def ^:private reentrant-call-count (atom 0)) + +(defmethod ptk/handle-error ::test-reentrant + [_err] + (swap! reentrant-call-count inc) + ;; Simulate a secondary error inside the error handler + ;; (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"}))) + +(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"}) + ;; The guard must have allowed only the first invocation through. + (t/is (= 1 @reentrant-call-count)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 3cd38c12f0..003e68264c 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -14,6 +14,7 @@ [frontend-tests.logic.frame-guides-test] [frontend-tests.logic.groups-test] [frontend-tests.logic.pasting-in-containers-test] + [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] @@ -41,6 +42,7 @@ (t/run-tests 'frontend-tests.basic-shapes-test 'frontend-tests.data.repo-test + 'frontend-tests.main-errors-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test 'frontend-tests.data.workspace-texts-test From de27ea904da50fc5362365c43b25de13e9c3bcd5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 09:59:45 +0200 Subject: [PATCH 37/39] :sparkles: Add minor adjustments to the auth events (#9027) --- backend/scripts/_env | 4 ++++ backend/src/app/rpc/commands/auth.clj | 14 ++++++++++---- frontend/src/app/config.cljs | 5 +++++ frontend/src/app/main/ui/auth/register.cljs | 1 + frontend/src/app/main/ui/auth/verify_token.cljs | 2 ++ 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/scripts/_env b/backend/scripts/_env index 0026d9f901..e6ff68b7f4 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -44,6 +44,10 @@ export PENPOT_FLAGS="\ enable-redis-cache \ enable-subscriptions"; +# Uncomment for nexus integration testing +# export PENPOT_FLAGS="$PENPOT_FLAGS enable-audit-log-archive"; +# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"; + # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index f3466f6d21..c3592d790c 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -446,6 +446,7 @@ (when (:create-welcome-file params) (let [cfg (dissoc cfg ::db/conn)] (wrk/submit! executor (create-welcome-file cfg profile)))))] + (cond ;; When profile is blocked, we just ignore it and return plain data (:is-blocked profile) @@ -453,7 +454,8 @@ (l/wrn :hint "register attempt for already blocked profile" :profile-id (str (:id profile)) :profile-email (:email profile)) - (rph/with-meta {:email (:email profile)} + (rph/with-meta {:id (:id profile) + :email (:email profile)} {::audit/replace-props props ::audit/context {:action "ignore-because-blocked"} ::audit/profile-id (:id profile) @@ -469,7 +471,9 @@ (:member-email invitation))) (let [invitation (assoc invitation :member-id (:id profile)) token (tokens/generate cfg invitation)] - (-> {:invitation-token token} + (-> {:id (:id profile) + :email (:email profile) + :invitation-token token} (rph/with-transform (session/create-fn cfg profile claims)) (rph/with-meta {::audit/replace-props props ::audit/context {:action "accept-invitation"} @@ -492,7 +496,8 @@ (when-not (eml/has-reports? conn (:email profile)) (send-email-verification! cfg profile)) - (-> {:email (:email profile)} + (-> {:id (:id profile) + :email (:email profile)} (rph/with-defer create-welcome-file-when-needed) (rph/with-meta {::audit/replace-props props @@ -519,7 +524,8 @@ {:id (:id profile)}) (send-email-verification! cfg profile)) - (rph/with-meta {:email (:email profile)} + (rph/with-meta {:email (:email profile) + :id (:id profile)} {::audit/replace-props (audit/profile->props profile) ::audit/context {:action action} ::audit/profile-id (:id profile) diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index f1c1e2b8bf..75f5010280 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -195,6 +195,11 @@ (let [f (obj/get global "externalContextInfo")] (when (fn? f) (f)))) +(defn external-notify-register-success + [profile-id] + (let [f (obj/get global "externalNotifyRegisterSuccess")] + (when (fn? f) (f (str profile-id))))) + (defn initialize-external-context-info [] (let [f (obj/get global "initializeExternalConfigInfo")] diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 3bd3fdf564..917b272dd9 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -276,6 +276,7 @@ (mf/use-fn (mf/deps on-success-callback) (fn [params] + (cf/external-notify-register-success (:id params)) (if (fn? on-success-callback) (on-success-callback (:email params)) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 334303ade4..16e818e4b2 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -6,6 +6,7 @@ (ns app.main.ui.auth.verify-token (:require + [app.config :as cf] [app.main.data.auth :as da] [app.main.data.common :as dcm] [app.main.data.notifications :as ntf] @@ -25,6 +26,7 @@ (defmethod handle-token :verify-email [data] + (cf/external-notify-register-success (:profile-id data)) (let [msg (tr "dashboard.notifications.email-verified-successfully")] (ts/schedule 1000 #(st/emit! (ntf/success msg))) (st/emit! (da/login-from-token data)))) From 390796f36e3982954feaf11694bc92a171cfba74 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 10:20:05 +0200 Subject: [PATCH 38/39] :paperclip: Update changelog --- CHANGES.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 58231d2543..da62fdd4f4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,12 @@ # CHANGELOG -## 2.14.3 (Unreleased) +## 2.14.3 ### :sparkles: New features & Enhancements - Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870) +- Add minor adjustments to the auth events [Github #9027](https://github.com/penpot/penpot/pull/9027) +- Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957) ### :bug: Bugs fixed @@ -17,6 +19,16 @@ - Fix path drawing preview passing shape instead of content to next-node - Fix swapped arguments in CLJS PathData `-nth` with default - Normalize PathData coordinates to safe integer bounds on read +- Fix RangeError from re-entrant error handling causing stack overflow [Github #8962](https://github.com/penpot/penpot/pull/8962) +- Fix builder bool styles and media validation [Github #8963](https://github.com/penpot/penpot/pull/8963) +- Fix "Move to" menu allowing same project as target when multiple files are selected +- Fix crash when index query param is duplicated in URL +- Fix wrong extremity point in path `calculate-extremities` for line-to segments +- Fix reversed args in DTCG shadow composite token conversion +- Fix `inside-layout?` passing shape id instead of shape to `frame-shape?` +- Fix wrong `mapcat` call in `collect-main-shapes` +- Fix stale accumulator in `get-children-in-instance` recursion +- Fix typo `:podition` in swap-shapes grid cell ## 2.14.2 From 69e505a6a2e599dd8e690f71c02b444966a9fa46 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 10:21:15 +0200 Subject: [PATCH 39/39] :paperclip: Update changelog --- CHANGES.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index da62fdd4f4..0d431c0d2b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,6 @@ ### :sparkles: New features & Enhancements - Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870) -- Add minor adjustments to the auth events [Github #9027](https://github.com/penpot/penpot/pull/9027) - Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957) ### :bug: Bugs fixed