🐛 Add plugin-api-test-suite tests and fix some synchronization problems

This commit is contained in:
alonso.torres 2026-07-06 15:33:40 +02:00
parent 3fddf2c5bc
commit 91d3c3db39
8 changed files with 861 additions and 100 deletions

View File

@ -99,26 +99,33 @@
;; Never call this directly but through the data-event `:layout/update` ;; Never call this directly but through the data-event `:layout/update`
;; Otherwise a lot of cycle dependencies could be generated ;; Otherwise a lot of cycle dependencies could be generated
(defn- update-layout-positions (defn- update-layout-positions
[{:keys [page-id ids undo-group]}] [{:keys [page-id ids undo-group drain-ids]}]
(ptk/reify ::update-layout-positions (ptk/reify ::update-layout-positions
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(let [page-id (or page-id (:current-page-id state)) (let [page-id (or page-id (:current-page-id state))
objects (dsh/lookup-page-objects state page-id) objects (dsh/lookup-page-objects state page-id)
ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))] ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))
(if (d/not-empty? ids)
(let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))] update-positions-stream
(if (features/active-feature? state "render-wasm/v1") (if (d/not-empty? ids)
(rx/of (dwm/apply-wasm-modifiers modif-tree (let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))]
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree
:stack-undo? true
:undo-group undo-group
:ignore-touched true))
(rx/of (dwm/apply-modifiers {:page-id page-id
:modifiers modif-tree
:stack-undo? true :stack-undo? true
:undo-group undo-group :ignore-touched true
:ignore-touched true)) :undo-group undo-group}))))
(rx/of (dwm/apply-modifiers {:page-id page-id (rx/empty))]
:modifiers modif-tree
:stack-undo? true (cond->> update-positions-stream
:ignore-touched true ;; Drain the pending-reflow marks only after the apply events above are processed
:undo-group undo-group})))) (d/not-empty? drain-ids)
(rx/empty)))))) (rx/finalize #(wrf/mark-done! :layout drain-ids)))))))
(defn initialize-shape-layout (defn initialize-shape-layout
[] []
@ -138,15 +145,15 @@
;; they are process together. It will get a better performance. ;; they are process together. It will get a better performance.
(rx/buffer-time 100) (rx/buffer-time 100)
(rx/filter #(d/not-empty? %)) (rx/filter #(d/not-empty? %))
;; Balance the per-event marks: decrement once per buffered event
;; (multiplicity preserved via mapcat, no deduping).
(rx/tap #(wrf/mark-done! :layout (mapcat :ids %)))
(rx/mapcat (rx/mapcat
(fn [data] (fn [data]
(->> (group-by :page-id data) (->> (group-by :page-id data)
(map (fn [[page-id items]] (map (fn [[page-id items]]
(let [ids (reduce #(into %1 (:ids %2)) #{} items)] (let [ids (reduce #(into %1 (:ids %2)) #{} items)
(update-layout-positions {:page-id page-id :ids ids}))))))) drain-ids (mapcat :ids items)]
(update-layout-positions {:page-id page-id
:ids ids
:drain-ids drain-ids})))))))
(rx/take-until stopper) (rx/take-until stopper)
;; On workspace teardown clear everything still pending. ;; On workspace teardown clear everything still pending.
(rx/finalize wrf/reset-pending!)))))) (rx/finalize wrf/reset-pending!))))))

View File

@ -420,7 +420,8 @@
(wrf/mark-pending! :font [id]) (wrf/mark-pending! :font [id])
(let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] (let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)]
(->> stream (->> stream
(rx/filter (ptk/type? ::commit-position-data)) (rx/filter (ptk/type? ::position-data-committed))
(rx/filter #(contains? (:ids (deref %)) id))
(rx/take 1) (rx/take 1)
;; Timeout so the shape cannot stay pending forever ;; Timeout so the shape cannot stay pending forever
(rx/timeout stuck-timeout (rx/of :timeout)) (rx/timeout stuck-timeout (rx/of :timeout))
@ -793,6 +794,8 @@
(-> shape (-> shape
(assoc :position-data (get position-data (:id shape))))) (assoc :position-data (get position-data (:id shape)))))
{:stack-undo? true :reg-objects? false})) {:stack-undo? true :reg-objects? false}))
(rx/of (ptk/data-event ::position-data-committed
{:ids (into #{} (keys position-data))}))
(rx/of (fn [state] (rx/of (fn [state]
(dissoc state ::update-position-data-debounce ::update-position-data)))))))) (dissoc state ::update-position-data-debounce ::update-position-data))))))))
@ -897,7 +900,8 @@
(wrf/mark-pending! :font [id]) (wrf/mark-pending! :font [id])
(let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] (let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)]
(->> stream (->> stream
(rx/filter (ptk/type? ::commit-position-data)) (rx/filter (ptk/type? ::position-data-committed))
(rx/filter #(contains? (:ids (deref %)) id))
(rx/take 1) (rx/take 1)
;; Timeout so the shape cannot stay pending forever ;; Timeout so the shape cannot stay pending forever
(rx/timeout stuck-timeout (rx/of :timeout)) (rx/timeout stuck-timeout (rx/of :timeout))

View File

@ -201,18 +201,24 @@
(every? (every?
(fn [font] (fn [font]
(let [font-data (wasm.fonts/make-font-data font)] (let [font-data (wasm.fonts/make-font-data font)]
(wasm.fonts/font-stored? font-data (:emoji? font-data))))))] (wasm.fonts/font-stored? font-data (:emoji? font-data))))))
(if fonts-loaded?
(let [pass-opts (when (or (some? undo-group) (some? undo-id))
(cond-> {}
(some? undo-group) (assoc :undo-group undo-group)
(some? undo-id) (assoc :undo-id undo-id)))]
(rx/of (resize-wasm-text-debounce-inner id pass-opts)))
;; Fonts not loaded; retry after 20 msecs resize-wasm-stream
(->> (rx/of (resize-wasm-text-debounce id opts)) (if fonts-loaded?
(rx/delay 20)))))))) (let [pass-opts (when (or (some? undo-group) (some? undo-id))
(cond-> {}
(some? undo-group) (assoc :undo-group undo-group)
(some? undo-id) (assoc :undo-id undo-id)))]
(rx/of (resize-wasm-text-debounce-inner id pass-opts)))
;; Fonts not loaded; retry after 20 msecs
(->> (rx/of (resize-wasm-text-debounce id opts))
(rx/delay 20)))]
(wrf/mark-pending! :text-resize [id])
(->> resize-wasm-stream
(rx/finalize #(wrf/mark-done! :text-resize [id]))))))))
(defn resize-wasm-text-all (defn resize-wasm-text-all
"Resize all text shapes (auto-width/auto-height) from a collection of ids." "Resize all text shapes (auto-width/auto-height) from a collection of ids."

View File

@ -161,12 +161,26 @@
"Stores the original WASM function values so they can be restored." "Stores the original WASM function values so they can be restored."
(atom {})) (atom {}))
;; Nesting depth of setup/teardown pairs. Only the outermost setup saves the
;; originals and installs the mocks, and only the outermost teardown restores
;; them, so a `with-wasm-mocks*` inside a fixture that already installed the
;; mocks does not capture mocks as "originals" and leak them.
(def ^:private install-depth (atom 0))
(declare install-wasm-mocks!)
(defn setup-wasm-mocks! (defn setup-wasm-mocks!
"Install WASM mocks via `set!` that persist across async boundaries. "Install WASM mocks via `set!` that persist across async boundaries.
Also resets call counts. Call `teardown-wasm-mocks!` to restore." Also resets call counts. Call `teardown-wasm-mocks!` to restore.
Nested calls are no-ops apart from resetting call counts."
[] []
;; Reset call tracking ;; Reset call tracking
(reset-call-counts!) (reset-call-counts!)
(when (= 1 (swap! install-depth inc))
(install-wasm-mocks!)))
(defn- install-wasm-mocks!
[]
;; Save originals ;; Save originals
(reset! originals (reset! originals
{:initialized? wasm.api/initialized? {:initialized? wasm.api/initialized?
@ -201,12 +215,16 @@
(set! wasm.fonts/make-font-data mock-make-font-data) (set! wasm.fonts/make-font-data mock-make-font-data)
(set! wasm.fonts/get-content-fonts mock-get-content-fonts)) (set! wasm.fonts/get-content-fonts mock-get-content-fonts))
(declare restore-wasm-mocks!)
(defn teardown-wasm-mocks! (defn teardown-wasm-mocks!
"Restore the original WASM functions saved by `setup-wasm-mocks!`. "Restore the original WASM functions saved by `setup-wasm-mocks!`.
No-op when there is nothing to restore (`originals` empty): restoring from an Nested calls are no-ops; only the outermost teardown restores."
empty snapshot would `set!` every WASM function to nil, breaking any later []
code that calls them (e.g. a leaked debounced event firing during a (when (zero? (swap! install-depth #(max 0 (dec %))))
subsequent test namespace)." (restore-wasm-mocks!)))
(defn- restore-wasm-mocks!
[] []
(when-let [orig (not-empty @originals)] (when-let [orig (not-empty @originals)]
(set! wasm.api/initialized? (:initialized? orig)) (set! wasm.api/initialized? (:initialized? orig))

View File

@ -420,8 +420,15 @@
(def ^:private zero-id "00000000-0000-0000-0000-000000000000") (def ^:private zero-id "00000000-0000-0000-0000-000000000000")
(t/use-fixtures :each (t/use-fixtures :each
{:before wrf/reset-pending! {:before (fn []
;; Keep the WASM mocks installed across the async test bodies:
;; debounced text-resize timers leaked by earlier test namespaces
;; can fire inside this namespace's async waits, and in Node they
;; would otherwise reach the real (unavailable) WASM API.
(thw/setup-wasm-mocks!)
(wrf/reset-pending!))
:after (fn [] :after (fn []
(thw/teardown-wasm-mocks!)
(wrf/reset-pending!) (wrf/reset-pending!)
(set! st/state original-st-state) (set! st/state original-st-state)
(set! st/stream original-st-stream))}) (set! st/stream original-st-stream))})

View File

@ -262,7 +262,7 @@
(fn [id theme] (fn [id theme]
(swap! captured conj {:id id :theme theme}) (swap! captured conj {:id id :theme theme})
:update-token-theme) :update-token-theme)
st/emit! identity] st/emit! mock/noop]
(.addSet theme set) (.addSet theme set)
(.removeSet theme set) (.removeSet theme set)
(t/is (= [theme-id theme-id] (mapv :id @captured))) (t/is (= [theme-id theme-id] (mapv :id @captured)))

View File

@ -1,7 +1,14 @@
{ {
"interfaces": { "interfaces": {
"ActiveUser": ["position", "zoom"], "ActiveUser": [
"Blur": ["hidden", "id", "value"], "position",
"zoom"
],
"Blur": [
"hidden",
"id",
"value"
],
"Board": [ "Board": [
"addFlexLayout", "addFlexLayout",
"addGridLayout", "addGridLayout",
@ -31,8 +38,17 @@
"insertChild", "insertChild",
"type" "type"
], ],
"Bounds": ["height", "width", "x", "y"], "Bounds": [
"CloseOverlay": ["animation", "destination", "type"], "height",
"width",
"x",
"y"
],
"CloseOverlay": [
"animation",
"destination",
"type"
],
"Color": [ "Color": [
"color", "color",
"fileId", "fileId",
@ -43,9 +59,20 @@
"opacity", "opacity",
"path" "path"
], ],
"ColorShapeInfo": ["shapesInfo"], "ColorShapeInfo": [
"ColorShapeInfoEntry": ["index", "property", "shapeId"], "shapesInfo"
"Comment": ["content", "date", "remove", "user"], ],
"ColorShapeInfoEntry": [
"index",
"property",
"shapeId"
],
"Comment": [
"content",
"date",
"remove",
"user"
],
"CommentThread": [ "CommentThread": [
"board", "board",
"findComments", "findComments",
@ -114,9 +141,12 @@
"uploadMediaData", "uploadMediaData",
"uploadMediaUrl", "uploadMediaUrl",
"version", "version",
"viewport" "viewport",
"waitForLayoutUpdate"
],
"ContextGeometryUtils": [
"center"
], ],
"ContextGeometryUtils": ["center"],
"ContextTypesUtils": [ "ContextTypesUtils": [
"isBoard", "isBoard",
"isBool", "isBool",
@ -130,9 +160,19 @@
"isVariantComponent", "isVariantComponent",
"isVariantContainer" "isVariantContainer"
], ],
"ContextUtils": ["geometry", "types"], "ContextUtils": [
"Dissolve": ["duration", "easing", "type"], "geometry",
"Ellipse": ["fills", "type"], "types"
],
"Dissolve": [
"duration",
"easing",
"type"
],
"Ellipse": [
"fills",
"type"
],
"EventsMap": [ "EventsMap": [
"contentsave", "contentsave",
"filechange", "filechange",
@ -142,7 +182,12 @@
"shapechange", "shapechange",
"themechange" "themechange"
], ],
"Export": ["scale", "skipChildren", "suffix", "type"], "Export": [
"scale",
"skipChildren",
"suffix",
"type"
],
"File": [ "File": [
"export", "export",
"findVersions", "findVersions",
@ -171,9 +216,21 @@
"fillImage", "fillImage",
"fillOpacity" "fillOpacity"
], ],
"Flags": ["naturalChildOrdering", "throwValidationErrors"], "Flags": [
"FlexLayout": ["appendChild", "dir", "wrap"], "naturalChildOrdering",
"Flow": ["name", "page", "remove", "startingBoard"], "throwValidationErrors"
],
"FlexLayout": [
"appendChild",
"dir",
"wrap"
],
"Flow": [
"name",
"page",
"remove",
"startingBoard"
],
"Font": [ "Font": [
"applyToRange", "applyToRange",
"applyToText", "applyToText",
@ -192,8 +249,21 @@
"findById", "findById",
"findByName" "findByName"
], ],
"FontVariant": ["fontStyle", "fontVariantId", "fontWeight", "name"], "FontVariant": [
"Gradient": ["endX", "endY", "startX", "startY", "stops", "type", "width"], "fontStyle",
"fontVariantId",
"fontWeight",
"name"
],
"Gradient": [
"endX",
"endY",
"startX",
"startY",
"stops",
"type",
"width"
],
"GridLayout": [ "GridLayout": [
"addColumn", "addColumn",
"addColumnAtIndex", "addColumnAtIndex",
@ -217,7 +287,11 @@
"removeMask", "removeMask",
"type" "type"
], ],
"GuideColumn": ["display", "params", "type"], "GuideColumn": [
"display",
"params",
"type"
],
"GuideColumnParams": [ "GuideColumnParams": [
"color", "color",
"gutter", "gutter",
@ -226,10 +300,24 @@
"size", "size",
"type" "type"
], ],
"GuideRow": ["display", "params", "type"], "GuideRow": [
"GuideSquare": ["display", "params", "type"], "display",
"GuideSquareParams": ["color", "size"], "params",
"HistoryContext": ["undoBlockBegin", "undoBlockFinish"], "type"
],
"GuideSquare": [
"display",
"params",
"type"
],
"GuideSquareParams": [
"color",
"size"
],
"HistoryContext": [
"undoBlockBegin",
"undoBlockFinish"
],
"ImageData": [ "ImageData": [
"data", "data",
"height", "height",
@ -239,7 +327,13 @@
"name", "name",
"width" "width"
], ],
"Interaction": ["action", "delay", "remove", "shape", "trigger"], "Interaction": [
"action",
"delay",
"remove",
"shape",
"trigger"
],
"LayoutCellProperties": [ "LayoutCellProperties": [
"areaName", "areaName",
"column", "column",
@ -297,7 +391,12 @@
"connected", "connected",
"local" "local"
], ],
"LibraryElement": ["id", "libraryId", "name", "path"], "LibraryElement": [
"id",
"libraryId",
"name",
"path"
],
"LibrarySummary": [ "LibrarySummary": [
"id", "id",
"name", "name",
@ -326,15 +425,25 @@
"variantProps", "variantProps",
"variants" "variants"
], ],
"LocalStorage": ["getItem", "getKeys", "removeItem", "setItem"], "LocalStorage": [
"getItem",
"getKeys",
"removeItem",
"setItem"
],
"NavigateTo": [ "NavigateTo": [
"animation", "animation",
"destination", "destination",
"preserveScrollPosition", "preserveScrollPosition",
"type" "type"
], ],
"OpenOverlay": ["type"], "OpenOverlay": [
"OpenUrl": ["type", "url"], "type"
],
"OpenUrl": [
"type",
"url"
],
"OverlayAction": [ "OverlayAction": [
"addBackgroundOverlay", "addBackgroundOverlay",
"animation", "animation",
@ -361,9 +470,23 @@
"root", "root",
"rulerGuides" "rulerGuides"
], ],
"Path": ["commands", "d", "fills", "type"], "Path": [
"PathCommand": ["command", "params"], "commands",
"Penpot": ["closePlugin", "off", "on", "ui", "utils"], "d",
"fills",
"type"
],
"PathCommand": [
"command",
"params"
],
"Penpot": [
"closePlugin",
"off",
"on",
"ui",
"utils"
],
"PluginData": [ "PluginData": [
"getPluginData", "getPluginData",
"getPluginDataKeys", "getPluginDataKeys",
@ -372,11 +495,28 @@
"setPluginData", "setPluginData",
"setSharedPluginData" "setSharedPluginData"
], ],
"Point": ["x", "y"], "Point": [
"PreviousScreen": ["type"], "x",
"Push": ["direction", "duration", "easing", "type"], "y"
"Rectangle": ["fills", "type"], ],
"RulerGuide": ["board", "orientation", "position", "remove"], "PreviousScreen": [
"type"
],
"Push": [
"direction",
"duration",
"easing",
"type"
],
"Rectangle": [
"fills",
"type"
],
"RulerGuide": [
"board",
"orientation",
"position"
],
"Shadow": [ "Shadow": [
"blur", "blur",
"color", "color",
@ -454,11 +594,19 @@
"switchVariant", "switchVariant",
"tokens", "tokens",
"visible", "visible",
"waitForLayoutUpdate",
"width", "width",
"x", "x",
"y" "y"
], ],
"Slide": ["direction", "duration", "easing", "offsetEffect", "type", "way"], "Slide": [
"direction",
"duration",
"easing",
"offsetEffect",
"type",
"way"
],
"Stroke": [ "Stroke": [
"strokeAlignment", "strokeAlignment",
"strokeCapEnd", "strokeCapEnd",
@ -472,7 +620,9 @@
"strokeStyle", "strokeStyle",
"strokeWidth" "strokeWidth"
], ],
"SvgRaw": ["type"], "SvgRaw": [
"type"
],
"Text": [ "Text": [
"align", "align",
"applyTypography", "applyTypography",
@ -513,7 +663,9 @@
"textTransform", "textTransform",
"verticalAlign" "verticalAlign"
], ],
"ToggleOverlay": ["type"], "ToggleOverlay": [
"type"
],
"TokenBase": [ "TokenBase": [
"applyToSelected", "applyToSelected",
"applyToShapes", "applyToShapes",
@ -524,8 +676,16 @@
"remove", "remove",
"resolvedValueString" "resolvedValueString"
], ],
"TokenBorderRadius": ["resolvedValue", "type", "value"], "TokenBorderRadius": [
"TokenBorderWidth": ["resolvedValue", "type", "value"], "resolvedValue",
"type",
"value"
],
"TokenBorderWidth": [
"resolvedValue",
"type",
"value"
],
"TokenCatalog": [ "TokenCatalog": [
"addSet", "addSet",
"addTheme", "addTheme",
@ -534,15 +694,51 @@
"sets", "sets",
"themes" "themes"
], ],
"TokenColor": ["resolvedValue", "type", "value"], "TokenColor": [
"TokenDimension": ["resolvedValue", "type", "value"], "resolvedValue",
"TokenFontFamilies": ["resolvedValue", "type", "value"], "type",
"TokenFontSizes": ["resolvedValue", "type", "value"], "value"
"TokenFontWeights": ["resolvedValue", "type", "value"], ],
"TokenLetterSpacing": ["resolvedValue", "type", "value"], "TokenDimension": [
"TokenNumber": ["resolvedValue", "type", "value"], "resolvedValue",
"TokenOpacity": ["resolvedValue", "type", "value"], "type",
"TokenRotation": ["resolvedValue", "type", "value"], "value"
],
"TokenFontFamilies": [
"resolvedValue",
"type",
"value"
],
"TokenFontSizes": [
"resolvedValue",
"type",
"value"
],
"TokenFontWeights": [
"resolvedValue",
"type",
"value"
],
"TokenLetterSpacing": [
"resolvedValue",
"type",
"value"
],
"TokenNumber": [
"resolvedValue",
"type",
"value"
],
"TokenOpacity": [
"resolvedValue",
"type",
"value"
],
"TokenRotation": [
"resolvedValue",
"type",
"value"
],
"TokenSet": [ "TokenSet": [
"active", "active",
"addToken", "addToken",
@ -555,7 +751,11 @@
"tokens", "tokens",
"tokensByType" "tokensByType"
], ],
"TokenShadow": ["resolvedValue", "type", "value"], "TokenShadow": [
"resolvedValue",
"type",
"value"
],
"TokenShadowValue": [ "TokenShadowValue": [
"blur", "blur",
"color", "color",
@ -572,10 +772,26 @@
"offsetY", "offsetY",
"spread" "spread"
], ],
"TokenSizing": ["resolvedValue", "type", "value"], "TokenSizing": [
"TokenSpacing": ["resolvedValue", "type", "value"], "resolvedValue",
"TokenTextCase": ["resolvedValue", "type", "value"], "type",
"TokenTextDecoration": ["resolvedValue", "type", "value"], "value"
],
"TokenSpacing": [
"resolvedValue",
"type",
"value"
],
"TokenTextCase": [
"resolvedValue",
"type",
"value"
],
"TokenTextDecoration": [
"resolvedValue",
"type",
"value"
],
"TokenTheme": [ "TokenTheme": [
"active", "active",
"activeSets", "activeSets",
@ -589,7 +805,11 @@
"removeSet", "removeSet",
"toggleActive" "toggleActive"
], ],
"TokenTypography": ["resolvedValue", "type", "value"], "TokenTypography": [
"resolvedValue",
"type",
"value"
],
"TokenTypographyValue": [ "TokenTypographyValue": [
"fontFamilies", "fontFamilies",
"fontSizes", "fontSizes",
@ -608,9 +828,20 @@
"textCase", "textCase",
"textDecoration" "textDecoration"
], ],
"Track": ["type", "value"], "Track": [
"User": ["avatarUrl", "color", "id", "name", "sessionId"], "type",
"VariantContainer": ["variants"], "value"
],
"User": [
"avatarUrl",
"color",
"id",
"name",
"sessionId"
],
"VariantContainer": [
"variants"
],
"Variants": [ "Variants": [
"addProperty", "addProperty",
"addVariant", "addVariant",
@ -1213,6 +1444,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -1713,6 +1950,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -2312,6 +2555,12 @@
"kind": "method", "kind": "method",
"type": "VariantContainer", "type": "VariantContainer",
"array": false "array": false
},
"waitForLayoutUpdate": {
"decl": "Context",
"kind": "method",
"type": null,
"array": false
} }
}, },
"ContextGeometryUtils": { "ContextGeometryUtils": {
@ -2845,6 +3094,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -4087,6 +4342,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -4671,6 +4932,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -6299,6 +6566,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -6620,6 +6893,12 @@
"kind": "method", "kind": "method",
"type": "VariantContainer", "type": "VariantContainer",
"array": false "array": false
},
"waitForLayoutUpdate": {
"decl": "Context",
"kind": "method",
"type": null,
"array": false
} }
}, },
"PluginData": { "PluginData": {
@ -7129,6 +7408,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -7657,6 +7942,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -8221,6 +8512,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -8787,6 +9084,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -11085,6 +11388,12 @@
"type": null, "type": null,
"array": false "array": false
}, },
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": { "getPluginData": {
"decl": "PluginData", "decl": "PluginData",
"kind": "method", "kind": "method",
@ -11240,7 +11549,11 @@
} }
}, },
"Animation": { "Animation": {
"variants": ["Dissolve", "Slide", "Push"], "variants": [
"Dissolve",
"Slide",
"Push"
],
"discriminant": { "discriminant": {
"field": "type", "field": "type",
"map": { "map": {
@ -11251,7 +11564,11 @@
} }
}, },
"Guide": { "Guide": {
"variants": ["GuideColumn", "GuideRow", "GuideSquare"], "variants": [
"GuideColumn",
"GuideRow",
"GuideSquare"
],
"discriminant": { "discriminant": {
"field": "type", "field": "type",
"map": { "map": {
@ -11332,7 +11649,10 @@
} }
}, },
"TokenValueString": { "TokenValueString": {
"variants": ["TokenShadowValueString", "TokenTypographyValueString"], "variants": [
"TokenShadowValueString",
"TokenTypographyValueString"
],
"discriminant": null "discriminant": null
} }
} }

View File

@ -0,0 +1,399 @@
import { expect, expectReject } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board, Font, Shape, Text } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// waitForLayoutUpdate (context-level and per-shape).
// The promise must resolve only once the pending reflow work has been applied,
// so every test asserts the *observable geometry* after awaiting: if the
// promise settles too early the read-back is stale and the test goes red.
// Reflow work is scheduled by three pipelines: flex/grid layout updates
// (buffered ~100ms), wasm text resizes (debounced), and font-change
// re-measurement. The probes below cover each one, including cascaded reflows
// (a child resize that must also re-hug its ancestors) and waits racing the
// font-loading retry loop.
function flexBoard(ctx: TestContext): Board {
const b = ctx.penpot.createBoard();
ctx.board.appendChild(b);
return b;
}
/** Creates a horizontal flex board with two 50x50 rects and a 10px gap. */
function flexRow(ctx: TestContext): {
board: Board;
rects: [Shape, Shape];
} {
const b = flexBoard(ctx);
const flex = b.addFlexLayout();
flex.dir = 'row';
flex.columnGap = 10;
const r1 = ctx.penpot.createRectangle();
r1.resize(50, 50);
flex.appendChild(r1);
const r2 = ctx.penpot.createRectangle();
r2.resize(50, 50);
flex.appendChild(r2);
return { board: b, rects: [r1, r2] };
}
/** Orders the two rects of a flex row by their current x position. */
function byX(rects: [Shape, Shape]): { left: Shape; right: Shape } {
const [a, b] = rects;
return a.x <= b.x ? { left: a, right: b } : { left: b, right: a };
}
/**
* Picks a font from the end of the catalog that differs from the text's
* current font, so assigning it triggers a real (not-yet-loaded) font fetch.
* `offset` lets consecutive tests pick distinct fonts: fonts loaded by an
* earlier test stay cached for the whole run and would drain the pending mark
* near-instantly if reused.
*/
function unloadedFont(ctx: TestContext, t: Text, offset: number): Font {
const all = ctx.penpot.fonts.all;
for (let i = all.length - 1 - offset; i >= 0; i--) {
const f = all[i];
if (f.fontId !== t.fontId && f.variants.length > 0) return f;
}
throw new Error('no alternative font available');
}
function autoWidthText(ctx: TestContext, value: string): Text {
const t = ctx.penpot.createText(value);
if (!t) throw new Error('createText returned null');
ctx.board.appendChild(t);
t.growType = 'auto-width';
t.fontSize = '36';
return t;
}
describe('WaitForLayoutUpdate', () => {
describe('Basics', () => {
test('context method returns a promise that resolves', async (ctx) => {
const p = ctx.penpot.waitForLayoutUpdate();
expect(typeof p.then).toBe('function');
const value = await p;
expect(value).toBeUndefined();
});
// For the next two, resolving (not rejecting, not hitting the runner
// timeout) is itself the behaviour under test, so they have no expects.
test('a timeout wait resolves via the fast path when nothing is pending', async (ctx) => {
// Drain any residual work first; once the untimed wait resolves the
// pending map is empty, so the settle signal replays synchronously and
// must win the race against the deadline: even a 1ms timeout has to
// resolve, never reject.
await ctx.penpot.waitForLayoutUpdate();
await ctx.penpot.waitForLayoutUpdate(1);
});
test('shape method resolves for a shape with no pending work', async (ctx) => {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
await r.waitForLayoutUpdate();
});
test('consecutive waits each track their own pending work', async (ctx) => {
// Each call opens a fresh subscription to the pending map, so a wait must
// settle on the work scheduled right before it and never carry state from
// an earlier, already-resolved wait. Two successive resizes to different
// sizes prove it: the second wait has to reflect the second reflow.
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
left.resize(70, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(80, 0);
});
});
describe('Flex reflow', () => {
test('sibling is repositioned after awaiting a child resize', async (ctx) => {
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test('board-level wait settles the reflow of its layout', async (ctx) => {
const { board: b, rects } = flexRow(ctx);
await b.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
// The layout pipeline marks the laid-out board (the parent) as pending,
// so the board proxy is the shape-level handle for this reflow.
await b.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test('back-to-back resizes settle to the last value', async (ctx) => {
// Overlapping operations on the same shapes: the wait must not resolve
// after the first one drains while the second is still in flight.
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(80, 50);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test('grid layout reflow settles child positions', async (ctx) => {
const b = flexBoard(ctx);
const grid = b.addGridLayout();
grid.addRow('fixed', 60);
grid.addColumn('fixed', 60);
grid.addColumn('fixed', 60);
const r1 = ctx.penpot.createRectangle();
r1.resize(50, 50);
grid.appendChild(r1, 1, 1);
const r2 = ctx.penpot.createRectangle();
r2.resize(50, 50);
grid.appendChild(r2, 1, 2);
await ctx.penpot.waitForLayoutUpdate();
// Cells are 60px wide, so the second child sits one track further right.
expect(Math.abs(r2.x - r1.x)).toBeCloseTo(60, 0);
});
});
describe('Hug sizing', () => {
test('hug board fits its content after awaiting the initial layout', async (ctx) => {
const { board: b } = flexRow(ctx);
const flex = b.flex;
if (!flex) throw new Error('expected a flex layout on the board');
flex.horizontalSizing = 'auto';
flex.verticalSizing = 'auto';
await ctx.penpot.waitForLayoutUpdate();
// 50 + 10 gap + 50, no padding.
expect(b.width).toBeCloseTo(110, 0);
});
test('hug board grows after awaiting a child resize', async (ctx) => {
const { board: b, rects } = flexRow(ctx);
const flex = b.flex;
if (!flex) throw new Error('expected a flex layout on the board');
flex.horizontalSizing = 'auto';
flex.verticalSizing = 'auto';
await ctx.penpot.waitForLayoutUpdate();
const { left } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(b.width).toBeCloseTo(180, 0);
});
test('nested hug boards both grow after awaiting a child resize', async (ctx) => {
// Cascade probe: the child resize reflows the inner board, whose new
// size must then reflow the outer board. The wait has to cover the whole
// chain, not just the first reflow round.
const outer = flexBoard(ctx);
const outerFlex = outer.addFlexLayout();
outerFlex.dir = 'row';
outerFlex.horizontalSizing = 'auto';
outerFlex.verticalSizing = 'auto';
const inner = ctx.penpot.createBoard();
outerFlex.appendChild(inner);
const innerFlex = inner.addFlexLayout();
innerFlex.dir = 'row';
innerFlex.horizontalSizing = 'auto';
innerFlex.verticalSizing = 'auto';
const rect = ctx.penpot.createRectangle();
rect.resize(50, 50);
innerFlex.appendChild(rect);
await ctx.penpot.waitForLayoutUpdate();
expect(inner.width).toBeCloseTo(50, 0);
expect(outer.width).toBeCloseTo(50, 0);
rect.resize(200, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(inner.width).toBeCloseTo(200, 0);
expect(outer.width).toBeCloseTo(200, 0);
});
test('hug board hugs an auto-width text after a characters change', async (ctx) => {
// Cascade probe across pipelines: the text resize is scheduled by the
// text pipeline and only then does the board reflow to hug it.
const b = flexBoard(ctx);
const flex = b.addFlexLayout();
flex.horizontalSizing = 'auto';
flex.verticalSizing = 'auto';
const t = ctx.penpot.createText('hi');
if (!t) throw new Error('createText returned null');
flex.appendChild(t);
t.growType = 'auto-width';
await ctx.penpot.waitForLayoutUpdate();
const w0 = b.width;
t.characters = 'a much much much longer text content than before';
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
expect(b.width).toBeCloseTo(t.width, 0);
});
});
describe('Text', () => {
test('auto-width text grows after awaiting a fontSize bump', async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
t.fontSize = '72';
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
});
test('auto-width text grows after awaiting a characters change', async (ctx) => {
const t = autoWidthText(ctx, 'short');
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
t.characters = 'short plus a considerably longer tail 0123456789';
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
});
test('shape-level wait covers the text own resize', async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await t.waitForLayoutUpdate();
const w0 = t.width;
t.fontSize = '72';
await t.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
});
test('auto-width text is re-measured after awaiting a font change', async (ctx) => {
// Probe for the font-loading window: right after a font change the new
// font is typically not loaded yet, and the wait must cover the load +
// re-measure, not resolve while the loader is still retrying.
const t = autoWidthText(
ctx,
'The quick brown fox jumps over the lazy dog 0123456789',
);
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
const font = ctx.penpot.fonts.all.find(
(f) => f.fontId !== t.fontId && f.variants.length > 0,
);
if (!font) throw new Error('no alternative font available');
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
await ctx.penpot.waitForLayoutUpdate();
// A different family practically never yields the same measured width
// for a 54-char string at 36px.
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
});
test('range font change is re-measured after awaiting', async (ctx) => {
// Same probe through the text-range pipeline instead of the shape-level
// font setter.
const t = autoWidthText(
ctx,
'The quick brown fox jumps over the lazy dog 0123456789',
);
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
const font = ctx.penpot.fonts.all.find(
(f) => f.fontId !== t.fontId && f.variants.length > 0,
);
if (!font) throw new Error('no alternative font available');
const range = t.getRange(0, t.characters.length);
range.fontFamily = font.fontFamily;
range.fontId = font.fontId;
await ctx.penpot.waitForLayoutUpdate();
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
});
});
describe('Timeout', () => {
// Pure geometry mutations (resize & co.) are settled synchronously by the
// renderer, so right after them nothing is pending and a short-timeout
// wait legitimately resolves. The deterministic pending source is a font
// change: it marks the text pending until the font is fetched and the
// shape re-measured, which can never complete within 1ms. These tests hit
// the real backend for the font asset, hence skipIfMocked.
test.skipIfMocked(
'rejects with a timeout error while a font is loading',
async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
const font = unloadedFont(ctx, t, 0);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
await expectReject(ctx.penpot.waitForLayoutUpdate(1), /timeout/i);
},
);
test.skipIfMocked('shape-level wait also rejects on timeout', async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
const font = unloadedFont(ctx, t, 1);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
await expectReject(t.waitForLayoutUpdate(1), /timeout/i);
});
test('resolves normally when work settles before the timeout', async (ctx) => {
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate(5000);
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test.skipIfMocked('a rejected wait leaves later waits working', async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
const font = unloadedFont(ctx, t, 2);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
await expectReject(ctx.penpot.waitForLayoutUpdate(1), /timeout/i);
// A second wait with a generous timeout still tracks the same pending
// work and resolves once the font lands and the text is re-measured.
await ctx.penpot.waitForLayoutUpdate(12000);
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
});
test.skipIfMocked(
'per-shape wait is not blocked by another shape pending work',
async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
const bystander = ctx.penpot.createRectangle();
ctx.board.appendChild(bystander);
await ctx.penpot.waitForLayoutUpdate();
const font = unloadedFont(ctx, t, 3);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
// The text has pending font work but the bystander does not; its wait
// must resolve on the fast path instead of waiting for the font.
const started = Date.now();
await bystander.waitForLayoutUpdate();
expect(Date.now() - started).toBeLessThan(90);
},
);
});
});