From 884cdbbf8de760847ba3440859745c4696bf4890 Mon Sep 17 00:00:00 2001 From: Juan de la Cruz Date: Mon, 23 Mar 2026 11:20:37 +0100 Subject: [PATCH 1/6] :sparkles: Add new MCP plugin UI changes (#8699) * :sparkles: Add new MCP plugin UI changes * :paperclip: Fix tool status misleading --- mcp/packages/plugin/index.html | 77 +++++++++- mcp/packages/plugin/public/icon.jpg | Bin 0 -> 7632 bytes mcp/packages/plugin/public/manifest.json | 1 + mcp/packages/plugin/src/main.ts | 99 ++++++++++--- mcp/packages/plugin/src/plugin.ts | 4 +- mcp/packages/plugin/src/style.css | 176 ++++++++++++++++++++++- 6 files changed, 329 insertions(+), 28 deletions(-) create mode 100644 mcp/packages/plugin/public/icon.jpg diff --git a/mcp/packages/plugin/index.html b/mcp/packages/plugin/index.html index b2c08b5dae..fa573c1d0e 100644 --- a/mcp/packages/plugin/index.html +++ b/mcp/packages/plugin/index.html @@ -3,12 +3,83 @@ - Penpot plugin example + Penpot MCP Plugin - +
+
+ + Not connected +
-
Not connected
+ + + +
+ + + Execution status + + +
+ Current task +
+ + --- +
+ +
+ Executed code + +
+ +
+
+
diff --git a/mcp/packages/plugin/public/icon.jpg b/mcp/packages/plugin/public/icon.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9df8dd26a4a08fea0c10604f732f7ceb204b7bf4 GIT binary patch literal 7632 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 M+yAL{;b-Ij2S*HOvH$=8 literal 0 HcmV?d00001 diff --git a/mcp/packages/plugin/public/manifest.json b/mcp/packages/plugin/public/manifest.json index e2a769c7f8..aa97095b30 100644 --- a/mcp/packages/plugin/public/manifest.json +++ b/mcp/packages/plugin/public/manifest.json @@ -1,6 +1,7 @@ { "name": "Penpot MCP Plugin", "code": "plugin.js", + "icon": "icon.jpg", "version": 2, "description": "This plugin enables interaction with the Penpot MCP server", "permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"] diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index da87fa025f..8aad137ec3 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -6,21 +6,33 @@ document.body.dataset.theme = searchParams.get("theme") ?? "light"; // WebSocket connection management let ws: WebSocket | null = null; -const statusElement = document.getElementById("connection-status"); + +const statusPill = document.getElementById("connection-status") as HTMLElement; +const statusText = document.getElementById("status-text") as HTMLElement; +const currentTaskEl = document.getElementById("current-task") as HTMLElement; +const executedCodeEl = document.getElementById("executed-code") as HTMLTextAreaElement; +const copyCodeBtn = document.getElementById("copy-code-btn") as HTMLButtonElement; +const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement; +const disconnectBtn = document.getElementById("disconnect-btn") as HTMLButtonElement; /** - * Updates the connection status display element. + * Updates the status pill and button visibility based on connection state. * - * @param status - the base status text to display - * @param isConnectedState - whether the connection is in a connected state (affects color) - * @param message - optional additional message to append to the status + * @param code - the connection state code ("idle" | "connecting" | "connected" | "disconnected" | "error") + * @param label - human-readable label to display inside the pill */ -function updateConnectionStatus(code: string, status: string, isConnectedState: boolean, message?: string): void { - if (statusElement) { - const displayText = message ? `${status}: ${message}` : status; - statusElement.textContent = displayText; - statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)"; +function updateConnectionStatus(code: string, label: string): void { + if (statusPill) { + statusPill.dataset.status = code; } + if (statusText) { + statusText.textContent = label; + } + + const isConnected = code === "connected"; + if (connectBtn) connectBtn.hidden = isConnected; + if (disconnectBtn) disconnectBtn.hidden = !isConnected; + parent.postMessage( { type: "update-connection-status", @@ -30,6 +42,34 @@ function updateConnectionStatus(code: string, status: string, isConnectedState: ); } +/** + * Updates the "Current task" display with the currently executing task name. + * + * @param taskName - the task name to display, or null to reset to "---" + */ +function updateCurrentTask(taskName: string | null): void { + if (currentTaskEl) { + currentTaskEl.textContent = taskName ?? "---"; + } + if (taskName === null) { + updateExecutedCode(null); + } +} + +/** + * Updates the executed code textarea with the last code run during task execution. + * + * @param code - the code string to display, or null to clear + */ +function updateExecutedCode(code: string | null): void { + if (executedCodeEl) { + executedCodeEl.value = code ?? ""; + } + if (copyCodeBtn) { + copyCodeBtn.disabled = !code; + } +} + /** * Sends a task response back to the MCP server via WebSocket. * @@ -49,7 +89,7 @@ function sendTaskResponse(response: any): void { */ function connectToMcpServer(baseUrl?: string, token?: string): void { if (ws?.readyState === WebSocket.OPEN) { - updateConnectionStatus("connected", "Already connected", true); + updateConnectionStatus("connected", "Connected"); return; } @@ -62,17 +102,22 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { } ws = new WebSocket(wsUrl); - updateConnectionStatus("connecting", "Connecting...", false); + updateConnectionStatus("connecting", "Connecting..."); ws.onopen = () => { console.log("Connected to MCP server"); - updateConnectionStatus("connected", "Connected to MCP server", true); + updateConnectionStatus("connected", "Connected"); }; ws.onmessage = (event) => { try { console.log("Received from MCP server:", event.data); const request = JSON.parse(event.data); + // Track the current task received from the MCP server + if (request.task) { + updateCurrentTask(request.task); + updateExecutedCode(request.params?.code ?? null); + } // Forward the task request to the plugin for execution parent.postMessage(request, "*"); } catch (error) { @@ -84,8 +129,9 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { // If we've send the error update we don't send the disconnect as well if (!wsError) { console.log("Disconnected from MCP server"); - const message = event.reason || undefined; - updateConnectionStatus("disconnected", "Disconnected", false, message); + const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected"; + updateConnectionStatus("disconnected", label); + updateCurrentTask(null); } ws = null; }; @@ -94,19 +140,34 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { console.error("WebSocket error:", error); wsError = error; // note: WebSocket error events typically don't contain detailed error messages - updateConnectionStatus("error", "Connection error", false); + updateConnectionStatus("error", "Connection error"); }; } catch (error) { console.error("Failed to connect to MCP server:", error); - const message = error instanceof Error ? error.message : undefined; - updateConnectionStatus("error", "Connection failed", false, message); + const reason = error instanceof Error ? error.message : undefined; + const label = reason ? `Connection failed: ${reason}` : "Connection failed"; + updateConnectionStatus("error", label); } } -document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => { +copyCodeBtn?.addEventListener("click", () => { + const code = executedCodeEl?.value; + if (!code) return; + + navigator.clipboard.writeText(code).then(() => { + copyCodeBtn.classList.add("copied"); + setTimeout(() => copyCodeBtn.classList.remove("copied"), 1500); + }); +}); + +connectBtn?.addEventListener("click", () => { connectToMcpServer(); }); +disconnectBtn?.addEventListener("click", () => { + ws?.close(); +}); + // Listen plugin.ts messages window.addEventListener("message", (event) => { if (event.data.type === "start-server") { diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index e113f7adc3..e2b5bee38e 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -10,8 +10,8 @@ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()]; // Open the plugin UI (main.ts) penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { - width: 158, - height: 200, + width: 236, + height: 210, hidden: !!mcp, } as any); diff --git a/mcp/packages/plugin/src/style.css b/mcp/packages/plugin/src/style.css index 030f2204e9..7061657b33 100644 --- a/mcp/packages/plugin/src/style.css +++ b/mcp/packages/plugin/src/style.css @@ -1,10 +1,178 @@ @import "@penpot/plugin-styles/styles.css"; body { - line-height: 1.5; - padding: 10px; + margin: 0; + padding: 0; } -p { - margin-block-end: 0.75rem; +.plugin-container { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16) var(--spacing-8); + box-sizing: border-box; +} + +/* ── Status pill ─────────────────────────────────────────────────── */ + +.status-pill { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); + border-radius: var(--spacing-8); + border: 1px solid var(--background-quaternary); + color: var(--foreground-secondary); + width: 100%; + box-sizing: border-box; +} + +.status-pill[data-status="connected"] { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.status-pill[data-status="disconnected"], +.status-pill[data-status="error"] { + border-color: var(--error-500); + color: var(--error-500); +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: currentColor; + flex-shrink: 0; +} + +/* ── Collapsible section ─────────────────────────────────────────── */ + +.collapsible-section { + border: 1px solid var(--background-quaternary); + border-radius: var(--spacing-8); + overflow: hidden; +} + +.collapsible-header { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + cursor: pointer; + color: var(--foreground-secondary); + list-style: none; + user-select: none; +} + +.collapsible-header::-webkit-details-marker { + display: none; +} + +.collapsible-arrow { + flex-shrink: 0; + transition: transform 0.2s ease; +} + +details[open] > .collapsible-header .collapsible-arrow { + transform: rotate(90deg); +} + +.collapsible-body { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-12) var(--spacing-12); + border-top: 1px solid var(--background-quaternary); +} + +/* ── Tool section ────────────────────────────────────────────────── */ + +.tool-label { + color: var(--foreground-secondary); +} + +.tool-display { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + min-height: 32px; + box-sizing: border-box; +} + +.tool-icon { + flex-shrink: 0; + opacity: 0.7; +} + +/* ── Code section ────────────────────────────────────────────────── */ + +.code-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: var(--spacing-8); +} + +.code-textarea { + width: 100%; + height: 100px; + resize: vertical; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + border: 1px solid var(--background-quaternary); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + font-family: monospace; + font-size: 11px; + line-height: 1.5; + box-sizing: border-box; + outline: none; +} + +.copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--background-quaternary); + border-radius: var(--spacing-4); + background-color: transparent; + color: var(--foreground-secondary); + cursor: pointer; + flex-shrink: 0; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.copy-btn:hover:not(:disabled) { + background-color: var(--background-tertiary); + color: var(--foreground-primary); +} + +.copy-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.copy-btn.copied { + color: var(--accent-primary); + border-color: var(--accent-primary); +} + +/* ── Action buttons ──────────────────────────────────────────────── */ + +#connect-btn, +#disconnect-btn { + width: 100%; + margin-top: var(--spacing-4); } From 43cdb910631efbd6aa6836351e18b5367cc4890c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Mar 2026 12:11:27 +0100 Subject: [PATCH 2/6] :recycle: Recycle frontend tests with wasm mocks (#8681) --- .../test/frontend_tests/helpers/wasm.cljs | 192 +++++++++ .../logic/components_and_tokens.cljs | 11 +- .../logic/frame_guides_test.cljs | 63 +-- .../plugins/context_shapes_test.cljs | 394 +++++++++--------- frontend/test/frontend_tests/runner.cljs | 4 - .../tokens/logic/token_actions_test.cljs | 58 ++- 6 files changed, 484 insertions(+), 238 deletions(-) create mode 100644 frontend/test/frontend_tests/helpers/wasm.cljs diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs new file mode 100644 index 0000000000..f64d7b7d59 --- /dev/null +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -0,0 +1,192 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.helpers.wasm + "Test helpers for mocking WASM API boundary functions. + + In the Node.js test environment the WASM binary is not available, + but the `render-wasm/v1` feature flag is enabled by default, so + every geometry-modifying event takes the WASM code path. + This namespace provides lightweight mock implementations that let + the Clojure-side logic execute normally while stubbing out every + call that would touch the WASM heap. + + Each mock tracks how many times it was called via `call-counts`. + Use `(call-count :propagate-modifiers)` in test assertions to + verify the WASM code path was exercised." + (:require + [app.common.data :as d] + [app.render-wasm.api :as wasm.api] + [app.render-wasm.api.fonts :as wasm.fonts])) + +;; --- Call tracking --------------------------------------------------- + +(def ^:private call-counts + "Atom holding a map of mock-name → number of calls since last reset." + (atom {})) + +(defn- track! + "Increment the call count for `mock-name`." + [mock-name] + (swap! call-counts update mock-name (fnil inc 0))) + +(defn call-count + "Return how many times mock `mock-name` was called since setup. + `mock-name` is a keyword, e.g. `:propagate-modifiers`." + [mock-name] + (get @call-counts mock-name 0)) + +(defn reset-call-counts! + "Reset all call counts to zero." + [] + (reset! call-counts {})) + +;; --- Mock implementations -------------------------------------------- + +(defn- mock-propagate-modifiers + "Passthrough mock for `wasm.api/propagate-modifiers`. + + Receives `entries` — a vector of `[uuid {:transform matrix, :kind kw}]` + pairs produced by `parse-geometry-modifiers` — and returns a vector + of `[uuid matrix]` pairs that `apply-wasm-modifiers` converts to a + transforms map via `(into {} result)`. + + This effectively tells the caller \"apply exactly the transform that + was requested\", which is what the real WASM engine does for simple + moves / resizes without constraints." + [entries _pixel-precision] + (track! :propagate-modifiers) + (when (d/not-empty? entries) + (into [] + (map (fn [[id data]] + (d/vec2 id (:transform data)))) + entries))) + +(defn- mock-clean-modifiers + [] + (track! :clean-modifiers) + nil) + +(defn- mock-set-structure-modifiers + [_entries] + (track! :set-structure-modifiers) + nil) + +(defn- mock-set-shape-grow-type + [_grow-type] + (track! :set-shape-grow-type) + nil) + +(defn- mock-set-shape-text-content + [_shape-id _content] + (track! :set-shape-text-content) + nil) + +(defn- mock-set-shape-text-images + ([_shape-id _content] + (track! :set-shape-text-images) + nil) + ([_shape-id _content _thumbnail?] + (track! :set-shape-text-images) + nil)) + +(defn- mock-get-text-dimensions + ([] + (track! :get-text-dimensions) + {:x 0 :y 0 :width 100 :height 20 :max-width 100}) + ([_id] + (track! :get-text-dimensions) + {:x 0 :y 0 :width 100 :height 20 :max-width 100})) + +(defn- mock-font-stored? + [_font-data _emoji?] + (track! :font-stored?) + true) + +(defn- mock-make-font-data + [font] + (track! :make-font-data) + {:wasm-id 0 + :weight (or (:font-weight font) "400") + :style (or (:font-style font) "normal") + :emoji? false}) + +(defn- mock-get-content-fonts + [_content] + (track! :get-content-fonts) + []) + +;; --- Persistent mock installation via `set!` -------------------------- +;; +;; These use `set!` to directly mutate the module-level JS vars, making +;; the mocks persist across async boundaries. They are intended to be +;; used with `t/use-fixtures :each` which correctly sequences `:after` +;; to run only after the async test's `done` callback fires. + +(def ^:private originals + "Stores the original WASM function values so they can be restored." + (atom {})) + +(defn setup-wasm-mocks! + "Install WASM mocks via `set!` that persist across async boundaries. + Also resets call counts. Call `teardown-wasm-mocks!` to restore." + [] + ;; Reset call tracking + (reset-call-counts!) + ;; Save originals + (reset! originals + {:clean-modifiers wasm.api/clean-modifiers + :set-structure-modifiers wasm.api/set-structure-modifiers + :propagate-modifiers wasm.api/propagate-modifiers + :set-shape-grow-type wasm.api/set-shape-grow-type + :set-shape-text-content wasm.api/set-shape-text-content + :set-shape-text-images wasm.api/set-shape-text-images + :get-text-dimensions wasm.api/get-text-dimensions + :font-stored? wasm.fonts/font-stored? + :make-font-data wasm.fonts/make-font-data + :get-content-fonts wasm.fonts/get-content-fonts}) + ;; Install mocks + (set! wasm.api/clean-modifiers mock-clean-modifiers) + (set! wasm.api/set-structure-modifiers mock-set-structure-modifiers) + (set! wasm.api/propagate-modifiers mock-propagate-modifiers) + (set! wasm.api/set-shape-grow-type mock-set-shape-grow-type) + (set! wasm.api/set-shape-text-content mock-set-shape-text-content) + (set! wasm.api/set-shape-text-images mock-set-shape-text-images) + (set! wasm.api/get-text-dimensions mock-get-text-dimensions) + (set! wasm.fonts/font-stored? mock-font-stored?) + (set! wasm.fonts/make-font-data mock-make-font-data) + (set! wasm.fonts/get-content-fonts mock-get-content-fonts)) + +(defn teardown-wasm-mocks! + "Restore the original WASM functions saved by `setup-wasm-mocks!`." + [] + (let [orig @originals] + (set! wasm.api/clean-modifiers (:clean-modifiers orig)) + (set! wasm.api/set-structure-modifiers (:set-structure-modifiers orig)) + (set! wasm.api/propagate-modifiers (:propagate-modifiers orig)) + (set! wasm.api/set-shape-grow-type (:set-shape-grow-type orig)) + (set! wasm.api/set-shape-text-content (:set-shape-text-content orig)) + (set! wasm.api/set-shape-text-images (:set-shape-text-images orig)) + (set! wasm.api/get-text-dimensions (:get-text-dimensions orig)) + (set! wasm.fonts/font-stored? (:font-stored? orig)) + (set! wasm.fonts/make-font-data (:make-font-data orig)) + (set! wasm.fonts/get-content-fonts (:get-content-fonts orig))) + (reset! originals {})) + +(defn with-wasm-mocks* + "Calls `(thunk)` with all WASM API boundary functions replaced by + safe mocks, restoring the originals when the thunk returns. + + NOTE: Teardown happens synchronously when `thunk` returns. For + async tests (e.g. those using `tohs/run-store-async`), use + `setup-wasm-mocks!` / `teardown-wasm-mocks!` via + `t/use-fixtures :each` instead." + [thunk] + (setup-wasm-mocks!) + (try + (thunk) + (finally + (teardown-wasm-mocks!)))) diff --git a/frontend/test/frontend_tests/logic/components_and_tokens.cljs b/frontend/test/frontend_tests/logic/components_and_tokens.cljs index 9e830c9a76..2ec37b8db1 100644 --- a/frontend/test/frontend_tests/logic/components_and_tokens.cljs +++ b/frontend/test/frontend_tests/logic/components_and_tokens.cljs @@ -23,11 +23,15 @@ [cljs.test :as t :include-macros true] [frontend-tests.helpers.pages :as thp] [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] [frontend-tests.tokens.helpers.state :as tohs] [frontend-tests.tokens.helpers.tokens :as toht])) (t/use-fixtures :each - {:before thp/reset-idmap!}) + {:before (fn [] + (thp/reset-idmap!) + (thw/setup-wasm-mocks!)) + :after thw/teardown-wasm-mocks!}) (defn- setup-base-file [] @@ -426,7 +430,10 @@ (t/is (mth/close? (get c-frame1' :width) 200)) (t/is (mth/close? (get c-frame1' :height) 200)) - (t/is (empty? (:touched c-frame1'))))))))] + (t/is (empty? (:touched c-frame1'))) + + (t/testing "WASM mocks were exercised" + (t/is (pos? (thw/call-count :propagate-modifiers)))))))))] (tohs/run-store-async store step2 events identity)))) diff --git a/frontend/test/frontend_tests/logic/frame_guides_test.cljs b/frontend/test/frontend_tests/logic/frame_guides_test.cljs index 996d84cf4a..e20bc99e26 100644 --- a/frontend/test/frontend_tests/logic/frame_guides_test.cljs +++ b/frontend/test/frontend_tests/logic/frame_guides_test.cljs @@ -13,7 +13,8 @@ [app.main.data.workspace.guides :as-alias dwg] [cljs.test :as t :include-macros true] [frontend-tests.helpers.pages :as thp] - [frontend-tests.helpers.state :as ths])) + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) (t/use-fixtures :each {:before thp/reset-idmap!}) @@ -22,35 +23,43 @@ (t/deftest test-remove-swap-slot-copy-paste-blue1-to-root (t/async done - (let [;; ==== Setup - file (-> (cthf/sample-file :file1) - (ctho/add-frame :frame1)) - store (ths/setup-store file) - frame1 (cths/get-shape file :frame1) + (thw/with-wasm-mocks* + (fn [] + (let [;; ==== Setup + file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame1)) + store (ths/setup-store file) + frame1 (cths/get-shape file :frame1) - guide {:axis :x - :frame-id (:id frame1) - :id (uuid/next) - :position 0} + guide {:axis :x + :frame-id (:id frame1) + :id (uuid/next) + :position 0} - ;; ==== Action - events - [(dw/update-guides guide) - (dw/update-position (:id frame1) {:x 100})]] + ;; ==== Action + events + [(dw/update-guides guide) + (dw/update-position (:id frame1) {:x 100})]] - (ths/run-store - store done events - (fn [new-state] - (let [;; ==== Get - file' (ths/get-file-from-state new-state) - page' (cthf/current-page file') + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + + guide' (-> page' + :guides + (vals) + (first))] + ;; ==== Check + ;; guide has moved + (t/is (= (:position guide') 100)) + + ;; WASM mocks were exercised + (t/is (pos? (thw/call-count :clean-modifiers))) + (t/is (pos? (thw/call-count :set-structure-modifiers))) + (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) - guide' (-> page' - :guides - (vals) - (first))] - ;; ==== Check - ;; guide has moved - (t/is (= (:position guide') 100)))))))) diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index 94fe2756d8..39b44e861f 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -12,242 +12,250 @@ [app.main.store :as st] [app.plugins.api :as api] [cljs.test :as t :include-macros true] - [frontend-tests.helpers.state :as ths])) + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) (t/deftest test-common-shape-properties - (let [;; ==== Setup - store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + (thw/with-wasm-mocks* + (fn [] + (let [;; ==== Setup + store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) - ^js context (api/create-context "00000000-0000-0000-0000-000000000000") + ^js context (api/create-context "00000000-0000-0000-0000-000000000000") - _ (set! st/state store) + _ (set! st/state store) - ^js file (. context -currentFile) - ^js page (. context -currentPage) - ^js shape (.createRectangle context) + ^js file (. context -currentFile) + ^js page (. context -currentPage) + ^js shape (.createRectangle context) - get-shape-path - #(vector :files (aget file "$id") :data :pages-index (aget page "$id") :objects (aget shape "$id") %)] + get-shape-path + #(vector :files (aget file "$id") :data :pages-index (aget page "$id") :objects (aget shape "$id") %)] - (t/testing "Basic shape properites" - (t/testing " - name" - (set! (.-name shape) "TEST") - (t/is (= (.-name shape) "TEST")) - (t/is (= (get-in @store (get-shape-path :name)) "TEST"))) + (t/testing "Basic shape properites" + (t/testing " - name" + (set! (.-name shape) "TEST") + (t/is (= (.-name shape) "TEST")) + (t/is (= (get-in @store (get-shape-path :name)) "TEST"))) - (t/testing " - x" - (set! (.-x shape) 10) - (t/is (= (.-x shape) 10)) - (t/is (= (get-in @store (get-shape-path :x)) 10)) + (t/testing " - x" + (set! (.-x shape) 10) + (t/is (= (.-x shape) 10)) + (t/is (= (get-in @store (get-shape-path :x)) 10)) - (set! (.-x shape) "fail") - (t/is (= (.-x shape) 10)) - (t/is (= (get-in @store (get-shape-path :x)) 10))) + (set! (.-x shape) "fail") + (t/is (= (.-x shape) 10)) + (t/is (= (get-in @store (get-shape-path :x)) 10))) - (t/testing " - y" - (set! (.-y shape) 50) - (t/is (= (.-y shape) 50)) - (t/is (= (get-in @store (get-shape-path :y)) 50)) + (t/testing " - y" + (set! (.-y shape) 50) + (t/is (= (.-y shape) 50)) + (t/is (= (get-in @store (get-shape-path :y)) 50)) - (set! (.-y shape) "fail") - (t/is (= (.-y shape) 50)) - (t/is (= (get-in @store (get-shape-path :y)) 50))) + (set! (.-y shape) "fail") + (t/is (= (.-y shape) 50)) + (t/is (= (get-in @store (get-shape-path :y)) 50))) - (t/testing " - resize" - (.resize shape 250 300) - (t/is (= (.-width shape) 250)) - (t/is (= (.-height shape) 300)) - (t/is (= (get-in @store (get-shape-path :width)) 250)) - (t/is (= (get-in @store (get-shape-path :height)) 300)) + (t/testing " - resize" + (.resize shape 250 300) + (t/is (= (.-width shape) 250)) + (t/is (= (.-height shape) 300)) + (t/is (= (get-in @store (get-shape-path :width)) 250)) + (t/is (= (get-in @store (get-shape-path :height)) 300)) - (.resize shape 0 0) - (t/is (= (.-width shape) 250)) - (t/is (= (.-height shape) 300)) - (t/is (= (get-in @store (get-shape-path :width)) 250)) - (t/is (= (get-in @store (get-shape-path :height)) 300))) + (.resize shape 0 0) + (t/is (= (.-width shape) 250)) + (t/is (= (.-height shape) 300)) + (t/is (= (get-in @store (get-shape-path :width)) 250)) + (t/is (= (get-in @store (get-shape-path :height)) 300))) - (t/testing " - blocked" - (set! (.-blocked shape) true) - (t/is (= (.-blocked shape) true)) - (t/is (= (get-in @store (get-shape-path :blocked)) true)) + (t/testing " - blocked" + (set! (.-blocked shape) true) + (t/is (= (.-blocked shape) true)) + (t/is (= (get-in @store (get-shape-path :blocked)) true)) - (set! (.-blocked shape) false) - (t/is (= (.-blocked shape) false)) - (t/is (= (get-in @store (get-shape-path :blocked)) false))) + (set! (.-blocked shape) false) + (t/is (= (.-blocked shape) false)) + (t/is (= (get-in @store (get-shape-path :blocked)) false))) - (t/testing " - hidden" - (set! (.-hidden shape) true) - (t/is (= (.-hidden shape) true)) - (t/is (= (get-in @store (get-shape-path :hidden)) true)) + (t/testing " - hidden" + (set! (.-hidden shape) true) + (t/is (= (.-hidden shape) true)) + (t/is (= (get-in @store (get-shape-path :hidden)) true)) - (set! (.-hidden shape) false) - (t/is (= (.-hidden shape) false)) - (t/is (= (get-in @store (get-shape-path :hidden)) false))) + (set! (.-hidden shape) false) + (t/is (= (.-hidden shape) false)) + (t/is (= (get-in @store (get-shape-path :hidden)) false))) - (t/testing " - proportionLock" - (set! (.-proportionLock shape) true) - (t/is (= (.-proportionLock shape) true)) - (t/is (= (get-in @store (get-shape-path :proportion-lock)) true))) + (t/testing " - proportionLock" + (set! (.-proportionLock shape) true) + (t/is (= (.-proportionLock shape) true)) + (t/is (= (get-in @store (get-shape-path :proportion-lock)) true))) - (t/testing " - constraintsHorizontal" - (set! (.-constraintsHorizontal shape) "fail") - (t/is (not= (.-constraintsHorizontal shape) "fail")) - (t/is (not= (get-in @store (get-shape-path :constraints-h)) "fail")) + (t/testing " - constraintsHorizontal" + (set! (.-constraintsHorizontal shape) "fail") + (t/is (not= (.-constraintsHorizontal shape) "fail")) + (t/is (not= (get-in @store (get-shape-path :constraints-h)) "fail")) - (set! (.-constraintsHorizontal shape) "right") - (t/is (= (.-constraintsHorizontal shape) "right")) - (t/is (= (get-in @store (get-shape-path :constraints-h)) :right))) + (set! (.-constraintsHorizontal shape) "right") + (t/is (= (.-constraintsHorizontal shape) "right")) + (t/is (= (get-in @store (get-shape-path :constraints-h)) :right))) - (t/testing " - constraintsVertical" - (set! (.-constraintsVertical shape) "fail") - (t/is (not= (.-constraintsVertical shape) "fail")) - (t/is (not= (get-in @store (get-shape-path :constraints-v)) "fail")) + (t/testing " - constraintsVertical" + (set! (.-constraintsVertical shape) "fail") + (t/is (not= (.-constraintsVertical shape) "fail")) + (t/is (not= (get-in @store (get-shape-path :constraints-v)) "fail")) - (set! (.-constraintsVertical shape) "bottom") - (t/is (= (.-constraintsVertical shape) "bottom")) - (t/is (= (get-in @store (get-shape-path :constraints-v)) :bottom))) + (set! (.-constraintsVertical shape) "bottom") + (t/is (= (.-constraintsVertical shape) "bottom")) + (t/is (= (get-in @store (get-shape-path :constraints-v)) :bottom))) - (t/testing " - borderRadius" - (set! (.-borderRadius shape) 10) - (t/is (= (.-borderRadius shape) 10)) - (t/is (= (get-in @store (get-shape-path :r1)) 10)) + (t/testing " - borderRadius" + (set! (.-borderRadius shape) 10) + (t/is (= (.-borderRadius shape) 10)) + (t/is (= (get-in @store (get-shape-path :r1)) 10)) - (set! (.-borderRadiusTopLeft shape) 20) - (t/is (= (.-borderRadiusTopLeft shape) 20)) - (t/is (= (get-in @store (get-shape-path :r1)) 20)) - (t/is (= (get-in @store (get-shape-path :r2)) 10)) - (t/is (= (get-in @store (get-shape-path :r3)) 10)) - (t/is (= (get-in @store (get-shape-path :r4)) 10)) + (set! (.-borderRadiusTopLeft shape) 20) + (t/is (= (.-borderRadiusTopLeft shape) 20)) + (t/is (= (get-in @store (get-shape-path :r1)) 20)) + (t/is (= (get-in @store (get-shape-path :r2)) 10)) + (t/is (= (get-in @store (get-shape-path :r3)) 10)) + (t/is (= (get-in @store (get-shape-path :r4)) 10)) - (set! (.-borderRadiusTopRight shape) 30) - (set! (.-borderRadiusBottomRight shape) 40) - (set! (.-borderRadiusBottomLeft shape) 50) - (t/is (= (.-borderRadiusTopRight shape) 30)) - (t/is (= (.-borderRadiusBottomRight shape) 40)) - (t/is (= (.-borderRadiusBottomLeft shape) 50)) + (set! (.-borderRadiusTopRight shape) 30) + (set! (.-borderRadiusBottomRight shape) 40) + (set! (.-borderRadiusBottomLeft shape) 50) + (t/is (= (.-borderRadiusTopRight shape) 30)) + (t/is (= (.-borderRadiusBottomRight shape) 40)) + (t/is (= (.-borderRadiusBottomLeft shape) 50)) - (t/is (= (get-in @store (get-shape-path :r1)) 20)) - (t/is (= (get-in @store (get-shape-path :r2)) 30)) - (t/is (= (get-in @store (get-shape-path :r3)) 40)) - (t/is (= (get-in @store (get-shape-path :r4)) 50))) + (t/is (= (get-in @store (get-shape-path :r1)) 20)) + (t/is (= (get-in @store (get-shape-path :r2)) 30)) + (t/is (= (get-in @store (get-shape-path :r3)) 40)) + (t/is (= (get-in @store (get-shape-path :r4)) 50))) - (t/testing " - opacity" - (set! (.-opacity shape) 0.5) - (t/is (= (.-opacity shape) 0.5)) - (t/is (= (get-in @store (get-shape-path :opacity)) 0.5))) + (t/testing " - opacity" + (set! (.-opacity shape) 0.5) + (t/is (= (.-opacity shape) 0.5)) + (t/is (= (get-in @store (get-shape-path :opacity)) 0.5))) - (t/testing " - blendMode" - (set! (.-blendMode shape) "multiply") - (t/is (= (.-blendMode shape) "multiply")) - (t/is (= (get-in @store (get-shape-path :blend-mode)) :multiply)) + (t/testing " - blendMode" + (set! (.-blendMode shape) "multiply") + (t/is (= (.-blendMode shape) "multiply")) + (t/is (= (get-in @store (get-shape-path :blend-mode)) :multiply)) - (set! (.-blendMode shape) "fail") - (t/is (= (.-blendMode shape) "multiply")) - (t/is (= (get-in @store (get-shape-path :blend-mode)) :multiply))) + (set! (.-blendMode shape) "fail") + (t/is (= (.-blendMode shape) "multiply")) + (t/is (= (get-in @store (get-shape-path :blend-mode)) :multiply))) - (t/testing " - shadows" - (let [shadow #js {:style "drop-shadow" - :color #js {:color "#FABADA" :opacity 1}}] - (set! (.-shadows shape) #js [shadow]) - (let [shadow-id (uuid/uuid (aget (aget (aget shape "shadows") 0) "id"))] - (t/is (= (-> (. shape -shadows) (aget 0) (aget "style")) "drop-shadow")) - (t/is (= (get-in @store (get-shape-path :shadow)) [{:id shadow-id - :style :drop-shadow - :offset-x 4 - :offset-y 4 - :blur 4 - :spread 0 - :color {:color "#fabada" :opacity 1} - :hidden false}])))) - (let [shadow #js {:style "fail"}] - (set! (.-shadows shape) #js [shadow]) - (t/is (= (-> (. shape -shadows) (aget 0) (aget "style")) "drop-shadow")))) + (t/testing " - shadows" + (let [shadow #js {:style "drop-shadow" + :color #js {:color "#FABADA" :opacity 1}}] + (set! (.-shadows shape) #js [shadow]) + (let [shadow-id (uuid/uuid (aget (aget (aget shape "shadows") 0) "id"))] + (t/is (= (-> (. shape -shadows) (aget 0) (aget "style")) "drop-shadow")) + (t/is (= (get-in @store (get-shape-path :shadow)) [{:id shadow-id + :style :drop-shadow + :offset-x 4 + :offset-y 4 + :blur 4 + :spread 0 + :color {:color "#fabada" :opacity 1} + :hidden false}])))) + (let [shadow #js {:style "fail"}] + (set! (.-shadows shape) #js [shadow]) + (t/is (= (-> (. shape -shadows) (aget 0) (aget "style")) "drop-shadow")))) - (t/testing " - blur" - (set! (.-blur shape) #js {:value 10}) - (t/is (= (-> (. shape -blur) (aget "type")) "layer-blur")) - (t/is (= (-> (. shape -blur) (aget "value")) 10)) - (t/is (= (-> (. shape -blur) (aget "hidden")) false)) - (let [id (-> (. shape -blur) (aget "id") uuid/uuid)] - (t/is (= (get-in @store (get-shape-path :blur)) {:id id :type :layer-blur :value 10 :hidden false})))) + (t/testing " - blur" + (set! (.-blur shape) #js {:value 10}) + (t/is (= (-> (. shape -blur) (aget "type")) "layer-blur")) + (t/is (= (-> (. shape -blur) (aget "value")) 10)) + (t/is (= (-> (. shape -blur) (aget "hidden")) false)) + (let [id (-> (. shape -blur) (aget "id") uuid/uuid)] + (t/is (= (get-in @store (get-shape-path :blur)) {:id id :type :layer-blur :value 10 :hidden false})))) - (t/testing " - exports" - (set! (.-exports shape) #js [#js {:type "pdf" :scale 2 :suffix "test"}]) - (t/is (= (-> (. shape -exports) (aget 0) (aget "type")) "pdf")) - (t/is (= (-> (. shape -exports) (aget 0) (aget "scale")) 2)) - (t/is (= (-> (. shape -exports) (aget 0) (aget "suffix")) "test")) - (t/is (= (get-in @store (get-shape-path :exports)) [{:type :pdf :scale 2 :suffix "test" :skip-children false}])) + (t/testing " - exports" + (set! (.-exports shape) #js [#js {:type "pdf" :scale 2 :suffix "test"}]) + (t/is (= (-> (. shape -exports) (aget 0) (aget "type")) "pdf")) + (t/is (= (-> (. shape -exports) (aget 0) (aget "scale")) 2)) + (t/is (= (-> (. shape -exports) (aget 0) (aget "suffix")) "test")) + (t/is (= (get-in @store (get-shape-path :exports)) [{:type :pdf :scale 2 :suffix "test" :skip-children false}])) - (set! (.-exports shape) #js [#js {:type 10 :scale 2 :suffix "test"}]) - (t/is (= (get-in @store (get-shape-path :exports)) [{:type :pdf :scale 2 :suffix "test" :skip-children false}]))) + (set! (.-exports shape) #js [#js {:type 10 :scale 2 :suffix "test"}]) + (t/is (= (get-in @store (get-shape-path :exports)) [{:type :pdf :scale 2 :suffix "test" :skip-children false}]))) - (t/testing " - flipX" - (set! (.-flipX shape) true) - (t/is (= (.-flipX shape) true)) - (t/is (= (get-in @store (get-shape-path :flip-x)) true))) + (t/testing " - flipX" + (set! (.-flipX shape) true) + (t/is (= (.-flipX shape) true)) + (t/is (= (get-in @store (get-shape-path :flip-x)) true))) - (t/testing " - flipY" - (set! (.-flipY shape) true) - (t/is (= (.-flipY shape) true)) - (t/is (= (get-in @store (get-shape-path :flip-y)) true))) + (t/testing " - flipY" + (set! (.-flipY shape) true) + (t/is (= (.-flipY shape) true)) + (t/is (= (get-in @store (get-shape-path :flip-y)) true))) - (t/testing " - rotation" - (set! (.-rotation shape) 45) - (t/is (= (.-rotation shape) 45)) - (t/is (= (get-in @store (get-shape-path :rotation)) 45)) + (t/testing " - rotation" + (set! (.-rotation shape) 45) + (t/is (= (.-rotation shape) 45)) + (t/is (= (get-in @store (get-shape-path :rotation)) 45)) - (set! (.-rotation shape) 0) - (t/is (= (.-rotation shape) 0)) - (t/is (= (get-in @store (get-shape-path :rotation)) 0))) + (set! (.-rotation shape) 0) + (t/is (= (.-rotation shape) 0)) + (t/is (= (get-in @store (get-shape-path :rotation)) 0))) - (t/testing " - fills" - (set! (.-fills shape) #js [#js {:fillColor 100}]) - (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#B1B2B5" :fill-opacity 1}])) - (t/is (= (-> (. shape -fills) (aget 0) (aget "fillColor")) "#B1B2B5")) + (t/testing " - fills" + (set! (.-fills shape) #js [#js {:fillColor 100}]) + (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#B1B2B5" :fill-opacity 1}])) + (t/is (= (-> (. shape -fills) (aget 0) (aget "fillColor")) "#B1B2B5")) - (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) - (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#fabada" :fill-opacity 1}])) - (t/is (= (-> (. shape -fills) (aget 0) (aget "fillColor")) "#fabada")) - (t/is (= (-> (. shape -fills) (aget 0) (aget "fillOpacity")) 1))) + (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) + (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#fabada" :fill-opacity 1}])) + (t/is (= (-> (. shape -fills) (aget 0) (aget "fillColor")) "#fabada")) + (t/is (= (-> (. shape -fills) (aget 0) (aget "fillOpacity")) 1))) - (t/testing " - strokes" - (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) - (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#fabada" :stroke-opacity 1 :stroke-width 5}])) - (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeColor")) "#fabada")) - (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeOpacity")) 1)) - (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeWidth")) 5)))) + (t/testing " - strokes" + (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) + (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#fabada" :stroke-opacity 1 :stroke-width 5}])) + (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeColor")) "#fabada")) + (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeOpacity")) 1)) + (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeWidth")) 5)))) - (t/testing "Relative properties" - (let [board (.createBoard context)] - (set! (.-x board) 100) - (set! (.-y board) 200) - (t/is (= (.-x board) 100)) - (t/is (= (.-y board) 200)) - (.appendChild board shape) + (t/testing "Relative properties" + (let [board (.createBoard context)] + (set! (.-x board) 100) + (set! (.-y board) 200) + (t/is (= (.-x board) 100)) + (t/is (= (.-y board) 200)) + (.appendChild board shape) - (t/testing " - boardX" - (set! (.-boardX ^js shape) 10) - (t/is (m/close? (.-boardX ^js shape) 10)) - (t/is (m/close? (.-x shape) 110)) - (t/is (m/close? (get-in @store (get-shape-path :x)) 110))) + (t/testing " - boardX" + (set! (.-boardX ^js shape) 10) + (t/is (m/close? (.-boardX ^js shape) 10)) + (t/is (m/close? (.-x shape) 110)) + (t/is (m/close? (get-in @store (get-shape-path :x)) 110))) - (t/testing " - boardY" - (set! (.-boardY ^js shape) 20) - (t/is (m/close? (.-boardY ^js shape) 20)) - (t/is (m/close? (.-y shape) 220)) - (t/is (m/close? (get-in @store (get-shape-path :y)) 220))) + (t/testing " - boardY" + (set! (.-boardY ^js shape) 20) + (t/is (m/close? (.-boardY ^js shape) 20)) + (t/is (m/close? (.-y shape) 220)) + (t/is (m/close? (get-in @store (get-shape-path :y)) 220))) - (t/testing " - parentX" - (set! (.-parentX ^js shape) 30) - (t/is (m/close? (.-parentX ^js shape) 30)) - (t/is (m/close? (.-x shape) 130)) - (t/is (m/close? (get-in @store (get-shape-path :x)) 130))) + (t/testing " - parentX" + (set! (.-parentX ^js shape) 30) + (t/is (m/close? (.-parentX ^js shape) 30)) + (t/is (m/close? (.-x shape) 130)) + (t/is (m/close? (get-in @store (get-shape-path :x)) 130))) - (t/testing " - parentY" - (set! (.-parentY ^js shape) 40) - (t/is (m/close? (.-parentY ^js shape) 40)) - (t/is (m/close? (.-y shape) 240)) - (t/is (m/close? (get-in @store (get-shape-path :y)) 240))))) + (t/testing " - parentY" + (set! (.-parentY ^js shape) 40) + (t/is (m/close? (.-parentY ^js shape) 40)) + (t/is (m/close? (.-y shape) 240)) + (t/is (m/close? (get-in @store (get-shape-path :y)) 240))))) - (t/testing "Clone") - (t/testing "Remove"))) + (t/testing "Clone") + (t/testing "Remove") + + (t/testing "WASM mocks were exercised" + (t/is (pos? (thw/call-count :clean-modifiers))) + (t/is (pos? (thw/call-count :set-structure-modifiers))) + (t/is (pos? (thw/call-count :propagate-modifiers)))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index cc899c9ac8..fe5f5efdac 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -30,10 +30,6 @@ (.exit js/process 0) (.exit js/process 1))) -;; FIXME: workaround, wasn is temporarily disabled for unit tests -(set! app.main.features/global-enabled-features - (disj app.main.features/global-enabled-features "render-wasm/v1")) - (defn init [] (t/run-tests diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index e468e420bb..0af65155bf 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -17,11 +17,15 @@ [cuerdas.core :as str] [frontend-tests.helpers.pages :as thp] [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] [frontend-tests.tokens.helpers.state :as tohs] [frontend-tests.tokens.helpers.tokens :as toht])) (t/use-fixtures :each - {:before thp/reset-idmap!}) + {:before (fn [] + (thp/reset-idmap!) + (thw/setup-wasm-mocks!)) + :after thw/teardown-wasm-mocks!}) (defn setup-file [] (cthf/sample-file :file-1 :page-label :page-1)) @@ -273,7 +277,9 @@ (t/is (= (:height (:applied-tokens rect-1')) (:name token-target')))) (t/testing "shapes width and height got updated" (t/is (= (:width rect-1') 100)) - (t/is (= (:height rect-1') 100)))))))))) + (t/is (= (:height rect-1') 100))) + (t/testing "WASM mocks were exercised" + (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) (t/deftest test-apply-padding (t/testing "applies padding token to shapes with layout" @@ -346,7 +352,9 @@ (t/is (= (:height (:applied-tokens rect-1')) (:name token-target')))) (t/testing "shapes width and height got updated" (t/is (= (:width rect-1') 100)) - (t/is (= (:height rect-1') 100)))))))))) + (t/is (= (:height rect-1') 100))) + (t/testing "WASM mocks were exercised" + (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) (t/deftest test-apply-opacity (t/testing "applies opacity token and updates the shapes opacity" @@ -431,7 +439,9 @@ rect-1' (cths/get-shape file' :rect-1)] (t/is (some? (:applied-tokens rect-1'))) (t/is (= (:rotation (:applied-tokens rect-1')) (:name token-target'))) - (t/is (= (:rotation rect-1') 120))))))))) + (t/is (= (:rotation rect-1') 120)) + (t/testing "WASM mocks were exercised" + (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) (t/deftest test-apply-stroke-width (t/testing "applies stroke-width token and updates the shapes with stroke" @@ -540,7 +550,10 @@ (:styles))] (t/is (some? (:applied-tokens text-1'))) (t/is (= (:font-size (:applied-tokens text-1')) (:name token-target'))) - (t/is (= (:font-size style-text-blocks) "24"))))))))) + (t/is (= (:font-size style-text-blocks) "24")) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-apply-line-height (t/testing "applies line-height token and updates the text line-height" @@ -575,7 +588,10 @@ (:styles))] (t/is (some? (:applied-tokens text-1'))) (t/is (= (:line-height (:applied-tokens text-1')) (:name token-target'))) - (t/is (= (:line-height style-text-blocks) 1.5))))))))) + (t/is (= (:line-height style-text-blocks) 1.5)) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-apply-letter-spacing (t/testing "applies letter-spacing token and updates the text letter-spacing" @@ -610,7 +626,10 @@ (:styles))] (t/is (some? (:applied-tokens text-1'))) (t/is (= (:letter-spacing (:applied-tokens text-1')) (:name token-target'))) - (t/is (= (:letter-spacing style-text-blocks) "2"))))))))) + (t/is (= (:letter-spacing style-text-blocks) "2")) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-apply-font-family (t/testing "applies font-family token and updates the text font-family" @@ -645,7 +664,10 @@ (:styles))] (t/is (some? (:applied-tokens text-1'))) (t/is (= (:font-family (:applied-tokens text-1')) (:name token-target'))) - (t/is (= (:font-family style-text-blocks) (:font-id txt/default-text-attrs)))))))))) + (t/is (= (:font-family style-text-blocks) (:font-id txt/default-text-attrs))) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-apply-text-case (t/testing "applies text-case token and updates the text transform" @@ -750,7 +772,10 @@ (:styles))] (t/is (some? (:applied-tokens text-1'))) (t/is (= (:font-weight (:applied-tokens text-1')) (:name token-target'))) - (t/is (= (:font-weight style-text-blocks) "400"))))))))) + (t/is (= (:font-weight style-text-blocks) "400")) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-toggle-token-none (t/testing "should apply token to all selected items, where no item has the token applied" @@ -973,7 +998,10 @@ (t/is (= (:font-family style-text-blocks) "sourcesanspro")) (t/is (= (:letter-spacing style-text-blocks) "2")) (t/is (= (:text-transform style-text-blocks) "uppercase")) - (t/is (= (:text-decoration style-text-blocks) "underline"))))))))) + (t/is (= (:text-decoration style-text-blocks) "underline")) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-apply-reference-typography-token (t/testing "applies typography (composite) tokens with references" @@ -1018,7 +1046,10 @@ (t/is (= (:typography (:applied-tokens text-1')) "typography")) (t/is (= (:font-size style-text-blocks) "100")) - (t/is (= (:font-family style-text-blocks) "Arial"))))))))) + (t/is (= (:font-family style-text-blocks) "Arial")) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-unapply-atomic-tokens-on-composite-apply (t/testing "unapplies atomic typography tokens when applying composite token" @@ -1172,4 +1203,7 @@ (t/is (nil? (:typography-ref-id paragraph-3))) (t/is (nil? (:typography-ref-file paragraph-3))) (t/is (nil? (:typography-ref-id text-node-3))) - (t/is (nil? (:typography-ref-file text-node-3)))))))))) + (t/is (nil? (:typography-ref-file text-node-3))) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) From 405fd49d799d76bd77332cb3034d8525d763e067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 23 Mar 2026 14:21:41 +0100 Subject: [PATCH 3/6] :bug: Fix exclusion being applied as union (wasm) --- render-wasm/src/wasm/paths/bools.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/render-wasm/src/wasm/paths/bools.rs b/render-wasm/src/wasm/paths/bools.rs index c19791dc95..0e6636e2f2 100644 --- a/render-wasm/src/wasm/paths/bools.rs +++ b/render-wasm/src/wasm/paths/bools.rs @@ -18,7 +18,7 @@ pub enum RawBoolType { Union = 0, Difference = 1, Intersection = 2, - Exclusion = 3, + Exclude = 3, } impl From for RawBoolType { @@ -33,7 +33,7 @@ impl From for BoolType { RawBoolType::Union => BoolType::Union, RawBoolType::Difference => BoolType::Difference, RawBoolType::Intersection => BoolType::Intersection, - RawBoolType::Exclusion => BoolType::Exclusion, + RawBoolType::Exclude => BoolType::Exclusion, } } } From 13ee27b1ad8d39846e53bb4a4021cbbe869542f5 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 23 Mar 2026 15:31:20 +0100 Subject: [PATCH 4/6] :bug: Fix problem with plugins export --- frontend/src/app/plugins/shape.cljs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index d97e10f9f8..451eb45fbf 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -1230,6 +1230,7 @@ {:cmd :export-shapes :profile-id (:profile-id @st/state) :wait true + :is-wasm false :exports [{:file-id file-id :page-id page-id :object-id id From 57be1428b3a7da231dca140c4f779166a77da894 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 23 Mar 2026 11:37:55 +0100 Subject: [PATCH 5/6] :bug: Fix background-blur on wasm export --- render-wasm/src/render.rs | 59 ++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 9eaaee0e85..707523f626 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -435,10 +435,10 @@ impl RenderState { shape.frame_clip_layer_blur() } - /// Renders background blur effect directly to the Current surface. + /// Renders background blur effect directly to the given target surface. /// Must be called BEFORE any save_layer for the shape's own opacity/blend, /// so that the backdrop blur is independent of the shape's visual properties. - fn render_background_blur(&mut self, shape: &Shape) { + fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) { if self.options.is_fast_mode() { return; } @@ -458,21 +458,22 @@ impl RenderState { let scaled_sigma = radius_to_sigma(blur.value * scale); // Cap sigma so the blur kernel (≈3σ) stays within the tile margin. // This prevents visible seams at tile boundaries when zoomed in. - let margin = self.surfaces.margins().width as f32; - let max_sigma = margin / 3.0; - let capped_sigma = scaled_sigma.min(max_sigma); - - let blur_filter = match skia::image_filters::blur( - (capped_sigma, capped_sigma), - skia::TileMode::Clamp, - None, - None, - ) { - Some(filter) => filter, - None => return, + // During export there's no tiling, so skip the cap. + let sigma = if self.export_context.is_some() { + scaled_sigma + } else { + let margin = self.surfaces.margins().width as f32; + let max_sigma = margin / 3.0; + scaled_sigma.min(max_sigma) }; - let snapshot = self.surfaces.snapshot(SurfaceId::Current); + let blur_filter = + match skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None) { + Some(filter) => filter, + None => return, + }; + + let target_surface_snapshot = self.surfaces.snapshot(target_surface); let translation = self .surfaces .get_render_context_translation(self.render_area, scale); @@ -482,10 +483,10 @@ impl RenderState { matrix.post_translate(center); matrix.pre_translate(-center); - let canvas = self.surfaces.canvas(SurfaceId::Current); + let canvas = self.surfaces.canvas(target_surface); canvas.save(); - // Current has no render context transform (identity canvas). + // Current/Export have no render context transform (identity canvas). // Apply scale + translate + shape transform so the clip maps // from shape-local coords to device pixels correctly. canvas.scale((scale, scale)); @@ -528,7 +529,7 @@ impl RenderState { let mut paint = skia::Paint::default(); paint.set_image_filter(blur_filter); paint.set_blend_mode(skia::BlendMode::Src); - canvas.draw_image(&snapshot, (0, 0), Some(&paint)); + canvas.draw_image(&target_surface_snapshot, (0, 0), Some(&paint)); canvas.restore(); } @@ -1521,6 +1522,11 @@ impl RenderState { ) -> Result<(Vec, i32, i32)> { let target_surface = SurfaceId::Export; + // Reset focus mode so all shapes in the export tree are rendered. + // Without this, leftover focus_mode state from the workspace could + // cause shapes (and their background blur) to be skipped. + self.focus_mode.clear(); + self.surfaces .canvas(target_surface) .clear(skia::Color::TRANSPARENT); @@ -1533,6 +1539,8 @@ impl RenderState { extrect.offset((margins.width as f32 / scale, margins.height as f32 / scale)); self.surfaces.resize_export_surface(scale, extrect); + self.render_area = extrect; + self.render_area_with_margins = extrect; self.surfaces.update_render_context(extrect, scale); self.pending_nodes.push(NodeRenderState { @@ -1546,6 +1554,9 @@ impl RenderState { self.render_shape_tree_partial_uncached(tree, timestamp, false, true)?; } + // Clear export context so get_scale() returns to workspace zoom. + self.export_context = None; + self.surfaces .flush_and_submit(&mut self.gpu_state, target_surface); @@ -2334,13 +2345,7 @@ impl RenderState { // Render background blur BEFORE save_layer so it modifies // the backdrop independently of the shape's opacity. if !node_render_state.is_root() && self.focus_mode.is_active() { - self.render_background_blur(element); - } - - // Render background blur BEFORE save_layer so it modifies - // the backdrop independently of the shape's opacity. - if !node_render_state.is_root() && self.focus_mode.is_active() { - self.render_background_blur(element); + self.render_background_blur(element, target_surface); } self.render_shape_enter(element, mask, target_surface); @@ -2910,6 +2915,10 @@ impl RenderState { } pub fn get_scale(&self) -> f32 { + // During export, use the export scale instead of the workspace zoom. + if let Some((_, export_scale)) = self.export_context { + return export_scale; + } self.viewbox.zoom() * self.options.dpr() } From ce0553951fff2177fb65bef6d8127e166f3b9c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 23 Mar 2026 17:21:28 +0100 Subject: [PATCH 6/6] :bug: Fix layout lines not disappearing on shape deletion (wasm) --- render-wasm/src/state.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 9239b38eb4..7b6ba8a65e 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -168,8 +168,16 @@ impl State { } } - if let Some(shape_to_delete) = self.shapes.get_mut(&id) { - shape_to_delete.set_deleted(true); + if let Some(shape_to_delete) = self.shapes.get(&id) { + let to_delete = shape_to_delete.all_children(&self.shapes, true, true); + for shape_id in to_delete { + if let Some(shape_to_delete) = self.shapes.get_mut(&shape_id) { + shape_to_delete.set_deleted(true); + } + if self.render_state.show_grid == Some(shape_id) { + self.render_state.show_grid = None; + } + } } } }