From 9b22d96553a06db19bdbf547d8489f1e5994b29d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 24 Jun 2026 12:28:06 +0200 Subject: [PATCH 001/100] :bug: Fix render from cache --- render-wasm/src/render.rs | 89 ++---------------- render-wasm/src/render/surfaces.rs | 144 ++++++++++++++++++++++++++--- 2 files changed, 138 insertions(+), 95 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 9dda9ce11d..cc26d6de45 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2003,89 +2003,12 @@ impl RenderState { pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { let _start = performance::begin_timed_log!("render_from_cache"); performance::begin_measure!("render_from_cache"); - let bg_color = self.background_color; - - // During fast mode (pan/zoom), if a previous full-quality render still has pending tiles, - // always prefer the persistent atlas. The atlas is incrementally updated as tiles finish, - // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. - if self.options.is_fast_mode() && !self.surfaces.atlas.is_empty() { - self.surfaces - .draw_atlas_to_backbuffer(self.viewbox, bg_color); - - self.present_frame(shapes); - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); - return; - } - - // Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache) - if self.cached_viewbox.area.width() > 0.0 { - // Scale and translate the target according to the cached data - let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom; - - let interest = self.options.dpr_viewport_interest_area_threshold; - let TileRect(start_tile_x, start_tile_y, _, _) = - tiles::get_tiles_for_viewbox_with_interest(&self.cached_viewbox, interest); - let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr; - let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr; - let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; - let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y; - - // For zoom-out, prefer cache only if it fully covers the viewport. - // Otherwise, atlas will provide a more correct full-viewport preview. - let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom; - if zooming_out { - let cache_dim = self.surfaces.cache_dimensions(); - let cache_w = cache_dim.width as f32; - let cache_h = cache_dim.height as f32; - - // Viewport in target pixels. - let vw = self.viewbox.dpr_width().max(1.0); - let vh = self.viewbox.dpr_height().max(1.0); - - // Inverse-map viewport corners into cache coordinates. - // target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords). - // => cache = (target / navigate_zoom) - translate - let inv = if navigate_zoom.abs() > f32::EPSILON { - 1.0 / navigate_zoom - } else { - 0.0 - }; - - // let cx0 = (0.0 * inv) - translate_x; - // let cy0 = (0.0 * inv) - translate_y; - // NOTA: 0.0 * inv => siempre 0 - let cx0 = -translate_x; - let cy0 = -translate_y; - let cx1 = (vw * inv) - translate_x; - let cy1 = (vh * inv) - translate_y; - - let min_x = cx0.min(cx1); - let min_y = cy0.min(cy1); - let max_x = cx0.max(cx1); - let max_y = cy0.max(cy1); - - let cache_covers = - min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h; - if !cache_covers { - // Early return only if atlas exists; otherwise keep cache path. - if !self.surfaces.atlas.is_empty() { - self.surfaces - .draw_atlas_to_backbuffer(self.viewbox, bg_color); - - self.present_frame(shapes); - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); - return; - } - } - } - - // Draw directly from cache surface, avoiding snapshot overhead - self.surfaces.draw_cache_to_backbuffer(); - - self.present_frame(shapes); - } + self.surfaces.draw_combined_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); + self.present_frame(shapes); performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 4f9952ca79..dc91011abc 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -590,6 +590,57 @@ impl Surfaces { canvas.restore(); } + /// Fast pan/zoom preview: draw the doc atlas as backdrop, then overlay HQ + /// cached tile textures placed via their stored document rects (pan + scale). + pub fn draw_combined_atlas_to_backbuffer( + &mut self, + viewbox: &Viewbox, + tile_viewbox: &TileViewbox, + background: skia::Color, + ) { + self.draw_atlas_to_backbuffer(*viewbox, background); + + // Tile textures are keyed by grid index but positioned in document space. + // Without `tile_doc_rects` we cannot displace/scale them correctly (e.g. + // right after zoom invalidation); the atlas backdrop alone is enough. + if self.atlas.tile_doc_rects.is_empty() { + return; + } + + let batch = self.tiles.build_atlas_draw_batch_for_doc_rects( + viewbox, + tile_viewbox, + &self.atlas.tile_doc_rects, + ); + if batch.is_empty() { + return; + } + + if self.tiles.needs_snapshot() || self.tile_atlas_image.is_none() { + self.tile_atlas_image = Some(self.tile_atlas.image_snapshot()); + self.tiles.snapshot(); + } + let Some(atlas_image) = self.tile_atlas_image.as_ref() else { + return; + }; + + let canvas = self.backbuffer.canvas(); + canvas.save(); + canvas.reset_matrix(); + + canvas.draw_atlas( + atlas_image, + &batch.transforms, + &batch.textures, + None, + skia::BlendMode::SrcOver, + self.atlas_sampling_options, + None, + None, + ); + canvas.restore(); + } + pub fn margins(&self) -> skia::ISize { self.margins } @@ -716,18 +767,6 @@ impl Surfaces { ); } - /// Draws the cache surface directly to the backbuffer canvas. - /// This avoids creating an intermediate snapshot, reducing GPU stalls. - pub fn draw_cache_to_backbuffer(&mut self) { - let sampling_options = self.sampling_options; - self.cache.draw( - self.backbuffer.canvas(), - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), - ); - } - pub fn cache_dimensions(&self) -> skia::ISize { skia::ISize::new(self.cache.width(), self.cache.height()) } @@ -1516,6 +1555,17 @@ pub struct TileTextureCache { removed: HashSet, } +pub struct AtlasDrawBatch { + pub transforms: Vec, + pub textures: Vec, +} + +impl AtlasDrawBatch { + pub fn is_empty(&self) -> bool { + self.transforms.is_empty() + } +} + impl TileTextureCache { pub fn new(texture_size: i32, capacity: usize) -> Self { Self { @@ -1615,6 +1665,76 @@ impl TileTextureCache { } } + pub fn build_atlas_draw_batch_for_doc_rects( + &self, + viewbox: &Viewbox, + tile_viewbox: &TileViewbox, + tile_doc_rects: &HashMap, + ) -> AtlasDrawBatch { + let mut transforms = Vec::new(); + let mut textures = Vec::new(); + + let s = viewbox.get_scale(); + let view_doc = viewbox.area; + + for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { + for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { + let tile = Tile(x, y); + + let Some(tile_ref) = self.grid.get(&tile) else { + continue; + }; + + if self.removed.contains(&tile) { + continue; + } + + let doc_rect = tile_doc_rects + .get(&tile) + .copied() + .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { + continue; + } + + let scos = doc_rect.width() * s / self.tile_size; + let tx = (doc_rect.left + viewbox.pan.x) * s; + let ty = (doc_rect.top + viewbox.pan.y) * s; + + transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); + textures.push(tile_ref.rect); + } + } + + // Cached tiles from a previous zoom level use indices outside visible_rect; + // place them via their stored document rect, not the current grid walk above. + for (&tile, tile_ref) in &self.grid { + if tile_viewbox.is_visible(&tile) || self.removed.contains(&tile) { + continue; + } + + let doc_rect = tile_doc_rects + .get(&tile) + .copied() + .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { + continue; + } + + let tx = (doc_rect.left + viewbox.pan.x) * s; + let ty = (doc_rect.top + viewbox.pan.y) * s; + let scos = doc_rect.width() * s / self.tile_size; + + transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); + textures.push(tile_ref.rect); + } + + AtlasDrawBatch { + transforms, + textures, + } + } + pub fn has(&self, tile: Tile) -> bool { self.grid.contains_key(&tile) && !self.removed.contains(&tile) } From b096832bf5554f8be4cd64386bb04b37c61c9cc4 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 24 Jun 2026 18:20:21 +0200 Subject: [PATCH 002/100] :bug: Fix v2 text editor detaching typography tokens (#10402) --- common/src/app/common/types/text.cljc | 16 ++++++++-- common/test/common_tests/types/text_test.cljc | 30 +++++++++++++++++++ .../src/app/util/text/content/from_dom.cljs | 12 ++++++-- .../src/app/util/text/content/to_dom.cljs | 5 ++-- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index e0330b3264..6068cfc829 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -78,6 +78,11 @@ text-transform-attrs text-fills)) +(def text-span-attrs + "Inline text span attrs. Line-height is paragraph-level in the DOM editor; + it may still be stored redundantly on span nodes." + (vec (remove #{:line-height} text-node-attrs))) + (defn text-node-attr? [attr] (d/index-of text-node-attrs attr)) @@ -317,9 +322,16 @@ "Given two content text structures, conformed by maps and vectors, compare them, and returns a set with the attributes that have changed. This is independent of the text structure, so if the structure changes - but the attributes are the same, it will return an empty set." + but the attributes are the same, it will return an empty set. + + Line-height on text nodes is ignored: it is a paragraph-level attribute + and may be stored redundantly on spans (e.g. after token apply)." [a b] - (let [diff-attrs (compare-text-content a b + (let [strip-span-line-height + #(transform-nodes is-text-node? (fn [node] (dissoc node :line-height)) %) + a (strip-span-line-height a) + b (strip-span-line-height b) + diff-attrs (compare-text-content a b {:text-cb identity :attribute-cb (fn [acc attr] (conj acc attr))})] (if-not (contains? diff-attrs :text-content-structure) diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index f6dea90560..b63a6db6e1 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -76,8 +76,31 @@ (assoc-in content-base [:children 0 :children 0 :children 0 :font-family] "Arial")) (def content-changed-line-height + (assoc-in content-base [:children 0 :children 0 :line-height] "1.5")) + +(def content-redundant-span-line-height (assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5")) +;; Token apply may store line-height on paragraph and spans; after a DOM +;; round-trip spans no longer carry it (paragraph-level in the editor). +(def content-token-like-line-height + (-> content-base + (assoc-in [:children 0 :children 0 :line-height] 1.4) + (assoc-in [:children 0 :children 0 :children 0 :line-height] 1.4))) + +(def content-after-editor-roundtrip + (update-in content-token-like-line-height + [:children 0 :children 0 :children 0] + dissoc :line-height)) + +;; from_dom used to merge default nil typography refs on import. +(def content-explicit-nil-typography-refs + (-> content-base + (assoc-in [:children 0 :children 0 :typography-ref-id] nil) + (assoc-in [:children 0 :children 0 :typography-ref-file] nil) + (assoc-in [:children 0 :children 0 :children 0 :typography-ref-id] nil) + (assoc-in [:children 0 :children 0 :children 0 :typography-ref-file] nil))) + (def content-changed-letter-spacing (assoc-in content-base [:children 0 :children 0 :children 0 :letter-spacing] "2")) @@ -185,6 +208,10 @@ ;; Other text-node-attr categories attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family) attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height) + attrs-span-line-height (cttx/get-diff-attrs content-base content-redundant-span-line-height) + attrs-roundtrip-line-height (cttx/get-diff-attrs content-token-like-line-height + content-after-editor-roundtrip) + attrs-nil-typography-refs (cttx/get-diff-attrs content-base content-explicit-nil-typography-refs) attrs-letter-spacing (cttx/get-diff-attrs content-base content-changed-letter-spacing) attrs-text-decoration (cttx/get-diff-attrs content-base content-changed-text-decoration) attrs-text-transform (cttx/get-diff-attrs content-base content-changed-text-transform) @@ -215,6 +242,9 @@ ;; Each text-node-attr category reports correct attr key (t/is (= #{:font-family} attrs-font-family)) (t/is (= #{:line-height} attrs-line-height)) + (t/is (= #{} attrs-span-line-height)) + (t/is (= #{} attrs-roundtrip-line-height)) + (t/is (= #{} attrs-nil-typography-refs)) (t/is (= #{:letter-spacing} attrs-letter-spacing)) (t/is (= #{:text-decoration} attrs-text-decoration)) (t/is (= #{:text-transform} attrs-text-transform)) diff --git a/frontend/src/app/util/text/content/from_dom.cljs b/frontend/src/app/util/text/content/from_dom.cljs index 4d016313b5..7b56b014c1 100644 --- a/frontend/src/app/util/text/content/from_dom.cljs +++ b/frontend/src/app/util/text/content/from_dom.cljs @@ -51,13 +51,19 @@ [_ style-decode] (get styles/mapping key)] (style-decode (.getPropertyValue style style-name))) (let [style-name (styles/get-style-name key)] - (styles/normalize-attr-value key (.getPropertyValue style style-name))))] - (assoc acc key (if (value-empty? value) (get defaults key) value)))) + (styles/normalize-attr-value key (.getPropertyValue style style-name)))) + default (get defaults key) + final-value (if (value-empty? value) default value)] + ;; Omit attrs with no CSS value when the default is nil (e.g. + ;; typography-ref-id). Avoids polluting round-tripped content. + (if (and (value-empty? value) (nil? default)) + acc + (assoc acc key final-value)))) {} attrs))) (defn get-text-span-styles [element] - (get-attrs-from-styles element txt/text-node-attrs (txt/get-default-text-attrs))) + (get-attrs-from-styles element txt/text-span-attrs (txt/get-default-text-attrs))) (defn get-paragraph-styles [element] diff --git a/frontend/src/app/util/text/content/to_dom.cljs b/frontend/src/app/util/text/content/to_dom.cljs index 373b49bdc9..cd7ab9d5aa 100644 --- a/frontend/src/app/util/text/content/to_dom.cljs +++ b/frontend/src/app/util/text/content/to_dom.cljs @@ -94,9 +94,8 @@ (defn get-text-span-styles [inline paragraph] - (let [node (if (= "" (:text inline)) paragraph inline) - styles (get-styles-from-attrs node txt/text-node-attrs txt/default-text-attrs)] - (dissoc styles :line-height))) + (let [node (if (= "" (:text inline)) paragraph inline)] + (get-styles-from-attrs node txt/text-span-attrs txt/default-text-attrs))) (defn normalize-spaces "Add zero-width spaces after forward slashes to enable word breaking" From 149caaf2926368a10414c3038ed6fefc11a66bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 25 Jun 2026 07:20:41 +0200 Subject: [PATCH 003/100] :bug: Fix rulers values visibility (#10408) --- render-wasm/src/render/rulers.rs | 51 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/render-wasm/src/render/rulers.rs b/render-wasm/src/render/rulers.rs index a33215b942..510979a2f8 100644 --- a/render-wasm/src/render/rulers.rs +++ b/render-wasm/src/render/rulers.rs @@ -28,13 +28,17 @@ const CANVAS_BORDER_RADIUS: f32 = 12.0; // distinct offsets for the two and we mirror that. const SELECTION_LABEL_BASELINE: f32 = 13.6; -// Selection-label gradient mask: matches the SVG `selection-gradient-start` -// and `selection-gradient-end` defs. The mask is `OVER_NUMBER_SIZE` screen -// pixels long, with the opaque part starting `OVER_NUMBER_PERCENT` of the -// way through the rect (40% from the outside edge, 60% from the inside). +// Selection-label gradient mask: fades the tick labels behind the selection +// band. The mask is `OVER_NUMBER_SIZE` screen pixels long. `OVER_NUMBER_PERCENT` +// is the fraction of the mask that sits *outside* the band; at 1.0 the mask's +// inner edge lands exactly on the band edge, so the dark shadow begins where +// the green selection area ends and fades outward (it never bleeds under the +// band). `GRADIENT_FADE_FRACTION` is the share of the mask spent on the +// transparent→opaque ramp at the outer end. const OVER_NUMBER_SIZE: f32 = 100.0; -const OVER_NUMBER_PERCENT: f32 = 0.75; +const OVER_NUMBER_PERCENT: f32 = 1.0; const GRADIENT_FADE_FRACTION: f32 = 0.4; +const OVER_NUMBER_OPACITY: f32 = 0.8; fn calculate_step_size(zoom: f32) -> f32 { if zoom <= 0.0 { @@ -308,23 +312,27 @@ fn draw_selection_x(ctx: &RenderCtx, sel: Rect, offset: f32) { ); let text_y = ctx.vy + SELECTION_LABEL_BASELINE * zi; - let pad_x = 4.0 * zi; + // Both labels use the same half-bar gap from the band so the start (left) + // and end (right) are spaced symmetrically. + let gap = (RULER_AREA_SIZE / 2.0) * zi; let left_label = format_label(sel.left - offset); let right_label = format_label(sel.right - offset); let (lw_font, _) = ctx.font.measure_str(&left_label, None); - // The right label is anchored at its left edge, so we don't need its - // measured width. - let lx = sel.left - pad_x - lw_font * zi; - let rx = sel.right + pad_x; + let lx = sel.left - gap - lw_font * zi; + let rx = sel.right + gap; let mut text_paint = Paint::default(); text_paint.set_color(ctx.state.accent_color); text_paint.set_anti_alias(true); + + // 1. Left label canvas.save(); canvas.translate((lx, text_y)); canvas.scale((zi, zi)); canvas.draw_str(&left_label, Point::new(0.0, 0.0), ctx.font, &text_paint); canvas.restore(); + + // 2. Right label canvas.save(); canvas.translate((rx, text_y)); canvas.scale((zi, zi)); @@ -341,7 +349,7 @@ enum MaskAxis { /// labels behind the selection band. `fade_to_end` flips it from /// transparent→opaque (before the band) to opaque→transparent (after). fn draw_mask(ctx: &RenderCtx, rect: Rect, axis: MaskAxis, fade_to_end: bool) { - let opaque = ctx.state.bg_color; + let opaque = with_alpha(ctx.state.bg_color, OVER_NUMBER_OPACITY); let transparent = with_alpha(ctx.state.bg_color, 0.0); let (colors, offsets): (&[skia::Color; 3], &[f32; 3]) = if fade_to_end { ( @@ -377,11 +385,10 @@ fn draw_selection_y(ctx: &RenderCtx, sel: Rect, offset: f32) { let canvas = ctx.canvas; let zi = ctx.zi; - let pad_y = 4.0 * zi; let top_label = format_label(sel.top - offset); let bottom_label = format_label(sel.bottom - offset); - // Top label's draw position doesn't depend on its own width (LX is just - // pad_y/zi), so we only need bw_font for the bottom label's right-anchor. + // Both labels sit a half-bar gap from the band; only the bottom label's + // origin depends on its own width (it reads toward the band). let (bw_font, _) = ctx.font.measure_str(&bottom_label, None); // Mask first (gradient bg over tick labels behind), then band, then @@ -416,33 +423,27 @@ fn draw_selection_y(ctx: &RenderCtx, sel: Rect, offset: f32) { let mut text_paint = Paint::default(); text_paint.set_color(ctx.state.accent_color); text_paint.set_anti_alias(true); - // Both labels read bottom-to-top on screen (after the -90° rotation - // local +x points upward). With the transform stack - // (translate→rotate→scale) and a draw at code-(LX, 0), the actual - // origin in document coords is (text_x, pivot_y − LX·zi). - // - // Top label: want origin just above sel.top, reading upward from - // there, so pivot_y − LX·zi = sel.top − pad_y ⇒ LX = pad_y/zi. + // Both labels read bottom-to-top on screen + // 1. Top label canvas.save(); canvas.translate((text_x, sel.top)); canvas.rotate(-90.0, None); canvas.scale((zi, zi)); canvas.draw_str( &top_label, - Point::new(pad_y / zi, 0.0), + Point::new(RULER_AREA_SIZE / 2.0, 0.0), ctx.font, &text_paint, ); canvas.restore(); - // Bottom label: want the text END at sel.bottom + pad_y and origin - // at sel.bottom + pad_y + bw so it reads upward toward the band. + // 2. Bottom label canvas.save(); canvas.translate((text_x, sel.bottom)); canvas.rotate(-90.0, None); canvas.scale((zi, zi)); canvas.draw_str( &bottom_label, - Point::new(-bw_font - pad_y / zi, 0.0), + Point::new(-bw_font - RULER_AREA_SIZE / 2.0, 0.0), ctx.font, &text_paint, ); From 15a336a24963ef10d151c03d6502ed99745a1116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Thu, 25 Jun 2026 09:28:23 +0200 Subject: [PATCH 004/100] :sparkles: Allow nitrate to view org teams (#10365) * :sparkles: Allow nitrate to view org teams * :paperclip: Code review * :paperclip: Code review 2 --- backend/src/app/rpc/management/nitrate.clj | 79 ++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 429205c1c7..7e25739d04 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -825,6 +825,85 @@ LEFT JOIN profile AS p nil)) +;; ---- API: get-teams-detail + +(def ^:private sql:get-teams-detail + "SELECT + t.id, + t.name, + t.photo_id, + t.created_at, + (SELECT MAX(p2.modified_at) + FROM project AS p2 + WHERE p2.team_id = t.id + AND p2.deleted_at IS NULL + AND p2.is_default IS FALSE) AS last_activity_at, + owner_tpr.profile_id AS owner_profile_id, + owner_p.fullname AS owner_name, + owner_p.photo_id AS owner_photo_id, + (SELECT COUNT(*) + FROM project AS p3 + WHERE p3.team_id = t.id + AND p3.deleted_at IS NULL + AND p3.is_default IS FALSE) AS num_projects, + (SELECT COUNT(*) + FROM file AS f + JOIN project AS p4 ON p4.id = f.project_id + WHERE p4.team_id = t.id + AND f.deleted_at IS NULL + AND p4.deleted_at IS NULL) AS num_files, + (SELECT COUNT(*) + FROM team_profile_rel AS tpr + WHERE tpr.team_id = t.id) AS num_members + FROM team AS t + LEFT JOIN team_profile_rel AS owner_tpr + ON owner_tpr.team_id = t.id AND owner_tpr.is_owner IS TRUE + LEFT JOIN profile AS owner_p + ON owner_p.id = owner_tpr.profile_id + WHERE t.id = ANY(?) + AND t.deleted_at IS NULL + AND t.is_default IS FALSE + ORDER BY last_activity_at DESC NULLS LAST") + +(def ^:private schema:get-teams-detail-params + [:map + [:organization-id ::sm/uuid]]) + +(def ^:private schema:get-teams-detail-result + [:vector + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:photo-url {:optional true} ::sm/uri] + [:created-at ::sm/inst] + [:last-activity-at {:optional true} [:maybe ::sm/inst]] + [:owner-profile-id {:optional true} [:maybe ::sm/uuid]] + [:owner-name {:optional true} [:maybe ::sm/text]] + [:owner-photo-url {:optional true} ::sm/uri] + [:num-projects ::sm/int] + [:num-files ::sm/int] + [:num-members ::sm/int]]]) + +(sv/defmethod ::get-teams-detail + "Get detailed information for all non-deleted teams in an organization, + including owner info and project/file/member counts." + {::doc/added "2.20" + ::sm/params schema:get-teams-detail-params + ::sm/result schema:get-teams-detail-result} + [cfg {:keys [organization-id]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + team-ids (into [] (comp d/xf:map-id (filter uuid?)) (:teams org-summary))] + (if (empty? team-ids) + [] + (db/run! cfg + (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (->> (db/exec! conn [sql:get-teams-detail ids-array]) + (mapv (fn [{:keys [photo-id owner-photo-id] :as row}] + (cond-> (dissoc row :photo-id :owner-photo-id) + photo-id (assoc :photo-url (files/resolve-public-uri photo-id)) + owner-photo-id (assoc :owner-photo-url (files/resolve-public-uri owner-photo-id)))))))))))) + ;; ---- API: notify-org-sso-change (sv/defmethod ::notify-org-sso-change From 2a5b6a69ad189520ca00d7c3cbe0ddaaf0bb4d16 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Thu, 25 Jun 2026 09:53:07 +0200 Subject: [PATCH 005/100] :sparkles: Send warning for email about nitrate orgs with sso (#10413) --- .../resources/app/email/invite-to-org/en.html | 28 ++- .../resources/app/email/invite-to-org/en.txt | 7 +- .../app/email/invite-to-team/en.html | 22 +- .../resources/app/email/invite-to-team/en.txt | 7 +- .../app/email/organization-setup-sso/en.html | 223 ++++++++++++++++++ .../app/email/organization-setup-sso/en.subj | 1 + .../app/email/organization-setup-sso/en.txt | 7 + backend/src/app/email.clj | 25 +- backend/src/app/nitrate.clj | 13 +- backend/src/app/rpc/commands/nitrate.clj | 50 ++-- .../app/rpc/commands/teams_invitations.clj | 6 +- backend/src/app/rpc/management/nitrate.clj | 56 ++--- backend/src/app/rpc/nitrate/emails_helper.clj | 106 +++++++++ .../app/rpc/nitrate/organization_helper.clj | 60 +++++ .../test/backend_tests/email_sending_test.clj | 66 ++++++ .../rpc_management_nitrate_test.clj | 79 +++++++ .../test/backend_tests/rpc_nitrate_test.clj | 118 +++++++++ common/src/app/common/types/organization.cljc | 3 +- 18 files changed, 792 insertions(+), 85 deletions(-) create mode 100644 backend/resources/app/email/organization-setup-sso/en.html create mode 100644 backend/resources/app/email/organization-setup-sso/en.subj create mode 100644 backend/resources/app/email/organization-setup-sso/en.txt create mode 100644 backend/src/app/rpc/nitrate/emails_helper.clj create mode 100644 backend/src/app/rpc/nitrate/organization_helper.clj diff --git a/backend/resources/app/email/invite-to-org/en.html b/backend/resources/app/email/invite-to-org/en.html index ce3a9846b3..fd5ee679b0 100644 --- a/backend/resources/app/email/invite-to-org/en.html +++ b/backend/resources/app/email/invite-to-org/en.html @@ -195,21 +195,39 @@
- +
+ background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}" + style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black"> {% if organization.initials %}{{organization.initials}}{% endif %}
- - {{ organization.name|abbreviate:50 }} + + {{ organization.name|abbreviate:50 }}
+ + {% if organization.sso-active %} + + +
+ "{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its + teams and files goes + through your organization's identity provider. If you can't get in, your account probably isn't + in the directory yet. + To get access, contact the organization owner. +
+ + + {% endif %} + diff --git a/backend/resources/app/email/invite-to-org/en.txt b/backend/resources/app/email/invite-to-org/en.txt index ff8eabf194..4b94fe6331 100644 --- a/backend/resources/app/email/invite-to-org/en.txt +++ b/backend/resources/app/email/invite-to-org/en.txt @@ -1,6 +1,11 @@ Hello! -{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”. +{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:50 }}”. + +{% if organization.sso-active %} +"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes +through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner. +{% endif %} Accept invitation using this link: diff --git a/backend/resources/app/email/invite-to-team/en.html b/backend/resources/app/email/invite-to-team/en.html index 9ebc59231f..96fe6de59d 100644 --- a/backend/resources/app/email/invite-to-team/en.html +++ b/backend/resources/app/email/invite-to-team/en.html @@ -186,10 +186,26 @@
- {{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %} - part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.
+ {{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if + organization %} + part of the organization “{{ organization.name|abbreviate:50 }}”{% endif %}. + {% if organization.sso-active %} + + +
+ "{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to + its + teams and files goes + through your organization's identity provider. If you can't get in, your account probably isn't + in the directory yet. + To get access, contact the organization owner. +
+ + + {% endif %} @@ -241,4 +257,4 @@ - + \ No newline at end of file diff --git a/backend/resources/app/email/invite-to-team/en.txt b/backend/resources/app/email/invite-to-team/en.txt index 3482fab0a5..c688f7d61d 100644 --- a/backend/resources/app/email/invite-to-team/en.txt +++ b/backend/resources/app/email/invite-to-team/en.txt @@ -1,6 +1,11 @@ Hello! -{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}. +{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization.name|abbreviate:50 }}"{% endif %}. + +{% if organization.sso-active %} +"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes +through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner. +{% endif %} Accept invitation using this link: diff --git a/backend/resources/app/email/organization-setup-sso/en.html b/backend/resources/app/email/organization-setup-sso/en.html new file mode 100644 index 0000000000..cfc72548ad --- /dev/null +++ b/backend/resources/app/email/organization-setup-sso/en.html @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + +
+
+ Hi{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %}, +
+
+
+ "{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its + teams and files goes through your organization's identity provider. If you can't get in, your + account probably isn't in the directory yet. To get access, contact the organization owner. +
+
+
+ The Penpot team.
+
+
+ +
+
+ + {% include "app/email/includes/footer.html" %} + +
+ + + diff --git a/backend/resources/app/email/organization-setup-sso/en.subj b/backend/resources/app/email/organization-setup-sso/en.subj new file mode 100644 index 0000000000..e8e3e42f39 --- /dev/null +++ b/backend/resources/app/email/organization-setup-sso/en.subj @@ -0,0 +1 @@ +“{{ organization-name|abbreviate:25 }}” has set up single sign-on (SSO) in Penpot diff --git a/backend/resources/app/email/organization-setup-sso/en.txt b/backend/resources/app/email/organization-setup-sso/en.txt new file mode 100644 index 0000000000..4521da3160 --- /dev/null +++ b/backend/resources/app/email/organization-setup-sso/en.txt @@ -0,0 +1,7 @@ +Hello! + +"{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes +through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner. + + +The Penpot team. diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index f15c6cbbc1..fbc768e1f6 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -419,10 +419,19 @@ :id ::change-email :schema schema:change-email)) +(def ^:private schema:organization-data + [:map + [:name ::sm/text] + [:initials {:optional true} [:maybe :string]] + [:logo {:optional true} [:maybe ::sm/uri]] + [:avatar-bg-url {:optional true} [:maybe ::sm/uri]] + [:sso-active {:optional true} [:maybe ::sm/boolean]]]) + (def ^:private schema:invite-to-team [:map [:invited-by ::sm/text] [:team ::sm/text] + [:organization {:optional true} [:maybe schema:organization-data]] [:token ::sm/text]]) (def invite-to-team @@ -431,13 +440,6 @@ :id ::invite-to-team :schema schema:invite-to-team)) -(def ^:private schema:organization-data - [:map - [:name ::sm/text] - [:initials [:maybe :string]] - [:logo [:maybe ::sm/uri]] - [:avatar-bg-url [:maybe ::sm/uri]]]) - (def ^:private schema:invite-to-org [:map [:invited-by ::sm/text] @@ -451,7 +453,16 @@ :id ::invite-to-org :schema schema:invite-to-org)) +(def ^:private schema:organization-setup-sso + [:map + [:user-name {:optional true} [:maybe ::sm/text]] + [:organization-name ::sm/text]]) +(def organization-setup-sso + "Email when an organization set up SSO" + (template-factory + :id ::organization-setup-sso + :schema schema:organization-setup-sso)) (def ^:private schema:renewal-notice [:map diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 3da69d29a1..9c9c8339fc 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -22,15 +22,26 @@ [app.rpc :as-alias rpc] [app.setup :as-alias setup] [clojure.core :as c] + [clojure.string :as str] [integrant.core :as ig])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- join-path-segments + "Build a single relative path from Nitrate URI segments, normalizing slashes." + [segments] + (let [path (->> segments (map str) (str/join "/"))] + (->> (str/split path #"/") + (remove str/blank?) + (str/join "/")))) + (defn- join-base-uri + "Join path segments to a base URI." [base-uri & segments] - (apply u/join (u/ensure-path-slash base-uri) segments)) + (u/join (u/ensure-path-slash base-uri) + (join-path-segments segments))) (defn- generate-nitrate-uri "Joins relative path segments to the Nitrate backend URI. diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index cbd64ad43f..2103452c17 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -21,6 +21,8 @@ [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] + [app.rpc.nitrate.emails-helper :as neh] + [app.rpc.nitrate.organization-helper :as noh] [app.rpc.notifications :as notifications] [app.tokens :as tokens] [app.util.services :as sv])) @@ -394,12 +396,6 @@ (notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org") nil) -(def ^:private sql:get-team-invitation-emails - "SELECT email_to - FROM team_invitation - WHERE team_id = ? - AND valid_until > now()") - (def ^:private sql:delete-team-external-invitations "DELETE FROM team_invitation WHERE team_id = ? @@ -421,8 +417,7 @@ allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})] (if allows-anybody {:allows-anybody true :external-emails []} - (let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id]) - emails (map :email-to invitation-emails)] + (let [emails (map :email (noh/get-team-invitation-emails conn team-id))] (if (empty? emails) {:allows-anybody false :external-emails []} (let [emails-array (db/create-array conn "text" (vec emails)) @@ -451,7 +446,8 @@ (assert-membership cfg profile-id organization-id) (when (contains? cf/flags :nitrate) - (let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id}) + (let [org-member-ids-before (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id})) + team-with-org (nitrate/call cfg :get-team-org {:team-id team-id}) source-org-id (get-in team-with-org [:organization :id]) source-org-perms (when source-org-id (nitrate/call cfg :get-org-permissions @@ -487,26 +483,32 @@ :profile-id profile-id}) (ex/raise :type :validation :code :not-allowed - :hint "You are not allowed to add teams in this organization"))) + :hint "You are not allowed to add teams in this organization")) - (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})] ;; Add teammates to the org if needed - (doseq [{member-id :profile-id} team-members - :when (not= member-id profile-id)] - (teams/initialize-user-in-nitrate-org cfg member-id organization-id))) + (let [team-members (db/query cfg :team-profile-rel {:team-id team-id}) + new-member-ids (->> team-members + (map :profile-id) + (remove #{profile-id}) + (remove org-member-ids-before))] + (doseq [member-id new-member-ids] + (teams/initialize-user-in-nitrate-org cfg member-id organization-id))) - ;; Api call to nitrate - (let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})] + ;; Api call to nitrate + (let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})] + ;; Notify connected users + (notifications/notify-team-change cfg team "dashboard.team-belong-org")) - ;; Notify connected users - (notifications/notify-team-change cfg team "dashboard.team-belong-org")) + ;; Delete pending invitations for users who are not members of the target organization + (let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] + (when (and (not allows-anybody) (seq external-emails)) + (let [conn (::db/conn cfg) + emails-array (db/create-array conn "text" external-emails)] + (db/exec! conn [sql:delete-team-external-invitations team-id emails-array])))) - ;; Delete pending invitations for users who are not members of the target organization - (let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] - (when (and (not allows-anybody) (seq external-emails)) - (let [conn (::db/conn cfg) - emails-array (db/create-array conn "text" external-emails)] - (db/exec! conn [sql:delete-team-external-invitations team-id emails-array]))))) + ;; Send warnings via email if the org has sso + (neh/send-organization-setup-sso-emails-for-team! + cfg organization-id team-id org-member-ids-before))) nil) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 50d3ba691b..34795ba77b 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -94,7 +94,9 @@ [:id ::sm/uuid] [:name :string] [:initials [:maybe :string]] - [:logo ::sm/uri]]] + [:logo ::sm/uri] + [:avatar-bg-url [:maybe ::sm/uri]] + [:sso-active [:maybe ::sm/boolean]]]] [:profile [:map [:id ::sm/uuid] @@ -246,7 +248,7 @@ :to email :invited-by (:fullname profile) :team (:name team) - :organization (dm/get-in team [:organization :name]) + :organization (:organization team) :token itoken :extra-data ptoken}))) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 7e25739d04..9168c0ea63 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -29,6 +29,8 @@ [app.rpc.commands.teams :as teams] [app.rpc.commands.teams-invitations :as ti] [app.rpc.doc :as doc] + [app.rpc.nitrate.emails-helper :as neh] + [app.rpc.nitrate.organization-helper :as noh] [app.rpc.notifications :as notifications] [app.storage :as sto] [app.util.services :as sv] @@ -485,23 +487,6 @@ RETURNING id, deleted_at;") ;; API: get-org-invitations -(def ^:private sql:get-org-invitations - "SELECT DISTINCT ON (email_to) - ti.id, - ti.org_id AS organization_id, - ti.email_to AS email, - ti.created_at AS sent_at, - p.fullname AS name, - p.id AS profile_id, - p.photo_id - FROM team_invitation AS ti -LEFT JOIN profile AS p - ON p.email = ti.email_to - AND p.deleted_at IS NULL - WHERE ti.valid_until >= now() - AND (ti.org_id = ? OR ti.team_id = ANY(?)) - ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") - (def ^:private schema:get-org-invitations-params [:map [:organization-id ::sm/uuid]]) @@ -523,18 +508,13 @@ LEFT JOIN profile AS p ::sm/params schema:get-org-invitations-params ::sm/result schema:get-org-invitations-result} [cfg {:keys [organization-id]}] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - team-ids (->> (:teams org-summary) - (map :id) - (filter uuid?) - (into []))] + (let [team-ids (noh/get-org-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] - (let [ids-array (db/create-array conn "uuid" team-ids)] - (->> (db/exec! conn [sql:get-org-invitations organization-id ids-array]) - (mapv (fn [{:keys [photo-id] :as invitation}] - (cond-> (dissoc invitation :photo-id) - photo-id - (assoc :photo-url (files/resolve-public-uri photo-id))))))))))) + (->> (noh/get-org-invitations conn organization-id team-ids) + (mapv (fn [{:keys [photo-id] :as invitation}] + (cond-> (dissoc invitation :photo-id) + photo-id + (assoc :photo-url (files/resolve-public-uri photo-id)))))))))) ;; API: delete-org-invitations @@ -554,12 +534,8 @@ LEFT JOIN profile AS p {::doc/added "2.16" ::sm/params schema:delete-org-invitations-params} [cfg {:keys [organization-id email]}] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - clean-email (profile/clean-email email) - team-ids (->> (:teams org-summary) - (map :id) - (filter uuid?) - (into []))] + (let [clean-email (profile/clean-email email) + team-ids (noh/get-org-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids-array (db/create-array conn "uuid" team-ids)] (db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array])))) @@ -585,9 +561,7 @@ LEFT JOIN profile AS p ::sm/params schema:delete-all-org-invitations-params ::rpc/auth false} [cfg {:keys [organization-id]}] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - team-ids (->> (:teams org-summary) - (map :id))] + (let [team-ids (noh/get-org-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids-array (db/create-array conn "uuid" team-ids)] (db/exec! conn [sql:delete-all-org-invitations organization-id ids-array])))) @@ -905,17 +879,19 @@ LEFT JOIN profile AS p owner-photo-id (assoc :owner-photo-url (files/resolve-public-uri owner-photo-id)))))))))))) ;; ---- API: notify-org-sso-change - (sv/defmethod ::notify-org-sso-change "Nitrate notifies that an organization sso values have changed" {::doc/added "2.19" ::sm/params [:map [:organization-id ::sm/uuid] - [:updated-props ::sm/boolean]] + [:updated-props ::sm/boolean] + [:became-active ::sm/boolean]] ::rpc/auth false} - [{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props]}] + [{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props became-active]}] (when updated-props (rpc/invalidate-org-sso-cache-by-org! organization-id) (session/clear-org-sso-sessions! pool organization-id)) (notifications/notify-organization-change-sso cfg organization-id) + (when became-active + (neh/send-organization-setup-sso-emails! cfg organization-id)) nil) diff --git a/backend/src/app/rpc/nitrate/emails_helper.clj b/backend/src/app/rpc/nitrate/emails_helper.clj new file mode 100644 index 0000000000..73fab3f6ba --- /dev/null +++ b/backend/src/app/rpc/nitrate/emails_helper.clj @@ -0,0 +1,106 @@ +;; 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 app.rpc.nitrate.emails-helper + "Helpers for organization SSO notification emails triggered by Nitrate integration." + (:require + [app.common.data :as d] + [app.config :as cf] + [app.db :as db] + [app.email :as eml] + [app.nitrate :as nitrate] + [app.rpc.commands.teams :as teams] + [app.rpc.nitrate.organization-helper :as neh] + [cuerdas.core :as str])) + +(def ^:private sql:get-profile-emails-by-ids + "SELECT email + FROM profile + WHERE id = ANY(?) + AND deleted_at IS NULL") + +(def ^:private sql:get-profiles-by-emails + "SELECT id, email, fullname, is_muted + FROM profile + WHERE email = ANY(?) + AND deleted_at IS NULL") + +(defn- org-sso-active? + "Return whether SSO is enabled for the organization." + [cfg organization-id] + (when (contains? cf/flags :nitrate) + (true? (:active (nitrate/call cfg :get-org-sso {:organization-id organization-id}))))) + +(def ^:private xf:map-email (map :email)) + +(defn- recipients-by-emails + "Build `{:email :user-name :profile}` maps for a deduplicated email list." + [conn emails] + (let [profiles (if (seq emails) + (let [emails-array (db/create-array conn "text" emails)] + (db/exec! conn [sql:get-profiles-by-emails emails-array])) + []) + profile-by-email (d/index-by (comp str/lower :email) profiles)] + (map (fn [email] + (let [profile (get profile-by-email (str/lower email))] + {:email email + :user-name (:fullname profile) + :profile profile})) + emails))) + +(defn- send-organization-setup-sso-email! + "Send the organization SSO setup email to a single recipient, when allowed." + [conn organization-name {:keys [email user-name profile]}] + (when (or (nil? profile) + (eml/allow-send-emails? conn profile)) + (eml/send! {::eml/conn conn + ::eml/factory eml/organization-setup-sso + :public-uri (cf/get :public-uri) + :to email + :user-name user-name + :organization-name organization-name}))) + +(defn- get-org-sso-notify-recipients + "Unique org members and pending org/team invitees for SSO activation emails." + [conn cfg organization-id org-summary] + (let [member-ids (nitrate/call cfg :get-org-members {:organization-id organization-id}) + team-ids (neh/get-org-team-ids org-summary) + member-emails (if (seq member-ids) + (let [ids-array (db/create-array conn "uuid" member-ids)] + (into #{} (map :email (db/exec! conn [sql:get-profile-emails-by-ids ids-array])))) + #{}) + invite-emails (into #{} (map :email + (neh/get-org-invitations conn organization-id team-ids))) + emails (into #{} (concat member-emails invite-emails))] + (recipients-by-emails conn emails))) + +(defn- get-team-sso-notify-recipients + "Team members who are not in `org-member-ids`, plus pending team invitations." + [conn team-id org-member-ids] + (let [team-members (->> (teams/get-team-members conn team-id) + (remove #(contains? org-member-ids (:id %)))) + invitations (neh/get-team-invitation-emails conn team-id)] + (->> (sequence xf:map-email (concat team-members invitations)) + (recipients-by-emails conn)))) + +(defn send-organization-setup-sso-emails! + "Notify all org members and pending org/team invitees that SSO is active." + [cfg organization-id] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (doseq [recipient (get-org-sso-notify-recipients conn cfg organization-id org-summary)] + (send-organization-setup-sso-email! conn (:name org-summary) recipient)))))) + +(defn send-organization-setup-sso-emails-for-team! + "Notify team members who are not in `org-member-ids-before` and pending team invitees." + [cfg organization-id team-id org-member-ids-before] + (when (org-sso-active? cfg organization-id) + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (doseq [recipient (get-team-sso-notify-recipients conn team-id org-member-ids-before)] + (send-organization-setup-sso-email! conn (:name org-summary) recipient))))))) diff --git a/backend/src/app/rpc/nitrate/organization_helper.clj b/backend/src/app/rpc/nitrate/organization_helper.clj new file mode 100644 index 0000000000..8627a4d10d --- /dev/null +++ b/backend/src/app/rpc/nitrate/organization_helper.clj @@ -0,0 +1,60 @@ +;; 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 app.rpc.nitrate.organization-helper + "Shared Nitrate organization query helpers." + (:require + [app.db :as db] + [app.nitrate :as nitrate])) + +(def ^:private sql:get-org-invitations + "SELECT DISTINCT ON (email_to) + ti.id, + ti.org_id AS organization_id, + ti.email_to AS email, + ti.created_at AS sent_at, + p.fullname AS name, + p.id AS profile_id, + p.photo_id + FROM team_invitation AS ti +LEFT JOIN profile AS p + ON p.email = ti.email_to + AND p.deleted_at IS NULL + WHERE ti.valid_until >= now() + AND (ti.org_id = ? OR ti.team_id = ANY(?)) + ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") + +(def ^:private sql:get-team-invitation-emails + "SELECT DISTINCT ON (email_to) + ti.email_to AS email + FROM team_invitation AS ti + WHERE ti.team_id = ? + AND ti.valid_until >= now() + ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") + +(defn get-org-team-ids + "Return team ids for an organization. + + Accepts either `cfg` and `organization-id` (fetches the org summary from + Nitrate) or an already-resolved org summary map." + ([cfg organization-id] + (get-org-team-ids (nitrate/call cfg :get-org-summary {:organization-id organization-id}))) + ([org-summary] + (->> (:teams org-summary) + (map :id) + (filter uuid?) + (vec)))) + +(defn get-org-invitations + "Fetch valid org-level and team-level invitations for an organization." + [conn organization-id team-ids] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (db/exec! conn [sql:get-org-invitations organization-id ids-array]))) + +(defn get-team-invitation-emails + "Return distinct valid team invitation recipient emails." + [conn team-id] + (db/exec! conn [sql:get-team-invitation-emails team-id])) diff --git a/backend/test/backend_tests/email_sending_test.clj b/backend/test/backend_tests/email_sending_test.clj index a688814e63..e96fd192ae 100644 --- a/backend/test/backend_tests/email_sending_test.clj +++ b/backend/test/backend_tests/email_sending_test.clj @@ -6,10 +6,12 @@ (ns backend-tests.email-sending-test (:require + [app.config :as cf] [app.db :as db] [app.email :as emails] [backend-tests.helpers :as th] [clojure.test :as t] + [cuerdas.core :as str] [promesa.core :as p])) (t/use-fixtures :once th/state-init) @@ -23,3 +25,67 @@ (t/is (contains? result :to)) #_(t/is (contains? result :reply-to)) (t/is (map? (:body result))))) + +(def ^:private sso-notice-snippet + "has set up single sign-on (SSO) in Penpot") + +(defn- email-text-body + [result] + (get-in result [:body "text/plain"])) + +(defn- invite-email-params + [organization] + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :user-name "Invitee User" + :token "test-token" + :organization organization}) + +(t/deftest invite-to-org-includes-sso-notice-when-active + (let [result (emails/render emails/invite-to-org + (invite-email-params {:name "Acme Inc" + :sso-active true}))] + (t/is (str/includes? (email-text-body result) sso-notice-snippet)) + (t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))) + +(t/deftest invite-to-org-omits-sso-notice-when-inactive + (let [result (emails/render emails/invite-to-org + (invite-email-params {:name "Acme Inc" + :sso-active false}))] + (t/is (not (str/includes? (email-text-body result) sso-notice-snippet))) + (t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))) + +(t/deftest invite-to-team-includes-sso-notice-when-active + (let [result (emails/render emails/invite-to-team + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :team "Design Team" + :token "test-token" + :organization {:name "Acme Inc" + :sso-active true}})] + (t/is (str/includes? (email-text-body result) sso-notice-snippet)) + (t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))) + +(t/deftest invite-to-team-omits-sso-notice-when-inactive + (let [result (emails/render emails/invite-to-team + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :team "Design Team" + :token "test-token" + :organization {:name "Acme Inc" + :sso-active false}})] + (t/is (not (str/includes? (email-text-body result) sso-notice-snippet))) + (t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))) + +(t/deftest invite-to-team-omits-sso-notice-without-organization + (let [result (emails/render emails/invite-to-team + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :team "Design Team" + :token "test-token"})] + (t/is (not (str/includes? (email-text-body result) sso-notice-snippet))) + (t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index abba0fa7a1..460471e639 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -11,6 +11,7 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as-alias db] + [app.email :as eml] [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] @@ -1198,3 +1199,81 @@ (t/is (some? rel1))) (let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] (t/is (some? rel2))))) + +(t/deftest notify-org-sso-change-sends-setup-sso-email-once-per-recipient + (let [owner (th/create-profile* 1 {:is-active true :fullname "Owner"}) + member (th/create-profile* 2 {:is-active true + :fullname "Member" + :email "member@example.com"}) + invited (th/create-profile* 3 {:is-active true + :fullname "Invited User" + :email "invited@example.com"}) + org-id (uuid/random) + org-name "Acme Inc" + team (th/create-team* 1 {:profile-id (:id owner)}) + org-summary {:id org-id + :name org-name + :teams [{:id (:id team)}]} + sent (atom []) + params {::th/type :notify-org-sso-change + :organization-id org-id + :updated-props false + :became-active true}] + + ;; Member also has a pending invitation: should still receive only one email. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to (:email member) + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to (:email invited) + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + ;; Invite without an existing profile. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "external@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "72h")}) + + (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-members [(:id owner) (:id member)] + :get-org-summary org-summary + nil)) + eml/send! (fn [params] (swap! sent conj params))] + (management-command-with-nitrate! params)) + + (let [emails (->> @sent (map :to) set)] + (t/is (= 4 (count @sent))) + (t/is (= #{"member@example.com" + (:email owner) + "invited@example.com" + "external@example.com"} + emails)) + (doseq [email-params @sent] + (t/is (= org-name (:organization-name email-params))) + (t/is (= eml/organization-setup-sso (::eml/factory email-params))))))) + +(t/deftest notify-org-sso-change-skips-email-when-not-active + (let [sent (atom []) + params {::th/type :notify-org-sso-change + :organization-id (uuid/random) + :updated-props false + :became-active false}] + (with-redefs [eml/send! (fn [params] (swap! sent conj params))] + (management-command-with-nitrate! params)) + (t/is (empty? @sent)))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 20e774ec99..28015bb369 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -6,12 +6,15 @@ (ns backend-tests.rpc-nitrate-test (:require + [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as-alias db] + [app.email :as eml] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.nitrate] + [app.rpc.commands.teams :as teams] [backend-tests.helpers :as th] [clojure.test :as t] [cuerdas.core :as str])) @@ -815,3 +818,118 @@ (t/is (not (th/success? out))) (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(defn- add-team-to-org-nitrate-mock + [{:keys [org-id org-summary org-perms owner-id team-id sso-active?]}] + (fn [_cfg method params] + (case method + :get-org-membership (if (= (:profile-id params) owner-id) + {:is-member true :organization-id org-id} + {:is-member false :organization-id org-id}) + :get-org-members [owner-id] + :get-team-org {:organization nil} + :get-org-permissions org-perms + :set-team-org {:id team-id} + :get-org-sso {:active sso-active?} + :get-org-summary (assoc org-summary :teams [{:id team-id}]) + :add-profile-to-org {:is-member true} + nil))) + +(t/deftest add-team-to-organization-sends-sso-emails-to-new-members-and-invitees + (let [owner (th/create-profile* 301 {:is-active true + :fullname "Owner" + :email "owner301@example.com"}) + member (th/create-profile* 302 {:is-active true + :fullname "Member" + :email "member302@example.com"}) + team (th/create-team* 301 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id member) + :role :editor}) + org-id (uuid/random) + org-name "SSO Org" + org-summary {:id org-id + :name org-name + :owner-id (:id owner) + :teams []} + org-perms {:owner-id (:id owner) + :permissions {:create-teams "any" + :move-teams "always" + :new-team-members "members"}} + sent (atom [])] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "external301@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call (add-team-to-org-nitrate-mock + {:org-id org-id + :org-summary org-summary + :org-perms org-perms + :owner-id (:id owner) + :team-id (:id team) + :sso-active? true}) + teams/initialize-user-in-nitrate-org (fn [& _] nil) + eml/send! (fn [params] (swap! sent conj params))] + (let [out (th/command! {::th/type :add-team-to-organization + ::rpc/profile-id (:id owner) + :team-id (:id team) + :organization-id org-id})] + (t/is (th/success? out)))) + + (let [emails (->> @sent (map :to) set)] + (t/is (= 2 (count @sent))) + (t/is (= #{"member302@example.com" "external301@example.com"} emails)) + (doseq [email-params @sent] + (t/is (= org-name (:organization-name email-params))) + (t/is (= eml/organization-setup-sso (::eml/factory email-params))))))) + +(t/deftest add-team-to-organization-skips-sso-emails-when-sso-inactive + (let [owner (th/create-profile* 303 {:is-active true :email "owner303@example.com"}) + member (th/create-profile* 304 {:is-active true :email "member304@example.com"}) + team (th/create-team* 303 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id member) + :role :editor}) + org-id (uuid/random) + org-summary {:id org-id + :name "No SSO Org" + :owner-id (:id owner) + :teams []} + org-perms {:owner-id (:id owner) + :permissions {:create-teams "any" + :move-teams "always" + :new-team-members "members"}} + sent (atom [])] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "external303@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call (add-team-to-org-nitrate-mock + {:org-id org-id + :org-summary org-summary + :org-perms org-perms + :owner-id (:id owner) + :team-id (:id team) + :sso-active? false}) + teams/initialize-user-in-nitrate-org (fn [& _] nil) + eml/send! (fn [params] (swap! sent conj params))] + (let [out (th/command! {::th/type :add-team-to-organization + ::rpc/profile-id (:id owner) + :team-id (:id team) + :organization-id org-id})] + (t/is (th/success? out)) + (t/is (empty? @sent)))))) diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index 62d77ac14c..ca13e7de5b 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -61,4 +61,5 @@ [:name ::sm/text] [:initials [:maybe :string]] [:logo [:maybe ::sm/uri]] - [:avatar-bg-url [:maybe ::sm/uri]]]) + [:avatar-bg-url [:maybe ::sm/uri]] + [:sso-active {:optional true} [:maybe :boolean]]]) From 4d1706f71c57280752f6ed1d8271e5a82d650f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Thu, 25 Jun 2026 10:37:50 +0200 Subject: [PATCH 006/100] :bug: Fix error when sending user feedback form (#10419) If the smtp-default-from is something like "Penpot ", the schema validation fails because it expects a simple email without < > --- backend/src/app/rpc/commands/feedback.clj | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/app/rpc/commands/feedback.clj b/backend/src/app/rpc/commands/feedback.clj index 9c14e0b2d9..565f41d30e 100644 --- a/backend/src/app/rpc/commands/feedback.clj +++ b/backend/src/app/rpc/commands/feedback.clj @@ -54,7 +54,6 @@ (eml/send! {::eml/conn pool ::eml/factory eml/user-feedback - :from (cf/get :smtp-default-from) :to destination :reply-to (:email profile) :email (:email profile) From f996ef372d0c323f21de598e5b2995d33b41cce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 25 Jun 2026 11:26:15 +0200 Subject: [PATCH 007/100] :wrench: Migrate auto-label workflow from PAT to GitHub App toke --- .github/workflows/auto-label.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index c27b45a5a2..cd6224a544 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -10,10 +10,18 @@ jobs: triage: runs-on: ubuntu-latest steps: + - name: Generate GitHub App token + id: triage-app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.TRAIGE_APP_ID }} + private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Process Issue or PR uses: actions/github-script@v7 with: - github-token: ${{ secrets.PROJECT_TOKEN }} + github-token: ${{ steps.triage-app-token.outputs.token }} script: | // === 1. CONFIGURATION === const PROJECT_NUMBER = 8; // <--- Replace with your project board number From 8645801eedf068c1ed793e81f0caaefb626ffea0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 25 Jun 2026 11:33:59 +0200 Subject: [PATCH 008/100] :paperclip: Update changelog --- CHANGES.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 831996000e..2446702c3b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2.16.2 + +### :bug: Bugs fixed + +- Fix error 500 when submitting the contact form [#10178](https://github.com/penpot/penpot/issues/10178) (PR: [#10419](https://github.com/penpot/penpot/pull/10419)) +- Fix text editor modifying content and detaching applied typography tokens [#10389](https://github.com/penpot/penpot/issues/10389) (PR: [#10402](https://github.com/penpot/penpot/pull/10402)) + ## 2.16.1 ### :sparkles: New features & Enhancements From d323aa96930d905156f5580f926ee13433fa85ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 25 Jun 2026 11:50:58 +0200 Subject: [PATCH 009/100] :bug: Fix guides not clipping (#10423) --- render-wasm/src/render/rulers.rs | 2 +- render-wasm/src/render/ui.rs | 8 +++++++- render-wasm/src/render/ui/guides.rs | 25 +++++++++++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/render-wasm/src/render/rulers.rs b/render-wasm/src/render/rulers.rs index 510979a2f8..582469e8ee 100644 --- a/render-wasm/src/render/rulers.rs +++ b/render-wasm/src/render/rulers.rs @@ -13,7 +13,7 @@ use super::fonts::FontStore; use crate::state::RulerState; use crate::view::Viewbox; -const RULER_AREA_SIZE: f32 = 22.0; +pub const RULER_AREA_SIZE: f32 = 22.0; const RULER_TICK_OFFSET: f32 = 15.0; const RULER_TICK_LEN: f32 = 4.0; const RULER_TICK_GAP: f32 = 2.0; diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index 3b69955a82..cf3f2d7963 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -65,13 +65,19 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { let viewbox = render_state.viewbox; let ruler_state = render_state.rulers; rulers::render(canvas, viewbox, &render_state.fonts, &ruler_state); - // TODO: pass guides data here + + // Width of the ruler bars in document coordinates. + // Note that when rulers are hidden, guides are not shown either, so we + // can use a fixed value here. + let ruler_width = rulers::RULER_AREA_SIZE / viewbox.zoom; + let (horizontal, vertical) = get_ui_state().guides(); guides::render( canvas, zoom, render_state.options.dpr, viewbox.area, + ruler_width, horizontal, vertical, ); diff --git a/render-wasm/src/render/ui/guides.rs b/render-wasm/src/render/ui/guides.rs index 07bc5a6f8a..8a9e3dcaa8 100644 --- a/render-wasm/src/render/ui/guides.rs +++ b/render-wasm/src/render/ui/guides.rs @@ -3,22 +3,43 @@ use skia_safe::{self as skia}; use crate::math::Rect; use crate::ui::{Guide, GuideKind}; -/// Renders the ruler guides overlay using the guides provided by the host -/// (ClojureScript) and stored in the render state. +/// Renders the ruler guides, clipped out of the ruler bars. pub fn render( canvas: &skia::Canvas, zoom: f32, dpr: f32, area: Rect, + ruler_width: f32, horizontal: &[Guide], vertical: &[Guide], ) { + // Horizontal guides: clip out the top strip (horizontal ruler) + canvas.save(); + canvas.clip_rect( + Rect::from_ltrb(area.left, area.top + ruler_width, area.right, area.bottom), + None, + false, + ); + for guide in horizontal { render_guide(canvas, zoom, dpr, area, *guide); } + + canvas.restore(); + + // Vertical guides: clip out the left strip (vertical ruler) + canvas.save(); + canvas.clip_rect( + Rect::from_ltrb(area.left + ruler_width, area.top, area.right, area.bottom), + None, + false, + ); + for guide in vertical { render_guide(canvas, zoom, dpr, area, *guide); } + + canvas.restore(); } pub fn render_guide(canvas: &skia::Canvas, zoom: f32, dpr: f32, area: Rect, guide: Guide) { From ce1191d86f001c80eb150e71cd81019b8ade5f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 25 Jun 2026 11:53:07 +0200 Subject: [PATCH 010/100] :bug: fix missing app-id in auto-label workflow --- .github/workflows/auto-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index cd6224a544..4025286273 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -14,7 +14,7 @@ jobs: id: triage-app-token uses: actions/create-github-app-token@v1 with: - app-id: ${{ secrets.TRAIGE_APP_ID }} + app-id: ${{ secrets.TRIAGE_APP_ID }} private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} From 14fb211733637bd36f9ce95f80fc6c260229681a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 25 Jun 2026 12:41:18 +0200 Subject: [PATCH 011/100] :bug: Set org as owner for auto-label workflow --- .github/workflows/auto-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 4025286273..27ae0f37a4 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -16,7 +16,7 @@ jobs: with: app-id: ${{ secrets.TRIAGE_APP_ID }} private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} + owner: penpot - name: Process Issue or PR uses: actions/github-script@v7 From d328cb4a9e1942a217cda00b66983e04e30d2c13 Mon Sep 17 00:00:00 2001 From: Juanfran Date: Thu, 25 Jun 2026 13:07:20 +0200 Subject: [PATCH 012/100] :sparkles: Enable org owners to view organization teams (#10388) --- backend/src/app/nitrate.clj | 42 +++++ backend/src/app/rpc/commands/binfile.clj | 4 +- backend/src/app/rpc/commands/comments.clj | 44 ++--- backend/src/app/rpc/commands/files.clj | 44 ++--- .../src/app/rpc/commands/files_snapshot.clj | 9 +- .../src/app/rpc/commands/files_thumbnails.clj | 8 +- backend/src/app/rpc/commands/fonts.clj | 12 +- backend/src/app/rpc/commands/management.clj | 2 +- backend/src/app/rpc/commands/projects.clj | 15 +- backend/src/app/rpc/commands/teams.clj | 66 +++++--- .../app/rpc/commands/teams_invitations.clj | 2 +- backend/src/app/rpc/commands/viewer.clj | 7 +- backend/src/app/rpc/commands/webhooks.clj | 2 +- backend/src/app/rpc/permissions.clj | 66 +++++++- .../rpc_org_owner_permissions_test.clj | 150 ++++++++++++++++++ frontend/src/app/main/data/helpers.cljs | 14 ++ frontend/src/app/main/data/team.cljs | 49 ++++-- frontend/src/app/main/features.cljs | 3 +- frontend/src/app/main/refs.cljs | 6 +- .../src/app/main/ui/dashboard/sidebar.cljs | 25 +-- 20 files changed, 445 insertions(+), 125 deletions(-) create mode 100644 backend/test/backend_tests/rpc_org_owner_permissions_test.clj diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 9c9c8339fc..c99451d6df 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -21,6 +21,7 @@ [app.http.session :as session] [app.rpc :as-alias rpc] [app.setup :as-alias setup] + [app.util.cache :as cache] [clojure.core :as c] [clojure.string :as str] [integrant.core :as ig])) @@ -507,6 +508,47 @@ ;; UTILS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defonce ^:private team-org-owner-cache + ;; Short TTL: permission checks run on the read path, so we avoid an + ;; HTTP call to nitrate per check. The org owner of a team rarely + ;; changes, and stale entries only grant read access for a few seconds. + (cache/create :expire "30s" :max-size 2048)) + +(defn- nitrate-client? + "True when `cfg` is a config map carrying the nitrate client (i.e. not + a raw db connection/pool passed by an internal caller)." + [cfg] + (and (map? cfg) (some? (get cfg ::client)))) + +(def ^:private cache-miss ::no-org-owner) + +(defn- get-team-org-owner-id + "Returns the organization owner-id for `team-id`, or nil. Cached + briefly, including negative results (teams with no organization) so + repeated unauthorized probes don't each hit nitrate." + [cfg team-id] + (let [owner-id (cache/get team-org-owner-cache team-id + (fn [team-id] + (let [team-with-org (call cfg :get-team-org {:team-id team-id})] + (or (get-in team-with-org [:organization :owner-id]) + cache-miss))))] + (when-not (= owner-id cache-miss) + owner-id))) + +(defn organization-owner-of-team? + "True if `profile-id` is the owner of the organization that owns + `team-id`. Used to grant non-member org owners read-only access to the + teams of their organizations. `cfg` must be a config map with the + nitrate client; raw db connections/pools yield false so internal + callers are unaffected. Returns false when the :nitrate flag is off." + [cfg profile-id team-id] + (boolean + (when (and (contains? cf/flags :nitrate) + (nitrate-client? cfg) + (some? team-id) + (some? profile-id)) + (= profile-id (get-team-org-owner-id cfg team-id))))) + (defn sso-session-authorized? "Fetches the org-SSO config for the given organization or team and checks whether the HTTP request has a valid session entry for it. Returns a map diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index ec27d455b8..79b0bf7cf9 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -74,8 +74,8 @@ ::doc/changes [["2.12" "Remove version parameter, only one version is supported"]] ::webhooks/event? true ::sm/params schema:export-binfile} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] - (files/check-read-permissions! pool profile-id file-id) + [cfg {:keys [::rpc/profile-id file-id] :as params}] + (files/check-read-permissions! cfg profile-id file-id) (sse/response (partial export-binfile cfg params))) ;; --- Command: import-binfile diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj index 3383d3d343..6a926d1e98 100644 --- a/backend/src/app/rpc/commands/comments.clj +++ b/backend/src/app/rpc/commands/comments.clj @@ -230,8 +230,8 @@ {::doc/added "1.15" ::sm/params schema:get-comment-threads} [cfg {:keys [::rpc/profile-id file-id share-id] :as params}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-comment-permissions! conn profile-id file-id share-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-comment-permissions! cfg profile-id file-id share-id) (get-comment-threads conn profile-id file-id)))) (defn- get-comment-threads-sql @@ -328,8 +328,8 @@ {::doc/added "1.15" ::sm/params schema:get-comment-thread} [cfg {:keys [::rpc/profile-id file-id id share-id] :as params}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-comment-permissions! conn profile-id file-id share-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-comment-permissions! cfg profile-id file-id share-id) (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id]) (decode-row))))) @@ -347,9 +347,9 @@ {::doc/added "1.15" ::sm/params schema:get-comments} [cfg {:keys [::rpc/profile-id thread-id share-id]}] - (db/run! cfg (fn [{:keys [::db/conn]}] + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] (let [{:keys [file-id]} (get-comment-thread conn thread-id)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (get-comments conn thread-id))))) (def sql:get-comments @@ -406,8 +406,8 @@ ::doc/changes ["1.15" "Imported from queries and renamed."] ::sm/params schema:get-profiles-for-file-comments} [cfg {:keys [::rpc/profile-id file-id share-id]}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-comment-permissions! conn profile-id file-id share-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-comment-permissions! cfg profile-id file-id share-id) (get-file-comments-users conn file-id profile-id)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -534,9 +534,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread-status ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (upsert-comment-thread-status! conn profile-id id))) ;; --- COMMAND: Update Comment Thread @@ -552,9 +552,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id is-resolved share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (db/update! conn :comment-thread {:is-resolved is-resolved} {:id id}) @@ -582,7 +582,7 @@ {:keys [team-id project-id] :as file} (get-file cfg file-id page-id)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (quotes/check! cfg {::quotes/id ::quotes/comments-per-file ::quotes/profile-id profile-id @@ -653,7 +653,7 @@ {:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) ;; Don't allow edit comments to not owners (when-not (= owner-id profile-id) @@ -690,9 +690,9 @@ {::doc/added "1.15" ::sm/params schema:delete-comment-thread ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}] (let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (when-not (= owner-id profile-id) (ex/raise :type :validation :code :not-allowed)) @@ -713,14 +713,14 @@ {::doc/added "1.15" ::sm/params schema:delete-comment ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}] (let [{:keys [owner-id thread-id] :as comment} (get-comment conn id ::sql/for-update true) {:keys [file-id]} (get-comment-thread conn thread-id)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (when-not (= owner-id profile-id) (ex/raise :type :validation :code :not-allowed)) @@ -743,9 +743,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread-position ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (db/update! conn :comment-thread {:modified-at request-at :position (db/pgpoint position) @@ -767,9 +767,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread-frame ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (db/update! conn :comment-thread {:modified-at request-at :frame-id frame-id} diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 3851ea577b..98a6ec0ec7 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -84,10 +84,10 @@ (perms/make-edition-predicate-fn bfc/get-file-permissions)) (def has-read-permissions? - (perms/make-read-predicate-fn bfc/get-file-permissions)) + (perms/make-read-predicate-fn perms/get-file-read-permissions)) (def has-comment-permissions? - (perms/make-comment-predicate-fn bfc/get-file-permissions)) + (perms/make-comment-predicate-fn perms/get-file-read-permissions)) (def check-edition-permissions! (perms/make-check-fn has-edit-permissions?)) @@ -99,8 +99,8 @@ ;; explicit comment permissions through the share-id (defn check-comment-permissions! - [conn profile-id file-id share-id] - (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) + [cfg profile-id file-id share-id] + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) can-read (has-read-permissions? perms) can-comment (has-comment-permissions? perms)] (when-not (or can-read can-comment) @@ -152,7 +152,7 @@ (defn- get-minimal-file-with-perms [cfg {:keys [:id ::rpc/profile-id]}] (let [mfile (get-minimal-file cfg id) - perms (bfc/get-file-permissions cfg profile-id id)] + perms (perms/get-file-read-permissions cfg profile-id id)] (assoc mfile :permissions perms))) (defn get-file-etag @@ -171,7 +171,7 @@ ::sm/params schema:get-file ::sm/result schema:file-with-permissions ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id project-id] :as params}] + [cfg {:keys [::rpc/profile-id id project-id] :as params}] ;; The COND middleware makes initial request for a file and ;; permissions when the incoming request comes with an ;; ETAG. When ETAG does not matches, the request is resolved @@ -179,10 +179,10 @@ ;; will be already prefetched and we just reuse them instead ;; of making an additional database queries. (let [perms (or (:permissions (::cond/object params)) - (bfc/get-file-permissions conn profile-id id))] + (perms/get-file-read-permissions cfg profile-id id))] (check-read-permissions! perms) - (let [team (teams/get-team conn + (let [team (teams/get-team cfg :profile-id profile-id :project-id project-id :file-id id) @@ -242,7 +242,7 @@ ::sm/result schema:file-fragment} [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}] (db/run! cfg (fn [cfg] - (let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)] + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] (check-read-permissions! perms) (-> (get-file-fragment cfg file-id fragment-id) (rph/with-http-cache long-cache-duration)))))) @@ -286,7 +286,7 @@ ::sm/params schema:get-project-files ::sm/result schema:files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}] - (projects/check-read-permissions! pool profile-id project-id) + (projects/check-read-permissions! cfg profile-id project-id) (get-project-files pool project-id)) ;; --- COMMAND QUERY: has-file-libraries @@ -304,7 +304,7 @@ ::sm/result ::sm/boolean} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! pool profile-id file-id) + (check-read-permissions! cfg profile-id file-id) (get-has-file-libraries conn file-id))) (def ^:private sql:has-file-libraries @@ -337,7 +337,7 @@ ::sm/result ::sm/int} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! pool profile-id file-id) + (check-read-permissions! cfg profile-id file-id) (get-library-usage conn file-id))) (def ^:private sql:get-library-usage @@ -387,7 +387,7 @@ :code :params-validation :hint "page-id is required when object-id is provided")) - (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) file (bfc/get-file cfg file-id :read-only? true) proj (db/get conn :project {:id (:project-id file)}) @@ -438,8 +438,8 @@ ::sm/params schema:get-page} [cfg {:keys [::rpc/profile-id file-id share-id] :as params}] (db/tx-run! cfg - (fn [{:keys [::db/conn] :as cfg}] - (check-read-permissions! conn profile-id file-id share-id) + (fn [cfg] + (check-read-permissions! cfg profile-id file-id share-id) (get-page cfg (assoc params :profile-id profile-id))))) ;; --- COMMAND QUERY: get-team-shared-files @@ -562,7 +562,7 @@ (defn- get-team-shared-files [{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}] - (teams/check-read-permissions! conn profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (let [process-row (fn [{:keys [id library-file-ids]}] @@ -675,8 +675,8 @@ ::sm/params schema:get-file-stats ::sm/result schema:get-file-stats-result ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}] - (check-read-permissions! conn profile-id id) + [cfg {:keys [::rpc/profile-id id]}] + (check-read-permissions! cfg profile-id id) (get-file-stats cfg id)) @@ -719,7 +719,7 @@ ::sm/params schema:get-library-file-references} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id file-id) + (check-read-permissions! cfg profile-id file-id) (get-library-file-references conn file-id))) ;; --- COMMAND QUERY: get-team-recent-files @@ -763,7 +763,7 @@ ::sm/params schema:get-team-recent-files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (teams/check-read-permissions! conn profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (get-team-recent-files conn team-id))) @@ -808,8 +808,8 @@ {::doc/added "2.12" ::sm/params schema:get-team-deleted-files} [cfg {:keys [::rpc/profile-id team-id]}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (teams/check-read-permissions! conn profile-id team-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (teams/check-read-permissions! cfg profile-id team-id) (get-team-deleted-files conn team-id)))) ;; --- COMMAND QUERY: get-file-info diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 38caa0aa05..7baac52428 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -22,6 +22,7 @@ [app.rpc.commands.files :as files] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] + [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.util.services :as sv])) @@ -33,8 +34,8 @@ {::doc/added "1.20" ::sm/params schema:get-file-snapshots} [cfg {:keys [::rpc/profile-id file-id] :as params}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-read-permissions! conn profile-id file-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-read-permissions! cfg profile-id file-id) (fsnap/get-visible-snapshots conn file-id)))) ;; --- COMMAND QUERY: get-file-snapshot @@ -52,8 +53,8 @@ ::sm/params schema:get-file-snapshot ::sm/result files/schema:file-with-permissions ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}] - (let [perms (bfc/get-file-permissions conn profile-id file-id)] + [cfg {:keys [::rpc/profile-id file-id id] :as params}] + (let [perms (perms/get-file-read-permissions cfg profile-id file-id)] (files/check-read-permissions! perms) (let [snapshot (fsnap/get-snapshot cfg file-id id)] (when-not snapshot diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 130d9a86be..2681b51c09 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -85,7 +85,7 @@ ::sm/result [:map-of [:string {:max 250}] [:string {:max 250}]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id tag] :as params}] (dm/with-open [conn (db/open pool)] - (files/check-read-permissions! conn profile-id file-id) + (files/check-read-permissions! cfg profile-id file-id) (if tag (get-object-thumbnails-by-tag conn file-id tag) (get-object-thumbnails conn file-id)))) @@ -197,9 +197,9 @@ ::sm/params schema:get-file-data-for-thumbnail ::sm/result schema:partial-file} [cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}] - (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - (files/check-read-permissions! conn profile-id file-id) - (let [team (teams/get-team conn + (db/run! cfg (fn [cfg] + (files/check-read-permissions! cfg profile-id file-id) + (let [team (teams/get-team cfg :profile-id profile-id :file-id file-id) file (bfc/get-file cfg file-id diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 5f031a4583..4d9eb77636 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -6,7 +6,6 @@ (ns app.rpc.commands.fonts (:require - [app.binfile.common :as bfc] [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.logging :as l] @@ -30,6 +29,7 @@ [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] + [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.storage :as sto] [app.storage.tmp :as tmp] @@ -71,14 +71,14 @@ (cond (uuid? team-id) (do - (teams/check-read-permissions! conn profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (db/query conn :team-font-variant {:team-id team-id :deleted-at nil})) (uuid? project-id) (let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})] - (projects/check-read-permissions! conn profile-id project-id) + (projects/check-read-permissions! cfg profile-id project-id) (db/query conn :team-font-variant {:team-id (:team-id project) :deleted-at nil})) @@ -86,7 +86,7 @@ (uuid? file-id) (let [file (db/get-by-id conn :file file-id {:columns [:id :project-id]}) project (db/get-by-id conn :project (:project-id file) {:columns [:id :team-id]}) - perms (bfc/get-file-permissions conn profile-id file-id share-id)] + perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] (files/check-read-permissions! perms) (db/query conn :team-font-variant {:team-id (:team-id project) @@ -400,7 +400,7 @@ ::sm/params schema:download-font} [{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] (let [variant (db/get pool :team-font-variant {:id id})] - (teams/check-read-permissions! pool profile-id (:team-id variant)) + (teams/check-read-permissions! cfg profile-id (:team-id variant)) ;; Try to get the best available font format (prefer TTF for broader compatibility). (let [media-id (or (:ttf-file-id variant) @@ -432,7 +432,7 @@ (ex/raise :type :not-found :code :object-not-found)) - (teams/check-read-permissions! pool profile-id (:team-id (first variants))) + (teams/check-read-permissions! cfg profile-id (:team-id (first variants))) (let [tempfile (tmp/tempfile :suffix ".zip") ffamily (-> variants first :font-family)] diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index c56f07ef83..41931f53ec 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -176,7 +176,7 @@ ;; profile-id is present; it can be ommited if this function is ;; called from SREPL helpers where no profile is available (when (uuid? profile-id) - (teams/check-read-permissions! conn profile-id team-id)) + (teams/check-read-permissions! cfg profile-id team-id)) (binding [bfc/*state* (volatile! {:index {team-id (uuid/next)}})] (let [projs (bfc/get-team-projects cfg team-id) diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index 0fdb9fb88f..0cb1b46e57 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -56,11 +56,16 @@ :can-edit (or is-owner is-admin can-edit) :can-read true}))) +(defn- get-read-permissions + [cfg profile-id project-id] + (or (get-permissions cfg profile-id project-id) + (perms/get-organization-owner-permissions cfg profile-id :project-id project-id))) + (def has-edit-permissions? (perms/make-edition-predicate-fn get-permissions)) (def has-read-permissions? - (perms/make-read-predicate-fn get-permissions)) + (perms/make-read-predicate-fn get-read-permissions)) (def check-edition-permissions! (perms/make-check-fn has-edit-permissions?)) @@ -159,10 +164,10 @@ {::doc/added "1.18" ::rpc/id-type :project ::sm/params schema:get-project} - [{:keys [::db/pool]} {:keys [::rpc/profile-id id]}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] (dm/with-open [conn (db/open pool)] (let [project (db/get-by-id conn :project id)] - (check-read-permissions! conn profile-id id) + (check-read-permissions! cfg profile-id id) project))) @@ -230,8 +235,8 @@ ::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id) ::webhooks/event? true ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id team-id is-pinned] :as params}] - (check-read-permissions! conn profile-id id) + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id team-id is-pinned] :as params}] + (check-read-permissions! cfg profile-id id) (db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned]) nil) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 50458c27f8..5efc564daa 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -60,6 +60,11 @@ :can-edit (or is-owner is-admin can-edit) :can-read true}))) +(defn get-read-permissions + [cfg profile-id team-id] + (or (get-permissions cfg profile-id team-id) + (perms/get-organization-owner-permissions cfg profile-id :team-id team-id))) + (def has-admin-permissions? (perms/make-admin-predicate-fn get-permissions)) @@ -67,7 +72,7 @@ (perms/make-edition-predicate-fn get-permissions)) (def has-read-permissions? - (perms/make-read-predicate-fn get-permissions)) + (perms/make-read-predicate-fn get-read-permissions)) (def check-admin-permissions! (perms/make-check-fn has-admin-permissions?)) @@ -180,7 +185,6 @@ sql (if (contains? cf/flags :subscriptions) sql:get-teams-with-permissions-and-subscription sql:get-teams-with-permissions)] - (->> (db/exec! conn [sql (:default-team-id profile) profile-id]) (into [] xform:process-teams)))) @@ -238,19 +242,34 @@ {::doc/added "1.17" ::rpc/id-type :team ::sm/params schema:get-team} - [{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}] - (get-team pool :profile-id profile-id :team-id id :file-id file-id)) + [cfg {:keys [::rpc/profile-id id file-id] :as params}] + (let [team (get-team cfg :profile-id profile-id :team-id id :file-id file-id)] + (if (contains? cf/flags :nitrate) + (nitrate/add-org-info-to-team cfg team params) + team))) + +(defn- get-org-owner-viewer-team + "When `profile-id` is a non-member owner of the organization that owns + the requested team, returns the team shaped with viewer permissions; + otherwise nil. `cfg` must carry the nitrate client." + [cfg profile-id default-team-id params] + (when-let [team-id (perms/resolve-team-id cfg params)] + (when (nitrate/organization-owner-of-team? cfg profile-id team-id) + (when-let [team (db/get* cfg :team {:id team-id})] + (when-not (db/is-row-deleted? team) + (-> team + (decode-row) + (merge perms/viewer-role-flags) + (assoc :is-default (= team-id default-team-id)) + (process-permissions))))))) (defn get-team - [conn & {:keys [profile-id team-id project-id file-id] :as params}] + [cfg & {:keys [profile-id team-id project-id file-id] :as params}] (assert (uuid? profile-id) "profile-id is mandatory") - (assert (or (db/connection? conn) - (db/pool? conn)) - "connection or pool is mandatory") (let [{:keys [default-team-id] :as profile} - (profile/get-profile conn profile-id) + (profile/get-profile cfg profile-id) sql (if (contains? cf/flags :subscriptions) @@ -262,14 +281,14 @@ (some? team-id) (let [sql (str "WITH teams AS (" sql ") " "SELECT * FROM teams WHERE id=?")] - (db/exec-one! conn [sql default-team-id profile-id team-id])) + (db/exec-one! cfg [sql default-team-id profile-id team-id])) (some? project-id) (let [sql (str "WITH teams AS (" sql ") " "SELECT t.* FROM teams AS t " " JOIN project AS p ON (p.team_id = t.id) " " WHERE p.id=?")] - (db/exec-one! conn [sql default-team-id profile-id project-id])) + (db/exec-one! cfg [sql default-team-id profile-id project-id])) (some? file-id) (let [sql (str "WITH teams AS (" sql ") " @@ -277,17 +296,18 @@ " JOIN project AS p ON (p.team_id = t.id) " " JOIN file AS f ON (f.project_id = p.id) " " WHERE f.id=?")] - (db/exec-one! conn [sql default-team-id profile-id file-id])) + (db/exec-one! cfg [sql default-team-id profile-id file-id])) :else (throw (IllegalArgumentException. "invalid arguments")))] - (when-not result - (ex/raise :type :not-found - :code :team-does-not-exist)) - (-> result - (decode-row) - (process-permissions)))) + (if result + (-> result + (decode-row) + (process-permissions)) + (or (get-org-owner-viewer-team cfg profile-id default-team-id params) + (ex/raise :type :not-found + :code :team-does-not-exist))))) ;; --- Query: Team Members @@ -316,7 +336,7 @@ ::sm/params schema:get-team-memebrs} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-team-members conn team-id))) ;; --- Query: Team Users @@ -342,10 +362,10 @@ (dm/with-open [conn (db/open pool)] (if team-id (do - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-users conn team-id)) (let [{team-id :id} (get-team-for-file conn file-id)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-users conn team-id))))) ;; This is a similar query to team members but can contain more data @@ -432,7 +452,7 @@ ::sm/params schema:get-team-stats} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-team-stats conn team-id))) (def sql:team-stats @@ -468,7 +488,7 @@ ::sm/params schema:get-team-invitations} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-team-invitations conn team-id))) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 34795ba77b..c17a4e5a45 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -541,7 +541,7 @@ ::doc/module :teams ::sm/params schema:get-team-invitation-token} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] - (teams/check-read-permissions! pool profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (let [email (profile/clean-email email) invit (-> (db/get pool :team-invitation {:team-id team-id diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj index 570751e48b..9333800af6 100644 --- a/backend/src/app/rpc/commands/viewer.clj +++ b/backend/src/app/rpc/commands/viewer.clj @@ -16,6 +16,7 @@ [app.rpc.commands.teams :as teams] [app.rpc.cond :as-alias cond] [app.rpc.doc :as-alias doc] + [app.rpc.permissions :as perms] [app.util.services :as sv] [cuerdas.core :as str])) @@ -125,8 +126,8 @@ ::sm/params schema:get-view-only-bundle} [system {:keys [::rpc/profile-id file-id share-id] :as params}] (db/run! system - (fn [{:keys [::db/conn] :as system}] - (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) + (fn [system] + (let [perms (perms/get-file-read-permissions system profile-id file-id share-id) params (-> params (assoc ::perms perms) (assoc :profile-id profile-id))] @@ -139,5 +140,3 @@ :hint "object not found")) (get-view-only-bundle system params))))) - - diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 702a9bdd14..33341bb34e 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -172,6 +172,6 @@ ::sm/params schema:get-webhooks} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (->> (db/exec! conn [sql:get-webhooks team-id]) (mapv decode-row)))) diff --git a/backend/src/app/rpc/permissions.clj b/backend/src/app/rpc/permissions.clj index f4107ec235..36ff9b2c23 100644 --- a/backend/src/app/rpc/permissions.clj +++ b/backend/src/app/rpc/permissions.clj @@ -7,8 +7,11 @@ (ns app.rpc.permissions "A permission checking helper factories." (:require + [app.binfile.common :as bfc] [app.common.exceptions :as ex] - [app.common.schema :as sm])) + [app.common.schema :as sm] + [app.db :as db] + [app.nitrate :as nitrate])) (def schema:permissions [:map {:title "Permissions"} @@ -89,3 +92,64 @@ (ex/raise :type :not-found :code :object-not-found :hint "not found")))) + +;; --- Organization owner (Nitrate) viewer access +;; +;; Read-permission helpers that augment normal Penpot membership with +;; Nitrate organization-owner viewer access. Edit/admin permission +;; providers intentionally stay membership-only. + +(def viewer-role-flags + "Role flags granted to a non-member organization owner: read-only. + Shared so callers that build full team/file rows shape permissions the + same way the permission lookups do." + {:is-owner false + :is-admin false + :can-edit false}) + +(def ^:private sql:get-team-id-for-project + "SELECT team_id FROM project WHERE id = ?") + +(def ^:private sql:get-team-id-for-file + "SELECT p.team_id + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + WHERE f.id = ?") + +(defn get-team-id-for-project + [cfg project-id] + (some-> (db/exec-one! cfg [sql:get-team-id-for-project project-id]) + (:team-id))) + +(defn get-team-id-for-file + [cfg file-id] + (some-> (db/exec-one! cfg [sql:get-team-id-for-file file-id]) + (:team-id))) + +(defn resolve-team-id + [cfg {:keys [team-id project-id file-id]}] + (cond + (some? team-id) team-id + (some? project-id) (get-team-id-for-project cfg project-id) + (some? file-id) (get-team-id-for-file cfg file-id))) + +(defn get-organization-owner-permissions + "When `profile-id` is a non-member owner of the organization that owns + the team/project/file referenced by `params`, returns read-only viewer + permissions; otherwise nil." + [cfg profile-id & {:as params}] + (when-let [team-id (resolve-team-id cfg params)] + (when (nitrate/organization-owner-of-team? cfg profile-id team-id) + (assoc viewer-role-flags + :can-read true + :type :membership + :is-logged (some? profile-id))))) + +(defn get-file-read-permissions + ([cfg profile-id file-id] + (or (bfc/get-file-permissions cfg profile-id file-id) + (get-organization-owner-permissions cfg profile-id :file-id file-id))) + + ([cfg profile-id file-id share-id] + (or (bfc/get-file-permissions cfg profile-id file-id share-id) + (get-organization-owner-permissions cfg profile-id :file-id file-id)))) diff --git a/backend/test/backend_tests/rpc_org_owner_permissions_test.clj b/backend/test/backend_tests/rpc_org_owner_permissions_test.clj new file mode 100644 index 0000000000..7ac84ef042 --- /dev/null +++ b/backend/test/backend_tests/rpc_org_owner_permissions_test.clj @@ -0,0 +1,150 @@ +;; 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 Sucursal en España SL + +(ns backend-tests.rpc-org-owner-permissions-test + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.nitrate :as nitrate] + [app.rpc :as-alias rpc] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- org-data + [org-id owner-id] + {:id org-id + :name "Acme" + :slug "acme" + :owner-id owner-id + :avatar-bg-url "http://example.com/avatar.png" + :permissions {}}) + +(defn- with-org-owner-access + [{:keys [org-owner-id org-id team-id]} f] + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/organization-owner-of-team? + (fn [_cfg profile-id candidate-team-id] + (and (= org-owner-id profile-id) + (= team-id candidate-team-id))) + + nitrate/call + (fn [_cfg method params] + (case method + :get-owned-orgs + [{:id org-id + :name "Acme" + :owner-id org-owner-id + :teams [{:id team-id :is-your-penpot false}]}] + + :get-team-org + (if (= team-id (:team-id params)) + {:id team-id + :is-your-penpot false + :organization (org-data org-id org-owner-id)} + {:id (:team-id params) + :is-your-penpot false + :organization nil})))] + (f))) + +(t/deftest org-owner-access-disabled-without-nitrate-flag + (let [team-owner (th/create-profile* 1) + org-owner (th/create-profile* 2) + target-team (th/create-team* 1 {:profile-id (:id team-owner)})] + + (let [out (th/command! {::th/type :get-projects + ::rpc/profile-id (:id org-owner) + :team-id (:id target-team)}) + error (:error out)] + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found))))) + +(t/deftest non-member-org-owner-gets-viewer-access-to-org-team + (let [team-owner (th/create-profile* 1) + org-owner (th/create-profile* 2) + target-team (th/create-team* 1 {:profile-id (:id team-owner)}) + project (th/create-project* 1 {:profile-id (:id team-owner) + :team-id (:id target-team)}) + file (th/create-file* 1 {:profile-id (:id team-owner) + :project-id (:id project)}) + org-id (uuid/next)] + + (with-org-owner-access {:org-owner-id (:id org-owner) + :org-id org-id + :team-id (:id target-team)} + (fn [] + ;; The team is not listed for a non-member, even though the org + ;; owner can access it directly. + (let [out (th/command! {::th/type :get-teams + ::rpc/profile-id (:id org-owner)})] + (t/is (nil? (:error out))) + (t/is (not-any? #(= (:id target-team) (:id %)) (:result out)))) + + (let [out (th/command! {::th/type :get-team + ::rpc/profile-id (:id org-owner) + :id (:id target-team)}) + team (:result out)] + (t/is (nil? (:error out))) + (t/is (= (:id target-team) (:id team))) + (t/is (false? (get-in team [:permissions :is-owner]))) + (t/is (false? (get-in team [:permissions :is-admin]))) + (t/is (false? (get-in team [:permissions :can-edit]))) + (t/is (= org-id (get-in team [:organization :id]))) + (t/is (= "Acme" (get-in team [:organization :name])))) + + (let [out (th/command! {::th/type :get-team-members + ::rpc/profile-id (:id org-owner) + :team-id (:id target-team)}) + members (:result out)] + (t/is (nil? (:error out))) + (t/is (some #(= (:id team-owner) (:id %)) members)) + (t/is (not-any? #(= (:id org-owner) (:id %)) members))) + + (let [out (th/command! {::th/type :get-projects + ::rpc/profile-id (:id org-owner) + :team-id (:id target-team)})] + (t/is (nil? (:error out))) + (t/is (some #(= (:id project) (:id %)) (:result out)))) + + (let [out (th/command! {::th/type :get-file + ::rpc/profile-id (:id org-owner) + :id (:id file)})] + (t/is (nil? (:error out))) + (t/is (= (:id file) (get-in out [:result :id]))) + (t/is (false? (get-in out [:result :permissions :can-edit])))) + + (let [out (th/command! {::th/type :rename-project + ::rpc/profile-id (:id org-owner) + :id (:id project) + :name "Nope"}) + error (:error out)] + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found))))))) + +(t/deftest org-owner-member-keeps-team-role + (let [team-owner (th/create-profile* 1) + org-owner (th/create-profile* 2) + target-team (th/create-team* 1 {:profile-id (:id team-owner)}) + org-id (uuid/next)] + + (th/create-team-role* {:team-id (:id target-team) + :profile-id (:id org-owner) + :role :editor}) + + (with-org-owner-access {:org-owner-id (:id org-owner) + :org-id org-id + :team-id (:id target-team)} + (fn [] + (let [out (th/command! {::th/type :get-team + ::rpc/profile-id (:id org-owner) + :id (:id target-team)}) + team (:result out)] + (t/is (nil? (:error out))) + (t/is (false? (get-in team [:permissions :is-owner]))) + (t/is (false? (get-in team [:permissions :is-admin]))) + (t/is (true? (get-in team [:permissions :can-edit])))))))) diff --git a/frontend/src/app/main/data/helpers.cljs b/frontend/src/app/main/data/helpers.cljs index fd22d092a0..1a3cd54509 100644 --- a/frontend/src/app/main/data/helpers.cljs +++ b/frontend/src/app/main/data/helpers.cljs @@ -209,6 +209,20 @@ (filter #(= team-id (:team-id (val %)))) (into {})))) +(defn lookup-team + "The team identified by `team-id`, looked up first in the membership + `:teams` map and falling back to the directly-opened `:current-team`. + The fallback covers org-owner access to teams the profile is not a + member of, which are kept out of `:teams` so they don't leak into the + teams listing." + ([state] + (lookup-team state (:current-team-id state))) + ([state team-id] + (or (dm/get-in state [:teams team-id]) + (let [current (:current-team state)] + (when (= team-id (:id current)) + current))))) + (defn get-selrect [selrect-transform shape] (if (some? selrect-transform) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index f99765b5c3..a83add6a99 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -8,7 +8,6 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.exceptions :as ex] [app.common.logging :as log] [app.common.schema :as sm] [app.common.types.nitrate-permissions :as nitrate-perms] @@ -16,6 +15,7 @@ [app.common.uri :as u] [app.config :as cf] [app.main.data.event :as ev] + [app.main.data.helpers :as dsh] [app.main.data.media :as di] [app.main.data.modal :as modal] [app.main.data.profile :as dp] @@ -67,6 +67,18 @@ (->> (rp/cmd! :get-teams) (rx/map teams-fetched))))) +(defn- update-team-data + [state team-id f & args] + (cond + (contains? (:teams state) team-id) + (apply update-in state [:teams team-id] f args) + + (= team-id (dm/get-in state [:current-team :id])) + (apply update state :current-team f args) + + :else + state)) + (defn with-refreshed-team "Fetches fresh team data from the server to ensure up-to-date org permissions, updates the app state, and calls f with the fresh team data. @@ -197,7 +209,7 @@ ptk/UpdateEvent (update [_ state] (-> state - (update-in [:teams team-id] assoc :members members) + (update-team-data team-id assoc :members members) (update :profiles merge (d/index-by :id members)))))) (defn fetch-members @@ -223,7 +235,7 @@ (ptk/reify ::invitations-fetched ptk/UpdateEvent (update [_ state] - (update-in state [:teams team-id] assoc :invitations invitations)))) + (update-team-data state team-id assoc :invitations invitations)))) (defn fetch-invitations [] @@ -239,15 +251,24 @@ (ptk/reify ::team-initialized ptk/WatchEvent (watch [_ state _] - (let [teams (get state :teams) - team (get teams team-id)] - (if (not team) - (rx/throw (ex/error :type :authentication)) + (let [team (dsh/lookup-team state team-id)] + (if team (let [permissions (get team :permissions) features (get team :features)] - (rx/of #(assoc % :permissions permissions) + (rx/of #(-> % + (assoc :current-team team) + (assoc :permissions permissions)) (features/initialize features) - (fetch-members team-id)))))) + (fetch-members team-id))) + (->> (rp/cmd! :get-team {:id team-id}) + (rx/mapcat (fn [team] + (let [permissions (get team :permissions) + features (get team :features)] + (rx/of #(-> % + (assoc :current-team team) + (assoc :permissions permissions)) + (features/initialize features) + (fetch-members team-id))))))))) ptk/EffectEvent (effect [_ _ _] @@ -258,7 +279,9 @@ (ptk/reify ::initialize-team ptk/UpdateEvent (update [_ state] - (assoc state :current-team-id team-id)) + (-> state + (assoc :current-team-id team-id) + (dissoc :current-team))) ptk/WatchEvent (watch [_ _ stream] @@ -279,6 +302,7 @@ (if (= team-id' team-id) (-> state (dissoc :current-team-id) + (dissoc :current-team) (dissoc :shared-files) (dissoc :fonts)) state))))) @@ -330,7 +354,7 @@ (ptk/reify ::stats-fetched ptk/UpdateEvent (update [_ state] - (update-in state [:teams team-id] assoc :stats stats)))) + (update-team-data state team-id assoc :stats stats)))) (defn fetch-stats [] @@ -346,7 +370,7 @@ (ptk/reify ::webhooks-fetched ptk/UpdateEvent (update [_ state] - (update-in state [:teams team-id] assoc :webhooks webhooks)))) + (update-team-data state team-id assoc :webhooks webhooks)))) (defn fetch-webhooks [] @@ -776,4 +800,3 @@ (defn team->organization [team] (when-let [org (:organization team)] (assoc org :default-team-id (:id team)))) - diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs index 58b33ce313..7e7890c435 100644 --- a/frontend/src/app/main/features.cljs +++ b/frontend/src/app/main/features.cljs @@ -12,6 +12,7 @@ [app.common.features :as cfeat] [app.common.logging :as log] [app.config :as cf] + [app.main.data.helpers :as dsh] [app.main.router :as rt] [app.main.store :as st] [app.render-wasm :as wasm] @@ -66,7 +67,7 @@ (defn get-enabled-features "An explicit lookup of enabled features for the current team" [state team-id] - (let [team (dm/get-in state [:teams team-id])] + (let [team (dsh/lookup-team state team-id)] (-> global-enabled-features (set/union (get state :features-runtime #{})) (set/intersection cfeat/no-migration-features) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 791520efd9..fc14e846f1 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -36,11 +36,7 @@ (l/derived (l/key :current-page-id) st/state)) (def team - (l/derived (fn [state] - (let [team-id (:current-team-id state) - teams (:teams state)] - (get teams team-id))) - st/state)) + (l/derived dsh/lookup-team st/state)) (def project (l/derived (fn [state] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index bcd2c92d7c..9ba8dfd53b 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -676,19 +676,25 @@ [{:keys [team profile]}] (let [teams (mf/deref refs/teams) - ;; Find the "your-penpot" teams, and transform them in orgs - orgs (mf/with-memo [teams] - (->> teams - vals - (filter :is-default) - (map dtm/team->organization) - (d/index-by :id))) + current-org (dtm/team->organization team) + + ;; Find the "your-penpot" teams, and transform them in orgs. When + ;; the selected team is directly accessible but not listed in + ;; membership teams, include only its org so the org selector can + ;; show the current selection without leaking the team into the + ;; teams dropdown. + orgs (mf/with-memo [teams current-org] + (cond-> (->> teams + vals + (filter :is-default) + (map dtm/team->organization) + (d/index-by :id)) + (:id current-org) + (assoc (:id current-org) current-org))) show-dropdown? (or (dnt/is-valid-license? profile) (> (count orgs) 1)) - current-org (dtm/team->organization team) - org-teams (mf/with-memo [teams current-org] (->> teams vals @@ -1446,4 +1452,3 @@ [:> profile-section* {:profile profile :team team}]]) - From 67386a035881f7656fe36104376a218428711db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 25 Jun 2026 13:30:53 +0200 Subject: [PATCH 013/100] :bug: Fix missing tiles on the left (#10421) --- render-wasm/src/tiles.rs | 140 +++++++-------------------------------- 1 file changed, 25 insertions(+), 115 deletions(-) diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 0d32b1ae1c..d00dc25bdf 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -312,102 +312,13 @@ impl TileHashMap { } const VIEWPORT_DEFAULT_CAPACITY: usize = 24 * 12; -const VIEWPORT_SPIRAL_DEFAULT_CAPACITY: usize = VIEWPORT_DEFAULT_CAPACITY; - -/// Cached spiral of tile offsets for a given grid size. -/// -/// Offsets are centered at (0,0) and must be translated by the desired origin/center tile. -#[derive(Debug, Default)] -pub struct TileSpiral { - offsets: Vec, - columns: usize, - rows: usize, -} - -impl TileSpiral { - pub fn new() -> Self { - Self { - offsets: Vec::with_capacity(VIEWPORT_SPIRAL_DEFAULT_CAPACITY), - columns: 0, - rows: 0, - } - } - - #[inline] - pub fn iter(&self) -> std::slice::Iter<'_, Tile> { - self.offsets.iter() - } - - /// Ensure the spiral offsets match the given grid size. - /// - /// This regenerates offsets whenever the size changes (grow or shrink) so callers - /// don't accidentally reuse a spiral built for a previous viewport. - pub fn ensure(&mut self, columns: usize, rows: usize) { - if self.columns == columns && self.rows == rows { - return; - } - self.columns = columns; - self.rows = rows; - - let total = columns.saturating_mul(rows); - self.offsets.clear(); - self.offsets.reserve(total); - - if total == 0 { - return; - } - - // Generate tiles in spiral order from center (same algorithm as before). - let mut cx = 0; - let mut cy = 0; - - let ratio = (columns as f32 / rows as f32).ceil() as i32; - - let mut direction_current = 0; - let mut direction_total_x = ratio; - let mut direction_total_y = 1; - let mut direction = 0; - - self.offsets.push(Tile(cx, cy)); - while self.offsets.len() < total { - match direction { - 0 => cx += 1, - 1 => cy += 1, - 2 => cx -= 1, - 3 => cy -= 1, - _ => unreachable!("Invalid direction"), - } - - self.offsets.push(Tile(cx, cy)); - - direction_current += 1; - let direction_total = if direction % 2 == 0 { - direction_total_x - } else { - direction_total_y - }; - - if direction_current == direction_total { - if direction % 2 == 0 { - direction_total_x += 1; - } else { - direction_total_y += 1; - } - direction = (direction + 1) % 4; - direction_current = 0; - } - } - - self.offsets.reverse(); - } -} // This structure keeps the list of tiles that are in the pending list, the // ones that are going to be rendered. pub struct PendingTiles { pub list: Vec, - pub spiral: TileSpiral, - pub spiral_rect: TileRect, + pub tile_order: Vec<(i32, Tile)>, + pub tile_rect: TileRect, pub visible_cached: Vec, pub visible_uncached: Vec, pub interest_cached: Vec, @@ -418,8 +329,8 @@ impl PendingTiles { pub fn new() -> Self { Self { list: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), - spiral: TileSpiral::new(), - spiral_rect: TileRect::empty(), + tile_order: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), + tile_rect: TileRect::empty(), visible_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), visible_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), interest_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), @@ -435,22 +346,13 @@ impl PendingTiles { // path, and pre-rendering tiles outside the viewport is wasted // work that just gets evicted on the next pointer move. The ring // is repopulated naturally on gesture end / on idle rAFs. - let spiral_rect = if only_visible { + let tile_rect = if only_visible { &tile_viewbox.visible_rect } else { &tile_viewbox.interest_rect }; - self.spiral_rect = *spiral_rect; - - // We do not regenerate spiral if the spiral_rect - // doesn't change. The spiral_rect is based on the - // viewbox so, if the viewbox doesn't change - // the spiral should not change. - let columns = spiral_rect.columns(); - let rows = spiral_rect.rows(); - - self.spiral.ensure(columns as usize, rows as usize); + self.tile_rect = *tile_rect; // Partition tiles into 4 priority groups (highest priority = processed last due to pop()): // 1. visible + cached (fastest - just blit from cache) @@ -462,15 +364,25 @@ impl PendingTiles { self.interest_cached.clear(); self.interest_uncached.clear(); - // Compute the scheduling center explicitly (inclusive range). - // This avoids relying on `TileRect::center_x/center_y` semantics, which may be used - // elsewhere with different expectations. - let center_tile = Tile( - (spiral_rect.x1() + spiral_rect.x2()) / 2, - (spiral_rect.y1() + spiral_rect.y2()) / 2, - ); - for spiral_tile in self.spiral.iter() { - let tile = Tile(spiral_tile.0 + center_tile.0, spiral_tile.1 + center_tile.1); + // Enumerate every tile in `tile_rect`, ordered by distance from the + // rect center. + let center_x = (tile_rect.x1() + tile_rect.x2()) / 2; + let center_y = (tile_rect.y1() + tile_rect.y2()) / 2; + + self.tile_order.clear(); + + for tile in tile_rect.iter(true) { + let dx = tile.x() - center_x; + let dy = tile.y() - center_y; + self.tile_order.push((dx * dx + dy * dy, tile)); + } + + // Farthest first, since we use pop() to process the tiles + // in order of priority (closest first) + self.tile_order.sort_unstable_by(|a, b| b.0.cmp(&a.0)); + + for (_, tile) in self.tile_order.iter() { + let tile = *tile; let is_visible = tile_viewbox.visible_rect.contains(&tile); let is_cached = surfaces.has_cached_tile_surface(tile); @@ -482,8 +394,6 @@ impl PendingTiles { } } - // Build final list with lowest priority first (they get popped last) - // Order: interest_uncached, interest_cached, visible_uncached, visible_cached self.list.extend(self.interest_uncached.iter()); self.list.extend(self.interest_cached.iter()); self.list.extend(self.visible_uncached.iter()); From 345affc6874c1669f00e1495a2c467b5a1844e19 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 25 Jun 2026 15:07:37 +0200 Subject: [PATCH 014/100] :bug: Fix premature WASM view-interaction end during pan (#10425) --- .../main/data/workspace/viewport_wasm.cljs | 2 +- frontend/src/app/render_wasm/api.cljs | 30 ++++++++++++------- render-wasm/src/main.rs | 7 +++++ render-wasm/src/render.rs | 13 ++++++-- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/main/data/workspace/viewport_wasm.cljs b/frontend/src/app/main/data/workspace/viewport_wasm.cljs index 4115589ab0..1c1caa801b 100644 --- a/frontend/src/app/main/data/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/data/workspace/viewport_wasm.cljs @@ -27,4 +27,4 @@ (defn maybe-view-interaction-end! [state] (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) - (wasm.api/view-interaction-end!))) \ No newline at end of file + (wasm.api/finalize-view-interaction!))) \ No newline at end of file diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index cc0814a6b2..f2a344fc25 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -357,12 +357,15 @@ (def ^:const FRAME_TYPE_NONE 0) ;; This type should never "leak". (def ^:const FRAME_TYPE_PARTIAL 1) ;; A frame needs more render calls to end. (def ^:const FRAME_TYPE_FULL 2) ;; A frame was full. +(def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause). (defn- internal-render ([] (internal-render 0)) ([timestamp] - (set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp wasm/internal-frame-type)) + (internal-render timestamp wasm/internal-frame-type)) + ([timestamp flags] + (set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp flags)) (when (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) (request-render "frame-type-partial")))) @@ -1311,20 +1314,27 @@ (perf/end-measure "render-finish") (reset! view-interaction-active? false))) +(defn- view-gesture-active? + "True while a pointer-driven pan or zoom gesture is in progress." + [] + (let [local (get @st/state :workspace-local)] + (or (:panning local) (:zooming local)))) + +(defn finalize-view-interaction! + "Ends the view interaction and triggers a full-quality render." + [] + (view-interaction-end!) + (internal-render 0 0)) + (def render-finish (letfn [(do-render [] ;; Check if context is still initialized before executing ;; to prevent errors when navigating quickly (when (initialized?) - (view-interaction-end!) - ;; Use async _render: visible tiles render synchronously - ;; (no yield), interest-area tiles render progressively - ;; via rAF. _set_view_end already rebuilt the tile - ;; index. For pan, most tiles are cached so the render - ;; completes in the first frame. For zoom, interest- - ;; area tiles (~3 tile margin) don't block the main - ;; thread. - (internal-render)))] + (if (view-gesture-active?) + ;; Pan/zoom pause: render without ending the interaction. + (internal-render 0 RENDER-FLAG-SYNC-TILES) + (finalize-view-interaction!))))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) (defn set-view-box diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index bcdd9bf28a..0aa3fae930 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -123,6 +123,9 @@ pub extern "C" fn render(timestamp: i32, flags: u8) -> Result { } } let is_partial = flags & RenderFlag::Partial as u8 == RenderFlag::Partial as u8; + if flags & RenderFlag::SyncTiles as u8 != 0 { + render_state.preserve_target_during_render = true; + } let frame_type = if is_partial && !render_state.preserve_target_during_render { state .continue_render_loop(timestamp) @@ -355,6 +358,10 @@ pub extern "C" fn set_view_end() -> Result<()> { // instead of re-drawing every visible tile from scratch. render_state.rebuild_tile_index(&state.shapes); } + // Avoid `reset_canvas` on the post-gesture render (pan at stable zoom). + if !render_state.options.is_profile_rebuild_tiles() { + render_state.preserve_target_during_render = true; + } performance::end_measure!("set_view_end"); }); Ok(()) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index cc26d6de45..ee4ebe3bd0 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -54,7 +54,8 @@ pub enum FrameType { pub enum RenderFlag { None = 0, Partial = 1, - Full = 2, + /// Rebuilds the tile index without leaving fast mode. + SyncTiles = 4, } #[derive(Debug)] @@ -2092,6 +2093,10 @@ impl RenderState { let preserve_target = self.preserve_target_during_render; self.preserve_target_during_render = false; + if preserve_target && self.options.is_fast_mode() { + self.rebuild_tile_index(tree); + } + if self.options.is_interactive_transform() { // Keep `Target` as the previous frame and overwrite only the tiles // that changed. This avoids clearing + redrawing an atlas backdrop @@ -3899,7 +3904,11 @@ impl RenderState { let mut all_tiles = HashSet::::new(); let ids = std::mem::take(&mut self.touched_ids); - self.preserve_target_during_render = !ids.is_empty(); + // Pan release sets `preserve_target` in `set_view_end`; don't reset it + // here when no shapes changed, or the next render clears the canvas. + if !ids.is_empty() { + self.preserve_target_during_render = true; + } for shape_id in ids.iter() { if let Some(shape) = tree.get(shape_id) { From 66719a14f52ee296e38e7ddfe67beeb5b9618e83 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Fri, 26 Jun 2026 09:42:22 +0200 Subject: [PATCH 015/100] :bug: Fix assets typography container is longer than others (#10406) * :bug: Fix assets typography container is longer than others * :recycle: Use new SCSS guidelines --- .../sidebar/assets/typographies.scss | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss index ebeac8d502..4c841ffbae 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss @@ -4,19 +4,21 @@ // // Copyright (c) KALEIDOS INC Sucursal en España SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .assets-list { - padding: 0 0 0 deprecated.$s-4; + padding: 0 0 0 var(--sp-xs); } .drop-space { - height: deprecated.$s-12; + block-size: $sz-12; } .grid-placeholder { - height: deprecated.$s-2; - width: 100%; + block-size: px2rem(2); + inline-size: 100%; background-color: var(--color-accent-primary); } @@ -24,18 +26,19 @@ position: relative; display: flex; align-items: center; - margin-bottom: deprecated.$s-4; - border-radius: deprecated.$br-8; - background-color: var(--assets-item-background-color); + margin-block-end: var(--sp-xs); + border-radius: $br-8; + background-color: var(--color-background-tertiary); + inline-size: var(--options-width); } .dragging { position: absolute; - top: 0; - left: 0; - height: 100%; - width: 100%; - border: deprecated.$s-2 solid var(--assets-item-border-color-drag); - border-radius: deprecated.$s-8; - background-color: var(--assets-item-background-color-drag); + inset-block-start: 0; + inset-inline-start: 0; + block-size: 100%; + inline-size: 100%; + border: $b-2 solid var(--color-accent-primary-muted); + border-radius: $br-8; + background-color: transparent; } From d16a2c93e0d119055c4b406666ba22e4c8986f55 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Fri, 26 Jun 2026 10:50:19 +0200 Subject: [PATCH 016/100] :bug: Fix long typography token name in tooltip in design tab (#10387) --- frontend/src/app/main/ui/ds/controls/shared/token_option.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.scss b/frontend/src/app/main/ui/ds/controls/shared/token_option.scss index 59f19b692c..e6e6e017be 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/token_option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.scss @@ -86,4 +86,5 @@ .token-name-tooltip { color: var(--color-foreground-primary); + overflow-wrap: anywhere; } From 89f882ecdabdae0ff044c7411130bfaae4f637ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 26 Jun 2026 10:59:22 +0200 Subject: [PATCH 017/100] :bug: Fix viewer rendering on Firefox+NVIDIA setup (#10385) --- frontend/src/app/main/render_viewer_wasm.cljs | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/render_viewer_wasm.cljs b/frontend/src/app/main/render_viewer_wasm.cljs index 9ae5d63994..45f7b83f73 100644 --- a/frontend/src/app/main/render_viewer_wasm.cljs +++ b/frontend/src/app/main/render_viewer_wasm.cljs @@ -8,6 +8,7 @@ "WASM offscreen rendering for the shared viewer (snapshot + fixed-scroll)." (:require [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.render-wasm.api :as wasm.api] [app.render-wasm.wasm :as wasm] [app.util.dom :as dom] @@ -43,15 +44,31 @@ :dpr 1})) (defn- draw-bitmap! + "Blit the rendered OffscreenCanvas onto the visible 2D `canvas`. Firefox+NVIDIA + can't `drawImage` a managed OffscreenCanvas directly (yields transparent + pixels), but `drawImage` of an `ImageBitmap` works — so go through + `createImageBitmap` (GPU-side, correct orientation, no CPU readback)." [canvas os-canvas object-id vis-w vis-h finish] - (ts/raf - (fn [] - (let [ctx2d (.getContext canvas "2d")] - (.clearRect ctx2d 0 0 vis-w vis-h) - ;; Draw directly from OffscreenCanvas so it can be reused across passes. - (.drawImage ctx2d os-canvas 0 0 vis-w vis-h) - (dom/set-attribute! canvas "id" (str "screenshot-" object-id)) - (finish))))) + (-> (js/createImageBitmap os-canvas) + (p/then + (fn [bitmap] + (ts/raf + (fn [] + (let [ctx2d (.getContext canvas "2d")] + (.clearRect ctx2d 0 0 vis-w vis-h) + (.drawImage ctx2d bitmap 0 0 vis-w vis-h) + (.close bitmap) + (dom/set-attribute! canvas "id" (str "screenshot-" object-id)) + (finish)))))) + (p/catch + (fn [e] + (finish) + (ts/schedule + (fn [] + (ex/raise :type :wasm-error + :code :viewer-canvas-failed + :hint "Viewer canvas failed" + :cause e))))))) (defn- viewer-disable-wasm-ui-overlay! "Workspace WASM UI (rulers + rounded viewport frame) is composited in From 6e61e3304bfd399186b0b6014f7a2b9c74ca89ba Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Fri, 26 Jun 2026 11:38:18 +0200 Subject: [PATCH 018/100] :sparkles: Add and endpoint for nitrate to check the SSO configuration for an organization (#10432) --- backend/src/app/auth/oidc.clj | 96 ++++++++++++++++++- backend/src/app/loggers/audit.clj | 3 +- backend/src/app/nitrate.clj | 14 +-- backend/src/app/rpc/commands/nitrate.clj | 17 +--- backend/src/app/rpc/management/nitrate.clj | 21 +++- backend/test/backend_tests/auth_oidc_test.clj | 17 ++++ .../rpc_management_nitrate_test.clj | 38 ++++++++ common/src/app/common/types/organization.cljc | 11 +++ 8 files changed, 190 insertions(+), 27 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 286ff3be45..4f7583d39d 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -761,6 +761,16 @@ ;; ORG SSO HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- non-blank-uri + [value] + (when-not (str/blank? value) value)) + +(defn org-sso-discovery-uri + "Return the OIDC discovery URI from an org SSO config, preferring :issuer." + [sso] + (or (non-blank-uri (:issuer sso)) + (non-blank-uri (:base-url sso)))) + (defn prepare-org-sso-provider "Build an OIDC provider map dynamically from the Nitrate org SSO config. Uses OIDC discovery via :base-url (or :issuer as fallback) when @@ -770,12 +780,96 @@ {:type "oidc" :client-id client-id :client-secret client-secret - :base-uri (some-> (or base-url issuer) + :base-uri (some-> (or (non-blank-uri base-url) + (non-blank-uri issuer)) (str/rtrim "/") (str "/")) :scopes (into default-oidc-scopes (or scopes #{})) :skip-ssrf-check? true})) +(defn build-org-sso-auth-redirect-uri + "Build the OIDC authorization redirect URI for an organization SSO config. + Raises if the config is incomplete or OIDC discovery fails." + [cfg sso & {:keys [dest-url organization-id provider]}] + (let [organization-id (or organization-id (:organization-id sso)) + issuer (org-sso-discovery-uri sso) + dest-url (or dest-url (str (cf/get :public-uri)))] + (when-not issuer + (ex/raise :type :validation + :code :invalid-sso-config + :hint "missing issuer or base-url")) + (let [oidc-provider (or provider (prepare-org-sso-provider cfg sso)) + state-token (tokens/generate cfg {:iss "oidc" + :dest-url dest-url + :organization-id organization-id + :issuer issuer + :exp (ct/in-future "4h")})] + (build-auth-redirect-uri oidc-provider state-token)))) + +(def ^:private probe-auth-code "penpot-sso-config-probe") + +(defn- decode-token-error-response + [body] + (when (and (string? body) (pos? (count body))) + (try + (json/decode body) + (catch Throwable _ nil)))) + +(defn- token-endpoint-error + [response] + (some-> response :body decode-token-error-response :error d/name)) + +(defn- token-endpoint-error-description + [response] + (some-> response :body decode-token-error-response :error-description)) + +(defn- token-endpoint-valid-client-error? + "Token endpoint rejected the dummy auth code but accepted the client credentials." + [response] + (= "invalid_grant" (token-endpoint-error response))) + +(defn- token-endpoint-invalid-client-error? + "Token endpoint rejected the client credentials." + [{:keys [status] :as response}] + (let [error (token-endpoint-error response) + description (str/lower (or (token-endpoint-error-description response) ""))] + (or (= status 401) + (#{"invalid_client" "unauthorized_client"} error) + (and (= error "access_denied") + (str/includes? description "unauthorized"))))) + +(defn- probe-org-sso-client-credentials + "Probe the token endpoint with a dummy authorization code. + Valid client credentials are expected to answer with `invalid_grant`." + [cfg provider] + (let [params {:client_id (:client-id provider) + :client_secret (:client-secret provider) + :code probe-auth-code + :grant_type "authorization_code" + :redirect_uri (build-redirect-uri)} + req {:method :post + :headers {"content-type" "application/x-www-form-urlencoded" + "accept" "application/json"} + :uri (:token-uri provider) + :body (u/map->query-string params)} + response (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})] + (cond + (token-endpoint-valid-client-error? response) true + (token-endpoint-invalid-client-error? response) false + :else false))) + +(defn is-organization-sso-config-valid? + "Return true when the SSO config can be discovered, can build a login URL, + and the client credentials are accepted by the token endpoint." + [cfg sso] + (try + (if (org-sso-discovery-uri sso) + (let [provider (prepare-org-sso-provider cfg sso)] + (and (build-org-sso-auth-redirect-uri cfg sso :provider provider) + (probe-org-sso-client-credentials cfg provider))) + false) + (catch Throwable _ false))) + (defn- auth-handler [cfg {:keys [params] :as request}] (let [provider (resolve-provider cfg params) diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 9de442a48a..c1b63c9aff 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -88,7 +88,8 @@ #{:session-id :password :old-password - :token}) + :token + :client-secret}) (defn extract-utm-params "Extracts additional data from params and namespace them under diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index c99451d6df..1f54c47121 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -14,7 +14,8 @@ [app.common.schema :as sm] [app.common.schema.generators :as sg] [app.common.time :as ct] - [app.common.types.organization :as cto] + [app.common.types.organization :as cto + :refer [schema:nitrate-sso]] [app.common.uri :as u] [app.config :as cf] [app.http.client :as http] @@ -430,17 +431,6 @@ [:permissions [:map-of :keyword :string]]] params)) -(def ^:private schema:nitrate-sso - [:map - [:organization-id ::sm/uuid] - [:active [:maybe :boolean]] - [:provider [:maybe :string]] - [:client-id [:maybe :string]] - [:base-url [:maybe :string]] - [:client-secret [:maybe :string]] - [:issuer [:maybe :string]] - [:scopes [:maybe [::sm/set ::sm/text]]]]) - (defn- get-org-sso-api "Fetches the SSO configuration for an organization from Nitrate." [cfg {:keys [organization-id] :as params}] diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 2103452c17..71a2e93b17 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -24,7 +24,6 @@ [app.rpc.nitrate.emails-helper :as neh] [app.rpc.nitrate.organization-helper :as noh] [app.rpc.notifications :as notifications] - [app.tokens :as tokens] [app.util.services :as sv])) @@ -647,17 +646,11 @@ {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)] (if authorized {:authorized true} - (if-let [issuer (or (:issuer sso) (:base-url sso))] - (let [oidc-provider (oidc/prepare-org-sso-provider cfg sso) - org-id (or organization-id (:organization-id sso)) - state-token (tokens/generate cfg {:iss "oidc" - :dest-url url - :organization-id org-id - :issuer issuer - :exp (ct/in-future "4h")}) - redirect-uri (oidc/build-auth-redirect-uri oidc-provider state-token)] - {:authorized false - :redirect-uri redirect-uri}) + (if (oidc/org-sso-discovery-uri sso) + {:authorized false + :redirect-uri (oidc/build-org-sso-auth-redirect-uri cfg sso + :dest-url url + :organization-id organization-id)} {:authorized false :redirect-uri nil}))) {:authorized true})) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 9168c0ea63..700cf59cac 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -8,11 +8,12 @@ "Internal Nitrate HTTP RPC API. Provides authenticated access to organization management and token validation endpoints." (:require + [app.auth.oidc :as oidc] [app.common.data :as d] [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar]] + [app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar schema:nitrate-sso]] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.team :refer [schema:team]] [app.config :as cf] @@ -878,6 +879,23 @@ RETURNING id, deleted_at;") photo-id (assoc :photo-url (files/resolve-public-uri photo-id)) owner-photo-id (assoc :owner-photo-url (files/resolve-public-uri owner-photo-id)))))))))))) +;; ---- API: check-organization-sso + +(def ^:private schema:check-organization-sso-result + [:map + [:valid ::sm/boolean]]) + +(sv/defmethod ::check-organization-sso + "Validate an organization SSO configuration by generating a login redirect URL. + Nitrate calls this while configuring SSO to verify client credentials and OIDC + discovery before saving the settings." + {::doc/added "2.20" + ::sm/params schema:nitrate-sso + ::sm/result schema:check-organization-sso-result + ::rpc/auth false} + [cfg params] + {:valid (oidc/is-organization-sso-config-valid? cfg params)}) + ;; ---- API: notify-org-sso-change (sv/defmethod ::notify-org-sso-change "Nitrate notifies that an organization sso values have changed" @@ -895,3 +913,4 @@ RETURNING id, deleted_at;") (when became-active (neh/send-organization-setup-sso-emails! cfg organization-id)) nil) + diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index bdf18e5541..7ec2f4bdaf 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -53,3 +53,20 @@ ;; not silently slip through as if it were the matching string. (t/is (= :auto (#'oidc/select-user-info-source :token))) (t/is (= :auto (#'oidc/select-user-info-source :userinfo))))) + +(t/deftest token-endpoint-errors-detect-valid-client-credentials + (let [response {:status 403 + :body "{\"error\":\"invalid_grant\",\"error_description\":\"Invalid authorization code\"}"}] + (t/is (#'oidc/token-endpoint-valid-client-error? response)) + (t/is (not (#'oidc/token-endpoint-invalid-client-error? response))))) + +(t/deftest token-endpoint-errors-detect-invalid-client-credentials + (t/is (#'oidc/token-endpoint-invalid-client-error? + {:status 401 + :body "{\"error\":\"access_denied\",\"error_description\":\"Unauthorized\"}"})) + (t/is (#'oidc/token-endpoint-invalid-client-error? + {:status 400 + :body "{\"error\":\"invalid_client\"}"})) + (t/is (not (#'oidc/token-endpoint-valid-client-error? + {:status 400 + :body "{\"error\":\"invalid_client\"}"})))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 460471e639..2691514952 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -6,6 +6,7 @@ (ns backend-tests.rpc-management-nitrate-test (:require + [app.auth.oidc :as oidc] [app.common.data :as d] [app.common.time :as ct] [app.common.uuid :as uuid] @@ -1277,3 +1278,40 @@ (with-redefs [eml/send! (fn [params] (swap! sent conj params))] (management-command-with-nitrate! params)) (t/is (empty? @sent)))) + +(t/deftest check-organization-sso-returns-valid-true + (let [org-id (uuid/random) + out (with-redefs [oidc/is-organization-sso-config-valid? (constantly true)] + (management-command-with-nitrate! + {::th/type :check-organization-sso + :organization-id org-id + :client-id "test-client" + :client-secret "test-secret" + :base-url "https://idp.example.com"}))] + (t/is (th/success? out)) + (t/is (true? (-> out :result :valid))))) + +(t/deftest check-organization-sso-returns-valid-false-on-invalid-config + (let [out (management-command-with-nitrate! + {::th/type :check-organization-sso + :organization-id (uuid/random) + :client-id "test-client" + :client-secret "test-secret"})] + (t/is (th/success? out)) + (t/is (false? (-> out :result :valid))))) + +(t/deftest check-organization-sso-uses-issuer-when-base-url-is-blank + (let [org-id (uuid/random) + out (with-redefs [oidc/is-organization-sso-config-valid? + (fn [_cfg sso] + (and (= "test-client" (:client-id sso)) + (= "https://idp.example.com/" (:issuer sso))))] + (management-command-with-nitrate! + {::th/type :check-organization-sso + :organization-id org-id + :client-id "test-client" + :client-secret "test-secret" + :base-url "" + :issuer "https://idp.example.com/"}))] + (t/is (th/success? out)) + (t/is (true? (-> out :result :valid))))) diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index ca13e7de5b..16c5ab22aa 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -63,3 +63,14 @@ [:logo [:maybe ::sm/uri]] [:avatar-bg-url [:maybe ::sm/uri]] [:sso-active {:optional true} [:maybe :boolean]]]) + +(def schema:nitrate-sso + [:map {:title "NitrateOrganizationSso"} + [:organization-id ::sm/uuid] + [:active {:optional true} [:maybe :boolean]] + [:provider {:optional true} [:maybe :string]] + [:client-id {:optional true} [:maybe :string]] + [:base-url {:optional true} [:maybe :string]] + [:client-secret {:optional true} [:maybe :string]] + [:issuer {:optional true} [:maybe :string]] + [:scopes {:optional true} [:maybe [::sm/set ::sm/text]]]]) From 8e9fb919598287ed4df4791cf977c950fe81d87d Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Fri, 26 Jun 2026 11:38:51 +0200 Subject: [PATCH 019/100] :bug: Fix view mode is not persisted in color picker (#10369) --- frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs index a304a2c27c..0de95be8ff 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs @@ -195,7 +195,7 @@ selected (deref selected*) layout (mf/deref refs/workspace-layout) - view-mode* (mf/use-state :grid) + view-mode* (h/use-persisted-state ::view-mode :grid) view-mode (deref view-mode*) file-id (mf/use-ctx ctx/current-file-id) From 10147b6abd83c7d1ed06d366221bdd0787c34f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Fri, 26 Jun 2026 11:53:11 +0200 Subject: [PATCH 020/100] :bug: Fix pixel grid and board pixel grid shown on top of rulers (#10430) * :bug: Fix pixel grid shown on top of rulers * :bug: Fix board pixel grid being rendered above rulers --- .../app/main/ui/workspace/viewport/frame_grid.cljs | 8 ++++++-- .../src/app/main/ui/workspace/viewport/rulers.cljs | 13 +++++++++++++ .../src/app/main/ui/workspace/viewport/widgets.cljs | 6 +++++- .../src/app/main/ui/workspace/viewport_wasm.cljs | 7 +++++-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs index 7c77a4351f..53b9eefe56 100644 --- a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs @@ -15,6 +15,7 @@ [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] [app.main.refs :as refs] + [app.main.ui.workspace.viewport.rulers :as rulers] [rumext.v2 :as mf])) (mf/defc square-grid* [{:keys [frame zoom grid]}] @@ -165,12 +166,15 @@ (mf/defc frame-grid* {::mf/wrap [mf/memo]} - [{:keys [zoom transform selected focus]}] + [{:keys [zoom transform selected focus vbox clip-rulers] :or {clip-rulers false}}] (let [frames (->> (mf/deref refs/workspace-frames) (filter has-grid?)) transforming (when (some? transform) selected)] - [:g.grid-display {:style {:pointer-events "none"}} + [:g.grid-display {:style {:pointer-events "none"} + :clip-path (when clip-rulers "url(#clip-frame-grid)")} + (when clip-rulers + [:> rulers/rulers-clip-path* {:id "clip-frame-grid" :vbox vbox :zoom zoom}]) (for [frame frames] (when (and #_(not (is-transform? frame)) (not (ctst/rotated-frame? frame)) diff --git a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs index 89c71a8afc..4a8c82cd61 100644 --- a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs @@ -35,6 +35,19 @@ ;; RULERS ;; ---------------- +(mf/defc rulers-clip-path* + "Defines a clip path (referenced by `id`) that excludes the ruler bars from the + given `vbox`. Used to keep SVG overlays from painting over the rulers that are + drawn by the wasm render engine on the canvas." + [{:keys [id vbox zoom]}] + (let [ruler-size (/ ruler-area-size zoom)] + [:defs + [:clipPath {:id id} + [:rect {:x (+ (:x vbox) ruler-size) + :y (+ (:y vbox) ruler-size) + :width (max 0 (- (:width vbox) ruler-size)) + :height (max 0 (- (:height vbox) ruler-size))}]]])) + (defn- calculate-step-size [zoom] (cond diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index 744f9abf3f..5521d5bbd6 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -23,6 +23,7 @@ [app.main.ui.context :as ctx] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks :as hooks] + [app.main.ui.workspace.viewport.rulers :as rulers] [app.main.ui.workspace.viewport.utils :as vwu] [app.util.debug :as dbg] [app.util.dom :as dom] @@ -32,7 +33,7 @@ [rumext.v2 :as mf])) (mf/defc pixel-grid* - [{:keys [vbox zoom]}] + [{:keys [vbox zoom clip-rulers] :or {clip-rulers false}}] (let [page (mf/deref refs/workspace-page) custom-color (:pixel-grid-color page) custom-alpha (:pixel-grid-opacity page) @@ -57,11 +58,14 @@ :stroke stroke :stroke-opacity opacity :stroke-width (str (/ 1 zoom))}}]]] + (when clip-rulers + [:> rulers/rulers-clip-path* {:id "clip-pixel-grid" :vbox vbox :zoom zoom}]) [:rect {:x (:x vbox) :y (:y vbox) :width (:width vbox) :height (:height vbox) :fill (str "url(#pixel-grid)") + :clip-path (when clip-rulers "url(#clip-pixel-grid)") :style {:pointer-events "none"}}]])) (mf/defc cursor-tooltip* diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 61f5025f81..285c43e223 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -851,11 +851,14 @@ {:zoom zoom :selected selected :transform transform - :focus focus}]) + :focus focus + :vbox vbox + :clip-rulers show-rulers?}]) (when show-pixel-grid? [:> widgets/pixel-grid* {:vbox vbox - :zoom zoom}]) + :zoom zoom + :clip-rulers show-rulers?}]) (when show-snap-points? [:> snap-points/snap-points* From 6a79383082e80b914db4a4cb80c764fe09aab3cf Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Fri, 26 Jun 2026 14:10:41 +0200 Subject: [PATCH 021/100] :bug: Blur info doesn't show on inspect in certain shapes (#10427) * :bug: Blur info doesn't show on inspect in certain shapes * :tada: Add test --- .../playwright/ui/specs/inspect-tab.spec.js | 47 +++++++++++++++++ .../src/app/main/ui/inspect/attributes.cljs | 28 +++++------ .../app/main/ui/inspect/attributes/blur.cljs | 50 +++++++++++++------ .../main/ui/inspect/attributes/geometry.cljs | 2 +- .../main/ui/inspect/attributes/layout.cljs | 2 +- .../ui/inspect/attributes/layout_element.cljs | 2 +- .../main/ui/inspect/attributes/shadow.cljs | 3 +- .../app/main/ui/inspect/attributes/svg.cljs | 2 +- .../app/main/ui/inspect/attributes/text.cljs | 2 +- frontend/src/app/main/ui/inspect/styles.cljs | 3 +- .../main/ui/inspect/styles/panels/blur.cljs | 24 +++++---- .../app/util/code_gen/style_css_formats.cljs | 2 +- 12 files changed, 118 insertions(+), 49 deletions(-) diff --git a/frontend/playwright/ui/specs/inspect-tab.spec.js b/frontend/playwright/ui/specs/inspect-tab.spec.js index 064ae644b2..1b1e6362c7 100644 --- a/frontend/playwright/ui/specs/inspect-tab.spec.js +++ b/frontend/playwright/ui/specs/inspect-tab.spec.js @@ -284,6 +284,53 @@ test.describe("Inspect tab - Styles", () => { await setupFile(workspacePage); await selectLayer(workspacePage, shapeToLayerName.blur); + + await openInspectTab(workspacePage); + + const panel = await getPanelByTitle(workspacePage, "Blur"); + await expect(panel).toBeVisible(); + + const propertyRow = panel.getByTestId("property-row"); + const propertyRowCount = await propertyRow.count(); + + expect(propertyRowCount).toBeGreaterThanOrEqual(1); + }); + + test("Shape - Blur on not svg compatible shape", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await setupFile(workspacePage); + + await selectLayer(workspacePage, shapeToLayerName.blur); + + await workspacePage.page.getByTestId("add-stroke").click(); + await workspacePage.page.getByTestId("stroke.alignment").click(); + await workspacePage.page.getByRole("option", { name: "Center" }).click(); + + await openInspectTab(workspacePage); + + const panel = await getPanelByTitle(workspacePage, "Blur"); + await expect(panel).toBeVisible(); + + const propertyRow = panel.getByTestId("property-row"); + const propertyRowCount = await propertyRow.count(); + + expect(propertyRowCount).toBeGreaterThanOrEqual(1); + }); + + test("Shape - Background Blur on not svg compatible shape", async ({ + page, + }) => { + const workspacePage = new WasmWorkspacePage(page); + await setupFile(workspacePage); + + await selectLayer(workspacePage, shapeToLayerName.blur); + + await workspacePage.page.getByRole('combobox', { name: 'Blur type select' }).click(); + await workspacePage.page.getByRole('option', { name: 'Background blur' }).click(); + await workspacePage.page.getByTestId("add-stroke").click(); + await workspacePage.page.getByTestId("stroke.alignment").click(); + await workspacePage.page.getByRole("option", { name: "Center" }).click(); + await openInspectTab(workspacePage); const panel = await getPanelByTitle(workspacePage, "Blur"); diff --git a/frontend/src/app/main/ui/inspect/attributes.cljs b/frontend/src/app/main/ui/inspect/attributes.cljs index 0223bb8d60..7535709328 100644 --- a/frontend/src/app/main/ui/inspect/attributes.cljs +++ b/frontend/src/app/main/ui/inspect/attributes.cljs @@ -12,15 +12,15 @@ [app.common.types.components-list :as ctkl] [app.main.ui.hooks :as hooks] [app.main.ui.inspect.annotation :refer [annotation*]] - [app.main.ui.inspect.attributes.blur :refer [blur-panel]] + [app.main.ui.inspect.attributes.blur :refer [blur-panel*]] [app.main.ui.inspect.attributes.fill :refer [fill-panel*]] - [app.main.ui.inspect.attributes.geometry :refer [geometry-panel]] - [app.main.ui.inspect.attributes.layout :refer [layout-panel]] - [app.main.ui.inspect.attributes.layout-element :refer [layout-element-panel]] - [app.main.ui.inspect.attributes.shadow :refer [shadow-panel]] + [app.main.ui.inspect.attributes.geometry :refer [geometry-panel*]] + [app.main.ui.inspect.attributes.layout :refer [layout-panel*]] + [app.main.ui.inspect.attributes.layout-element :refer [layout-element-panel*]] + [app.main.ui.inspect.attributes.shadow :refer [shadow-panel*]] [app.main.ui.inspect.attributes.stroke :refer [stroke-panel*]] - [app.main.ui.inspect.attributes.svg :refer [svg-panel]] - [app.main.ui.inspect.attributes.text :refer [text-panel]] + [app.main.ui.inspect.attributes.svg :refer [svg-panel*]] + [app.main.ui.inspect.attributes.text :refer [text-panel*]] [app.main.ui.inspect.attributes.variant :refer [variant-panel*]] [app.main.ui.inspect.attributes.visibility :refer [visibility-panel*]] [app.main.ui.inspect.exports :refer [exports]] @@ -61,16 +61,16 @@ :workspace-element-options (= from :workspace))} (for [[idx option] (map-indexed vector options)] [:> (case option - :geometry geometry-panel - :layout layout-panel - :layout-element layout-element-panel + :geometry geometry-panel* + :layout layout-panel* + :layout-element layout-element-panel* :fill fill-panel* :stroke stroke-panel* - :shadow shadow-panel - :blur blur-panel + :shadow shadow-panel* + :blur blur-panel* :visibility visibility-panel* - :text text-panel - :svg svg-panel + :text text-panel* + :svg svg-panel* :variant variant-panel*) {:key idx :shapes shapes diff --git a/frontend/src/app/main/ui/inspect/attributes/blur.cljs b/frontend/src/app/main/ui/inspect/attributes/blur.cljs index 821df98814..ba50e6be32 100644 --- a/frontend/src/app/main/ui/inspect/attributes/blur.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/blur.cljs @@ -13,6 +13,7 @@ [app.main.ui.components.copy-button :refer [copy-button*]] [app.main.ui.components.title-bar :refer [inspect-title-bar*]] [app.util.code-gen.style-css :as css] + [app.util.code-gen.style-css-formats :refer [format-blur]] [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) @@ -20,8 +21,8 @@ (or (:blur shape) (:background-blur shape))) -(mf/defc blur-panel - [{:keys [objects shapes]}] +(mf/defc blur-panel* + [{:keys [shapes]}] (let [render-wasm? (features/use-feature "render-wasm/v1") bg-blur? (and render-wasm? (contains? cf/flags :background-blur)) @@ -36,34 +37,51 @@ :class (stl/css :title-wrapper) :title-class (stl/css :blur-attr-title)} (when (= (count shapes) 1) - (let [background-blur (:background-blur (first shapes)) - layer-blur (:blur (first shapes))] + (let [layer-blur (:blur (first shapes)) + blur-property :filter + blue-value-raw (get-in (first shapes) [:blur :value]) + blur-property-value (css/format-css-property [blur-property blue-value-raw] {}) + + background-blur (:background-blur (first shapes)) + background-blur-property :backdrop-filter + background-blur-value-raw (get-in (first shapes) [:background-blur :value]) + background-blur-property-value (css/format-css-property [background-blur-property background-blur-value-raw] {})] (when background-blur - [:> copy-button* {:data (css/get-css-property objects (first shapes) :backdrop-filter) + [:> copy-button* {:data (dm/str background-blur-property-value) :class (stl/css :copy-btn-title)}]) (when layer-blur - [:> copy-button* {:data (css/get-css-property objects (first shapes) :filter) + [:> copy-button* {:data (dm/str blur-property-value) :class (stl/css :copy-btn-title)}])))] [:div {:class (stl/css :attributes-content)} (for [shape shapes] - (let [background-blur (:background-blur (first shapes)) - layer-blur (:blur (first shapes))] - [:div {:class (stl/css :blur-row) - :key (dm/str "block-" (:id shape) "-blur")} + (let [layer-blur (:blur (first shapes)) + blur-property :filter + blue-value-raw (get-in (first shapes) [:blur :value]) + blur-property-value (css/format-css-property [blur-property blue-value-raw] {}) + blur-value-detail (format-blur blue-value-raw) + + background-blur (:background-blur (first shapes)) + background-blur-property :backdrop-filter + background-blur-value-raw (get-in (first shapes) [:background-blur :value]) + background-blur-property-value (css/format-css-property [background-blur-property background-blur-value-raw] {}) + background-blur-value-detail (format-blur background-blur-value-raw)] + [:* (when background-blur - [:div {:key (dm/str "block-" (:id shape) "-background-blur")} + [:div {:class (stl/css :blur-row) + :key (dm/str "block-" (:id shape) "-background-blur")} [:div {:class (stl/css :global/attr-label)} "Backdrop Filter"] [:div {:class (stl/css :global/attr-value)} - [:> copy-button* {:data (css/get-css-property objects shape :backdrop-filter)} + [:> copy-button* {:data (dm/str background-blur-property-value)} [:div {:class (stl/css :button-children)} - (css/get-css-value objects shape :backdrop-filter)]]]]) + (dm/str background-blur-value-detail)]]]]) (when layer-blur - [:div {:key (dm/str "block-" (:id shape) "-layer-blur")} + [:div {:class (stl/css :blur-row) + :key (dm/str "block-" (:id shape) "-layer-blur")} [:div {:class (stl/css :global/attr-label)} "Filter"] [:div {:class (stl/css :global/attr-value)} - [:> copy-button* {:data (css/get-css-property objects shape :filter)} + [:> copy-button* {:data (dm/str blur-property-value)} [:div {:class (stl/css :button-children)} - (css/get-css-value objects shape :filter)]]]])]))]]))) + (dm/str blur-value-detail)]]]])]))]]))) diff --git a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs index 03abd63f59..dbfa79a52c 100644 --- a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs @@ -39,7 +39,7 @@ [:div {:class (stl/css :button-children)} value]]]])))]) -(mf/defc geometry-panel +(mf/defc geometry-panel* [{:keys [objects shapes]}] [:div {:class (stl/css :attributes-block)} [:> inspect-title-bar* diff --git a/frontend/src/app/main/ui/inspect/attributes/layout.cljs b/frontend/src/app/main/ui/inspect/attributes/layout.cljs index b82102de43..8c2d227f6f 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/layout.cljs @@ -49,7 +49,7 @@ [:> copy-button* {:data (css/get-css-property objects shape property)} [:div {:class (stl/css :button-children)} value]]]])))) -(mf/defc layout-panel +(mf/defc layout-panel* [{:keys [objects shapes]}] (let [shapes (->> shapes (filter ctl/any-layout?))] diff --git a/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs b/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs index 87b3cb1d0c..11b904d1a7 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs @@ -44,7 +44,7 @@ [:> copy-button* {:data (css/get-css-property objects shape property)} [:div {:class (stl/css :button-children)} value]]]])))) -(mf/defc layout-element-panel +(mf/defc layout-element-panel* [{:keys [objects shapes]}] (let [shapes (->> shapes (filter #(ctl/any-layout-immediate-child? objects %))) only-flex? (every? #(ctl/flex-layout-immediate-child? objects %) shapes) diff --git a/frontend/src/app/main/ui/inspect/attributes/shadow.cljs b/frontend/src/app/main/ui/inspect/attributes/shadow.cljs index 140f9de5c7..0f092d9b6c 100644 --- a/frontend/src/app/main/ui/inspect/attributes/shadow.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/shadow.cljs @@ -56,7 +56,8 @@ :copy-data (copy-color-data (:color shadow) color-format*) :on-change-format on-change-format}]])) -(mf/defc shadow-panel [{:keys [shapes]}] +(mf/defc shadow-panel* + [{:keys [shapes]}] (let [shapes (->> shapes (filter has-shadow?))] (when (and (seq shapes) (> (count shapes) 0)) diff --git a/frontend/src/app/main/ui/inspect/attributes/svg.cljs b/frontend/src/app/main/ui/inspect/attributes/svg.cljs index 11222a5c47..5ccb3dacee 100644 --- a/frontend/src/app/main/ui/inspect/attributes/svg.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/svg.cljs @@ -47,7 +47,7 @@ (for [[attr-key attr-value] (:svg-attrs shape)] [:& svg-attr {:attr attr-key :value attr-value :key (str/join "svg-block-key-" (d/name attr-key))}])]) -(mf/defc svg-panel +(mf/defc svg-panel* [{:keys [shapes]}] (let [shape (first shapes)] (when (seq (:svg-attrs shape)) diff --git a/frontend/src/app/main/ui/inspect/attributes/text.cljs b/frontend/src/app/main/ui/inspect/attributes/text.cljs index 02462229ad..a8565fde26 100644 --- a/frontend/src/app/main/ui/inspect/attributes/text.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/text.cljs @@ -151,7 +151,7 @@ :style full-style :text text}]))) -(mf/defc text-panel +(mf/defc text-panel* [{:keys [shapes]}] (when-let [shapes (seq (filter has-text? shapes))] [:div {:class (stl/css :attributes-block)} diff --git a/frontend/src/app/main/ui/inspect/styles.cljs b/frontend/src/app/main/ui/inspect/styles.cljs index 7b8238e40c..df61a66778 100644 --- a/frontend/src/app/main/ui/inspect/styles.cljs +++ b/frontend/src/app/main/ui/inspect/styles.cljs @@ -246,8 +246,7 @@ (let [shapes (->> shapes (filter has-blur?))] (when (seq shapes) [:> style-box* {:panel :blur} - [:> blur-panel* {:shapes shapes - :objects objects}]])) + [:> blur-panel* {:shapes shapes}]])) ;; TEXT PANEL :text (let [shapes (filter has-text? shapes)] diff --git a/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs b/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs index b67c466d88..6f089cf256 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs @@ -11,31 +11,35 @@ [app.main.ui.inspect.attributes.common :as cmm] [app.main.ui.inspect.styles.rows.properties-row :refer [properties-row*]] [app.util.code-gen.style-css :as css] + [app.util.code-gen.style-css-formats :refer [format-blur]] [rumext.v2 :as mf])) (mf/defc blur-panel* - [{:keys [shapes objects]}] + [{:keys [shapes]}] [:div {:class (stl/css :blur-panel)} (for [shape shapes] [:div {:key (:id shape) :class (stl/css :blur-shape)} (let [blur-property :filter - blur-value (css/get-css-value objects shape blur-property) - background-blur-property :backdrop-filter + blue-value-raw (get-in shape [:blur :value]) + blur-value-detail (format-blur blue-value-raw) blur-property-name (cmm/get-css-rule-humanized blur-property) - blur-property-value (css/get-css-property objects shape blur-property) - background-blur-value (css/get-css-value objects shape background-blur-property) + blur-property-value (css/format-css-property [blur-property blue-value-raw] {}) + + background-blur-property :backdrop-filter + background-blur-value-raw (get-in shape [:background-blur :value]) + background-blur-value-detail (format-blur background-blur-value-raw) background-blur-property-name (cmm/get-css-rule-humanized background-blur-property) - background-blur-property-value (css/get-css-property objects shape background-blur-property)] + background-blur-property-value (css/format-css-property [background-blur-property background-blur-value-raw] {})] [:div - (when blur-property-value + (when blue-value-raw [:> properties-row* {:key (dm/str "blur-property-" blur-property) :term blur-property-name - :detail (dm/str blur-value) + :detail blur-value-detail :property blur-property-value :copiable true}]) - (when background-blur-property-value + (when background-blur-value-raw [:> properties-row* {:key (dm/str "blur-property-" background-blur-property) :term background-blur-property-name - :detail (dm/str background-blur-value) + :detail background-blur-value-detail :property background-blur-property-value :copiable true}])])])]) diff --git a/frontend/src/app/util/code_gen/style_css_formats.cljs b/frontend/src/app/util/code_gen/style_css_formats.cljs index 9a650f7528..85e305fa4d 100644 --- a/frontend/src/app/util/code_gen/style_css_formats.cljs +++ b/frontend/src/app/util/code_gen/style_css_formats.cljs @@ -174,7 +174,7 @@ (map #(format-shadow->css % options)) (str/join ", "))) -(defn- format-blur +(defn format-blur [value] (dm/fmt "blur(%)" (fmt/format-pixels value))) From 44e39a1008f4aa46d5cca44e7e7f9271653412aa Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 26 Jun 2026 14:24:44 +0200 Subject: [PATCH 022/100] :bug: Sync WASM viewport when locating board in grid layout editor (#10443) --- .../src/app/main/data/workspace/grid_layout/editor.cljs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/data/workspace/grid_layout/editor.cljs b/frontend/src/app/main/data/workspace/grid_layout/editor.cljs index f73502a2e4..07fb2c093b 100644 --- a/frontend/src/app/main/data/workspace/grid_layout/editor.cljs +++ b/frontend/src/app/main/data/workspace/grid_layout/editor.cljs @@ -10,6 +10,7 @@ [app.common.geom.rect :as grc] [app.common.types.shape.layout :as ctl] [app.main.data.helpers :as dsh] + [app.main.data.workspace.viewport-wasm :as dwvw] [potok.v2.core :as ptk])) (defn hover-grid-cell @@ -104,7 +105,11 @@ y (+ y (/ height 2) (- (/ (:height vport) 2 zoom))) srect (grc/make-rect x y width height)] (-> local - (update :vbox merge (select-keys srect [:x :y :x1 :x2 :y1 :y2]))))))))))) + (update :vbox merge (select-keys srect [:x :y :x1 :x2 :y1 :y2]))))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn select-track-cells [grid-id type index] From 736a22ab1dd19406c791010c31bd6e766cd0e8c9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 26 Jun 2026 14:19:11 +0200 Subject: [PATCH 023/100] :books: Add paren-repair script for automatic parentheses repair --- .serena/memories/backend/core.md | 4 + .serena/memories/critical-info.md | 6 + .serena/memories/frontend/core.md | 5 + .../frontend/handling-errors-and-debugging.md | 6 + .serena/memories/tools/paren-repair.md | 29 ++ tools/paren-repair.bb | 361 ++++++++++++++++++ 6 files changed, 411 insertions(+) create mode 100644 .serena/memories/tools/paren-repair.md create mode 100755 tools/paren-repair.bb diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index b7a9199965..34a272dedc 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -92,6 +92,10 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. * **Linting:** `pnpm run lint` from the repository root. * **Formatting:** `pnpm run check-fmt`. Use `pnpm run fmt` to fix. Avoid unrelated whitespace diffs. +**Before linting:** if delimiter errors are suspected (after LLM edits), run +`tools/paren-repair.bb` on the affected files first. Delimiter errors produce +misleading linter/compiler output. See `mem:tools/paren-repair`. + ## Testing IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 496bcf6f2e..c6f57cb8bd 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -14,6 +14,9 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool. - Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps. *After making changes, run the applicable lint and format checks for the affected module before considering the work done (per example `mem:backend/core` or `mem:frontend/core`). +- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files, + fix them with `tools/paren-repair.bb` BEFORE running lint/format checks. + See `mem:tools/paren-repair` for usage. - Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra. # Project modules @@ -39,6 +42,9 @@ module. You can read it from `mem:/core` When working on devenv startup, compose layout, instance config (`defaults.env`), tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s `*-devenv` commands, read `mem:devenv/core`. +- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval), + `paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), and + `taiga.py` / `gh.py` (issue management helpers). - `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it. - `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it. diff --git a/.serena/memories/frontend/core.md b/.serena/memories/frontend/core.md index 7e0638ece2..7edf80bbe1 100644 --- a/.serena/memories/frontend/core.md +++ b/.serena/memories/frontend/core.md @@ -26,6 +26,11 @@ From `frontend/`: - Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`. - Translation formatting after i18n edits: `pnpm run translations`. +**Before linting:** if delimiter errors are suspected (after LLM edits, or +lint/compiler reports syntax errors), run `tools/paren-repair.bb` on the +affected files first. Delimiter errors produce misleading linter output. +See `mem:tools/paren-repair`. + ## Focused memory routing UI and packages: diff --git a/.serena/memories/frontend/handling-errors-and-debugging.md b/.serena/memories/frontend/handling-errors-and-debugging.md index 29c7929112..ede8c1581b 100644 --- a/.serena/memories/frontend/handling-errors-and-debugging.md +++ b/.serena/memories/frontend/handling-errors-and-debugging.md @@ -10,6 +10,12 @@ You have access to two tools for finding errors in Clojure source code (which yo The latter is needed because syntax errors in parentheses give an uninformative compiler error, and the second tool can often find the exact location of such errors. +When delimiter errors are detected (typically from lint or compiler output), +fix the affected files with `tools/paren-repair.bb`. The `clj_check_parentheses` +MCP tool can also pinpoint the error location when available, but it is not +required — standard build errors are usually enough. +See `mem:tools/paren-repair`. + ## Runtime patching with `set!` Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching. diff --git a/.serena/memories/tools/paren-repair.md b/.serena/memories/tools/paren-repair.md new file mode 100644 index 0000000000..e3817ca839 --- /dev/null +++ b/.serena/memories/tools/paren-repair.md @@ -0,0 +1,29 @@ +# Paren-Repair + +`tools/paren-repair.bb` fixes mismatched parentheses, brackets, and braces in +Clojure/ClojureScript files, then reformats them with cljfmt. + +## When to use + +- After LLM edits introduce broken delimiters — proactively run it on files + you just touched. +- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax + errors mentioning mismatched/unclosed delimiters, reader errors, or + unexpected EOF. +- Before running lint/format checks — delimiter errors make linter output + misleading. Fix them first, then lint. + +## How to use + +```bash +# File mode (in-place fix + format) +bb tools/paren-repair.bb path/to/file.clj + +# Pipe mode (stdin → fixed code to stdout) +echo '(def x 1' | bb tools/paren-repair.bb + +# Help +bb tools/paren-repair.bb --help +``` + +`bb` must be invoked from the repo root so the path `tools/paren-repair.bb` resolves. diff --git a/tools/paren-repair.bb b/tools/paren-repair.bb new file mode 100755 index 0000000000..aba0ee26ac --- /dev/null +++ b/tools/paren-repair.bb @@ -0,0 +1,361 @@ +#!/usr/bin/env bb + +;; ── Dependencies (resolved once, cached forever by Babashka) ── +(babashka.deps/add-deps + '{:deps {dev.weavejester/cljfmt {:mvn/version "0.15.5"} + parinferish/parinferish {:mvn/version "0.8.0"}}}) + +(ns paren-repair + "Standalone CLI tool for fixing delimiter errors and formatting Clojure files. + + Single-file consolidation of: + clojure-mcp-light.delimiter-repair (detection + repair engine) + clojure-mcp-light.hook (file detection + combine repair+format) + clojure-mcp-light.paren-repair (CLI / main entry point) + + Stripped: stats logging, timbre, tools.cli, apply-patch, tmp sessions. + Includes a fix for the process-stdin destructuring bug in the original." + + (:require [babashka.fs :as fs] + [cheshire.core :as json] + [cljfmt.core :as cljfmt] + [cljfmt.main] + [clojure.java.io :as io] + [clojure.java.shell :as shell] + [clojure.string :as string] + [edamame.core :as e] + [parinferish.core :as parinferish])) + +;; ═══════════════════════════════════════════════════════════════════════════════ +;; Section 1: Delimiter Detection & Repair +;; (from delimiter_repair.clj — stats calls removed) +;; ═══════════════════════════════════════════════════════════════════════════════ + +(def ^:dynamic *signal-on-bad-parse* + "When true, non-delimiter parse errors still trigger parinfer as a safety net. + Running parinfer on valid code is generally benign." + true) + +(defn delimiter-error? + "Returns true if the string has a delimiter error specifically. + Checks that it's an :edamame/error with :edamame/opened-delimiter info. + Uses :all true to enable all standard Clojure reader features: + function literals, regex, quotes, syntax-quote, deref, var, etc. + Also enables :read-cond :allow to support reader conditionals. + Handles unknown data readers gracefully with a default reader fn." + [s] + (try + (e/parse-string-all s {:all true + :features #{:bb :clj :cljs :cljr :default} + :read-cond :allow + :readers (fn [_tag] (fn [data] data)) + :auto-resolve name}) + false ;; No error = no delimiter error + (catch clojure.lang.ExceptionInfo ex + (let [data (ex-data ex)] + (and (= :edamame/error (:type data)) + (contains? data :edamame/opened-delimiter)))) + (catch Exception _ + ;; Non-edamame parse error — run parinfer as a safety net + ;; (parinfer on valid code is generally benign) + *signal-on-bad-parse*))) + +(defn actual-delimiter-error? + "Like delimiter-error? but never falls back to parinfer on unknown parse errors." + [s] + (binding [*signal-on-bad-parse* false] + (delimiter-error? s))) + +(defn parinferish-repair + "Attempts to repair delimiter errors using parinferish (pure Clojure). + Returns a map with :success, :text, and :error." + [s] + (try + (let [repaired (parinferish/flatten + (parinferish/parse s {:mode :indent}))] + {:success true + :text repaired + :error nil}) + (catch Exception e + {:success false + :error (.getMessage e)}))) + +(def parinfer-rust-available? + "Check if parinfer-rust binary is available on PATH. Result is memoized." + (memoize + (fn [] + (try + (let [result (shell/sh "which" "parinfer-rust")] + (zero? (:exit result))) + (catch Exception _ + false))))) + +(defn parinfer-repair + "Attempts to repair delimiter errors using parinfer-rust (external binary). + Returns a map with :success, :text, and :error. + Uses JSON output format from parinfer-rust." + [s] + (let [result (shell/sh "parinfer-rust" + "--mode" "indent" + "--language" "clojure" + "--output-format" "json" + :in s) + exit-code (:exit result)] + (if (zero? exit-code) + (try + (json/parse-string (:out result) true) + (catch Exception _ + {:success false})) + {:success false}))) + +(defn repair-delimiters + "Unified delimiter repair: prefers parinfer-rust when available, + falls back to parinferish (pure Clojure). + Returns a map with :success, :text, and :error." + [s] + (if (parinfer-rust-available?) + (parinfer-repair s) + (parinferish-repair s))) + +(defn fix-delimiters + "Takes a Clojure string and attempts to fix delimiter errors. + Returns the repaired string if successful, or nil if unfixable. + If no delimiter errors exist, returns the original string unchanged." + [s] + (if (delimiter-error? s) + (let [{:keys [text success]} (repair-delimiters s)] + (when (and success text (not (delimiter-error? text))) + text)) + s)) + +;; ═══════════════════════════════════════════════════════════════════════════════ +;; Section 2: File Processing +;; (from hook.clj — stripped of stats, timbre, backup/restore, hook dispatch) +;; ═══════════════════════════════════════════════════════════════════════════════ + +(def ^:dynamic *enable-cljfmt* + "When true, files are reformatted with cljfmt after delimiter repair." + false) + +(defn- babashka-shebang? + "Checks if a file starts with a Babashka shebang line." + [file-path] + (when (fs/exists? file-path) + (try + (with-open [r (io/reader file-path)] + (let [line (-> r line-seq first)] + (and line + (re-matches #"^#!/[^\s]+/(bb|env\s{1,3}bb)(\s.*)?$" line)))) + (catch Exception _ false)))) + +(defn clojure-file? + "Checks if a file path has a Clojure-related extension or Babashka shebang. + Supported extensions: .clj .cljs .cljc .cljd .bb .edn .lpy + Also detects files with a Babashka shebang (#!/.../bb)." + [file-path] + (when file-path + (let [lower-path (string/lower-case file-path)] + (or (string/ends-with? lower-path ".clj") + (string/ends-with? lower-path ".cljs") + (string/ends-with? lower-path ".cljc") + (string/ends-with? lower-path ".cljd") + (string/ends-with? lower-path ".bb") + (string/ends-with? lower-path ".lpy") + (string/ends-with? lower-path ".edn") + (babashka-shebang? file-path))))) + +(defn run-cljfmt + "Check if file needs formatting (via cljfmt.core), then reformat in-place + (via cljfmt.main to respect user cljfmt config). Returns true if file was + reformatted, false otherwise." + [file-path] + (when *enable-cljfmt* + (try + (let [original (slurp file-path :encoding "UTF-8") + formatted (cljfmt/reformat-string original)] + (if (not= original formatted) + (do + (cljfmt.main/-main "fix" file-path) + true) + false)) + (catch Exception _ + false)))) + +(defn fix-and-format-file! + "Core logic for fixing delimiters and (optionally) formatting a Clojure file + in-place. Returns a map with :success, :delimiter-fixed, :formatted, and + :message." + [file-path enable-cljfmt] + (try + (let [file-content (slurp file-path :encoding "UTF-8") + has-delimiter-error? (delimiter-error? file-content)] + (if has-delimiter-error? + ;; Has delimiter error — try to fix + (if-let [fixed-content (fix-delimiters file-content)] + (do + (spit file-path fixed-content :encoding "UTF-8") + (let [formatted? (binding [*enable-cljfmt* enable-cljfmt] + (run-cljfmt file-path))] + {:success true + :delimiter-fixed true + :formatted (boolean formatted?) + :message "Delimiter errors fixed and formatted"})) + {:success false + :delimiter-fixed false + :formatted false + :message "Could not fix delimiter errors"}) + ;; No delimiter error — just format if enabled + (let [formatted? (binding [*enable-cljfmt* enable-cljfmt] + (run-cljfmt file-path))] + {:success true + :delimiter-fixed false + :formatted (boolean formatted?) + :message (if formatted? "Formatted" "No changes needed")}))) + (catch Exception e + {:success false + :delimiter-fixed false + :formatted false + :message (str "Error: " (.getMessage e))}))) + +;; ═══════════════════════════════════════════════════════════════════════════════ +;; Section 3: CLI +;; (from paren_repair.clj — with process-stdin bug fix, no timbre) +;; ═══════════════════════════════════════════════════════════════════════════════ + +(defn has-stdin-data? + "Check if stdin has data available (not a TTY). + Returns true if stdin is ready to be read (e.g., piped input or heredoc)." + [] + (try + (.ready *in*) + (catch Exception _ false))) + +(defn process-stdin + "Process code from stdin: fix delimiters and format. + Outputs result to stdout. + Returns a map with :success and :changed." + [] + (let [input (slurp *in*) + fixed (fix-delimiters input)] + (if fixed + ;; fix-delimiters succeeded (or no errors) — format and print + (let [formatted (try + (cljfmt/reformat-string fixed) + (catch Exception _ + fixed)) + changed? (not= input formatted)] + (print formatted) + (flush) + {:success true + :changed changed?}) + ;; fix-delimiters returned nil (unfixable) + (do + (binding [*out* *err*] + (println "Error: Could not fix delimiter errors")) + {:success false + :changed false})))) + +(defn process-file + "Process a single file: fix delimiters and format in-place. + Returns a map with :success, :file-path, :message, :delimiter-fixed, + and :formatted." + [file-path] + (cond + (not (fs/exists? file-path)) + {:success false + :file-path file-path + :message "File does not exist" + :delimiter-fixed false + :formatted false} + + (not (clojure-file? file-path)) + {:success false + :file-path file-path + :message "Not a Clojure file (skipping)" + :delimiter-fixed false + :formatted false} + + :else + (assoc (fix-and-format-file! file-path true) + :file-path file-path))) + +(defn show-help [] + (println "Usage: paren-repair [FILE ...]") + (println " echo CODE | paren-repair") + (println " paren-repair <<'EOF' ... EOF") + (println) + (println "Fix delimiter errors and format Clojure code.") + (println) + (println "When no files are provided, reads from stdin and writes to stdout.") + (println "If no changes are needed, echoes the input unchanged.") + (println) + (println "Options:") + (println " -h, --help Show this help message")) + +(defn -main [& args] + (let [show-help? (some #{"--help" "-h"} args) + file-args (remove #{"--help" "-h"} args)] + + (cond + ;; Help requested + show-help? + (do + (show-help) + (System/exit 0)) + + ;; No file args — check for stdin + (empty? file-args) + (if (has-stdin-data?) + ;; Stdin mode: read, process, output to stdout + (let [result (process-stdin)] + (System/exit (if (:success result) 0 1))) + ;; No stdin and no files — show help + (do + (show-help) + (System/exit 1))) + + ;; File mode + :else + (try + (let [results (doall (map process-file file-args)) + successes (filter :success results) + failures (filter (complement :success) results) + success-count (count successes) + failure-count (count failures)] + + ;; Print results + (println) + (println "paren-repair Results") + (println "========================") + (println) + + (doseq [{:keys [file-path message delimiter-fixed formatted]} results] + (let [tags (when (or delimiter-fixed formatted) + (str " [" + (string/join ", " + (filter some? + [(when delimiter-fixed "delimiter-fixed") + (when formatted "formatted")])) + "]"))] + (println (str " " file-path ": " message tags)))) + + (println) + (println "Summary:") + (println " Success:" success-count) + (println " Failed: " failure-count) + (println) + + (if (zero? failure-count) + (System/exit 0) + (System/exit 1))) + (catch Exception e + (binding [*out* *err*] + (println "Fatal error:" (.getMessage e))) + (System/exit 1)))))) + +;; ═══════════════════════════════════════════════════════════════════════════════ +;; Entry point — only run -main when executed directly (not loaded as lib) +;; ═══════════════════════════════════════════════════════════════════════════════ + +(when (= *file* (System/getProperty "babashka.file")) + (apply -main *command-line-args*)) From 925dca35ab6d48d2fdd54c7b8b666580966a415d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 26 Jun 2026 14:28:49 +0200 Subject: [PATCH 024/100] :books: Update common testing doc on .serena --- .serena/memories/common/core.md | 2 +- .../common/{test-setup.md => testing.md} | 35 ++++++++++--------- 2 files changed, 20 insertions(+), 17 deletions(-) rename .serena/memories/common/{test-setup.md => testing.md} (60%) diff --git a/.serena/memories/common/core.md b/.serena/memories/common/core.md index a74939d58c..474fb8d87e 100644 --- a/.serena/memories/common/core.md +++ b/.serena/memories/common/core.md @@ -48,7 +48,7 @@ Components, variants, and debugging: Text and tests: - Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`. -- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/test-setup`. +- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`. ## Areas without focused memories diff --git a/.serena/memories/common/test-setup.md b/.serena/memories/common/testing.md similarity index 60% rename from .serena/memories/common/test-setup.md rename to .serena/memories/common/testing.md index 32f87710e9..bfd126d26f 100644 --- a/.serena/memories/common/test-setup.md +++ b/.serena/memories/common/testing.md @@ -1,24 +1,27 @@ -# Common Module Test Setup +# Common Testing and Verification `common/` is CLJC shared code. Tests should cover the relevant runtime(s): JVM for backend/common logic and JS for frontend/exporter behavior. For geometry, component, and file-model changes, JVM tests are common and fast, but JS/browser behavior can differ when WASM modifier math or CLJS-specific state is involved. -## Running tests +## Unit tests + +Common tests live under `common/test/common_tests/` and use `clojure.test`. +They are CLJC and run on both JVM and JS. From `common/`: +- Full JVM test run: `clojure -M:dev:test` +- Full JS test run: `pnpm run test:quiet` +- Focus a JVM test namespace: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test` +- Focus a JVM test var: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch` +- Focus a JS test namespace: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test` +- Focus a JS test var: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute` +- Quiet logging during a JS run: append `--log-level warn` (or `trace|debug|info|warn|error`) +- Build JS test target only: `pnpm run build:test` +- After `pnpm run build:test`, direct compiled runner: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn` +- Watch tests: `pnpm run watch:test` -```bash -pnpm run test:jvm -clojure -M:dev:test -pnpm run test:jvm --focus common-tests.logic.variants-switch-test -clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch -pnpm run test:js -pnpm run test:quiet -pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test -pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn -pnpm run watch:test -``` - -Use `test:quiet` for non-interactive JS runs; it buffers `build:test` output and forwards runner args. Common JS runner args support `--focus ` and `--log-level trace|debug|info|warn|error`. After `pnpm run build:test`, direct compiled runner focus is faster: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn`. New common JS test namespaces must be required/listed in `common_tests/runner.cljc`; new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags compose as a union. +New common JS test namespaces must be required/listed in `common_tests/runner.cljc`; +new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags +compose as a union. ## Test helpers @@ -45,4 +48,4 @@ For geometry-sensitive tests, read `mem:common/geometry-invariants` before posit ## Debugging -Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes. \ No newline at end of file +Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes. From 0dee6e3cb06ed37abd6511a5efa9fc0e8b34af3a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 26 Jun 2026 14:32:32 +0200 Subject: [PATCH 025/100] :books: Add let binding algnment info to serena --- .serena/memories/critical-info.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index c6f57cb8bd..23881c6f89 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -14,6 +14,8 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool. - Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps. *After making changes, run the applicable lint and format checks for the affected module before considering the work done (per example `mem:backend/core` or `mem:frontend/core`). +- Align `let` binding values: when a `let` form has multiple bindings spanning + several lines, align the value forms to the same column with spaces. - If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files, fix them with `tools/paren-repair.bb` BEFORE running lint/format checks. See `mem:tools/paren-repair` for usage. From e9c0982a94329a7fe349518a4759073535e05a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Fri, 26 Jun 2026 15:04:52 +0200 Subject: [PATCH 026/100] :sparkles: Disable hot swap of render engines (#10444) --- frontend/src/app/main/data/workspace.cljs | 9 ++--- .../app/main/data/workspace/libraries.cljs | 6 +-- .../src/app/main/ui/workspace/main_menu.cljs | 37 ++++++------------- 3 files changed, 17 insertions(+), 35 deletions(-) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 39b28fbdc0..8716b1a26b 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -72,7 +72,6 @@ [app.main.refs :as refs] [app.main.repo :as rp] [app.main.router :as rt] - [app.main.store :as st] [app.plugins.register :as preg] [app.render-wasm :as wasm] [app.render-wasm.api :as wasm.api] @@ -352,10 +351,8 @@ (let [stoper-s (rx/filter (ptk/type? ::finalize-workspace) stream) rparams (rt/get-params state) features (features/get-enabled-features state team-id) - ;; since render-wasm/v1 can be hot-toggled by the user, we need to query it - ;; from the state with active-feature? - render-wasm-enabled? #(features/active-feature? @st/state "render-wasm/v1") - render-wasm-ready? #(and (render-wasm-enabled?) + render-wasm-enabled? (features/active-feature? state "render-wasm/v1") + render-wasm-ready? #(and render-wasm-enabled? wasm-state/context-initialized? (not @wasm-state/context-lost?))] @@ -368,7 +365,7 @@ (rx/concat ;; Fetch all essential data that should be loaded before the file (rx/merge - (if ^boolean (render-wasm-enabled?) + (if ^boolean render-wasm-enabled? (->> (rx/from @wasm/module) (rx/filter true?) (rx/tap (fn [_] diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index a3a93cf7cc..667f0b2b61 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -1379,7 +1379,7 @@ [] (ptk/reify ::watch-component-changes ptk/WatchEvent - (watch [_ _ stream] + (watch [_ state stream] (let [stopper-s (->> stream (rx/map ptk/type) @@ -1449,7 +1449,7 @@ (rx/tap #(log/trc :hint "buffer initialized")))] (when (or (contains? cf/flags :component-thumbnails) - (features/active-feature? @st/state "render-wasm/v1")) + (features/active-feature? state "render-wasm/v1")) (->> (rx/merge changes-s @@ -1457,7 +1457,7 @@ ;; change so single edits (fill, etc.) update instantly. ;; Non-WASM persists on every render, so it stays on the ;; debounced path below to avoid per-edit backend posts. - (if (features/active-feature? @st/state "render-wasm/v1") + (if (features/active-feature? state "render-wasm/v1") (->> changes-s (rx/filter (ptk/type? ::component-changed)) (rx/map deref) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 51ca68d105..394864c22a 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -959,32 +959,17 @@ ev-name (if (= next-renderer :wasm) "enable-webgl-rendering" "disable-webgl-rendering")] - - (if (cf/external-feature-flag "renderer-hard-reload" "test") - ;; Bare RPC + hard reload: skips `du/update-profile-props`, so - ;; `features/recompute-features` is not run here; bootstrap - ;; after reload resolves render-wasm/v1 from the saved profile. - (do - (->> (rx/zip - (rp/cmd! :update-profile-props {:props {:renderer next-renderer}}) - (rx/filter (ptk/type? ::ev/chunk-persisted) st/stream)) - (rx/timeout 2000 (rx/of :timeout)) - (rx/subs! (fn [_] - (dom/reload-current-window true)) - (fn [_] - (st/emit! (ntf/error (tr "errors.generic")))))) - (st/emit! (ev/event {::ev/name ev-name - ::ev/origin "workspace:menu"}) - (ptk/data-event ::ev/force-persist {}))) - - ;; `update-profile-props` WatchEvent calls - ;; `features/recompute-features`. - (st/emit! (ev/event {::ev/name ev-name - ::ev/origin "workspace:menu"}) - (du/update-profile-props {:renderer next-renderer}) - (ntf/success (tr (if (= next-renderer :wasm) - "webgl.toast.webgl-render-enabled" - "webgl.toast.webgl-render-disabled")))))))) + (->> (rx/zip + (rp/cmd! :update-profile-props {:props {:renderer next-renderer}}) + (rx/filter (ptk/type? ::ev/chunk-persisted) st/stream)) + (rx/timeout 2000 (rx/of :timeout)) + (rx/subs! (fn [_] + (dom/reload-current-window true)) + (fn [_] + (st/emit! (ntf/error (tr "errors.generic")))))) + (st/emit! (ev/event {::ev/name ev-name + ::ev/origin "workspace:menu"}) + (ptk/data-event ::ev/force-persist {}))))) open-plugins-manager (mf/use-fn From a47b0122c733efde17900897ffc73d74afa6226b Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Mon, 29 Jun 2026 09:24:37 +0200 Subject: [PATCH 027/100] :bug: Fix problem with measures menu (#10445) --- .../sidebar/options/menus/measures.cljs | 15 ++++-- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/measures_menu_props_test.cljs | 51 +++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/measures_menu_props_test.cljs diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index d3c6eb1320..8334acbebb 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -48,7 +48,12 @@ :selrect :points :show-content - :hide-in-viewer]) + :hide-in-viewer + + ;; Needed to disable/enable width/height + ;; otherwise the memo will not detect changes + :layout-item-h-sizing + :layout-item-v-sizing]) (def ^:private generic-options #{:size :position :rotation}) @@ -130,7 +135,7 @@ acc)))) acc))))) -(defn- check-measures-menu-props +(defn check-measures-menu-props [old-props new-props] (let [o-values (unchecked-get old-props "values") n-values (unchecked-get new-props "values")] @@ -150,10 +155,12 @@ (get n-values :hide-in-viewer)) (identical? (get o-values :width) (get n-values :width)) - (identical? (get o-values :width) - (get n-values :width)) (identical? (get o-values :height) (get n-values :height)) + (identical? (get o-values :layout-item-h-sizing) + (get n-values :layout-item-h-sizing)) + (identical? (get o-values :layout-item-v-sizing) + (get n-values :layout-item-v-sizing)) (identical? (get o-values :points) (get n-values :points)) (identical? (get o-values :selrect) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index cbe7af9ebf..4dc39abc18 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -45,6 +45,7 @@ [frontend-tests.tokens.token-errors-test] [frontend-tests.tokens.workspace-tokens-remap-test] [frontend-tests.ui.ds-controls-numeric-input-test] + [frontend-tests.ui.measures-menu-props-test] [frontend-tests.util-object-test] [frontend-tests.util-range-tree-test] [frontend-tests.util-simple-math-test] @@ -104,6 +105,7 @@ 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.workspace-tokens-remap-test 'frontend-tests.ui.ds-controls-numeric-input-test + 'frontend-tests.ui.measures-menu-props-test 'frontend-tests.render-wasm.process-objects-test 'frontend-tests.util-object-test 'frontend-tests.util-range-tree-test diff --git a/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs b/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs new file mode 100644 index 0000000000..8214572f59 --- /dev/null +++ b/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs @@ -0,0 +1,51 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.ui.measures-menu-props-test + (:require + [app.main.ui.workspace.sidebar.options.menus.measures :refer [check-measures-menu-props]] + [cljs.test :as t :include-macros true])) + +;; Shared, identical-by-reference props so the comparator only reacts to the +;; `values` differences we are testing. +(def ^:private ids #js ["id-1"]) +(def ^:private shape-type :rect) +(def ^:private tokens #js {}) + +(defn- props + [values] + #js {"ids" ids "type" shape-type "appliedTokens" tokens "values" values}) + +(def ^:private base-values + {:width 100 + :height 200 + :layout-item-h-sizing :fix + :layout-item-v-sizing :fix}) + +(t/deftest test-check-measures-menu-props + (t/testing "skips re-render when nothing relevant changed" + ;; Different map instances with identical scalar content must be treated + ;; as equal (returns true => memoized, no re-render). + (t/is (true? (check-measures-menu-props + (props base-values) + (props (into {} base-values)))))) + + (t/testing "re-renders when horizontal sizing changes but width does not" + ;; Regression test: toggling fix <-> auto without changing the width value + ;; must force a re-render so the width input enabled/disabled state updates. + (t/is (false? (check-measures-menu-props + (props base-values) + (props (assoc base-values :layout-item-h-sizing :auto)))))) + + (t/testing "re-renders when vertical sizing changes but height does not" + (t/is (false? (check-measures-menu-props + (props base-values) + (props (assoc base-values :layout-item-v-sizing :auto)))))) + + (t/testing "re-renders when width changes" + (t/is (false? (check-measures-menu-props + (props base-values) + (props (assoc base-values :width 150))))))) From a9f3949abc476c781c063673af34cfea8fab6d8c Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 29 Jun 2026 09:44:16 +0200 Subject: [PATCH 028/100] :sparkles: Avoid going to last team on login if it is protected by sso (#10442) --- frontend/src/app/main/data/auth.cljs | 16 ++++++++++------ frontend/src/app/main/data/team.cljs | 12 ++++++++++++ frontend/src/app/main/ui/routes.cljs | 19 +++++++++++++------ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/main/data/auth.cljs b/frontend/src/app/main/data/auth.cljs index ad60233006..2339c02452 100644 --- a/frontend/src/app/main/data/auth.cljs +++ b/frontend/src/app/main/data/auth.cljs @@ -53,12 +53,16 @@ :team-id (:default-team-id profile)) (dp/update-profile-props {:welcome-file-id nil})) - (let [teams (into #{} (map :id) teams) - team-id (dtm/get-last-team-id) - team-id (if (and team-id (contains? teams team-id)) - team-id - (:default-team-id profile))] - (rx/of (dcm/go-to-dashboard-recent {:team-id team-id})))))))] + (let [default-team-id (:default-team-id profile) + team-ids (into #{} (map :id) teams) + team-id (dtm/get-last-team-id) + team-id (if (and team-id (contains? team-ids team-id)) + team-id + default-team-id)] + (->> (dtm/resolve-login-team-id {:team-id team-id + :default-team-id default-team-id}) + (rx/mapcat (fn [team-id] + (rx/of (dcm/go-to-dashboard-recent {:team-id team-id}))))))))))] (ptk/reify ::logged-in ptk/WatchEvent diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index a83add6a99..ede9ca26ce 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -36,6 +36,18 @@ [] (::current-team-id storage/global)) +(defn resolve-login-team-id + "Resolve the team to navigate to after login. Falls back to the + default team when the candidate requires SSO and the user has no + valid SSO session for it." + [{:keys [team-id default-team-id]}] + (if (or (not (contains? cf/flags :nitrate)) + (= team-id default-team-id)) + (rx/of team-id) + (->> (rp/cmd! :check-nitrate-sso {:team-id team-id :url (rt/get-current-href)}) + (rx/map (fn [{:keys [authorized]}] + (if authorized team-id default-team-id)))))) + (defn teams-fetched [teams] (ptk/reify ::teams-fetched diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 2c62f5d1b4..b33c3b6782 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -156,12 +156,19 @@ (st/emit! (rt/nav :auth-login))) empty-path? - (let [team-id (dtm/get-last-team-id)] - (if (contains? teams team-id) - (st/emit! (rt/nav :dashboard-recent - (assoc query-params :team-id team-id))) - (st/emit! (rt/nav :dashboard-recent - (assoc query-params :team-id (:default-team-id profile)))))) + (let [default-team-id (:default-team-id profile) + last-team-id (dtm/get-last-team-id) + team-id (if (contains? teams last-team-id) + last-team-id + default-team-id)] + (->> (dtm/resolve-login-team-id {:team-id team-id + :default-team-id default-team-id}) + (rx/subs! + (fn [team-id] + (st/emit! (rt/nav :dashboard-recent + (assoc query-params :team-id team-id)))) + (fn [cause] + (errors/on-error cause))))) :else (st/emit! (rt/assign-exception {:type :not-found})))) From 2d4a24bf97420e4281f1c68b85e99820a351b0fe Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 29 Jun 2026 10:34:44 +0200 Subject: [PATCH 029/100] :bug: Fix stroke to path extra points (#10190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix stroke to path extra points * :bug: Set evenodd when needed on stroke to path (#10446) --------- Co-authored-by: Elena Torró --- .../data/workspace/path/shapes_to_path.cljs | 22 ++++++----- frontend/src/app/render_wasm/api.cljs | 18 +++++---- render-wasm/src/mem.rs | 20 ++++++++++ render-wasm/src/shapes/paths.rs | 15 +++++++- render-wasm/src/shapes/stroke_paths.rs | 37 +++++++++---------- render-wasm/src/wasm/paths.rs | 4 +- 6 files changed, 76 insertions(+), 40 deletions(-) diff --git a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs index 61134948cd..6c55397514 100644 --- a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs +++ b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs @@ -104,17 +104,19 @@ (into [] (keep-indexed (fn [idx stroke] - (let [content (wasm.api/stroke-to-path (:id shape) idx)] - (when (some? content) + (let [result (wasm.api/stroke-to-path (:id shape) idx)] + (when (some? result) (cts/setup-shape - {:type :path - :id (uuid/next) - :name (str (:name shape) " (stroke)") - :parent-id parent-id - :frame-id frame-id - :content content - :fills [(stroke->fill stroke)] - :strokes []}))))) + (cond-> {:type :path + :id (uuid/next) + :name (str (:name shape) " (stroke)") + :parent-id parent-id + :frame-id frame-id + :content (:content result) + :fills [(stroke->fill stroke)] + :strokes []} + (:even-odd? result) + (assoc :svg-attrs {:fillRule "evenodd"}))))))) (:strokes shape))) (defn convert-selected-strokes-to-path diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index c824603756..d99324647c 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2360,21 +2360,25 @@ (defn stroke-to-path "Converts a shape's stroke at the given index into a filled path. - Returns the stroke outline as PathData content." + Returns a map {:content :even-odd? }, or nil when the + stroke produces no geometry. The buffer carries two header words ahead of + the segments: [even-odd flag][length] (the flat segment list can't encode + the fill rule itself)." [id stroke-index] (use-shape id) (try - (let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index) - (mem/->offset-32)) - heap (mem/get-heap-u32) - length (aget heap offset)] + (let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index) + (mem/->offset-32)) + heap (mem/get-heap-u32) + even-odd? (not (zero? (aget heap offset))) + length (aget heap (inc offset))] (if (pos? length) (let [data (mem/slice heap - (+ offset 1) + (+ offset 2) (* length path.impl/SEGMENT-U32-SIZE)) content (path/from-bytes data)] (mem/free) - content) + {:content content :even-odd? even-odd?}) (do (mem/free) nil))) (catch :default cause diff --git a/render-wasm/src/mem.rs b/render-wasm/src/mem.rs index 1c8531e7e9..a786f8eb55 100644 --- a/render-wasm/src/mem.rs +++ b/render-wasm/src/mem.rs @@ -95,3 +95,23 @@ pub fn write_vec(result: Vec) -> *mut u8 { write_bytes(result_bytes) } + +/* + Like `write_vec`, but prepends an extra u32 header word before the + length. Layout: [header u32][length u32][items...] +*/ +pub fn write_vec_with_header(header: u32, result: Vec) -> *mut u8 { + let elem_size = size_of::(); + let bytes_len = 8 + result.len() * elem_size; + let mut result_bytes = vec![0; bytes_len]; + + result_bytes[0..4].clone_from_slice(&header.to_le_bytes()); + result_bytes[4..8].clone_from_slice(&result.len().to_le_bytes()); + + for (i, item) in result.iter().enumerate() { + let base = 8 + i * elem_size; + item.clone_to_slice(&mut result_bytes[base..base + elem_size]); + } + + write_bytes(result_bytes) +} diff --git a/render-wasm/src/shapes/paths.rs b/render-wasm/src/shapes/paths.rs index 33666bb0de..94f7521c3e 100644 --- a/render-wasm/src/shapes/paths.rs +++ b/render-wasm/src/shapes/paths.rs @@ -156,6 +156,7 @@ impl Path { let mut current_point = 0; let mut current_conic = 0; let mut last_point = skia::Point::new(0.0, 0.0); + let mut subpath_start = skia::Point::new(0.0, 0.0); for verb in verbs { match verb { @@ -163,12 +164,15 @@ impl Path { let p = points[current_point]; segments.push(Segment::MoveTo((p.x, p.y))); last_point = p; + subpath_start = p; current_point += 1; } skia::PathVerb::Line => { let p = points[current_point]; - segments.push(Segment::LineTo((p.x, p.y))); - last_point = p; + if p != last_point { + segments.push(Segment::LineTo((p.x, p.y))); + last_point = p; + } current_point += 1; } skia::PathVerb::Quad => { @@ -239,6 +243,13 @@ impl Path { current_point += 3; } skia::PathVerb::Close => { + if let Some(Segment::LineTo(p)) = segments.last() { + if (p.0 - subpath_start.x).abs() < 1e-5 + && (p.1 - subpath_start.y).abs() < 1e-5 + { + segments.pop(); + } + } segments.push(Segment::Close); } } diff --git a/render-wasm/src/shapes/stroke_paths.rs b/render-wasm/src/shapes/stroke_paths.rs index 358be86141..cbf06874db 100644 --- a/render-wasm/src/shapes/stroke_paths.rs +++ b/render-wasm/src/shapes/stroke_paths.rs @@ -58,27 +58,24 @@ pub fn stroke_to_path( // For inner/outer strokes, use boolean ops to clip // the 2×-width stroke outline to the correct region. // Set EvenOdd to preserve the annular ring's inner hole, - // then as_winding() on the result fixes contour winding - // for Penpot's NonZero fill rule. + // then switch to Winding for Penpot's NonZero fill rule. + // Use set_fill_type instead of as_winding() because as_winding() + // decomposes self-intersecting geometry, which removes points + // at intersections of straight lines in closed paths. + // Center strokes skip the conversion: fill_path_with_paint + // already produces correctly-wound contours. let final_path = match render_kind { - StrokeKind::Inner => { - stroke_outline.set_fill_type(skia::PathFillType::EvenOdd); - let inner = stroke_outline - .op(&transformed_shape_path, skia::PathOp::Intersect) - .unwrap_or(stroke_outline); - inner.as_winding().unwrap_or(inner) - } - StrokeKind::Outer => { - stroke_outline.set_fill_type(skia::PathFillType::EvenOdd); - let outer = stroke_outline - .op(&transformed_shape_path, skia::PathOp::Difference) - .unwrap_or(stroke_outline); - outer.as_winding().unwrap_or(outer) - } - StrokeKind::Center => { - stroke_outline.set_fill_type(skia::PathFillType::EvenOdd); - stroke_outline.as_winding().unwrap_or(stroke_outline) - } + StrokeKind::Inner => stroke_outline + .simplify() + .unwrap() + .op(&transformed_shape_path, skia::PathOp::Intersect) + .unwrap_or(stroke_outline), + StrokeKind::Outer => stroke_outline + .simplify() + .unwrap() + .op(&transformed_shape_path, skia::PathOp::Difference) + .unwrap_or(stroke_outline), + StrokeKind::Center => stroke_outline.simplify().unwrap_or(stroke_outline), }; // If there was a path_transform, invert it back to local coords diff --git a/render-wasm/src/wasm/paths.rs b/render-wasm/src/wasm/paths.rs index 2350edb393..b035ee4f81 100644 --- a/render-wasm/src/wasm/paths.rs +++ b/render-wasm/src/wasm/paths.rs @@ -243,6 +243,7 @@ pub extern "C" fn current_to_path() -> *mut u8 { #[no_mangle] pub extern "C" fn convert_stroke_to_path(stroke_index: i32) -> *mut u8 { let mut result = Vec::::default(); + let mut even_odd = false; with_current_shape!(state, |shape: &Shape| { let idx = stroke_index as usize; if let Some(stroke) = shape.strokes.get(idx) { @@ -257,6 +258,7 @@ pub extern "C" fn convert_stroke_to_path(stroke_index: i32) -> *mut u8 { shape.svg_attrs.as_ref(), false, ) { + even_odd = path.is_even_odd(); result = path .segments() .iter() @@ -267,7 +269,7 @@ pub extern "C" fn convert_stroke_to_path(stroke_index: i32) -> *mut u8 { } }); - mem::write_vec(result) + mem::write_vec_with_header(even_odd as u32, result) } #[cfg(test)] From 99638fa60ca82075c7cf40db1b79e9daef6e13cc Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 29 Jun 2026 17:03:45 +0200 Subject: [PATCH 030/100] :bug: Fix wasm render playwright tests (#10462) * :bug: Fix playwright wasm test for updating canvas background * :bug: Fix playwright wasm test for rendering blurs * :bug: Fix invisible emoji texts in render-wasm playwright data --- .../data/render-wasm/get-file-blurs.json | 410 +++++++++++++++++- .../render-wasm/get-file-text-images.json | 147 ++++++- .../ui/render-wasm-specs/shapes.spec.js | 2 +- 3 files changed, 523 insertions(+), 36 deletions(-) diff --git a/frontend/playwright/data/render-wasm/get-file-blurs.json b/frontend/playwright/data/render-wasm/get-file-blurs.json index 1b7e094606..4861c63b29 100644 --- a/frontend/playwright/data/render-wasm/get-file-blurs.json +++ b/frontend/playwright/data/render-wasm/get-file-blurs.json @@ -389,7 +389,37 @@ "~:fills": [], "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 281.419982910156, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0px", + "~:x": 2111, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.239990234375, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae134c87eab3": { @@ -758,7 +788,37 @@ "~:fills": [], "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 281.419982910156, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2719, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.239990234375, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae1409412914": { @@ -1143,7 +1203,37 @@ "~:fills": [], "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 488.419982910156, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2707, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.239990234375, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae1488974651": { @@ -1293,7 +1383,37 @@ "~:fills": [], "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 111.419998168945, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2313, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.2399978637695, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae149cc8c2f1": { @@ -1432,7 +1552,32 @@ }, "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 201.419982910156, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2714, + "~:fills": [], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.239990234375, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae149cc8c2f0": { @@ -1571,7 +1716,32 @@ }, "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 201.419982910156, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2514, + "~:fills": [], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.239990234375, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae136831d5b7": { @@ -2116,7 +2286,37 @@ "~:fills": [], "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 281.419982910156, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2519, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.239990234375, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae1409412913": { @@ -2368,7 +2568,37 @@ "~:fills": [], "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 201.419982910156, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0px", + "~:x": 2106, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.239990234375, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae149fbf3112": { @@ -2525,7 +2755,37 @@ "~:fills": [], "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 281.419982910156, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2318, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.239990234375, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae1409412912": { @@ -4487,7 +4747,44 @@ }, "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 111.419998168945, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2714, + "~:fills": [ + { + "~:fill-opacity": 1, + "~:fill-image": { + "~:id": "~uaa0a383a-7553-808a-8006-ae13a3c575eb", + "~:width": 100, + "~:height": 100, + "~:mtype": "image/jpeg", + "~:keep-aspect-ratio": true, + "~:name": "sample" + } + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.2399978637695, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae136eba4581": { @@ -5550,7 +5847,32 @@ }, "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 201.419982910156, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2313, + "~:fills": [], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.239990234375, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae148b39a12f": { @@ -5700,7 +6022,37 @@ "~:fills": [], "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 111.419998168945, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2514, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.2399978637695, + "~:text": "HOLA" + } + ] } }, "~u13fc1849-119a-8028-8006-ae145f7fe46e": { @@ -6244,7 +6596,37 @@ }, "~:flip-x": null, "~:height": 63.999981880188, - "~:flip-y": null + "~:flip-y": null, + "~:position-data": [ + { + "~:y": 111.419998168945, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "72px", + "~:font-weight": "900", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 169.070068359375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 2106, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 93.2399978637695, + "~:text": "HOLA" + } + ] } } }, @@ -6258,4 +6640,4 @@ "~:base-font-size": "16px" } } -} \ No newline at end of file +} diff --git a/frontend/playwright/data/render-wasm/get-file-text-images.json b/frontend/playwright/data/render-wasm/get-file-text-images.json index e04435395a..6df32616a6 100644 --- a/frontend/playwright/data/render-wasm/get-file-text-images.json +++ b/frontend/playwright/data/render-wasm/get-file-text-images.json @@ -637,7 +637,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "🔥" }, @@ -654,7 +659,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "👩🏿\u200d🚀" }, @@ -671,7 +681,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "👺" }, @@ -688,7 +703,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "🚀" } @@ -706,7 +726,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro" }, { @@ -726,7 +751,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "" } @@ -744,7 +774,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro" } ] @@ -2395,7 +2430,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "🔥" }, @@ -2412,7 +2452,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "👩🏿\u200d🚀" }, @@ -2429,7 +2474,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "👺" }, @@ -2446,7 +2496,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "🚀" } @@ -2464,7 +2519,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro" }, { @@ -2484,7 +2544,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "" } @@ -2502,7 +2567,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro" } ] @@ -3433,7 +3503,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "🔥" }, @@ -3450,7 +3525,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "👩🏿\u200d🚀" }, @@ -3467,7 +3547,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "👺" }, @@ -3484,7 +3569,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "🚀" } @@ -3502,7 +3592,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro" }, { @@ -3522,7 +3617,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro", "~:text": "" } @@ -3540,7 +3640,12 @@ "~:font-variant-id": "regular", "~:text-decoration": "none", "~:letter-spacing": "0", - "~:fills": [], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], "~:font-family": "sourcesanspro" } ] diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 55158b9564..33304fe5f0 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -162,7 +162,7 @@ test("Updates canvas background", async ({ page }) => { const canvasBackgroundInput = workspace.page.getByRole("textbox", { name: "Color", - }); + }).first(); await canvasBackgroundInput.fill("FABADA"); await workspace.page.keyboard.press("Enter"); await workspace.waitForFirstRenderWithoutUI(); From f993f203bd3798ded54c5d2df9ff76d7c80d42f7 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Mon, 29 Jun 2026 17:32:15 +0200 Subject: [PATCH 031/100] :bug: Fix problems with plugins API (#10412) * :sparkles: Adds static dispatch safe stubs in tests * :bug: Fix shapesColors metadata key to match ColorShapeInfo * :bug: Fix CommentThread.remove rejecting the owner's own threads * :bug: Fix page.removeCommentThread throwing on a spurious Promise * :sparkles: Implement ShapeBase.swapComponent in the plugin API * :sparkles: Expose File.revn in the plugin API * :bug: Fix FileVersion.createdAt calling Luxon method on a js/Date * :bug: Fix plugin font/typography application to text and ranges * :bug: Default plugin overlay interaction position for non-manual types * :bug: Fix plugin interaction setters passing an id-only shape * :bug: Fix grid addColumnAtIndex rejecting valid track types * :bug: Expose libraryId on library color/typography/component proxies * :sparkles: Implement LibraryTypography.setFont in the plugin API * :bug: Fix typography.applyToTextRange reading unexposed range bounds * :bug: Fix utils.geometry.center argument mismatch * :bug: Fix localStorage.removeItem calling getItem * :bug: Fix shape backgroundBlur proxy key casing * :bug: Report boolean shape type as 'boolean' in the plugin API * :bug: Return the resulting paths from plugin flatten * :bug: Make plugin z-order methods act on the target shape * :bug: Make is-variant-container? return a boolean * :sparkles: Implement Group.isMask in the plugin API * :bug: Return a shape proxy from TextRange.shape * :bug: Return the duplicated set from TokenSet.duplicate * :bug: Fix theme addSet/removeSet reading set name with a keyword * :bug: Accept string fontFamilies token value in the plugin API * :bug: Fix combineAsVariants ignoring the passed component ids * :bug: Fix board removeRulerGuide ignoring its argument * :bug: Fix board guides setter schema and parser * :bug: Avoid 0-byte allocation when syncing empty grid tracks * :bug: Validate grid track indices in the plugin API * :bug: Return null for empty input in group() and centerShapes() * :bug: Return TokenTypographyValue[] from a typography token's resolvedValue * :bug: Return TokenShadowValue[] from a shadow token's resolvedValue * :bug: Return string[] from a fontFamilies token's resolvedValue * :bug: Clear mutually-exclusive reps when setting LibraryColor gradient/image * :bug: Add readonly tags to types, deprecate Image type * :books: Update plugins changelog --- common/src/app/common/types/component.cljc | 2 +- frontend/src/app/main/data/workspace.cljs | 72 ++++--- .../data/workspace/tokens/library_edit.cljs | 29 +-- frontend/src/app/plugins/api.cljs | 14 +- frontend/src/app/plugins/comments.cljs | 6 +- frontend/src/app/plugins/file.cljs | 5 +- frontend/src/app/plugins/fonts.cljs | 12 +- frontend/src/app/plugins/format.cljs | 3 +- frontend/src/app/plugins/grid.cljs | 94 +++++--- frontend/src/app/plugins/library.cljs | 37 +++- frontend/src/app/plugins/local_storage.cljs | 2 +- frontend/src/app/plugins/page.cljs | 3 +- frontend/src/app/plugins/parser.cljs | 6 +- frontend/src/app/plugins/public_utils.cljs | 9 +- frontend/src/app/plugins/shape.cljs | 49 ++++- frontend/src/app/plugins/text.cljs | 7 +- frontend/src/app/plugins/tokens.cljs | 103 ++++++++- frontend/src/app/render_wasm/api.cljs | 95 ++++---- .../test/frontend_tests/helpers/mock.cljc | 33 +++ .../frontend_tests/plugins/comments_test.cljs | 60 ++++++ .../frontend_tests/plugins/file_test.cljs | 21 ++ .../frontend_tests/plugins/format_test.cljs | 14 ++ .../frontend_tests/plugins/grid_test.cljs | 49 +++++ .../frontend_tests/plugins/library_test.cljs | 95 ++++++++ .../plugins/local_storage_test.cljs | 28 +++ .../frontend_tests/plugins/parser_test.cljs | 30 +++ .../plugins/shape_bugfixes_test.cljs | 202 ++++++++++++++++++ .../frontend_tests/plugins/text_test.cljs | 87 ++++++++ .../frontend_tests/plugins/tokens_test.cljs | 112 ++++++++++ .../render_wasm/process_objects_test.cljs | 20 ++ frontend/test/frontend_tests/runner.cljs | 19 +- plugins/CHANGELOG.md | 24 ++- plugins/libs/plugin-types/index.d.ts | 128 +++++------ render-wasm/src/wasm/layouts/grid.rs | 6 +- 34 files changed, 1226 insertions(+), 250 deletions(-) create mode 100644 frontend/test/frontend_tests/plugins/comments_test.cljs create mode 100644 frontend/test/frontend_tests/plugins/file_test.cljs create mode 100644 frontend/test/frontend_tests/plugins/grid_test.cljs create mode 100644 frontend/test/frontend_tests/plugins/library_test.cljs create mode 100644 frontend/test/frontend_tests/plugins/local_storage_test.cljs create mode 100644 frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs create mode 100644 frontend/test/frontend_tests/plugins/text_test.cljs diff --git a/common/src/app/common/types/component.cljc b/common/src/app/common/types/component.cljc index 46337ca813..37a090217b 100644 --- a/common/src/app/common/types/component.cljc +++ b/common/src/app/common/types/component.cljc @@ -249,7 +249,7 @@ (defn is-variant-container? "Check if this shape is a variant container" [shape] - (:is-variant-container shape)) + (boolean (:is-variant-container shape))) (defn set-touched-group [touched group] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 8716b1a26b..8304dc9f1f 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -772,44 +772,46 @@ #{:up :down :bottom :top}) (defn vertical-order-selected - [loc] - (dm/assert! - "expected valid location" - (contains? valid-vertical-locations loc)) - (ptk/reify ::vertical-order-selected - ptk/WatchEvent - (watch [it state _] - (let [page-id (:current-page-id state) - objects (dsh/lookup-page-objects state page-id) - selected-ids (dsh/lookup-selected state) - selected-shapes (map (d/getf objects) selected-ids) - undo-id (js/Symbol) + ([loc] + (vertical-order-selected loc nil)) + ([loc ids] + (dm/assert! + "expected valid location" + (contains? valid-vertical-locations loc)) + (ptk/reify ::vertical-order-selected + ptk/WatchEvent + (watch [it state _] + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + selected-ids (or ids (dsh/lookup-selected state)) + selected-shapes (map (d/getf objects) selected-ids) + undo-id (js/Symbol) - move-shape - (fn [changes shape] - (let [parent (get objects (:parent-id shape)) - sibling-ids (:shapes parent) - current-index (d/index-of sibling-ids (:id shape)) - index-in-selection (d/index-of selected-ids (:id shape)) - new-index (case loc - :top (count sibling-ids) - :down (max 0 (- current-index 1)) - :up (min (count sibling-ids) (+ (inc current-index) 1)) - :bottom index-in-selection)] - (pcb/change-parent changes - (:id parent) - [shape] - new-index))) + move-shape + (fn [changes shape] + (let [parent (get objects (:parent-id shape)) + sibling-ids (:shapes parent) + current-index (d/index-of sibling-ids (:id shape)) + index-in-selection (d/index-of selected-ids (:id shape)) + new-index (case loc + :top (count sibling-ids) + :down (max 0 (- current-index 1)) + :up (min (count sibling-ids) (+ (inc current-index) 1)) + :bottom index-in-selection)] + (pcb/change-parent changes + (:id parent) + [shape] + new-index))) - changes (reduce move-shape - (-> (pcb/empty-changes it page-id) - (pcb/with-objects objects)) - selected-shapes)] + changes (reduce move-shape + (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects)) + selected-shapes)] - (rx/of (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (ptk/data-event :layout/update {:ids selected-ids}) - (dwu/commit-undo-transaction undo-id)))))) + (rx/of (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (ptk/data-event :layout/update {:ids selected-ids}) + (dwu/commit-undo-transaction undo-id))))))) (defn set-shape-index [file-id page-id id new-index] diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index d8be9688ae..5ccf4c2a31 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -343,20 +343,23 @@ (dch/commit-changes changes)))))) (defn duplicate-token-set - [id] - (ptk/reify ::duplicate-token-set - ptk/WatchEvent - (watch [it state _] - (let [data (dsh/lookup-file-data state) - tokens-lib (get data :tokens-lib) - suffix (tr "workspace.tokens.duplicate-suffix")] + ([id] + (duplicate-token-set id nil)) + ([id {:keys [id-ref]}] + (ptk/reify ::duplicate-token-set + ptk/WatchEvent + (watch [it state _] + (let [data (dsh/lookup-file-data state) + tokens-lib (get data :tokens-lib) + suffix (tr "workspace.tokens.duplicate-suffix")] - (when-let [token-set (ctob/duplicate-set id tokens-lib {:suffix suffix})] - (let [changes (-> (pcb/empty-changes it) - (pcb/with-library-data data) - (pcb/set-token-set (ctob/get-id token-set) token-set))] - (rx/of (set-selected-token-set-id (ctob/get-id token-set)) - (dch/commit-changes changes)))))))) + (when-let [token-set (ctob/duplicate-set id tokens-lib {:suffix suffix})] + (when id-ref (reset! id-ref (ctob/get-id token-set))) + (let [changes (-> (pcb/empty-changes it) + (pcb/with-library-data data) + (pcb/set-token-set (ctob/get-id token-set) token-set))] + (rx/of (set-selected-token-set-id (ctob/get-id token-set)) + (dch/commit-changes changes))))))))) (defn set-enabled-token-set [name enabled?] diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index a9b1e4d323..3d3796a14e 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -317,6 +317,11 @@ (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (u/not-valid plugin-id :group-shapes shapes) + ;; A group cannot be created from no shapes; per the documented contract + ;; return null instead of a proxy pointing at a shape that never exists. + (zero? (alength shapes)) + nil + (some #(not (u/page-active? (obj/get % "$page"))) shapes) (u/not-valid plugin-id :group "Cannot modify a page that is not currently active") @@ -664,8 +669,13 @@ (u/not-valid plugin-id :flatten-shapes "Not valid shapes") :else - (let [ids (into #{} (map #(obj/get % "$id")) shapes)] - (st/emit! (dw/convert-selected-to-path ids))))) + ;; convert-selected-to-path converts the shapes in place (keeping their + ;; ids), so return proxies for the same ids, now resolving as paths. + (let [file-id (:current-file-id @st/state) + page-id (:current-page-id @st/state) + ids (mapv #(obj/get % "$id") shapes)] + (st/emit! (dw/convert-selected-to-path (into #{} ids))) + (apply array (map #(shape/shape-proxy plugin-id file-id page-id %) ids))))) :createVariantFromComponents (fn [shapes] diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs index 629f445c40..71e8a0311b 100644 --- a/frontend/src/app/plugins/comments.cljs +++ b/frontend/src/app/plugins/comments.cljs @@ -9,7 +9,6 @@ [app.common.geom.point :as gpt] [app.common.schema :as sm] [app.main.data.comments :as dc] - [app.main.data.helpers :as dsh] [app.main.data.workspace.comments :as dwc] [app.main.repo :as rp] [app.main.store :as st] @@ -203,13 +202,12 @@ :remove (fn [] - (let [profile (:profile @st/state) - owner (dsh/lookup-profile @st/state (:owner-id data))] + (let [profile (:profile @st/state)] (cond (not (r/check-permission plugin-id "comment:write")) (u/not-valid plugin-id :remove "Plugin doesn't have 'comment:write' permission") - (not= (:id profile) owner) + (not= (:id profile) (:owner-id data)) (u/not-valid plugin-id :remove "Cannot change content from another user's comments") :else diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 69e794d2d0..ac6ef089e5 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -64,7 +64,7 @@ (user/user-proxy plugin-id user-data)))} :createdAt - {:get #(.toJSDate ^js (:created-at @data))} + {:get #(:created-at @data)} :isAutosave {:get #(= "system" (:created-by @data))} @@ -136,6 +136,9 @@ :name {:get #(-> (u/locate-file id) :name)} + :revn + {:get #(-> (u/locate-file id) :revn)} + :pages {:this true :get #(.getPages ^js %)} diff --git a/frontend/src/app/plugins/fonts.cljs b/frontend/src/app/plugins/fonts.cljs index 306db90698..2ae009ce9d 100644 --- a/frontend/src/app/plugins/fonts.cljs +++ b/frontend/src/app/plugins/fonts.cljs @@ -64,13 +64,13 @@ (u/not-valid plugin-id :applyToText "Cannot modify a page that is not currently active") :else - (let [id (obj/get text "$id") + (let [text-id (obj/get text "$id") values {:font-id id :font-family family :font-style (d/nilv (obj/get variant "fontStyle") (:style default-variant)) :font-variant-id (d/nilv (obj/get variant "fontVariantId") (:id default-variant)) :font-weight (d/nilv (obj/get variant "fontWeight") (:weight default-variant))}] - (st/emit! (dwt/update-attrs id values))))) + (st/emit! (dwt/update-attrs text-id values))))) :applyToRange (fn [range variant] @@ -85,15 +85,15 @@ (u/not-valid plugin-id :applyToRange "Cannot modify a page that is not currently active") :else - (let [id (obj/get range "$id") - start (obj/get range "start") - end (obj/get range "end") + (let [range-id (obj/get range "$id") + start (obj/get range "$start") + end (obj/get range "$end") values {:font-id id :font-family family :font-style (d/nilv (obj/get variant "fontStyle") (:style default-variant)) :font-variant-id (d/nilv (obj/get variant "fontVariantId") (:id default-variant)) :font-weight (d/nilv (obj/get variant "fontWeight") (:weight default-variant))}] - (st/emit! (dwt/update-text-range id start end values))))))))) + (st/emit! (dwt/update-text-range range-id start end values))))))))) (defn fonts-subcontext [plugin-id] diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index 36137f1307..e920793434 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -47,6 +47,7 @@ :frame "board" :rect "rectangle" :circle "ellipse" + :bool "boolean" (d/name type))) ;;export type Bounds = { @@ -146,7 +147,7 @@ [[color attrs]] (let [shapes-info (apply array (map format-shape-info attrs)) color (format-color color)] - (obj/set! color "shapeInfo" shapes-info) + (obj/set! color "shapesInfo" shapes-info) color)) diff --git a/frontend/src/app/plugins/grid.cljs b/frontend/src/app/plugins/grid.cljs index 5582b80d0c..6964a034dd 100644 --- a/frontend/src/app/plugins/grid.cljs +++ b/frontend/src/app/plugins/grid.cljs @@ -301,11 +301,15 @@ :addRowAtIndex (fn [index type value] - (let [type (keyword type)] + (let [type (keyword type) + num-rows (-> (u/locate-shape file-id page-id id) :layout-grid-rows count)] (cond (not (sm/valid-safe-int? index)) (u/not-valid plugin-id :addRowAtIndex-index index) + (or (< index 0) (> index num-rows)) + (u/not-valid plugin-id :addRowAtIndex-index index) + (not (contains? ctl/grid-track-types type)) (u/not-valid plugin-id :addRowAtIndex-type type) @@ -344,64 +348,80 @@ :addColumnAtIndex (fn [index type value] - (cond - (not (sm/valid-safe-int? index)) - (u/not-valid plugin-id :addColumnAtIndex-index index) + (let [type (keyword type) + num-columns (-> (u/locate-shape file-id page-id id) :layout-grid-columns count)] + (cond + (not (sm/valid-safe-int? index)) + (u/not-valid plugin-id :addColumnAtIndex-index index) - (not (contains? ctl/grid-track-types type)) - (u/not-valid plugin-id :addColumnAtIndex-type type) + (or (< index 0) (> index num-columns)) + (u/not-valid plugin-id :addColumnAtIndex-index index) - (and (or (= :percent type) (= :flex type) (= :fixed type)) - (not (sm/valid-safe-number? value))) - (u/not-valid plugin-id :addColumnAtIndex-value value) + (not (contains? ctl/grid-track-types type)) + (u/not-valid plugin-id :addColumnAtIndex-type type) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :addColumnAtIndex "Plugin doesn't have 'content:write' permission") + (and (or (= :percent type) (= :flex type) (= :fixed type)) + (not (sm/valid-safe-number? value))) + (u/not-valid plugin-id :addColumnAtIndex-value value) - (not (u/page-active? page-id)) - (u/not-valid plugin-id :addColumnAtIndex "Cannot modify a page that is not currently active") + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addColumnAtIndex "Plugin doesn't have 'content:write' permission") - :else - (let [type (keyword type)] + (not (u/page-active? page-id)) + (u/not-valid plugin-id :addColumnAtIndex "Cannot modify a page that is not currently active") + + :else (st/emit! (dwsl/add-layout-track #{id} :column {:type type :value value} index))))) :removeRow (fn [index] - (cond - (not (sm/valid-safe-int? index)) - (u/not-valid plugin-id :removeRow index) + (let [num-rows (-> (u/locate-shape file-id page-id id) :layout-grid-rows count)] + (cond + (not (sm/valid-safe-int? index)) + (u/not-valid plugin-id :removeRow index) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :removeRow "Plugin doesn't have 'content:write' permission") + (or (< index 0) (>= index num-rows)) + (u/not-valid plugin-id :removeRow index) - (not (u/page-active? page-id)) - (u/not-valid plugin-id :removeRow "Cannot modify a page that is not currently active") + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :removeRow "Plugin doesn't have 'content:write' permission") - :else - (st/emit! (dwsl/remove-layout-track #{id} :row index)))) + (not (u/page-active? page-id)) + (u/not-valid plugin-id :removeRow "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwsl/remove-layout-track #{id} :row index))))) :removeColumn (fn [index] - (cond - (not (sm/valid-safe-int? index)) - (u/not-valid plugin-id :removeColumn index) + (let [num-columns (-> (u/locate-shape file-id page-id id) :layout-grid-columns count)] + (cond + (not (sm/valid-safe-int? index)) + (u/not-valid plugin-id :removeColumn index) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :removeColumn "Plugin doesn't have 'content:write' permission") + (or (< index 0) (>= index num-columns)) + (u/not-valid plugin-id :removeColumn index) - (not (u/page-active? page-id)) - (u/not-valid plugin-id :removeColumn "Cannot modify a page that is not currently active") + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :removeColumn "Plugin doesn't have 'content:write' permission") - :else - (st/emit! (dwsl/remove-layout-track #{id} :column index)))) + (not (u/page-active? page-id)) + (u/not-valid plugin-id :removeColumn "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwsl/remove-layout-track #{id} :column index))))) :setColumn (fn [index type value] - (let [type (keyword type)] + (let [type (keyword type) + num-columns (-> (u/locate-shape file-id page-id id) :layout-grid-columns count)] (cond (not (sm/valid-safe-int? index)) (u/not-valid plugin-id :setColumn-index index) + (or (< index 0) (>= index num-columns)) + (u/not-valid plugin-id :setColumn-index index) + (not (contains? ctl/grid-track-types type)) (u/not-valid plugin-id :setColumn-type type) @@ -420,11 +440,15 @@ :setRow (fn [index type value] - (let [type (keyword type)] + (let [type (keyword type) + num-rows (-> (u/locate-shape file-id page-id id) :layout-grid-rows count)] (cond (not (sm/valid-safe-int? index)) (u/not-valid plugin-id :setRow-index index) + (or (< index 0) (>= index num-rows)) + (u/not-valid plugin-id :setRow-index index) + (not (contains? ctl/grid-track-types type)) (u/not-valid plugin-id :setRow-type type) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 2e5b5c434c..dccce39b97 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -51,6 +51,7 @@ :$file {:enumerable false :get (constantly file-id)} :id {:get (fn [] (dm/str id))} + :libraryId {:get (fn [] (dm/str file-id))} :fileId {:get #(dm/str file-id)} :name @@ -101,7 +102,8 @@ :else (let [color (-> (u/proxy->library-color self) - (assoc :color value))] + (assoc :color value) + (dissoc :gradient :image))] (st/emit! (dwl/update-color-data color file-id)))))} :opacity @@ -136,7 +138,8 @@ :else (let [color (-> (u/proxy->library-color self) - (assoc :gradient value))] + (assoc :gradient value) + (dissoc :color :image))] (st/emit! (dwl/update-color-data color file-id))))))} :image @@ -154,7 +157,8 @@ :else (let [color (-> (u/proxy->library-color self) - (assoc :image value))] + (assoc :image value) + (dissoc :color :gradient))] (st/emit! (dwl/update-color-data color file-id))))))} :remove @@ -295,6 +299,7 @@ :$id {:enumerable false :get (constantly id)} :$file {:enumerable false :get (constantly file-id)} :id {:get (fn [] (dm/str id))} + :libraryId {:get (fn [] (dm/str file-id))} :name {:this true @@ -484,6 +489,27 @@ (assoc :text-transform value))] (st/emit! (dwl/update-typography typo file-id)))))} + :setFont + (fn [font variant] + (cond + (not (obj/type-of? font "FontProxy")) + (u/not-valid plugin-id :setFont font) + + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :setFont "Plugin doesn't have 'library:write' permission") + + :else + ;; When a variant is given read the variant-specific fields from it; + ;; otherwise the FontProxy exposes the font's default variant fields. + (let [source (if (obj/type-of? variant "FontVariantProxy") variant font) + typo (-> (u/locate-library-typography file-id id) + (assoc :font-id (obj/get font "fontId") + :font-family (obj/get font "fontFamily") + :font-variant-id (obj/get source "fontVariantId") + :font-style (obj/get source "fontStyle") + :font-weight (obj/get source "fontWeight")))] + (st/emit! (dwl/update-typography typo file-id))))) + :remove (fn [] (cond @@ -539,8 +565,8 @@ :else (let [shape-id (obj/get range "$id") - start (obj/get range "start") - end (obj/get range "end") + start (obj/get range "$start") + end (obj/get range "$end") typography (u/locate-library-typography file-id id) attrs (-> typography (assoc :typography-ref-file file-id) @@ -718,6 +744,7 @@ :$id {:enumerable false :get (constantly id)} :$file {:enumerable false :get (constantly file-id)} :id {:get (fn [] (dm/str id))} + :libraryId {:get (fn [] (dm/str file-id))} :name {:this true diff --git a/frontend/src/app/plugins/local_storage.cljs b/frontend/src/app/plugins/local_storage.cljs index 4a9a4967a8..1b24d520ed 100644 --- a/frontend/src/app/plugins/local_storage.cljs +++ b/frontend/src/app/plugins/local_storage.cljs @@ -60,7 +60,7 @@ (u/not-valid plugin-id :removeItem "The key must be a string") :else - (.getItem ^js local-storage (prefix-key plugin-id key)))) + (.removeItem ^js local-storage (prefix-key plugin-id key)))) :getKeys (fn [] diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index 27755844ad..9ab02e3b6c 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -412,8 +412,7 @@ (js/Promise. (fn [resolve] (let [thread-id (obj/get thread "$id")] - (js/Promise. - (st/emit! (dc/delete-comment-thread-on-workspace {:id thread-id} #(resolve))))))))) + (st/emit! (dc/delete-comment-thread-on-workspace {:id thread-id} #(resolve)))))))) :findCommentThreads (fn [criteria] diff --git a/frontend/src/app/plugins/parser.cljs b/frontend/src/app/plugins/parser.cljs index 3d6b481b50..0c3c2af32a 100644 --- a/frontend/src/app/plugins/parser.cljs +++ b/frontend/src/app/plugins/parser.cljs @@ -343,10 +343,10 @@ (when (some? guide) (case (obj/get guide "type") "column" - parse-frame-guide-column + (parse-frame-guide-column guide) "row" - parse-frame-guide-row + (parse-frame-guide-row guide) "square" (parse-frame-guide-square guide)))) @@ -489,7 +489,7 @@ :destination (-> (obj/get action "destination") (obj/get "$id")) :relative-to (-> (obj/get action "relativeTo") (obj/get "$id")) :overlay-pos-type (-> (obj/get action "position") parse-keyword) - :overlay-position (-> (obj/get action "manualPositionLocation") parse-point) + :overlay-position (-> (obj/get action "manualPositionLocation") parse-point (d/nilv (gpt/point 0 0))) :close-click-outside (obj/get action "closeWhenClickOutside") :background-overlay (obj/get action "addBackgroundOverlay") :animation (-> (obj/get action "animation") parse-animation)} diff --git a/frontend/src/app/plugins/public_utils.cljs b/frontend/src/app/plugins/public_utils.cljs index 1f7c92f8cd..3a303fe888 100644 --- a/frontend/src/app/plugins/public_utils.cljs +++ b/frontend/src/app/plugins/public_utils.cljs @@ -14,10 +14,15 @@ [app.plugins.utils :as u])) (defn ^:export centerShapes - [plugin-id shapes] + [shapes] (cond (not (every? shape/shape-proxy? shapes)) - (u/not-valid plugin-id :centerShapes shapes) + (u/not-valid nil :centerShapes shapes) + + ;; The documented contract returns null for an empty array; without this + ;; guard `shapes->rect` yields a non-rect and `rect->center` asserts. + (empty? shapes) + nil :else (let [shapes (->> shapes (map u/proxy->shape))] diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 882f8eca05..cccc32c625 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -101,7 +101,7 @@ :else (st/emit! (dwi/update-interaction - {:id shape-id} + (u/locate-shape file-id page-id shape-id) index #(assoc % :event-type value) {:page-id page-id})))))} @@ -117,7 +117,7 @@ :else (st/emit! (dwi/update-interaction - {:id shape-id} + (u/locate-shape file-id page-id shape-id) index #(assoc % :delay value) {:page-id page-id}))))} @@ -137,7 +137,7 @@ :else (st/emit! (dwi/update-interaction - {:id shape-id} + (u/locate-shape file-id page-id shape-id) index #(d/patch-object % params) {:page-id page-id})))))} @@ -592,7 +592,7 @@ :else (st/emit! (dwsh/update-shapes [id] #(assoc % :blur value)))))))} - :background-blur + :backgroundBlur {:this true :get #(-> % u/proxy->shape :background-blur format/format-blur) :set @@ -1249,6 +1249,11 @@ :else (st/emit! (dwg/unmask-group #{id}))))) + :isMask + (fn [] + (let [shape (u/locate-shape file-id page-id id)] + (boolean (cfh/mask-shape? shape)))) + ;; Only for path and bool shapes :toD (fn [] @@ -1315,19 +1320,19 @@ :bringForward (fn [] - (st/emit! (dw/vertical-order-selected :up))) + (st/emit! (dw/vertical-order-selected :up [id]))) :sendBackward (fn [] - (st/emit! (dw/vertical-order-selected :down))) + (st/emit! (dw/vertical-order-selected :down [id]))) :bringToFront (fn [] - (st/emit! (dw/vertical-order-selected :top))) + (st/emit! (dw/vertical-order-selected :top [id]))) :sendToBack (fn [] - (st/emit! (dw/vertical-order-selected :bottom))) + (st/emit! (dw/vertical-order-selected :bottom [id]))) ;; COMPONENTS :isComponentInstance @@ -1402,6 +1407,28 @@ :else (st/emit! (dwl/detach-component id)))) + :swapComponent + (fn [component] + (let [shape (u/locate-shape file-id page-id id)] + (cond + (not (u/page-active? page-id)) + (u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active") + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :swapComponent "Plugin doesn't have 'content:write' permission") + + (not (obj/type-of? component "LibraryComponentProxy")) + (u/not-valid plugin-id :swapComponent "Component not valid") + + (not (ctk/in-component-copy? shape)) + (u/not-valid plugin-id :swapComponent "The shape is not a component copy instance") + + :else + (st/emit! (dwl/component-swap shape + (obj/get component "$file") + (obj/get component "$id") + true))))) + ;; Export :export (fn [value] @@ -1536,7 +1563,7 @@ (rg/ruler-guide-proxy plugin-id file-id page-id ruler-id))))) :removeRulerGuide - (fn [_ value] + (fn [value] (cond (not (rg/ruler-guide-proxy? value)) (u/not-valid plugin-id :removeRulerGuide "Guide not provided") @@ -1618,7 +1645,7 @@ :else (let [ids - (into #{id} (keep uuid/parse*) id) + (into #{id} (keep uuid/parse*) ids) valid? (every? @@ -1740,7 +1767,7 @@ (let [id (obj/get self "$id") value (parser/parse-frame-guides value)] (cond - (not (sm/validate [:vector ::ctg/grid] value)) + (not (sm/validate [:vector ctg/schema:grid] value)) (u/not-valid plugin-id :guides value) (not (r/check-permission plugin-id "content:write")) diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 26e13b1039..b584625399 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -78,7 +78,7 @@ taking? (or taking? (and (<= from start) (< start to))) text (subs text (max 0 (- start acc)) (- end acc)) result (cond-> result - (and taking? (d/not-empty? text)) + (and taking? (seq text)) (conj (assoc node-style :text text))) continue? (or (> from end) (>= end to))] (recur (when continue? (rest styles)) taking? to result)) @@ -95,10 +95,11 @@ :$id {:enumerable false :get (constantly id)} :$file {:enumerable false :get (constantly file-id)} :$page {:enumerable false :get (constantly page-id)} + :$start {:enumerable false :get (constantly start)} + :$end {:enumerable false :get (constantly end)} :shape - {:this true - :get #(-> % u/proxy->shape)} + {:get (fn [] (format/shape-proxy plugin-id file-id page-id id))} :characters {:this true diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index e18ef5f5b5..a9c27c9649 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -96,13 +96,83 @@ :expand-with-children false}) (se/add-event plugin-id)))))) +(defn- typography-resolved-value->js + "Converts a resolved typography composite (a Clojure map keyed by the + tokenscript field names) into the plugin's `TokenTypographyValue[]` shape: a + JS array with a single object using the public camelCase member names." + [m] + (when (map? m) + #js [#js {"fontFamilies" (clj->js (:font-family m)) + "fontSizes" (:font-size m) + "fontWeights" (some-> (:font-weight m) str) + "letterSpacing" (:letter-spacing m) + "lineHeight" (:line-height m) + "textCase" (:text-case m) + "textDecoration" (:text-decoration m)}])) + +(defn- shadow-key->camel + "Renames a shadow composite field name (kebab string) to its public camelCase + member name. The shadow schema is closed; offset-x/offset-y are its only + multi-word fields, so the rest (blur, spread, color, inset) pass through." + [k] + (case k + "offset-x" "offsetX" + "offset-y" "offsetY" + k)) + +(defn- shadow-entry->js + "Converts one resolved shadow entry (a JS Map of field name -> tokenscript + symbol) into a plain JS object using the public member names and the + unit-converted values." + [^js m] + (let [out #js {}] + (.forEach m (fn [sym k] + (obj/set! out (shadow-key->camel k) + (ts/tokenscript-symbols->penpot-unit sym)))) + out)) + +(defn- shadow-resolved-value->js + "Converts a resolved shadow composite (a sequence of shadow entries) into the + plugin's `TokenShadowValue[]` shape." + [entries] + (when (some? entries) + (into-array (map shadow-entry->js entries)))) + +(defn- font-families-resolved-value->js + "Converts a resolved fontFamilies value (a tokenscript list symbol) into the + documented `string[]` shape rather than leaking the raw tokenscript structure." + [resolved-value] + (let [v (ts/tokenscript-symbols->penpot-unit resolved-value)] + (cond + (nil? v) nil + (sequential? v) (clj->js v) + :else #js [v]))) + (defn- get-resolved-value [token tokens-tree] (let [resolved-tokens (ts/resolve-tokens tokens-tree) - resolved-value (-> resolved-tokens - (dm/get-in [(:name token) :resolved-value]) - (ts/tokenscript-symbols->penpot-unit))] - resolved-value)) + resolved-value (dm/get-in resolved-tokens [(:name token) :resolved-value])] + (cond + (= :font-family (:type token)) + ;; A fontFamilies token resolves to a list of families; expose it as the + ;; documented `string[]` rather than the raw tokenscript list symbol. + (font-families-resolved-value->js resolved-value) + + (= :typography (:type token)) + ;; A typography token resolves to a composite; expose it as the documented + ;; `TokenTypographyValue[]` rather than the raw tokenscript structure. + (typography-resolved-value->js + (ts/tokenscript-symbols->penpot-unit resolved-value)) + + (= :shadow (:type token)) + ;; A shadow token resolves to a list of composites whose entries the + ;; tokenscript unit conversion leaves as raw symbols; expose them as the + ;; documented `TokenShadowValue[]`. + (shadow-resolved-value->js + (ts/tokenscript-symbols->penpot-unit resolved-value)) + + :else + (ts/tokenscript-symbols->penpot-unit resolved-value)))) (defn token-proxy? [p] (obj/type-of? p "TokenProxy")) @@ -150,11 +220,21 @@ (fn [_] (let [token (u/locate-token file-id set-id id)] (json/->js (:value token)))) - :schema (let [token (u/locate-token file-id set-id id)] - (cfo/make-token-value-schema (:type token))) + :schema (let [token (u/locate-token file-id set-id id) + base (cfo/make-token-value-schema (:type token))] + ;; plugin-types declares the fontFamilies value as + ;; `string | string[]`, but the core schema only accepts a + ;; vector/ref; also accept a plain string (normalized in :set). + (if (= :font-family (:type token)) + [:or :string base] + base)) :set (fn [_ value] - (st/emit! (dwtl/update-token set-id id {:value value})))} + (let [token (u/locate-token file-id set-id id) + value (cond-> value + (= :font-family (:type token)) + (ctob/convert-dtcg-font-family))] + (st/emit! (dwtl/update-token set-id id {:value value}))))} :resolvedValue {:this true @@ -361,7 +441,10 @@ :duplicate (fn [] - (st/emit! (dwtl/duplicate-token-set id))) + (let [id-ref (atom nil)] + (st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref})) + (when (some? @id-ref) + (token-set-proxy plugin-id file-id @id-ref)))) :remove (fn [] @@ -460,7 +543,7 @@ ;; Guard against nil to prevent `enable-set` from conj'ing nil ;; into the theme's :sets — which would send `:sets #{nil}` to the ;; backend and crash the workspace. - (let [set-name (obj/get token-set :name) + (let [set-name (obj/get token-set "name") theme (u/locate-token-theme file-id id)] (when (and (some? set-name) (some? theme)) (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name))))))} @@ -470,7 +553,7 @@ :schema [:tuple [:fn token-set-proxy?]] :fn (fn [token-set] ;; Same nil guard as addSet — see comment above. - (let [set-name (obj/get token-set :name) + (let [set-name (obj/get token-set "name") theme (u/locate-token-theme file-id id)] (when (and (some? set-name) (some? theme)) (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name))))))} diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f2a344fc25..744f0b1830 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1074,66 +1074,75 @@ (defn set-grid-layout-rows [entries] - (let [size (mem/get-alloc-size entries GRID-LAYOUT-ROW-U8-SIZE) - offset (mem/alloc size) - dview (mem/get-data-view)] + ;; Only allocate when there are entries; an empty list would alloc 0 bytes. + ;; The wasm side reads an empty buffer as zero rows. + (when (seq entries) + (let [size (mem/get-alloc-size entries GRID-LAYOUT-ROW-U8-SIZE) + offset (mem/alloc size) + dview (mem/get-data-view)] - (reduce (fn [offset {:keys [type value]}] - (-> offset - (mem/write-u8 dview (sr/translate-grid-track-type type)) - (+ 3) ;; padding - (mem/write-f32 dview value) - (mem/assert-written offset GRID-LAYOUT-ROW-U8-SIZE))) + (reduce (fn [offset {:keys [type value]}] + (-> offset + (mem/write-u8 dview (sr/translate-grid-track-type type)) + (+ 3) ;; padding + (mem/write-f32 dview value) + (mem/assert-written offset GRID-LAYOUT-ROW-U8-SIZE))) - offset - entries) + offset + entries))) - (h/call wasm/internal-module "_set_grid_rows"))) + (h/call wasm/internal-module "_set_grid_rows")) (defn set-grid-layout-columns [entries] - (let [size (mem/get-alloc-size entries GRID-LAYOUT-COLUMN-U8-SIZE) - offset (mem/alloc size) - dview (mem/get-data-view)] + ;; Only allocate when there are entries; an empty list would alloc 0 bytes. + ;; The wasm side reads an empty buffer as zero columns. + (when (seq entries) + (let [size (mem/get-alloc-size entries GRID-LAYOUT-COLUMN-U8-SIZE) + offset (mem/alloc size) + dview (mem/get-data-view)] - (reduce (fn [offset {:keys [type value]}] - (-> offset - (mem/write-u8 dview (sr/translate-grid-track-type type)) - (+ 3) ;; padding - (mem/write-f32 dview value) - (mem/assert-written offset GRID-LAYOUT-COLUMN-U8-SIZE))) - offset - entries) + (reduce (fn [offset {:keys [type value]}] + (-> offset + (mem/write-u8 dview (sr/translate-grid-track-type type)) + (+ 3) ;; padding + (mem/write-f32 dview value) + (mem/assert-written offset GRID-LAYOUT-COLUMN-U8-SIZE))) + offset + entries))) - (h/call wasm/internal-module "_set_grid_columns"))) + (h/call wasm/internal-module "_set_grid_columns")) (defn set-grid-layout-cells [cells] - (let [size (mem/get-alloc-size cells GRID-LAYOUT-CELL-U8-SIZE) - offset (mem/alloc size) - dview (mem/get-data-view)] + ;; Only allocate when there are cells; an empty collection would alloc 0 + ;; bytes. The wasm side reads an empty buffer as zero cells. + (when (seq cells) + (let [size (mem/get-alloc-size cells GRID-LAYOUT-CELL-U8-SIZE) + offset (mem/alloc size) + dview (mem/get-data-view)] - (reduce-kv (fn [offset _ cell] - (let [shape-id (-> (get cell :shapes) first)] - (-> offset - (mem/write-i32 dview (get cell :row)) - (mem/write-i32 dview (get cell :row-span)) - (mem/write-i32 dview (get cell :column)) - (mem/write-i32 dview (get cell :column-span)) + (reduce-kv (fn [offset _ cell] + (let [shape-id (-> (get cell :shapes) first)] + (-> offset + (mem/write-i32 dview (get cell :row)) + (mem/write-i32 dview (get cell :row-span)) + (mem/write-i32 dview (get cell :column)) + (mem/write-i32 dview (get cell :column-span)) - (mem/write-u8 dview (sr/translate-align-self (get cell :align-self))) - (mem/write-u8 dview (sr/translate-justify-self (get cell :justify-self))) + (mem/write-u8 dview (sr/translate-align-self (get cell :align-self))) + (mem/write-u8 dview (sr/translate-justify-self (get cell :justify-self))) - ;; padding - (+ 2) + ;; padding + (+ 2) - (mem/write-uuid dview (d/nilv shape-id uuid/zero)) - (mem/assert-written offset GRID-LAYOUT-CELL-U8-SIZE)))) + (mem/write-uuid dview (d/nilv shape-id uuid/zero)) + (mem/assert-written offset GRID-LAYOUT-CELL-U8-SIZE)))) - offset - cells) + offset + cells))) - (h/call wasm/internal-module "_set_grid_cells"))) + (h/call wasm/internal-module "_set_grid_cells")) (defn set-grid-layout [shape] diff --git a/frontend/test/frontend_tests/helpers/mock.cljc b/frontend/test/frontend_tests/helpers/mock.cljc index fce14876a3..bed04f506a 100644 --- a/frontend/test/frontend_tests/helpers/mock.cljc +++ b/frontend/test/frontend_tests/helpers/mock.cljc @@ -126,6 +126,39 @@ [_ms] (rx/of :immediate)) + ;; Static-dispatch-safe stubs + ;; ═══════════════════════════════════════════════════════════════ + ;; + ;; The `:esm` test build compiles calls to a *multi-arity* var as + ;; `f.cljs$core$IFn$_invoke$arity$N(...)`. A plain single-arity `fn` + ;; (including `identity`) does not expose that property, so using one + ;; to redefine such a var throws "arity$N is not a function". Multi-arity + ;; fns do expose the property, hence the helpers below. + + (defn noop + "Multi-arity no-op. Use to stub static-dispatched multi-arity vars + such as `st/emit!` (replacing `identity`, which is single-arity)." + ([] nil) + ([_] nil) + ([_ _] nil) + ([_ _ _] nil) + ([_ _ _ _] nil) + ([_ _ _ _ & _] nil)) + + (defn stub + "Wraps `f` in a multi-arity fn (arities 0-6) delegating to `f`, so the + result exposes `cljs$core$IFn$_invoke$arity$N`. Required when replacing + a multi-arity var in a `with-redefs`/`set!` mock with a capturing fn." + [f] + (fn + ([] (f)) + ([a] (f a)) + ([a b] (f a b)) + ([a b c] (f a b c)) + ([a b c d] (f a b c d)) + ([a b c d e] (f a b c d e)) + ([a b c d e g] (f a b c d e g)))) + ;; Lifecycle ;; ═══════════════════════════════════════════════════════════════ diff --git a/frontend/test/frontend_tests/plugins/comments_test.cljs b/frontend/test/frontend_tests/plugins/comments_test.cljs new file mode 100644 index 0000000000..ee19153a73 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/comments_test.cljs @@ -0,0 +1,60 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.comments-test + (:require + [app.main.data.comments :as dc] + [app.main.store :as st] + [app.plugins.comments :as comments] + [app.plugins.page :as page] + [app.plugins.register :as r] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(t/deftest comment-thread-remove-allows-the-owner + (let [owner-id (random-uuid) + file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + emitted (atom nil) + thread (comments/comment-thread-proxy + plugin-id + file-id + page-id + {:id thread-id :owner-id owner-id})] + (set! st/state (atom {:profile {:id owner-id}})) + (with-redefs [r/check-permission (constantly true) + dc/delete-comment-thread-on-workspace + (mock/stub (fn [params callback] + (callback) + [:delete-thread params])) + st/emit! (mock/stub (fn [event] (reset! emitted event)))] + (let [result (.remove thread)] + (t/is (instance? js/Promise result)) + (t/is (= [:delete-thread {:id thread-id}] @emitted)))))) + +(t/deftest page-remove-comment-thread-emits-delete-event + (let [file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + emitted (atom nil) + page (page/page-proxy plugin-id file-id page-id) + thread (comments/comment-thread-proxy + plugin-id + file-id + page-id + {:id thread-id :owner-id (random-uuid)})] + (with-redefs [r/check-permission (constantly true) + dc/delete-comment-thread-on-workspace + (mock/stub (fn [params callback] + (callback) + [:delete-thread params])) + st/emit! (mock/stub (fn [event] (reset! emitted event)))] + (let [result (.removeCommentThread page thread)] + (t/is (instance? js/Promise result)) + (t/is (= [:delete-thread {:id thread-id}] @emitted)))))) diff --git a/frontend/test/frontend_tests/plugins/file_test.cljs b/frontend/test/frontend_tests/plugins/file_test.cljs new file mode 100644 index 0000000000..8d7c6b0d12 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/file_test.cljs @@ -0,0 +1,21 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.file-test + (:require + [app.plugins.file :as file] + [cljs.test :as t :include-macros true])) + +(t/deftest file-version-created-at-returns-stored-date + (let [created-at (js/Date.) + version (file/file-version-proxy + "00000000-0000-0000-0000-000000000000" + (random-uuid) + {} + {:id (random-uuid) + :label "Version" + :created-at created-at})] + (t/is (identical? created-at (.-createdAt version))))) diff --git a/frontend/test/frontend_tests/plugins/format_test.cljs b/frontend/test/frontend_tests/plugins/format_test.cljs index 2bc525b224..fd52f1f3fe 100644 --- a/frontend/test/frontend_tests/plugins/format_test.cljs +++ b/frontend/test/frontend_tests/plugins/format_test.cljs @@ -38,3 +38,17 @@ (format/format-frame-guides nil) (format/format-tracks nil) (format/format-path-content nil))) + +(t/deftest test-format-color-result-uses-shapes-info-key + (let [shape-id (random-uuid) + result (format/format-color-result + [{:color "#fabada"} + [{:prop :fill :shape-id shape-id :index 0}]]) + info (aget result "shapesInfo")] + (t/is (array? info)) + (t/is (nil? (aget result "shapesColors"))) + (t/is (= "fill" (aget (aget info 0) "property"))) + (t/is (= (str shape-id) (aget (aget info 0) "shapeId"))))) + +(t/deftest test-shape-type-reports-boolean + (t/is (= "boolean" (format/shape-type :bool)))) diff --git a/frontend/test/frontend_tests/plugins/grid_test.cljs b/frontend/test/frontend_tests/plugins/grid_test.cljs new file mode 100644 index 0000000000..035153ccde --- /dev/null +++ b/frontend/test/frontend_tests/plugins/grid_test.cljs @@ -0,0 +1,49 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.grid-test + (:require + [app.common.test-helpers.files :as cthf] + [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.wasm :as thw] + [potok.v2.core :as ptk])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(defn- setup-grid [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + _ (set! st/state store) + _ (set! st/stream (ptk/input-stream store)) + context (api/create-context plugin-id) + board (.createBoard ^js context) + grid (.addGridLayout ^js board)] + {:store store :context context :board board :grid grid})) + +(t/deftest add-column-at-index-accepts-fixed-track-type + (thw/with-wasm-mocks* + (fn [] + (let [{:keys [^js grid]} (setup-grid)] + (.addColumn grid "flex" 1) + (.addColumnAtIndex grid 0 "fixed" 100) + (t/is (= "fixed" (aget (aget (.-columns grid) 0) "type"))) + (t/is (= 100 (aget (aget (.-columns grid) 0) "value"))))))) + +(t/deftest grid-track-methods-reject-out-of-range-indices + (thw/with-wasm-mocks* + (fn [] + (let [{:keys [store ^js grid]} (setup-grid)] + (swap! store assoc-in [:plugins :flags plugin-id :throw-validation-errors] true) + (.addRow grid "flex" 1) + (.addColumn grid "flex" 1) + (t/is (thrown? js/Error (.addRowAtIndex grid -1 "fixed" 10))) + (t/is (thrown? js/Error (.addColumnAtIndex grid 2 "fixed" 10))) + (t/is (thrown? js/Error (.setRow grid 1 "fixed" 10))) + (t/is (thrown? js/Error (.setColumn grid 1 "fixed" 10))) + (t/is (thrown? js/Error (.removeRow grid 1))) + (t/is (thrown? js/Error (.removeColumn grid 1))))))) diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs new file mode 100644 index 0000000000..47d5869b1a --- /dev/null +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -0,0 +1,95 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.library-test + (:require + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.texts :as dwt] + [app.main.store :as st] + [app.plugins.library :as library] + [app.plugins.register :as r] + [app.plugins.text :as text] + [app.plugins.utils :as u] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(t/deftest library-asset-proxies-expose-library-id + (let [file-id (random-uuid) + id (random-uuid)] + (t/is (= (str file-id) (.-libraryId (library/lib-color-proxy plugin-id file-id id)))) + (t/is (= (str file-id) (.-libraryId (library/lib-typography-proxy plugin-id file-id id)))) + (t/is (= (str file-id) (.-libraryId (library/lib-component-proxy plugin-id file-id id)))))) + +(t/deftest typography-apply-to-text-range-uses-hidden-range-bounds + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + typography-id (random-uuid) + typography (library/lib-typography-proxy plugin-id file-id typography-id) + text-range (text/text-range-proxy plugin-id file-id page-id shape-id 2 5) + captured (atom nil)] + (with-redefs [r/check-permission (constantly true) + u/page-active? (constantly true) + u/locate-library-typography + (constantly {:id typography-id + :name "Body" + :font-size "14"}) + dwt/update-text-range + (fn [shape-id start end attrs] + (reset! captured {:shape-id shape-id + :start start + :end end + :attrs attrs}) + :update-text-range) + st/emit! mock/noop] + (.applyToTextRange typography text-range) + (t/is (= shape-id (:shape-id @captured))) + (t/is (= 2 (:start @captured))) + (t/is (= 5 (:end @captured))) + (t/is (= file-id (get-in @captured [:attrs :typography-ref-file]))) + (t/is (= typography-id (get-in @captured [:attrs :typography-ref-id])))))) + +(t/deftest library-color-gradient-and-image-clear-exclusive-representations + (let [file-id (random-uuid) + color-id (random-uuid) + proxy (library/lib-color-proxy plugin-id file-id color-id) + captured (atom nil) + base {:id color-id + :name "Brand" + :color "#fabada" + :opacity 1 + :gradient {:type :linear} + :image {:id (random-uuid) :width 1 :height 1}}] + (with-redefs [r/check-permission (constantly true) + u/proxy->library-color (constantly base) + dwl/update-color-data (fn [color file-id] + (reset! captured {:color color :file-id file-id}) + :update-color-data) + st/emit! mock/noop] + (set! (.-gradient proxy) + #js {:type "linear" + :startX 0 + :startY 0 + :endX 1 + :endY 1 + :width 1 + :stops #js [#js {:color "#000000" + :opacity 1 + :offset 0}]}) + (t/is (contains? (:color @captured) :gradient)) + (t/is (not (contains? (:color @captured) :color))) + (t/is (not (contains? (:color @captured) :image))) + + (set! (.-image proxy) + #js {:id (str (random-uuid)) + :width 10 + :height 20 + :mtype "image/png"}) + (t/is (contains? (:color @captured) :image)) + (t/is (not (contains? (:color @captured) :color))) + (t/is (not (contains? (:color @captured) :gradient)))))) diff --git a/frontend/test/frontend_tests/plugins/local_storage_test.cljs b/frontend/test/frontend_tests/plugins/local_storage_test.cljs new file mode 100644 index 0000000000..f1f5117ed2 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/local_storage_test.cljs @@ -0,0 +1,28 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.local-storage-test + (:require + [app.plugins.local-storage :as storage] + [app.plugins.register :as r] + [cljs.test :as t :include-macros true])) + +(t/deftest remove-item-removes-the-prefixed-key + (let [data (atom {}) + fake #js {} + plugin-id "plugin-a"] + (set! (.-getItem fake) (fn [key] (get @data key))) + (set! (.-setItem fake) (fn [key value] (swap! data assoc key value))) + (set! (.-removeItem fake) (fn [key] (swap! data dissoc key))) + (set! (.-keys fake) (fn [] (to-array (keys @data)))) + (with-redefs [r/check-permission (constantly true) + storage/local-storage fake] + (let [proxy (storage/local-storage-proxy plugin-id)] + (.setItem proxy "key" "value") + (t/is (= "value" (.getItem proxy "key"))) + (.removeItem proxy "key") + (t/is (nil? (.getItem proxy "key"))) + (t/is (empty? @data)))))) diff --git a/frontend/test/frontend_tests/plugins/parser_test.cljs b/frontend/test/frontend_tests/plugins/parser_test.cljs index 4b257b2023..44abb8ebbd 100644 --- a/frontend/test/frontend_tests/plugins/parser_test.cljs +++ b/frontend/test/frontend_tests/plugins/parser_test.cljs @@ -31,3 +31,33 @@ (t/is (gpt/point? result)) (t/is (= 0 (:x result))) (t/is (= 0 (:y result)))))) + +(t/deftest test-parse-overlay-action-defaults-manual-position + (let [destination #js {"$id" (random-uuid)} + action (parser/parse-action + #js {:type "open-overlay" + :destination destination + :position "center"})] + (t/is (= :open-overlay (:action-type action))) + (t/is (= :center (:overlay-pos-type action))) + (t/is (gpt/point? (:overlay-position action))) + (t/is (= 0 (:x (:overlay-position action)))) + (t/is (= 0 (:y (:overlay-position action)))))) + +(t/deftest test-parse-frame-guide-calls-guide-parser + (let [column (parser/parse-frame-guide + #js {:type "column" + :display true + :params #js {:type "stretch" + :size 12}}) + row (parser/parse-frame-guide + #js {:type "row" + :display false + :params #js {:type "center" + :margin 4}})] + (t/is (= :column (:type column))) + (t/is (= true (:display column))) + (t/is (= :stretch (get-in column [:params :type]))) + (t/is (= :row (:type row))) + (t/is (= false (:display row))) + (t/is (= :center (get-in row [:params :type]))))) diff --git a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs new file mode 100644 index 0000000000..bf9e77f325 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs @@ -0,0 +1,202 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.shape-bugfixes-test + (:require + [app.common.data :as d] + [app.common.test-helpers.files :as cthf] + [app.common.types.component :as ctk] + [app.common.uuid :as uuid] + [app.main.data.workspace :as dw] + [app.main.data.workspace.variants :as dwv] + [app.main.store :as st] + [app.plugins.api :as api] + [app.plugins.public-utils :as public-utils] + [app.plugins.shape :as shape] + [app.plugins.utils :as u] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +;; --------------------------------------------------------------------------- +;; Helpers +;; --------------------------------------------------------------------------- + +(defn- child-shapes + "Ordered child shape ids of `board`, read back from the live store + (the observable result of a z-order operation)." + [store ^js context ^js board] + (let [file-id (aget (. context -currentFile) "$id") + page-id (aget (. context -currentPage) "$id") + board-id (aget board "$id")] + (get-in @store [:files file-id :data :pages-index page-id + :objects board-id :shapes]))) + +(defn- page-guides + "The guides map of the current page, read back from the live store." + [store ^js context] + (let [file-id (aget (. context -currentFile) "$id") + page-id (aget (. context -currentPage) "$id")] + (get-in @store [:files file-id :data :pages-index page-id :guides]))) + +;; --------------------------------------------------------------------------- +;; Tests +;; --------------------------------------------------------------------------- + +(t/deftest trigger-setter-updates-the-interaction-event-type + ;; Regression: the `trigger` setter must update the interaction of the + ;; located shape. Asserting on the observable interaction (read back through + ;; the proxy from the live store) covers that without coupling to which + ;; internal action gets emitted. + (thw/with-wasm-mocks* + (fn [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + ^js context (api/create-context plugin-id) + _ (set! st/state store) + ^js board (.createBoard context)] + (.addInteraction board "click" #js {:type "open-url" :url "https://example.com"}) + (let [^js interaction (aget (.-interactions board) 0)] + (t/is (= "click" (.-trigger interaction)) + "the interaction starts with the click trigger") + (set! (.-trigger interaction) "mouse-over") + (t/is (= "mouse-over" (.-trigger interaction)) + "the trigger setter updates the interaction event-type")))))) + +(t/deftest center-shapes-empty-input-returns-nil + (t/is (nil? (public-utils/centerShapes #js [])))) + +(t/deftest background-blur-reads-background-blur-key + (let [file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + blur-id (uuid/next) + proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (with-redefs [u/proxy->shape (constantly {:background-blur {:id blur-id + :value 12 + :hidden false}})] + (let [blur (.-backgroundBlur proxy)] + (t/is (= (str blur-id) (aget blur "id"))) + (t/is (= 12 (aget blur "value"))))))) + +(t/deftest flatten-returns-proxies-for-converted-shapes + ;; `convert-selected-to-path` runs the WASM boolean/path pipeline, so this + ;; test stays at the proxy boundary: it verifies `flatten` forwards the + ;; selected ids to the conversion and wraps the result back into proxies. + (let [file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + input (shape/shape-proxy plugin-id file-id page-id shape-id) + emitted (atom nil) + context (api/create-context plugin-id)] + (set! st/state (atom {:current-file-id file-id + :current-page-id page-id})) + (with-redefs [dw/convert-selected-to-path + (mock/stub (fn [ids] + (reset! emitted ids) + :convert-selected-to-path)) + st/emit! mock/noop + shape/shape-proxy + (mock/stub (fn [_plugin file page id] + #js {"$file" file "$page" page "$id" id}))] + (let [result (.flatten context #js [input])] + (t/is (= #{shape-id} @emitted)) + (t/is (array? result)) + (t/is (= shape-id (aget result 0 "$id"))) + (t/is (= file-id (aget result 0 "$file"))) + (t/is (= page-id (aget result 0 "$page"))))))) + +(t/deftest z-order-methods-reorder-the-shape-within-its-parent + ;; Asserts the observable child order in the parent after each z-order + ;; method, instead of merely checking which location keyword was emitted. + ;; The assertions are independent of the parent's `:shapes` ordering + ;; convention: a reorder is verified by relative movement and extremes. + (thw/with-wasm-mocks* + (fn [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + ^js context (api/create-context plugin-id) + _ (set! st/state store) + ^js board (.createBoard context) + children (mapv (fn [_] (.createRectangle context)) (range 4)) + ids (mapv #(aget % "$id") children) + order #(child-shapes store context board)] + (doseq [^js c children] (.appendChild board c)) + + ;; Operate on a shape that is currently interior (so both a forward + ;; and a backward step are observable). + (let [mid-id (nth (order) 1) + ^js mid (nth children (d/index-of ids mid-id))] + + (t/testing "bringForward and sendBackward move in opposite directions" + (let [i0 (d/index-of (order) mid-id) + _ (.bringForward mid) + i1 (d/index-of (order) mid-id) + _ (.sendBackward mid) + i2 (d/index-of (order) mid-id)] + (t/is (not= i0 i1) "bringForward changes the order") + (t/is (not= i1 i2) "sendBackward changes the order") + (t/is (= (pos? (- i1 i0)) (neg? (- i2 i1))) + "the two steps move the shape in opposite directions"))) + + (t/testing "bringToFront and sendToBack move to opposite extremes" + (let [n (count (order)) + _ (.bringToFront mid) + p1 (d/index-of (order) mid-id) + _ (.sendToBack mid) + p2 (d/index-of (order) mid-id)] + (t/is (contains? #{0 (dec n)} p1) "bringToFront moves to an extreme") + (t/is (contains? #{0 (dec n)} p2) "sendToBack moves to an extreme") + (t/is (not= p1 p2) "front and back are opposite extremes")))))))) + +(t/deftest is-variant-container-predicate-returns-boolean + (t/is (false? (ctk/is-variant-container? {}))) + (t/is (true? (ctk/is-variant-container? {:is-variant-container true})))) + +(t/deftest combine-as-variants-uses-the-passed-component-ids + ;; `combine-as-variants` needs real main components and the variant pipeline, + ;; so this stays at the proxy boundary and verifies the component ids that + ;; the head proxy collects from its argument before delegating. + (let [file-id (uuid/next) + page-id (uuid/next) + head-id (uuid/next) + other-id (uuid/next) + proxy (shape/shape-proxy plugin-id file-id page-id head-id) + captured (atom nil)] + (with-redefs [u/locate-shape (fn [_file _page id] {:id id :component-id id}) + u/locate-library-component (constantly {:id (uuid/next)}) + ctk/is-variant? (constantly false) + dwv/combine-as-variants + (fn [ids opts] + (reset! captured {:ids ids :opts opts}) + ;; return value flows through `se/add-event` (which + ;; calls `with-meta`), so it must support metadata + {:event :combine-as-variants}) + st/emit! mock/noop + shape/shape-proxy (mock/stub (fn [& _] #js {}))] + (.combineAsVariants proxy #js [(str other-id)]) + (t/is (= #{head-id other-id} (:ids @captured)))))) + +(t/deftest remove-ruler-guide-deletes-the-guide-from-the-page + ;; Adds a real ruler guide through the API and asserts it is gone from the + ;; page guides after removeRulerGuide, rather than checking the removal call. + (thw/with-wasm-mocks* + (fn [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + ^js context (api/create-context plugin-id) + _ (set! st/state store) + ^js board (.createBoard context) + ^js guide (.addRulerGuide board "horizontal" 10)] + (t/is (= 1 (count (page-guides store context))) + "addRulerGuide stores one guide on the page") + (.removeRulerGuide board guide) + (t/is (empty? (page-guides store context)) + "removeRulerGuide deletes the guide from the page"))))) + +(t/deftest group-empty-input-returns-nil + (let [context (api/create-context plugin-id)] + (t/is (nil? (.group context #js []))))) diff --git a/frontend/test/frontend_tests/plugins/text_test.cljs b/frontend/test/frontend_tests/plugins/text_test.cljs new file mode 100644 index 0000000000..9845e0c706 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/text_test.cljs @@ -0,0 +1,87 @@ +;; 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.plugins.text-test + (:require + [app.main.data.workspace.texts :as dwt] + [app.main.store :as st] + [app.plugins.fonts :as fonts] + [app.plugins.format :as format] + [app.plugins.register :as r] + [app.plugins.shape :as shape] + [app.plugins.text :as plugins.text] + [app.plugins.utils :as u] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(t/deftest font-apply-to-text-uses-font-id-not-shape-id + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + font (fonts/font-proxy + plugin-id + {:id "font-id" + :family "Inter" + :name "Inter" + :variants [{:id "regular" + :name "Regular" + :weight "400" + :style "normal"}]}) + text (shape/shape-proxy plugin-id file-id page-id shape-id) + captured (atom nil)] + (with-redefs [r/check-permission (constantly true) + u/page-active? (constantly true) + dwt/update-attrs + (fn [id attrs] + (reset! captured {:id id :attrs attrs}) + :update-attrs) + st/emit! mock/noop] + (.applyToText font text nil) + (t/is (= shape-id (:id @captured))) + (t/is (= "font-id" (get-in @captured [:attrs :font-id])))))) + +(t/deftest font-apply-to-range-uses-hidden-range-bounds + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + font (fonts/font-proxy + plugin-id + {:id "font-id" + :family "Inter" + :name "Inter" + :variants [{:id "regular" + :name "Regular" + :weight "400" + :style "normal"}]}) + range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 1 4) + captured (atom nil)] + (with-redefs [r/check-permission (constantly true) + u/page-active? (constantly true) + dwt/update-text-range + (fn [id start end attrs] + (reset! captured {:id id + :start start + :end end + :attrs attrs}) + :update-text-range) + st/emit! mock/noop] + (.applyToRange font range nil) + (t/is (= shape-id (:id @captured))) + (t/is (= 1 (:start @captured))) + (t/is (= 4 (:end @captured))) + (t/is (= "font-id" (get-in @captured [:attrs :font-id])))))) + +(t/deftest text-range-shape-returns-a-shape-proxy + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 0 3)] + (with-redefs [format/shape-proxy shape/shape-proxy] + (let [text-shape (.-shape range)] + (t/is (shape/shape-proxy? text-shape)) + (t/is (= shape-id (aget text-shape "$id"))))))) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index c45789b6ca..f95ba1d811 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -12,15 +12,20 @@ [app.common.test-helpers.tokens :as ctht] [app.common.types.tokens-lib :as ctob] [app.main.data.tokenscript :as ts] + [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] [app.plugins.api :as api] [app.plugins.tokens :as ptok] + [app.plugins.utils :as u] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.state :as ths] [potok.v2.core :as ptk])) (t/use-fixtures :each {:before cthi/reset-idmap!}) +(def ^:private get-resolved-value @#'ptok/get-resolved-value) + ;; Regression coverage for issue #9162. ;; ;; Plugin code calling `shape.applyToken(token, ["fill"])` or @@ -226,3 +231,110 @@ {:keys [errors resolved-value]} (get resolved (:name token))] (t/is (nil? resolved-value)) (t/is (seq errors)))) + +(t/deftest token-set-duplicate-returns-the-duplicated-set + (let [file-id (cthi/new-id! :file) + set-id (cthi/new-id! :set) + dup-id (cthi/new-id! :dup) + proxy (ptok/token-set-proxy "plugin-id" file-id set-id)] + (with-redefs [dwtl/duplicate-token-set + (mock/stub (fn [id {:keys [id-ref]}] + (t/is (= set-id id)) + (reset! id-ref dup-id) + :duplicate-token-set)) + st/emit! mock/noop] + (let [dup (.duplicate proxy)] + (t/is (ptok/token-set-proxy? dup)) + (t/is (= (str dup-id) (.-id dup))))))) + +(t/deftest theme-add-set-and-remove-set-use-the-set-name + (let [file-id (cthi/new-id! :file) + theme-id (cthi/new-id! :theme) + set-id (cthi/new-id! :set) + set (ptok/token-set-proxy "plugin-id" file-id set-id "Primitives") + theme (ptok/token-theme-proxy "plugin-id" file-id theme-id) + captured (atom [])] + (with-redefs [u/locate-token-theme + (fn [_file _theme] + (ctob/make-token-theme :id theme-id + :name "Theme" + :sets #{"Primitives"})) + dwtl/update-token-theme + (fn [id theme] + (swap! captured conj {:id id :theme theme}) + :update-token-theme) + st/emit! identity] + (.addSet theme set) + (.removeSet theme set) + (t/is (= [theme-id theme-id] (mapv :id @captured))) + (t/is (contains? (-> @captured first :theme :sets) "Primitives")) + (t/is (not (contains? (-> @captured second :theme :sets) "Primitives")))))) + +(t/deftest font-family-token-value-accepts-a-string + (let [file-id (cthi/new-id! :file) + set-id (cthi/new-id! :set) + token-id (cthi/new-id! :token) + captured (atom nil)] + (with-redefs [u/locate-token (constantly {:id token-id + :name "font.primary" + :type :font-family + :value ["Inter"]}) + dwtl/update-token (mock/stub (fn [set-id token-id attrs] + (reset! captured {:set-id set-id + :token-id token-id + :attrs attrs}) + :update-token)) + st/emit! mock/noop] + (let [token (ptok/token-proxy "plugin-id" file-id set-id token-id)] + (set! (.-value token) "Inter, Arial") + (t/is (= set-id (:set-id @captured))) + (t/is (= token-id (:token-id @captured))) + (t/is (= ["Inter" "Arial"] (get-in @captured [:attrs :value]))))))) + +(t/deftest typography-token-resolved-value-is-plugin-array-shape + (let [token (ctob/make-token + {:name "type.body" + :type :typography + :value {:font-family ["Inter" "Arial"] + :font-size "16px" + :font-weight "600" + :line-height "20px" + :letter-spacing "1" + :text-case "uppercase" + :text-decoration "underline"}}) + result (get-resolved-value token {(:name token) token}) + entry (aget result 0)] + (t/is (array? result)) + (t/is (= ["Inter" "Arial"] (vec (aget entry "fontFamilies")))) + (t/is (= 16 (aget entry "fontSizes"))) + (t/is (= "600" (aget entry "fontWeights"))) + (t/is (= 20 (aget entry "lineHeight"))) + (t/is (= "uppercase" (aget entry "textCase"))) + (t/is (= "underline" (aget entry "textDecoration"))))) + +(t/deftest shadow-token-resolved-value-is-plugin-array-shape + (let [token (ctob/make-token + {:name "shadow.card" + :type :shadow + :value [{:offset-x "1px" + :offset-y "2px" + :blur "3px" + :spread "4px" + :color "#000000" + :inset false}]}) + result (get-resolved-value token {(:name token) token}) + entry (aget result 0)] + (t/is (array? result)) + (t/is (= 1 (aget entry "offsetX"))) + (t/is (= 2 (aget entry "offsetY"))) + (t/is (= 3 (aget entry "blur"))) + (t/is (= 4 (aget entry "spread"))))) + +(t/deftest font-family-token-resolved-value-is-string-array + (let [token (ctob/make-token + {:name "font.primary" + :type :font-family + :value ["Inter" "Arial"]}) + result (get-resolved-value token {(:name token) token})] + (t/is (array? result)) + (t/is (= ["Inter" "Arial"] (vec result))))) diff --git a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs index e20aa2e1bc..740029a3ca 100644 --- a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs @@ -16,6 +16,8 @@ in :fetching) and are permanently stuck with fallback-font layout metrics." (:require [app.render-wasm.api :as wasm.api] + [app.render-wasm.mem :as mem] + [app.render-wasm.wasm :as wasm] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true])) @@ -108,3 +110,21 @@ ;; process-pending fires update-text-layouts, it covers shape-b too. (t/is (= 2 (count (:shapes @captured))) "Both shapes are in process-pending so font-load covers all of them"))) + +(t/deftest empty-grid-tracks-do-not-allocate-zero-bytes + (let [calls (atom []) + ;; `h/call` is a macro that resolves the wasm function off the module + ;; via `unchecked-get`, so it cannot be redefined. Mock the module + ;; itself with recording stubs and let the real macro expansion run. + module #js {"_set_grid_rows" (fn [& _] (swap! calls conj [:call "_set_grid_rows"]) nil) + "_set_grid_columns" (fn [& _] (swap! calls conj [:call "_set_grid_columns"]) nil)}] + (with-redefs [mem/alloc (fn [size] + (swap! calls conj [:alloc size]) + 0) + wasm/internal-module module] + (wasm.api/set-grid-layout-rows []) + (wasm.api/set-grid-layout-columns [])) + (t/is (not-any? #(= :alloc (first %)) @calls)) + (t/is (= [[:call "_set_grid_rows"] + [:call "_set_grid_columns"]] + @calls)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 4dc39abc18..e9777a6012 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -27,12 +27,19 @@ [frontend-tests.logic.groups-test] [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.main-errors-test] + [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] + [frontend-tests.plugins.file-test] [frontend-tests.plugins.format-test] + [frontend-tests.plugins.grid-test] [frontend-tests.plugins.interactions-test] + [frontend-tests.plugins.library-test] + [frontend-tests.plugins.local-storage-test] [frontend-tests.plugins.page-active-validation-test] [frontend-tests.plugins.page-test] [frontend-tests.plugins.parser-test] + [frontend-tests.plugins.shape-bugfixes-test] + [frontend-tests.plugins.text-test] [frontend-tests.plugins.tokens-test] [frontend-tests.plugins.utils-test] [frontend-tests.render-wasm.process-objects-test] @@ -65,7 +72,8 @@ (.exit js/process 1))) (def test-namespaces - ['frontend-tests.basic-shapes-test + ['frontend-tests.plugins.text-test + 'frontend-tests.basic-shapes-test 'frontend-tests.code-gen-style-test 'frontend-tests.copy-as-svg-test 'frontend-tests.data.nitrate-test @@ -89,11 +97,20 @@ 'frontend-tests.logic.groups-test 'frontend-tests.logic.pasting-in-containers-test 'frontend-tests.plugins.context-shapes-test + 'frontend-tests.plugins.comments-test + 'frontend-tests.plugins.file-test + 'frontend-tests.plugins.format-test + 'frontend-tests.plugins.grid-test + 'frontend-tests.plugins.interactions-test + 'frontend-tests.plugins.library-test + 'frontend-tests.plugins.local-storage-test 'frontend-tests.plugins.page-active-validation-test 'frontend-tests.plugins.interactions-test 'frontend-tests.plugins.format-test 'frontend-tests.plugins.page-test 'frontend-tests.plugins.parser-test + 'frontend-tests.plugins.shape-bugfixes-test + 'frontend-tests.plugins.text-test 'frontend-tests.plugins.tokens-test 'frontend-tests.plugins.utils-test 'frontend-tests.svg-fills-test diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 64163dbd42..c3138b0659 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -1,22 +1,36 @@ ## 1.5.0 (Unreleased) +### 💣 Breaking changes & Deprecations + - **plugins-runtime**: changes outside the current page now raise a validation error when the target belongs to a page that is not currently active, instead of silently operating on the active page. -- **plugins-runtime**: Fix inverted validation that rejected valid values (and accepted invalid ones) on text range `align`, `direction`, `textDecoration`, `letterSpacing` and on layout child `zIndex`. -- **plugins-runtime**: Array-typed properties (e.g. `page.flows`, `shape.exports`, `shape.shadows`, layout `rows`/`columns`, ruler guides, path `commands`) now always return an array, returning an empty array instead of `null` when there are no items +- **plugin-types**: Change return type of `combineAsVariants` +- **plugin-types:** Deprecate the legacy `Image` shape interface — image shapes exist only for backward compatibility with old files; new images are embedded in a `Fill` via its `fillImage` (an `ImageData`). +- We've solved several inconsistencies accross the API, if you relied on an undocumented property or method be aware that might have changed. + +### 🚀 Features + - **plugins-runtime**: Added `version` field that returns the current version - **plugins-runtime**: Added optional parameter `throwOnError` to `penpot.ui.sendMessage` (default false, backwards-compatible) - **plugin-types**: Added a flags subcontexts with the flag `naturalChildrenOrdering` +- **plugin-types**: Added flag `throwValidationErrors` to enable exceptions on validation - **plugin-types**: `penpot.openPage()` now returns `Promise` and should be awaited before performing operations on the new page -- **plugin-types**: Fix penpot.openPage() to navigate in same tab by default - **plugin-types:** Change `LibraryComponent.isVariant()` return type to type guard `this is LibraryVariantComponent` - **plugin-types**: Added `createVariantFromComponents` -- **plugin-types**: Change return type of `combineAsVariants` - **plugin-types**: Added `textBounds` property for text shapes -- **plugin-types**: Added flag `throwValidationErrors` to enable exceptions on validation - **plugin-types**: Fix missing `webp` export format in `Export.type` - **plugin-types**: Added `fixedWhenScrolling` property for shapes - **plugin-runtime:** `addToken` now resolves references against all token sets, allowing references to tokens in inactive sets - **plugin-types:** `TokenCatalog.addSet` now accepts an optional `active` flag to create an already-active set (sets are inactive by default) +- **plugin-runtime:** A `fontFamilies` token's `resolvedValue` now returns the documented `string[]` (the resolved family list) instead of leaking the raw tokenscript list symbol + +### 🩹 Fixes + +- **plugins-runtime**: Fix inverted validation that rejected valid values (and accepted invalid ones) on text range `align`, `direction`, `textDecoration`, `letterSpacing` and on layout child `zIndex`. +- **plugins-runtime**: Array-typed properties (e.g. `page.flows`, `shape.exports`, `shape.shadows`, layout `rows`/`columns`, ruler guides, path `commands`) now always return an array, returning an empty array instead of `null` when there are no items +- **plugin-types**: Fix penpot.openPage() to navigate in same tab by default +- **plugin-types**: Rename `LibraryTypography.fontFamilies` to `fontFamily` to match the runtime (it holds a single font family, not an array) +- **plugin-runtime:** Setting a `LibraryColor`'s `gradient` or `image` now clears the other color representations (solid/gradient/image are mutually exclusive), so the result is a valid color instead of being rejected with "expected valid color" +- **plugin-types:** Mark members that have no runtime setter as `readonly`, fixing a mismatch where they were typed as writable: font metadata (`Font.*`, `FontVariant.*`, `FontsContext.all`), the `Ellipse`/`Image`/`SvgRaw` `type` discriminants (now consistent with the other shapes), `File.name`/`pages`/`revn`, `Page.root`, `TokenTheme.activeSets`, `Variants.properties`, `ImageData.*`, the board guide value objects (`GuideColumn`/`GuideRow`/`GuideSquare` and their params — `board.guides` returns a formatted snapshot, so reconfiguring means reassigning the whole array), the `Point` and `Bounds` value objects, the `Penpot.ui`/`Penpot.utils` subcontexts, the derived `Boolean` path data (`d`/`content`/`commands` are computed from the operands; `Boolean` is not editable like a `Path`), and the `EventsMap` event entries (a type-only event→callback map, never assigned). Members that do expose a setter stay writable: `Board.children`, `Path.d`/`content`/`commands` and `FileVersion.label`. ## 1.4.2 (2026-01-21) diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index fc53fd1b33..e83fffe473 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -6,7 +6,7 @@ export interface Penpot extends Omit< Context, 'addListener' | 'removeListener' > { - ui: { + readonly ui: { /** * Opens the plugin UI. It is possible to develop a plugin without interface (see Palette color example) but if you need, the way to open this UI is using `penpot.ui.open`. * There is a minimum and maximum size for this modal and a default size but it's possible to customize it anyway with the options parameter. @@ -84,7 +84,7 @@ export interface Penpot extends Omit< /** * Provides access to utility functions and context-specific operations. */ - utils: ContextUtils; + readonly utils: ContextUtils; /** * Closes the plugin. When this method is called the UI will be closed. * @@ -390,17 +390,17 @@ export interface Boolean extends ShapeBase { * The content of the boolean shape, defined as the path string. * @deprecated Use either `d` or `commands`. */ - content: string; + readonly content: string; /** * The content of the boolean shape, defined as the path string. */ - d: string; + readonly d: string; /** * The content of the boolean shape, defined as an array of path commands. */ - commands: Array; + readonly commands: Array; /** * The fills applied to the shape. @@ -455,19 +455,19 @@ export type Bounds = { /** * Top-left x position of the rectangular area defined */ - x: number; + readonly x: number; /** * Top-left y position of the rectangular area defined */ - y: number; + readonly y: number; /** * Width of the represented area */ - width: number; + readonly width: number; /** * Height of the represented area */ - height: number; + readonly height: number; }; /** @@ -1517,7 +1517,7 @@ export interface Ellipse extends ShapeBase { /** * The type of the shape, which is always 'ellipse' for ellipse shapes. */ - type: 'ellipse'; + readonly type: 'ellipse'; /** * The fills applied to the shape. @@ -1540,37 +1540,37 @@ export interface EventsMap { /** * The `pagechange` event is triggered when the active page in the project is changed. */ - pagechange: Page; + readonly pagechange: Page; /** * The `filechange` event is triggered when a different file is opened. * The callback will receive the new file. */ - filechange: File; + readonly filechange: File; /** * The `selectionchange` event is triggered when the selection of elements changes. * This event passes a list of identifiers of the selected elements. */ - selectionchange: string[]; + readonly selectionchange: string[]; /** * The `themechange` event is triggered when the application theme is changed. */ - themechange: Theme; + readonly themechange: Theme; /** * The `finish` event is triggered when the current file is closed. * The callback will receive the id of the closed file. */ - finish: string; + readonly finish: string; /** * This event will trigger whenever the shape in the props change. It's mandatory to send * with the props an object like `{ shapeId: '' }` */ - shapechange: Shape; + readonly shapechange: Shape; /** * The `contentsave` event will trigger after the file content is saved in the backend. */ - contentsave: void; + readonly contentsave: void; } /** @@ -1609,17 +1609,17 @@ export interface File extends PluginData { /** * The `name` for the file */ - name: string; + readonly name: string; /** * The `revn` will change for every document update */ - revn: number; + readonly revn: number; /** * List all the pages for the current file */ - pages: Page[]; + readonly pages: Page[]; /** * Export the current file to an archive. @@ -1819,37 +1819,37 @@ export interface Font { /** * This property holds the human-readable name of the font. */ - name: string; + readonly name: string; /** * The unique identifier of the font. */ - fontId: string; + readonly fontId: string; /** * The font family of the font. */ - fontFamily: string; + readonly fontFamily: string; /** * The default font style of the font. */ - fontStyle?: 'normal' | 'italic' | null; + readonly fontStyle?: 'normal' | 'italic' | null; /** * The default font variant ID of the font. */ - fontVariantId: string; + readonly fontVariantId: string; /** * The default font weight of the font. */ - fontWeight: string; + readonly fontWeight: string; /** * An array of font variants available for the font. */ - variants: FontVariant[]; + readonly variants: FontVariant[]; /** * Applies the font styles to a text shape. @@ -1884,22 +1884,22 @@ export interface FontVariant { /** * The name of the font variant. */ - name: string; + readonly name: string; /** * The unique identifier of the font variant. */ - fontVariantId: string; + readonly fontVariantId: string; /** * The font weight of the font variant. */ - fontWeight: string; + readonly fontWeight: string; /** * The font style of the font variant. */ - fontStyle: 'normal' | 'italic'; + readonly fontStyle: 'normal' | 'italic'; } /** @@ -1910,7 +1910,7 @@ export interface FontsContext { /** * An array containing all available fonts. */ - all: Font[]; + readonly all: Font[]; /** * Finds a font by its unique identifier. @@ -2208,15 +2208,15 @@ export interface GuideColumn { /** * The type of the guide, which is always 'column' for column guides. */ - type: 'column'; + readonly type: 'column'; /** * Specifies whether the column guide is displayed. */ - display: boolean; + readonly display: boolean; /** * The parameters defining the appearance and layout of the column guides. */ - params: GuideColumnParams; + readonly params: GuideColumnParams; } /** @@ -2227,7 +2227,7 @@ export interface GuideColumnParams { /** * The color configuration for the column guides. */ - color: { color: string; opacity: number }; + readonly color: { color: string; opacity: number }; /** * The optional alignment type of the column guides. * - 'stretch': Columns stretch to fit the available space. @@ -2235,23 +2235,23 @@ export interface GuideColumnParams { * - 'center': Columns align to the center. * - 'right': Columns align to the right. */ - type?: 'stretch' | 'left' | 'center' | 'right'; + readonly type?: 'stretch' | 'left' | 'center' | 'right'; /** * The optional size of each column. */ - size?: number; + readonly size?: number; /** * The optional margin between the columns and the board edges. */ - margin?: number; + readonly margin?: number; /** * The optional length of each item within the columns. */ - itemLength?: number; + readonly itemLength?: number; /** * The optional gutter width between columns. */ - gutter?: number; + readonly gutter?: number; } /** @@ -2262,16 +2262,16 @@ export interface GuideRow { /** * The type of the guide, which is always 'row' for row guides. */ - type: 'row'; + readonly type: 'row'; /** * Specifies whether the row guide is displayed. */ - display: boolean; + readonly display: boolean; /** * The parameters defining the appearance and layout of the row guides. * Note: This reuses the same parameter structure as column guides. */ - params: GuideColumnParams; + readonly params: GuideColumnParams; } /** @@ -2282,15 +2282,15 @@ export interface GuideSquare { /** * The type of the guide, which is always 'square' for square guides. */ - type: 'square'; + readonly type: 'square'; /** * Specifies whether the square guide is displayed. */ - display: boolean; + readonly display: boolean; /** * The parameters defining the appearance and layout of the square guides. */ - params: GuideSquareParams; + readonly params: GuideSquareParams; } /** @@ -2301,11 +2301,11 @@ export interface GuideSquareParams { /** * The color configuration for the square guides. */ - color: { color: string; opacity: number }; + readonly color: { color: string; opacity: number }; /** * The optional size of each square guide. */ - size?: number; + readonly size?: number; } /** @@ -2334,12 +2334,14 @@ export interface HistoryContext { /** * Represents an image shape in Penpot. * This interface extends `ShapeBase` and includes properties specific to image shapes. + * @deprecated Image shapes exist only for backward compatibility with old files. + * New images are embedded in a `Fill` via its `fillImage` (an `ImageData`). */ export interface Image extends ShapeBase { /** * The type of the shape, which is always 'image' for image shapes. */ - type: 'image'; + readonly type: 'image'; /** * The fills applied to the shape. @@ -2355,28 +2357,28 @@ export type ImageData = { /** * The optional name of the image. */ - name?: string; + readonly name?: string; /** * The width of the image. */ - width: number; + readonly width: number; /** * The height of the image. */ - height: number; + readonly height: number; /** * The optional media type of the image (e.g., 'image/png', 'image/jpeg'). */ - mtype?: string; + readonly mtype?: string; /** * The unique identifier for the image. */ - id: string; + readonly id: string; /** * Whether to keep the aspect ratio of the image when resizing. * Defaults to false if omitted. */ - keepAspectRatio?: boolean; + readonly keepAspectRatio?: boolean; /** * Returns the image data as a byte array. @@ -2870,9 +2872,9 @@ export interface LibraryTypography extends LibraryElement { fontId: string; /** - * The font families of the typography element. + * The font family of the typography element. */ - fontFamilies: string; + fontFamily: string; /** * The unique identifier of the font variant used in the typography element. @@ -3097,7 +3099,7 @@ export interface Page extends PluginData { * The root shape of the current page. Will be the parent shape of all the shapes inside the document. * Requires `content:read` permission. */ - root: Shape; + readonly root: Shape; /** * Retrieves a shape by its unique identifier. @@ -3437,7 +3439,7 @@ export interface PluginData { /** * Point represents a point in 2D space, typically with x and y coordinates. */ -export type Point = { x: number; y: number }; +export type Point = { readonly x: number; readonly y: number }; /** * It takes back to the last board shown. @@ -4126,7 +4128,7 @@ export interface SvgRaw extends ShapeBase { /** * The type of the shape, which is always 'svg-raw' for raw SVG shapes. */ - type: 'svg-raw'; + readonly type: 'svg-raw'; } /** @@ -5297,7 +5299,7 @@ export interface TokenTheme { /** * The sets that will be activated if this theme is activated. */ - activeSets: TokenSet[]; + readonly activeSets: TokenSet[]; /** * Adds a set to the list of the theme. @@ -5563,7 +5565,7 @@ export interface Variants { /** * A list with the names of the properties of the Variant */ - properties: string[]; + readonly properties: string[]; /** * A list of all the values of a property across all the VariantComponents of this Variant diff --git a/render-wasm/src/wasm/layouts/grid.rs b/render-wasm/src/wasm/layouts/grid.rs index 15284250c2..573b99a5da 100644 --- a/render-wasm/src/wasm/layouts/grid.rs +++ b/render-wasm/src/wasm/layouts/grid.rs @@ -174,7 +174,7 @@ pub extern "C" fn set_grid_layout_data( #[no_mangle] #[wasm_error] pub extern "C" fn set_grid_columns() -> Result<()> { - let bytes = mem::bytes(); + let bytes = mem::bytes_or_empty(); let entries: Vec = bytes .chunks(size_of::()) @@ -197,7 +197,7 @@ pub extern "C" fn set_grid_columns() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_grid_rows() -> Result<()> { - let bytes = mem::bytes(); + let bytes = mem::bytes_or_empty(); let entries: Vec = bytes .chunks(size_of::()) @@ -220,7 +220,7 @@ pub extern "C" fn set_grid_rows() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_grid_cells() -> Result<()> { - let bytes = mem::bytes(); + let bytes = mem::bytes_or_empty(); let cells: Vec = bytes .chunks(size_of::()) From 21f646aeee403b59e062cfef7556179f038888ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 30 Jun 2026 10:35:03 +0200 Subject: [PATCH 032/100] :bug: Fix decimal rounding and format in WebGL rulers (#10487) --- render-wasm/src/fonts/WorkSans-Numeric.ttf | Bin 9828 -> 4460 bytes render-wasm/src/render/rulers.rs | 16 +++++++--------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/render-wasm/src/fonts/WorkSans-Numeric.ttf b/render-wasm/src/fonts/WorkSans-Numeric.ttf index 9566da91bebf127c6b72280a4c210c60ee8c388e..eeb70b375f6e5bbef0dd606c3aae00d29e9e6f3b 100644 GIT binary patch delta 1355 zcmYjRU2GIp6h8ON>`Z6&hn1hwV!`QlyX`J4D_ge4Xd>t~EuodB8xq$7GO0@!!k}0wx<0lO`H5m=FFN9!742d-{L?x~-mGQf`8)R;J=YBuu z+%xx{?d{>;V>J>)6sEUGqtU_99fkCpy;Y(}FA>iS7Ir@U?}t`FHPPeUr#qSuVLRw*M0SlE^ND z>o1o}HP^f<&LF`sh|M&U0j_WKx1sC` zY{EPrn1w!o-Fu<0qZITxqgrEACcO8#@00B>(j{>ejMHQ~{6#S;PJrobGHrfC3@Iko zWGr~bR8&`!S>yjBwksyyWMYUas;KTJqai8_PtByn1ExLVI5z8ffEia|xPDb)Oc>%N zKFtP-zAQ@?A-cq;fE|T9L`aM(y;I>f_=gmZDQrO>Q~oZ6*X$Ce*sh4UBIE`|70|7) zhJdWn6ZiE!{0TPfxsS({zn3oKdShY`1>Pc4-Q34kT;7~5Mo{Gq*#zFNRx&K8LyVx# zTZBXOp0)SmesaO7DB>*$W3bw^0@Cf^Q;^(H5mzTO7ZLKXJRUC+ei=$VoWa~4d84V1 zLvJUXv9}PJsO9|zU7;#kjUH1i)}`8Qc5OVq7JpSWV}(|u=jo=Xiwu5sBvy6iME%|c zktr0Q5Ur;V@I5XC6avwFbm_*Epl9e6xUoaYhV z$-BA46@HbE@GKwak8qG*^G}+o4QTtcQ`)@to1W1p^&|Q*wH5RG@6F6#_gbRYG@J6? z^=OY+|KUTgCDGhukPZ{mMtU5LK$NCO<=ejo){teP#gpgvcK*mKHo{6GFJ^EQVX62R zy)E(9b^bXl7ZmZQ|CxUb2tVbe$6xS1by~ft_>lL7W9a@5{#g)h?Ekg&`rG?HzRgQ_ zFR!J@LK!x;mE~w^=-5%mfd!}q#Tamy7!pZrVc2Szl*Ev6Ar-G-#qh{1|K|w6E3*Vu a7DHt*RhE$aM`U9u(=fg`MZ&9hfA=3KfiVXF literal 9828 zcmb_id2A!sd4F$acf)|6mo%s5a0LS_r33Z*ZbZ#k7ArLR)a5zvFY^EN@(5p6ULNlj0v67D~Dq5zVwTKX3YC4 zsJ^ze($$;2z0t>*9bJ}NJD0!69%0X;pF@9a?U5^T$7=O28I%7E{WnfrJbCVTm*r{T zKfzd4#mW5Tix{r~e`>8K&pvkI(?ehXB4fM)_(x7{iT63MT>UV%2IE4YroBS0_ z`4ReUr_Nou7Ju86L%$#W&z!xmmS6bUoDcLQ-|D&iwTt{W)obW~2z~i{{@h0AlTj$= z#vJ~Oix)0mDSndmV*NDXFJ9WXIP_ONkfrrA=>L)lELyxR1cg^vIcs3SNGB6To@bn| zE||C=u)G9bhuLCf1!oR>MMGtS#l*^axw)b~VDhww`v>}Z>z#FGU++M;zg_W|optqm z@Z$5&Uwrtv=N?Y4tfZF?9xQwPwbx#!znRTvo_+DfXP?<*oYg`;S@;;MX6{If)gplLLBU~zsb4hNSCvAaJ;{2ivNq<=XCd! z&k7BS-|_BQa1O(hW@yyPx+B5L3TVN(C1MV#CxpH}=iJ(JX-_JeShh@6UR_VD4UBC*QttDW{abYQ{E-9d=xlbb zbM<`q!SRVlpIL$qJcR{W0QNNrpJZKZJ~F$1UyUeA)e?+M0+~fYGz(8LkqMCSG?OG# zo|#Om3y_%QEr#hi>+rc-n;YxwRTWk<>*8Hj!}z|z`ue_YyIaEby(JiS+L7JjY!OHW z?#X<(F*W-dtw zB$&9O9JFP2d#&A4(HwAx-C^Em?{g|nZy!QvP=Ss4`K_<<;KtRfTi^H(Sf`@B=5wR` zvs*`#pL_e)%>&I%6Afg|Nm$c@l|3vPnP}jG$zCIgl5R{BjA+u0C>}!;rwt>TOGez+ z?5uOt>L&DXkL7L?!fXz=(`~Wz?O4yuTQg_&FV@7PiKW?i_k6>Yk6+&UPO~61ruruz z>>oWnkBNz6m?!{VY_=FHzNt8TTap|%EIZ{@Azb>3UwU}>j)W_CLJ5S2!RDxP-Q ziUSUP|7`~ak1wsa&Q-@phtnNW`Dm&wzCQTDQ!AH75?7OvbbGWTF+VT75}i{*;nWjL zwSjO`e=c_XO6KEh7yfi{>&x?F?UUZw=;SP{?uRl}y1!c@E&<+7!P)GXzFVOHHmjqq zM#TPs0nLI_>8~Ah`}w~9es*>pKrgfx{|rL%VwAbL2hvx z+jZL-gJIn$4Z2N?y&>+-QvJs+_ozehu`{zXXUAeAfxgSfMv_y*!&8aT*@LUoQ-==; zFF%;>Td3pC)rpaVq0qsR@xxB;Sm;kbm>TNs85-*89eVed(V^jJba*IA{!T(SKm6Uo zI?3M-xVpRyxd#3=F%fD)J4s^sZGR&gDAhtaxs|i_w$@I$v#FuF68>)CE!+O43soYgsdOW0j=MR4xb=eC!y@0oj)e-s+wOf~cs?`TAvAJ92t2i#>14>$9NgjYoD zPWE-fQpK;AEQRy^tHQ^DlMNidxr5t>9FW%Mx$nX?g6E8m^W25AVcxRNQ@LxsW^Umr z9oKf(Jd!i3^Yq>|PXy0ieO~)L=N%PZh7LXhC*IM4cs`)>^zY?~UEtx=j#pkD;{Uv-@J;?(W6KC^tbUD z{u6wVjmr1}Z2Lo2#axlb@-j=Qa+slvKg$>GH9~n~z|m({=xo8!5}pvAoS5Rgex$Xw zt5f*l*5&`H@;RKcX-ukbp;KiaUw}v6!l{Cap}Oj>ieYYHr(&4ZD+UkqWWAlHa%8;l zz`)>ya97b_!MaWyBz;a_L#Uy9!0BuE1%*jzIo1<(Po*lGs%*ism-s@rU9N2pcQ(i! zJ!<`UqHV+-IMQ#k+c+17Az34AIRnW8?D0rthrdNMnQQHWh!Z5G+0vPamm$Z9{1WmD zk|&N6v)L#dAhIY5$02u0Sae0&iO$5Or{1418*y^x^LpfcO-@HmmCeEeJYXfy=v5w6 zDjhh;N2PkhV-z2{38?h=Vr-_#Yp?5Y9g2RiHC{2N1cH9aYLYDCVw)VPh*mBMZ;f@g zbUHbA`dj-ZaxDp^MQ-H%e5|E)tf41_7_z}5%kXg-b5Mm*%_R}#5Y#rA1xY}uD51m< z#gfZQ6-KSyZnu;-2RKR#C9H%Us4-As7zM`i^ys&~H2<|{MbTVqR``V<^$E=62#v@kPvPYHVs&UW>LwhTWc%ai0Crp_Qhf6J05hws#fv-&{O&e6}}cAF+64CDg#{dfO&b>3Dk}(C$@LK7DHa z#0g)ZrLol?@m97pCx=H9Lx>}v>XLo1tq6bi>i%qtxZz2vjTt|N4Lh?yweYM!g*erS z4v97|6@_6G6K3a$$n4K~*X%f7_`qjVBO~*l+JHZFI~)~$%o!vEA)^wjLVs2rl zih#e`z0FOWsh!^L_HMbQsja!KvK*VN(N^-?b`b&ZQSx~I65>{DVagvoG&{2z^t-(a zF^}Kx@%Vj;zpKmd>+TYMJlq>=;N?Bu{_*ktwqCq8h2-AhaI4ED%Pv>z);}uyTNI^b zKaM=Zvk$K~x-o1D2pywmKc7RH*X3p?_;B|SO4&Ad&yy?!Cy%UB$RN01F zyX%IV3#*xh!IV8_@wN|knYd-#otRHg`^c}Mknq--0x?%qtcMGC3sY1qMhF0xDzG+t6N{R^}{ac?mi4wgonjx9~;lER_rYMqAv3hxJu$-3{zYUIN@d=s_~K1BrudrIgF!~xYLL)%>}7T+m; z$Kb)3##C8jhcd~mET?X=ssky_ymBO?^}95GHg`haJe1J{Z~iM*W@T$@%5j(5t+A}e z;!5H=c*JwDpvF~A&YcKqf~vR`cTf{mdHsf1=VYV({!y21;Su0Ctau|^bS4PkPK?6BgE}PA|U@fg8zNWF| zjK)$#>_*>}(pm^=N#$?Wur;E%X=2B-+4X!@T( zVW9(=T-udi&SsRXJ1c9El?-q$vdgw8LCvCSws_z=!bi8d6B)NnxZJB!AR>k7USf_g@aLU!afmC5|;Q@#chw3>w zxvAvIsk*V5iyWfKE(lqY60TM931f-M`%Kr`Fbk7+RNFnNisE01-KeZ!ViGQLDei11 zg1A~O2tra@&nJRfjS3ZHS*woEk!AqkA+3gb%jnhU{<4EnjcybfHduq;nmwMAH*>OP zhuMN!t(rQRDM;&yY@1fOpoi(6+B;fKOyz8N(GZ54o)B>lr5%#9GNiB%fHme1RT9;ZdQM*qqn5o^b7A(}Z zs0Ayvt!lwWZ9s)J4Et;594xKKA&oysyCtZpyO8xe$cGJNa2K+D2YJar$|}>U1NYSv zN`28#mGs=LpBwtgkjn%8s8ygJwQbOkS}*jYwjKIW>w|vO`k^1S9ng>3PUuH%NR`L* zjnt*eIju1#BkA!R#Y7&h7NVH#R<*8x)`k7pgRL-)h`r~ARPsX#CHD8FKsW}q-ja(8 zRpul`oYvD>FmY!xgQQ4a?Av9Y`_c5Pa#)u<3~mOWhI1kcgEWOB&hrqtUj_W`u?PgAlx#*4fip}9IGiBwkpvv` zbXt)RBEGjX>7BI@EMp~G$dJI)zHJL-3}zbNL1|@3CbB%GOw*#|#fcJ5SH`fk#tvpe z@)!FB+k-95-Oca4GvcZ?64*^pnp9dap=zUn zO@trC*d})R{bYkfLs~aPOzJ9A7`Gdi&!a$#8LCq_E7)lv>^wuEDYa0BvxcHykNn>* zq^Iu{VE>;eCVNvJ8&if{?p^WX&Th*&jRZUrC|P9&z0rW1d`5QJ)@H{9voL|v*!HMS zu)`f8Z2-G`?mpuwu;Xx#cMw!X;}hsua-L0?fRm;#bEh0BHc6 zfCK7vu8%AO&_`B?W*Vr2L_@$Kq9I_FXb3p0-asyl1Ihrx*$ybH-ay$;&=Ek!*aJkz zY3u_;r$Y}Coemu(IvqMjYbHUKqcsW0)0zYvr!@&!Bbpfi>qJAq2GJ03f@lahsmqlB zbV`?tpwqfs1U;n7MbH^tE`rYLauIY+my4kDx?BWZfQ`p?9C%UhX%QeEHoz#rOJsB+ z(_-jd##sxHXs#F_(OlJO2thQDVDk75*GKi9KI57J(u~IpkjQ@ze8vryA2dKB_=k%V=?|B0x@(&vz zk-vbMlLo&R4Uh;n4Uh;vqFyi8D_hO%x{mv`Bwm_umStlB&AOq9ZRu;J13k#_0{F%< z%1Qnb&N}0j?_z;jVi6n5vM6I-3+vSTHAkgI>y$KN?v*;F7PF;nd-%ApYMv7!(tr>W z+9Z9rDi*ah+;ZHq->SM*ajWc>ZJ)&|g}g!qsHtg|&k^B@pz9)Z~P;c}5tM`UO>xB}-N3OW!sM~UwaJ%dS)(3`(x1KXSUGhq77 z|K>I<;P(D+w_%a>vmb2367EIt>Z8v!u_+$fhVeTJzry0|0=vi_W0!CM)atPAWMMjr)-)7#&tz=QacBqgY8XZ7ID>K!{{cO+T{pOct%791`? zhmfvCHyZ_QlFjO^p+!lDAlp0VURv&@Sj8HbpwDT@ejYmRUPXp(SJ(-xPIkBe4NilG z?CHn{*!& z^a3*#f6A=IpA)wD1N4RBe**eDy?-0D95_?)tIUGdhChKx_%>(_2xAZm^6`4!4W9U! zfIh_^6yL`8H;dmdV#mGz2ONJVpW?TFCv1LOCIan0Jmlpvv>kYC`Uv%q5HL;_C(GOP z`_gh93v1)C085XgKc;OOGdNlBm@vD6XHs zaE{fTxRhUG&F8P4yM((}BX&qOqM#m0SHV^hKNI*i_*2BBv4&ZvutG}8PaL<>#(exw g4tnLdyEOVnjG2qK^>HVD~-=>Px# diff --git a/render-wasm/src/render/rulers.rs b/render-wasm/src/render/rulers.rs index 582469e8ee..0d4fbd2379 100644 --- a/render-wasm/src/render/rulers.rs +++ b/render-wasm/src/render/rulers.rs @@ -74,15 +74,13 @@ fn calculate_step_size(zoom: f32) -> f32 { } fn format_label(value: f32) -> String { - // Match `format-number` in app.main.ui.formats: round to integer if whole, - // else 2 decimals. Tick steps are integers in our table, so this is the - // common path. - let rounded = value.round(); - if (value - rounded).abs() < 1e-3 { - format!("{}", rounded as i64) - } else { - format!("{:.2}", value) - } + // Match `format-number` in app.main.ui.formats: round to at most 2 decimals. + // Display drops trailing zeros for free, so 123.00 -> "123", + // 123.50 -> "123.5", 123.456 -> "123.46". + let rounded = (value * 100.0).round() / 100.0; + // Normalize -0.0 so we don't render "-0". + let rounded = if rounded == 0.0 { 0.0 } else { rounded }; + format!("{rounded}") } fn with_alpha(color: Color, alpha_fraction: f32) -> Color { From ca81776d045273eca7c7dc2caee642756602fc7d Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 30 Jun 2026 13:33:47 +0200 Subject: [PATCH 033/100] :bug: Fix problems when dragging frame with comments (#10460) * :bug: Fix undo frame position not undoing comments * :bug: Fix problem with hover capturing dragging event * :bug: Fix watch updates for comment bubbles * :bug: Fix "Maximum update depth" crash on SVG shape transforms * :bug: Fix comment geometry problems --- .../src/app/common/geom/shapes/intersect.cljc | 14 +- .../test/common_tests/files_changes_test.cljc | 51 +++++++ .../geom_shapes_intersect_test.cljc | 16 ++ frontend/src/app/main/data/workspace.cljs | 3 + .../src/app/main/data/workspace/comments.cljs | 137 ++++++++++++++---- .../app/main/data/workspace/transforms.cljs | 48 ++++-- frontend/src/app/main/ui/comments.cljs | 12 +- .../main/ui/workspace/viewport/comments.cljs | 89 +++++++----- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/comments_position_modifier_test.cljs | 60 ++++++++ 10 files changed, 341 insertions(+), 91 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs diff --git a/common/src/app/common/geom/shapes/intersect.cljc b/common/src/app/common/geom/shapes/intersect.cljc index d111f6d0da..9338a0ad56 100644 --- a/common/src/app/common/geom/shapes/intersect.cljc +++ b/common/src/app/common/geom/shapes/intersect.cljc @@ -355,11 +355,15 @@ (defn has-point? [shape point] - (if (or ^boolean (cfh/path-shape? shape) - ^boolean (cfh/bool-shape? shape) - ^boolean (cfh/circle-shape? shape)) - (slow-has-point? shape point) - (fast-has-point? shape point))) + (let [rotation (dm/get-prop shape :rotation)] + ;; Rotated shapes don't match their axis-aligned box, so use the polygon test. + (if (or ^boolean (cfh/path-shape? shape) + ^boolean (cfh/bool-shape? shape) + ^boolean (cfh/circle-shape? shape) + (and (some? rotation) + (not ^boolean (mth/almost-zero? rotation)))) + (slow-has-point? shape point) + (fast-has-point? shape point)))) (defn rect-contains-shape? [rect shape] diff --git a/common/test/common_tests/files_changes_test.cljc b/common/test/common_tests/files_changes_test.cljc index 12d2cb4844..3cbc475000 100644 --- a/common/test/common_tests/files_changes_test.cljc +++ b/common/test/common_tests/files_changes_test.cljc @@ -8,6 +8,8 @@ (:require [app.common.features :as ffeat] [app.common.files.changes :as ch] + [app.common.files.changes-builder :as pcb] + [app.common.geom.point :as gpt] [app.common.schema :as sm] [app.common.schema.generators :as sg] [app.common.schema.test :as smt] @@ -736,6 +738,55 @@ {:num 1000}))) +(t/deftest set-comment-thread-position + (let [file-id (uuid/custom 2 2) + page-id (uuid/custom 1 1) + thread-id (uuid/custom 3 1) + frame-id (uuid/custom 4 1) + data (make-file-data file-id page-id)] + + (t/testing "stores position and frame-id" + (let [change {:type :set-comment-thread-position + :page-id page-id + :comment-thread-id thread-id + :frame-id frame-id + :position (gpt/point 10 20)} + res (ch/process-changes data [change])] + (t/is (= {:frame-id frame-id :position (gpt/point 10 20)} + (get-in res [:pages-index page-id :comment-thread-positions thread-id]))))) + + (t/testing "removes the position when frame-id and position are nil" + (let [data (ch/process-changes data [{:type :set-comment-thread-position + :page-id page-id + :comment-thread-id thread-id + :frame-id frame-id + :position (gpt/point 10 20)}]) + res (ch/process-changes data [{:type :set-comment-thread-position + :page-id page-id + :comment-thread-id thread-id + :frame-id nil + :position nil}])] + (t/is (nil? (get-in res [:pages-index page-id :comment-thread-positions thread-id]))))) + + (t/testing "builder round-trips the position through undo and redo" + (let [data (ch/process-changes data [{:type :set-comment-thread-position + :page-id page-id + :comment-thread-id thread-id + :frame-id frame-id + :position (gpt/point 10 20)}]) + page (get-in data [:pages-index page-id]) + changes (-> (pcb/empty-changes) + (pcb/with-page page) + (pcb/set-comment-thread-position {:id thread-id + :frame-id frame-id + :position (gpt/point 100 200)})) + redone (ch/process-changes data (:redo-changes changes)) + undone (ch/process-changes redone (:undo-changes changes))] + (t/is (= (gpt/point 100 200) + (get-in redone [:pages-index page-id :comment-thread-positions thread-id :position]))) + (t/is (= (gpt/point 10 20) + (get-in undone [:pages-index page-id :comment-thread-positions thread-id :position]))))))) + (t/deftest set-plugin-data-json-encode-decode (let [schema ch/schema:set-plugin-data-change encode (sm/encoder schema (sm/json-transformer)) diff --git a/common/test/common_tests/geom_shapes_intersect_test.cljc b/common/test/common_tests/geom_shapes_intersect_test.cljc index 676290de89..a670d938c4 100644 --- a/common/test/common_tests/geom_shapes_intersect_test.cljc +++ b/common/test/common_tests/geom_shapes_intersect_test.cljc @@ -254,3 +254,19 @@ shape {:points points}] (t/is (true? (gint/slow-has-point? shape (pt 50 25)))) (t/is (false? (gint/slow-has-point? shape (pt 150 25))))))) + +(t/deftest has-point-rotated-test + ;; Diamond (a square rotated 45º); its axis-aligned x/y/width/height box does + ;; not match the rotated polygon. + (let [points [(pt 50 0) (pt 100 50) (pt 50 100) (pt 0 50)] + shape {:x 20 :y 20 :width 60 :height 60 :rotation 45 :points points}] + (t/testing "point inside the polygon but outside the box is contained" + (t/is (true? (gint/has-point? shape (pt 50 5))))) + (t/testing "point inside the box but outside the polygon is not contained" + (t/is (false? (gint/has-point? shape (pt 22 22))))))) + +(t/deftest has-point-axis-aligned-test + (let [shape {:x 10 :y 20 :width 100 :height 50 :rotation 0}] + (t/testing "unrotated shape uses the axis-aligned box" + (t/is (true? (gint/has-point? shape (pt 50 40)))) + (t/is (false? (gint/has-point? shape (pt 200 40))))))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 8304dc9f1f..26bed66c22 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -438,6 +438,9 @@ (rx/take 1) (rx/map #(dwcm/navigate-to-comment-id comment-id)))) + ;; Keep comment thread positions in sync on undo/redo + (rx/of (dwcm/watch-comment-thread-position-changes stoper-s)) + (let [local-commits-s (->> stream (rx/filter dch/commit?) diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index 450268cbd1..968cc8194e 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -8,10 +8,13 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.changes-builder :as pcb] + [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.schema :as sm] [app.common.types.shape-tree :as ctst] + [app.main.data.changes :as dwc] [app.main.data.comments :as dcmt] [app.main.data.common :as dcm] [app.main.data.event :as ev] @@ -126,6 +129,14 @@ ny (- (:y position) nh)] (update local :vbox assoc :x nx :y ny))))))) +(defn- set-comment-thread + "Stores the comment thread in the workspace state so its bubble re-renders." + [thread] + (ptk/reify ::set-comment-thread + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:comment-threads (:id thread)] thread)))) + (defn update-comment-thread-position ([thread [new-x new-y]] (update-comment-thread-position thread [new-x new-y] nil)) @@ -136,35 +147,106 @@ (dcmt/check-comment-thread! thread)) (ptk/reify ::update-comment-thread-position ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [page (dsh/lookup-page state) page-id (:id page) objects (dsh/lookup-page-objects state page-id) frame-id (if (nil? frame-id) (ctst/get-frame-id-by-position objects (gpt/point new-x new-y)) (:frame-id thread)) - thread (-> thread - (assoc :position (gpt/point new-x new-y)) - (assoc :frame-id frame-id)) - thread-id (:id thread)] + position (gpt/point new-x new-y) + thread (-> thread + (assoc :position position) + (assoc :frame-id frame-id)) + thread-id (:id thread) + + ;; Record the position as a change so it joins the undo entry + set-position-changes + (-> (pcb/empty-changes it) + (pcb/with-page page) + (pcb/set-comment-thread-position thread))] (rx/concat - (rx/of (fn [state] - (-> state - (update :comment-threads assoc thread-id thread) - ;; Keep the page positions map in sync so subsequent - ;; frame moves compute the relative offset from the - ;; latest position instead of a stale one. - (dsh/update-page page-id - #(update-in % [:comment-thread-positions thread-id] - (fn [pos] - (-> pos - (assoc :position (:position thread)) - (assoc :frame-id (:frame-id thread))))))))) - (->> (rp/cmd! :update-comment-thread-position thread) + ;; Update the new position in the rendered thread, and commit the + ;; change so the move is part of the undo entry + (rx/of (set-comment-thread thread) + (dwc/commit-changes set-position-changes)) + (->> (rp/cmd! :update-comment-thread-position {:id thread-id + :position position + :frame-id frame-id}) (rx/catch #(rx/throw {:type :update-comment-thread-position})) (rx/ignore)))))))) +(def ^:private undo-origins + #{:app.main.data.workspace.undo/undo + :app.main.data.workspace.undo/redo + :app.main.data.workspace.undo/undo-to-index}) + +(defn- sync-comment-thread-position + "Syncs the rendered thread and the backend for a comment position change." + [{:keys [comment-thread-id position frame-id]}] + (ptk/reify ::sync-comment-thread-position + ptk/UpdateEvent + (update [_ state] + (cond-> state + (and position frame-id) + (update-in [:comment-threads comment-thread-id] + (fn [thread] + (some-> thread (assoc :position position :frame-id frame-id)))))) + + ptk/WatchEvent + (watch [_ _ _] + (if (and position frame-id) + (->> (rp/cmd! :update-comment-thread-position {:id comment-thread-id + :position position + :frame-id frame-id}) + (rx/catch #(rx/throw {:type :update-comment-thread-position})) + (rx/ignore)) + (rx/empty))))) + +(defn watch-comment-thread-position-changes + "Syncs rendered threads and the backend when an undo/redo changes a comment position." + [stopper] + (ptk/reify ::watch-comment-thread-position-changes + ptk/WatchEvent + (watch [_ _ stream] + (->> stream + (rx/filter dwc/commit?) + (rx/map deref) + (rx/filter #(contains? undo-origins (:origin %))) + (rx/mapcat (fn [commit] + (->> (:redo-changes commit) + (filter #(= :set-comment-thread-position (:type %))) + (rx/from)))) + (rx/map sync-comment-thread-position) + (rx/take-until stopper))))) + +(defn frame-pin-transform + "Matrix that moves a comment pinned inside `frame` as the frame is transformed, + following its translation and rotation but not its resize scale." + [frame modifiers transform] + (when (and (some? frame) (or (some? modifiers) (some? transform))) + (let [frame' (cond-> frame + (some? modifiers) (gsh/transform-shape modifiers) + (some? transform) (gsh/apply-transform transform)) + + c (gsh/shape->center frame) + c' (gsh/shape->center frame') + tfi (or (:transform-inverse frame) (gmt/matrix)) + tf' (or (:transform frame') (gmt/matrix)) + sr (:selrect frame) + sr' (:selrect frame') + d (gpt/point (- (:x sr') (:x sr)) + (- (:y sr') (:y sr)))] + (-> (gmt/matrix) + (gmt/translate! c') + (gmt/multiply! tf') + (gmt/translate! (gpt/negate c')) + (gmt/translate! d) + (gmt/translate! c) + (gmt/multiply! tfi) + (gmt/translate! (gpt/negate c)))))) + ;; Move comment threads that are inside a frame when that frame is moved" (defn- move-frame-comment-threads @@ -187,25 +269,18 @@ build-move-event (fn [comment-thread] - (let [frame-id (:frame-id comment-thread) + (let [frame-id (:frame-id comment-thread) frame (get objects frame-id) modifiers (get-in object-modifiers [frame-id :modifiers]) transform (get transforms frame-id) - frame' - (cond-> frame - (some? modifiers) - (gsh/transform-shape modifiers) + matrix (frame-pin-transform frame modifiers transform) - (some? transform) - (gsh/apply-transform transform)) - - moved (gpt/to-vec (gpt/point (:x frame) (:y frame)) - (gpt/point (:x frame') (:y frame'))) position (get-in threads-position-map [(:id comment-thread) :position]) - new-x (+ (:x position) (:x moved)) - new-y (+ (:y position) (:y moved))] - (update-comment-thread-position comment-thread [new-x new-y] (:id frame))))] + position' (cond-> position + (some? matrix) + (gpt/transform matrix))] + (update-comment-thread-position comment-thread [(:x position') (:y position')] frame-id)))] (->> (:comment-threads state) (vals) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index bff7170b1b..bc3a9f24ab 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -327,12 +327,21 @@ (dwm/create-modif-tree shape-ids %) :ignore-constraints (contains? layout :scale-text))))) - (->> resize-events-stream - (rx/mapcat + (let [emit-modifiers (fn [modifiers] (let [modif-tree (dwm/create-modif-tree shape-ids modifiers)] - (rx/of (dwm/set-modifiers modif-tree (contains? layout :scale-text)))))) - (rx/take-until stopper)))] + (rx/of (dwm/set-modifiers modif-tree (contains? layout :scale-text)))))] + ;; Throttle the live preview to limit re-renders; the trailing + ;; rx/last applies the exact final frame. + (rx/merge + (->> resize-events-stream + (rx/sample 16) + (rx/mapcat emit-modifiers) + (rx/take-until stopper)) + (->> resize-events-stream + (rx/take-until stopper) + (rx/last) + (rx/mapcat emit-modifiers)))))] (rx/concat ;; This initial stream waits for some pixels to be move before making the resize @@ -523,14 +532,22 @@ (rx/of (finish-transform))) - (rx/concat - (rx/merge - (->> angle-stream - (rx/map - #(dwm/set-rotation-modifiers % shapes group-center)) - (rx/take-until stopper))) - (rx/of (dwm/apply-modifiers) - (finish-transform)))))))) + (let [emit-modifiers + (fn [angle] (dwm/set-rotation-modifiers angle shapes group-center))] + ;; Throttle the live preview to limit re-renders; the trailing + ;; rx/last applies the exact final frame. + (rx/concat + (rx/merge + (->> angle-stream + (rx/sample 16) + (rx/map emit-modifiers) + (rx/take-until stopper)) + (->> angle-stream + (rx/take-until stopper) + (rx/last) + (rx/map emit-modifiers))) + (rx/of (dwm/apply-modifiers) + (finish-transform))))))))) (defn increase-rotation "Rotate shapes a fixed angle, from a keyboard action." @@ -822,6 +839,8 @@ (rx/merge (->> modifiers-stream + ;; Throttle the live preview to limit re-renders. + (rx/sample 16) (rx/map (fn [[modifiers snap-ignore-axis]] (dwm/set-modifiers modifiers false false {:snap-ignore-axis snap-ignore-axis})))) @@ -843,10 +862,13 @@ ;; Last event will write the modifiers creating the changes (->> move-stream (rx/last) + (rx/with-latest-from modifiers-stream) (rx/mapcat - (fn [[_ target-frame drop-index drop-cell]] + (fn [[[_ target-frame drop-index drop-cell] [modifiers snap-ignore-axis]]] (let [undo-id (js/Symbol)] (rx/of (dwu/start-undo-transaction undo-id) + ;; Apply the exact final modifiers; the preview may drop the last frame. + (dwm/set-modifiers modifiers false false {:snap-ignore-axis snap-ignore-axis}) (dwm/apply-modifiers {:undo-transation? false}) (move-shapes-to-frame ids target-frame drop-index drop-cell) (finish-transform) diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 85a9b3ac2f..c3bd8044fa 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -1164,6 +1164,9 @@ test-id (str/join "-" (map :seqn (sort-by :seqn thread-group))) + ;; Click-through while transforming a shape, so it doesn't capture the drag + dragging? (some? (mf/deref refs/current-transform)) + on-click (mf/use-fn (mf/deps thread-group position zoom) @@ -1176,7 +1179,8 @@ (dwz/set-zoom position scale-zoom)))))] [:div {:style {:top (dm/str pos-y "px") - :left (dm/str pos-x "px")} + :left (dm/str pos-x "px") + :pointer-events (when dragging? "none")} :on-click on-click :class (stl/css :floating-preview-wrapper :floating-preview-bubble)} [:> comment-avatar* @@ -1198,6 +1202,9 @@ frame-id (:frame-id thread) + ;; Click-through while transforming a shape, so it doesn't capture the drag + dragging? (some? (mf/deref refs/current-transform)) + state (mf/use-state #(do {:is-hover false :is-grabbing false @@ -1290,7 +1297,8 @@ (on-click thread))))] [:div {:style {:top (dm/str pos-y "px") - :left (dm/str pos-x "px")} + :left (dm/str pos-x "px") + :pointer-events (when dragging? "none")} :on-pointer-down on-pointer-down :on-pointer-up on-pointer-up :on-pointer-move on-pointer-move diff --git a/frontend/src/app/main/ui/workspace/viewport/comments.cljs b/frontend/src/app/main/ui/workspace/viewport/comments.cljs index 8468b29b4b..8fc476751c 100644 --- a/frontend/src/app/main/ui/workspace/viewport/comments.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/comments.cljs @@ -8,9 +8,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.common.geom.matrix :as gmt] - [app.common.geom.point :as gpt] - [app.common.geom.shapes :as gsh] [app.main.data.comments :as dcm] [app.main.data.workspace.comments :as dwcm] [app.main.refs :as refs] @@ -18,6 +15,47 @@ [app.main.ui.comments :as cmt] [rumext.v2 :as mf])) +;; Pin transform for the bubble's frame so it follows the frame during a drag, +;; scoped per frame to avoid re-rendering the whole layer each tick. +(defn- use-frame-position-modifier + [frame-id] + (let [modifiers (mf/deref refs/workspace-modifiers) + wasm-mods (mf/deref refs/workspace-wasm-modifiers) + objects (mf/deref refs/workspace-page-objects)] + (dwcm/frame-pin-transform (get objects frame-id) + (get-in modifiers [frame-id :modifiers]) + (get wasm-mods frame-id)))) + +(mf/defc comment-floating-bubble-wrapper* + {::mf/private true} + [{:keys [thread zoom is-open]}] + (let [position-modifier (use-frame-position-modifier (:frame-id thread))] + [:> cmt/comment-floating-bubble* + {:thread thread + :zoom zoom + :position-modifier position-modifier + :is-open is-open}])) + +(mf/defc comment-floating-group-wrapper* + {::mf/private true} + [{:keys [thread-group zoom]}] + (let [thread (first thread-group) + position-modifier (use-frame-position-modifier (:frame-id thread))] + [:> cmt/comment-floating-group* + {:thread-group thread-group + :zoom zoom + :position-modifier position-modifier}])) + +(mf/defc comment-floating-thread-wrapper* + {::mf/private true} + [{:keys [thread viewport zoom]}] + (let [position-modifier (use-frame-position-modifier (:frame-id thread))] + [:> cmt/comment-floating-thread* + {:thread thread + :viewport viewport + :position-modifier position-modifier + :zoom zoom}])) + (mf/defc comments-layer* {::mf/wrap [mf/memo]} [{:keys [vbox vport zoom file-id page-id]}] @@ -34,38 +72,12 @@ threads-map (mf/deref refs/threads) - ;; Active transform modifiers (e.g. while dragging a board). We use - ;; them to move comment bubbles live alongside their frame, instead of - ;; only repositioning them at drop time. The SVG (legacy) renderer keeps - ;; them in `:workspace-modifiers`, while the WASM renderer pushes them - ;; through the `wasm-modifiers` stream as plain transform matrices. - modifiers (mf/deref refs/workspace-modifiers) - wasm-mods (into {} (mf/deref refs/workspace-wasm-modifiers)) - objects (mf/deref refs/workspace-page-objects) - threads (mf/with-memo [threads-map local profile page-id] (->> (vals threads-map) (filter #(= (:page-id %) page-id)) (dcm/apply-filters local profile))) - ;; Returns the position translation matrix for a frame that is being - ;; transformed, or nil when the frame has no active modifier. The delta - ;; matches `move-frame-comment-threads` (frame top-left displacement) so - ;; the bubble does not jump when the modifier is committed. - frame-position-modifier - (fn [frame-id] - (when-let [frame (get objects frame-id)] - (let [frame' - (if-let [modifier (get-in modifiers [frame-id :modifiers])] - (gsh/transform-shape frame modifier) - (when-let [transform (get wasm-mods frame-id)] - (gsh/apply-transform frame transform)))] - (when (some? frame') - (let [delta (gpt/to-vec (gpt/point (:x frame) (:y frame)) - (gpt/point (:x frame') (:y frame')))] - (gmt/translate-matrix delta)))))) - viewport (assoc vport :offset-x pos-x :offset-y pos-y) @@ -94,23 +106,20 @@ (let [group? (> (count thread-group) 1) thread (first thread-group)] (if group? - [:> cmt/comment-floating-group* {:thread-group thread-group - :zoom zoom - :position-modifier (frame-position-modifier (:frame-id thread)) - :key (:seqn thread)}] - [:> cmt/comment-floating-bubble* {:thread thread - :zoom zoom - :position-modifier (frame-position-modifier (:frame-id thread)) - :is-open (= (:id thread) (:open local)) - :key (:seqn thread)}]))) + [:> comment-floating-group-wrapper* {:thread-group thread-group + :zoom zoom + :key (:seqn thread)}] + [:> comment-floating-bubble-wrapper* {:thread thread + :zoom zoom + :is-open (= (:id thread) (:open local)) + :key (:seqn thread)}]))) (when-let [id (:open local)] (when-let [thread (get threads-map id)] (when (seq (dcm/apply-filters local profile [thread])) - [:> cmt/comment-floating-thread* + [:> comment-floating-thread-wrapper* {:thread thread :viewport viewport - :position-modifier (frame-position-modifier (:frame-id thread)) :zoom zoom}]))) (when-let [draft (:draft local)] diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e9777a6012..69c439dab2 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -51,6 +51,7 @@ [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] [frontend-tests.tokens.workspace-tokens-remap-test] + [frontend-tests.ui.comments-position-modifier-test] [frontend-tests.ui.ds-controls-numeric-input-test] [frontend-tests.ui.measures-menu-props-test] [frontend-tests.util-object-test] @@ -121,6 +122,7 @@ 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.workspace-tokens-remap-test + 'frontend-tests.ui.comments-position-modifier-test 'frontend-tests.ui.ds-controls-numeric-input-test 'frontend-tests.ui.measures-menu-props-test 'frontend-tests.render-wasm.process-objects-test diff --git a/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs b/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs new file mode 100644 index 0000000000..3f842df31a --- /dev/null +++ b/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs @@ -0,0 +1,60 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.ui.comments-position-modifier-test + (:require + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.math :as mth] + [app.common.types.modifiers :as ctm] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.main.data.workspace.comments :as dwcm] + [cljs.test :as t :include-macros true])) + +(defn- frame + [id] + (cts/setup-shape {:id id :type :frame :name "Board" + :x 100 :y 100 :width 200 :height 150})) + +(defn- close-point? + [a b] + (and (mth/close? (:x a) (:x b)) + (mth/close? (:y a) (:y b)))) + +(t/deftest frame-pin-transform-move + (let [f (frame (uuid/next)) + mods (ctm/move-modifiers (gpt/point 10 20)) + m (dwcm/frame-pin-transform f mods nil)] + (t/testing "the comment follows the frame translation" + (t/is (close-point? (gpt/point 160 170) + (gpt/transform (gpt/point 150 150) m)))))) + +(t/deftest frame-pin-transform-rotation + (let [f (frame (uuid/next)) + center (gsh/shape->center f) + mods (ctm/rotation (ctm/empty) center 90) + m (dwcm/frame-pin-transform f mods nil) + p (gpt/point 150 150)] + (t/testing "the comment rotates around the frame center" + (t/is (close-point? (gpt/transform p (ctm/modifiers->transform mods)) + (gpt/transform p m)))))) + +(t/deftest frame-pin-transform-resize + (let [f (frame (uuid/next)) + mods (ctm/resize (ctm/empty) (gpt/point 2 2) (gpt/point 100 100)) + m (dwcm/frame-pin-transform f mods nil) + p (gpt/point 150 150)] + (t/testing "the comment keeps its position without scaling" + (t/is (close-point? p (gpt/transform p m)))) + (t/testing "the comment is not scaled along with the frame" + (t/is (not (close-point? (gpt/transform p (ctm/modifiers->transform mods)) + (gpt/transform p m))))))) + +(t/deftest frame-pin-transform-without-transform + (let [f (frame (uuid/next))] + (t/testing "no active transform yields no matrix" + (t/is (nil? (dwcm/frame-pin-transform f nil nil)))))) From e6a49adfbc0876d2577fdaa8ff1d7e45b13d758e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 30 Jun 2026 13:55:06 +0200 Subject: [PATCH 034/100] :bug: Fix crash on composition update when pressing Esc on a IME (#10479) --- .../ui/workspace/shapes/text/v3_editor.cljs | 17 +++++++++-------- render-wasm/src/wasm/text_editor.rs | 4 ++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 749c5af69f..450db8f839 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -81,8 +81,10 @@ (when-not composing? (reset! composing? true)) + ;; IME cancel (e.g. Escape on Linux ibus-mozc) fires compositionupdate + ;; with an empty string; that must reach WASM to clear the preview text. (let [data (.-data event)] - (when data + (when (some? data) (text-editor/text-editor-composition-update data) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-composition")) @@ -93,13 +95,12 @@ (mf/use-fn (fn [^js event] (reset! composing? false) - (let [data (.-data event)] - (when data - (text-editor/text-editor-composition-end data) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-composition")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))) + (let [data (or (.-data event) "")] + (text-editor/text-editor-composition-end data) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-composition")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) "")))) on-paste (mf/use-fn diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 6fc4a3e20a..189bbd8713 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -324,7 +324,7 @@ pub extern "C" fn text_editor_composition_start() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn text_editor_composition_end() -> Result<()> { - let bytes = crate::mem::bytes(); + let bytes = crate::mem::bytes_or_empty(); let text = match String::from_utf8(bytes) { Ok(text) => text, Err(_) => return Ok(()), @@ -380,7 +380,7 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn text_editor_composition_update() -> Result<()> { - let bytes = crate::mem::bytes(); + let bytes = crate::mem::bytes_or_empty(); let text = match String::from_utf8(bytes) { Ok(text) => text, Err(_) => return Ok(()), From e0be9f7adecdac04ec2ad5ad95924fbd8dfc7628 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 30 Jun 2026 14:01:38 +0200 Subject: [PATCH 035/100] :sparkles: Plugin for api testing (#10410) --- .github/workflows/tests-plugin-api-suite.yml | 133 + plugins/apps/plugin-api-test-suite/README.md | 391 + .../ci/fixtures/get-file.json | 60 + .../apps/plugin-api-test-suite/ci/run-ci.ts | 475 + .../plugin-api-test-suite/eslint.config.js | 27 + plugins/apps/plugin-api-test-suite/index.html | 12 + .../apps/plugin-api-test-suite/package.json | 22 + .../plugin-api-test-suite/public/_headers | 4 + .../public/assets/icon.png | Bin 0 -> 2171 bytes .../public/manifest.json | 18 + .../plugin-api-test-suite/src/ci/headless.ts | 39 + .../src/framework/coverage.ts | 287 + .../src/framework/expect.ts | 285 + .../src/framework/registry.ts | 115 + .../src/framework/runner.ts | 179 + .../src/framework/static-coverage.ts | 70 + .../src/framework/types.ts | 118 + .../src/generated/api-surface.json | 11192 ++++++++++++++++ .../apps/plugin-api-test-suite/src/model.ts | 60 + .../apps/plugin-api-test-suite/src/plugin.ts | 63 + .../plugin-api-test-suite/src/tests-bundle.ts | 15 + .../src/tests/colors.test.ts | 50 + .../src/tests/comments.test.ts | 158 + .../src/tests/components.test.ts | 135 + .../src/tests/events.test.ts | 67 + .../src/tests/file.test.ts | 103 + .../src/tests/fills-strokes.test.ts | 280 + .../src/tests/fixtures.ts | 12 + .../src/tests/fonts.test.ts | 122 + .../src/tests/interactions.test.ts | 333 + .../src/tests/layout.test.ts | 402 + .../src/tests/library.test.ts | 223 + .../src/tests/media.test.ts | 145 + .../src/tests/misc.test.ts | 389 + .../src/tests/pages.test.ts | 162 + .../src/tests/platform.test.ts | 164 + .../src/tests/plugin-data.test.ts | 108 + .../src/tests/shadows-blur.test.ts | 81 + .../src/tests/shapes-factories.test.ts | 318 + .../src/tests/shapes-geometry.test.ts | 408 + .../src/tests/shapes-types.test.ts | 299 + .../src/tests/text.test.ts | 327 + .../src/tests/tokens.test.ts | 482 + .../src/tests/value-objects.test.ts | 316 + .../src/tests/variants.test.ts | 201 + .../src/tests/viewport-guides.test.ts | 136 + plugins/apps/plugin-api-test-suite/src/ui.css | 334 + plugins/apps/plugin-api-test-suite/src/ui.ts | 558 + .../tools/gen-api-surface.ts | 339 + .../plugin-api-test-suite/tsconfig.app.json | 8 + .../apps/plugin-api-test-suite/tsconfig.json | 26 + .../plugin-api-test-suite/tsconfig.node.json | 19 + .../vite.config.headless.ts | 6 + .../plugin-api-test-suite/vite.config.iife.ts | 34 + .../vite.config.tests.ts | 8 + .../apps/plugin-api-test-suite/vite.config.ts | 39 + plugins/package.json | 3 +- plugins/pnpm-lock.yaml | 37 +- 58 files changed, 20394 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/tests-plugin-api-suite.yml create mode 100644 plugins/apps/plugin-api-test-suite/README.md create mode 100644 plugins/apps/plugin-api-test-suite/ci/fixtures/get-file.json create mode 100644 plugins/apps/plugin-api-test-suite/ci/run-ci.ts create mode 100644 plugins/apps/plugin-api-test-suite/eslint.config.js create mode 100644 plugins/apps/plugin-api-test-suite/index.html create mode 100644 plugins/apps/plugin-api-test-suite/package.json create mode 100644 plugins/apps/plugin-api-test-suite/public/_headers create mode 100644 plugins/apps/plugin-api-test-suite/public/assets/icon.png create mode 100644 plugins/apps/plugin-api-test-suite/public/manifest.json create mode 100644 plugins/apps/plugin-api-test-suite/src/ci/headless.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/coverage.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/expect.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/registry.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/runner.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/static-coverage.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/types.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/generated/api-surface.json create mode 100644 plugins/apps/plugin-api-test-suite/src/model.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/plugin.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests-bundle.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/colors.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/components.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/events.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/file.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/fixtures.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/library.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/media.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/misc.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/platform.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/shapes-types.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/text.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/ui.css create mode 100644 plugins/apps/plugin-api-test-suite/src/ui.ts create mode 100644 plugins/apps/plugin-api-test-suite/tools/gen-api-surface.ts create mode 100644 plugins/apps/plugin-api-test-suite/tsconfig.app.json create mode 100644 plugins/apps/plugin-api-test-suite/tsconfig.json create mode 100644 plugins/apps/plugin-api-test-suite/tsconfig.node.json create mode 100644 plugins/apps/plugin-api-test-suite/vite.config.headless.ts create mode 100644 plugins/apps/plugin-api-test-suite/vite.config.iife.ts create mode 100644 plugins/apps/plugin-api-test-suite/vite.config.tests.ts create mode 100644 plugins/apps/plugin-api-test-suite/vite.config.ts diff --git a/.github/workflows/tests-plugin-api-suite.yml b/.github/workflows/tests-plugin-api-suite.yml new file mode 100644 index 0000000000..e589c5414c --- /dev/null +++ b/.github/workflows/tests-plugin-api-suite.yml @@ -0,0 +1,133 @@ +name: "CI: Plugin API Test Suite" + +# Runs the Plugin API Test Suite (it exercises the real Penpot Plugin API, so it +# needs a running frontend + the plugin runtime). Two jobs: +# +# - api-test-suite-mocked (pull_request / push): the per-PR gate. Serves the +# prebuilt frontend bundle and intercepts every backend RPC with Playwright +# (MOCK_BACKEND=1). No backend / no login. Validates the frontend Plugin API +# binding + in-memory store; backend-result-dependent tests are skipped via the +# `skipIfMocked` tag. See plugins/apps/plugin-api-test-suite/README.md. +# +# - api-test-suite-live (workflow_dispatch): true end-to-end against a LIVE +# instance. Point PENPOT_BASE_URL at a reachable instance and provide login +# credentials via repo secrets. Manual because the CI runner has no Docker to +# stand up a full stack. + +defaults: + run: + shell: bash + +on: + workflow_dispatch: + inputs: + base_url: + description: "Penpot base URL (e.g. https://localhost:3449)" + required: false + default: "https://localhost:3449" + + pull_request: + paths: + - 'plugins/**' + - 'frontend/**' + - 'common/**' + types: + - opened + - synchronize + - ready_for_review + + push: + branches: + - develop + - staging + paths: + - 'plugins/**' + - 'frontend/src/app/plugins/**' + - 'common/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + api-test-suite-mocked: + if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }} + name: "Run Plugin API Test Suite (mocked)" + runs-on: penpot-runner-02 + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + + steps: + - uses: actions/checkout@v6 + + # Mocked mode serves the prebuilt bundle from frontend/resources/public. + - name: Build frontend bundle + working-directory: ./frontend + run: ./scripts/build + + - name: Install deps + working-directory: ./plugins + run: | + corepack enable; + corepack install; + pnpm install; + + - name: Install Playwright Chromium + working-directory: ./plugins + run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium + + - name: Generate API surface + working-directory: ./plugins + run: pnpm --filter plugin-api-test-suite run gen:api + + - name: Run API test suite (mocked) + working-directory: ./plugins + env: + MOCK_BACKEND: "1" + run: pnpm --filter plugin-api-test-suite run test:ci + + ## The following job will launch the whole suite of tests but we need + ## to have a full environment in the CI for this to work. + + # api-test-suite-live: + # if: ${{ github.event_name == 'workflow_dispatch' }} + # name: Run Plugin API Test Suite (live) + # runs-on: penpot-runner-02 + # container: + # image: penpotapp/devenv:latest + # + # env: + # PENPOT_BASE_URL: ${{ github.event.inputs.base_url }} + # E2E_LOGIN_EMAIL: ${{ secrets.E2E_LOGIN_EMAIL }} + # E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }} + # + # steps: + # - uses: actions/checkout@v6 + # + # - name: Setup Node + # uses: actions/setup-node@v6 + # with: + # node-version-file: .nvmrc + # + # - name: Install deps + # working-directory: ./plugins + # run: | + # corepack enable; + # corepack install; + # pnpm install; + # + # - name: Install Playwright Chromium + # working-directory: ./plugins + # run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium + # + # - name: Generate API surface + # working-directory: ./plugins + # run: pnpm --filter plugin-api-test-suite run gen:api + # + # # Note: requires a running Penpot instance reachable at PENPOT_BASE_URL. + # - name: Run API test suite + # working-directory: ./plugins + # run: pnpm --filter plugin-api-test-suite run test:ci diff --git a/plugins/apps/plugin-api-test-suite/README.md b/plugins/apps/plugin-api-test-suite/README.md new file mode 100644 index 0000000000..82313a1060 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/README.md @@ -0,0 +1,391 @@ +# Plugin API Test Suite + +A Penpot plugin that is a launcher + runner for a battery of tests exercising the +Penpot **Plugin API** against a live Penpot instance. It doubles as living +documentation of what the public API actually does at runtime. + +- A plain TypeScript + Vite Penpot plugin living in `plugins/apps/plugin-api-test-suite`. +- The UI (an iframe) lists auto-discovered tests and lets you run all / a subset / + one. Each test shows green (pass) or red (fail, with the error message). +- It reports **API coverage**: which members of the public Plugin API the tests + exercised, measured against `libs/plugin-types/index.d.ts`. +- The same test files run both in the plugin UI and in a headless CI runner, so a + test is never written twice. + +This document is the context a developer (or agent) needs to add tests. Read it +fully before writing any test. + +## The one rule that matters most + +> **Always call the API through `ctx.penpot`, never the global `penpot`.** + +`ctx.penpot` is a recording proxy. Calls made through it are what count towards +coverage and are correctly attributed to the right interface. Calls on the global +`penpot` still work but are invisible to coverage. Same for shapes: operate on the +objects returned by `ctx.penpot.*` (and on `ctx.board`), not on objects obtained +some other way. + +## Running and iterating + +From `plugins/`: + +- Dev server: `pnpm run start:plugin:api-test-suite` (serves on port 4202). +- In Penpot: open the Plugin Manager (Ctrl+Alt+P) and install + `http://localhost:4202/manifest.json`. +- **Hot-reloading tests:** after editing a `*.test.ts`, click **Reload** in the + plugin UI. It fetches the freshly built test bundle and swaps in your changes — + no need to close/reopen the plugin. (The dev server rebuilds the bundle on save.) +- **Adding a _new_ test file:** tests are discovered via `import.meta.glob` at + build time, and `vite build --watch` does not reliably pick up a brand-new file + (only edits to files already in its graph). After creating a new `*.test.ts`, + **restart the watch process** (`pnpm run watch` or `pnpm run init`) and then + click **Reload** (or reopen the plugin). Editing an existing test file does not + need this. +- The UI: tests are shown in **collapsible groups** (from `describe`) with per-group + passed/failed/total counts. Run with **Run all**, **Run selected** (per-test or + per-group checkboxes), the per-group **Run group**, or the per-row **Run** button. + Failures expand to show the error. The coverage panel shows the percentage, a + progress bar, and per-interface get/set/call targets. + +## Running in CI + +A headless runner executes the same tests against a live instance via Playwright: + +``` +E2E_LOGIN_EMAIL=… E2E_LOGIN_PASSWORD=… \ + pnpm --filter plugin-api-test-suite run test:ci +``` + +- It builds `headless.js`, logs in, creates a scratch file, injects the test + bundle, and prints per-test results + the coverage report. +- Exit code is non-zero iff any test failed (coverage does not affect it). +- Optional env: `PENPOT_BASE_URL` (default `https://localhost:3449`). Against a + local devenv with a self-signed certificate, prefix the command with + `NODE_TLS_REJECT_UNAUTHORIZED=0` to avoid a `fetch failed` TLS error. +- `PRINT_UNCOVERED=1` dumps the uncovered targets per interface; `PRINT_STATIC=1` + dumps the statically-covered ones (see [Coverage](#how-coverage-works-and-how-to-write-tests-that-move-it)). + +CI entry points reuse the exact same test files (`src/ci/headless.ts` discovers +them the same way the plugin does). + +### Mocked-backend mode + +The same runner can run without a live instance — it serves the prebuilt +frontend via the frontend e2e static server and intercepts every backend RPC +with Playwright `page.route`, reusing the frontend e2e mock fixtures: + +``` +pnpm --filter plugin-api-test-suite run test:ci:mocked +``` + +(equivalently `MOCK_BACKEND=1 … run test:ci`). No login or backend is needed. +This validates the frontend Plugin API binding + in-memory store only, so it +can't faithfully reproduce results that depend on real backend behaviour +(validation, persistence, generated ids, …). Tests that need the real backend +opt out of this mode by tagging themselves `skipIfMocked`: + +```ts +test.skipIfMocked('depends on backend validation', (ctx) => { + /* … */ +}); + +// or a whole group: +describe.skipIfMocked('Backend-dependent', () => { + /* … */ +}); +``` + +Skipped tests are listed in the runner output. The wiring (fixtures, RPC mocks, +WebSocket mock) lives in `ci/run-ci.ts`; mocked-mode fidelity is its main +limitation, so prefer the live `test:ci` for anything backend-sensitive. + +## Anatomy of a test + +Tests live in `src/tests/*.test.ts` and are **auto-discovered** (via +`import.meta.glob`) — just create a file matching that glob, no registration list +to update. A file registers one or more tests by calling `test(name, fn)`. + +```ts +import { expect } from '../framework/expect'; +import { test } from '../framework/registry'; + +test('creates a rectangle', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + + expect(rect.type).toBe('rectangle'); + rect.name = 'sample-rect'; + expect(rect.name).toBe('sample-rect'); +}); +``` + +### Grouping tests + +Wrap related tests in `describe(groupName, fn)` to group them. In the UI each group +is a **collapsible section** showing its own passed / failed / total counts, with a +"Run group" button and a select-all checkbox. Tests not inside any `describe` fall +into the `General` group. + +```ts +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; + +describe('Shapes', () => { + test('creates a rectangle', (ctx) => { + /* … */ + }); + + test('creates an ellipse', (ctx) => { + /* … */ + }); +}); +``` + +`describe` blocks may be nested in a file. Nested names are **joined into a single +group path** with `" / "`, so the group reveals the file/area it lives in — e.g. +`describe('Layout', () => describe('Flex', …))` produces the group `Layout / Flex`. +Wrap each file's tests in a top-level `describe` named after its area so every +group is recognizable. Several files may contribute to the same group path (they +merge in the UI). Prefer one clear group per feature area. + +In the UI each group header shows an aggregate **status dot** rolled up from its +tests: it turns purple while any test in the group is running, red if any failed, +green only once every test passed, and grey until then. + +### The test context (`ctx`) + +`fn` receives a `TestContext` (`src/framework/types.ts`): + +- `ctx.penpot` — the recording proxy over the real `penpot` global. Use it for + every API call. +- `ctx.board` — a **fresh scratch `Board`** created for this test and + **removed automatically afterwards**. Append shapes you create to it + (`ctx.board.appendChild(shape)`) so the user's canvas is left clean. Do not rely + on it persisting between tests. + +The runner also resets shared state between tests: the selection is cleared and the +active page is restored to whatever was active when the run started (both through +the raw `penpot`, so they aren't credited toward coverage). A test that changes the +active page therefore won't leak into later tests. + +### Sync or async + +`fn` may be `void` or `Promise`; async tests are awaited. Use `async (ctx) =>` +and `await` when the API call is asynchronous (e.g. `uploadMediaUrl`, +`library.availableLibraries()`, token application — see notes below). + +### Naming + +The test name becomes its id (slugified) and is shown in the UI. Keep names unique +and descriptive; duplicates are de-duplicated automatically but that's confusing. + +## Assertions + +Import `expect` from `../framework/expect`. It is a small, dependency-free, +jest-like matcher set (it must stay dependency-free — it runs inside the SES +sandbox). Available matchers: + +- `toBe(expected)` — `Object.is` equality +- `toEqual(expected)` — deep structural equality +- `toBeTruthy()` / `toBeFalsy()` +- `toBeNull()` / `toBeUndefined()` / `toBeDefined()` +- `toContain(item)` — substring or array membership +- `toHaveLength(n)` +- `toBeGreaterThan(n)` / `toBeLessThan(n)` +- `toBeCloseTo(n, numDigits?)` — for floats +- `toThrow(expected?)` — `expected` is a substring or `RegExp` matched against the + error message; pass a function as the value: `expect(() => …).toThrow('msg')` +- `.not` negates any matcher: `expect(x).not.toBeNull()` + +For asynchronous failures use `expectReject(promiseOrThunk, expected?)`: `toThrow` +calls its argument synchronously, so it can't catch a rejected promise, whereas +`expectReject` awaits and asserts the rejection (string includes / RegExp on the +message). + +A failing matcher throws; the runner turns that into a red test with the message. +You can also just `throw new Error('…')` to fail a test. + +> Do not add other assertion libraries. Anything imported here is bundled into the +> sandbox and must be SES-safe and dependency-free. + +## How coverage works (and how to write tests that move it) + +Coverage is **type-aware** and tracks three separate targets per member: + +- **`name (get)`** — reading a property (`const n = shape.name`) +- **`name (set)`** — writing a property (`shape.name = 'x'`) +- **`appendChild()`** — calling a method (credited only when actually **called**, + not when merely referenced) + +Implications when writing tests: + +- A property has independent get/set targets. To cover both, read it _and_ write + it. Read-only properties (declared `readonly` in the d.ts) only have a get + target; methods only have a call target. +- Accessing a member through a value you got from `ctx.penpot` is what counts. + Reaching a nested object also counts: e.g. `ctx.board.children[0].type` records + `Board.children (get)` and then the element's `type` get, resolved to the + concrete shape type at runtime. +- Coverage **accumulates across a run**. Running all tests aggregates every test's + accesses. Running a single test shows only that test's accesses. + +### Recorded vs. effective coverage + +The report distinguishes three states per target: + +- **Covered (recorded)** — credited by the recording proxy (green). +- **Statically covered** — exercised behaviourally by the tests but the proxy + _structurally cannot_ credit it (shown in a distinct colour). These come from a + curated allowlist in `src/framework/static-coverage.ts`, keyed by + `Interface.member#mode`. See [Coverage notes](#coverage-notes) for which members + and why. +- **Uncovered** — neither. + +The header shows two numbers: the **recorded** percentage (what the proxy actually +credited) and the **effective** percentage (recorded + statically covered). +Recorded coverage always wins, so listing a target in the static allowlist that +turns out to be recorded is harmless — it simply never shows as static. Coverage is +report-only; it never fails a run or the build. + +The denominator comes from `src/generated/api-surface.json`, generated from +`libs/plugin-types/index.d.ts`. If the Plugin API types change, regenerate it: + +``` +pnpm --filter plugin-api-test-suite run gen:api +``` + +## Runtime details you need to know + +- **Shape `type` values** returned at runtime: `Board` → `'board'`, + `Rectangle` → `'rectangle'`, `Ellipse` → `'ellipse'`, plus `'text'`, `'path'`, + `'group'`, `'image'`, `'svg-raw'`. (`createRectangle().type === 'rectangle'`.) +- `createText(str)` returns `Text | null` — guard the result (`if (text) { … }`). +- `width`/`height` are read-only; use `resize(w, h)`. `x`/`y` are writable. +- The plugin manifest already requests broad permissions (`content:*`, + `library:*`, `user:read`, `comment:*`, `allow:downloads`, `allow:localstorage`), + so most of the API is callable from tests without changes. +- The runner sets `throwValidationErrors = true` and `naturalChildOrdering = true`, + so invalid API usage throws (surfacing as a red test) and `children` is always in + z-index order. +- The runtime is SES-sandboxed: no Node APIs, no DOM, no extra npm deps inside + tests. Stick to the Plugin API, `expect`, and plain JS. + +## Coverage notes + +The suite covers a large majority of the type surface. The remaining members are +uncovered or only _statically_ covered for the reasons below — **not** missing +tests. Note these notes can drift as the API is fixed: when in doubt, write the +test asserting the documented correct behaviour and run `test:ci` to see what +actually happens. + +### Exercised behaviourally but not creditable by the recorder (statically covered) + +Listed in `src/framework/static-coverage.ts`: + +- **`ContextTypesUtils.*` and `ContextGeometryUtils.center`** — `penpot.utils.types` + and `penpot.utils.geometry` are frozen (SES) data properties, so the recording + proxy must return them raw and cannot wrap their members. Both are exercised + behaviourally in `platform.test.ts`. +- **`ColorShapeInfo.shapesInfo`, `ColorShapeInfoEntry.*`** — `shapesColors()` has an + unresolved return type in the generated surface (`type: null`), so the recorder + hands the result back raw and can't attribute nested access. Exercised in + `colors.test.ts`. (Alternatively, resolving the return type in + `tools/gen-api-surface.ts` would make these genuinely recorded.) +- **`EventsMap.*`** — a type map, not a runtime object. `on`/`off` are credited on + `Penpot`, never as `EventsMap` members. The deterministic events + (`selectionchange`, `shapechange`) are exercised in `events.test.ts`. +- **`ShapeBase.fills`** — every concrete shape redeclares `fills`, so accesses are + attributed to the concrete type (`Rectangle.fills`, …); the base-interface target + is never the attribution. +- **`LibraryVariantComponent.*`** — the recorder types a component as + `LibraryComponent` and can't narrow to `LibraryVariantComponent` via the + `isVariant()` type-guard. The behaviour is exercised via `VariantContainer.variants` + in `variants.test.ts`. + +### Read-only at runtime + +Members that have no setter in the runtime binding (`frontend/src/app/plugins/*.cljs`) +are now marked `readonly` in the Plugin API d.ts (`Font.*`, `FontVariant.*`, +`FontsContext.all`, `Image/Ellipse/SvgRaw.type`, `File.name/pages/revn`, `Page.root`, +`TokenTheme.activeSets`, `Variants.properties`, `ImageData.*`, and the board guide +value objects `GuideColumn/GuideRow/GuideSquare` and their params — `board.guides` +returns a formatted snapshot, so guides are reconfigured by reassigning the whole +array, not by mutating a returned guide), the `Point`/`Bounds` value objects, the +`Penpot.ui`/`Penpot.utils` subcontexts, and the derived `Boolean` path data +(`d`/`content`/`commands` are computed from the operands — a `Boolean` isn't editable +like a `Path`). They therefore have only a `(get)` target and need no runtime +assertion — the type system enforces the contract. + +Members that **do** have a runtime setter stay writable, even when the setter +rejects some inputs (that's input validation, not read-only-ness): `Board.children` +(assigning a reordered array reorders the children), `Path.d/content/commands` +(editing the path), and `FileVersion.label` (relabels the version). + +### Excluded from coverage + +`tools/gen-api-surface.ts` drops two categories from the denominator so they never +count: + +- **`@deprecated` interfaces and members** — the legacy `Image` shape interface + (images live in a `Fill` via `fillImage`), `Color.refId`/`refFile`, and the + `Boolean`/`Path` `toD()`/`content` path accessors. +- **Members removed by the public interface via `Omit`** — `Context` is the + internal interface and the public `Penpot` is `Omit` (those are superseded by `on`/`off`). The generator honors the + `Omit`, so `Context.addListener`/`removeListener` aren't reachable surface and + don't count. + +### Red tests pinning confirmed API bugs + +When a member is confirmed broken, add a test that asserts its **correct** behaviour +and comment it as blocked-by-bug; it stays red until the API is fixed and then turns +green (at which point drop the "API bug" framing). There are currently no such red +tests — e.g. the `fontFamilies` token `resolvedValue` bug (it used to leak the raw +tokenscript structure instead of `string[]`) has since been fixed. + +### d.ts / runtime mismatches + +`strokeStyle: 'none'` is listed in the d.ts but rejected at runtime ("Value not +valid"); `fills-strokes.test.ts` pins this with a `toThrow`. + +### External state / not reachable headless + +- **`ActiveUser.position/zoom`** — needs a second collaborator in the file. +- **`LibrarySummary.*`, `LibraryContext.connectLibrary`** — need a published shared + library. +- **`FileVersion.restore`, `Penpot.closePlugin`, `Penpot.ui`, `Context.openViewer`** — + tear down or navigate away from the running plugin/workspace. +- **`FileVersion.pin`** — only converts a _system_ autosave to a permanent version; + a plugin can only create manual versions (`saveVersion`), so `pin()` always + rejects. +- **`Context.addListener/removeListener`** — omitted from the `penpot` global + (`Omit`), so unreachable via `penpot`. +- **`EventsMap` events `pagechange/filechange/themechange/contentsave/finish`** — + can't be triggered deterministically in the headless runner. + +## Checklist before finishing + +- [ ] Test file is `src/tests/.test.ts` and uses `test(...)` + `expect`, + ideally wrapped in a `describe('', …)`. +- [ ] All API calls go through `ctx.penpot`; shapes are appended to `ctx.board`. +- [ ] Created shapes don't leak (rely on the scratch board cleanup; don't touch the + user's existing content). +- [ ] Lint/format/typecheck pass: + `pnpm --filter plugin-api-test-suite run lint` and, from `plugins/`, + `pnpm exec prettier --check "apps/plugin-api-test-suite/**/*.{ts,css,json}"`. +- [ ] If you relied on new API members, `gen:api` was re-run so coverage reflects + them. + +## Where things live (for deeper changes) + +- `src/framework/registry.ts` — `test()`, `describe()`, `getTests()`, `setTests()` (reload). +- `src/framework/runner.ts` — runs tests, scratch board lifecycle, per-test state reset, coverage. +- `src/framework/coverage.ts` — the recording proxy + coverage computation. +- `src/framework/static-coverage.ts` — the statically-covered allowlist. +- `src/framework/expect.ts` — the assertion library. +- `src/framework/types.ts` — `TestContext`, `TestResult`, `CoverageReport`, etc. +- `tools/gen-api-surface.ts` — generates `src/generated/api-surface.json`. +- `src/plugin.ts` (sandbox), `src/ui.ts` (iframe), `src/model.ts` (messages). +- `src/ci/headless.ts` + `ci/run-ci.ts` — CI path. + +Writing tests should only ever require touching `src/tests/`. diff --git a/plugins/apps/plugin-api-test-suite/ci/fixtures/get-file.json b/plugins/apps/plugin-api-test-suite/ci/fixtures/get-file.json new file mode 100644 index 0000000000..dcc331badd --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/ci/fixtures/get-file.json @@ -0,0 +1,60 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "fdata/shape-data-type", + "fdata/path-data", + "components/v2", + "design-tokens/v1", + "variants/v1", + "plugins/runtime" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 1", + "~:revn": 11, + "~:modified-at": "~m1713873823633", + "~:id": "~uc7ce0794-0992-8105-8004-38f280443849", + "~:is-shared": false, + "~:version": 46, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1713536343369", + "~:data": { + "~:pages": ["~u66697432-c33d-8055-8006-2c62cc084cad"], + "~:pages-index": { + "~u66697432-c33d-8055-8006-2c62cc084cad": { + "~#penpot/pointer": [ + "~ude58c8f6-c5c2-8196-8004-3df9e2e52d88", + { + "~:created-at": "~m1713873823636" + } + ] + } + }, + "~:id": "~uc7ce0794-0992-8105-8004-38f280443849", + "~:options": { + "~:components-v2": true + }, + "~:recent-colors": [ + { + "~:color": "#0000ff", + "~:opacity": 1, + "~:id": null, + "~:file-id": null, + "~:image": null + } + ] + } +} diff --git a/plugins/apps/plugin-api-test-suite/ci/run-ci.ts b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts new file mode 100644 index 0000000000..5b03d30386 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts @@ -0,0 +1,475 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, type Page } from 'playwright'; +import type { CoverageReport, TestResult } from '../src/framework/types'; + +// Out-of-sandbox CI driver (Node + Playwright). Injects the prebuilt +// `headless.js` bundle (built from the in-sandbox entry `src/ci/headless.ts` — +// note: a different `ci/` directory) into the plugin sandbox via +// `globalThis.ɵloadPlugin` and captures results/coverage from the page console. +// Two modes: +// +// - LIVE (default): logs into a real Penpot instance (devenv), creates a scratch +// file, and drives the real backend + frontend end-to-end. +// Required env: E2E_LOGIN_EMAIL, E2E_LOGIN_PASSWORD. +// Optional env: PENPOT_BASE_URL (default https://localhost:3449). +// +// - MOCKED (`MOCK_BACKEND=1`): serves the prebuilt frontend bundle via the e2e +// static server and intercepts every backend RPC with Playwright `page.route`, +// reusing the frontend e2e mock fixtures. No backend/login needed. Validates +// the frontend Plugin API binding + in-memory store only; results that depend +// on real backend behaviour are not faithfully reproduced, so those tests are +// skipped via the `skipIfMocked` tag. + +const here = dirname(fileURLToPath(import.meta.url)); +// here = /plugins/apps/plugin-api-test-suite/ci +const repoRoot = resolve(here, '../../../../'); +const frontendDir = resolve(repoRoot, 'frontend'); +const e2eDataDir = resolve(frontendDir, 'playwright/data'); + +const MOCKED = !!process.env['MOCK_BACKEND']; +const MOCK_BASE_URL = 'http://localhost:3000'; +const apiUrl = MOCKED + ? MOCK_BASE_URL + : (process.env['PENPOT_BASE_URL'] ?? 'https://localhost:3449'); + +const headlessBundlePath = resolve( + here, + '../../../dist/apps/plugin-api-test-suite/headless.js', +); + +// Source the permissions from the same manifest the real plugin ships with, so +// the CI sandbox never drifts from what users actually grant. +const manifestPath = resolve(here, '../public/manifest.json'); +const PERMISSIONS: string[] = ( + JSON.parse(readFileSync(manifestPath, 'utf-8')) as { permissions: string[] } +).permissions; + +function cleanId(id: string): string { + return id.replace('~u', ''); +} + +interface FileRpc { + '~:id': string; + '~:project-id': string; + '~:data': { '~:pages': string[] }; +} + +async function login() { + const email = process.env['E2E_LOGIN_EMAIL']; + const password = process.env['E2E_LOGIN_PASSWORD']; + if (!email || !password) { + throw new Error('E2E_LOGIN_EMAIL / E2E_LOGIN_PASSWORD must be set'); + } + + const response = await fetch( + `${apiUrl}/api/main/methods/login-with-password`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }, + ); + + const loginData = await response.json(); + const authToken = response.headers + .getSetCookie() + .find((cookie) => cookie.startsWith('auth-token=')) + ?.split(';')[0]; + + if (!authToken) + throw new Error('Login failed: no auth-token cookie returned'); + + return { authToken, defaultProjectId: loginData['~:default-project-id'] }; +} + +async function createFile( + authToken: string, + projectId: string, +): Promise { + const response = await fetch(`${apiUrl}/api/main/methods/create-file`, { + method: 'POST', + headers: { + 'Content-Type': 'application/transit+json', + cookie: authToken, + }, + body: JSON.stringify({ + '~:name': `api-test-suite ${new Date().toISOString()}`, + '~:project-id': projectId, + '~:features': { + '~#set': [ + 'fdata/objects-map', + 'fdata/pointer-map', + 'fdata/shape-data-type', + 'fdata/path-data', + 'design-tokens/v1', + 'variants/v1', + 'components/v2', + 'styles/v2', + 'layout/grid', + 'plugins/runtime', + ], + }, + }), + }); + + return (await response.json()) as FileRpc; +} + +function getFileUrl(file: FileRpc): string { + const projectId = cleanId(file['~:project-id']); + const fileId = cleanId(file['~:id']); + const pageId = cleanId(file['~:data']['~:pages'][0]); + return `${apiUrl}/#/workspace/${projectId}/${fileId}?page-id=${pageId}`; +} + +// --- Mocked mode setup ------------------------------------------------------- + +// Ids of the mocked full-feature file fixture (`ci/fixtures/get-file.json`), +// kept in sync with the frontend e2e fixtures. +const MOCK_TEAM_ID = 'c7ce0794-0992-8105-8004-38e630f7920a'; +const MOCK_FILE_ID = 'c7ce0794-0992-8105-8004-38f280443849'; +const MOCK_PAGE_ID = '66697432-c33d-8055-8006-2c62cc084cad'; + +// Workspace-load RPCs mirrored from the frontend e2e harness +// (WorkspacePage.init + setupEmptyFile). Maps RPC glob -> fixture file relative +// to frontend/playwright/data. +const MOCK_RPCS: Record = { + 'get-profile': 'logged-in-user/get-profile-logged-in.json', + 'get-teams': 'get-teams.json', + 'get-team?id=*': 'workspace/get-team-default.json', + 'get-team-members?team-id=*': + 'logged-in-user/get-team-members-your-penpot.json', + 'get-team-users?file-id=*': 'logged-in-user/get-team-users-single-user.json', + 'get-project?id=*': 'workspace/get-project-default.json', + 'get-comment-threads?file-id=*': 'workspace/get-comment-threads-empty.json', + 'get-profiles-for-file-comments?file-id=*': + 'workspace/get-profile-for-file-comments.json', + 'get-file-object-thumbnails?file-id=*': + 'workspace/get-file-object-thumbnails-blank.json', + 'get-font-variants?team-id=*': 'workspace/get-font-variants-empty.json', + 'get-file-fragment?file-id=*': 'workspace/get-file-fragment-blank.json', + 'get-file-libraries?file-id=*': 'workspace/get-file-libraries-empty.json', + 'update-profile-props': 'workspace/update-profile-empty.json', +}; + +// Persistence (`update-file`) response shape the frontend expects: it reads +// `revn`/`lagged` (persistence.cljs `update-file-revn`). `revn` is merged with +// `max`, so a low value is harmless. +const UPDATE_FILE_RESPONSE = JSON.stringify({ '~:revn': 1, '~:lagged': [] }); + +async function waitForServer(url: string, timeoutMs = 30000): Promise { + const start = Date.now(); + for (;;) { + try { + const res = await fetch(url); + if (res.ok || res.status === 404) return; // static server is up + } catch { + /* not up yet */ + } + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for server at ${url}`); + } + await new Promise((r) => setTimeout(r, 250)); + } +} + +function startE2eServer(): ChildProcess { + // Reuse the frontend e2e static server: it serves frontend/resources/public + // on port 3000, which is also the host the app opens its notifications + // WebSocket against (ws://localhost:3000/ws/notifications) — so the WS mock + // below matches without extra config. + const child = spawn('node', ['scripts/e2e-server.js'], { + cwd: frontendDir, + stdio: 'inherit', + }); + return child; +} + +// Install the frontend e2e WebSocket mock so the workspace's notifications +// socket can be "opened" without a backend. +async function installWebSocketMock(page: Page): Promise { + const created = new Set(); + await page.exposeFunction('onMockWebSocketConstructor', (url: string) => { + created.add(url); + }); + await page.addInitScript({ + path: resolve(frontendDir, 'playwright/scripts/MockWebSocket.js'), + }); + // Stash the helper on the page object for later use. + (page as unknown as { __wsCreated: Set }).__wsCreated = created; +} + +async function openNotificationsWebSocket(page: Page): Promise { + const created = (page as unknown as { __wsCreated: Set }).__wsCreated; + const start = Date.now(); + let wsUrl: string | undefined; + while (!wsUrl) { + wsUrl = [...created].find((u) => u.includes('ws/notifications')); + if (wsUrl) break; + if (Date.now() - start > 30000) { + throw new Error('Timed out waiting for notifications WebSocket'); + } + await new Promise((r) => setTimeout(r, 50)); + } + await page.evaluate((url) => { + ( + WebSocket as unknown as { + getByURL: (u: string) => { mockOpen: () => void } | undefined; + } + ) + .getByURL(url) + ?.mockOpen(); + }, wsUrl); +} + +async function setupMockedRoutes(page: Page): Promise { + // Config flags: deterministic empty flags (mirror BasePage.mockConfigFlags). + await page.route('**/js/config.js*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'var penpotFlags = "";\n', + }), + ); + + // Workspace-load RPCs from fixtures. + for (const [rpc, fixture] of Object.entries(MOCK_RPCS)) { + await page.route(`**/api/main/methods/${rpc}`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/transit+json', + path: resolve(e2eDataDir, fixture), + }), + ); + } + + // get-file: the custom full-feature fixture (enables plugins/runtime, + // design-tokens/v1, variants/v1, ...). Without these features active the + // plugin runtime never initialises. + await page.route(/\/api\/main\/methods\/get-file\?/, (route) => + route.fulfill({ + status: 200, + contentType: 'application/transit+json', + path: resolve(here, 'fixtures/get-file.json'), + }), + ); + + // Blanket no-op persistence: most of the Plugin API mutates the in-memory + // store optimistically, so a 200 `update-file` mock is enough for the bulk of + // the suite to run against in-memory state. + await page.route(/\/api\/main\/methods\/update-file\b/, (route) => + route.fulfill({ + status: 200, + contentType: 'application/transit+json', + body: UPDATE_FILE_RESPONSE, + }), + ); +} + +function mockedFileUrl(): string { + return `${MOCK_BASE_URL}/#/workspace?team-id=${MOCK_TEAM_ID}&file-id=${MOCK_FILE_ID}&page-id=${MOCK_PAGE_ID}`; +} + +// --- Reporting --------------------------------------------------------------- + +function printReport( + results: TestResult[], + coverage: CoverageReport | null, + skipped: string[], +) { + // Each result is already printed live as it streams in; here we only recap the + // failures so they're easy to find at the bottom of a long run. + const failures = results.filter((r) => r.status === 'fail'); + if (failures.length > 0) { + console.log('\nFailures:'); + for (const r of failures) { + console.log(` ✗ ${r.name} (${r.durationMs}ms)`); + if (r.error) { + console.log(` ${r.error}`); + } + } + } + + if (skipped.length > 0) { + console.log(`\nSkipped (mocked mode): ${skipped.length}`); + for (const name of skipped) { + console.log(` - ${name}`); + } + } + + if (coverage) { + console.log( + `\nAPI coverage (report-only): ${coverage.percent}% recorded ` + + `(${coverage.covered}/${coverage.total}), ` + + `${coverage.effectivePercent}% effective ` + + `(+${coverage.staticallyCovered} statically covered)`, + ); + + // Opt-in dump of the uncovered targets per interface, to drive test writing. + if (process.env['PRINT_UNCOVERED']) { + console.log('\nUncovered targets by interface:'); + for (const [iface, info] of Object.entries(coverage.byInterface)) { + if (info.uncovered.length > 0) { + console.log(` ${iface}: ${info.uncovered.join(', ')}`); + } + } + } + + // Opt-in dump of the statically-covered targets (exercised behaviourally but + // not creditable through the recording proxy). + if (process.env['PRINT_STATIC']) { + console.log('\nStatically covered targets by interface:'); + for (const [iface, info] of Object.entries(coverage.byInterface)) { + if (info.staticallyCovered.length > 0) { + console.log(` ${iface}: ${info.staticallyCovered.join(', ')}`); + } + } + } + } +} + +async function main() { + const bundle = readFileSync(headlessBundlePath, 'utf-8'); + + let server: ChildProcess | undefined; + let fileUrl: string; + let authToken: string | undefined; + + if (MOCKED) { + server = startE2eServer(); + await waitForServer(MOCK_BASE_URL); + fileUrl = mockedFileUrl(); + } else { + const session = await login(); + authToken = session.authToken; + const file = await createFile(authToken, session.defaultProjectId); + fileUrl = getFileUrl(file); + } + + const browser = await chromium.launch({ + args: ['--ignore-certificate-errors'], + }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + if (authToken) { + await context.addCookies([ + { name: 'auth-token', value: authToken.split('=')[1], url: apiUrl }, + ]); + } + + const page = await context.newPage(); + + if (MOCKED) { + await installWebSocketMock(page); + await setupMockedRoutes(page); + } + + // The bundle runs inside an SES Compartment (its own `globalThis`), so a page + // `addInitScript` global can't reach it. Prepend the mocked flag straight into + // the evaluated code so the bundle's `runTests` excludes `skipIfMocked` tests. + const injectedCode = MOCKED + ? `globalThis.__PLUGIN_SUITE_MOCKED__ = true;\n${bundle}` + : bundle; + + const results: TestResult[] = []; + let coverage: CoverageReport | null = null; + let skipped: string[] = []; + let fatal: string | null = null; + + console.log('\nRunning tests:'); + const done = new Promise((resolvePromise) => { + page.on('console', (msg) => { + const text = msg.text(); + if (text.startsWith('__TEST_RESULT__ ')) { + const result: TestResult = JSON.parse( + text.slice('__TEST_RESULT__ '.length), + ); + results.push(result); + // Print each result as it streams in so the run shows live progress + // instead of staying silent until it finishes. + const icon = result.status === 'pass' ? '✓' : '✗'; + console.log(` ${icon} ${result.name} (${result.durationMs}ms)`); + if (result.status === 'fail' && result.error) { + console.log(` ${result.error}`); + } + } else if (text.startsWith('__TEST_COVERAGE__ ')) { + coverage = JSON.parse(text.slice('__TEST_COVERAGE__ '.length)); + } else if (text.startsWith('__TEST_SKIPPED__ ')) { + skipped = JSON.parse(text.slice('__TEST_SKIPPED__ '.length)); + } else if (text.startsWith('__TEST_DONE__ ')) { + resolvePromise(); + } else if (text.startsWith('__TEST_FATAL__ ')) { + fatal = JSON.parse(text.slice('__TEST_FATAL__ '.length)).message; + resolvePromise(); + } + }); + }); + + await page.goto(fileUrl); + + if (MOCKED) { + await openNotificationsWebSocket(page); + } + + await page.waitForSelector('[data-testid="viewport"]'); + // The plugin runtime initialises asynchronously after the file's features are + // active; wait for the loader to be exposed before injecting the bundle. + await page.waitForFunction( + () => + typeof (globalThis as unknown as { ɵloadPlugin?: unknown }) + .ɵloadPlugin === 'function', + { timeout: 30000 }, + ); + + await page.evaluate( + ({ code, permissions }) => { + ( + globalThis as unknown as { ɵloadPlugin: (m: unknown) => void } + ).ɵloadPlugin({ + pluginId: '00000000-0000-0000-0000-000000000000', + name: 'Plugin API Test Suite (CI)', + code, + icon: '', + description: '', + permissions, + }); + }, + { code: injectedCode, permissions: PERMISSIONS }, + ); + + await Promise.race([ + done, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timed out waiting for test results')), + 120000, + ), + ), + ]); + + await browser.close(); + server?.kill(); + + printReport(results, coverage, skipped); + + if (fatal) { + console.error(`\nFatal error while running tests: ${fatal}`); + process.exit(1); + } + + const failed = results.filter((r) => r.status === 'fail').length; + const passed = results.filter((r) => r.status === 'pass').length; + console.log( + `\n${passed} passed, ${failed} failed${ + skipped.length ? `, ${skipped.length} skipped` : '' + }.`, + ); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/plugins/apps/plugin-api-test-suite/eslint.config.js b/plugins/apps/plugin-api-test-suite/eslint.config.js new file mode 100644 index 0000000000..961ac200ec --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/eslint.config.js @@ -0,0 +1,27 @@ +import baseConfig from '../../eslint.config.js'; + +export default [ + ...baseConfig, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + project: './tsconfig.*?.json', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: {}, + }, + { + ignores: [ + '**/assets/*.js', + 'vite.config.ts', + 'vite.config.headless.ts', + 'vite.config.tests.ts', + 'vite.config.iife.ts', + ], + }, +]; diff --git a/plugins/apps/plugin-api-test-suite/index.html b/plugins/apps/plugin-api-test-suite/index.html new file mode 100644 index 0000000000..22b70e5f1f --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/index.html @@ -0,0 +1,12 @@ + + + + + Plugin API Test Suite + + + +
+ + + diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json new file mode 100644 index 0000000000..43bf67412c --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -0,0 +1,22 @@ +{ + "name": "plugin-api-test-suite", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build --emptyOutDir && pnpm run build:headless && pnpm run build:tests", + "build:headless": "vite build --config vite.config.headless.ts", + "build:tests": "vite build --config vite.config.tests.ts", + "watch": "concurrently --kill-others --names app,tests \"vite build --watch --mode development\" \"vite build --watch --mode development --config vite.config.tests.ts\"", + "serve": "vite preview", + "init": "concurrently --kill-others --names build,serve \"pnpm run watch\" \"pnpm run serve\"", + "lint": "eslint .", + "gen:api": "tsx tools/gen-api-surface.ts", + "test:ci": "pnpm run build:headless && tsx ci/run-ci.ts", + "test:ci:mocked": "pnpm run build:headless && MOCK_BACKEND=1 tsx ci/run-ci.ts" + }, + "devDependencies": { + "playwright": "^1.61.0" + } +} diff --git a/plugins/apps/plugin-api-test-suite/public/_headers b/plugins/apps/plugin-api-test-suite/public/_headers new file mode 100644 index 0000000000..cdb4e7ed20 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/public/_headers @@ -0,0 +1,4 @@ +/* +Access-Control-Allow-Origin: * +Access-Control-Allow-Methods: GET, POST, OPTIONS +Access-Control-Allow-Headers: Content-Type diff --git a/plugins/apps/plugin-api-test-suite/public/assets/icon.png b/plugins/apps/plugin-api-test-suite/public/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4f010b502f09ca1c2d663af93c67ce45d5182633 GIT binary patch literal 2171 zcmV->2!!{EP)Px-HAzH4RCt{2Tg`7&*A@SrbMKq+%y?om7~8Q^3?}4*#!3m1P+JKRsf!>rU9#z- zQj=91seeXQRqCpnC}EkZMCzuyO0CqCBAT>FsDU6L6zm3Y{Q>@(8P9w7-qXbxdp-oN*;mMZV)8tZt)G#-58mBLQ5aVI=GWmL|e*U9zkjP>>U!@fR5$Bw!D*|UZUg_M;A`s{|Sov>8ZMs#hBEE9Pd#(7&6-KqX`CC8ugGx1M5U$;adUV-)EpJ6LD?vxg{BKL& zEi4cupgxJfE-Y-?Q}w?=Yt#CSxBfRW2T&9}nGI*2u0svt4M22|gQ{!v0&Q+NkBkUq z{DYt<0*{Pr+PlJJP zrW!Pe%Y94iz;SOsy#b&I7&x0TKDwEgSaK&-Q^2`*-Z98#cQ(#u>$(l%hJWDfz#Cpc z*~33KAt@771G2BNWRG#WBLocoxE&}e7SVU+%#J(RcjkK%&RP=pJoys=v;*D-Mh4Q?p)hIM1zAUy!T$BCnh=uK=A^i zuYRrh(dW%ba2Cm-pBel7kAmtHx&;6mgHdu96i*vggDjcD?9>6z?qeg`G& zJSb{>T%i2gJy-7mt6wrD4@vr8cCP;=fy#FiM)v>!04!h^g}6vUxo~q?UM2vbfq`%m z5F7vqs36E3Wt(|}ETz4*T!y)PS-aU;MzWDZ zQ3M_xb^62!gETfM38JM-O09jV*sj%|k%G;PiYc8kHaot%Y*4De`e&k(o6@lwQ9+PD ztr9if!Wx`|y?$NUt5+3Fhi0NR)YOz!ue@TxEool4#hJyw=*_^l5!3&gOG>AVuzxxP zbpdk+oEAI)NSUzy52w*u@5^PR1`hznz<%+CLMs~-i_FqRl@>ez=(Ve?D>s8;@@Io9 zs0ZdDb`ezjr)(vrb4V^;j8cPtEXE+YbSY}&4$;!z6~_x+6*gM@RI_>HqY8uo5Z@Lh zUSrUP{;e$lWObEfanYNg$oxFX%F3p`Uc2UB0! zNOZLM#OT*wH(sYeYv|+0o7S;~W6t(<<_L|FlujF@PGZdx)HBsVhNZ&gQ~Co^-_plIz0NOQO*drW%Lj-{OQ)1~FM! zpya}Z2$hyEx49hjsZ$9L4RvONb`9CZKWVPqY2G-}h)bu9DSqdhV}o#jlmS-;Q3P;5 z#Jf|7K&n+prBWwD`MgjrhsL~5b=p-yW|c@1i0&81{pgkubw!2%00O`f!GYl*^(X$P zkBEqw2>_%XxVJG8Kve&yz7+urw@6n2xLIO1tu!_coNRc(`^51PXAq_MQ>_wDPe;@; z7K_%9iHSsVxy~sd2{P+9BW;%(kBR~qwZ6l~=#gDc0Snl#l$M9oxp+nbb<`y^(0&m> z^Ye=5=2XW!0wTDP5i3JOZN*aAWy-GKj40(^&gfwm_e~lJsO>>rK<1fKg9m_w;HFu( z9!g3Q@cew|;8Cv)JU6GPZFCl7YIk&6@BpCA9BVVTJ8Td&f}LTnl#w!#o8hghBm$b5 ziQqg=sB@sNzizro$;8u&UB#70aUoI-w?0Q&bLZ~4TbP<02JV?KLDZqLO0|(cX0*TU?Tye zE$Wx#{>%6(QXXO3nBS^)lFdpG2+Vbx4Y8aLyBk!6r_Tmy&aAJ-D;v2obvEX^zN&<2_72 xvIjt~9q6^}0nlp)dM$eZ^xA=5%QFwa{{gL)lgoyKnVkRt002ovPDHLkV1g829PI!A literal 0 HcmV?d00001 diff --git a/plugins/apps/plugin-api-test-suite/public/manifest.json b/plugins/apps/plugin-api-test-suite/public/manifest.json new file mode 100644 index 0000000000..a56ee76498 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/public/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Plugin API Test Suite", + "description": "Launcher for a battery of Penpot Plugin API tests", + "code": "plugin.js", + "version": 2, + "icon": "assets/icon.png", + "permissions": [ + "content:read", + "content:write", + "library:read", + "library:write", + "user:read", + "comment:read", + "comment:write", + "allow:downloads", + "allow:localstorage" + ] +} diff --git a/plugins/apps/plugin-api-test-suite/src/ci/headless.ts b/plugins/apps/plugin-api-test-suite/src/ci/headless.ts new file mode 100644 index 0000000000..9374ab4e48 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/ci/headless.ts @@ -0,0 +1,39 @@ +import { runTests } from '../framework/runner'; + +// In-sandbox CI entry point. Built as a standalone IIFE bundle (headless.js) and +// evaluated inside a real Penpot plugin sandbox by the out-of-sandbox driver +// `ci/run-ci.ts` (note: distinct from this `src/ci/` directory). It runs every +// test and reports results + coverage through `console.log` markers that the +// Playwright driver parses. It has no UI. + +// Auto-discover the same tests used by the UI plugin. +import.meta.glob('../tests/*.test.ts', { eager: true }); + +async function main() { + // Set by the mocked-backend runner (MOCK_BACKEND=1) before this bundle loads, + // so backend-result-dependent tests tagged `skipIfMocked` are excluded. + const skipMocked = !!( + globalThis as unknown as { __PLUGIN_SUITE_MOCKED__?: boolean } + ).__PLUGIN_SUITE_MOCKED__; + + // Stream each result as it completes (not just at the end) so the runner sees + // progress and partial output survives if a later test hangs to its timeout. + const { summary, coverage, skipped } = await runTests( + 'all', + (result) => { + if (result.status !== 'running') { + console.log('__TEST_RESULT__ ' + JSON.stringify(result)); + } + }, + { skipMocked }, + ); + + console.log('__TEST_COVERAGE__ ' + JSON.stringify(coverage)); + console.log('__TEST_SKIPPED__ ' + JSON.stringify(skipped)); + console.log('__TEST_DONE__ ' + JSON.stringify(summary)); +} + +main().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + console.log('__TEST_FATAL__ ' + JSON.stringify({ message })); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/framework/coverage.ts b/plugins/apps/plugin-api-test-suite/src/framework/coverage.ts new file mode 100644 index 0000000000..cbf36aa048 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/coverage.ts @@ -0,0 +1,287 @@ +import { STATIC_COVERAGE } from './static-coverage'; +import type { ApiSurface, CoverageReport, InterfaceCoverage } from './types'; + +export interface Recorder { + /** Proxy to hand to tests; mirrors `root` but records member access. */ + proxy: T; + /** Every `Interface.member` pair touched through the proxy. */ + accessed: Set; + /** + * Wraps an already-obtained value as a given interface so subsequent access + * through it is recorded, without crediting how it was obtained. Used for the + * scratch board, whose creation is harness bookkeeping, not test coverage. + */ + wrap(value: V, typeName: string): V; +} + +function isWrappable(value: unknown): value is object { + return ( + value !== null && (typeof value === 'object' || typeof value === 'function') + ); +} + +/** + * True when `prop` is a non-configurable, non-writable data property of `target`. + * The Proxy `get` invariant requires returning that exact value, so wrapping it + * is not allowed. + */ +function nonConfigurableData(target: object, prop: PropertyKey): boolean { + const desc = Reflect.getOwnPropertyDescriptor(target, prop); + return ( + !!desc && + desc.configurable === false && + desc.writable === false && + !desc.get && + !desc.set + ); +} + +/** + * Wraps `root` (the real `penpot` API) in a recursive Proxy that records member + * access in a *type-aware* way. Each proxy is tagged with the interface (or union + * alias) name the underlying value has, derived from the API type graph. When a + * member is accessed we record `Interface.member` against the interface that + * actually declares it, and we tag the returned value with the member's declared + * type so nested access is attributed correctly too. + * + * This avoids the false positives of name-only matching, where e.g. reading + * `shape.id` would wrongly credit every interface that happens to have an `id` + * member. Unknown/primitive types are returned unwrapped and never recorded. + */ +export function createRecorder( + root: T, + surface: ApiSurface, +): Recorder { + const accessed = new Set(); + const toOriginal = new WeakMap(); + // Cache proxies per (target, typeName) so identity is stable and cycles end. + const cache = new WeakMap>(); + + function unwrap(value: unknown): unknown { + if (isWrappable(value) && toOriginal.has(value)) { + return toOriginal.get(value); + } + return value; + } + + /** Resolves the concrete interface name for a tagged value (handles unions). */ + function concreteType(target: object, typeName: string): string | null { + if (surface.graph[typeName]) return typeName; + + const union = surface.unions[typeName]; + if (union?.discriminant) { + const disc = Reflect.get(target, union.discriminant.field) as unknown; + if (typeof disc === 'string') { + return union.discriminant.map[disc] ?? null; + } + } + return null; + } + + function wrapValue( + value: unknown, + typeName: string | null, + array: boolean, + ): unknown { + if (!isWrappable(value) || !typeName) return value; + if (array) { + return Array.isArray(value) ? wrapArray(value, typeName) : value; + } + if (surface.graph[typeName] || surface.unions[typeName]) { + return wrapObject(value, typeName); + } + return value; + } + + function wrapArray(arr: unknown[], elementType: string): unknown[] { + const proxy = new Proxy(arr, { + get(tgt, prop, receiver): unknown { + const value = Reflect.get(tgt, prop, receiver); + if (typeof prop === 'string' && /^\d+$/.test(prop)) { + // A frozen array (e.g. the selection array, sealed by SES) has + // non-configurable, non-writable elements. The Proxy invariant then + // forbids returning a wrapped value that differs from the target's, + // so return the raw element (it just isn't credited for coverage). + if (nonConfigurableData(tgt, prop)) return value; + return wrapValue(value, elementType, false); + } + return value; + }, + }); + toOriginal.set(proxy, arr); + return proxy; + } + + function wrapObject(target: object, typeName: string): object { + let byType = cache.get(target); + if (!byType) { + byType = new Map(); + cache.set(target, byType); + } + const cached = byType.get(typeName); + if (cached) return cached; + + const proxy: object = new Proxy(target, { + get(tgt, prop, receiver): unknown { + const concrete = concreteType(tgt, typeName); + const entry = + concrete && typeof prop === 'string' + ? surface.graph[concrete]?.[prop] + : undefined; + + const raw = Reflect.get(tgt, prop, receiver === proxy ? tgt : receiver); + + // Methods are credited on call (see wrapMethod), not on access. Property + // reads are credited here as `#get`. + if (entry && entry.kind === 'method') { + return wrapMethod(raw as (...a: unknown[]) => unknown, tgt, { + ...entry, + member: String(prop), + }); + } + if (entry) accessed.add(`${entry.decl}.${String(prop)}#get`); + + // Don't wrap a frozen own property (Proxy invariant would be violated). + if (typeof prop === 'string' && nonConfigurableData(tgt, prop)) { + return raw; + } + + return entry ? wrapValue(raw, entry.type, entry.array) : raw; + }, + set(tgt, prop, value, receiver): boolean { + const concrete = concreteType(tgt, typeName); + const entry = + concrete && typeof prop === 'string' + ? surface.graph[concrete]?.[prop] + : undefined; + if (entry) accessed.add(`${entry.decl}.${String(prop)}#set`); + + return Reflect.set( + tgt, + prop, + unwrap(value), + receiver === proxy ? tgt : receiver, + ); + }, + }); + + toOriginal.set(proxy, target); + byType.set(typeName, proxy); + return proxy; + } + + function wrapMethod( + fn: (...a: unknown[]) => unknown, + self: object, + entry: { + decl: string; + member: string; + type: string | null; + array: boolean; + }, + ): (...a: unknown[]) => unknown { + return (...args: unknown[]) => { + // Credit the call only once it returns without throwing, so coverage + // means "successfully exercised" rather than "merely invoked". + const result = fn.apply(self, args.map(unwrap)); + accessed.add(`${entry.decl}.${entry.member}#call`); + + // Async API methods (e.g. uploadMediaUrl, createShapeFromSvgWithImages) + // return a Promise. Wrapping the Promise itself as the declared type would + // break `await` (then() called on the proxy is an incompatible receiver), + // so resolve it first and wrap the resolved value instead. + if ( + isWrappable(result) && + typeof (result as { then?: unknown }).then === 'function' + ) { + return Promise.resolve(result as Promise).then((value) => + wrapValue(value, entry.type, entry.array), + ); + } + + return wrapValue(result, entry.type, entry.array); + }; + } + + return { + proxy: wrapObject(root, 'Penpot') as T, + accessed, + wrap: (value: V, typeName: string) => + wrapValue(value, typeName, false) as V, + }; +} + +/** + * Compares the recorded `Interface.member` pairs against the public API surface + * and produces a report grouped by interface. The denominator is each + * interface's own declared members. + */ +export function computeCoverage( + accessed: Set, + surface: ApiSurface, +): CoverageReport { + const byInterface: Record = {}; + let total = 0; + let coveredCount = 0; + let staticCount = 0; + + for (const [iface, members] of Object.entries(surface.interfaces)) { + const all: string[] = []; + const covered: string[] = []; + const staticallyCovered: string[] = []; + const uncovered: string[] = []; + + for (const member of members) { + // Each writable property contributes separate get/set targets; read-only + // properties only get; methods only call. + const kind = surface.graph[iface]?.[member]?.kind ?? 'getset'; + const targets: { mode: string; label: string }[] = + kind === 'method' + ? [{ mode: 'call', label: `${member}()` }] + : kind === 'get' + ? [{ mode: 'get', label: member }] + : [ + { mode: 'get', label: `${member} (get)` }, + { mode: 'set', label: `${member} (set)` }, + ]; + + for (const { mode, label } of targets) { + all.push(label); + total += 1; + const key = `${iface}.${member}#${mode}`; + if (accessed.has(key)) { + covered.push(label); + coveredCount += 1; + } else if (STATIC_COVERAGE.has(key)) { + staticallyCovered.push(label); + staticCount += 1; + } else { + uncovered.push(label); + } + } + } + + byInterface[iface] = { + members: all, + covered, + staticallyCovered, + uncovered, + }; + } + + const percent = + total === 0 ? 100 : Math.round((coveredCount / total) * 1000) / 10; + const effectivePercent = + total === 0 + ? 100 + : Math.round(((coveredCount + staticCount) / total) * 1000) / 10; + + return { + total, + covered: coveredCount, + staticallyCovered: staticCount, + percent, + effectivePercent, + byInterface, + }; +} diff --git a/plugins/apps/plugin-api-test-suite/src/framework/expect.ts b/plugins/apps/plugin-api-test-suite/src/framework/expect.ts new file mode 100644 index 0000000000..e194317e6b --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/expect.ts @@ -0,0 +1,285 @@ +/** + * Minimal, dependency-free jest-like assertion library. It must not rely on any + * Node/browser globals beyond the basics because it runs inside the SES plugin + * sandbox. Every failed matcher throws an {@link AssertionError}; the runner + * turns that into a red test with the message attached. + * + * Two properties matter for coverage correctness: + * - Failure messages are built lazily (only when an assertion fails), so passing + * assertions never touch the value's members. + * - `stringify` never enumerates non-plain objects (e.g. the recording proxies + * used for API coverage). Otherwise `JSON.stringify` would walk every property + * of a shape and inflate coverage with members the test never used. + */ + +export class AssertionError extends Error { + constructor(message: string) { + super(message); + this.name = 'AssertionError'; + } +} + +function isPlainObject(value: object): boolean { + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function stringify(value: unknown): string { + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'bigint') return `${value}n`; + if (typeof value === 'function') + return `[Function ${value.name || 'anonymous'}]`; + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + // Only enumerate plain objects/arrays. Host/proxy objects (e.g. Penpot shape + // proxies) are rendered opaquely so stringifying never reads their members. + if ( + typeof value === 'object' && + !Array.isArray(value) && + !isPlainObject(value) + ) { + return '[object]'; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if ( + typeof a !== 'object' || + typeof b !== 'object' || + a === null || + b === null + ) { + return false; + } + if (Array.isArray(a) !== Array.isArray(b)) return false; + + const aKeys = Object.keys(a as Record); + const bKeys = Object.keys(b as Record); + if (aKeys.length !== bKeys.length) return false; + + return aKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(b, key) && + deepEqual( + (a as Record)[key], + (b as Record)[key], + ), + ); +} + +export interface Matchers { + toBe(expected: unknown): void; + toEqual(expected: unknown): void; + toBeTruthy(): void; + toBeFalsy(): void; + toBeNull(): void; + toBeUndefined(): void; + toBeDefined(): void; + toContain(item: unknown): void; + toHaveLength(length: number): void; + toBeGreaterThan(n: number): void; + toBeLessThan(n: number): void; + toBeCloseTo(n: number, numDigits?: number): void; + toThrow(expected?: string | RegExp): void; +} + +export interface Expectation extends Matchers { + not: Matchers; +} + +type Message = () => string; + +function errorMessage(thrown: unknown): string { + return thrown instanceof Error ? thrown.message : String(thrown); +} + +function messageMatches(message: string, expected?: string | RegExp): boolean { + if (typeof expected === 'string') return message.includes(expected); + if (expected instanceof RegExp) return expected.test(message); + return true; +} + +function makeMatchers(actual: unknown, negate: boolean): Matchers { + // Message factories are only invoked on failure, so passing assertions never + // stringify `actual` (which would enumerate proxies and skew coverage). + const check = (pass: boolean, message: Message, negatedMessage: Message) => { + if (negate ? pass : !pass) { + throw new AssertionError((negate ? negatedMessage : message)()); + } + }; + + return { + toBe(expected) { + check( + Object.is(actual, expected), + () => `Expected ${stringify(actual)} to be ${stringify(expected)}`, + () => `Expected ${stringify(actual)} not to be ${stringify(expected)}`, + ); + }, + toEqual(expected) { + check( + deepEqual(actual, expected), + () => `Expected ${stringify(actual)} to equal ${stringify(expected)}`, + () => + `Expected ${stringify(actual)} not to equal ${stringify(expected)}`, + ); + }, + toBeTruthy() { + check( + !!actual, + () => `Expected ${stringify(actual)} to be truthy`, + () => `Expected ${stringify(actual)} not to be truthy`, + ); + }, + toBeFalsy() { + check( + !actual, + () => `Expected ${stringify(actual)} to be falsy`, + () => `Expected ${stringify(actual)} not to be falsy`, + ); + }, + toBeNull() { + check( + actual === null, + () => `Expected ${stringify(actual)} to be null`, + () => `Expected ${stringify(actual)} not to be null`, + ); + }, + toBeUndefined() { + check( + actual === undefined, + () => `Expected ${stringify(actual)} to be undefined`, + () => `Expected ${stringify(actual)} not to be undefined`, + ); + }, + toBeDefined() { + check( + actual !== undefined, + () => 'Expected value to be defined', + () => 'Expected value not to be defined', + ); + }, + toContain(item) { + const pass = + (typeof actual === 'string' && actual.includes(String(item))) || + (Array.isArray(actual) && actual.includes(item)); + check( + pass, + () => `Expected ${stringify(actual)} to contain ${stringify(item)}`, + () => `Expected ${stringify(actual)} not to contain ${stringify(item)}`, + ); + }, + toHaveLength(length) { + const actualLength = (actual as { length?: number })?.length; + check( + actualLength === length, + () => + `Expected ${stringify(actual)} to have length ${length} but got ${actualLength}`, + () => `Expected ${stringify(actual)} not to have length ${length}`, + ); + }, + toBeGreaterThan(n) { + check( + typeof actual === 'number' && actual > n, + () => `Expected ${stringify(actual)} to be greater than ${n}`, + () => `Expected ${stringify(actual)} not to be greater than ${n}`, + ); + }, + toBeLessThan(n) { + check( + typeof actual === 'number' && actual < n, + () => `Expected ${stringify(actual)} to be less than ${n}`, + () => `Expected ${stringify(actual)} not to be less than ${n}`, + ); + }, + toBeCloseTo(n, numDigits = 2) { + const pass = + typeof actual === 'number' && + Math.abs(actual - n) < Math.pow(10, -numDigits) / 2; + check( + pass, + () => + `Expected ${stringify(actual)} to be close to ${n} (${numDigits} digits)`, + () => + `Expected ${stringify(actual)} not to be close to ${n} (${numDigits} digits)`, + ); + }, + toThrow(expected) { + if (typeof actual !== 'function') { + throw new AssertionError( + `Expected a function to call but got ${stringify(actual)}`, + ); + } + let thrown: unknown; + let didThrow = false; + try { + (actual as () => unknown)(); + } catch (err) { + didThrow = true; + thrown = err; + } + if (!didThrow) { + check( + false, + () => 'Expected function to throw', + () => 'Expected function not to throw', + ); + return; + } + const message = errorMessage(thrown); + const matches = messageMatches(message, expected); + check( + matches, + () => + `Expected function to throw matching ${stringify(expected)} but threw ${stringify(message)}`, + () => `Expected function not to throw matching ${stringify(expected)}`, + ); + }, + }; +} + +export function expect(actual: unknown): Expectation { + const matchers = makeMatchers(actual, false) as Expectation; + matchers.not = makeMatchers(actual, true); + return matchers; +} + +/** + * Async counterpart to {@link Matchers.toThrow}: awaits a promise (or a 0-arg + * thunk returning one) and asserts that it REJECTS. `toThrow` can't cover this + * because it calls its argument synchronously, but a large share of edge cases + * are async (uploads, exports, version/comment/library ops). + * + * A thunk that throws synchronously also counts as a rejection, so callers can + * pass `() => ctx.penpot.someAsyncCall(badArgs)` regardless of whether the + * failure surfaces before or after the first await. The optional `expected` + * matches the error message exactly like `toThrow` (string includes / RegExp). + */ +export async function expectReject( + actual: Promise | (() => Promise | unknown), + expected?: string | RegExp, +): Promise { + let thrown: unknown; + let didReject = false; + try { + await (typeof actual === 'function' ? actual() : actual); + } catch (err) { + didReject = true; + thrown = err; + } + if (!didReject) { + throw new AssertionError('Expected promise to reject but it resolved'); + } + const message = errorMessage(thrown); + if (!messageMatches(message, expected)) { + throw new AssertionError( + `Expected promise to reject matching ${stringify(expected)} but rejected with ${stringify(message)}`, + ); + } +} diff --git a/plugins/apps/plugin-api-test-suite/src/framework/registry.ts b/plugins/apps/plugin-api-test-suite/src/framework/registry.ts new file mode 100644 index 0000000000..da01c57430 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/registry.ts @@ -0,0 +1,115 @@ +import type { TestCase, TestFn, TestMeta } from './types'; + +export const DEFAULT_GROUP = 'General'; + +let registry: TestCase[] = []; +let seenIds = new Set(); +const groupStack: string[] = []; +// >0 while inside a `describe.skipIfMocked` block; every test registered while +// it is positive is tagged `mockedSkip`. +let skipMockedDepth = 0; + +/** Separator used to join nested `describe` names into a single group path. */ +export const GROUP_SEPARATOR = ' / '; + +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Groups the tests registered inside `fn` under `name`. Groups are collapsible in + * the UI and show their own pass/fail counts. Calls may be nested in a file; the + * nested names are joined into a single hierarchical path (e.g. `Layout / Flex`) + * so a group always reveals the file/area it belongs to. Tests registered outside + * any `describe` fall into the {@link DEFAULT_GROUP}. + */ +function describeImpl(name: string, fn: () => void): void { + groupStack.push(name); + try { + fn(); + } finally { + groupStack.pop(); + } +} + +/** + * Groups the tests registered inside `fn` under `name`. + * + * `describe.skipIfMocked(name, fn)` additionally tags every test registered in + * the block as {@link TestCase.mockedSkip} — use it for a whole group of + * backend-dependent tests. + */ +export const describe: { + (name: string, fn: () => void): void; + skipIfMocked(name: string, fn: () => void): void; +} = Object.assign(describeImpl, { + skipIfMocked(name: string, fn: () => void): void { + skipMockedDepth++; + try { + describeImpl(name, fn); + } finally { + skipMockedDepth--; + } + }, +}); + +function registerTest(name: string, fn: TestFn, mockedSkip: boolean): void { + const base = slugify(name) || 'test'; + let id = base; + let n = 2; + while (seenIds.has(id)) { + id = `${base}-${n++}`; + } + seenIds.add(id); + const group = groupStack.length + ? groupStack.join(GROUP_SEPARATOR) + : DEFAULT_GROUP; + registry.push({ id, name, group, fn, mockedSkip }); +} + +/** + * Registers a test. Called at module load time from the auto-discovered + * `tests/*.test.ts` files. Ids are derived from the name and de-duplicated so + * the UI and runner can address each test unambiguously. + * + * `test.skipIfMocked(name, fn)` registers a single test that is excluded when + * running against a mocked backend (see {@link TestCase.mockedSkip}). + */ +export const test: { + (name: string, fn: TestFn): void; + skipIfMocked(name: string, fn: TestFn): void; +} = Object.assign( + (name: string, fn: TestFn): void => + registerTest(name, fn, skipMockedDepth > 0), + { + skipIfMocked(name: string, fn: TestFn): void { + registerTest(name, fn, true); + }, + }, +); + +export function getTests(): TestCase[] { + return registry.slice(); +} + +export function getTestMetas(): TestMeta[] { + return registry.map(({ id, name, group }) => ({ id, name, group })); +} + +/** + * Replaces the whole registry. Used by the reload mechanism, which evaluates a + * freshly built test bundle and hands back the discovered {@link TestCase}s. + */ +export function setTests(tests: TestCase[]): void { + registry = tests.slice(); + seenIds = new Set(registry.map((t) => t.id)); +} + +export function clearTests(): void { + registry = []; + seenIds = new Set(); +} diff --git a/plugins/apps/plugin-api-test-suite/src/framework/runner.ts b/plugins/apps/plugin-api-test-suite/src/framework/runner.ts new file mode 100644 index 0000000000..218a95e2f8 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/runner.ts @@ -0,0 +1,179 @@ +import apiSurface from '../generated/api-surface.json'; +import { computeCoverage, createRecorder } from './coverage'; +import { getTests } from './registry'; +import type { + ApiSurface, + CoverageReport, + RunSummary, + TestResult, +} from './types'; + +const SCRATCH_NAME = '__api_test_scratch__'; + +// A single test must never freeze the whole run. Some plugin API calls can hang +// indefinitely (e.g. an async op whose completion event never fires), so each +// test is raced against this timeout and turned into a failure if it exceeds it. +const TEST_TIMEOUT_MS = 15000; + +export interface RunOutput { + results: TestResult[]; + summary: RunSummary; + coverage: CoverageReport; + /** Names of tests excluded because of {@link RunOptions.skipMocked}. */ + skipped: string[]; +} + +export interface RunOptions { + /** + * When true, tests tagged {@link TestCase.mockedSkip} are excluded from the + * run (used by the mocked-backend CI mode). Their names are returned in + * {@link RunOutput.skipped}. + */ + skipMocked?: boolean; +} + +export type ResultReporter = (result: TestResult) => void; + +function withTimeout(promise: void | Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + settled = true; + reject(new Error(`Test timed out after ${ms}ms`)); + } + }, ms); + Promise.resolve(promise).then( + () => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(); + } + }, + (err) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(err); + } + }, + ); + }); +} + +/** + * Runs the selected tests (or all of them) in the plugin sandbox. Each test gets + * a fresh scratch board through the recording proxy and that board is removed + * afterwards, so the user's file is left clean. API usage across the whole run is + * accumulated and turned into a coverage report. + */ +export async function runTests( + ids: string[] | 'all', + onResult?: ResultReporter, + options?: RunOptions, +): Promise { + const all = getTests(); + const requested = ids === 'all' ? all : all.filter((t) => ids.includes(t.id)); + const skipped = options?.skipMocked + ? requested.filter((t) => t.mockedSkip).map((t) => t.name) + : []; + const selected = options?.skipMocked + ? requested.filter((t) => !t.mockedSkip) + : requested; + + const recorder = createRecorder(penpot, apiSurface as ApiSurface); + + // Run every test with strict, deterministic API behavior. Set through the + // recording proxy so the flags also count towards coverage: + // - throwValidationErrors: invalid API usage throws instead of only logging, + // so it surfaces as a red test rather than passing silently. + // - naturalChildOrdering: `children` is always in z-index order and + // appendChild/insertChild respect it, making ordering assertions stable. + recorder.proxy.flags.throwValidationErrors = true; + recorder.proxy.flags.naturalChildOrdering = true; + + // Remember the page that was active when the run started. Tests share global + // state (selection, the active page) with no per-test reset, so a test that + // changes the active page — or fails before restoring it — would silently make + // every later test run on the wrong page. After each test we clear the + // selection and restore this page, all through the *raw* penpot so the cleanup + // isn't credited toward coverage. + const homePage = penpot.currentPage; + + const results: TestResult[] = []; + + for (const testCase of selected) { + onResult?.({ + id: testCase.id, + name: testCase.name, + status: 'running', + durationMs: 0, + }); + + const start = Date.now(); + // Create/name/remove the scratch board through the *raw* penpot so this + // harness bookkeeping isn't credited toward coverage. The test still gets a + // recording-wrapped board, so its own access to it is counted. + let rawBoard: ReturnType | undefined; + let result: TestResult; + + try { + rawBoard = penpot.createBoard(); + rawBoard.name = SCRATCH_NAME; + const board = recorder.wrap(rawBoard, 'Board'); + await withTimeout( + testCase.fn({ penpot: recorder.proxy, board }), + TEST_TIMEOUT_MS, + ); + result = { + id: testCase.id, + name: testCase.name, + status: 'pass', + durationMs: Date.now() - start, + }; + } catch (err) { + result = { + id: testCase.id, + name: testCase.name, + status: 'fail', + error: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }; + } finally { + try { + rawBoard?.remove(); + } catch { + // best-effort cleanup; never fail a test because teardown failed + } + // Reset shared state so the next test starts clean. All best-effort: a + // teardown failure must never turn into a test failure. + try { + penpot.selection = []; + } catch { + /* ignore */ + } + try { + const active = penpot.currentPage; + if (homePage && active && active.id !== homePage.id) { + await penpot.openPage(homePage); + } + } catch { + /* ignore */ + } + } + + results.push(result); + onResult?.(result); + } + + const summary: RunSummary = { + total: results.length, + passed: results.filter((r) => r.status === 'pass').length, + failed: results.filter((r) => r.status === 'fail').length, + }; + + const coverage = computeCoverage(recorder.accessed, apiSurface as ApiSurface); + + return { results, summary, coverage, skipped }; +} diff --git a/plugins/apps/plugin-api-test-suite/src/framework/static-coverage.ts b/plugins/apps/plugin-api-test-suite/src/framework/static-coverage.ts new file mode 100644 index 0000000000..ecc1e86736 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/static-coverage.ts @@ -0,0 +1,70 @@ +/** + * "Statically covered" coverage targets. + * + * These members ARE exercised behaviourally by the test suite, but the recording + * proxy structurally cannot credit them, so they would otherwise show as + * uncovered. The reasons are all recorder limitations: frozen SES values (the + * proxy must return them raw), base-interface attribution (members redeclared on + * concrete types are credited there), type maps (events are credited on + * `Penpot.on/off`), type-guard narrowing the recorder can't perform, and methods + * whose return type the surface generator couldn't resolve (the result is handed + * back raw). See README.md "Coverage notes". + * + * Keys are `Interface.member#mode` (mode ∈ get/set/call), exactly matching the + * recorder's accessed-set keys and the targets in `computeCoverage`. Only add a + * target here when a named test genuinely exercises it — this set feeds the + * "effective" coverage number, so over-claiming makes that number dishonest. + * + * Recorder-credited (recorded) coverage always wins over this set: a target that + * turns out to be recorded simply never shows as static. + */ +export const STATIC_COVERAGE: ReadonlySet = new Set([ + // ShapeBase.fills — every concrete shape redeclares `fills`, so accesses are + // attributed to the concrete type (Rectangle.fills, …); exercised pervasively + // (fills-strokes.test.ts, misc.test.ts). + 'ShapeBase.fills#get', + 'ShapeBase.fills#set', + + // utils.types predicates — `penpot.utils.types` is a frozen data property, so + // its members can't be wrapped. Exercised in platform.test.ts. + 'ContextTypesUtils.isBoard#call', + 'ContextTypesUtils.isBool#call', + 'ContextTypesUtils.isEllipse#call', + 'ContextTypesUtils.isGroup#call', + 'ContextTypesUtils.isMask#call', + 'ContextTypesUtils.isPath#call', + 'ContextTypesUtils.isRectangle#call', + 'ContextTypesUtils.isSVG#call', + 'ContextTypesUtils.isText#call', + 'ContextTypesUtils.isVariantComponent#call', + 'ContextTypesUtils.isVariantContainer#call', + + // utils.geometry.center — `penpot.utils.geometry` is likewise a frozen data + // property, so the call can't be wrapped. Exercised (and verified) in + // platform.test.ts. + 'ContextGeometryUtils.center#call', + + // shapesColors() returns objects whose declared type the surface generator + // couldn't resolve (it records as `type: null`), so the recorder hands the + // result back raw and cannot credit nested access. The members are exercised + // in colors.test.ts (entry.shapesInfo[0].property / .shapeId). + 'ColorShapeInfo.shapesInfo#get', + 'ColorShapeInfoEntry.index#get', + 'ColorShapeInfoEntry.property#get', + 'ColorShapeInfoEntry.shapeId#get', + + // Deterministic events — `on`/`off` are credited on `Penpot`, never as + // `EventsMap` members. Exercised in events.test.ts. The remaining events + // (pagechange/filechange/themechange/contentsave/finish) are not triggered + // deterministically headless and stay genuinely uncovered. + 'EventsMap.selectionchange#get', + 'EventsMap.shapechange#get', + + // LibraryVariantComponent — the recorder types a component as LibraryComponent + // and can't narrow via the isVariant() type-guard; the behaviour is exercised + // via VariantContainer.variants in variants.test.ts. + 'LibraryVariantComponent.variants#get', + 'LibraryVariantComponent.variantProps#get', + 'LibraryVariantComponent.addVariant#call', + 'LibraryVariantComponent.setVariantProperty#call', +]); diff --git a/plugins/apps/plugin-api-test-suite/src/framework/types.ts b/plugins/apps/plugin-api-test-suite/src/framework/types.ts new file mode 100644 index 0000000000..94b19cbe8b --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/types.ts @@ -0,0 +1,118 @@ +import type { Board, Penpot } from '@penpot/plugin-types'; + +export type TestStatus = 'pending' | 'running' | 'pass' | 'fail'; + +/** + * The context handed to every test. Tests MUST use `ctx.penpot` (the recording + * proxy) rather than the global `penpot` so their API usage is counted towards + * coverage. A fresh scratch `board` is provided per test and removed afterwards. + */ +export interface TestContext { + penpot: Penpot; + board: Board; +} + +export type TestFn = (ctx: TestContext) => void | Promise; + +export interface TestCase { + id: string; + name: string; + /** Group the test belongs to (set via `describe`, defaults to "General"). */ + group: string; + fn: TestFn; + /** + * When true, the test is excluded from runs against a mocked backend + * (`MOCK_BACKEND=1`): it depends on real backend results/validation that a + * `page.route` mock cannot faithfully reproduce. Set via `test.skipIfMocked` + * or `describe.skipIfMocked`. + */ + mockedSkip?: boolean; +} + +/** Lightweight test description sent to the UI (no function). */ +export interface TestMeta { + id: string; + name: string; + group: string; +} + +export interface TestResult { + id: string; + name: string; + status: TestStatus; + error?: string; + durationMs: number; +} + +export interface RunSummary { + total: number; + passed: number; + failed: number; +} + +/** Per-interface coverage detail derived from the public Plugin API types. */ +export interface InterfaceCoverage { + members: string[]; + covered: string[]; + /** + * Targets exercised behaviourally by the tests but not creditable through the + * recording proxy (see {@link ../framework/static-coverage}). Listed + * separately from `covered` and `uncovered`. + */ + staticallyCovered: string[]; + uncovered: string[]; +} + +export interface CoverageReport { + total: number; + /** Targets credited by the recording proxy. */ + covered: number; + /** Targets covered only via the static allowlist (not recorder-credited). */ + staticallyCovered: number; + /** Recorder-credited coverage: `covered / total`. */ + percent: number; + /** Effective coverage including static targets: `(covered + static) / total`. */ + effectivePercent: number; + byInterface: Record; +} + +/** + * How a member is exercised, which determines the coverage targets it has: + * - `method`: callable -> a single `call` target. + * - `get`: read-only property -> a single `get` target. + * - `getset`: writable property -> separate `get` and `set` targets. + */ +export type MemberKind = 'method' | 'get' | 'getset'; + +/** A single member in the API type graph. */ +export interface ApiMemberInfo { + /** Interface that actually declares this member (may be a base interface). */ + decl: string; + /** Whether the member is a method, a read-only, or a writable property. */ + kind: MemberKind; + /** + * The interface/union name the member yields (return type for methods, + * property type otherwise), or `null` when it is a primitive/untracked type. + */ + type: string | null; + /** True when the member yields an array of `type`. */ + array: boolean; +} + +/** A union alias (e.g. `Shape`) and how to resolve it at runtime. */ +export interface UnionInfo { + variants: string[]; + /** Discriminant used to pick the concrete variant from a runtime value. */ + discriminant: { field: string; map: Record } | null; +} + +/** + * Shape of the generated `api-surface.json`. Coverage is type-aware: `interfaces` + * is the denominator (own members per interface) and `graph`/`unions` let the + * recorder attribute each access to the interface the value actually is. + */ +export interface ApiSurface { + interfaces: Record; + graph: Record>; + unions: Record; +} diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json new file mode 100644 index 0000000000..783f45d044 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -0,0 +1,11192 @@ +{ + "interfaces": { + "ActiveUser": ["position", "zoom"], + "Blur": ["hidden", "id", "value"], + "Board": [ + "addFlexLayout", + "addGridLayout", + "addRulerGuide", + "appendChild", + "children", + "clipContent", + "fills", + "flex", + "grid", + "guides", + "horizontalSizing", + "insertChild", + "isVariantContainer", + "removeRulerGuide", + "rulerGuides", + "showInViewMode", + "type", + "verticalSizing" + ], + "Boolean": [ + "appendChild", + "children", + "commands", + "d", + "fills", + "insertChild", + "type" + ], + "Bounds": ["height", "width", "x", "y"], + "CloseOverlay": ["animation", "destination", "type"], + "Color": [ + "color", + "fileId", + "gradient", + "id", + "image", + "name", + "opacity", + "path" + ], + "ColorShapeInfo": ["shapesInfo"], + "ColorShapeInfoEntry": ["index", "property", "shapeId"], + "Comment": ["content", "date", "remove", "user"], + "CommentThread": [ + "board", + "findComments", + "owner", + "position", + "remove", + "reply", + "resolved", + "seqNumber" + ], + "CommonLayout": [ + "alignContent", + "alignItems", + "bottomPadding", + "columnGap", + "horizontalPadding", + "horizontalSizing", + "justifyContent", + "justifyItems", + "leftPadding", + "remove", + "rightPadding", + "rowGap", + "topPadding", + "verticalPadding", + "verticalSizing" + ], + "Context": [ + "activeUsers", + "alignHorizontal", + "alignVertical", + "createBoard", + "createBoolean", + "createEllipse", + "createPage", + "createPath", + "createRectangle", + "createShapeFromSvg", + "createShapeFromSvgWithImages", + "createText", + "createVariantFromComponents", + "currentFile", + "currentPage", + "currentUser", + "distributeHorizontal", + "distributeVertical", + "flags", + "flatten", + "fonts", + "generateFontFaces", + "generateMarkup", + "generateStyle", + "group", + "history", + "library", + "localStorage", + "openPage", + "openViewer", + "replaceColor", + "root", + "selection", + "shapesColors", + "theme", + "ungroup", + "uploadMediaData", + "uploadMediaUrl", + "version", + "viewport" + ], + "ContextGeometryUtils": ["center"], + "ContextTypesUtils": [ + "isBoard", + "isBool", + "isEllipse", + "isGroup", + "isMask", + "isPath", + "isRectangle", + "isSVG", + "isText", + "isVariantComponent", + "isVariantContainer" + ], + "ContextUtils": ["geometry", "types"], + "Dissolve": ["duration", "easing", "type"], + "Ellipse": ["fills", "type"], + "EventsMap": [ + "contentsave", + "filechange", + "finish", + "pagechange", + "selectionchange", + "shapechange", + "themechange" + ], + "Export": ["scale", "skipChildren", "suffix", "type"], + "File": [ + "export", + "findVersions", + "id", + "name", + "pages", + "revn", + "saveVersion" + ], + "FileVersion": [ + "createdAt", + "createdBy", + "isAutosave", + "label", + "pin", + "remove", + "restore" + ], + "Fill": [ + "fillColor", + "fillColorGradient", + "fillColorRefFile", + "fillColorRefId", + "fillImage", + "fillOpacity" + ], + "Flags": ["naturalChildOrdering", "throwValidationErrors"], + "FlexLayout": ["appendChild", "dir", "wrap"], + "Flow": ["name", "page", "remove", "startingBoard"], + "Font": [ + "applyToRange", + "applyToText", + "fontFamily", + "fontId", + "fontStyle", + "fontVariantId", + "fontWeight", + "name", + "variants" + ], + "FontsContext": [ + "all", + "findAllById", + "findAllByName", + "findById", + "findByName" + ], + "FontVariant": ["fontStyle", "fontVariantId", "fontWeight", "name"], + "Gradient": ["endX", "endY", "startX", "startY", "stops", "type", "width"], + "GridLayout": [ + "addColumn", + "addColumnAtIndex", + "addRow", + "addRowAtIndex", + "appendChild", + "columns", + "dir", + "removeColumn", + "removeRow", + "rows", + "setColumn", + "setRow" + ], + "Group": [ + "appendChild", + "children", + "insertChild", + "isMask", + "makeMask", + "removeMask", + "type" + ], + "GuideColumn": ["display", "params", "type"], + "GuideColumnParams": [ + "color", + "gutter", + "itemLength", + "margin", + "size", + "type" + ], + "GuideRow": ["display", "params", "type"], + "GuideSquare": ["display", "params", "type"], + "GuideSquareParams": ["color", "size"], + "HistoryContext": ["undoBlockBegin", "undoBlockFinish"], + "ImageData": [ + "data", + "height", + "id", + "keepAspectRatio", + "mtype", + "name", + "width" + ], + "Interaction": ["action", "delay", "remove", "shape", "trigger"], + "LayoutCellProperties": [ + "areaName", + "column", + "columnSpan", + "position", + "row", + "rowSpan" + ], + "LayoutChildProperties": [ + "absolute", + "alignSelf", + "bottomMargin", + "horizontalMargin", + "horizontalSizing", + "leftMargin", + "maxHeight", + "maxWidth", + "minHeight", + "minWidth", + "rightMargin", + "topMargin", + "verticalMargin", + "verticalSizing", + "zIndex" + ], + "Library": [ + "colors", + "components", + "createColor", + "createComponent", + "createTypography", + "id", + "name", + "tokens", + "typographies" + ], + "LibraryColor": [ + "asFill", + "asStroke", + "color", + "gradient", + "image", + "opacity" + ], + "LibraryComponent": [ + "instance", + "isVariant", + "mainInstance", + "transformInVariant" + ], + "LibraryContext": [ + "availableLibraries", + "connectLibrary", + "connected", + "local" + ], + "LibraryElement": ["id", "libraryId", "name", "path"], + "LibrarySummary": [ + "id", + "name", + "numColors", + "numComponents", + "numTypographies" + ], + "LibraryTypography": [ + "applyToText", + "applyToTextRange", + "fontFamily", + "fontId", + "fontSize", + "fontStyle", + "fontVariantId", + "fontWeight", + "letterSpacing", + "lineHeight", + "setFont", + "textTransform" + ], + "LibraryVariantComponent": [ + "addVariant", + "setVariantProperty", + "variantError", + "variantProps", + "variants" + ], + "LocalStorage": ["getItem", "getKeys", "removeItem", "setItem"], + "NavigateTo": [ + "animation", + "destination", + "preserveScrollPosition", + "type" + ], + "OpenOverlay": ["type"], + "OpenUrl": ["type", "url"], + "OverlayAction": [ + "addBackgroundOverlay", + "animation", + "closeWhenClickOutside", + "destination", + "manualPositionLocation", + "position", + "relativeTo" + ], + "Page": [ + "addCommentThread", + "addRulerGuide", + "createFlow", + "findCommentThreads", + "findShapes", + "flows", + "getShapeById", + "id", + "name", + "removeCommentThread", + "removeFlow", + "removeRulerGuide", + "root", + "rulerGuides" + ], + "Path": ["commands", "d", "fills", "type"], + "PathCommand": ["command", "params"], + "Penpot": ["closePlugin", "off", "on", "ui", "utils"], + "PluginData": [ + "getPluginData", + "getPluginDataKeys", + "getSharedPluginData", + "getSharedPluginDataKeys", + "setPluginData", + "setSharedPluginData" + ], + "Point": ["x", "y"], + "PreviousScreen": ["type"], + "Push": ["direction", "duration", "easing", "type"], + "Rectangle": ["fills", "type"], + "RulerGuide": ["board", "orientation", "position"], + "Shadow": [ + "blur", + "color", + "hidden", + "id", + "offsetX", + "offsetY", + "spread", + "style" + ], + "ShapeBase": [ + "addInteraction", + "applyToken", + "backgroundBlur", + "blendMode", + "blocked", + "blur", + "boardX", + "boardY", + "borderRadius", + "borderRadiusBottomLeft", + "borderRadiusBottomRight", + "borderRadiusTopLeft", + "borderRadiusTopRight", + "bounds", + "bringForward", + "bringToFront", + "center", + "clone", + "combineAsVariants", + "component", + "componentHead", + "componentRefShape", + "componentRoot", + "constraintsHorizontal", + "constraintsVertical", + "detach", + "export", + "exports", + "fills", + "fixedWhenScrolling", + "flipX", + "flipY", + "height", + "hidden", + "id", + "interactions", + "isComponentCopyInstance", + "isComponentHead", + "isComponentInstance", + "isComponentMainInstance", + "isComponentRoot", + "isVariantHead", + "layoutCell", + "layoutChild", + "name", + "opacity", + "parent", + "parentIndex", + "parentX", + "parentY", + "proportionLock", + "remove", + "removeInteraction", + "resize", + "rotate", + "rotation", + "sendBackward", + "sendToBack", + "setParentIndex", + "shadows", + "strokes", + "swapComponent", + "switchVariant", + "tokens", + "visible", + "width", + "x", + "y" + ], + "Slide": ["direction", "duration", "easing", "offsetEffect", "type", "way"], + "Stroke": [ + "strokeAlignment", + "strokeCapEnd", + "strokeCapStart", + "strokeColor", + "strokeColorGradient", + "strokeColorRefFile", + "strokeColorRefId", + "strokeOpacity", + "strokeStyle", + "strokeWidth" + ], + "SvgRaw": ["type"], + "Text": [ + "align", + "applyTypography", + "characters", + "direction", + "fontFamily", + "fontId", + "fontSize", + "fontStyle", + "fontVariantId", + "fontWeight", + "getRange", + "growType", + "letterSpacing", + "lineHeight", + "textBounds", + "textDecoration", + "textTransform", + "type", + "verticalAlign" + ], + "TextRange": [ + "align", + "applyTypography", + "characters", + "direction", + "fills", + "fontFamily", + "fontId", + "fontSize", + "fontStyle", + "fontVariantId", + "fontWeight", + "letterSpacing", + "lineHeight", + "shape", + "textDecoration", + "textTransform", + "verticalAlign" + ], + "ToggleOverlay": ["type"], + "TokenBase": [ + "applyToSelected", + "applyToShapes", + "description", + "duplicate", + "id", + "name", + "remove", + "resolvedValueString" + ], + "TokenBorderRadius": ["resolvedValue", "type", "value"], + "TokenBorderWidth": ["resolvedValue", "type", "value"], + "TokenCatalog": [ + "addSet", + "addTheme", + "getSetById", + "getThemeById", + "sets", + "themes" + ], + "TokenColor": ["resolvedValue", "type", "value"], + "TokenDimension": ["resolvedValue", "type", "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": [ + "active", + "addToken", + "duplicate", + "getTokenById", + "id", + "name", + "remove", + "toggleActive", + "tokens", + "tokensByType" + ], + "TokenShadow": ["resolvedValue", "type", "value"], + "TokenShadowValue": [ + "blur", + "color", + "inset", + "offsetX", + "offsetY", + "spread" + ], + "TokenShadowValueString": [ + "blur", + "color", + "inset", + "offsetX", + "offsetY", + "spread" + ], + "TokenSizing": ["resolvedValue", "type", "value"], + "TokenSpacing": ["resolvedValue", "type", "value"], + "TokenTextCase": ["resolvedValue", "type", "value"], + "TokenTextDecoration": ["resolvedValue", "type", "value"], + "TokenTheme": [ + "active", + "activeSets", + "addSet", + "duplicate", + "externalId", + "group", + "id", + "name", + "remove", + "removeSet", + "toggleActive" + ], + "TokenTypography": ["resolvedValue", "type", "value"], + "TokenTypographyValue": [ + "fontFamilies", + "fontSizes", + "fontWeights", + "letterSpacing", + "lineHeight", + "textCase", + "textDecoration" + ], + "TokenTypographyValueString": [ + "fontFamilies", + "fontSizes", + "fontWeight", + "letterSpacing", + "lineHeight", + "textCase", + "textDecoration" + ], + "Track": ["type", "value"], + "User": ["avatarUrl", "color", "id", "name", "sessionId"], + "VariantContainer": ["variants"], + "Variants": [ + "addProperty", + "addVariant", + "currentValues", + "id", + "libraryId", + "properties", + "removeProperty", + "renameProperty", + "variantComponents" + ], + "Viewport": [ + "bounds", + "center", + "zoom", + "zoomIntoView", + "zoomReset", + "zoomToFitAll" + ] + }, + "graph": { + "ActiveUser": { + "position": { + "decl": "ActiveUser", + "kind": "getset", + "type": null, + "array": false + }, + "zoom": { + "decl": "ActiveUser", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "avatarUrl": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "color": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "sessionId": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + } + }, + "Blur": { + "id": { + "decl": "Blur", + "kind": "getset", + "type": null, + "array": false + }, + "value": { + "decl": "Blur", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "Blur", + "kind": "getset", + "type": null, + "array": false + } + }, + "Board": { + "type": { + "decl": "Board", + "kind": "get", + "type": null, + "array": false + }, + "clipContent": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "showInViewMode": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "grid": { + "decl": "Board", + "kind": "get", + "type": "GridLayout", + "array": false + }, + "flex": { + "decl": "Board", + "kind": "get", + "type": "FlexLayout", + "array": false + }, + "guides": { + "decl": "Board", + "kind": "getset", + "type": "Guide", + "array": true + }, + "rulerGuides": { + "decl": "Board", + "kind": "get", + "type": "RulerGuide", + "array": true + }, + "horizontalSizing": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "Board", + "kind": "getset", + "type": "Fill", + "array": true + }, + "children": { + "decl": "Board", + "kind": "getset", + "type": "Shape", + "array": true + }, + "appendChild": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "insertChild": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "addFlexLayout": { + "decl": "Board", + "kind": "method", + "type": "FlexLayout", + "array": false + }, + "addGridLayout": { + "decl": "Board", + "kind": "method", + "type": "GridLayout", + "array": false + }, + "addRulerGuide": { + "decl": "Board", + "kind": "method", + "type": "RulerGuide", + "array": false + }, + "removeRulerGuide": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "isVariantContainer": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Boolean": { + "type": { + "decl": "Boolean", + "kind": "get", + "type": null, + "array": false + }, + "toD": { + "decl": "Boolean", + "kind": "method", + "type": null, + "array": false + }, + "content": { + "decl": "Boolean", + "kind": "get", + "type": null, + "array": false + }, + "d": { + "decl": "Boolean", + "kind": "get", + "type": null, + "array": false + }, + "commands": { + "decl": "Boolean", + "kind": "get", + "type": "PathCommand", + "array": true + }, + "fills": { + "decl": "Boolean", + "kind": "getset", + "type": "Fill", + "array": true + }, + "children": { + "decl": "Boolean", + "kind": "get", + "type": "Shape", + "array": true + }, + "appendChild": { + "decl": "Boolean", + "kind": "method", + "type": null, + "array": false + }, + "insertChild": { + "decl": "Boolean", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Bounds": { + "x": { + "decl": "Bounds", + "kind": "get", + "type": null, + "array": false + }, + "y": { + "decl": "Bounds", + "kind": "get", + "type": null, + "array": false + }, + "width": { + "decl": "Bounds", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "Bounds", + "kind": "get", + "type": null, + "array": false + } + }, + "CloseOverlay": { + "type": { + "decl": "CloseOverlay", + "kind": "get", + "type": null, + "array": false + }, + "destination": { + "decl": "CloseOverlay", + "kind": "get", + "type": "Board", + "array": false + }, + "animation": { + "decl": "CloseOverlay", + "kind": "get", + "type": "Animation", + "array": false + } + }, + "Color": { + "id": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "fileId": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "name": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "color": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "refId": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "refFile": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "gradient": { + "decl": "Color", + "kind": "getset", + "type": "Gradient", + "array": false + }, + "image": { + "decl": "Color", + "kind": "getset", + "type": "ImageData", + "array": false + } + }, + "ColorShapeInfo": { + "shapesInfo": { + "decl": "ColorShapeInfo", + "kind": "get", + "type": "ColorShapeInfoEntry", + "array": true + } + }, + "ColorShapeInfoEntry": { + "property": { + "decl": "ColorShapeInfoEntry", + "kind": "get", + "type": null, + "array": false + }, + "index": { + "decl": "ColorShapeInfoEntry", + "kind": "get", + "type": null, + "array": false + }, + "shapeId": { + "decl": "ColorShapeInfoEntry", + "kind": "get", + "type": null, + "array": false + } + }, + "Comment": { + "user": { + "decl": "Comment", + "kind": "get", + "type": "User", + "array": false + }, + "date": { + "decl": "Comment", + "kind": "get", + "type": null, + "array": false + }, + "content": { + "decl": "Comment", + "kind": "getset", + "type": null, + "array": false + }, + "remove": { + "decl": "Comment", + "kind": "method", + "type": null, + "array": false + } + }, + "CommentThread": { + "seqNumber": { + "decl": "CommentThread", + "kind": "get", + "type": null, + "array": false + }, + "board": { + "decl": "CommentThread", + "kind": "get", + "type": "Board", + "array": false + }, + "owner": { + "decl": "CommentThread", + "kind": "get", + "type": "User", + "array": false + }, + "position": { + "decl": "CommentThread", + "kind": "getset", + "type": "Point", + "array": false + }, + "resolved": { + "decl": "CommentThread", + "kind": "getset", + "type": null, + "array": false + }, + "findComments": { + "decl": "CommentThread", + "kind": "method", + "type": "Comment", + "array": true + }, + "reply": { + "decl": "CommentThread", + "kind": "method", + "type": "Comment", + "array": false + }, + "remove": { + "decl": "CommentThread", + "kind": "method", + "type": null, + "array": false + } + }, + "CommonLayout": { + "alignItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "alignContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rowGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "columnGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "topPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rightPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "bottomPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "leftPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "remove": { + "decl": "CommonLayout", + "kind": "method", + "type": null, + "array": false + } + }, + "Context": { + "version": { + "decl": "Context", + "kind": "get", + "type": null, + "array": false + }, + "root": { + "decl": "Context", + "kind": "get", + "type": "Shape", + "array": false + }, + "currentFile": { + "decl": "Context", + "kind": "get", + "type": "File", + "array": false + }, + "currentPage": { + "decl": "Context", + "kind": "get", + "type": "Page", + "array": false + }, + "viewport": { + "decl": "Context", + "kind": "get", + "type": "Viewport", + "array": false + }, + "flags": { + "decl": "Context", + "kind": "get", + "type": "Flags", + "array": false + }, + "history": { + "decl": "Context", + "kind": "get", + "type": "HistoryContext", + "array": false + }, + "library": { + "decl": "Context", + "kind": "get", + "type": "LibraryContext", + "array": false + }, + "fonts": { + "decl": "Context", + "kind": "get", + "type": "FontsContext", + "array": false + }, + "currentUser": { + "decl": "Context", + "kind": "get", + "type": "User", + "array": false + }, + "activeUsers": { + "decl": "Context", + "kind": "get", + "type": "ActiveUser", + "array": true + }, + "theme": { + "decl": "Context", + "kind": "get", + "type": "Theme", + "array": false + }, + "localStorage": { + "decl": "Context", + "kind": "get", + "type": "LocalStorage", + "array": false + }, + "selection": { + "decl": "Context", + "kind": "getset", + "type": "Shape", + "array": true + }, + "shapesColors": { + "decl": "Context", + "kind": "method", + "type": null, + "array": true + }, + "replaceColor": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "uploadMediaUrl": { + "decl": "Context", + "kind": "method", + "type": "ImageData", + "array": false + }, + "uploadMediaData": { + "decl": "Context", + "kind": "method", + "type": "ImageData", + "array": false + }, + "group": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "ungroup": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "createRectangle": { + "decl": "Context", + "kind": "method", + "type": "Rectangle", + "array": false + }, + "createBoard": { + "decl": "Context", + "kind": "method", + "type": "Board", + "array": false + }, + "createEllipse": { + "decl": "Context", + "kind": "method", + "type": "Ellipse", + "array": false + }, + "createPath": { + "decl": "Context", + "kind": "method", + "type": "Path", + "array": false + }, + "createBoolean": { + "decl": "Context", + "kind": "method", + "type": "Boolean", + "array": false + }, + "createShapeFromSvg": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "createShapeFromSvgWithImages": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "createText": { + "decl": "Context", + "kind": "method", + "type": "Text", + "array": false + }, + "generateMarkup": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "generateStyle": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "generateFontFaces": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "addListener": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "removeListener": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "openViewer": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "createPage": { + "decl": "Context", + "kind": "method", + "type": "Page", + "array": false + }, + "openPage": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "alignHorizontal": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "alignVertical": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "distributeHorizontal": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "distributeVertical": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "flatten": { + "decl": "Context", + "kind": "method", + "type": "Path", + "array": true + }, + "createVariantFromComponents": { + "decl": "Context", + "kind": "method", + "type": "VariantContainer", + "array": false + } + }, + "ContextGeometryUtils": { + "center": { + "decl": "ContextGeometryUtils", + "kind": "method", + "type": null, + "array": false + } + }, + "ContextTypesUtils": { + "isBoard": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isGroup": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isMask": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isBool": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isRectangle": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isPath": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isText": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isEllipse": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isSVG": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isVariantContainer": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isVariantComponent": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + } + }, + "ContextUtils": { + "geometry": { + "decl": "ContextUtils", + "kind": "get", + "type": "ContextGeometryUtils", + "array": false + }, + "types": { + "decl": "ContextUtils", + "kind": "get", + "type": "ContextTypesUtils", + "array": false + } + }, + "Dissolve": { + "type": { + "decl": "Dissolve", + "kind": "get", + "type": null, + "array": false + }, + "duration": { + "decl": "Dissolve", + "kind": "get", + "type": null, + "array": false + }, + "easing": { + "decl": "Dissolve", + "kind": "get", + "type": null, + "array": false + } + }, + "Ellipse": { + "type": { + "decl": "Ellipse", + "kind": "get", + "type": null, + "array": false + }, + "fills": { + "decl": "Ellipse", + "kind": "getset", + "type": "Fill", + "array": true + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "EventsMap": { + "pagechange": { + "decl": "EventsMap", + "kind": "get", + "type": "Page", + "array": false + }, + "filechange": { + "decl": "EventsMap", + "kind": "get", + "type": "File", + "array": false + }, + "selectionchange": { + "decl": "EventsMap", + "kind": "get", + "type": null, + "array": true + }, + "themechange": { + "decl": "EventsMap", + "kind": "get", + "type": "Theme", + "array": false + }, + "finish": { + "decl": "EventsMap", + "kind": "get", + "type": null, + "array": false + }, + "shapechange": { + "decl": "EventsMap", + "kind": "get", + "type": "Shape", + "array": false + }, + "contentsave": { + "decl": "EventsMap", + "kind": "get", + "type": null, + "array": false + } + }, + "Export": { + "type": { + "decl": "Export", + "kind": "getset", + "type": null, + "array": false + }, + "scale": { + "decl": "Export", + "kind": "getset", + "type": null, + "array": false + }, + "suffix": { + "decl": "Export", + "kind": "getset", + "type": null, + "array": false + }, + "skipChildren": { + "decl": "Export", + "kind": "getset", + "type": null, + "array": false + } + }, + "File": { + "id": { + "decl": "File", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "File", + "kind": "get", + "type": null, + "array": false + }, + "revn": { + "decl": "File", + "kind": "get", + "type": null, + "array": false + }, + "pages": { + "decl": "File", + "kind": "get", + "type": "Page", + "array": true + }, + "export": { + "decl": "File", + "kind": "method", + "type": null, + "array": false + }, + "findVersions": { + "decl": "File", + "kind": "method", + "type": "FileVersion", + "array": true + }, + "saveVersion": { + "decl": "File", + "kind": "method", + "type": "FileVersion", + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "FileVersion": { + "label": { + "decl": "FileVersion", + "kind": "getset", + "type": null, + "array": false + }, + "createdBy": { + "decl": "FileVersion", + "kind": "get", + "type": "User", + "array": false + }, + "createdAt": { + "decl": "FileVersion", + "kind": "get", + "type": null, + "array": false + }, + "isAutosave": { + "decl": "FileVersion", + "kind": "get", + "type": null, + "array": false + }, + "restore": { + "decl": "FileVersion", + "kind": "method", + "type": null, + "array": false + }, + "remove": { + "decl": "FileVersion", + "kind": "method", + "type": null, + "array": false + }, + "pin": { + "decl": "FileVersion", + "kind": "method", + "type": "FileVersion", + "array": false + } + }, + "Fill": { + "fillColor": { + "decl": "Fill", + "kind": "getset", + "type": null, + "array": false + }, + "fillOpacity": { + "decl": "Fill", + "kind": "getset", + "type": null, + "array": false + }, + "fillColorGradient": { + "decl": "Fill", + "kind": "getset", + "type": "Gradient", + "array": false + }, + "fillColorRefFile": { + "decl": "Fill", + "kind": "getset", + "type": null, + "array": false + }, + "fillColorRefId": { + "decl": "Fill", + "kind": "getset", + "type": null, + "array": false + }, + "fillImage": { + "decl": "Fill", + "kind": "getset", + "type": "ImageData", + "array": false + } + }, + "Flags": { + "naturalChildOrdering": { + "decl": "Flags", + "kind": "getset", + "type": null, + "array": false + }, + "throwValidationErrors": { + "decl": "Flags", + "kind": "getset", + "type": null, + "array": false + } + }, + "FlexLayout": { + "dir": { + "decl": "FlexLayout", + "kind": "getset", + "type": null, + "array": false + }, + "wrap": { + "decl": "FlexLayout", + "kind": "getset", + "type": null, + "array": false + }, + "appendChild": { + "decl": "FlexLayout", + "kind": "method", + "type": null, + "array": false + }, + "alignItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "alignContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rowGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "columnGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "topPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rightPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "bottomPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "leftPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "remove": { + "decl": "CommonLayout", + "kind": "method", + "type": null, + "array": false + } + }, + "Flow": { + "page": { + "decl": "Flow", + "kind": "get", + "type": "Page", + "array": false + }, + "name": { + "decl": "Flow", + "kind": "getset", + "type": null, + "array": false + }, + "startingBoard": { + "decl": "Flow", + "kind": "getset", + "type": "Board", + "array": false + }, + "remove": { + "decl": "Flow", + "kind": "method", + "type": null, + "array": false + } + }, + "Font": { + "name": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "fontId": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "fontFamily": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "fontStyle": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "fontVariantId": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "variants": { + "decl": "Font", + "kind": "get", + "type": "FontVariant", + "array": true + }, + "applyToText": { + "decl": "Font", + "kind": "method", + "type": null, + "array": false + }, + "applyToRange": { + "decl": "Font", + "kind": "method", + "type": null, + "array": false + } + }, + "FontsContext": { + "all": { + "decl": "FontsContext", + "kind": "get", + "type": "Font", + "array": true + }, + "findById": { + "decl": "FontsContext", + "kind": "method", + "type": "Font", + "array": false + }, + "findByName": { + "decl": "FontsContext", + "kind": "method", + "type": "Font", + "array": false + }, + "findAllById": { + "decl": "FontsContext", + "kind": "method", + "type": "Font", + "array": true + }, + "findAllByName": { + "decl": "FontsContext", + "kind": "method", + "type": "Font", + "array": true + } + }, + "FontVariant": { + "name": { + "decl": "FontVariant", + "kind": "get", + "type": null, + "array": false + }, + "fontVariantId": { + "decl": "FontVariant", + "kind": "get", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "FontVariant", + "kind": "get", + "type": null, + "array": false + }, + "fontStyle": { + "decl": "FontVariant", + "kind": "get", + "type": null, + "array": false + } + }, + "Gradient": { + "type": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "startX": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "startY": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "endX": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "endY": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "stops": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": true + } + }, + "GridLayout": { + "dir": { + "decl": "GridLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rows": { + "decl": "GridLayout", + "kind": "get", + "type": "Track", + "array": true + }, + "columns": { + "decl": "GridLayout", + "kind": "get", + "type": "Track", + "array": true + }, + "addRow": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "addRowAtIndex": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "addColumn": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "addColumnAtIndex": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "removeRow": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "removeColumn": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "setColumn": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "setRow": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "appendChild": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "alignItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "alignContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rowGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "columnGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "topPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rightPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "bottomPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "leftPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "remove": { + "decl": "CommonLayout", + "kind": "method", + "type": null, + "array": false + } + }, + "Group": { + "type": { + "decl": "Group", + "kind": "get", + "type": null, + "array": false + }, + "children": { + "decl": "Group", + "kind": "get", + "type": "Shape", + "array": true + }, + "appendChild": { + "decl": "Group", + "kind": "method", + "type": null, + "array": false + }, + "insertChild": { + "decl": "Group", + "kind": "method", + "type": null, + "array": false + }, + "isMask": { + "decl": "Group", + "kind": "method", + "type": null, + "array": false + }, + "makeMask": { + "decl": "Group", + "kind": "method", + "type": null, + "array": false + }, + "removeMask": { + "decl": "Group", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Fill", + "array": true + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "GuideColumn": { + "type": { + "decl": "GuideColumn", + "kind": "get", + "type": null, + "array": false + }, + "display": { + "decl": "GuideColumn", + "kind": "get", + "type": null, + "array": false + }, + "params": { + "decl": "GuideColumn", + "kind": "get", + "type": "GuideColumnParams", + "array": false + } + }, + "GuideColumnParams": { + "color": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + }, + "type": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + }, + "size": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + }, + "margin": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + }, + "itemLength": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + }, + "gutter": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + } + }, + "GuideRow": { + "type": { + "decl": "GuideRow", + "kind": "get", + "type": null, + "array": false + }, + "display": { + "decl": "GuideRow", + "kind": "get", + "type": null, + "array": false + }, + "params": { + "decl": "GuideRow", + "kind": "get", + "type": "GuideColumnParams", + "array": false + } + }, + "GuideSquare": { + "type": { + "decl": "GuideSquare", + "kind": "get", + "type": null, + "array": false + }, + "display": { + "decl": "GuideSquare", + "kind": "get", + "type": null, + "array": false + }, + "params": { + "decl": "GuideSquare", + "kind": "get", + "type": "GuideSquareParams", + "array": false + } + }, + "GuideSquareParams": { + "color": { + "decl": "GuideSquareParams", + "kind": "get", + "type": null, + "array": false + }, + "size": { + "decl": "GuideSquareParams", + "kind": "get", + "type": null, + "array": false + } + }, + "HistoryContext": { + "undoBlockBegin": { + "decl": "HistoryContext", + "kind": "method", + "type": null, + "array": false + }, + "undoBlockFinish": { + "decl": "HistoryContext", + "kind": "method", + "type": null, + "array": false + } + }, + "Image": { + "type": { + "decl": "Image", + "kind": "get", + "type": null, + "array": false + }, + "fills": { + "decl": "Image", + "kind": "getset", + "type": "Fill", + "array": true + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "ImageData": { + "name": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "width": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "mtype": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "keepAspectRatio": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "data": { + "decl": "ImageData", + "kind": "method", + "type": null, + "array": false + } + }, + "Interaction": { + "shape": { + "decl": "Interaction", + "kind": "get", + "type": "Shape", + "array": false + }, + "trigger": { + "decl": "Interaction", + "kind": "getset", + "type": "Trigger", + "array": false + }, + "delay": { + "decl": "Interaction", + "kind": "getset", + "type": null, + "array": false + }, + "action": { + "decl": "Interaction", + "kind": "getset", + "type": "Action", + "array": false + }, + "remove": { + "decl": "Interaction", + "kind": "method", + "type": null, + "array": false + } + }, + "LayoutCellProperties": { + "row": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + }, + "rowSpan": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + }, + "column": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + }, + "columnSpan": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + }, + "areaName": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + }, + "position": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + } + }, + "LayoutChildProperties": { + "absolute": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "zIndex": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalSizing": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "alignSelf": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "verticalMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "topMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "rightMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "bottomMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "leftMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "maxWidth": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "maxHeight": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "minWidth": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "minHeight": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + } + }, + "Library": { + "id": { + "decl": "Library", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "Library", + "kind": "get", + "type": null, + "array": false + }, + "colors": { + "decl": "Library", + "kind": "get", + "type": "LibraryColor", + "array": true + }, + "typographies": { + "decl": "Library", + "kind": "get", + "type": "LibraryTypography", + "array": true + }, + "components": { + "decl": "Library", + "kind": "get", + "type": "LibraryComponent", + "array": true + }, + "tokens": { + "decl": "Library", + "kind": "get", + "type": "TokenCatalog", + "array": false + }, + "createColor": { + "decl": "Library", + "kind": "method", + "type": "LibraryColor", + "array": false + }, + "createTypography": { + "decl": "Library", + "kind": "method", + "type": "LibraryTypography", + "array": false + }, + "createComponent": { + "decl": "Library", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LibraryColor": { + "color": { + "decl": "LibraryColor", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "LibraryColor", + "kind": "getset", + "type": null, + "array": false + }, + "gradient": { + "decl": "LibraryColor", + "kind": "getset", + "type": "Gradient", + "array": false + }, + "image": { + "decl": "LibraryColor", + "kind": "getset", + "type": "ImageData", + "array": false + }, + "asFill": { + "decl": "LibraryColor", + "kind": "method", + "type": "Fill", + "array": false + }, + "asStroke": { + "decl": "LibraryColor", + "kind": "method", + "type": "Stroke", + "array": false + }, + "id": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LibraryComponent": { + "instance": { + "decl": "LibraryComponent", + "kind": "method", + "type": "Shape", + "array": false + }, + "mainInstance": { + "decl": "LibraryComponent", + "kind": "method", + "type": "Shape", + "array": false + }, + "isVariant": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "transformInVariant": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LibraryContext": { + "local": { + "decl": "LibraryContext", + "kind": "get", + "type": "Library", + "array": false + }, + "connected": { + "decl": "LibraryContext", + "kind": "get", + "type": "Library", + "array": true + }, + "availableLibraries": { + "decl": "LibraryContext", + "kind": "method", + "type": "LibrarySummary", + "array": true + }, + "connectLibrary": { + "decl": "LibraryContext", + "kind": "method", + "type": "Library", + "array": false + } + }, + "LibraryElement": { + "id": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LibrarySummary": { + "id": { + "decl": "LibrarySummary", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibrarySummary", + "kind": "get", + "type": null, + "array": false + }, + "numColors": { + "decl": "LibrarySummary", + "kind": "get", + "type": null, + "array": false + }, + "numComponents": { + "decl": "LibrarySummary", + "kind": "get", + "type": null, + "array": false + }, + "numTypographies": { + "decl": "LibrarySummary", + "kind": "get", + "type": null, + "array": false + } + }, + "LibraryTypography": { + "fontId": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "fontFamily": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "fontVariantId": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "fontSize": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "fontStyle": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "lineHeight": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "letterSpacing": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "textTransform": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "applyToText": { + "decl": "LibraryTypography", + "kind": "method", + "type": null, + "array": false + }, + "applyToTextRange": { + "decl": "LibraryTypography", + "kind": "method", + "type": null, + "array": false + }, + "setFont": { + "decl": "LibraryTypography", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LibraryVariantComponent": { + "variants": { + "decl": "LibraryVariantComponent", + "kind": "get", + "type": "Variants", + "array": false + }, + "variantProps": { + "decl": "LibraryVariantComponent", + "kind": "get", + "type": null, + "array": false + }, + "variantError": { + "decl": "LibraryVariantComponent", + "kind": "getset", + "type": null, + "array": false + }, + "addVariant": { + "decl": "LibraryVariantComponent", + "kind": "method", + "type": null, + "array": false + }, + "setVariantProperty": { + "decl": "LibraryVariantComponent", + "kind": "method", + "type": null, + "array": false + }, + "instance": { + "decl": "LibraryComponent", + "kind": "method", + "type": "Shape", + "array": false + }, + "mainInstance": { + "decl": "LibraryComponent", + "kind": "method", + "type": "Shape", + "array": false + }, + "isVariant": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "transformInVariant": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LocalStorage": { + "getItem": { + "decl": "LocalStorage", + "kind": "method", + "type": null, + "array": false + }, + "setItem": { + "decl": "LocalStorage", + "kind": "method", + "type": null, + "array": false + }, + "removeItem": { + "decl": "LocalStorage", + "kind": "method", + "type": null, + "array": false + }, + "getKeys": { + "decl": "LocalStorage", + "kind": "method", + "type": null, + "array": true + } + }, + "NavigateTo": { + "type": { + "decl": "NavigateTo", + "kind": "get", + "type": null, + "array": false + }, + "destination": { + "decl": "NavigateTo", + "kind": "get", + "type": "Board", + "array": false + }, + "preserveScrollPosition": { + "decl": "NavigateTo", + "kind": "get", + "type": null, + "array": false + }, + "animation": { + "decl": "NavigateTo", + "kind": "get", + "type": "Animation", + "array": false + } + }, + "OpenOverlay": { + "type": { + "decl": "OpenOverlay", + "kind": "get", + "type": null, + "array": false + }, + "destination": { + "decl": "OverlayAction", + "kind": "get", + "type": "Board", + "array": false + }, + "relativeTo": { + "decl": "OverlayAction", + "kind": "get", + "type": "Shape", + "array": false + }, + "position": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "manualPositionLocation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Point", + "array": false + }, + "closeWhenClickOutside": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "addBackgroundOverlay": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "animation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Animation", + "array": false + } + }, + "OpenUrl": { + "type": { + "decl": "OpenUrl", + "kind": "get", + "type": null, + "array": false + }, + "url": { + "decl": "OpenUrl", + "kind": "get", + "type": null, + "array": false + } + }, + "OverlayAction": { + "destination": { + "decl": "OverlayAction", + "kind": "get", + "type": "Board", + "array": false + }, + "relativeTo": { + "decl": "OverlayAction", + "kind": "get", + "type": "Shape", + "array": false + }, + "position": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "manualPositionLocation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Point", + "array": false + }, + "closeWhenClickOutside": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "addBackgroundOverlay": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "animation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Animation", + "array": false + } + }, + "Page": { + "id": { + "decl": "Page", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "Page", + "kind": "getset", + "type": null, + "array": false + }, + "rulerGuides": { + "decl": "Page", + "kind": "get", + "type": "RulerGuide", + "array": true + }, + "root": { + "decl": "Page", + "kind": "get", + "type": "Shape", + "array": false + }, + "getShapeById": { + "decl": "Page", + "kind": "method", + "type": "Shape", + "array": false + }, + "findShapes": { + "decl": "Page", + "kind": "method", + "type": "Shape", + "array": true + }, + "flows": { + "decl": "Page", + "kind": "get", + "type": "Flow", + "array": true + }, + "createFlow": { + "decl": "Page", + "kind": "method", + "type": "Flow", + "array": false + }, + "removeFlow": { + "decl": "Page", + "kind": "method", + "type": null, + "array": false + }, + "addRulerGuide": { + "decl": "Page", + "kind": "method", + "type": "RulerGuide", + "array": false + }, + "removeRulerGuide": { + "decl": "Page", + "kind": "method", + "type": null, + "array": false + }, + "addCommentThread": { + "decl": "Page", + "kind": "method", + "type": "CommentThread", + "array": false + }, + "removeCommentThread": { + "decl": "Page", + "kind": "method", + "type": null, + "array": false + }, + "findCommentThreads": { + "decl": "Page", + "kind": "method", + "type": "CommentThread", + "array": true + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Path": { + "type": { + "decl": "Path", + "kind": "get", + "type": null, + "array": false + }, + "toD": { + "decl": "Path", + "kind": "method", + "type": null, + "array": false + }, + "content": { + "decl": "Path", + "kind": "getset", + "type": null, + "array": false + }, + "d": { + "decl": "Path", + "kind": "getset", + "type": null, + "array": false + }, + "commands": { + "decl": "Path", + "kind": "getset", + "type": "PathCommand", + "array": true + }, + "fills": { + "decl": "Path", + "kind": "getset", + "type": "Fill", + "array": true + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "PathCommand": { + "command": { + "decl": "PathCommand", + "kind": "getset", + "type": null, + "array": false + }, + "params": { + "decl": "PathCommand", + "kind": "getset", + "type": null, + "array": false + } + }, + "Penpot": { + "ui": { + "decl": "Penpot", + "kind": "get", + "type": null, + "array": false + }, + "utils": { + "decl": "Penpot", + "kind": "get", + "type": "ContextUtils", + "array": false + }, + "closePlugin": { + "decl": "Penpot", + "kind": "method", + "type": null, + "array": false + }, + "on": { + "decl": "Penpot", + "kind": "method", + "type": null, + "array": false + }, + "off": { + "decl": "Penpot", + "kind": "method", + "type": null, + "array": false + }, + "version": { + "decl": "Context", + "kind": "get", + "type": null, + "array": false + }, + "root": { + "decl": "Context", + "kind": "get", + "type": "Shape", + "array": false + }, + "currentFile": { + "decl": "Context", + "kind": "get", + "type": "File", + "array": false + }, + "currentPage": { + "decl": "Context", + "kind": "get", + "type": "Page", + "array": false + }, + "viewport": { + "decl": "Context", + "kind": "get", + "type": "Viewport", + "array": false + }, + "flags": { + "decl": "Context", + "kind": "get", + "type": "Flags", + "array": false + }, + "history": { + "decl": "Context", + "kind": "get", + "type": "HistoryContext", + "array": false + }, + "library": { + "decl": "Context", + "kind": "get", + "type": "LibraryContext", + "array": false + }, + "fonts": { + "decl": "Context", + "kind": "get", + "type": "FontsContext", + "array": false + }, + "currentUser": { + "decl": "Context", + "kind": "get", + "type": "User", + "array": false + }, + "activeUsers": { + "decl": "Context", + "kind": "get", + "type": "ActiveUser", + "array": true + }, + "theme": { + "decl": "Context", + "kind": "get", + "type": "Theme", + "array": false + }, + "localStorage": { + "decl": "Context", + "kind": "get", + "type": "LocalStorage", + "array": false + }, + "selection": { + "decl": "Context", + "kind": "getset", + "type": "Shape", + "array": true + }, + "shapesColors": { + "decl": "Context", + "kind": "method", + "type": null, + "array": true + }, + "replaceColor": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "uploadMediaUrl": { + "decl": "Context", + "kind": "method", + "type": "ImageData", + "array": false + }, + "uploadMediaData": { + "decl": "Context", + "kind": "method", + "type": "ImageData", + "array": false + }, + "group": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "ungroup": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "createRectangle": { + "decl": "Context", + "kind": "method", + "type": "Rectangle", + "array": false + }, + "createBoard": { + "decl": "Context", + "kind": "method", + "type": "Board", + "array": false + }, + "createEllipse": { + "decl": "Context", + "kind": "method", + "type": "Ellipse", + "array": false + }, + "createPath": { + "decl": "Context", + "kind": "method", + "type": "Path", + "array": false + }, + "createBoolean": { + "decl": "Context", + "kind": "method", + "type": "Boolean", + "array": false + }, + "createShapeFromSvg": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "createShapeFromSvgWithImages": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "createText": { + "decl": "Context", + "kind": "method", + "type": "Text", + "array": false + }, + "generateMarkup": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "generateStyle": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "generateFontFaces": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "openViewer": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "createPage": { + "decl": "Context", + "kind": "method", + "type": "Page", + "array": false + }, + "openPage": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "alignHorizontal": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "alignVertical": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "distributeHorizontal": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "distributeVertical": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "flatten": { + "decl": "Context", + "kind": "method", + "type": "Path", + "array": true + }, + "createVariantFromComponents": { + "decl": "Context", + "kind": "method", + "type": "VariantContainer", + "array": false + } + }, + "PluginData": { + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Point": { + "x": { + "decl": "Point", + "kind": "get", + "type": null, + "array": false + }, + "y": { + "decl": "Point", + "kind": "get", + "type": null, + "array": false + } + }, + "PreviousScreen": { + "type": { + "decl": "PreviousScreen", + "kind": "get", + "type": null, + "array": false + } + }, + "Push": { + "type": { + "decl": "Push", + "kind": "get", + "type": null, + "array": false + }, + "direction": { + "decl": "Push", + "kind": "get", + "type": null, + "array": false + }, + "duration": { + "decl": "Push", + "kind": "get", + "type": null, + "array": false + }, + "easing": { + "decl": "Push", + "kind": "get", + "type": null, + "array": false + } + }, + "Rectangle": { + "type": { + "decl": "Rectangle", + "kind": "get", + "type": null, + "array": false + }, + "fills": { + "decl": "Rectangle", + "kind": "getset", + "type": "Fill", + "array": true + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "RulerGuide": { + "orientation": { + "decl": "RulerGuide", + "kind": "get", + "type": "RulerGuideOrientation", + "array": false + }, + "position": { + "decl": "RulerGuide", + "kind": "getset", + "type": null, + "array": false + }, + "board": { + "decl": "RulerGuide", + "kind": "getset", + "type": "Board", + "array": false + } + }, + "Shadow": { + "id": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "style": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "offsetX": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "offsetY": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "blur": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "spread": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "color": { + "decl": "Shadow", + "kind": "getset", + "type": "Color", + "array": false + } + }, + "ShapeBase": { + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Fill", + "array": true + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Slide": { + "type": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + }, + "way": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + }, + "direction": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + }, + "duration": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + }, + "offsetEffect": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + }, + "easing": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + } + }, + "Stroke": { + "strokeColor": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeColorRefFile": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeColorRefId": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeOpacity": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeStyle": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeWidth": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeAlignment": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeCapStart": { + "decl": "Stroke", + "kind": "getset", + "type": "StrokeCap", + "array": false + }, + "strokeCapEnd": { + "decl": "Stroke", + "kind": "getset", + "type": "StrokeCap", + "array": false + }, + "strokeColorGradient": { + "decl": "Stroke", + "kind": "getset", + "type": "Gradient", + "array": false + } + }, + "SvgRaw": { + "type": { + "decl": "SvgRaw", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Fill", + "array": true + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Text": { + "type": { + "decl": "Text", + "kind": "get", + "type": null, + "array": false + }, + "characters": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "growType": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontId": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontFamily": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontVariantId": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontSize": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontStyle": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "lineHeight": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "letterSpacing": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "textTransform": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "textDecoration": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "direction": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "align": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "verticalAlign": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "textBounds": { + "decl": "Text", + "kind": "get", + "type": null, + "array": false + }, + "getRange": { + "decl": "Text", + "kind": "method", + "type": "TextRange", + "array": false + }, + "applyTypography": { + "decl": "Text", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Fill", + "array": true + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "TextRange": { + "shape": { + "decl": "TextRange", + "kind": "get", + "type": "Text", + "array": false + }, + "characters": { + "decl": "TextRange", + "kind": "get", + "type": null, + "array": false + }, + "fontId": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fontFamily": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fontVariantId": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fontSize": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fontStyle": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "lineHeight": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "letterSpacing": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "textTransform": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "textDecoration": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "direction": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "TextRange", + "kind": "getset", + "type": "Fill", + "array": true + }, + "align": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "verticalAlign": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "applyTypography": { + "decl": "TextRange", + "kind": "method", + "type": null, + "array": false + } + }, + "ToggleOverlay": { + "type": { + "decl": "ToggleOverlay", + "kind": "get", + "type": null, + "array": false + }, + "destination": { + "decl": "OverlayAction", + "kind": "get", + "type": "Board", + "array": false + }, + "relativeTo": { + "decl": "OverlayAction", + "kind": "get", + "type": "Shape", + "array": false + }, + "position": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "manualPositionLocation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Point", + "array": false + }, + "closeWhenClickOutside": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "addBackgroundOverlay": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "animation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Animation", + "array": false + } + }, + "TokenBase": { + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenBorderRadius": { + "type": { + "decl": "TokenBorderRadius", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenBorderRadius", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenBorderRadius", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenBorderWidth": { + "type": { + "decl": "TokenBorderWidth", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenBorderWidth", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenBorderWidth", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenCatalog": { + "themes": { + "decl": "TokenCatalog", + "kind": "get", + "type": "TokenTheme", + "array": true + }, + "sets": { + "decl": "TokenCatalog", + "kind": "get", + "type": "TokenSet", + "array": true + }, + "addTheme": { + "decl": "TokenCatalog", + "kind": "method", + "type": "TokenTheme", + "array": false + }, + "addSet": { + "decl": "TokenCatalog", + "kind": "method", + "type": "TokenSet", + "array": false + }, + "getThemeById": { + "decl": "TokenCatalog", + "kind": "method", + "type": "TokenTheme", + "array": false + }, + "getSetById": { + "decl": "TokenCatalog", + "kind": "method", + "type": "TokenSet", + "array": false + } + }, + "TokenColor": { + "type": { + "decl": "TokenColor", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenColor", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenColor", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenDimension": { + "type": { + "decl": "TokenDimension", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenDimension", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenDimension", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenFontFamilies": { + "type": { + "decl": "TokenFontFamilies", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenFontFamilies", + "kind": "getset", + "type": null, + "array": true + }, + "resolvedValue": { + "decl": "TokenFontFamilies", + "kind": "get", + "type": null, + "array": true + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenFontSizes": { + "type": { + "decl": "TokenFontSizes", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenFontSizes", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenFontSizes", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenFontWeights": { + "type": { + "decl": "TokenFontWeights", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenFontWeights", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenFontWeights", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenLetterSpacing": { + "type": { + "decl": "TokenLetterSpacing", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenLetterSpacing", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenLetterSpacing", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenNumber": { + "type": { + "decl": "TokenNumber", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenNumber", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenNumber", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenOpacity": { + "type": { + "decl": "TokenOpacity", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenOpacity", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenOpacity", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenRotation": { + "type": { + "decl": "TokenRotation", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenRotation", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenRotation", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenSet": { + "id": { + "decl": "TokenSet", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenSet", + "kind": "getset", + "type": null, + "array": false + }, + "active": { + "decl": "TokenSet", + "kind": "getset", + "type": null, + "array": false + }, + "tokens": { + "decl": "TokenSet", + "kind": "get", + "type": "Token", + "array": true + }, + "tokensByType": { + "decl": "TokenSet", + "kind": "get", + "type": null, + "array": true + }, + "toggleActive": { + "decl": "TokenSet", + "kind": "method", + "type": null, + "array": false + }, + "getTokenById": { + "decl": "TokenSet", + "kind": "method", + "type": "Token", + "array": false + }, + "addToken": { + "decl": "TokenSet", + "kind": "method", + "type": "Token", + "array": false + }, + "duplicate": { + "decl": "TokenSet", + "kind": "method", + "type": "TokenSet", + "array": false + }, + "remove": { + "decl": "TokenSet", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenShadow": { + "type": { + "decl": "TokenShadow", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenShadow", + "kind": "getset", + "type": "TokenShadowValueString", + "array": true + }, + "resolvedValue": { + "decl": "TokenShadow", + "kind": "get", + "type": "TokenShadowValue", + "array": true + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenShadowValue": { + "color": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + }, + "inset": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + }, + "offsetX": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + }, + "offsetY": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + }, + "spread": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + }, + "blur": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + } + }, + "TokenShadowValueString": { + "color": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + }, + "inset": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + }, + "offsetX": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + }, + "offsetY": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + }, + "spread": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + }, + "blur": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + } + }, + "TokenSizing": { + "type": { + "decl": "TokenSizing", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenSizing", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenSizing", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenSpacing": { + "type": { + "decl": "TokenSpacing", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenSpacing", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenSpacing", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenTextCase": { + "type": { + "decl": "TokenTextCase", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenTextCase", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenTextCase", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenTextDecoration": { + "type": { + "decl": "TokenTextDecoration", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenTextDecoration", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenTextDecoration", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenTheme": { + "id": { + "decl": "TokenTheme", + "kind": "get", + "type": null, + "array": false + }, + "externalId": { + "decl": "TokenTheme", + "kind": "get", + "type": null, + "array": false + }, + "group": { + "decl": "TokenTheme", + "kind": "getset", + "type": null, + "array": false + }, + "name": { + "decl": "TokenTheme", + "kind": "getset", + "type": null, + "array": false + }, + "active": { + "decl": "TokenTheme", + "kind": "getset", + "type": null, + "array": false + }, + "toggleActive": { + "decl": "TokenTheme", + "kind": "method", + "type": null, + "array": false + }, + "activeSets": { + "decl": "TokenTheme", + "kind": "get", + "type": "TokenSet", + "array": true + }, + "addSet": { + "decl": "TokenTheme", + "kind": "method", + "type": null, + "array": false + }, + "removeSet": { + "decl": "TokenTheme", + "kind": "method", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenTheme", + "kind": "method", + "type": "TokenTheme", + "array": false + }, + "remove": { + "decl": "TokenTheme", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenTypography": { + "type": { + "decl": "TokenTypography", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenTypography", + "kind": "getset", + "type": "TokenTypographyValueString", + "array": false + }, + "resolvedValue": { + "decl": "TokenTypography", + "kind": "get", + "type": "TokenTypographyValue", + "array": true + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenTypographyValue": { + "letterSpacing": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + }, + "fontFamilies": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": true + }, + "fontSizes": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + }, + "fontWeights": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + }, + "lineHeight": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + }, + "textCase": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + }, + "textDecoration": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + } + }, + "TokenTypographyValueString": { + "letterSpacing": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + }, + "fontFamilies": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": true + }, + "fontSizes": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + }, + "lineHeight": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + }, + "textCase": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + }, + "textDecoration": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + } + }, + "Track": { + "type": { + "decl": "Track", + "kind": "getset", + "type": "TrackType", + "array": false + }, + "value": { + "decl": "Track", + "kind": "getset", + "type": null, + "array": false + } + }, + "User": { + "id": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "avatarUrl": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "color": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "sessionId": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + } + }, + "VariantContainer": { + "variants": { + "decl": "VariantContainer", + "kind": "get", + "type": "Variants", + "array": false + }, + "type": { + "decl": "Board", + "kind": "get", + "type": null, + "array": false + }, + "clipContent": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "showInViewMode": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "grid": { + "decl": "Board", + "kind": "get", + "type": "GridLayout", + "array": false + }, + "flex": { + "decl": "Board", + "kind": "get", + "type": "FlexLayout", + "array": false + }, + "guides": { + "decl": "Board", + "kind": "getset", + "type": "Guide", + "array": true + }, + "rulerGuides": { + "decl": "Board", + "kind": "get", + "type": "RulerGuide", + "array": true + }, + "horizontalSizing": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "Board", + "kind": "getset", + "type": "Fill", + "array": true + }, + "children": { + "decl": "Board", + "kind": "getset", + "type": "Shape", + "array": true + }, + "appendChild": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "insertChild": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "addFlexLayout": { + "decl": "Board", + "kind": "method", + "type": "FlexLayout", + "array": false + }, + "addGridLayout": { + "decl": "Board", + "kind": "method", + "type": "GridLayout", + "array": false + }, + "addRulerGuide": { + "decl": "Board", + "kind": "method", + "type": "RulerGuide", + "array": false + }, + "removeRulerGuide": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "isVariantContainer": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Variants": { + "id": { + "decl": "Variants", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "Variants", + "kind": "get", + "type": null, + "array": false + }, + "properties": { + "decl": "Variants", + "kind": "get", + "type": null, + "array": true + }, + "currentValues": { + "decl": "Variants", + "kind": "method", + "type": null, + "array": true + }, + "removeProperty": { + "decl": "Variants", + "kind": "method", + "type": null, + "array": false + }, + "renameProperty": { + "decl": "Variants", + "kind": "method", + "type": null, + "array": false + }, + "variantComponents": { + "decl": "Variants", + "kind": "method", + "type": "LibraryComponent", + "array": true + }, + "addVariant": { + "decl": "Variants", + "kind": "method", + "type": null, + "array": false + }, + "addProperty": { + "decl": "Variants", + "kind": "method", + "type": null, + "array": false + } + }, + "Viewport": { + "center": { + "decl": "Viewport", + "kind": "getset", + "type": "Point", + "array": false + }, + "zoom": { + "decl": "Viewport", + "kind": "getset", + "type": null, + "array": false + }, + "bounds": { + "decl": "Viewport", + "kind": "get", + "type": "Bounds", + "array": false + }, + "zoomReset": { + "decl": "Viewport", + "kind": "method", + "type": null, + "array": false + }, + "zoomToFitAll": { + "decl": "Viewport", + "kind": "method", + "type": null, + "array": false + }, + "zoomIntoView": { + "decl": "Viewport", + "kind": "method", + "type": null, + "array": false + } + } + }, + "unions": { + "Action": { + "variants": [ + "NavigateTo", + "OpenOverlay", + "ToggleOverlay", + "CloseOverlay", + "PreviousScreen", + "OpenUrl" + ], + "discriminant": { + "field": "type", + "map": { + "navigate-to": "NavigateTo", + "open-overlay": "OpenOverlay", + "toggle-overlay": "ToggleOverlay", + "close-overlay": "CloseOverlay", + "previous-screen": "PreviousScreen", + "open-url": "OpenUrl" + } + } + }, + "Animation": { + "variants": ["Dissolve", "Slide", "Push"], + "discriminant": { + "field": "type", + "map": { + "dissolve": "Dissolve", + "slide": "Slide", + "push": "Push" + } + } + }, + "Guide": { + "variants": ["GuideColumn", "GuideRow", "GuideSquare"], + "discriminant": { + "field": "type", + "map": { + "column": "GuideColumn", + "row": "GuideRow", + "square": "GuideSquare" + } + } + }, + "Shape": { + "variants": [ + "Board", + "Group", + "Boolean", + "Rectangle", + "Path", + "Text", + "Ellipse", + "SvgRaw", + "Image" + ], + "discriminant": { + "field": "type", + "map": { + "board": "Board", + "group": "Group", + "boolean": "Boolean", + "rectangle": "Rectangle", + "path": "Path", + "text": "Text", + "ellipse": "Ellipse", + "svg-raw": "SvgRaw", + "image": "Image" + } + } + }, + "Token": { + "variants": [ + "TokenBorderRadius", + "TokenShadow", + "TokenColor", + "TokenDimension", + "TokenFontFamilies", + "TokenFontSizes", + "TokenFontWeights", + "TokenLetterSpacing", + "TokenNumber", + "TokenOpacity", + "TokenRotation", + "TokenSizing", + "TokenSpacing", + "TokenBorderWidth", + "TokenTextCase", + "TokenTextDecoration", + "TokenTypography" + ], + "discriminant": { + "field": "type", + "map": { + "borderRadius": "TokenBorderRadius", + "shadow": "TokenShadow", + "color": "TokenColor", + "dimension": "TokenDimension", + "fontFamilies": "TokenFontFamilies", + "fontSizes": "TokenFontSizes", + "fontWeights": "TokenFontWeights", + "letterSpacing": "TokenLetterSpacing", + "number": "TokenNumber", + "opacity": "TokenOpacity", + "rotation": "TokenRotation", + "sizing": "TokenSizing", + "spacing": "TokenSpacing", + "borderWidth": "TokenBorderWidth", + "textCase": "TokenTextCase", + "textDecoration": "TokenTextDecoration", + "typography": "TokenTypography" + } + } + }, + "TokenValueString": { + "variants": ["TokenShadowValueString", "TokenTypographyValueString"], + "discriminant": null + } + } +} diff --git a/plugins/apps/plugin-api-test-suite/src/model.ts b/plugins/apps/plugin-api-test-suite/src/model.ts new file mode 100644 index 0000000000..a86cb3eb19 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/model.ts @@ -0,0 +1,60 @@ +import type { + CoverageReport, + RunSummary, + TestMeta, + TestResult, +} from './framework/types'; + +// Messages sent from the UI iframe to the plugin sandbox. +export interface ReadyMessage { + type: 'ready'; +} + +export interface RunMessage { + type: 'run'; + ids: string[] | 'all'; +} + +/** Carries the freshly built tests bundle source to be evaluated in the sandbox. */ +export interface ReloadTestsMessage { + type: 'reloadTests'; + code: string; +} + +export type UIToPluginMessage = ReadyMessage | RunMessage | ReloadTestsMessage; + +// Messages sent from the plugin sandbox to the UI iframe. +export interface TestsMessage { + type: 'tests'; + tests: TestMeta[]; +} + +export interface ResultMessage { + type: 'result'; + result: TestResult; +} + +export interface RunCompleteMessage { + type: 'runComplete'; + summary: RunSummary; + coverage: CoverageReport; +} + +export interface ThemeMessage { + type: 'theme'; + theme: string; +} + +/** Sent after a reload attempt so the UI can surface success/failure. */ +export interface ReloadedMessage { + type: 'reloaded'; + ok: boolean; + error?: string; +} + +export type PluginToUIMessage = + | TestsMessage + | ResultMessage + | RunCompleteMessage + | ThemeMessage + | ReloadedMessage; diff --git a/plugins/apps/plugin-api-test-suite/src/plugin.ts b/plugins/apps/plugin-api-test-suite/src/plugin.ts new file mode 100644 index 0000000000..db4f548bbb --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/plugin.ts @@ -0,0 +1,63 @@ +import { getTestMetas, setTests } from './framework/registry'; +import type { TestCase } from './framework/types'; +import { runTests } from './framework/runner'; +import type { PluginToUIMessage, UIToPluginMessage } from './model'; + +// Auto-discover every test. Importing the modules eagerly runs their top-level +// `test(...)` calls, which register them into the shared registry. +import.meta.glob('./tests/*.test.ts', { eager: true }); + +penpot.ui.open('Plugin API Test Suite', `?theme=${penpot.theme}`, { + width: 400, + height: 600, +}); + +function send(message: PluginToUIMessage) { + penpot.ui.sendMessage(message); +} + +penpot.ui.onMessage(async (message) => { + if (message.type === 'ready') { + send({ type: 'tests', tests: getTestMetas() }); + return; + } + + if (message.type === 'run') { + const { summary, coverage } = await runTests(message.ids, (result) => + send({ type: 'result', result }), + ); + send({ type: 'runComplete', summary, coverage }); + return; + } + + if (message.type === 'reloadTests') { + try { + // The runtime is configured with `evalTaming: 'unsafeEval'`, so evaluating + // the freshly built IIFE bundle is allowed. It publishes the discovered + // tests on `globalThis.__penpotReloadedTests`, which we swap into the + // registry so the next run uses the edited code. + const globals = globalThis as unknown as { + __penpotReloadedTests?: TestCase[]; + }; + globals.__penpotReloadedTests = undefined; + (0, eval)(message.code); + const reloaded = globals.__penpotReloadedTests; + if (!reloaded) { + throw new Error('Reloaded bundle did not expose any tests'); + } + setTests(reloaded); + send({ type: 'tests', tests: getTestMetas() }); + send({ type: 'reloaded', ok: true }); + } catch (err) { + send({ + type: 'reloaded', + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + } + } +}); + +penpot.on('themechange', () => { + send({ type: 'theme', theme: penpot.theme }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests-bundle.ts b/plugins/apps/plugin-api-test-suite/src/tests-bundle.ts new file mode 100644 index 0000000000..cdba097903 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests-bundle.ts @@ -0,0 +1,15 @@ +import { getTests } from './framework/registry'; + +// Standalone bundle of all test cases, built as a single self-executing (IIFE) +// chunk by `vite.config.tests.ts` and rebuilt on every save by `watch`. +// +// The reload flow (see `src/plugin.ts`) fetches the freshly built bundle and +// `eval`s it inside the plugin sandbox. Importing the test modules registers them +// into this bundle's own registry; we then publish the discovered tests on +// `globalThis` so the sandbox can pick them up and swap them in without the user +// having to close and reopen the plugin. +import.meta.glob('./tests/*.test.ts', { eager: true }); + +( + globalThis as unknown as { __penpotReloadedTests?: unknown } +).__penpotReloadedTests = getTests(); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/colors.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/colors.test.ts new file mode 100644 index 0000000000..d61a34234d --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/colors.test.ts @@ -0,0 +1,50 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { TestContext } from '../framework/types'; + +// Colors. +// Exercises the context-level color helpers shapesColors() and replaceColor(), +// plus the ColorShapeInfo metadata they expose. + +function rect(ctx: TestContext) { + const r = ctx.penpot.createRectangle(); + ctx.board.appendChild(r); + return r; +} + +describe('Colors', () => { + test('shapesColors lists the colors used by shapes', (ctx) => { + const r = rect(ctx); + r.fills = [{ fillColor: '#abcdef', fillOpacity: 1 }]; + + const colors = ctx.penpot.shapesColors([r]); + expect(colors.length).toBeGreaterThan(0); + + const entry = colors.find((c) => c.color === '#abcdef'); + expect(entry).toBeDefined(); + if (entry) { + expect(entry.shapesInfo).toBeDefined(); + expect(entry.shapesInfo.length).toBeGreaterThan(0); + expect(entry.shapesInfo[0].property).toBe('fill'); + expect(entry.shapesInfo[0].shapeId).toBe(r.id); + } + }); + + test('replaceColor swaps a solid fill color', (ctx) => { + const r = rect(ctx); + r.fills = [{ fillColor: '#111111', fillOpacity: 1 }]; + + // replaceColor matches by exact color-attrs equality, so the old color must + // include the same opacity the fill has. + ctx.penpot.replaceColor( + [r], + { color: '#111111', opacity: 1 }, + { color: '#222222', opacity: 1 }, + ); + + const fills = r.fills; + if (Array.isArray(fills)) { + expect(fills[0].fillColor).toBe('#222222'); + } + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts new file mode 100644 index 0000000000..b6ba1745ad --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts @@ -0,0 +1,158 @@ +import { expect, expectReject } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { CommentThread, Page } from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; + +// Comments. +// Comment threads are created on the current page. Both thread removal APIs are +// currently broken (see the dedicated red tests), so cleanup is best-effort to +// keep the other assertions meaningful. + +function page(ctx: TestContext): Page { + const p = ctx.penpot.currentPage; + if (!p) throw new Error('no current page'); + return p; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function cleanup(thread: CommentThread): void { + try { + thread.remove(); + } catch (err) { + void err; // thread.remove is currently broken; ignore for cleanup + } +} + +// Skipped under MOCK_BACKEND: comments assert backend-shaped responses +// (seqNumber, etc.) and pin real backend behaviour that a mock won't reproduce. +describe.skipIfMocked('Comments', () => { + test('addCommentThread creates a thread', async (ctx) => { + const p = page(ctx); + const thread = await p.addCommentThread('Hello comment', { + x: 100, + y: 120, + }); + try { + expect(typeof thread.seqNumber).toBe('number'); + expect(thread.position.x).toBeCloseTo(100, 0); + expect(thread.position.y).toBeCloseTo(120, 0); + expect(thread.resolved).toBe(false); + expect(thread.owner).toBeDefined(); + // A page-level thread has no board; reading it still exercises the getter. + void thread.board; + } finally { + cleanup(thread); + } + }); + + test('findCommentThreads lists threads', async (ctx) => { + const p = page(ctx); + const thread = await p.addCommentThread('Find me', { x: 50, y: 50 }); + try { + const threads = await p.findCommentThreads(); + expect(threads.length).toBeGreaterThan(0); + } finally { + cleanup(thread); + } + }); + + test('reply adds a comment and findComments lists them', async (ctx) => { + const p = page(ctx); + const thread = await p.addCommentThread('First comment', { x: 10, y: 10 }); + try { + const reply = await thread.reply('A reply'); + expect(reply.content).toBe('A reply'); + expect(reply.user).toBeDefined(); + expect(reply.date).toBeDefined(); + + const comments = await thread.findComments(); + expect(comments.length).toBeGreaterThan(1); + } finally { + cleanup(thread); + } + }); + + test('thread resolved and position round-trip', async (ctx) => { + const p = page(ctx); + const thread = await p.addCommentThread('Toggle me', { x: 30, y: 30 }); + try { + thread.resolved = true; + expect(thread.resolved).toBe(true); + thread.position = { x: 200, y: 220 }; + expect(thread.position.x).toBeCloseTo(200, 0); + expect(thread.position.y).toBeCloseTo(220, 0); + } finally { + cleanup(thread); + } + }); + + test('comment content round-trips', async (ctx) => { + const p = page(ctx); + const thread = await p.addCommentThread('Editable', { x: 0, y: 0 }); + try { + const comments = await thread.findComments(); + const comment = comments[0]; + comment.content = 'edited content'; + // The content setter persists via an async RPC before updating locally. + await sleep(300); + expect(comment.content).toBe('edited content'); + expect(comment.user).toBeDefined(); + } finally { + cleanup(thread); + } + }); + + test('a comment can be removed', async (ctx) => { + const p = page(ctx); + const thread = await p.addCommentThread('Keep', { x: 5, y: 5 }); + try { + const reply = await thread.reply('to be removed'); + await reply.remove(); + const comments = await thread.findComments(); + expect(comments.length).toBeGreaterThan(0); + } finally { + cleanup(thread); + } + }); + + test('a comment thread can be removed', async (ctx) => { + const p = page(ctx); + const thread = await p.addCommentThread('Remove via thread', { + x: 8, + y: 8, + }); + thread.remove(); + const threads = await p.findCommentThreads(); + expect(threads.every((t) => t.seqNumber !== thread.seqNumber)).toBe(true); + }); + + test('removeCommentThread removes a thread', async (ctx) => { + const p = page(ctx); + const thread: CommentThread = await p.addCommentThread('Remove me', { + x: 70, + y: 70, + }); + await p.removeCommentThread(thread); + }); + + // --------------------------------------------------------------------------- + // Edge cases: empty comment content must be rejected. + // --------------------------------------------------------------------------- + test('addCommentThread with empty content rejects', async (ctx) => { + const p = page(ctx); + await expectReject(() => p.addCommentThread('', { x: 0, y: 0 })); + }); + + test('reply with empty content rejects', async (ctx) => { + const p = page(ctx); + const thread = await p.addCommentThread('parent', { x: 12, y: 12 }); + try { + await expectReject(() => thread.reply('')); + } finally { + cleanup(thread); + } + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts new file mode 100644 index 0000000000..4982bc574c --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts @@ -0,0 +1,135 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { Board, Shape } from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; + +// Component instances and the ShapeBase component methods. +// A component is built from a rectangle and instantiated; the instance exposes +// the component predicates and navigation methods. + +function makeComponent(ctx: TestContext) { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + return ctx.penpot.library.local.createComponent([rect]); +} + +function instanceOf(ctx: TestContext): Shape { + const comp = makeComponent(ctx); + const inst = comp.instance(); + ctx.board.appendChild(inst); + return inst; +} + +describe('Component instances', () => { + test('component predicates identify an instance', (ctx) => { + const inst = instanceOf(ctx); + expect(inst.isComponentInstance()).toBeTruthy(); + expect(inst.isComponentRoot()).toBeTruthy(); + expect(inst.isComponentHead()).toBeTruthy(); + // A fresh instance is a copy, not the main instance. + expect(inst.isComponentMainInstance()).toBeFalsy(); + expect(inst.isComponentCopyInstance()).toBeTruthy(); + expect(inst.isVariantHead()).toBeFalsy(); + }); + + test('component navigation methods return shapes', (ctx) => { + const inst = instanceOf(ctx); + expect(inst.componentRoot()).toBeDefined(); + expect(inst.componentHead()).toBeDefined(); + expect(inst.componentRefShape()).toBeDefined(); + }); + + test('component() returns the library component', (ctx) => { + const inst = instanceOf(ctx); + const comp = inst.component(); + expect(comp).not.toBeNull(); + if (comp) { + expect(typeof comp.id).toBe('string'); + } + }); + + test('detach turns an instance into a basic shape', (ctx) => { + const inst = instanceOf(ctx); + inst.detach(); + expect(inst.isComponentInstance()).toBeFalsy(); + }); + + test('swapComponent replaces the instance component', (ctx) => { + const inst = instanceOf(ctx); + const other = makeComponent(ctx); + inst.swapComponent(other); + const comp = inst.component(); + expect(comp).not.toBeNull(); + if (comp) { + expect(comp.id).toBe(other.id); + } + }); + + // --------------------------------------------------------------------------- + // Edge cases. "fail" tests exercise the component methods on shapes + // that are not component instances (documented null/self returns, invalid + // swap target); the "success" test checks instance independence. + // --------------------------------------------------------------------------- + test('component() on a plain shape returns null', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + expect(rect.component()).toBeNull(); + }); + + test('componentRoot() on a plain shape returns null', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + // componentRoot (like component(), componentHead(), componentRefShape()) + // is null for a shape that is not part of any component. The d.ts + // "returns itself" note applies to a shape that IS the root of a component. + expect(rect.componentRoot()).toBeNull(); + }); + + test('swapComponent with a non-component target throws', (ctx) => { + const inst = instanceOf(ctx); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + expect(() => + inst.swapComponent(rect as unknown as ReturnType), + ).toThrow(); + }); + + test('two instances of one component are independent but share the source', (ctx) => { + const comp = makeComponent(ctx); + const first = comp.instance(); + const second = comp.instance(); + ctx.board.appendChild(first); + ctx.board.appendChild(second); + + first.name = 'first'; + second.name = 'second'; + expect(first.id).not.toBe(second.id); + expect(first.name).toBe('first'); + expect(second.name).toBe('second'); + + const c1 = first.component(); + const c2 = second.component(); + expect(c1).not.toBeNull(); + expect(c2).not.toBeNull(); + if (c1 && c2) { + expect(c1.id).toBe(c2.id); + } + }); +}); + +describe('Shape interactions cleanup', () => { + test('removeInteraction removes an interaction from a shape', (ctx) => { + const dest = ctx.penpot.createBoard(); + ctx.board.appendChild(dest as Board); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + + const interaction = rect.addInteraction('click', { + type: 'navigate-to', + destination: dest, + }); + const before = rect.interactions.length; + rect.removeInteraction(interaction); + expect(rect.interactions.length).toBe(before - 1); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/events.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/events.test.ts new file mode 100644 index 0000000000..8d1fddf66a --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/events.test.ts @@ -0,0 +1,67 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; + +// Events. +// Listeners are registered with `on`, triggered by mutating state, and removed +// with `off`. Callbacks are debounced (~10ms), so the tests wait before asserting. + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('Events', () => { + test('selectionchange fires with the selected ids', async (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + + let received: string[] | null = null; + const listenerId = ctx.penpot.on('selectionchange', (ids) => { + received = ids; + }); + + ctx.penpot.selection = [rect]; + await sleep(150); + ctx.penpot.off(listenerId); + + expect(received).not.toBeNull(); + if (received) { + expect((received as string[]).includes(rect.id)).toBe(true); + } + }); + + test('shapechange fires when the observed shape changes', async (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + + let fired = false; + const listenerId = ctx.penpot.on( + 'shapechange', + () => { + fired = true; + }, + { shapeId: rect.id }, + ); + + rect.name = 'changed-name'; + await sleep(150); + ctx.penpot.off(listenerId); + + expect(fired).toBe(true); + }); + + test('off stops further notifications', async (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + + let count = 0; + const listenerId = ctx.penpot.on('selectionchange', () => { + count += 1; + }); + ctx.penpot.off(listenerId); + + ctx.penpot.selection = [rect]; + await sleep(150); + + expect(count).toBe(0); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/file.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/file.test.ts new file mode 100644 index 0000000000..5f8f4a8b11 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/file.test.ts @@ -0,0 +1,103 @@ +import { expect, expectReject } from '../framework/expect'; +import { describe, test } from '../framework/registry'; + +// File & versions. +// Read-only assertions on currentFile plus the version history API. The file +// name is only read (renaming would mutate the user's file). + +describe('File', () => { + test('currentFile exposes id and name', (ctx) => { + const file = ctx.penpot.currentFile; + expect(file).not.toBeNull(); + if (file) { + expect(typeof file.id).toBe('string'); + expect(typeof file.name).toBe('string'); + } + }); + + test('currentFile exposes revn', (ctx) => { + const file = ctx.penpot.currentFile; + if (file) { + expect(typeof file.revn).toBe('number'); + } + }); + + test('file lists its pages', (ctx) => { + const file = ctx.penpot.currentFile; + if (file) { + expect(file.pages.length).toBeGreaterThan(0); + expect(typeof file.pages[0].id).toBe('string'); + } + }); + + test('export returns binary data', async (ctx) => { + const file = ctx.penpot.currentFile; + if (file) { + // The exporter service may be unavailable in the headless runner, so a + // rejection here is treated as an environment limitation; when it does + // run, the result must be a non-empty byte array. + const data = await file.export('penpot', 'detach').catch(() => null); + if (data) { + expect(data.length).toBeGreaterThan(0); + } + } + }); + + // Skipped under MOCK_BACKEND: version history is persisted/returned by the + // backend; a no-op persist mock can't reproduce saved versions. + describe.skipIfMocked('Versions', () => { + test('saveVersion and findVersions manage version history', async (ctx) => { + const file = ctx.penpot.currentFile; + expect(file).not.toBeNull(); + if (file) { + const version = await file.saveVersion('plugin-test-version'); + expect(version).toBeDefined(); + expect(version.label).toBe('plugin-test-version'); + expect(version.isAutosave).toBe(false); + + // Relabel the saved version (covers FileVersion.label set). + version.label = 'plugin-test-version-renamed'; + expect(version.label).toBe('plugin-test-version-renamed'); + + const versions = await file.findVersions(); + expect(versions.length).toBeGreaterThan(0); + + // Clean up the version we just created. + await version.remove(); + } + }); + + test('version exposes its creation date', async (ctx) => { + const file = ctx.penpot.currentFile; + if (file) { + const version = await file.saveVersion('plugin-test-version-date'); + try { + expect(version.createdAt).toBeDefined(); + } finally { + await version.remove(); + } + } + }); + + test('version createdBy is exercised', async (ctx) => { + const file = ctx.penpot.currentFile; + if (file) { + const version = await file.saveVersion('plugin-test-version-pin'); + void version.createdBy; + // `pin` is intentionally not exercised: it only converts a *system* + // autosave to a permanent version, and a plugin cannot create an + // autosave, so calling it would always reject. See README.md. + await version.remove().catch(() => undefined); + } + }); + + // Edge case: an empty version label must be rejected. + test('saveVersion with an empty label rejects', async (ctx) => { + const file = ctx.penpot.currentFile; + expect(file).not.toBeNull(); + if (file) { + await expectReject(() => file.saveVersion('')); + } + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts new file mode 100644 index 0000000000..bf4ef96cf0 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts @@ -0,0 +1,280 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { TestContext } from '../framework/types'; + +// Fills & strokes. +// Fills/strokes are assigned as whole arrays of plain objects and read back +// through the shape proxy, so the Fill/Stroke getters are what coverage records +// (the per-property setters are not individually settable at runtime). +// +// Each group bundles its happy-path round-trips together with the related edge +// cases: "throws" tests assert invalid input is rejected, the "(currently +// unvalidated)" tests pin lenient behaviour, and the remaining ones cover +// non-trivial valid behaviour (ordering, type switching, multiple strokes, +// clearing). + +function rect(ctx: TestContext) { + const r = ctx.penpot.createRectangle(); + ctx.board.appendChild(r); + return r; +} + +describe('Fills & strokes', () => { + describe('Fills', () => { + test('solid fill color and opacity round-trip', (ctx) => { + const r = rect(ctx); + r.fills = [{ fillColor: '#ff0000', fillOpacity: 0.5 }]; + + const fills = r.fills; + expect(fills).toHaveLength(1); + if (Array.isArray(fills)) { + expect(fills[0].fillColor).toBe('#ff0000'); + expect(fills[0].fillOpacity).toBeCloseTo(0.5, 2); + } + }); + + test('gradient fill is preserved', (ctx) => { + const r = rect(ctx); + r.fills = [ + { + fillColorGradient: { + type: 'linear', + startX: 0, + startY: 0, + endX: 1, + endY: 1, + width: 1, + stops: [ + { color: '#ff0000', opacity: 1, offset: 0 }, + { color: '#0000ff', opacity: 1, offset: 1 }, + ], + }, + }, + ]; + + const fills = r.fills; + if (Array.isArray(fills)) { + const gradient = fills[0].fillColorGradient; + expect(gradient).toBeDefined(); + expect(gradient && gradient.type).toBe('linear'); + } + }); + + test('multiple fills can be stacked', (ctx) => { + const r = rect(ctx); + r.fills = [ + { fillColor: '#ff0000', fillOpacity: 0.5 }, + { fillColor: '#0000ff', fillOpacity: 0.5 }, + ]; + expect(r.fills).toHaveLength(2); + }); + + test('multiple fills preserve their order', (ctx) => { + const r = rect(ctx); + r.fills = [ + { fillColor: '#ff0000', fillOpacity: 1 }, + { fillColor: '#00ff00', fillOpacity: 1 }, + { fillColor: '#0000ff', fillOpacity: 1 }, + ]; + const fills = r.fills; + expect(fills).toHaveLength(3); + if (Array.isArray(fills)) { + expect(fills.map((f) => f.fillColor)).toEqual([ + '#ff0000', + '#00ff00', + '#0000ff', + ]); + } + }); + + test('a fill can switch solid -> gradient -> solid', (ctx) => { + const r = rect(ctx); + r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + r.fills = [ + { + fillColorGradient: { + type: 'linear', + startX: 0, + startY: 0, + endX: 1, + endY: 1, + width: 1, + stops: [ + { color: '#ff0000', opacity: 1, offset: 0 }, + { color: '#0000ff', opacity: 1, offset: 1 }, + ], + }, + }, + ]; + let fills = r.fills; + if (Array.isArray(fills)) { + expect(fills[0].fillColorGradient).toBeDefined(); + } + r.fills = [{ fillColor: '#00ff00', fillOpacity: 1 }]; + fills = r.fills; + if (Array.isArray(fills)) { + expect(fills[0].fillColor).toBe('#00ff00'); + // Switching back to a solid fill clears the gradient (read back as null). + expect(fills[0].fillColorGradient).toBeFalsy(); + } + }); + + test('fillOpacity above 1 throws', (ctx) => { + const r = rect(ctx); + expect(() => { + r.fills = [{ fillColor: '#ff0000', fillOpacity: 1.5 }]; + }).toThrow(); + }); + + test('fillOpacity below 0 throws', (ctx) => { + const r = rect(ctx); + expect(() => { + r.fills = [{ fillColor: '#ff0000', fillOpacity: -0.5 }]; + }).toThrow(); + }); + + test('setting fills on a group is accepted (currently unvalidated)', (ctx) => { + // The plugin API does not block fills on groups, so the assignment is + // accepted rather than rejected. This pins the current (lenient) behaviour. + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + ctx.board.appendChild(a); + ctx.board.appendChild(b); + const group = ctx.penpot.group([a, b]); + expect(group).not.toBeNull(); + if (group) { + expect(() => { + group.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + }).not.toThrow(); + } + }); + + test('assigning empty arrays clears fills and strokes', (ctx) => { + const r = rect(ctx); + r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }]; + r.fills = []; + r.strokes = []; + expect(r.fills).toHaveLength(0); + expect(r.strokes).toHaveLength(0); + }); + }); + + describe('Strokes', () => { + test('stroke properties round-trip', (ctx) => { + const r = rect(ctx); + r.strokes = [ + { + strokeColor: '#0000ff', + strokeOpacity: 1, + strokeStyle: 'solid', + strokeWidth: 3, + strokeAlignment: 'center', + }, + ]; + + expect(r.strokes).toHaveLength(1); + const stroke = r.strokes[0]; + expect(stroke.strokeColor).toBe('#0000ff'); + expect(stroke.strokeOpacity).toBeCloseTo(1, 2); + expect(stroke.strokeStyle).toBe('solid'); + expect(stroke.strokeWidth).toBeCloseTo(3, 0); + expect(stroke.strokeAlignment).toBe('center'); + }); + + test('stroke caps round-trip on an open path', (ctx) => { + const path = ctx.penpot.createPath(); + ctx.board.appendChild(path); + path.d = 'M0 0 L40 0'; + path.strokes = [ + { + strokeColor: '#000000', + strokeWidth: 4, + strokeCapStart: 'round', + strokeCapEnd: 'triangle-arrow', + }, + ]; + + const stroke = path.strokes[0]; + expect(stroke.strokeCapStart).toBe('round'); + expect(stroke.strokeCapEnd).toBe('triangle-arrow'); + }); + + test('dashed stroke style is preserved', (ctx) => { + const r = rect(ctx); + r.strokes = [ + { strokeColor: '#00ff00', strokeWidth: 2, strokeStyle: 'dashed' }, + ]; + expect(r.strokes[0].strokeStyle).toBe('dashed'); + }); + + test('dotted stroke style is preserved', (ctx) => { + const r = rect(ctx); + r.strokes = [ + { strokeColor: '#0000ff', strokeWidth: 2, strokeStyle: 'dotted' }, + ]; + expect(r.strokes[0].strokeStyle).toBe('dotted'); + }); + + test("stroke style 'none' is rejected at runtime (d.ts lists it)", (ctx) => { + // The d.ts allows strokeStyle 'none', but the runtime rejects it as an + // invalid value ("Value not valid"), so with throwValidationErrors it + // throws. Pins the current d.ts/runtime mismatch. + const r = rect(ctx); + expect(() => { + r.strokes = [ + { strokeColor: '#0000ff', strokeWidth: 2, strokeStyle: 'none' }, + ]; + }).toThrow(); + }); + + test('two strokes with different alignment coexist', (ctx) => { + const r = rect(ctx); + r.strokes = [ + { strokeColor: '#000000', strokeWidth: 2, strokeAlignment: 'inner' }, + { strokeColor: '#ffffff', strokeWidth: 1, strokeAlignment: 'outer' }, + ]; + expect(r.strokes).toHaveLength(2); + expect(r.strokes.map((s) => s.strokeAlignment).sort()).toEqual([ + 'inner', + 'outer', + ]); + }); + + test('negative strokeWidth is accepted (currently unvalidated)', (ctx) => { + // The plugin API does not constrain strokeWidth to be non-negative, so a + // negative value is stored as-is rather than rejected. This pins the current + // (lenient) behaviour. + const r = rect(ctx); + r.strokes = [{ strokeColor: '#000000', strokeWidth: -3 }]; + expect(r.strokes).toHaveLength(1); + expect(typeof r.strokes[0].strokeWidth).toBe('number'); + }); + + test('invalid strokeStyle throws', (ctx) => { + const r = rect(ctx); + expect(() => { + r.strokes = [ + { + strokeColor: '#000000', + strokeWidth: 1, + strokeStyle: 'wavy' as unknown as 'solid', + }, + ]; + }).toThrow(); + }); + + test('invalid strokeAlignment throws', (ctx) => { + const r = rect(ctx); + expect(() => { + r.strokes = [ + { + strokeColor: '#000000', + strokeWidth: 1, + strokeAlignment: 'middle' as unknown as 'center', + }, + ]; + }).toThrow(); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/fixtures.ts b/plugins/apps/plugin-api-test-suite/src/tests/fixtures.ts new file mode 100644 index 0000000000..45c3302b4d --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/fixtures.ts @@ -0,0 +1,12 @@ +// Shared test fixtures. Not a `*.test.ts`, so the runner's glob doesn't pick it +// up as a test file; it's only imported by the tests that need it. + +// A valid 1x1 PNG (opaque red, RGBA), so uploadMediaData needs no network. The +// bytes must form a well-formed PNG — the backend processes the image with +// ImageMagick, which rejects a malformed IDAT chunk (bad CRC / extra data). +export const PNG_1X1 = new Uint8Array([ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, + 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, + 156, 99, 248, 207, 192, 240, 31, 0, 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, + 0, 73, 69, 78, 68, 174, 66, 96, 130, +]); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts new file mode 100644 index 0000000000..7340ddfa94 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts @@ -0,0 +1,122 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { Text } from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; + +// Fonts. +// Exercises the FontsContext lookups, the Font/FontVariant metadata, applying a +// font to a text shape / range, and generateFontFaces. Fonts are self-provided +// from `fonts.all` so the tests don't depend on a specific font being present. + +function text(ctx: TestContext, value = 'Hello Penpot'): Text { + const t = ctx.penpot.createText(value); + if (!t) throw new Error('createText returned null'); + ctx.board.appendChild(t); + return t; +} + +describe('Fonts', () => { + test('fonts.all lists available fonts', (ctx) => { + const all = ctx.penpot.fonts.all; + expect(all.length).toBeGreaterThan(0); + }); + + test('a font exposes metadata and variants', (ctx) => { + const font = ctx.penpot.fonts.all[0]; + expect(typeof font.name).toBe('string'); + expect(typeof font.fontId).toBe('string'); + expect(typeof font.fontFamily).toBe('string'); + expect(typeof font.fontVariantId).toBe('string'); + expect(typeof font.fontWeight).toBe('string'); + // fontStyle is optional (string or null). + expect(font.fontStyle == null || typeof font.fontStyle === 'string').toBe( + true, + ); + + expect(font.variants.length).toBeGreaterThan(0); + const variant = font.variants[0]; + expect(typeof variant.name).toBe('string'); + expect(typeof variant.fontVariantId).toBe('string'); + expect(typeof variant.fontWeight).toBe('string'); + expect( + variant.fontStyle === 'normal' || variant.fontStyle === 'italic', + ).toBe(true); + }); + + test('findById returns the matching font', (ctx) => { + const font = ctx.penpot.fonts.all[0]; + const found = ctx.penpot.fonts.findById(font.fontId); + expect(found).not.toBeNull(); + expect(found && found.fontId).toBe(font.fontId); + }); + + test('findByName returns the matching font', (ctx) => { + const font = ctx.penpot.fonts.all[0]; + const found = ctx.penpot.fonts.findByName(font.name); + expect(found).not.toBeNull(); + expect(found && found.name).toBe(font.name); + }); + + test('findAllById and findAllByName return arrays', (ctx) => { + const font = ctx.penpot.fonts.all[0]; + expect(ctx.penpot.fonts.findAllById(font.fontId).length).toBeGreaterThan(0); + expect(ctx.penpot.fonts.findAllByName(font.name).length).toBeGreaterThan(0); + }); + + test('applyToText sets the font on a text shape', (ctx) => { + const t = text(ctx); + const font = ctx.penpot.fonts.all[0]; + font.applyToText(t); + expect(t.fontId).toBe(font.fontId); + }); + + test('applyToRange sets the font on a text range', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const font = ctx.penpot.fonts.all[0]; + const range = t.getRange(0, 5); + font.applyToRange(range); + expect(range.fontId).toBe(font.fontId); + }); + + test('generateFontFaces returns a css string', async (ctx) => { + const t = text(ctx); + const faces = await ctx.penpot.generateFontFaces([t]); + expect(typeof faces).toBe('string'); + }); + + // --------------------------------------------------------------------------- + // Edge cases. "fail" tests assert the documented null returns for + // unknown lookups; the "success" test applies a specific variant and reads it + // back. + // --------------------------------------------------------------------------- + test('findById of an unknown id returns null', (ctx) => { + const found = ctx.penpot.fonts.findById('definitely-not-a-font-id'); + expect(found).toBeNull(); + }); + + test('findByName of an unknown name returns null', (ctx) => { + const found = ctx.penpot.fonts.findByName('No Such Font Name 12345'); + expect(found).toBeNull(); + }); + + test('findAllById of an unknown id returns an empty array', (ctx) => { + expect(ctx.penpot.fonts.findAllById('definitely-not-a-font-id')).toEqual( + [], + ); + }); + + test('applying a specific variant sets the variant on the text', (ctx) => { + const t = text(ctx); + // Prefer a font that has more than one variant so the chosen variant is + // meaningful; fall back to the first font otherwise. + const font = + ctx.penpot.fonts.all.find((f) => f.variants.length > 1) ?? + ctx.penpot.fonts.all[0]; + const variant = font.variants[font.variants.length - 1]; + + font.applyToText(t, variant); + expect(t.fontId).toBe(font.fontId); + expect(t.fontVariantId).toBe(variant.fontVariantId); + expect(t.fontWeight).toBe(variant.fontWeight); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts new file mode 100644 index 0000000000..91f26d3dce --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts @@ -0,0 +1,333 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { Board, Rectangle } from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; + +// Interactions, overlays and animations. +// Interactions are added to a shape; navigate/overlay actions target boards, so +// destination boards are self-provisioned on the scratch board. + +function board(ctx: TestContext): Board { + const b = ctx.penpot.createBoard(); + ctx.board.appendChild(b); + return b; +} + +function rect(ctx: TestContext): Rectangle { + const r = ctx.penpot.createRectangle(); + ctx.board.appendChild(r); + return r; +} + +describe('Interactions', () => { + test('navigate-to interaction round-trips', (ctx) => { + const dest = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'navigate-to', + destination: dest, + }); + + expect(interaction.trigger).toBe('click'); + expect(interaction.action.type).toBe('navigate-to'); + if (interaction.action.type === 'navigate-to') { + expect(interaction.action.destination.id).toBe(dest.id); + } + expect(interaction.shape && interaction.shape.id).toBe(r.id); + expect(r.interactions.length).toBeGreaterThan(0); + }); + + test('open-url interaction round-trips', (ctx) => { + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'open-url', + url: 'https://example.com', + }); + expect(interaction.action.type).toBe('open-url'); + if (interaction.action.type === 'open-url') { + expect(interaction.action.url).toBe('https://example.com'); + } + }); + + test('open-overlay interaction round-trips', (ctx) => { + const overlay = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'open-overlay', + destination: overlay, + position: 'manual', + manualPositionLocation: { x: 10, y: 20 }, + closeWhenClickOutside: true, + addBackgroundOverlay: true, + animation: { type: 'dissolve', duration: 100, easing: 'linear' }, + }); + expect(interaction.action.type).toBe('open-overlay'); + if (interaction.action.type === 'open-overlay') { + expect(interaction.action.destination.id).toBe(overlay.id); + expect(interaction.action.position).toBe('manual'); + expect(interaction.action.closeWhenClickOutside).toBe(true); + expect(interaction.action.addBackgroundOverlay).toBe(true); + } + }); + + test('open-overlay supports a non-manual position', (ctx) => { + const overlay = board(ctx); + const r = rect(ctx); + // Per the types, manualPositionLocation is only needed for 'manual'. + const interaction = r.addInteraction('click', { + type: 'open-overlay', + destination: overlay, + position: 'center', + animation: { type: 'dissolve', duration: 100, easing: 'linear' }, + }); + expect(interaction.action.type).toBe('open-overlay'); + }); + + test('toggle-overlay interaction round-trips', (ctx) => { + const overlay = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'toggle-overlay', + destination: overlay, + position: 'manual', + manualPositionLocation: { x: 0, y: 0 }, + animation: { type: 'dissolve', duration: 100, easing: 'linear' }, + }); + expect(interaction.action.type).toBe('toggle-overlay'); + if (interaction.action.type === 'toggle-overlay') { + expect(interaction.action.destination.id).toBe(overlay.id); + } + }); + + test('close-overlay interaction round-trips', (ctx) => { + const overlay = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'close-overlay', + destination: overlay, + animation: { type: 'dissolve', duration: 200, easing: 'linear' }, + }); + expect(interaction.action.type).toBe('close-overlay'); + if (interaction.action.type === 'close-overlay') { + expect( + interaction.action.destination && interaction.action.destination.id, + ).toBe(overlay.id); + expect(interaction.action.animation).toBeDefined(); + } + }); + + test('previous-screen interaction round-trips', (ctx) => { + const r = rect(ctx); + const interaction = r.addInteraction('click', { type: 'previous-screen' }); + expect(interaction.action.type).toBe('previous-screen'); + }); + + test('after-delay trigger carries a delay', (ctx) => { + const dest = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction( + 'after-delay', + { type: 'navigate-to', destination: dest }, + 1000, + ); + expect(interaction.trigger).toBe('after-delay'); + expect(interaction.delay).toBeCloseTo(1000, 0); + }); + + test('mouse-leave trigger is recorded', (ctx) => { + // click / mouse-enter / after-delay are covered above; mouse-leave is the + // remaining trigger. + const dest = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('mouse-leave', { + type: 'navigate-to', + destination: dest, + }); + expect(interaction.trigger).toBe('mouse-leave'); + }); + + // Pins persistence of the `delay` and `action` setters on an existing + // interaction (mutating after `addInteraction`). `misc.test.ts:300` exercises + // these setters' (set) coverage targets but never asserts that the new values + // stick; this fills that behavioural gap. (An older note claimed these setters + // "don't persist" — that is stale: CI confirms they do.) + test('interaction delay and action setters persist', (ctx) => { + const dest = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction( + 'after-delay', + { type: 'navigate-to', destination: dest }, + 1000, + ); + + interaction.delay = 250; + interaction.action = { type: 'previous-screen' }; + + expect(interaction.delay).toBeCloseTo(250, 0); + expect(interaction.action.type).toBe('previous-screen'); + }); + + describe('Animations', () => { + test('dissolve animation round-trips', (ctx) => { + const dest = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'navigate-to', + destination: dest, + animation: { type: 'dissolve', duration: 300, easing: 'ease' }, + }); + if ( + interaction.action.type === 'navigate-to' && + interaction.action.animation + ) { + expect(interaction.action.animation.type).toBe('dissolve'); + if (interaction.action.animation.type === 'dissolve') { + expect(interaction.action.animation.duration).toBeCloseTo(300, 0); + expect(interaction.action.animation.easing).toBe('ease'); + } + } + }); + + test('dissolve animation accepts every easing curve', (ctx) => { + // Only `linear` and `ease` are exercised elsewhere; cover the remaining + // easing curves so a single broken curve is caught. + for (const easing of ['ease-in', 'ease-out', 'ease-in-out'] as const) { + const dest = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'navigate-to', + destination: dest, + animation: { type: 'dissolve', duration: 200, easing }, + }); + if ( + interaction.action.type === 'navigate-to' && + interaction.action.animation && + interaction.action.animation.type === 'dissolve' + ) { + expect(interaction.action.animation.easing).toBe(easing); + } + } + }); + + test('slide animation round-trips', (ctx) => { + const dest = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'navigate-to', + destination: dest, + animation: { + type: 'slide', + way: 'in', + direction: 'right', + duration: 300, + easing: 'linear', + }, + }); + if ( + interaction.action.type === 'navigate-to' && + interaction.action.animation + ) { + expect(interaction.action.animation.type).toBe('slide'); + if (interaction.action.animation.type === 'slide') { + expect(interaction.action.animation.way).toBe('in'); + expect(interaction.action.animation.direction).toBe('right'); + expect(interaction.action.animation.duration).toBeCloseTo(300, 0); + } + } + }); + + test('push animation round-trips', (ctx) => { + const dest = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'navigate-to', + destination: dest, + animation: { + type: 'push', + direction: 'left', + duration: 300, + easing: 'linear', + }, + }); + if ( + interaction.action.type === 'navigate-to' && + interaction.action.animation + ) { + expect(interaction.action.animation.type).toBe('push'); + if (interaction.action.animation.type === 'push') { + expect(interaction.action.animation.direction).toBe('left'); + expect(interaction.action.animation.duration).toBeCloseTo(300, 0); + } + } + }); + }); + + test('an interaction can be removed', (ctx) => { + const dest = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'navigate-to', + destination: dest, + }); + + const before = r.interactions.length; + interaction.remove(); + expect(r.interactions.length).toBe(before - 1); + }); + + test('interaction trigger can be changed', (ctx) => { + const dest = board(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'navigate-to', + destination: dest, + }); + + interaction.trigger = 'mouse-enter'; + expect(interaction.trigger).toBe('mouse-enter'); + }); + + // --------------------------------------------------------------------------- + // Edge cases. "fail" tests assert invalid interaction input is + // rejected; the "success" test checks several triggers coexisting. + // --------------------------------------------------------------------------- + // addInteraction validates the interaction's structure (schema) but not the + // liveness of a navigate destination nor the format of an open-url string, + // so both of these are accepted rather than rejected. These pin the current + // (lenient) behaviour. + test('navigate-to a removed board is accepted (dangling destination)', (ctx) => { + const dest = board(ctx); + const r = rect(ctx); + dest.remove(); + expect(() => + r.addInteraction('click', { type: 'navigate-to', destination: dest }), + ).not.toThrow(); + }); + + test('open-url accepts an arbitrary url string', (ctx) => { + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'open-url', + url: 'not a valid url', + }); + expect(interaction.action.type).toBe('open-url'); + if (interaction.action.type === 'open-url') { + expect(interaction.action.url).toBe('not a valid url'); + } + }); + + test('several triggers on one shape coexist', (ctx) => { + const dest = board(ctx); + const r = rect(ctx); + r.addInteraction('click', { type: 'navigate-to', destination: dest }); + r.addInteraction('mouse-enter', { + type: 'navigate-to', + destination: dest, + }); + expect(r.interactions).toHaveLength(2); + expect(r.interactions.map((i) => i.trigger).sort()).toEqual([ + 'click', + 'mouse-enter', + ]); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts new file mode 100644 index 0000000000..f4f8aacc6e --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts @@ -0,0 +1,402 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { Board } from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; + +// Layout (flex & grid). +// Layouts are created on boards via addFlexLayout/addGridLayout. Child and cell +// properties are reached through a shape that lives inside the laid-out board. +// Each group keeps its happy-path round-trips together with the related edge +// cases: "throws" tests assert invalid input is rejected (a red test surfaces a +// missing-validation bug) and the remaining ones pin non-trivial valid behaviour. +// Note: track insertion indices are 0-based (addRowAtIndex/removeRow/setRow); +// appendChild cell coordinates and layoutCell.row/column are 1-based. + +function board(ctx: TestContext): Board { + const b = ctx.penpot.createBoard(); + ctx.board.appendChild(b); + return b; +} + +describe('Layout', () => { + describe('Flex', () => { + test('addFlexLayout adds a flex layout to the board', (ctx) => { + const b = board(ctx); + const flex = b.addFlexLayout(); + expect(flex).toBeDefined(); + expect(b.flex).toBeDefined(); + }); + + test('direction and wrap round-trip', (ctx) => { + const flex = board(ctx).addFlexLayout(); + flex.dir = 'column'; + flex.wrap = 'wrap'; + expect(flex.dir).toBe('column'); + expect(flex.wrap).toBe('wrap'); + }); + + test('alignment round-trips', (ctx) => { + const flex = board(ctx).addFlexLayout(); + flex.alignItems = 'center'; + flex.alignContent = 'space-between'; + flex.justifyItems = 'center'; + flex.justifyContent = 'space-around'; + expect(flex.alignItems).toBe('center'); + expect(flex.alignContent).toBe('space-between'); + expect(flex.justifyItems).toBe('center'); + expect(flex.justifyContent).toBe('space-around'); + }); + + test('gaps and padding round-trip', (ctx) => { + const flex = board(ctx).addFlexLayout(); + flex.rowGap = 5; + flex.columnGap = 10; + flex.verticalPadding = 4; + flex.horizontalPadding = 8; + flex.topPadding = 1; + flex.rightPadding = 2; + flex.bottomPadding = 3; + flex.leftPadding = 4; + expect(flex.rowGap).toBeCloseTo(5, 0); + expect(flex.columnGap).toBeCloseTo(10, 0); + expect(flex.topPadding).toBeCloseTo(1, 0); + expect(flex.rightPadding).toBeCloseTo(2, 0); + expect(flex.bottomPadding).toBeCloseTo(3, 0); + expect(flex.leftPadding).toBeCloseTo(4, 0); + }); + + test('sizing round-trips', (ctx) => { + const flex = board(ctx).addFlexLayout(); + flex.horizontalSizing = 'fix'; + flex.verticalSizing = 'auto'; + expect(flex.horizontalSizing).toBe('fix'); + expect(flex.verticalSizing).toBe('auto'); + }); + + test('appendChild adds a child to the flex layout', (ctx) => { + const b = board(ctx); + const flex = b.addFlexLayout(); + const rect = ctx.penpot.createRectangle(); + flex.appendChild(rect); + expect(b.children.length).toBeGreaterThan(0); + }); + + test('remove deletes the flex layout', (ctx) => { + const b = board(ctx); + const flex = b.addFlexLayout(); + flex.remove(); + expect(b.flex).toBeFalsy(); + }); + }); + + describe('Grid', () => { + test('addGridLayout adds a grid layout to the board', (ctx) => { + const b = board(ctx); + const grid = b.addGridLayout(); + expect(grid).toBeDefined(); + expect(b.grid).toBeDefined(); + }); + + test('direction round-trips', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.dir = 'row'; + expect(grid.dir).toBe('row'); + }); + + test('rows and columns can be added and read as tracks', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('flex', 1); + grid.addColumn('percent', 50); + + expect(grid.rows.length).toBeGreaterThan(0); + expect(grid.columns.length).toBeGreaterThan(0); + expect(grid.rows[0].type).toBe('flex'); + expect(grid.columns[0].type).toBe('percent'); + expect(grid.columns[0].value).toBeCloseTo(50, 0); + }); + + test('addRowAtIndex inserts a row at an index', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('flex', 1); + grid.addRowAtIndex(0, 'fixed', 100); + expect(grid.rows[0].type).toBe('fixed'); + }); + + test('addColumnAtIndex inserts a column at an index', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addColumn('flex', 1); + grid.addColumnAtIndex(0, 'fixed', 100); + expect(grid.columns[0].type).toBe('fixed'); + }); + + test('setRow and setColumn update tracks', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('flex', 1); + grid.addColumn('flex', 1); + grid.setRow(0, 'fixed', 80); + grid.setColumn(0, 'percent', 25); + expect(grid.rows[0].type).toBe('fixed'); + expect(grid.rows[0].value).toBeCloseTo(80, 0); + expect(grid.columns[0].type).toBe('percent'); + }); + + test('removeRow and removeColumn drop tracks', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('flex', 1); + grid.addRow('flex', 1); + grid.addColumn('flex', 1); + grid.addColumn('flex', 1); + const rowsBefore = grid.rows.length; + const colsBefore = grid.columns.length; + grid.removeRow(0); + grid.removeColumn(0); + expect(grid.rows.length).toBe(rowsBefore - 1); + expect(grid.columns.length).toBe(colsBefore - 1); + }); + + test('appendChild places a child into a cell', (ctx) => { + const b = board(ctx); + const grid = b.addGridLayout(); + grid.addRow('flex', 1); + grid.addColumn('flex', 1); + const rect = ctx.penpot.createRectangle(); + grid.appendChild(rect, 1, 1); + expect(b.children.length).toBeGreaterThan(0); + }); + + test('alignment and gaps round-trip', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.alignItems = 'center'; + grid.justifyItems = 'start'; + grid.rowGap = 7; + grid.columnGap = 9; + expect(grid.alignItems).toBe('center'); + expect(grid.justifyItems).toBe('start'); + expect(grid.rowGap).toBeCloseTo(7, 0); + expect(grid.columnGap).toBeCloseTo(9, 0); + }); + + // Index boundaries — invalid indices must be rejected. + test('addRowAtIndex with a negative index throws', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('flex', 1); + expect(() => grid.addRowAtIndex(-1, 'fixed', 100)).toThrow(); + }); + + test('addRowAtIndex past the end throws', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('flex', 1); + expect(() => grid.addRowAtIndex(5, 'fixed', 100)).toThrow(); + }); + + test('addColumnAtIndex with a negative index throws', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addColumn('flex', 1); + expect(() => grid.addColumnAtIndex(-1, 'fixed', 100)).toThrow(); + }); + + test('removeRow on an empty grid throws', (ctx) => { + const grid = board(ctx).addGridLayout(); + expect(() => grid.removeRow(0)).toThrow(); + }); + + test('removeRow with an out-of-range index throws', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('flex', 1); + expect(() => grid.removeRow(5)).toThrow(); + }); + + test('removeColumn with an out-of-range index throws', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addColumn('flex', 1); + expect(() => grid.removeColumn(5)).toThrow(); + }); + + test('setRow with an out-of-range index throws', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('flex', 1); + expect(() => grid.setRow(5, 'fixed', 80)).toThrow(); + }); + + test('setColumn with an out-of-range index throws', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addColumn('flex', 1); + expect(() => grid.setColumn(5, 'fixed', 80)).toThrow(); + }); + + // Track type — invalid track types must be rejected. + test('addRow with an invalid track type throws', (ctx) => { + const grid = board(ctx).addGridLayout(); + expect(() => grid.addRow('not-a-type' as unknown as 'flex', 1)).toThrow(); + }); + + test('addColumn with an invalid track type throws', (ctx) => { + const grid = board(ctx).addGridLayout(); + expect(() => + grid.addColumn('not-a-type' as unknown as 'flex', 1), + ).toThrow(); + }); + + // Success edges — non-trivial valid behaviour. + test('addRowAtIndex inserts at the position and shifts the rest', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('fixed', 10); + grid.addRow('percent', 20); + grid.addRowAtIndex(1, 'flex', 1); + expect(grid.rows.length).toBe(3); + expect(grid.rows[0].type).toBe('fixed'); + expect(grid.rows[1].type).toBe('flex'); + expect(grid.rows[2].type).toBe('percent'); + }); + + test('setRow updates a track in place without changing the count', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('flex', 1); + grid.addRow('flex', 1); + grid.addRow('flex', 1); + grid.setRow(1, 'fixed', 80); + expect(grid.rows.length).toBe(3); + expect(grid.rows[0].type).toBe('flex'); + expect(grid.rows[1].type).toBe('fixed'); + expect(grid.rows[1].value).toBeCloseTo(80, 0); + expect(grid.rows[2].type).toBe('flex'); + }); + + test('mixed track types coexist and read back', (ctx) => { + const grid = board(ctx).addGridLayout(); + grid.addRow('flex', 1); + grid.addRow('fixed', 50); + grid.addRow('percent', 25); + grid.addRow('auto'); + expect(grid.rows.map((r) => r.type)).toEqual([ + 'flex', + 'fixed', + 'percent', + 'auto', + ]); + }); + + test('appendChild places children into the cells requested', (ctx) => { + const b = board(ctx); + const grid = b.addGridLayout(); + grid.addRow('flex', 1); + grid.addRow('flex', 1); + grid.addColumn('flex', 1); + grid.addColumn('flex', 1); + + const a = ctx.penpot.createRectangle(); + const c = ctx.penpot.createRectangle(); + grid.appendChild(a, 1, 1); + grid.appendChild(c, 2, 2); + + const cellA = a.layoutCell; + const cellC = c.layoutCell; + expect(cellA).toBeDefined(); + expect(cellC).toBeDefined(); + if (cellA && cellC) { + expect(cellA.row).toBeCloseTo(1, 0); + expect(cellA.column).toBeCloseTo(1, 0); + expect(cellC.row).toBeCloseTo(2, 0); + expect(cellC.column).toBeCloseTo(2, 0); + } + }); + + test('a grid board can nest a flex board as a child', (ctx) => { + const outer = board(ctx); + const grid = outer.addGridLayout(); + grid.addRow('flex', 1); + grid.addColumn('flex', 1); + + const inner = ctx.penpot.createBoard(); + const flex = inner.addFlexLayout(); + flex.dir = 'column'; + grid.appendChild(inner, 1, 1); + + expect(outer.children.length).toBeGreaterThan(0); + expect(inner.flex).toBeDefined(); + expect(inner.flex && inner.flex.dir).toBe('column'); + }); + }); + + describe('Child', () => { + test('layout child properties round-trip', (ctx) => { + const b = board(ctx); + const flex = b.addFlexLayout(); + const rect = ctx.penpot.createRectangle(); + flex.appendChild(rect); + + const child = rect.layoutChild; + expect(child).toBeDefined(); + if (child) { + child.absolute = true; + child.zIndex = 3; + child.horizontalSizing = 'fill'; + child.verticalSizing = 'fix'; + child.alignSelf = 'center'; + child.horizontalMargin = 2; + child.verticalMargin = 4; + child.topMargin = 1; + child.rightMargin = 2; + child.bottomMargin = 3; + child.leftMargin = 4; + child.maxWidth = 200; + child.maxHeight = 150; + child.minWidth = 10; + child.minHeight = 20; + + expect(child.absolute).toBe(true); + expect(child.zIndex).toBeCloseTo(3, 0); + expect(child.horizontalSizing).toBe('fill'); + expect(child.verticalSizing).toBe('fix'); + expect(child.alignSelf).toBe('center'); + expect(child.topMargin).toBeCloseTo(1, 0); + expect(child.maxWidth).toBeCloseTo(200, 0); + expect(child.minHeight).toBeCloseTo(20, 0); + } + }); + }); + + describe('Cell', () => { + test('layout cell properties round-trip', (ctx) => { + const b = board(ctx); + const grid = b.addGridLayout(); + grid.addRow('flex', 1); + grid.addRow('flex', 1); + grid.addColumn('flex', 1); + grid.addColumn('flex', 1); + const rect = ctx.penpot.createRectangle(); + grid.appendChild(rect, 1, 1); + + const cell = rect.layoutCell; + expect(cell).toBeDefined(); + if (cell) { + cell.row = 1; + cell.column = 1; + cell.rowSpan = 1; + cell.columnSpan = 2; + expect(cell.row).toBeCloseTo(1, 0); + expect(cell.column).toBeCloseTo(1, 0); + expect(cell.columnSpan).toBeCloseTo(2, 0); + } + }); + }); + + describe('Switching type', () => { + // addFlexLayout/addGridLayout do not reject a board that already has a + // layout; they create the requested layout (switching the board's type). + // These pin that behaviour. + test('adding a grid layout to a board that already has a flex layout switches to grid', (ctx) => { + const b = board(ctx); + b.addFlexLayout(); + expect(() => b.addGridLayout()).not.toThrow(); + expect(b.grid).toBeDefined(); + }); + + test('adding a flex layout to a board that already has a grid layout switches to flex', (ctx) => { + const b = board(ctx); + b.addGridLayout(); + expect(() => b.addFlexLayout()).not.toThrow(); + expect(b.flex).toBeDefined(); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts new file mode 100644 index 0000000000..f28e3263b2 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts @@ -0,0 +1,223 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { Text } from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; +import { PNG_1X1 } from './fixtures'; + +// Library colors, typographies and components. +// Assets are created in the local library (self-provisioned). Reached through +// `ctx.penpot.library.local` so the Library chain is recorded for coverage. + +function text(ctx: TestContext, value = 'Hello Penpot'): Text { + const t = ctx.penpot.createText(value); + if (!t) throw new Error('createText returned null'); + ctx.board.appendChild(t); + return t; +} + +describe('Library', () => { + test('local library exposes id and name', (ctx) => { + const lib = ctx.penpot.library.local; + expect(typeof lib.id).toBe('string'); + expect(typeof lib.name).toBe('string'); + }); + + test('local library lists its assets', (ctx) => { + const lib = ctx.penpot.library.local; + expect(Array.isArray(lib.colors)).toBe(true); + expect(Array.isArray(lib.typographies)).toBe(true); + expect(Array.isArray(lib.components)).toBe(true); + expect(lib.tokens).toBeDefined(); + }); + + test('library context exposes connected libraries', (ctx) => { + expect(Array.isArray(ctx.penpot.library.connected)).toBe(true); + }); + + test('library elements expose a libraryId', (ctx) => { + const color = ctx.penpot.library.local.createColor(); + expect(typeof color.libraryId).toBe('string'); + }); + + // Skipped under MOCK_BACKEND: availableLibraries() returns backend-shaped + // shared-library summaries; under a mock it would resolve vacuously. + test.skipIfMocked('availableLibraries resolves to summaries', async (ctx) => { + // The shared-libraries RPC can error in the headless team context; treat a + // rejection as an environment limitation. + const summaries = await ctx.penpot.library + .availableLibraries() + .catch(() => []); + expect(Array.isArray(summaries)).toBe(true); + if (summaries.length > 0) { + const summary = summaries[0]; + expect(typeof summary.id).toBe('string'); + expect(typeof summary.name).toBe('string'); + expect(typeof summary.numColors).toBe('number'); + expect(typeof summary.numComponents).toBe('number'); + expect(typeof summary.numTypographies).toBe('number'); + } + }); + + // NOTE: connectLibrary with an unknown id is intentionally NOT exercised here. + // Calling it with a non-existent library id crashes the plugin workspace (the + // returned promise never settles and the sandbox freezes), which would hang + // the whole CI run. This is a genuine API bug to fix at the source; until then + // the suite must not trigger it. + + describe('Colors', () => { + test('createColor adds a color asset', (ctx) => { + const color = ctx.penpot.library.local.createColor(); + color.name = 'plugin-color'; + // Use a single-segment path: Penpot normalizes `a/b` to `a / b`. + color.path = 'plugingroup'; + color.color = '#ff8800'; + color.opacity = 0.8; + + expect(typeof color.id).toBe('string'); + expect(color.name).toBe('plugin-color'); + expect(color.path).toBe('plugingroup'); + expect(color.color).toBe('#ff8800'); + expect(color.opacity).toBeCloseTo(0.8, 2); + }); + + test('library color converts to fill and stroke', (ctx) => { + const color = ctx.penpot.library.local.createColor(); + color.color = '#123456'; + color.opacity = 1; + + const fill = color.asFill(); + expect(fill.fillColor).toBe('#123456'); + const stroke = color.asStroke(); + expect(stroke.strokeColor).toBe('#123456'); + }); + + test('library color gradient round-trips', (ctx) => { + const color = ctx.penpot.library.local.createColor(); + color.gradient = { + type: 'linear', + startX: 0, + startY: 0, + endX: 1, + endY: 1, + width: 1, + stops: [ + { color: '#ff0000', opacity: 1, offset: 0 }, + { color: '#0000ff', opacity: 1, offset: 1 }, + ], + }; + + const g = color.gradient; + expect(g).toBeDefined(); + if (g) { + expect(g.type).toBe('linear'); + expect(g.stops).toHaveLength(2); + } + }); + + // Skipped under MOCK_BACKEND: uploadMediaData needs real backend media + // processing (ImageMagick); a mock can't return usable image data. + test.skipIfMocked('library color image round-trips', async (ctx) => { + const image = await ctx.penpot.uploadMediaData( + 'lib-color-image', + PNG_1X1, + 'image/png', + ); + const color = ctx.penpot.library.local.createColor(); + color.image = image; + expect(color.image).toBeDefined(); + }); + }); + + describe('Typographies', () => { + test('createTypography adds a typography asset', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + typo.name = 'plugin-typo'; + typo.path = 'text'; + typo.fontSize = '18'; + typo.lineHeight = '1.4'; + typo.letterSpacing = '0.5'; + + expect(typeof typo.id).toBe('string'); + expect(typo.name).toBe('plugin-typo'); + expect(typo.fontSize).toBe('18'); + expect(typeof typo.fontId).toBe('string'); + }); + + test('typography fontFamily and fontId round-trip', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + expect(typeof typo.fontFamily).toBe('string'); + + typo.fontFamily = 'Arial'; + typo.fontId = 'gfont-arial'; + expect(typo.fontFamily).toBe('Arial'); + expect(typo.fontId).toBe('gfont-arial'); + }); + + test('typography style members round-trip', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + typo.fontStyle = 'italic'; + typo.textTransform = 'uppercase'; + typo.fontWeight = '700'; + typo.fontVariantId = 'regular'; + typo.lineHeight = '1.5'; + typo.letterSpacing = '1'; + expect(typo.fontStyle).toBe('italic'); + expect(typo.textTransform).toBe('uppercase'); + expect(typo.fontWeight).toBe('700'); + expect(typo.fontVariantId).toBe('regular'); + expect(typeof typo.lineHeight).toBe('string'); + expect(typeof typo.letterSpacing).toBe('string'); + }); + + test('typography setFont updates the font', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + const font = ctx.penpot.fonts.all[0]; + typo.setFont(font); + expect(typo.fontId).toBe(font.fontId); + }); + + test('typography applies to a text shape', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + typo.fontSize = '22'; + const t = text(ctx); + typo.applyToText(t); + expect(t.fontSize).toBe('22'); + }); + + test('typography applies to a text range', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + typo.fontSize = '28'; + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + typo.applyToTextRange(range); + expect(range.fontSize).toBe('28'); + }); + }); + + describe('Components', () => { + test('createComponent creates a component asset', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const comp = ctx.penpot.library.local.createComponent([rect]); + + expect(typeof comp.id).toBe('string'); + comp.name = 'plugin-component'; + expect(comp.name).toBe('plugin-component'); + expect(comp.isVariant()).toBe(false); + }); + + test('component instance and mainInstance return shapes', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const comp = ctx.penpot.library.local.createComponent([rect]); + + const main = comp.mainInstance(); + expect(main).toBeDefined(); + expect(typeof main.id).toBe('string'); + + const instance = comp.instance(); + expect(instance).toBeDefined(); + expect(typeof instance.id).toBe('string'); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/media.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/media.test.ts new file mode 100644 index 0000000000..c05ba336e2 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/media.test.ts @@ -0,0 +1,145 @@ +import { expect, expectReject } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import { PNG_1X1 } from './fixtures'; + +// Media uploads and exports. + +// Skipped under MOCK_BACKEND: media upload exercises real ImageMagick on the +// backend (image validation / canned upload data) that a 200 mock can't +// reproduce — the rejection tests would fail and the success tests go vacuous. +describe.skipIfMocked('Media', () => { + test('uploadMediaData uploads bytes and returns image data', async (ctx) => { + const image = await ctx.penpot.uploadMediaData( + 'plugin-image', + PNG_1X1, + 'image/png', + ); + expect(typeof image.id).toBe('string'); + expect(image.width).toBe(1); + expect(image.height).toBe(1); + expect(image.mtype).toBe('image/png'); + expect(typeof image.name).toBe('string'); + // keepAspectRatio is optional and may be null when not set. + expect( + image.keepAspectRatio == null || + typeof image.keepAspectRatio === 'boolean', + ).toBe(true); + + const bytes = await image.data(); + expect(bytes.length).toBeGreaterThan(0); + }); + + test('an uploaded image can be used as a fill', async (ctx) => { + const image = await ctx.penpot.uploadMediaData( + 'plugin-fill', + PNG_1X1, + 'image/png', + ); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.fills = [{ fillOpacity: 1, fillImage: image }]; + + const fills = rect.fills; + if (Array.isArray(fills)) { + expect(fills[0].fillImage).toBeDefined(); + } + }); + + test('Fill.fillImage can be set on a fill', async (ctx) => { + const image = await ctx.penpot.uploadMediaData( + 'plugin-fill-set', + PNG_1X1, + 'image/png', + ); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + + // Set fillImage directly on the fill (covers Fill.fillImage (set)). + const fill = rect.fills[0]; + fill.fillImage = image; + expect(fill.fillImage).toBeDefined(); + }); + + test('uploadMediaUrl resolves to image data', async (ctx) => { + // Needs the backend to fetch an external URL, which may be unavailable in + // the headless runner; treat a rejection as an environment limitation. + const image = await ctx.penpot + .uploadMediaUrl( + 'plugin-url-image', + 'https://design.penpot.app/images/favicon.png', + ) + .catch(() => null); + if (image) { + expect(typeof image.id).toBe('string'); + } + }); + + // --------------------------------------------------------------------------- + // Edge cases. Invalid upload input must not resolve. (These hold + // even when the backend is unreachable in the headless runner, since a + // rejection is the asserted outcome.) + // --------------------------------------------------------------------------- + test('uploadMediaData with empty bytes rejects', async (ctx) => { + await expectReject(() => + ctx.penpot.uploadMediaData('empty', new Uint8Array([]), 'image/png'), + ); + }); + + test('uploadMediaData with non-image bytes rejects', async (ctx) => { + const garbage = new Uint8Array([1, 2, 3, 4, 5]); + await expectReject(() => + ctx.penpot.uploadMediaData('garbage', garbage, 'image/png'), + ); + }); + + test('uploadMediaUrl with an invalid URL rejects', async (ctx) => { + await expectReject(() => + ctx.penpot.uploadMediaUrl('bad-url', 'not://a.valid/url'), + ); + }); +}); + +describe('Exports', () => { + test('export settings round-trip on a shape', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.exports = [ + { type: 'png', scale: 2, suffix: '@2x', skipChildren: false }, + ]; + + expect(rect.exports).toHaveLength(1); + const exp = rect.exports[0]; + expect(exp.type).toBe('png'); + expect(exp.scale).toBeCloseTo(2, 0); + expect(exp.suffix).toBe('@2x'); + // skipChildren is optional; a stored `false` reads back as undefined. + expect(exp.skipChildren).toBeFalsy(); + }); + + test('export settings accept jpeg, webp, svg and pdf types', (ctx) => { + // Only png is exercised above; pin that the other export formats round-trip + // as settings (the actual render is covered separately and may be headless- + // limited). + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + for (const type of ['jpeg', 'webp', 'svg', 'pdf'] as const) { + rect.exports = [{ type, scale: 1 }]; + expect(rect.exports).toHaveLength(1); + expect(rect.exports[0].type).toBe(type); + } + }); + + test('shape export renders to bytes', async (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.resize(20, 20); + // Rendering may not be available in the headless runner; tolerate failure. + const bytes = await rect + .export({ type: 'png', scale: 1 }) + .catch(() => null); + if (bytes) { + expect(bytes.length).toBeGreaterThan(0); + } + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/misc.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/misc.test.ts new file mode 100644 index 0000000000..cabb0869b4 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/misc.test.ts @@ -0,0 +1,389 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { Board } from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; + +// Misc — remaining coverable members across many interfaces. + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function rect(ctx: TestContext) { + const r = ctx.penpot.createRectangle(); + ctx.board.appendChild(r); + return r; +} + +// Note: penpot.utils.types / geometry are frozen (SES) data properties, so the +// recorder cannot wrap them and their members aren't recorded (see README.md +// coverage notes). The predicates are still exercised behaviourally in +// platform.test.ts. + +describe('Misc', () => { + describe('Context root', () => { + test('root is a shape', (ctx) => { + expect(ctx.penpot.root).toBeDefined(); + }); + }); + + describe('Concrete shape fills', () => { + test('fills round-trip on ellipse, path and board', (ctx) => { + const ellipse = ctx.penpot.createEllipse(); + const pathShape = ctx.penpot.createPath(); + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(ellipse); + ctx.board.appendChild(pathShape); + ctx.board.appendChild(board); + + ellipse.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + pathShape.fills = [{ fillColor: '#00ff00', fillOpacity: 1 }]; + board.fills = [{ fillColor: '#0000ff', fillOpacity: 1 }]; + + expect(ellipse.fills).toHaveLength(1); + expect(pathShape.fills).toHaveLength(1); + expect(board.fills).toHaveLength(1); + }); + }); + + describe('Boolean members', () => { + test('boolean content, path data and children round-trip', (ctx) => { + const a = rect(ctx); + const b = rect(ctx); + b.x = 40; + const bool = ctx.penpot.createBoolean('union', [a, b]); + expect(bool).not.toBeNull(); + if (bool) { + ctx.board.appendChild(bool); + // Boolean fills round-trip; d/content/commands are derived from the + // operands and not independently settable (see coverage notes). + bool.fills = [{ fillColor: '#abcdef', fillOpacity: 1 }]; + void bool.content; + expect(bool.fills).toHaveLength(1); + } + }); + + test('appendChild and insertChild add operands to a boolean', (ctx) => { + const a = rect(ctx); + const b = rect(ctx); + b.x = 40; + const bool = ctx.penpot.createBoolean('union', [a, b]); + expect(bool).not.toBeNull(); + if (bool) { + ctx.board.appendChild(bool); + const before = bool.children.length; + bool.appendChild(rect(ctx)); + bool.insertChild(0, rect(ctx)); + expect(bool.children.length).toBe(before + 2); + } + }); + }); + + describe('Export settings setters', () => { + test('export members round-trip on the returned export', (ctx) => { + const r = rect(ctx); + r.exports = [{ type: 'png', scale: 1, suffix: '', skipChildren: false }]; + const exp = r.exports[0]; + exp.type = 'jpeg'; + exp.scale = 2; + exp.suffix = '@2x'; + exp.skipChildren = true; + expect(exp.type).toBe('jpeg'); + expect(exp.scale).toBeCloseTo(2, 0); + }); + }); + + describe('Gradient and shadow leftovers', () => { + test('gradient endpoints and stops round-trip', (ctx) => { + const r = rect(ctx); + r.fills = [ + { + fillColorGradient: { + type: 'linear', + startX: 0, + startY: 0, + endX: 1, + endY: 1, + width: 1, + stops: [ + { color: '#ff0000', opacity: 1, offset: 0 }, + { color: '#0000ff', opacity: 1, offset: 1 }, + ], + }, + }, + ]; + const fills = r.fills; + if (Array.isArray(fills)) { + const g = fills[0].fillColorGradient; + if (g) { + void g.endX; + void g.startY; + g.stops = [ + { color: '#00ff00', opacity: 1, offset: 0 }, + { color: '#000000', opacity: 1, offset: 1 }, + ]; + expect(g.stops.length).toBeGreaterThan(0); + } + } + }); + + test('shadow color and id round-trip', (ctx) => { + const r = rect(ctx); + r.shadows = [ + { + style: 'drop-shadow', + offsetX: 1, + offsetY: 1, + blur: 2, + spread: 0, + hidden: false, + color: { color: '#000000', opacity: 1 }, + }, + ]; + const shadow = r.shadows[0]; + void shadow.id; + shadow.color = { color: '#ff00ff', opacity: 0.5 }; + const color = shadow.color; + if (color) { + void color.id; + void color.fileId; + void color.refId; + void color.refFile; + color.gradient = { + type: 'linear', + startX: 0, + startY: 0, + endX: 1, + endY: 1, + width: 1, + stops: [{ color: '#ff0000', opacity: 1, offset: 0 }], + }; + void color.gradient; + } + expect(r.shadows).toHaveLength(1); + }); + }); + + describe('Bounds and Point', () => { + test('viewport bounds members are readable', (ctx) => { + // The bounds object is frozen, so only the getters are exercised. + const b = ctx.penpot.viewport.bounds; + expect(typeof b.x).toBe('number'); + expect(typeof b.y).toBe('number'); + expect(typeof b.width).toBe('number'); + expect(typeof b.height).toBe('number'); + }); + + test('viewport center point members are readable', (ctx) => { + const c = ctx.penpot.viewport.center; + expect(typeof c.x).toBe('number'); + expect(typeof c.y).toBe('number'); + }); + }); + + describe('Layout leftovers', () => { + test('flex padding and child margins are readable', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + const flex = board.addFlexLayout(); + flex.horizontalPadding = 4; + flex.verticalPadding = 6; + void flex.horizontalPadding; + void flex.verticalPadding; + + const child = ctx.penpot.createRectangle(); + flex.appendChild(child); + const lc = child.layoutChild; + if (lc) { + lc.horizontalMargin = 1; + lc.verticalMargin = 2; + lc.topMargin = 3; + lc.rightMargin = 4; + lc.bottomMargin = 5; + lc.leftMargin = 6; + lc.maxHeight = 100; + lc.minWidth = 10; + void lc.horizontalMargin; + void lc.verticalMargin; + void lc.leftMargin; + void lc.rightMargin; + void lc.bottomMargin; + void lc.maxHeight; + void lc.minWidth; + } + expect(board.type).toBe('board'); + }); + + test('grid cell properties round-trip', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + const grid = board.addGridLayout(); + grid.addRow('flex', 1); + grid.addColumn('flex', 1); + const child = ctx.penpot.createRectangle(); + grid.appendChild(child, 1, 1); + const cell = child.layoutCell; + if (cell) { + cell.areaName = 'header'; + cell.position = 'auto'; + void cell.areaName; + void cell.position; + void cell.rowSpan; + } + expect(board.type).toBe('board'); + }); + }); + + describe('Track', () => { + test('grid track members round-trip on the returned track', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + const grid = board.addGridLayout(); + grid.addRow('flex', 1); + const track = grid.rows[0]; + track.type = 'fixed'; + track.value = 80; + expect(track.type).toBe('fixed'); + expect(track.value).toBeCloseTo(80, 0); + }); + }); + + describe('Path commands', () => { + test('path command members round-trip', (ctx) => { + const path = ctx.penpot.createPath(); + ctx.board.appendChild(path); + path.d = 'M0 0 L10 10'; + const commands = path.commands; + expect(commands.length).toBeGreaterThan(0); + const cmd = commands[0]; + void cmd.command; + void cmd.params; + cmd.command = 'line-to'; + cmd.params = { x: 5, y: 5 }; + expect(cmd.command).toBe('line-to'); + // Reassign the whole command list (Path.commands set). + path.commands = commands; + }); + }); + + describe('Shape ordering and blur', () => { + test('sendBackward and backgroundBlur are exercised', (ctx) => { + const a = rect(ctx); + const b = rect(ctx); + void b; + a.sendBackward(); + void a.backgroundBlur; + expect(a.type).toBe('rectangle'); + }); + }); + + describe('Interaction reads', () => { + test('overlay action fields are readable', (ctx) => { + const overlay = ctx.penpot.createBoard(); + ctx.board.appendChild(overlay as Board); + const relative = rect(ctx); + const r = rect(ctx); + const interaction = r.addInteraction('click', { + type: 'open-overlay', + destination: overlay, + relativeTo: relative, + position: 'manual', + manualPositionLocation: { x: 5, y: 5 }, + animation: { type: 'dissolve', duration: 100, easing: 'linear' }, + }); + if (interaction.action.type === 'open-overlay') { + void interaction.action.relativeTo; + void interaction.action.manualPositionLocation; + void interaction.action.animation; + } + // Interaction.action and delay setters (records the (set) targets; + // persistence is asserted in interactions.test.ts). + interaction.delay = 250; + interaction.action = { type: 'previous-screen' }; + expect(interaction.shape && interaction.shape.id).toBe(r.id); + }); + + test('navigate-to preserveScrollPosition and slide/push animation fields', (ctx) => { + const dest = ctx.penpot.createBoard(); + ctx.board.appendChild(dest as Board); + const r = rect(ctx); + const nav = r.addInteraction('click', { + type: 'navigate-to', + destination: dest, + preserveScrollPosition: true, + animation: { + type: 'slide', + way: 'in', + direction: 'right', + duration: 300, + offsetEffect: true, + easing: 'ease', + }, + }); + if (nav.action.type === 'navigate-to') { + void nav.action.preserveScrollPosition; + const anim = nav.action.animation; + if (anim && anim.type === 'slide') { + void anim.offsetEffect; + void anim.easing; + } + } + + const r2 = rect(ctx); + const push = r2.addInteraction('click', { + type: 'navigate-to', + destination: dest, + animation: { + type: 'push', + direction: 'left', + duration: 300, + easing: 'ease', + }, + }); + if (push.action.type === 'navigate-to') { + const anim = push.action.animation; + if (anim && anim.type === 'push') { + void anim.easing; + } + } + expect(r.type).toBe('rectangle'); + }); + }); + + describe('Variant container variants', () => { + test('Variants interface members via a variant container', async (ctx) => { + function main(): Board { + const r = ctx.penpot.createRectangle(); + ctx.board.appendChild(r); + return ctx.penpot.library.local + .createComponent([r]) + .mainInstance() as Board; + } + const container = ctx.penpot.createVariantFromComponents([ + main(), + main(), + ]); + await sleep(300); + const v = container.variants; + expect(v).not.toBeNull(); + if (v) { + expect(typeof v.id).toBe('string'); + expect(typeof v.libraryId).toBe('string'); + expect(Array.isArray(v.properties)).toBe(true); + expect(Array.isArray(v.variantComponents())).toBe(true); + if (v.properties.length > 0) { + void v.currentValues(v.properties[0]); + } + v.addProperty(); + await sleep(300); + v.addVariant(); + await sleep(300); + if (v.properties.length > 0) { + v.renameProperty(0, 'Size'); + await sleep(200); + v.removeProperty(v.properties.length - 1); + } + } + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts new file mode 100644 index 0000000000..1cd3463d7f --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts @@ -0,0 +1,162 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; + +// Pages, selection and flows. +// Most assertions use the active page (`currentPage`) and the scratch board so +// the user's file is left clean. createPage/openPage necessarily leave a page +// behind (the API has no removePage), so the active page is restored afterwards. + +describe('Pages', () => { + test('currentPage exposes id and name', (ctx) => { + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + expect(typeof page.id).toBe('string'); + expect(typeof page.name).toBe('string'); + } + }); + + test('createPage and openPage activate a new page', async (ctx) => { + const original = ctx.penpot.currentPage; + const page = ctx.penpot.createPage(); + page.name = 'plugin-test-page'; + expect(page.name).toBe('plugin-test-page'); + + await ctx.penpot.openPage(page); + const active = ctx.penpot.currentPage; + expect(active && active.id).toBe(page.id); + + // Restore the originally active page so other tests aren't affected. + if (original) await ctx.penpot.openPage(original); + }); + + test('getShapeById finds a shape on the page', (ctx) => { + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + const found = page.getShapeById(ctx.board.id); + expect(found).not.toBeNull(); + expect(found && found.id).toBe(ctx.board.id); + } + }); + + test('findShapes returns shapes on the page', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const page = ctx.penpot.currentPage; + if (page) { + expect(page.findShapes().length).toBeGreaterThan(0); + } + }); + + test('page root is a shape', (ctx) => { + const page = ctx.penpot.currentPage; + if (page) { + expect(page.root).toBeDefined(); + expect(typeof page.root.type).toBe('string'); + } + }); + + // Edge cases. + test('getShapeById of an unknown id returns null', (ctx) => { + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + const found = page.getShapeById('00000000-0000-0000-0000-0000000000ff'); + expect(found).toBeNull(); + } + }); + + test('getShapeById finds a just-created shape', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const page = ctx.penpot.currentPage; + if (page) { + const found = page.getShapeById(rect.id); + expect(found).not.toBeNull(); + expect(found && found.id).toBe(rect.id); + } + }); +}); + +describe('Selection', () => { + test('selection can be set and read', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + + ctx.penpot.selection = [rect]; + expect(ctx.penpot.selection).toHaveLength(1); + expect(ctx.penpot.selection[0].id).toBe(rect.id); + }); + + // Edge cases. + test('assigning an empty selection clears it', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + ctx.penpot.selection = [rect]; + ctx.penpot.selection = []; + expect(ctx.penpot.selection).toHaveLength(0); + }); + + test('selecting the same shape twice keeps a single entry', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + ctx.penpot.selection = [rect, rect]; + expect(ctx.penpot.selection).toHaveLength(1); + }); +}); + +describe('Flows', () => { + test('createFlow defines a flow on a board', (ctx) => { + const targetBoard = ctx.penpot.createBoard(); + ctx.board.appendChild(targetBoard); + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + const flow = page.createFlow('plugin-flow', targetBoard); + expect(flow.name).toBe('plugin-flow'); + expect(flow.startingBoard.id).toBe(targetBoard.id); + expect(flow.page.id).toBe(page.id); + expect(page.flows.length).toBeGreaterThan(0); + } + }); + + test('flow name and starting board round-trip', (ctx) => { + const first = ctx.penpot.createBoard(); + const second = ctx.penpot.createBoard(); + ctx.board.appendChild(first); + ctx.board.appendChild(second); + const page = ctx.penpot.currentPage; + if (page) { + const flow = page.createFlow('flow-a', first); + flow.name = 'flow-b'; + flow.startingBoard = second; + expect(flow.name).toBe('flow-b'); + expect(flow.startingBoard.id).toBe(second.id); + } + }); + + test('flow can be removed', (ctx) => { + const targetBoard = ctx.penpot.createBoard(); + ctx.board.appendChild(targetBoard); + const page = ctx.penpot.currentPage; + if (page) { + const flow = page.createFlow('to-remove', targetBoard); + const before = page.flows.length; + flow.remove(); + expect(page.flows.length).toBe(before - 1); + } + }); + + test('page.removeFlow removes a flow', (ctx) => { + const targetBoard = ctx.penpot.createBoard(); + ctx.board.appendChild(targetBoard); + const page = ctx.penpot.currentPage; + if (page) { + const flow = page.createFlow('to-remove-2', targetBoard); + const before = page.flows.length; + page.removeFlow(flow); + expect(page.flows.length).toBe(before - 1); + } + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/platform.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/platform.test.ts new file mode 100644 index 0000000000..ba318d0599 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/platform.test.ts @@ -0,0 +1,164 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; + +// Platform: user/session, context info, history, utils and markup. + +describe('Platform', () => { + describe('User', () => { + test('currentUser exposes profile fields', (ctx) => { + const user = ctx.penpot.currentUser; + expect(typeof user.id).toBe('string'); + expect(typeof user.name).toBe('string'); + expect(typeof user.sessionId).toBe('string'); + // avatarUrl and color may be undefined depending on the profile. + void user.avatarUrl; + void user.color; + }); + + test('activeUsers is an array', (ctx) => { + const users = ctx.penpot.activeUsers; + expect(Array.isArray(users)).toBe(true); + if (users.length > 0) { + expect(typeof users[0].id).toBe('string'); + void users[0].position; + void users[0].zoom; + } + }); + }); + + describe('Context info', () => { + test('version is a string', (ctx) => { + expect(typeof ctx.penpot.version).toBe('string'); + }); + + test('theme is light or dark', (ctx) => { + expect(['light', 'dark']).toContain(ctx.penpot.theme); + }); + + test('flags are readable', (ctx) => { + expect(typeof ctx.penpot.flags.naturalChildOrdering).toBe('boolean'); + expect(typeof ctx.penpot.flags.throwValidationErrors).toBe('boolean'); + }); + }); + + describe('History', () => { + test('undo block begin and finish wrap operations', (ctx) => { + const block = ctx.penpot.history.undoBlockBegin(); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.name = 'in-undo-block'; + ctx.penpot.history.undoBlockFinish(block); + expect(rect.name).toBe('in-undo-block'); + }); + + // Edge cases. + test('finishing an unknown undo block is a no-op (not rejected)', (ctx) => { + // undoBlockFinish does not validate the block id; an unknown id is ignored + // rather than rejected. + expect(() => + ctx.penpot.history.undoBlockFinish(Symbol('not-a-real-block')), + ).not.toThrow(); + }); + + test('nested undo blocks begin and finish in order', (ctx) => { + const outer = ctx.penpot.history.undoBlockBegin(); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const inner = ctx.penpot.history.undoBlockBegin(); + rect.name = 'nested'; + ctx.penpot.history.undoBlockFinish(inner); + ctx.penpot.history.undoBlockFinish(outer); + expect(rect.name).toBe('nested'); + }); + }); + + describe('Utils', () => { + test('geometry center returns a point', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.x = 0; + rect.y = 0; + rect.resize(100, 100); + const center = ctx.penpot.utils.geometry.center([rect]); + expect(center).not.toBeNull(); + if (center) { + expect(center.x).toBeCloseTo(50, 0); + expect(center.y).toBeCloseTo(50, 0); + } + }); + + // Edge cases. + test('center of an empty array returns null', (ctx) => { + expect(ctx.penpot.utils.geometry.center([])).toBeNull(); + }); + + test('center of two shapes sits at their midpoint', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + ctx.board.appendChild(a); + ctx.board.appendChild(b); + a.x = 0; + a.y = 0; + a.resize(100, 100); + b.x = 200; + b.y = 100; + b.resize(100, 100); + const center = ctx.penpot.utils.geometry.center([a, b]); + expect(center).not.toBeNull(); + if (center) { + // a spans 0..100, b spans 200..300 → combined bounds 0..300 → centre 150. + expect(center.x).toBeCloseTo(150, 0); + expect(center.y).toBeCloseTo(100, 0); + } + }); + + test('types predicates identify shapes', (ctx) => { + const types = ctx.penpot.utils.types; + const rect = ctx.penpot.createRectangle(); + const ellipse = ctx.penpot.createEllipse(); + const text = ctx.penpot.createText('hi'); + const path = ctx.penpot.createPath(); + ctx.board.appendChild(rect); + ctx.board.appendChild(ellipse); + ctx.board.appendChild(path); + if (text) ctx.board.appendChild(text); + + expect(types.isRectangle(rect)).toBe(true); + expect(types.isEllipse(ellipse)).toBe(true); + expect(types.isPath(path)).toBe(true); + expect(types.isBoard(ctx.board)).toBe(true); + if (text) { + expect(types.isText(text)).toBe(true); + } + // Non-matching predicates should be falsy. + expect(types.isGroup(rect)).toBeFalsy(); + expect(types.isBool(rect)).toBeFalsy(); + expect(types.isMask(rect)).toBeFalsy(); + expect(types.isSVG(rect)).toBeFalsy(); + expect(types.isVariantContainer(rect)).toBeFalsy(); + }); + }); + + describe('Markup', () => { + test('generateMarkup returns html', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const markup = ctx.penpot.generateMarkup([rect]); + expect(typeof markup).toBe('string'); + }); + + test('generateMarkup can target svg', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const svg = ctx.penpot.generateMarkup([rect], { type: 'svg' }); + expect(typeof svg).toBe('string'); + }); + + test('generateStyle returns css', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const styles = ctx.penpot.generateStyle([rect]); + expect(typeof styles).toBe('string'); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts new file mode 100644 index 0000000000..e5c02be79b --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts @@ -0,0 +1,108 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; + +// Plugin data and local storage. + +describe('Plugin data', () => { + test('plugin data round-trips on a shape', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setPluginData('exampleKey', 'exampleValue'); + expect(rect.getPluginData('exampleKey')).toBe('exampleValue'); + expect(rect.getPluginDataKeys()).toContain('exampleKey'); + }); + + test('shared plugin data round-trips on a shape', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setSharedPluginData('ns', 'sharedKey', 'sharedValue'); + expect(rect.getSharedPluginData('ns', 'sharedKey')).toBe('sharedValue'); + expect(rect.getSharedPluginDataKeys('ns')).toContain('sharedKey'); + }); + + test('plugin data round-trips on the file', (ctx) => { + const file = ctx.penpot.currentFile; + expect(file).not.toBeNull(); + if (file) { + file.setPluginData('fileKey', 'fileValue'); + expect(file.getPluginData('fileKey')).toBe('fileValue'); + } + }); + + // --------------------------------------------------------------------------- + // Edge cases. "fail" tests assert invalid keys/values are + // rejected; "success" tests cover multi-key listing, overwrite, large values, + // missing keys and local/shared isolation. + // --------------------------------------------------------------------------- + test('setPluginData with an empty key is accepted (currently unvalidated)', (ctx) => { + // An empty key is not rejected; this pins the current lenient behaviour + // (a candidate for future hardening). + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + expect(() => rect.setPluginData('', 'value')).not.toThrow(); + }); + + test('setPluginData with a non-string value throws', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + expect(() => rect.setPluginData('key', 123 as unknown as string)).toThrow(); + }); + + test('multiple keys round-trip and are all listed', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setPluginData('a', '1'); + rect.setPluginData('b', '2'); + rect.setPluginData('c', '3'); + expect(rect.getPluginData('a')).toBe('1'); + expect(rect.getPluginData('b')).toBe('2'); + expect(rect.getPluginData('c')).toBe('3'); + const keys = rect.getPluginDataKeys(); + expect(keys).toContain('a'); + expect(keys).toContain('b'); + expect(keys).toContain('c'); + }); + + test('overwriting a key replaces its value', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setPluginData('k', 'first'); + rect.setPluginData('k', 'second'); + expect(rect.getPluginData('k')).toBe('second'); + }); + + test('a large value round-trips', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const big = 'x'.repeat(10000); + rect.setPluginData('big', big); + expect(rect.getPluginData('big')).toBe(big); + }); + + test('reading a missing key is falsy', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + expect(rect.getPluginData('never-set')).toBeFalsy(); + }); + + test('local and shared plugin data are isolated', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setPluginData('k', 'local'); + rect.setSharedPluginData('ns', 'k', 'shared'); + expect(rect.getPluginData('k')).toBe('local'); + expect(rect.getSharedPluginData('ns', 'k')).toBe('shared'); + }); +}); + +describe('Local storage', () => { + test('set, get, keys and remove an item', (ctx) => { + const ls = ctx.penpot.localStorage; + ls.setItem('plugin-key', 'plugin-value'); + expect(ls.getItem('plugin-key')).toBe('plugin-value'); + expect(ls.getKeys()).toContain('plugin-key'); + + ls.removeItem('plugin-key'); + expect(ls.getItem('plugin-key')).toBeFalsy(); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts new file mode 100644 index 0000000000..7ede39799a --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts @@ -0,0 +1,81 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { TestContext } from '../framework/types'; + +// Shadows & blur. +// Like fills/strokes, shadows are assigned as a whole array and read back; the +// nested Shadow.color yields a Color whose members are then exercised on read. + +function rect(ctx: TestContext) { + const r = ctx.penpot.createRectangle(); + ctx.board.appendChild(r); + return r; +} + +describe('Shadows', () => { + test('drop shadow round-trips with a color', (ctx) => { + const r = rect(ctx); + r.shadows = [ + { + style: 'drop-shadow', + offsetX: 4, + offsetY: 6, + blur: 8, + spread: 1, + hidden: false, + color: { color: '#000000', opacity: 0.5 }, + }, + ]; + + expect(r.shadows).toHaveLength(1); + const shadow = r.shadows[0]; + expect(shadow.style).toBe('drop-shadow'); + expect(shadow.offsetX).toBeCloseTo(4, 0); + expect(shadow.offsetY).toBeCloseTo(6, 0); + expect(shadow.blur).toBeCloseTo(8, 0); + expect(shadow.spread).toBeCloseTo(1, 0); + expect(shadow.hidden).toBe(false); + expect(shadow.color).toBeDefined(); + expect(shadow.color && shadow.color.color).toBe('#000000'); + expect(shadow.color && shadow.color.opacity).toBeCloseTo(0.5, 2); + }); + + test('inner shadow can be hidden', (ctx) => { + const r = rect(ctx); + r.shadows = [ + { + style: 'inner-shadow', + offsetX: 0, + offsetY: 0, + blur: 4, + spread: 0, + hidden: true, + color: { color: '#ff0000', opacity: 1 }, + }, + ]; + + const shadow = r.shadows[0]; + expect(shadow.style).toBe('inner-shadow'); + expect(shadow.hidden).toBe(true); + }); +}); + +describe('Blur', () => { + test('layer blur round-trips', (ctx) => { + const r = rect(ctx); + r.blur = { value: 10 }; + + expect(r.blur).toBeDefined(); + expect(r.blur && r.blur.value).toBeCloseTo(10, 0); + // hidden defaults to false when omitted. + expect(r.blur && r.blur.hidden).toBeFalsy(); + }); + + test('background blur round-trips', (ctx) => { + const r = rect(ctx); + r.backgroundBlur = { value: 5 }; + + expect(r.backgroundBlur).toBeDefined(); + expect(r.backgroundBlur && r.backgroundBlur.value).toBeCloseTo(5, 0); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts new file mode 100644 index 0000000000..89209a0906 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts @@ -0,0 +1,318 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; + +// Shapes & geometry. +// Exercises the Context shape factories and the context-level structural +// operations (group/ungroup/flatten, align/distribute). Everything created is +// appended to the scratch board `ctx.board` so the user's canvas stays clean. +// Each group keeps its happy-path tests together with the related edge cases: +// "fail" tests assert the documented null returns / rejections for degenerate +// input; the remaining ones pin non-trivial valid construction (clone +// independence, group order, boolean tree, svg tree). + +describe('Shapes', () => { + describe('Factories', () => { + test('createRectangle returns a rectangle', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + expect(rect.type).toBe('rectangle'); + }); + + test('createEllipse returns an ellipse', (ctx) => { + const ellipse = ctx.penpot.createEllipse(); + ctx.board.appendChild(ellipse); + expect(ellipse.type).toBe('ellipse'); + }); + + test('createBoard returns a board', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + expect(board.type).toBe('board'); + }); + + test('createPath returns a path', (ctx) => { + const path = ctx.penpot.createPath(); + ctx.board.appendChild(path); + expect(path.type).toBe('path'); + }); + + test('createText returns a text shape with the given content', (ctx) => { + const text = ctx.penpot.createText('Hello Penpot'); + expect(text).not.toBeNull(); + if (text) { + ctx.board.appendChild(text); + expect(text.type).toBe('text'); + expect(text.characters).toContain('Hello'); + } + }); + + test('createBoolean unions two shapes', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + b.x = 50; + ctx.board.appendChild(a); + ctx.board.appendChild(b); + + const bool = ctx.penpot.createBoolean('union', [a, b]); + expect(bool).not.toBeNull(); + if (bool) { + ctx.board.appendChild(bool); + expect(bool.type).toBe('boolean'); + } + }); + + test('createShapeFromSvg returns a group', (ctx) => { + const svg = + '' + + ''; + const group = ctx.penpot.createShapeFromSvg(svg); + expect(group).not.toBeNull(); + if (group) { + ctx.board.appendChild(group); + expect(group.type).toBe('group'); + } + }); + + test('createShapeFromSvgWithImages resolves to a group', async (ctx) => { + const svg = + '' + + ''; + const group = await ctx.penpot.createShapeFromSvgWithImages(svg); + expect(group).not.toBeNull(); + if (group) { + ctx.board.appendChild(group); + expect(group.type).toBe('group'); + } + }); + + // Degenerate input — documented null returns / rejections. + test('createText with an empty string returns null', (ctx) => { + // The d.ts documents: "Returns null if an empty string is provided". + const t = ctx.penpot.createText(''); + expect(t).toBeNull(); + }); + + test('group of an empty array returns null', (ctx) => { + const g = ctx.penpot.group([]); + expect(g).toBeNull(); + }); + + test('createBoolean with an empty shapes array is rejected', (ctx) => { + // createBoolean validates a non-empty shapes array, so with + // throwValidationErrors enabled it throws rather than returning null. + expect(() => ctx.penpot.createBoolean('union', [])).toThrow(); + }); + + test('createBoolean supports difference, exclude and intersection', (ctx) => { + // Only `union` is exercised elsewhere; cover the remaining boolean ops so a + // typology-specific regression in any single operation is caught. + for (const op of ['difference', 'exclude', 'intersection'] as const) { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createEllipse(); + ctx.board.appendChild(a); + ctx.board.appendChild(b); + b.x = 10; + b.y = 10; + const bool = ctx.penpot.createBoolean(op, [a, b]); + expect(bool).not.toBeNull(); + if (bool) { + ctx.board.appendChild(bool); + expect(typeof bool.d).toBe('string'); + expect(typeof bool.toD()).toBe('string'); + } + } + }); + + test('createShapeFromSvg is lenient with unparseable markup', (ctx) => { + // The SVG importer is permissive: it still produces a group for input + // that is not valid SVG rather than returning null. + const group = ctx.penpot.createShapeFromSvg('not svg at all'); + expect(group).not.toBeNull(); + if (group) { + ctx.board.appendChild(group); + } + }); + + // Success edges — non-trivial valid construction. + test('clone produces an independent copy', (ctx) => { + const r = ctx.penpot.createRectangle(); + r.name = 'original'; + ctx.board.appendChild(r); + + const copy = r.clone(); + ctx.board.appendChild(copy); + copy.name = 'copy'; + + expect(copy.id).not.toBe(r.id); + expect(copy.name).toBe('copy'); + // Mutating the copy must not affect the original. + expect(r.name).toBe('original'); + }); + + test('group preserves child count and order', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createEllipse(); + const c = ctx.penpot.createRectangle(); + a.name = 'a'; + b.name = 'b'; + c.name = 'c'; + ctx.board.appendChild(a); + ctx.board.appendChild(b); + ctx.board.appendChild(c); + + const group = ctx.penpot.group([a, b, c]); + expect(group).not.toBeNull(); + if (group) { + expect(group.children).toHaveLength(3); + expect(group.children.map((s) => s.name).sort()).toEqual([ + 'a', + 'b', + 'c', + ]); + } + }); + + test('a boolean keeps its two operands as children', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + b.x = 50; + ctx.board.appendChild(a); + ctx.board.appendChild(b); + + const bool = ctx.penpot.createBoolean('union', [a, b]); + expect(bool).not.toBeNull(); + if (bool) { + ctx.board.appendChild(bool); + expect(bool.type).toBe('boolean'); + expect(bool.children).toHaveLength(2); + } + }); + + test('createShapeFromSvg builds a group with children', (ctx) => { + const svg = + '' + + '' + + ''; + const group = ctx.penpot.createShapeFromSvg(svg); + expect(group).not.toBeNull(); + if (group) { + ctx.board.appendChild(group); + expect(group.type).toBe('group'); + expect(group.children.length).toBeGreaterThan(0); + } + }); + }); + + describe('Grouping', () => { + test('group wraps shapes in a group', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createEllipse(); + ctx.board.appendChild(a); + ctx.board.appendChild(b); + + const group = ctx.penpot.group([a, b]); + expect(group).not.toBeNull(); + if (group) { + expect(group.type).toBe('group'); + expect(group.children).toHaveLength(2); + } + }); + + test('ungroup dissolves a group', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createEllipse(); + ctx.board.appendChild(a); + ctx.board.appendChild(b); + + const group = ctx.penpot.group([a, b]); + expect(group).not.toBeNull(); + if (group) { + const before = ctx.board.children.length; + ctx.penpot.ungroup(group); + // After ungroup the two shapes should be back on the board directly. + expect(ctx.board.children.length).toBeGreaterThan(before - 1); + } + }); + + test('flatten converts shapes into paths', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + + const paths = ctx.penpot.flatten([rect]); + expect(paths).toHaveLength(1); + expect(paths[0].type).toBe('path'); + }); + }); + + describe('Align & distribute', () => { + test('alignHorizontal moves shapes to a shared edge', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + a.x = 0; + b.x = 200; + ctx.board.appendChild(a); + ctx.board.appendChild(b); + + ctx.penpot.alignHorizontal([a, b], 'left'); + expect(a.x).toBeCloseTo(b.x, 0); + }); + + test('alignVertical moves shapes to a shared edge', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + a.y = 0; + b.y = 200; + ctx.board.appendChild(a); + ctx.board.appendChild(b); + + ctx.penpot.alignVertical([a, b], 'top'); + expect(a.y).toBeCloseTo(b.y, 0); + }); + + test('distributeHorizontal runs without error', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + const c = ctx.penpot.createRectangle(); + a.x = 0; + b.x = 50; + c.x = 300; + ctx.board.appendChild(a); + ctx.board.appendChild(b); + ctx.board.appendChild(c); + + ctx.penpot.distributeHorizontal([a, b, c]); + // Middle shape should end up between the outer two. + expect(b.x).toBeGreaterThan(a.x); + }); + + test('distributeVertical runs without error', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + const c = ctx.penpot.createRectangle(); + a.y = 0; + b.y = 50; + c.y = 300; + ctx.board.appendChild(a); + ctx.board.appendChild(b); + ctx.board.appendChild(c); + + ctx.penpot.distributeVertical([a, b, c]); + expect(b.y).toBeGreaterThan(a.y); + }); + + // Edge cases. + test('aligning an empty array is a no-op (not rejected)', (ctx) => { + // align/distribute do not validate the shape list; an empty array is + // simply a no-op rather than an error. + expect(() => ctx.penpot.alignHorizontal([], 'left')).not.toThrow(); + }); + + test('distributing a single shape leaves it in place', (ctx) => { + const a = ctx.penpot.createRectangle(); + a.x = 10; + ctx.board.appendChild(a); + ctx.penpot.distributeHorizontal([a]); + expect(a.x).toBeCloseTo(10, 0); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts new file mode 100644 index 0000000000..2402f29035 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts @@ -0,0 +1,408 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { TestContext } from '../framework/types'; + +// Shapes & geometry. +// Exercises the `ShapeBase` identity / geometry / transform / ordering members +// that are common to every shape, using a rectangle on the scratch board. + +/** Creates a rectangle, appends it to the scratch board and returns it. */ +function rect(ctx: TestContext) { + const r = ctx.penpot.createRectangle(); + ctx.board.appendChild(r); + return r; +} + +describe('Shapes', () => { + describe('Identity', () => { + test('exposes a stable id', (ctx) => { + const r = rect(ctx); + expect(typeof r.id).toBe('string'); + expect(r.id.length).toBeGreaterThan(0); + }); + + test('name is readable and writable', (ctx) => { + const r = rect(ctx); + r.name = 'sample-rect'; + expect(r.name).toBe('sample-rect'); + }); + + test('parent points at the containing board', (ctx) => { + const r = rect(ctx); + expect(r.parent).not.toBeNull(); + expect(r.parent && r.parent.id).toBe(ctx.board.id); + }); + + test('parentIndex is a distinct structural index per sibling', (ctx) => { + const a = rect(ctx); + const b = rect(ctx); + // Two siblings on a fresh board occupy indices 0 and 1 (direction depends + // on naturalChildOrdering, so assert the set rather than which is which). + expect([a.parentIndex, b.parentIndex].sort()).toEqual([0, 1]); + }); + }); + + describe('Geometry', () => { + test('x and y are readable and writable', (ctx) => { + const r = rect(ctx); + r.x = 120; + r.y = 80; + expect(r.x).toBeCloseTo(120, 0); + expect(r.y).toBeCloseTo(80, 0); + }); + + test('resize changes width and height', (ctx) => { + const r = rect(ctx); + r.resize(200, 100); + expect(r.width).toBeCloseTo(200, 0); + expect(r.height).toBeCloseTo(100, 0); + }); + + test('bounds describes a rectangular area', (ctx) => { + const r = rect(ctx); + r.x = 10; + r.y = 20; + r.resize(50, 40); + const b = r.bounds; + expect(b.width).toBeCloseTo(50, 0); + expect(b.height).toBeCloseTo(40, 0); + }); + + test('center sits in the middle of the shape', (ctx) => { + const r = rect(ctx); + r.x = 0; + r.y = 0; + r.resize(100, 100); + const c = r.center; + expect(c.x).toBeCloseTo(50, 0); + expect(c.y).toBeCloseTo(50, 0); + }); + + test('boardX and boardY are readable and writable', (ctx) => { + const r = rect(ctx); + r.boardX = 15; + r.boardY = 25; + expect(r.boardX).toBeCloseTo(15, 0); + expect(r.boardY).toBeCloseTo(25, 0); + }); + + test('parentX and parentY are readable and writable', (ctx) => { + const r = rect(ctx); + r.parentX = 12; + r.parentY = 22; + expect(r.parentX).toBeCloseTo(12, 0); + expect(r.parentY).toBeCloseTo(22, 0); + }); + }); + + describe('Transform', () => { + test('rotation is readable and writable', (ctx) => { + const r = rect(ctx); + r.rotation = 45; + expect(r.rotation).toBeCloseTo(45, 0); + }); + + test('rotate() applies an angle', (ctx) => { + const r = rect(ctx); + r.rotate(90); + expect(r.rotation).toBeCloseTo(90, 0); + }); + + test('flipX is readable and writable', (ctx) => { + const r = rect(ctx); + expect(r.flipX).toBe(false); + r.flipX = true; + expect(r.flipX).toBe(true); + }); + + test('flipY is readable and writable', (ctx) => { + const r = rect(ctx); + expect(r.flipY).toBe(false); + r.flipY = true; + expect(r.flipY).toBe(true); + }); + }); + + // The geometry/transform members above run on a rectangle. Re-exercise the + // core ones on other shape types so a type-specific regression is caught. + describe('Geometry across shape types', () => { + test('resize and rotate work on an ellipse', (ctx) => { + const e = ctx.penpot.createEllipse(); + ctx.board.appendChild(e); + e.resize(120, 60); + expect(e.width).toBeCloseTo(120, 0); + expect(e.height).toBeCloseTo(60, 0); + e.rotate(45); + expect(e.rotation).toBeCloseTo(45, 0); + }); + + test('flip works on an ellipse', (ctx) => { + // Kept separate from rotation: flipping an already-rotated shape does not + // round-trip through flipX, so exercise flip on an unrotated ellipse. + const e = ctx.penpot.createEllipse(); + ctx.board.appendChild(e); + expect(e.flipX).toBe(false); + e.flipX = true; + expect(e.flipX).toBe(true); + e.flipY = true; + expect(e.flipY).toBe(true); + }); + + test('resize and reposition work on a nested board', (ctx) => { + const b = ctx.penpot.createBoard(); + ctx.board.appendChild(b); + b.resize(200, 150); + b.x = 25; + b.y = 35; + expect(b.width).toBeCloseTo(200, 0); + expect(b.height).toBeCloseTo(150, 0); + expect(b.x).toBeCloseTo(25, 0); + expect(b.y).toBeCloseTo(35, 0); + }); + }); + + describe('Appearance flags', () => { + test('blocked is readable and writable', (ctx) => { + const r = rect(ctx); + r.blocked = true; + expect(r.blocked).toBe(true); + }); + + test('hidden is readable and writable', (ctx) => { + const r = rect(ctx); + r.hidden = true; + expect(r.hidden).toBe(true); + }); + + test('visible is readable and writable', (ctx) => { + const r = rect(ctx); + r.visible = false; + expect(r.visible).toBe(false); + }); + + test('proportionLock is readable and writable', (ctx) => { + const r = rect(ctx); + r.proportionLock = true; + expect(r.proportionLock).toBe(true); + }); + + test('fixedWhenScrolling is readable and writable', (ctx) => { + const r = rect(ctx); + r.fixedWhenScrolling = true; + expect(r.fixedWhenScrolling).toBe(true); + }); + + test('opacity is readable and writable', (ctx) => { + const r = rect(ctx); + r.opacity = 0.5; + expect(r.opacity).toBeCloseTo(0.5, 2); + }); + + test('blendMode is readable and writable', (ctx) => { + const r = rect(ctx); + r.blendMode = 'multiply'; + expect(r.blendMode).toBe('multiply'); + }); + }); + + describe('Constraints', () => { + test('constraintsHorizontal is readable and writable', (ctx) => { + const r = rect(ctx); + r.constraintsHorizontal = 'center'; + expect(r.constraintsHorizontal).toBe('center'); + }); + + test('constraintsVertical is readable and writable', (ctx) => { + const r = rect(ctx); + r.constraintsVertical = 'center'; + expect(r.constraintsVertical).toBe('center'); + }); + }); + + describe('Corner radius', () => { + test('borderRadius is readable and writable', (ctx) => { + const r = rect(ctx); + r.borderRadius = 8; + expect(r.borderRadius).toBeCloseTo(8, 0); + }); + + test('per-corner border radius is readable and writable', (ctx) => { + const r = rect(ctx); + r.borderRadiusTopLeft = 1; + r.borderRadiusTopRight = 2; + r.borderRadiusBottomRight = 3; + r.borderRadiusBottomLeft = 4; + expect(r.borderRadiusTopLeft).toBeCloseTo(1, 0); + expect(r.borderRadiusTopRight).toBeCloseTo(2, 0); + expect(r.borderRadiusBottomRight).toBeCloseTo(3, 0); + expect(r.borderRadiusBottomLeft).toBeCloseTo(4, 0); + }); + }); + + describe('Ordering', () => { + test('setParentIndex moves the shape to the given index', (ctx) => { + const a = rect(ctx); + const b = rect(ctx); + void a; + b.setParentIndex(0); + expect(b.parentIndex).toBe(0); + }); + + test('bringToFront / sendToBack move the shape to opposite extremes', (ctx) => { + const a = rect(ctx); + const b = rect(ctx); + void b; + const last = ctx.board.children.length - 1; + + a.bringToFront(); + const front = a.parentIndex; + expect(front === 0 || front === last).toBe(true); + + a.sendToBack(); + const back = a.parentIndex; + expect(back === 0 || back === last).toBe(true); + + // Front and back must be different extremes. + expect(front).not.toBe(back); + }); + + test('bringForward / sendBackward move the shape one step', (ctx) => { + const a = rect(ctx); + const b = rect(ctx); + const c = rect(ctx); + void b; + void c; + + const start = a.parentIndex; + a.bringForward(); + expect(Math.abs(a.parentIndex - start)).toBe(1); + + const mid = a.parentIndex; + a.sendBackward(); + expect(Math.abs(a.parentIndex - mid)).toBe(1); + }); + }); + + describe('Lifecycle', () => { + test('clone duplicates a shape', (ctx) => { + const r = rect(ctx); + r.name = 'original'; + const copy = r.clone(); + ctx.board.appendChild(copy); + expect(copy.id).not.toBe(r.id); + expect(copy.type).toBe('rectangle'); + }); + + test('remove detaches the shape from its parent', (ctx) => { + const r = rect(ctx); + const before = ctx.board.children.length; + r.remove(); + expect(ctx.board.children.length).toBe(before - 1); + }); + }); + + // --------------------------------------------------------------------------- + // Edge cases. "fail" tests assert invalid numeric/enum input is + // rejected; "success" tests assert documented boundary behaviour + // (setParentIndex clamps to last, rotation about the center, opacity 0/1). + // --------------------------------------------------------------------------- + describe('Numeric & enum — invalid values (fail)', () => { + test('opacity below 0 throws', (ctx) => { + const r = rect(ctx); + expect(() => { + r.opacity = -0.1; + }).toThrow(); + }); + + test('opacity above 1 throws', (ctx) => { + const r = rect(ctx); + expect(() => { + r.opacity = 1.5; + }).toThrow(); + }); + + test('NaN opacity throws', (ctx) => { + const r = rect(ctx); + expect(() => { + r.opacity = NaN; + }).toThrow(); + }); + + test('NaN rotation is accepted (currently unvalidated)', (ctx) => { + // The rotation setter does not reject NaN; this pins the current lenient + // behaviour (a candidate for future hardening). + const r = rect(ctx); + expect(() => { + r.rotation = NaN; + }).not.toThrow(); + }); + + test('invalid blendMode throws', (ctx) => { + const r = rect(ctx); + expect(() => { + r.blendMode = 'not-a-mode' as unknown as 'normal'; + }).toThrow(); + }); + + test('negative borderRadius throws', (ctx) => { + const r = rect(ctx); + expect(() => { + r.borderRadius = -8; + }).toThrow(); + }); + + test('setParentIndex with a negative index is accepted (currently unvalidated)', (ctx) => { + // setParentIndex does not reject a negative index; this pins the current + // lenient behaviour (a candidate for future hardening). + const a = rect(ctx); + const b = rect(ctx); + void b; + expect(() => a.setParentIndex(-1)).not.toThrow(); + }); + }); + + describe('Geometry & ordering — success edges', () => { + test('opacity accepts the 0 and 1 boundaries', (ctx) => { + const r = rect(ctx); + r.opacity = 0; + expect(r.opacity).toBeCloseTo(0, 2); + r.opacity = 1; + expect(r.opacity).toBeCloseTo(1, 2); + }); + + test('setParentIndex past the end positions the shape last', (ctx) => { + const a = rect(ctx); + const b = rect(ctx); + const c = rect(ctx); + void b; + void c; + // The d.ts documents: "If the index is greater than the number of + // elements it will positioned last." + a.setParentIndex(999); + expect(a.parentIndex).toBe(ctx.board.children.length - 1); + }); + + test('setParentIndex reorders siblings while keeping a contiguous index set', (ctx) => { + const a = rect(ctx); + const b = rect(ctx); + const c = rect(ctx); + void a; + void c; + b.setParentIndex(0); + expect(b.parentIndex).toBe(0); + const indices = ctx.board.children.map((s) => s.parentIndex).sort(); + expect(indices).toEqual([0, 1, 2]); + }); + + test('rotating 360 degrees leaves the center unchanged', (ctx) => { + const r = rect(ctx); + r.x = 0; + r.y = 0; + r.resize(100, 100); + r.rotation = 360; + const c = r.center; + expect(c.x).toBeCloseTo(50, 0); + expect(c.y).toBeCloseTo(50, 0); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shapes-types.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shapes-types.test.ts new file mode 100644 index 0000000000..dcd7c82dd5 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/shapes-types.test.ts @@ -0,0 +1,299 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { Shape } from '@penpot/plugin-types'; + +// Shapes & geometry. +// Exercises members specific to the concrete shape types (Board, Group, Boolean, +// Path, Ellipse, SvgRaw) beyond the common `ShapeBase` surface. + +/** Depth-first search for the first descendant matching `type`. */ +function findByType(shape: Shape, type: string): Shape | null { + if (shape.type === type) return shape; + const children = 'children' in shape ? shape.children : undefined; + if (children) { + for (const child of children) { + const found = findByType(child, type); + if (found) return found; + } + } + return null; +} + +describe('Shapes', () => { + describe('Board', () => { + test('clipContent is readable and writable', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + board.clipContent = false; + expect(board.clipContent).toBe(false); + board.clipContent = true; + expect(board.clipContent).toBe(true); + }); + + test('showInViewMode is readable and writable', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + board.showInViewMode = false; + expect(board.showInViewMode).toBe(false); + }); + + test('appendChild and children reflect added shapes', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + const child = ctx.penpot.createRectangle(); + board.appendChild(child); + expect(board.children).toHaveLength(1); + expect(board.children[0].id).toBe(child.id); + }); + + test('children setter accepts a reorder and rejects a different set', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + board.appendChild(a); + board.appendChild(b); + + const ids = board.children.map((c) => c.id).sort(); + + // `children` is writable only for *reordering*: assigning the same shapes + // in a new order is accepted and preserves the set. (The visible order is + // governed by the naturalChildOrdering flag, so only the set is asserted.) + board.children = [...board.children].reverse(); + expect(board.children).toHaveLength(2); + expect(board.children.map((c) => c.id).sort()).toEqual(ids); + + // Assigning a set that doesn't match the current children is rejected. + expect(() => { + board.children = [a]; + }).toThrow(); + }); + + test('insertChild places a shape at a given index', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + const first = ctx.penpot.createRectangle(); + const second = ctx.penpot.createRectangle(); + board.appendChild(first); + board.insertChild(0, second); + // Use the structural parentIndex; the children array sort direction + // depends on the naturalChildOrdering flag. + expect(second.parentIndex).toBe(0); + }); + + test('horizontalSizing and verticalSizing are readable and writable', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + board.horizontalSizing = 'fix'; + board.verticalSizing = 'fix'; + expect(board.horizontalSizing).toBe('fix'); + expect(board.verticalSizing).toBe('fix'); + }); + + test('isVariantContainer is false for a plain board', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + expect(board.isVariantContainer()).toBe(false); + }); + }); + + describe('Group', () => { + test('children and appendChild work on a group', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + ctx.board.appendChild(a); + ctx.board.appendChild(b); + const group = ctx.penpot.group([a, b]); + expect(group).not.toBeNull(); + if (group) { + expect(group.children).toHaveLength(2); + const extra = ctx.penpot.createRectangle(); + group.appendChild(extra); + expect(group.children).toHaveLength(3); + } + }); + + test('insertChild places a shape into a group', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + ctx.board.appendChild(a); + ctx.board.appendChild(b); + const group = ctx.penpot.group([a, b]); + expect(group).not.toBeNull(); + if (group) { + const extra = ctx.penpot.createRectangle(); + group.insertChild(0, extra); + expect(extra.parentIndex).toBe(0); + } + }); + + test('makeMask and removeMask run without error', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + ctx.board.appendChild(a); + ctx.board.appendChild(b); + const group = ctx.penpot.group([a, b]); + expect(group).not.toBeNull(); + if (group) { + group.makeMask(); + group.removeMask(); + } + }); + + test('isMask reports whether the group is a mask', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + ctx.board.appendChild(a); + ctx.board.appendChild(b); + const group = ctx.penpot.group([a, b]); + expect(group).not.toBeNull(); + if (group) { + expect(group.isMask()).toBe(false); + group.makeMask(); + expect(group.isMask()).toBe(true); + } + }); + }); + + describe('Boolean', () => { + test('boolean exposes path data and child shapes', (ctx) => { + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + b.x = 40; + ctx.board.appendChild(a); + ctx.board.appendChild(b); + const bool = ctx.penpot.createBoolean('union', [a, b]); + expect(bool).not.toBeNull(); + if (bool) { + ctx.board.appendChild(bool); + expect(bool.children.length).toBeGreaterThan(1); + expect(typeof bool.d).toBe('string'); + expect(typeof bool.toD()).toBe('string'); + expect(Array.isArray(bool.commands)).toBe(true); + } + }); + }); + + describe('Path', () => { + test('d round-trips and populates commands', (ctx) => { + const path = ctx.penpot.createPath(); + ctx.board.appendChild(path); + path.d = 'M0 0 L10 0 L10 10 Z'; + expect(path.d).toContain('M'); + expect(path.commands.length).toBeGreaterThan(0); + expect(typeof path.toD()).toBe('string'); + }); + + test('content alias is readable and writable', (ctx) => { + const path = ctx.penpot.createPath(); + ctx.board.appendChild(path); + path.content = 'M0 0 L20 20'; + expect(typeof path.content).toBe('string'); + }); + }); + + describe('Ellipse', () => { + test('an ellipse reports its type', (ctx) => { + const ellipse = ctx.penpot.createEllipse(); + ctx.board.appendChild(ellipse); + expect(ellipse.type).toBe('ellipse'); + }); + }); + + describe('SvgRaw', () => { + test('an SVG import contains svg-raw descendants', (ctx) => { + // Native tags (rect/circle/path/…) import as their own shape types; only + // tags without a native mapping (e.g. ) become raw svg nodes, so the + // fixture must include one to exercise SvgRaw. + const svg = + '' + + '' + + 'hi'; + const group = ctx.penpot.createShapeFromSvg(svg); + expect(group).not.toBeNull(); + if (group) { + ctx.board.appendChild(group); + const raw = findByType(group, 'svg-raw'); + expect(raw).not.toBeNull(); + expect(raw && raw.type).toBe('svg-raw'); + } + }); + }); + + // --------------------------------------------------------------------------- + // Edge cases. "fail" tests assert that building a circular shape + // hierarchy is rejected; "success" tests assert the type predicates classify + // shapes correctly and that masking round-trips (incl. nested in a board). + // --------------------------------------------------------------------------- + describe('Hierarchy — circular references', () => { + // The plugin appendChild does not explicitly reject cycle-creating moves; + // the underlying relocate handles them without throwing. These pin that the + // call is not rejected (cycle-prevention at the API boundary is a candidate + // for future hardening). + test('appending a board into itself does not throw', (ctx) => { + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(board); + expect(() => board.appendChild(board)).not.toThrow(); + }); + + test('appending an ancestor into its descendant does not throw', (ctx) => { + const outer = ctx.penpot.createBoard(); + const inner = ctx.penpot.createBoard(); + ctx.board.appendChild(outer); + outer.appendChild(inner); + expect(() => inner.appendChild(outer)).not.toThrow(); + }); + }); + + describe('Type predicates — success edges', () => { + test('utils.types classifies shapes by their concrete type', (ctx) => { + const types = ctx.penpot.utils.types; + + const rect = ctx.penpot.createRectangle(); + const ellipse = ctx.penpot.createEllipse(); + const path = ctx.penpot.createPath(); + const board = ctx.penpot.createBoard(); + ctx.board.appendChild(rect); + ctx.board.appendChild(ellipse); + ctx.board.appendChild(path); + ctx.board.appendChild(board); + + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + ctx.board.appendChild(a); + ctx.board.appendChild(b); + const group = ctx.penpot.group([a, b]); + + expect(types.isRectangle(rect)).toBe(true); + expect(types.isBoard(rect)).toBe(false); + expect(types.isEllipse(ellipse)).toBe(true); + expect(types.isPath(path)).toBe(true); + expect(types.isBoard(board)).toBe(true); + expect(types.isGroup(board)).toBe(false); + if (group) { + expect(types.isGroup(group)).toBe(true); + expect(types.isMask(group)).toBe(false); + } + }); + + test('makeMask / removeMask toggles isMask, including nested in a board', (ctx) => { + const host = ctx.penpot.createBoard(); + ctx.board.appendChild(host); + + const a = ctx.penpot.createRectangle(); + const b = ctx.penpot.createRectangle(); + host.appendChild(a); + host.appendChild(b); + const group = ctx.penpot.group([a, b]); + expect(group).not.toBeNull(); + if (group) { + expect(group.isMask()).toBe(false); + group.makeMask(); + expect(group.isMask()).toBe(true); + expect(ctx.penpot.utils.types.isMask(group)).toBe(true); + group.removeMask(); + expect(group.isMask()).toBe(false); + } + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts new file mode 100644 index 0000000000..21a4efbe38 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts @@ -0,0 +1,327 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { Text } from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; + +// Text & text ranges. +// Font-dependent properties (fontFamily/fontWeight/…) are only read here; they +// are set properly via the Font API in fonts.test.ts. The style properties that +// don't depend on a concrete font are set and read back. + +function text(ctx: TestContext, value = 'Hello Penpot'): Text { + const t = ctx.penpot.createText(value); + if (!t) throw new Error('createText returned null'); + ctx.board.appendChild(t); + return t; +} + +describe('Text', () => { + test('characters round-trip', (ctx) => { + const t = text(ctx); + t.characters = 'Updated content'; + expect(t.characters).toBe('Updated content'); + }); + + test('growType round-trips', (ctx) => { + const t = text(ctx); + t.growType = 'auto-height'; + expect(t.growType).toBe('auto-height'); + }); + + test('fontSize round-trips', (ctx) => { + const t = text(ctx); + t.fontSize = '24'; + expect(t.fontSize).toBe('24'); + }); + + test('lineHeight and letterSpacing round-trip', (ctx) => { + const t = text(ctx); + t.lineHeight = '1.5'; + t.letterSpacing = '2'; + expect(t.lineHeight).toBe('1.5'); + expect(t.letterSpacing).toBe('2'); + }); + + test('alignment round-trips', (ctx) => { + const t = text(ctx); + t.align = 'center'; + t.verticalAlign = 'center'; + expect(t.align).toBe('center'); + expect(t.verticalAlign).toBe('center'); + }); + + test('transform, decoration and direction round-trip', (ctx) => { + const t = text(ctx); + t.textTransform = 'uppercase'; + t.textDecoration = 'underline'; + t.direction = 'rtl'; + expect(t.textTransform).toBe('uppercase'); + expect(t.textDecoration).toBe('underline'); + expect(t.direction).toBe('rtl'); + }); + + test('font identity and variant setters accept a real font/variant', (ctx) => { + const t = text(ctx); + const font = ctx.penpot.fonts.all[0]; + const variant = font.variants[0]; + // Set the font identity first, then the variant-specific properties using + // values drawn from that same font so validation passes. + t.fontId = font.fontId; + t.fontFamily = font.fontFamily; + t.fontVariantId = variant.fontVariantId; + t.fontWeight = variant.fontWeight; + t.fontStyle = variant.fontStyle; + expect(t.fontId).toBe(font.fontId); + }); + + test('font properties are readable', (ctx) => { + const t = text(ctx); + expect(typeof t.fontId).toBe('string'); + expect(typeof t.fontFamily).toBe('string'); + expect(typeof t.fontVariantId).toBe('string'); + expect(typeof t.fontWeight).toBe('string'); + // fontStyle is 'normal' | 'italic' | 'mixed' | null + expect(t.fontStyle === null || typeof t.fontStyle === 'string').toBe(true); + }); + + test('textBounds exposes a rectangle shape', (ctx) => { + const t = text(ctx); + const b = t.textBounds; + // The numeric values depend on text layout (`:position-data`), which the + // headless runner does not compute, so width/height may be null in CI but + // are real numbers in the interactive editor. Assert the shape of the object. + expect('x' in b).toBe(true); + expect('y' in b).toBe(true); + expect('width' in b).toBe(true); + expect('height' in b).toBe(true); + }); + + test('applyTypography applies a typography to the text shape', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + typo.fontSize = '21'; + const t = text(ctx); + t.applyTypography(typo); + expect(t.fontSize).toBe('21'); + }); + + describe('Range', () => { + test('getRange returns the range characters', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + expect(range.characters.length).toBeGreaterThan(0); + }); + + test('range shape references the owning text shape', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + expect(range.shape.type).toBe('text'); + }); + + test('range font size round-trips', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + range.fontSize = '30'; + expect(range.fontSize).toBe('30'); + }); + + test('range line height and letter spacing round-trip', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + range.lineHeight = '2'; + range.letterSpacing = '1'; + expect(range.lineHeight).toBe('2'); + expect(range.letterSpacing).toBe('1'); + }); + + test('range alignment round-trips', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + range.align = 'right'; + range.verticalAlign = 'center'; + expect(range.align).toBe('right'); + expect(range.verticalAlign).toBe('center'); + }); + + test('range transform and decoration round-trip', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + range.textTransform = 'lowercase'; + range.textDecoration = 'line-through'; + expect(range.textTransform).toBe('lowercase'); + expect(range.textDecoration).toBe('line-through'); + }); + + test('range fills round-trip', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + range.fills = [{ fillColor: '#00ff00', fillOpacity: 1 }]; + + const fills = range.fills; + if (Array.isArray(fills)) { + expect(fills[0].fillColor).toBe('#00ff00'); + } + }); + + test('two ranges keep independent fills', (ctx) => { + // Mixed-style coverage: distinct fills on distinct sub-ranges must not + // bleed into each other. + const t = text(ctx, 'Hello Penpot'); + const first = t.getRange(0, 5); + const second = t.getRange(6, 12); + first.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + second.fills = [{ fillColor: '#0000ff', fillOpacity: 1 }]; + + const f1 = first.fills; + const f2 = second.fills; + if (Array.isArray(f1) && Array.isArray(f2)) { + expect(f1[0].fillColor).toBe('#ff0000'); + expect(f2[0].fillColor).toBe('#0000ff'); + } + }); + + test('range font properties are readable', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + expect(typeof range.fontId).toBe('string'); + expect(typeof range.fontFamily).toBe('string'); + expect(typeof range.fontVariantId).toBe('string'); + expect(typeof range.fontWeight).toBe('string'); + }); + + test('range style properties are readable', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + void range.direction; + void range.fontStyle; + void range.letterSpacing; + void range.lineHeight; + void range.textDecoration; + void range.textTransform; + void range.verticalAlign; + void range.align; + expect(range.characters.length).toBeGreaterThan(0); + }); + + test('range font properties can be set', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + const font = ctx.penpot.fonts.all[0]; + // Setting records the (set) targets; partial-range persistence is a known + // API bug covered elsewhere, so only the call is exercised here. + // (fontStyle/fontVariantId/fontWeight are validated strictly against the + // current font's variants, so they are left out to avoid fragility.) + range.fontFamily = font.fontFamily; + range.fontId = font.fontId; + range.direction = 'ltr'; + // Variant-specific setters, using values from the same font so the strict + // per-font validation passes. + const variant = font.variants[0]; + range.fontVariantId = variant.fontVariantId; + range.fontWeight = variant.fontWeight; + range.fontStyle = variant.fontStyle; + expect(range.characters.length).toBeGreaterThan(0); + }); + + test('applyTypography applies to a text range', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, 5); + range.applyTypography(typo); + expect(range.characters.length).toBeGreaterThan(0); + }); + }); + + // --------------------------------------------------------------------------- + // Edge cases. "fail" tests assert invalid input is rejected; + // "success" tests assert non-trivial valid behaviour (mixed detection, + // full-span application, multi-paragraph round-trip). + // --------------------------------------------------------------------------- + test('getRange with start greater than end throws', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + expect(() => t.getRange(5, 1)).toThrow(); + }); + + test('getRange with a negative index throws', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + expect(() => t.getRange(-1, 5)).toThrow(); + }); + + test('getRange beyond the text length is clamped (not rejected)', (ctx) => { + // An end index past the text length is clamped rather than rejected. + const t = text(ctx, 'Hello Penpot'); + let range: ReturnType | null = null; + expect(() => { + range = t.getRange(0, 999); + }).not.toThrow(); + expect(range).not.toBeNull(); + }); + + test('empty fontSize throws', (ctx) => { + const t = text(ctx); + expect(() => { + t.fontSize = ''; + }).toThrow(); + }); + + test('negative fontSize throws', (ctx) => { + const t = text(ctx); + expect(() => { + t.fontSize = '-12'; + }).toThrow(); + }); + + test('non-numeric fontSize throws', (ctx) => { + const t = text(ctx); + expect(() => { + t.fontSize = 'abc'; + }).toThrow(); + }); + + test('invalid align value throws', (ctx) => { + const t = text(ctx); + expect(() => { + t.align = 'middle' as unknown as 'center'; + }).toThrow(); + }); + + test('wrong-case textTransform throws', (ctx) => { + const t = text(ctx); + expect(() => { + t.textTransform = 'UPPERCASE' as unknown as 'uppercase'; + }).toThrow(); + }); + + test('invalid direction value throws', (ctx) => { + const t = text(ctx); + expect(() => { + t.direction = 'sideways' as unknown as 'ltr'; + }).toThrow(); + }); + + test('a uniformly-set fontSize is reported, not mixed', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + t.fontSize = '20'; + expect(t.fontSize).toBe('20'); + }); + + test('setting fontSize on a sub-range makes the shape report mixed', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + t.fontSize = '20'; + const range = t.getRange(0, 5); + range.fontSize = '40'; + expect(t.fontSize).toBe('mixed'); + }); + + test('applying a value to the full span is uniform, not mixed', (ctx) => { + const t = text(ctx, 'Hello Penpot'); + const range = t.getRange(0, t.characters.length); + range.fontSize = '33'; + expect(t.fontSize).toBe('33'); + }); + + test('multi-paragraph content round-trips', (ctx) => { + const t = text(ctx); + t.characters = 'first line\nsecond line'; + expect(t.characters).toBe('first line\nsecond line'); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts new file mode 100644 index 0000000000..1bfc15003e --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts @@ -0,0 +1,482 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { + TokenCatalog, + TokenColor, + TokenSet, + TokenShadow, + TokenType, + TokenTypography, +} from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; + +// Design tokens. +// The token catalog is reached through the local library. Sets/themes/tokens are +// self-provisioned; sets are created active so token references resolve. + +function catalog(ctx: TestContext): TokenCatalog { + return ctx.penpot.library.local.tokens; +} + +function activeSet(ctx: TestContext, name: string): TokenSet { + return catalog(ctx).addSet({ name, active: true }); +} + +// Names must be unique across runs too: sets/themes leak into the file (the +// API has no theme remove and set removal is best-effort), so a plain counter +// collides with leftovers from a previous run. Add a per-run random tag. +const runTag = Math.random().toString(36).slice(2, 8); +let counter = 0; +function unique(prefix: string): string { + counter += 1; + return `${prefix}-${runTag}-${counter}`; +} + +/** Token application and theme/set wiring update the store asynchronously. */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('Tokens', () => { + describe('Catalog', () => { + test('addSet creates a token set', (ctx) => { + const cat = catalog(ctx); + const set = cat.addSet({ name: unique('set'), active: true }); + expect(typeof set.id).toBe('string'); + expect(set.active).toBe(true); + expect(cat.sets.length).toBeGreaterThan(0); + expect(cat.getSetById(set.id)).toBeDefined(); + }); + + test('addTheme creates a token theme', (ctx) => { + const cat = catalog(ctx); + const theme = cat.addTheme({ group: '', name: unique('theme') }); + expect(typeof theme.id).toBe('string'); + expect(cat.themes.length).toBeGreaterThan(0); + expect(cat.getThemeById(theme.id)).toBeDefined(); + }); + }); + + describe('Set', () => { + test('name and active round-trip', (ctx) => { + const set = activeSet(ctx, unique('set')); + const newName = unique('renamed'); + set.name = newName; + expect(set.name).toBe(newName); + set.active = false; + expect(set.active).toBe(false); + set.toggleActive(); + expect(set.active).toBe(true); + }); + + test('addToken adds a token and lists it', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'color', + name: unique('color.'), + value: '#ff0000', + }); + expect(typeof token.id).toBe('string'); + expect(set.tokens.length).toBeGreaterThan(0); + expect(Array.isArray(set.tokensByType)).toBe(true); + expect(set.getTokenById(token.id)).toBeDefined(); + }); + + test('duplicate and remove a set', (ctx) => { + const set = activeSet(ctx, unique('set')); + const dup = set.duplicate(); + expect(dup).not.toBeNull(); + expect(dup.id).not.toBe(set.id); + dup.remove(); + }); + + // Invalid input — addToken must reject bad input. + test('empty token name throws', (ctx) => { + const set = activeSet(ctx, unique('set')); + expect(() => + set.addToken({ type: 'color', name: '', value: '#ff0000' }), + ).toThrow(); + }); + + test('duplicate token name in the same set throws', (ctx) => { + const set = activeSet(ctx, unique('set')); + const name = unique('color.dup'); + set.addToken({ type: 'color', name, value: '#ff0000' }); + expect(() => + set.addToken({ type: 'color', name, value: '#0000ff' }), + ).toThrow(); + }); + + test('opacity token value outside 0..1 throws', (ctx) => { + const set = activeSet(ctx, unique('set')); + expect(() => + set.addToken({ type: 'opacity', name: unique('op.'), value: '2' }), + ).toThrow(); + }); + + test('invalid token type throws', (ctx) => { + const set = activeSet(ctx, unique('set')); + expect(() => + set.addToken({ + type: 'not-a-type' as unknown as TokenType, + name: unique('bad.'), + value: '1', + }), + ).toThrow(); + }); + }); + + describe('Theme', () => { + test('group, name and active round-trip', (ctx) => { + const theme = catalog(ctx).addTheme({ group: '', name: unique('theme') }); + theme.group = 'brand'; + theme.name = 'dark'; + expect(theme.group).toBe('brand'); + expect(theme.name).toBe('dark'); + theme.active = true; + expect(theme.active).toBe(true); + theme.toggleActive(); + }); + + test('addSet and removeSet manage the theme sets', async (ctx) => { + const cat = catalog(ctx); + const theme = cat.addTheme({ group: '', name: unique('theme') }); + const set = cat.addSet({ name: unique('set'), active: false }); + theme.addSet(set); + await sleep(300); + expect(theme.activeSets.length).toBeGreaterThan(0); + theme.removeSet(set); + }); + + test('duplicate and remove a theme', (ctx) => { + const theme = catalog(ctx).addTheme({ group: '', name: unique('theme') }); + const dup = theme.duplicate(); + expect(dup.id).not.toBe(theme.id); + dup.remove(); + }); + }); + + describe('Token', () => { + test('base properties round-trip', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'color', + name: unique('color.'), + value: '#00ff00', + }); + token.description = 'a token'; + expect(token.description).toBe('a token'); + expect(typeof token.id).toBe('string'); + // resolvedValueString resolves against active sets. + expect(token.resolvedValueString).toBeDefined(); + }); + + test('color token exposes type and value', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'color', + name: unique('color.'), + value: '#123456', + }); + expect(token.type).toBe('color'); + expect(token.value).toBe('#123456'); + }); + + test('dimension and number tokens expose resolved values', (ctx) => { + const set = activeSet(ctx, unique('set')); + const dim = set.addToken({ + type: 'dimension', + name: unique('dim.'), + value: '16', + }); + const num = set.addToken({ + type: 'rotation', + name: unique('rot.'), + value: '2', + }); + expect(dim.type).toBe('dimension'); + expect(num.type).toBe('rotation'); + if (dim.type === 'dimension') { + expect(dim.resolvedValue).toBeCloseTo(16, 0); + } + }); + + test('applyToShapes applies a token to a shape', async (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'borderRadius', + name: unique('radius.'), + value: '8', + }); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + token.applyToShapes([rect]); + await sleep(300); + expect(Object.keys(rect.tokens).length).toBeGreaterThan(0); + }); + + test('applyToSelected applies a token to the selection', async (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'opacity', + name: unique('opacity.'), + value: '0.5', + }); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + ctx.penpot.selection = [rect]; + token.applyToSelected(); + await sleep(300); + expect(Object.keys(rect.tokens).length).toBeGreaterThan(0); + }); + + test('applyToken applies a token through the shape', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'borderRadius', + name: unique('radius.'), + value: '12', + }); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.applyToken(token); + expect(rect.tokens).toBeDefined(); + }); + + test('duplicate and remove a token', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'color', + name: unique('color.'), + value: '#abcdef', + }); + const dup = token.duplicate(); + expect(dup.id).not.toBe(token.id); + dup.remove(); + }); + + // Reference resolution — a token referencing another resolves transitively. + test('a token referencing another token resolves transitively', (ctx) => { + const set = activeSet(ctx, unique('set')); + const baseName = unique('dim.base'); + set.addToken({ type: 'dimension', name: baseName, value: '16' }); + const ref = set.addToken({ + type: 'dimension', + name: unique('dim.ref'), + value: `{${baseName}}`, + }); + if (ref.type === 'dimension') { + expect(ref.resolvedValue).toBeCloseTo(16, 0); + } + }); + }); +}); + +// Every token type and the composite value types. +describe('Token types', () => { + const simpleCases: [TokenType, string, string][] = [ + ['borderRadius', '8', '12'], + ['color', '#ff0000', '#00ff00'], + ['dimension', '16', '24'], + ['fontFamilies', 'Arial', 'Helvetica'], + ['fontSizes', '14', '18'], + ['fontWeights', '700', '400'], + ['letterSpacing', '2', '3'], + ['number', '3', '4'], + ['opacity', '0.5', '0.8'], + ['rotation', '45', '90'], + ['sizing', '100', '120'], + ['spacing', '8', '12'], + ['borderWidth', '2', '3'], + ['textCase', 'uppercase', 'lowercase'], + ['textDecoration', 'underline', 'none'], + ]; + + for (const [type, value, value2] of simpleCases) { + test(`${type} token exposes type, value and resolvedValue`, (ctx) => { + const set = activeSet(ctx, unique('set')); + // Cast to a concrete variant for property access; the recorder attributes + // members to the real runtime type via the `type` discriminant. + const token = set.addToken({ + type, + name: unique(`${type}.`), + value, + }) as TokenColor; + expect(typeof token.type).toBe('string'); + void token.value; + token.value = value2; + token.name = unique('renamed.'); + expect(typeof token.name).toBe('string'); + // Record the resolvedValue (get) target for every type. fontFamilies + // returns the wrong shape (see the dedicated red test below), but reading + // it no longer throws, so a plain read is enough here. + void token.resolvedValue; + }); + } + + // A fontFamilies token's `resolvedValue` is the resolved family list + // (`string[] | undefined`, e.g. ['Arial']). The binding used to leak the raw + // tokenscript list symbol; it now returns the documented array. + test('fontFamilies token resolvedValue is the family list', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'fontFamilies', + name: unique('fontFamilies.'), + value: 'Arial', + }); + const resolved = token.resolvedValue; + expect(Array.isArray(resolved)).toBe(true); + expect(resolved as unknown as string[]).toContain('Arial'); + }); + + test('shadow token exposes its composite value', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'shadow', + name: unique('shadow.'), + value: { + color: '#000000', + inset: 'false', + offsetX: '1', + offsetY: '2', + spread: '0', + blur: '4', + }, + }) as TokenShadow; + expect(token.type).toBe('shadow'); + + // Round-trip the value (covers TokenShadow.value get + set) without changing + // it — the setter validates against the token's value schema, so assigning + // back exactly what the getter returned is guaranteed valid. + const v = token.value; + token.value = v; + + if (typeof v !== 'string' && v.length > 0) { + const sv = v[0]; + void sv.color; + void sv.inset; + void sv.offsetX; + void sv.offsetY; + void sv.spread; + void sv.blur; + sv.color = '#111111'; + sv.inset = 'true'; + sv.offsetX = '3'; + sv.offsetY = '4'; + sv.spread = '1'; + sv.blur = '5'; + } + + // resolvedValue resolves the composite into a TokenShadowValue[]; each entry + // exposes the shadow members with their resolved (unit-converted) values. + const rv = token.resolvedValue; + expect(Array.isArray(rv)).toBe(true); + expect(rv).toBeDefined(); + if (rv && rv.length > 0) { + const s = rv[0]; + expect(s.color).toBe('#000000'); + expect(s.inset).toBe(false); + expect(s.offsetX).toBeCloseTo(1, 0); + expect(s.offsetY).toBeCloseTo(2, 0); + expect(s.spread).toBeCloseTo(0, 0); + expect(s.blur).toBeCloseTo(4, 0); + // Exercise the writable members (records the set targets). + s.color = '#222222'; + s.inset = true; + s.offsetX = 9; + s.offsetY = 8; + s.spread = 2; + s.blur = 6; + } + }); + + test('typography token exposes its composite value', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'typography', + name: unique('typo.'), + value: { + letterSpacing: '1', + fontFamilies: 'Arial', + fontSizes: '14', + fontWeight: '400', + lineHeight: '1.2', + textCase: 'none', + textDecoration: 'none', + }, + }) as TokenTypography; + expect(token.type).toBe('typography'); + + const v = token.value; + if (typeof v !== 'string') { + void v.letterSpacing; + void v.fontFamilies; + void v.fontSizes; + void v.fontWeight; + void v.lineHeight; + void v.textCase; + void v.textDecoration; + v.letterSpacing = '2'; + v.fontFamilies = 'Helvetica'; + v.fontSizes = '16'; + v.fontWeight = '700'; + v.lineHeight = '1.5'; + v.textCase = 'uppercase'; + v.textDecoration = 'underline'; + } + + // resolvedValue resolves the composite into a TokenTypographyValue[]; each + // entry exposes the typography members with their resolved (unit-converted) + // values. + const rv = token.resolvedValue; + expect(Array.isArray(rv)).toBe(true); + expect(rv).toBeDefined(); + if (rv && rv.length > 0) { + const t = rv[0]; + expect(t.fontSizes).toBeCloseTo(14, 0); + expect(t.letterSpacing).toBeCloseTo(1, 0); + expect(t.lineHeight).toBeCloseTo(1.2, 1); + expect(Array.isArray(t.fontFamilies)).toBe(true); + expect(t.fontFamilies).toContain('Arial'); + expect(typeof t.fontWeights).toBe('string'); + expect(t.textCase).toBe('none'); + expect(t.textDecoration).toBe('none'); + // Exercise the writable members (records the set targets). + t.letterSpacing = 3; + t.fontFamilies = ['Helvetica']; + t.fontSizes = 18; + t.fontWeights = '500'; + t.lineHeight = 2; + t.textCase = 'lowercase'; + t.textDecoration = 'line-through'; + } + + token.value = { + letterSpacing: '2', + fontFamilies: 'Helvetica', + fontSizes: '16', + fontWeight: '700', + lineHeight: '1.5', + textCase: 'uppercase', + textDecoration: 'underline', + }; + }); + + test('a token set can be removed', (ctx) => { + const cat = catalog(ctx); + const set = cat.addSet({ name: unique('set'), active: true }); + set.remove(); + }); + + test('theme externalId, activeSets and removeSet', (ctx) => { + const cat = catalog(ctx); + const theme = cat.addTheme({ group: '', name: unique('theme') }); + const set = cat.addSet({ name: unique('set'), active: false }); + void theme.externalId; + // theme.activeSets has no runtime setter (declared writable) — API bug. + void theme.activeSets; + theme.addSet(set); + theme.removeSet(set); + expect(typeof theme.id).toBe('string'); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts new file mode 100644 index 0000000000..adcbabd52c --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts @@ -0,0 +1,316 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { TestContext } from '../framework/types'; +import { PNG_1X1 } from './fixtures'; + +// Value-object property setters. +// Fills/strokes/gradients are returned as live proxies (their setters persist); +// shadows/blur/colors are returned as plain snapshots (setting records the +// member and round-trips on the returned object). Either way every writable +// member is exercised by reading the value object and setting each property. + +function rect(ctx: TestContext) { + const r = ctx.penpot.createRectangle(); + ctx.board.appendChild(r); + return r; +} + +describe('Value objects', () => { + describe('Fill', () => { + test('solid fill members round-trip', (ctx) => { + const r = rect(ctx); + r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + const fills = r.fills; + if (Array.isArray(fills)) { + const fill = fills[0]; + fill.fillColor = '#00ff00'; + fill.fillOpacity = 0.5; + expect(fill.fillColor).toBe('#00ff00'); + expect(fill.fillOpacity).toBeCloseTo(0.5, 2); + } + }); + + test('assigning a gradient on a live solid fill switches it', (ctx) => { + const r = rect(ctx); + r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + const fills = r.fills; + if (Array.isArray(fills)) { + fills[0].fillColorGradient = { + type: 'linear', + startX: 0, + startY: 0, + endX: 1, + endY: 1, + width: 1, + stops: [ + { color: '#ff0000', opacity: 1, offset: 0 }, + { color: '#0000ff', opacity: 1, offset: 1 }, + ], + }; + expect(fills[0].fillColorGradient).toBeDefined(); + } + }); + + test('fill color reference members round-trip', (ctx) => { + const r = rect(ctx); + r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + const fills = r.fills; + if (Array.isArray(fills)) { + const fill = fills[0]; + fill.fillColorRefId = '00000000-0000-0000-0000-000000000001'; + fill.fillColorRefFile = '00000000-0000-0000-0000-000000000002'; + expect(fill.fillColorRefId).toBe( + '00000000-0000-0000-0000-000000000001', + ); + expect(fill.fillColorRefFile).toBe( + '00000000-0000-0000-0000-000000000002', + ); + } + }); + + test('fill gradient members round-trip', (ctx) => { + const r = rect(ctx); + r.fills = [ + { + fillColorGradient: { + type: 'linear', + startX: 0, + startY: 0, + endX: 1, + endY: 1, + width: 1, + stops: [ + { color: '#ff0000', opacity: 1, offset: 0 }, + { color: '#0000ff', opacity: 1, offset: 1 }, + ], + }, + }, + ]; + const fills = r.fills; + if (Array.isArray(fills)) { + const gradient = fills[0].fillColorGradient; + expect(gradient).toBeDefined(); + if (gradient) { + gradient.type = 'radial'; + gradient.startX = 0.2; + gradient.startY = 0.3; + gradient.endX = 0.8; + gradient.endY = 0.9; + gradient.width = 0.5; + expect(gradient.type).toBe('radial'); + expect(gradient.startX).toBeCloseTo(0.2, 2); + expect(gradient.endY).toBeCloseTo(0.9, 2); + expect(gradient.width).toBeCloseTo(0.5, 2); + expect(gradient.stops.length).toBeGreaterThan(0); + } + } + }); + }); + + describe('Stroke', () => { + test('stroke members round-trip', (ctx) => { + const r = rect(ctx); + r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }]; + const stroke = r.strokes[0]; + stroke.strokeColor = '#112233'; + stroke.strokeOpacity = 0.7; + stroke.strokeStyle = 'dotted'; + stroke.strokeWidth = 4; + stroke.strokeAlignment = 'inner'; + stroke.strokeCapStart = 'round'; + stroke.strokeCapEnd = 'square'; + expect(stroke.strokeColor).toBe('#112233'); + expect(stroke.strokeOpacity).toBeCloseTo(0.7, 2); + expect(stroke.strokeStyle).toBe('dotted'); + expect(stroke.strokeWidth).toBeCloseTo(4, 0); + expect(stroke.strokeAlignment).toBe('inner'); + expect(stroke.strokeCapStart).toBe('round'); + expect(stroke.strokeCapEnd).toBe('square'); + }); + + test('stroke reference and gradient members round-trip', (ctx) => { + const r = rect(ctx); + r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }]; + const stroke = r.strokes[0]; + stroke.strokeColorRefId = '00000000-0000-0000-0000-000000000001'; + stroke.strokeColorRefFile = '00000000-0000-0000-0000-000000000002'; + expect(stroke.strokeColorRefId).toBe( + '00000000-0000-0000-0000-000000000001', + ); + expect(stroke.strokeColorRefFile).toBe( + '00000000-0000-0000-0000-000000000002', + ); + + stroke.strokeColorGradient = { + type: 'linear', + startX: 0, + startY: 0, + endX: 1, + endY: 1, + width: 1, + stops: [ + { color: '#ff0000', opacity: 1, offset: 0 }, + { color: '#0000ff', opacity: 1, offset: 1 }, + ], + }; + expect(stroke.strokeColorGradient).toBeDefined(); + }); + }); + + describe('Shadow', () => { + test('shadow members round-trip on the returned shadow', (ctx) => { + const r = rect(ctx); + r.shadows = [ + { + style: 'drop-shadow', + offsetX: 1, + offsetY: 1, + blur: 2, + spread: 0, + hidden: false, + color: { color: '#000000', opacity: 1 }, + }, + ]; + const shadow = r.shadows[0]; + shadow.style = 'inner-shadow'; + shadow.offsetX = 5; + shadow.offsetY = 6; + shadow.blur = 7; + shadow.spread = 2; + shadow.hidden = true; + shadow.id = '00000000-0000-0000-0000-000000000003'; + expect(shadow.style).toBe('inner-shadow'); + expect(shadow.offsetX).toBeCloseTo(5, 0); + expect(shadow.offsetY).toBeCloseTo(6, 0); + expect(shadow.blur).toBeCloseTo(7, 0); + expect(shadow.spread).toBeCloseTo(2, 0); + expect(shadow.hidden).toBe(true); + }); + + // Skipped under MOCK_BACKEND: exercises uploadMediaData, which needs real + // backend media processing (ImageMagick) a mock can't reproduce. + test.skipIfMocked('shadow color members round-trip', async (ctx) => { + const image = await ctx.penpot.uploadMediaData( + 'shadow-color-image', + PNG_1X1, + 'image/png', + ); + const r = rect(ctx); + r.shadows = [ + { + style: 'drop-shadow', + offsetX: 1, + offsetY: 1, + blur: 2, + spread: 0, + hidden: false, + color: { color: '#000000', opacity: 1 }, + }, + ]; + const color = r.shadows[0].color; + expect(color).toBeDefined(); + if (color) { + color.color = '#abcdef'; + color.opacity = 0.4; + color.id = '00000000-0000-0000-0000-000000000004'; + color.name = 'shadow-color'; + color.path = 'group'; + color.refId = '00000000-0000-0000-0000-000000000005'; + color.refFile = '00000000-0000-0000-0000-000000000006'; + color.fileId = '00000000-0000-0000-0000-000000000007'; + // Color is a plain snapshot, so image set/read round-trips on it like + // the other members. + color.image = image; + expect(color.color).toBe('#abcdef'); + expect(color.opacity).toBeCloseTo(0.4, 2); + expect(color.name).toBe('shadow-color'); + expect(color.path).toBe('group'); + expect(color.image).toBeDefined(); + } + }); + }); + + describe('Blur', () => { + test('blur members round-trip on the returned blur', (ctx) => { + const r = rect(ctx); + r.blur = { value: 5 }; + const blur = r.blur; + expect(blur).toBeDefined(); + if (blur) { + blur.value = 12; + blur.hidden = true; + blur.id = '00000000-0000-0000-0000-000000000008'; + expect(blur.value).toBeCloseTo(12, 0); + expect(blur.hidden).toBe(true); + expect(blur.id).toBe('00000000-0000-0000-0000-000000000008'); + } + }); + + test('negative blur value is accepted (currently unvalidated)', (ctx) => { + // The blur setter does not reject a negative value; this pins the current + // lenient behaviour (a candidate for future hardening). + const r = rect(ctx); + expect(() => { + r.blur = { value: -5 }; + }).not.toThrow(); + }); + }); + + // --------------------------------------------------------------------------- + // Edge cases — gradients. + // --------------------------------------------------------------------------- + describe('Gradient — edge cases', () => { + test('a gradient stop offset outside 0..1 throws', (ctx) => { + const r = rect(ctx); + expect(() => { + r.fills = [ + { + fillColorGradient: { + type: 'linear', + startX: 0, + startY: 0, + endX: 1, + endY: 1, + width: 1, + stops: [ + { color: '#ff0000', opacity: 1, offset: 0 }, + { color: '#0000ff', opacity: 1, offset: 1.5 }, + ], + }, + }, + ]; + }).toThrow(); + }); + + test('a gradient with many stops at boundary offsets round-trips', (ctx) => { + const r = rect(ctx); + r.fills = [ + { + fillColorGradient: { + type: 'linear', + startX: 0, + startY: 0, + endX: 1, + endY: 1, + width: 1, + stops: [ + { color: '#ff0000', opacity: 1, offset: 0 }, + { color: '#00ff00', opacity: 1, offset: 0.5 }, + { color: '#0000ff', opacity: 1, offset: 1 }, + ], + }, + }, + ]; + const fills = r.fills; + if (Array.isArray(fills)) { + const gradient = fills[0].fillColorGradient; + expect(gradient).toBeDefined(); + if (gradient) { + expect(gradient.stops.length).toBe(3); + expect(gradient.stops[0].offset).toBeCloseTo(0, 2); + expect(gradient.stops[2].offset).toBeCloseTo(1, 2); + } + } + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts new file mode 100644 index 0000000000..6afb75f495 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts @@ -0,0 +1,201 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { Board, LibraryVariantComponent } from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; + +// Variants. +// A standard component is created and transformed into a variant; the resulting +// VariantComponent exposes the Variants interface. Variant containers are also +// built from main-instance boards via createVariantFromComponents. + +function componentMain(ctx: TestContext): Board { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const comp = ctx.penpot.library.local.createComponent([rect]); + return comp.mainInstance() as Board; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// transformInVariant is async, so `variants` is only populated after a tick. +async function variantComponent( + ctx: TestContext, +): Promise { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const comp = ctx.penpot.library.local.createComponent([rect]); + comp.transformInVariant(); + await sleep(400); + return comp as LibraryVariantComponent; +} + +describe('Variants', () => { + test('transformInVariant turns a component into a variant', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const comp = ctx.penpot.library.local.createComponent([rect]); + comp.transformInVariant(); + expect(comp.isVariant()).toBe(true); + }); + + test('createVariantFromComponents builds a variant container', (ctx) => { + const mainA = componentMain(ctx); + const mainB = componentMain(ctx); + const container = ctx.penpot.createVariantFromComponents([mainA, mainB]); + expect(container).toBeDefined(); + expect(container.isVariantContainer()).toBe(true); + expect(container.variants).not.toBeNull(); + }); + + test('combineAsVariants builds a variant container', (ctx) => { + const mainA = componentMain(ctx); + const mainB = componentMain(ctx); + const container = mainA.combineAsVariants([mainB.id]); + expect(container).toBeDefined(); + expect(container.isVariantContainer()).toBe(true); + }); + + test('variant component exposes variant props and Variants', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const comp = ctx.penpot.library.local.createComponent([rect]); + comp.transformInVariant(); + expect(comp.isVariant()).toBe(true); + + const variantComp = comp as LibraryVariantComponent; + expect(variantComp.variants).not.toBeNull(); + expect(typeof variantComp.variantProps).toBe('object'); + + const variants = variantComp.variants; + if (variants) { + expect(typeof variants.id).toBe('string'); + expect(typeof variants.libraryId).toBe('string'); + expect(Array.isArray(variants.properties)).toBe(true); + expect(Array.isArray(variants.variantComponents())).toBe(true); + } + }); + + test('variant property can be added and read', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const comp = ctx.penpot.library.local.createComponent([rect]); + comp.transformInVariant(); + + const variants = (comp as LibraryVariantComponent).variants; + expect(variants).not.toBeNull(); + if (variants) { + const before = variants.properties.length; + variants.addProperty(); + expect(variants.properties.length).toBe(before + 1); + variants.currentValues(variants.properties[0]); + } + }); + + test('variant component exposes the Variants interface', async (ctx) => { + const vc = await variantComponent(ctx); + expect(vc.isVariant()).toBe(true); + expect(typeof vc.variantProps).toBe('object'); + void vc.variantError; // get only (no runtime setter) + + const v = vc.variants; + expect(v).not.toBeNull(); + if (v) { + expect(typeof v.id).toBe('string'); + expect(typeof v.libraryId).toBe('string'); + expect(Array.isArray(v.properties)).toBe(true); + expect(Array.isArray(v.variantComponents())).toBe(true); + if (v.properties.length > 0) { + expect(Array.isArray(v.currentValues(v.properties[0]))).toBe(true); + } + } + }); + + test('variant properties can be added, renamed and removed', async (ctx) => { + const vc = await variantComponent(ctx); + const v = vc.variants; + expect(v).not.toBeNull(); + if (v) { + v.addProperty(); + await sleep(300); + const count = v.properties.length; + expect(count).toBeGreaterThan(0); + + v.renameProperty(0, 'Size'); + await sleep(300); + v.removeProperty(count - 1); + await sleep(300); + } + }); + + test('addVariant and setVariantProperty mutate the variant', async (ctx) => { + const vc = await variantComponent(ctx); + const v = vc.variants; + expect(v).not.toBeNull(); + if (v) { + const before = v.variantComponents().length; + vc.addVariant(); + await sleep(300); + expect(v.variantComponents().length).toBeGreaterThan(before); + + if (v.properties.length > 0) { + vc.setVariantProperty(0, 'large'); + await sleep(300); + } + } + }); + + test('switchVariant on a variant instance does not throw', async (ctx) => { + const vc = await variantComponent(ctx); + // Add a second variant so there is another value to switch to. + vc.addVariant(); + await sleep(300); + + const instance = vc.instance(); + ctx.board.appendChild(instance); + // Valid args (nat-int pos, string value): switches to the nearest variant + // with that value at the property position, or no-ops — never throws. + expect(() => instance.switchVariant(0, 'large')).not.toThrow(); + }); + + test('utils.types.isVariantComponent identifies a variant component', async (ctx) => { + const vc = await variantComponent(ctx); + expect(ctx.penpot.utils.types.isVariantComponent(vc)).toBeTruthy(); + }); + + // --------------------------------------------------------------------------- + // Edge cases. Out-of-bounds property positions and degenerate + // container input should be rejected. + // --------------------------------------------------------------------------- + // createVariantFromComponents([]) is rejected (validated), but the + // positional property ops do not bounds-check `pos`; an out-of-range index + // is a no-op rather than an error. These pin the current behaviour + // (bounds-checking the position is a candidate for future hardening). + test('createVariantFromComponents of an empty array throws', (ctx) => { + expect(() => ctx.penpot.createVariantFromComponents([])).toThrow(); + }); + + test('removeProperty out of bounds is a no-op (not rejected)', async (ctx) => { + const vc = await variantComponent(ctx); + const v = vc.variants; + expect(v).not.toBeNull(); + if (v) { + expect(() => v.removeProperty(999)).not.toThrow(); + } + }); + + test('renameProperty out of bounds is a no-op (not rejected)', async (ctx) => { + const vc = await variantComponent(ctx); + const v = vc.variants; + expect(v).not.toBeNull(); + if (v) { + expect(() => v.renameProperty(999, 'Nope')).not.toThrow(); + } + }); + + test('setVariantProperty out of bounds is a no-op (not rejected)', async (ctx) => { + const vc = await variantComponent(ctx); + expect(() => vc.setVariantProperty(999, 'large')).not.toThrow(); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts new file mode 100644 index 0000000000..0745629625 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts @@ -0,0 +1,136 @@ +import { expect } from '../framework/expect'; +import { describe, test } from '../framework/registry'; + +// Viewport and guides (ruler guides + board guides). + +describe('Viewport', () => { + test('zoom is readable and writable', (ctx) => { + const vp = ctx.penpot.viewport; + expect(typeof vp.zoom).toBe('number'); + vp.zoom = 2; + expect(vp.zoom).toBeCloseTo(2, 1); + vp.zoomReset(); + }); + + test('center is readable and writable', (ctx) => { + const vp = ctx.penpot.viewport; + vp.center = { x: 100, y: 200 }; + expect(vp.center.x).toBeCloseTo(100, 0); + expect(vp.center.y).toBeCloseTo(200, 0); + }); + + test('bounds are readable', (ctx) => { + const vp = ctx.penpot.viewport; + expect(typeof vp.bounds.width).toBe('number'); + expect(typeof vp.bounds.height).toBe('number'); + }); + + test('zoom helpers run without error', (ctx) => { + const vp = ctx.penpot.viewport; + vp.zoomToFitAll(); + vp.zoomIntoView([ctx.board]); + vp.zoomReset(); + }); +}); + +describe('Ruler guides', () => { + test('board addRulerGuide returns a guide', (ctx) => { + const guide = ctx.board.addRulerGuide('vertical', 50); + expect(guide.orientation).toBe('vertical'); + // A board-attached ruler guide exposes its board. + void guide.board; + }); + + test('board ruler guide can be reassigned to another board', (ctx) => { + const guide = ctx.board.addRulerGuide('vertical', 50); + const other = ctx.penpot.createBoard(); + ctx.board.appendChild(other); + guide.board = other; + expect(guide.board && guide.board.id).toBe(other.id); + }); + + test('board ruler guide position round-trips', (ctx) => { + const guide = ctx.board.addRulerGuide('vertical', 50); + guide.position = 60; + expect(guide.position).toBeCloseTo(60, 0); + }); + + test('board lists its ruler guides', (ctx) => { + ctx.board.addRulerGuide('horizontal', 30); + expect(ctx.board.rulerGuides.length).toBeGreaterThan(0); + }); + + test('board removeRulerGuide removes a guide', (ctx) => { + const guide = ctx.board.addRulerGuide('vertical', 50); + ctx.board.removeRulerGuide(guide); + expect(ctx.board.rulerGuides.length).toBe(0); + }); + + test('page ruler guides can be added and removed', (ctx) => { + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + const guide = page.addRulerGuide('horizontal', 120); + expect(guide.orientation).toBe('horizontal'); + expect(page.rulerGuides.length).toBeGreaterThan(0); + page.removeRulerGuide(guide); + } + }); +}); + +describe('Board guides', () => { + test('column, row and square guides round-trip', (ctx) => { + ctx.board.guides = [ + { + type: 'column', + display: true, + params: { + color: { color: '#ff0000', opacity: 1 }, + type: 'stretch', + size: 12, + gutter: 8, + }, + }, + { + type: 'row', + display: true, + params: { + color: { color: '#00ff00', opacity: 1 }, + type: 'stretch', + size: 12, + gutter: 8, + }, + }, + { + type: 'square', + display: true, + params: { color: { color: '#0000ff', opacity: 1 }, size: 16 }, + }, + ]; + + const guides = ctx.board.guides; + expect(guides).toHaveLength(3); + expect(guides[0].type).toBe('column'); + expect(guides[1].type).toBe('row'); + expect(guides[2].type).toBe('square'); + expect(guides[0].display).toBe(true); + expect(guides[0].params.color.color).toBe('#ff0000'); + + // Read every guide's display + params fields so the per-type guide and + // params getters are all exercised. + for (const g of guides) { + void g.display; + if (g.type === 'column' || g.type === 'row') { + void g.params.color; + void g.params.type; + void g.params.size; + void g.params.gutter; + void g.params.margin; + void g.params.itemLength; + } else if (g.type === 'square') { + void g.params.color; + void g.params.size; + } + } + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/ui.css b/plugins/apps/plugin-api-test-suite/src/ui.css new file mode 100644 index 0000000000..104ac64c90 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/ui.css @@ -0,0 +1,334 @@ +:root { + font-family: var(--font-family, sans-serif); + font-size: 12px; +} + +body { + margin: 0; +} + +.wrapper { + display: flex; + flex-direction: column; + gap: var(--spacing-12, 12px); + padding: var(--spacing-12, 12px); + min-height: 100vh; + box-sizing: border-box; +} + +.header { + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); +} + +.title { + font-size: 14px; + margin: 0; + color: var(--foreground-primary); +} + +.summary { + margin: 0; + color: var(--foreground-secondary); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8, 8px); +} + +.toolbar .reload { + margin-inline-start: auto; +} + +.toolbar-status { + flex-basis: 100%; + color: var(--foreground-secondary); +} + +.groups { + display: flex; + flex-direction: column; + gap: var(--spacing-8, 8px); +} + +.group { + border-radius: var(--spacing-8, 8px); + background-color: var(--background-secondary); +} + +.group-summary { + display: flex; + align-items: center; + gap: var(--spacing-8, 8px); + padding: var(--spacing-8, 8px); + cursor: pointer; + user-select: none; +} + +.group-name { + color: var(--foreground-primary); +} + +.group-counts { + margin-inline-start: auto; + font-variant-numeric: tabular-nums; +} + +.count-pass { + color: #2d9d78; +} + +.count-fail { + color: #e65244; +} + +.count-sep, +.count-total { + color: var(--foreground-secondary); +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--spacing-4, 4px); +} + +.icon { + display: block; +} + +.reload.is-loading .icon { + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Small rotating ring reused wherever something is in progress. */ +.spinner { + display: inline-block; + flex: 0 0 auto; + width: 10px; + height: 10px; + border: 1.5px solid var(--background-quaternary); + border-top-color: #6911d4; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.running-badge { + display: inline-flex; + align-items: center; + gap: var(--spacing-4, 4px); + color: #6911d4; +} + +.group .test-list { + padding: 0 var(--spacing-8, 8px) var(--spacing-8, 8px); +} + +.test-list { + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); + list-style: none; + margin: 0; + padding: 0; +} + +.test-row { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: var(--spacing-8, 8px); + padding: var(--spacing-8, 8px); + border-radius: var(--spacing-8, 8px); + background-color: var(--background-tertiary); +} + +.test-main { + display: flex; + align-items: center; + gap: var(--spacing-8, 8px); + cursor: pointer; + min-width: 0; +} + +.test-name { + color: var(--foreground-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.test-duration { + display: inline-flex; + align-items: center; + gap: var(--spacing-4, 4px); + color: var(--foreground-secondary); + font-variant-numeric: tabular-nums; +} + +.test-duration.running { + color: #6911d4; +} + +/* Tint the whole row while its test runs so it stands out at a glance. */ +.test-row.status-running { + background-color: color-mix(in srgb, #6911d4 12%, var(--background-tertiary)); +} + +.status-dot { + flex: 0 0 auto; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--foreground-secondary); +} + +.dot-pending { + background-color: #8f9da3; +} +.dot-running { + background-color: #6911d4; + animation: pulse 1s ease-in-out infinite; +} +.dot-pass { + background-color: #2d9d78; +} +.dot-fail { + background-color: #e65244; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +.test-error { + grid-column: 1 / -1; + margin: 0; + padding: var(--spacing-8, 8px); + border-radius: var(--spacing-4, 4px); + background-color: var(--background-primary); + color: #e65244; + white-space: pre-wrap; + word-break: break-word; + font-family: monospace; +} + +.coverage { + display: flex; + flex-direction: column; + gap: var(--spacing-8, 8px); + border-top: 1px solid var(--background-quaternary); + padding-top: var(--spacing-8, 8px); + color: var(--foreground-secondary); +} + +.coverage-empty { + margin: 0; +} + +.coverage-header { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.coverage-title { + color: var(--foreground-primary); + font-weight: 600; +} + +.coverage-value { + color: var(--foreground-secondary); + font-variant-numeric: tabular-nums; +} + +.progress-track { + width: 100%; + height: 8px; + border-radius: 999px; + background-color: var(--background-quaternary); + overflow: hidden; +} + +.progress-track { + position: relative; +} + +.progress-fill { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + height: 100%; + border-radius: inherit; + background-color: #2d9d78; + transition: width 0.3s ease; +} + +/* The static segment sits behind the recorded fill, in a distinct blue. */ +.progress-fill.static { + background-color: #4a8fe7; +} + +.coverage summary { + cursor: pointer; + color: var(--foreground-primary); +} + +.coverage-body { + display: flex; + flex-direction: column; + gap: var(--spacing-8, 8px); + margin-top: var(--spacing-8, 8px); +} + +.coverage-iface { + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); +} + +.coverage-iface strong { + color: var(--foreground-primary); +} + +.coverage-members { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4, 4px); +} + +.coverage-member { + padding: 1px 6px; + border-radius: var(--spacing-4, 4px); + background-color: var(--background-tertiary); + word-break: break-word; +} + +.coverage-member.covered { + color: #2d9d78; +} + +.coverage-member.static { + color: #4a8fe7; +} + +.coverage-member.uncovered { + color: var(--foreground-secondary); +} diff --git a/plugins/apps/plugin-api-test-suite/src/ui.ts b/plugins/apps/plugin-api-test-suite/src/ui.ts new file mode 100644 index 0000000000..64dc79b486 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/ui.ts @@ -0,0 +1,558 @@ +import 'plugins-styles/lib/styles.css'; +import './ui.css'; + +import type { PluginToUIMessage, UIToPluginMessage } from './model'; +import type { + CoverageReport, + TestMeta, + TestResult, + TestStatus, +} from './framework/types'; + +const root = document.getElementById('app') as HTMLElement; + +let tests: TestMeta[] = []; +const results = new Map(); +const selected = new Set(); +const expandedGroups = new Set(); +let running = false; +let reloading = false; +let statusText = ''; + +/** Groups tests by their `group`, preserving first-seen order. */ +function groupTests(): { name: string; tests: TestMeta[] }[] { + const order: string[] = []; + const byGroup = new Map(); + for (const test of tests) { + let bucket = byGroup.get(test.group); + if (!bucket) { + bucket = []; + byGroup.set(test.group, bucket); + order.push(test.group); + } + bucket.push(test); + } + return order.map((name) => ({ name, tests: byGroup.get(name)! })); +} + +function applyTheme(theme: string | null) { + document.documentElement.setAttribute( + 'data-theme', + theme === 'light' ? 'light' : 'dark', + ); +} + +function sendToPlugin(message: UIToPluginMessage) { + // `'*'` is intentional: the plugin host controls this iframe's parent and the + // exact embedding origin isn't known ahead of time. Standard for Penpot + // plugin iframes; nothing sensitive crosses this channel. + parent.postMessage(message, '*'); +} + +/** + * Rolls a group's leaf statuses up into a single status for its header dot: + * running if any test is running, otherwise failed if any failed, otherwise + * passed only when every test passed, and pending until then. + */ +function aggregateStatus(statuses: TestStatus[]): TestStatus { + if (statuses.some((s) => s === 'running')) return 'running'; + if (statuses.some((s) => s === 'fail')) return 'fail'; + if (statuses.length > 0 && statuses.every((s) => s === 'pass')) return 'pass'; + return 'pending'; +} + +function statusLabel(status: TestStatus): string { + switch (status) { + case 'pass': + return 'Passed'; + case 'fail': + return 'Failed'; + case 'running': + return 'Running…'; + default: + return 'Not run'; + } +} + +function el( + tag: K, + props: Partial = {}, + children: (Node | string)[] = [], +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + Object.assign(node, props); + for (const child of children) { + node.append(child); + } + return node; +} + +function svgIcon(paths: string[], fill: boolean): SVGSVGElement { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('width', '12'); + svg.setAttribute('height', '12'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('icon'); + for (const d of paths) { + const path = document.createElementNS(ns, 'path'); + path.setAttribute('d', d); + if (fill) { + path.setAttribute('fill', 'currentColor'); + } else { + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', 'currentColor'); + path.setAttribute('stroke-width', '1.5'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + } + svg.append(path); + } + return svg; +} + +/** A small triangular "play" icon used on run buttons. */ +function playIcon(): SVGSVGElement { + return svgIcon(['M4 2.5v11l9-5.5z'], true); +} + +/** A spinning ring, shown wherever something is in progress. */ +function spinner(): HTMLElement { + return el('span', { className: 'spinner', ariaLabel: 'In progress' }); +} + +/** A circular-arrow "reload" icon. */ +function reloadIcon(): SVGSVGElement { + return svgIcon(['M13 8a5 5 0 1 1-1.46-3.54', 'M13 2.5v3h-3'], false); +} + +function render() { + root.replaceChildren( + renderHeader(), + renderToolbar(), + renderList(), + renderCoverage(), + ); +} + +function renderHeader(): HTMLElement { + const passed = [...results.values()].filter( + (r) => r.status === 'pass', + ).length; + const failed = [...results.values()].filter( + (r) => r.status === 'fail', + ).length; + const summary = el('p', { className: 'summary' }, [ + `${tests.length} tests · ${passed} passed · ${failed} failed`, + ]); + if (running) { + summary.append( + ' · ', + el('span', { className: 'running-badge' }, [spinner(), 'Running…']), + ); + } + + return el('header', { className: 'header' }, [ + el('h1', { className: 'title', textContent: 'Plugin API Test Suite' }), + summary, + ]); +} + +function renderToolbar(): HTMLElement { + const runAll = el('button', { + textContent: 'Run all', + disabled: running || tests.length === 0, + }); + runAll.dataset.appearance = 'primary'; + runAll.addEventListener('click', () => run('all')); + + const runSelected = el('button', { + textContent: 'Run selected', + disabled: running || selected.size === 0, + }); + runSelected.dataset.appearance = 'secondary'; + runSelected.addEventListener('click', () => run([...selected])); + + const reload = el('button', { + className: `icon-button reload${reloading ? ' is-loading' : ''}`, + title: reloading + ? 'Reloading tests…' + : 'Reload: fetch and apply edited tests without reopening the plugin', + ariaLabel: 'Reload tests', + disabled: running || reloading, + }); + reload.dataset.appearance = 'secondary'; + reload.append(reloadIcon()); + reload.addEventListener('click', () => reloadTests()); + + const toolbar = el('div', { className: 'toolbar' }, [ + runAll, + runSelected, + reload, + ]); + + if (statusText) { + toolbar.append( + el('span', { className: 'toolbar-status', textContent: statusText }), + ); + } + + return toolbar; +} + +function renderRow(test: TestMeta): HTMLElement { + const result = results.get(test.id); + const status = result?.status ?? 'pending'; + + const checkbox = el('input', { + type: 'checkbox', + className: 'checkbox-input', + checked: selected.has(test.id), + disabled: running, + }); + checkbox.addEventListener('change', () => { + if (checkbox.checked) selected.add(test.id); + else selected.delete(test.id); + render(); + }); + + const runButton = el('button', { + className: 'icon-button run-single', + title: `Run "${test.name}"`, + ariaLabel: `Run "${test.name}"`, + disabled: running, + }); + runButton.dataset.appearance = 'secondary'; + runButton.append(playIcon()); + runButton.addEventListener('click', () => run([test.id])); + + const durationCell = + status === 'running' + ? el('span', { className: 'test-duration running' }, [ + spinner(), + 'Running…', + ]) + : el('span', { + className: 'test-duration', + textContent: result ? `${result.durationMs}ms` : '', + }); + + const row = el('li', { className: `test-row status-${status}` }, [ + el('label', { className: 'test-main' }, [ + checkbox, + el('span', { + className: `status-dot dot-${status}`, + title: statusLabel(status), + }), + el('span', { className: 'test-name', textContent: test.name }), + ]), + durationCell, + runButton, + ]); + + if (result?.status === 'fail' && result.error) { + row.append( + el('pre', { className: 'test-error', textContent: result.error }), + ); + } + + return row; +} + +function renderGroupSummary( + name: string, + groupTestList: TestMeta[], +): HTMLElement { + const statuses = groupTestList.map( + (t) => results.get(t.id)?.status ?? 'pending', + ); + const passed = statuses.filter((s) => s === 'pass').length; + const failed = statuses.filter((s) => s === 'fail').length; + const total = groupTestList.length; + const aggregate = aggregateStatus(statuses); + + // Select-all checkbox for the group (indeterminate when partially selected). + const ids = groupTestList.map((t) => t.id); + const selectedCount = ids.filter((id) => selected.has(id)).length; + const groupCheckbox = el('input', { + type: 'checkbox', + className: 'checkbox-input', + checked: selectedCount === total && total > 0, + disabled: running, + }); + groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total; + // Keep the checkbox from toggling the
when clicked. + groupCheckbox.addEventListener('click', (e) => e.stopPropagation()); + groupCheckbox.addEventListener('change', () => { + if (groupCheckbox.checked) ids.forEach((id) => selected.add(id)); + else ids.forEach((id) => selected.delete(id)); + render(); + }); + + const runButton = el('button', { + className: 'icon-button run-group', + title: `Run "${name}"`, + ariaLabel: `Run "${name}"`, + disabled: running, + }); + runButton.dataset.appearance = 'secondary'; + runButton.append(playIcon()); + runButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + run(ids); + }); + + const counts = el('span', { className: 'group-counts' }, [ + el('span', { className: 'count-pass', textContent: `${passed}` }), + el('span', { className: 'count-sep', textContent: ' / ' }), + el('span', { className: 'count-fail', textContent: `${failed}` }), + el('span', { + className: 'count-total', + textContent: ` · ${total} test${total === 1 ? '' : 's'}`, + }), + ]); + + return el('summary', { className: 'group-summary' }, [ + groupCheckbox, + el('span', { + className: `status-dot dot-${aggregate}`, + title: statusLabel(aggregate), + }), + el('span', { className: 'group-name', textContent: name }), + counts, + runButton, + ]); +} + +function renderList(): HTMLElement { + const container = el('div', { className: 'groups' }); + + for (const group of groupTests()) { + const details = el('details', { className: 'group' }); + // Groups are collapsed by default; remember the ones the user expands. + details.open = expandedGroups.has(group.name); + details.addEventListener('toggle', () => { + if (details.open) expandedGroups.add(group.name); + else expandedGroups.delete(group.name); + }); + + details.append(renderGroupSummary(group.name, group.tests)); + + const list = el('ul', { className: 'test-list' }); + for (const test of group.tests) { + list.append(renderRow(test)); + } + details.append(list); + + container.append(details); + } + + return container; +} + +let lastCoverage: CoverageReport | null = null; + +function renderProgressBar( + percent: number, + effectivePercent: number, +): HTMLElement { + const track = el('div', { + className: 'progress-track', + role: 'progressbar', + title: `${percent}% recorded, ${effectivePercent}% effective`, + }); + track.setAttribute('aria-valuenow', String(percent)); + track.setAttribute('aria-valuemin', '0'); + track.setAttribute('aria-valuemax', '100'); + // Layered: the static segment (lighter) spans the effective coverage, the + // recorded fill (green) sits on top spanning the recorder-credited coverage. + const staticFill = el('div', { className: 'progress-fill static' }); + staticFill.style.width = `${effectivePercent}%`; + const fill = el('div', { className: 'progress-fill' }); + fill.style.width = `${percent}%`; + track.append(staticFill, fill); + return track; +} + +function renderCoverage(): HTMLElement { + const section = el('div', { className: 'coverage' }); + + if (!lastCoverage) { + section.append( + el('p', { + className: 'coverage-empty', + textContent: 'API coverage — run tests to measure', + }), + ); + return section; + } + + const { + covered, + staticallyCovered, + total, + percent, + effectivePercent, + byInterface, + } = lastCoverage; + + const valueText = + staticallyCovered > 0 + ? `${percent}% · ${effectivePercent}% eff. (${covered}+${staticallyCovered}/${total})` + : `${percent}% (${covered}/${total})`; + + section.append( + el('div', { className: 'coverage-header' }, [ + el('span', { + className: 'coverage-title', + textContent: 'API coverage', + }), + el('span', { + className: 'coverage-value', + textContent: valueText, + }), + ]), + renderProgressBar(percent, effectivePercent), + ); + + const details = el('details', { className: 'coverage-details' }); + details.append(el('summary', { textContent: 'Coverage by interface' })); + + const list = el('div', { className: 'coverage-body' }); + const interfaces = Object.entries(byInterface) + .filter(([, info]) => info.members.length > 0) + .sort(([a], [b]) => a.localeCompare(b)); + + for (const [iface, info] of interfaces) { + const members = el('div', { className: 'coverage-members' }); + // Covered (green) first, then statically covered (blue), then uncovered. + for (const m of info.covered) { + members.append( + el('span', { className: 'coverage-member covered', textContent: m }), + ); + } + for (const m of info.staticallyCovered) { + members.append( + el('span', { + className: 'coverage-member static', + textContent: m, + title: 'Exercised behaviourally; not creditable via the proxy', + }), + ); + } + for (const m of info.uncovered) { + members.append( + el('span', { className: 'coverage-member uncovered', textContent: m }), + ); + } + + const ifaceLabel = + info.staticallyCovered.length > 0 + ? `${iface} (${info.covered.length}+${info.staticallyCovered.length}/${info.members.length})` + : `${iface} (${info.covered.length}/${info.members.length})`; + + list.append( + el('div', { className: 'coverage-iface' }, [ + el('strong', { + textContent: ifaceLabel, + }), + members, + ]), + ); + } + + details.append(list); + section.append(details); + return section; +} + +function run(ids: string[] | 'all') { + if (running) return; + running = true; + + const targetIds = ids === 'all' ? tests.map((t) => t.id) : ids; + for (const id of targetIds) { + const test = tests.find((t) => t.id === id); + if (test) { + results.set(id, { + id, + name: test.name, + status: 'running', + durationMs: 0, + }); + } + } + + render(); + sendToPlugin({ type: 'run', ids }); +} + +async function reloadTests() { + if (running || reloading) return; + reloading = true; + statusText = ''; + render(); + + try { + // Fetch the freshly built tests bundle from the dev server (same origin as + // this iframe). `vite build --watch` rebuilds it on every save, so this + // picks up edited tests. The cache-busting query avoids any stale copy. + const response = await fetch(`./tests-bundle.js?t=${Date.now()}`); + if (!response.ok) { + throw new Error(`Failed to fetch tests bundle (${response.status})`); + } + const code = await response.text(); + // The sandbox evaluates the bundle and replies with `reloaded` + `tests`. + sendToPlugin({ type: 'reloadTests', code }); + } catch (err) { + reloading = false; + statusText = `Reload failed: ${err instanceof Error ? err.message : String(err)}`; + render(); + } +} + +window.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + if (!message || typeof message !== 'object') return; + + switch (message.type) { + case 'tests': { + tests = message.tests; + // Drop results/selection for tests that no longer exist after a reload. + const ids = new Set(tests.map((t) => t.id)); + for (const id of [...results.keys()]) { + if (!ids.has(id)) results.delete(id); + } + for (const id of [...selected]) { + if (!ids.has(id)) selected.delete(id); + } + render(); + break; + } + case 'result': + results.set(message.result.id, message.result); + render(); + break; + case 'runComplete': + running = false; + lastCoverage = message.coverage; + render(); + break; + case 'reloaded': + reloading = false; + statusText = message.ok + ? `Reloaded ${tests.length} tests` + : `Reload failed: ${message.error ?? 'unknown error'}`; + render(); + break; + case 'theme': + applyTheme(message.theme); + break; + } +}); + +applyTheme(new URLSearchParams(window.location.search).get('theme')); +render(); +sendToPlugin({ type: 'ready' }); diff --git a/plugins/apps/plugin-api-test-suite/tools/gen-api-surface.ts b/plugins/apps/plugin-api-test-suite/tools/gen-api-surface.ts new file mode 100644 index 0000000000..bafb5f7f34 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/tools/gen-api-surface.ts @@ -0,0 +1,339 @@ +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import ts from 'typescript'; + +/** + * Generates `src/generated/api-surface.json` from `libs/plugin-types/index.d.ts` + * using the TypeScript compiler API. The output drives type-aware coverage: + * + * - `interfaces`: own (syntactically declared) members per interface — the + * coverage denominator. + * - `graph`: for every interface, all reachable members (including inherited), + * each annotated with the interface that declares it and the type it yields. + * This lets the recorder attribute an access to the interface the value really + * is, instead of matching member names across unrelated interfaces. + * - `unions`: union aliases (e.g. `Shape`) with the discriminant needed to pick + * the concrete variant of a runtime value. + * + * Re-run with `pnpm run gen:api` whenever the public Plugin API types change. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const typesPath = resolve(here, '../../../libs/plugin-types/index.d.ts'); +const outPath = resolve(here, '../src/generated/api-surface.json'); + +const program = ts.createProgram([typesPath], { skipLibCheck: true }); +const checker = program.getTypeChecker(); +const source = program.getSourceFile(typesPath); + +if (!source) { + throw new Error(`Could not load Plugin API types at ${typesPath}`); +} + +const interfaceDecls = new Map(); +const unionAliases = new Map(); +// Object-literal type aliases (e.g. `type LibraryContext = { local: Library; … }`) +// are treated like interfaces so the recorder can wrap them and follow the chain +// into the types they expose (e.g. Context.library -> LibraryContext.local -> Library). +const objectAliases = new Map< + string, + { decl: ts.TypeAliasDeclaration; literal: ts.TypeLiteralNode } +>(); + +source.forEachChild((node) => { + if (ts.isInterfaceDeclaration(node)) { + interfaceDecls.set(node.name.text, node); + } else if (ts.isTypeAliasDeclaration(node) && ts.isUnionTypeNode(node.type)) { + unionAliases.set(node.name.text, node); + } else if ( + ts.isTypeAliasDeclaration(node) && + ts.isTypeLiteralNode(node.type) + ) { + objectAliases.set(node.name.text, { decl: node, literal: node.type }); + } +}); + +const knownInterfaces = new Set([ + ...interfaceDecls.keys(), + ...objectAliases.keys(), +]); +const knownUnions = new Set(unionAliases.keys()); + +function memberName(member: ts.TypeElement): string | undefined { + if ( + (ts.isPropertySignature(member) || ts.isMethodSignature(member)) && + member.name && + (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) + ) { + return member.name.text; + } + return undefined; +} + +/** True when a declaration carries an `@deprecated` JSDoc tag. */ +function isDeprecated(node: ts.Node): boolean { + return ts.getJSDocTags(node).some((t) => t.tagName.text === 'deprecated'); +} + +// Own (declared) members per interface — the coverage denominator. Deprecated +// interfaces and members are skipped so deprecated API never counts towards +// coverage (e.g. the legacy `Image` shape, `Color.refId/refFile`). +const interfaces: Record = {}; +for (const [name, decl] of interfaceDecls) { + if (isDeprecated(decl)) continue; + const names = new Set(); + for (const member of decl.members) { + if (isDeprecated(member)) continue; + const m = memberName(member); + if (m) names.add(m); + } + if (names.size > 0) interfaces[name] = [...names].sort(); +} +for (const [name, { decl, literal }] of objectAliases) { + if (isDeprecated(decl)) continue; + const names = new Set(); + for (const member of literal.members) { + if (isDeprecated(member)) continue; + const m = memberName(member); + if (m) names.add(m); + } + if (names.size > 0) interfaces[name] = [...names].sort(); +} + +// Honor `Omit` in heritage clauses: a member the *public* interface +// removes from an internal base is not part of the reachable surface, so it must +// not count towards coverage. `Penpot extends Omit` is the motivating case — `Context` is the internal interface +// and `Penpot` is the public one — but this applies to any such omission. +function stringLiterals(node: ts.TypeNode): string[] { + const collect = (n: ts.TypeNode): string[] => { + if (ts.isLiteralTypeNode(n) && ts.isStringLiteral(n.literal)) { + return [n.literal.text]; + } + if (ts.isUnionTypeNode(n)) return n.types.flatMap(collect); + return []; + }; + return collect(node); +} + +for (const decl of interfaceDecls.values()) { + for (const clause of decl.heritageClauses ?? []) { + for (const t of clause.types) { + if ( + ts.isIdentifier(t.expression) && + t.expression.text === 'Omit' && + t.typeArguments?.length === 2 + ) { + const [baseRef, keysArg] = t.typeArguments; + if ( + ts.isTypeReferenceNode(baseRef) && + ts.isIdentifier(baseRef.typeName) + ) { + const base = baseRef.typeName.text; + const omitted = new Set(stringLiterals(keysArg)); + if (interfaces[base] && omitted.size > 0) { + interfaces[base] = interfaces[base].filter((m) => !omitted.has(m)); + } + } + } + } + } +} + +/** + * Resolves a type to a tracked interface/union name (+ array flag) by parsing + * its textual form. Using `typeToString` keeps this resilient across compiler + * versions, where the structural type-flag APIs differ. + */ +function resolveType(type: ts.Type): { name: string | null; array: boolean } { + let text = checker.typeToString(type).replace(/^readonly\s+/, ''); + + // Unwrap Promise<...> + const promiseMatch = text.match(/^Promise<(.+)>$/s); + if (promiseMatch) text = promiseMatch[1].trim(); + + // Drop nullish, string-literal and bare-primitive union parts before array + // detection, so a single tracked type can still be resolved out of unions like + // `Group | null`, `Fill[] | 'mixed'` or `string | TokenShadowValueString[]`. + // Dropping primitives is safe: the recorder never wraps primitive values, so a + // primitive run-time value is returned as-is regardless of the resolved type. + const primitives = new Set([ + 'null', + 'undefined', + 'string', + 'number', + 'boolean', + 'unknown', + 'any', + 'void', + ]); + text = text + .split('|') + .map((p) => p.trim()) + .filter((p) => !primitives.has(p) && !/^["'].*["']$/.test(p)) + .join(' | '); + + let array = false; + const arrayMatch = text.match(/^(.+)\[\]$/s) ?? text.match(/^Array<(.+)>$/s); + if (arrayMatch) { + array = true; + text = arrayMatch[1].trim(); + } + + if (knownInterfaces.has(text) || knownUnions.has(text)) { + return { name: text, array }; + } + return { name: null, array }; +} + +// Full member graph per interface (including inherited members). +const graph: Record> = {}; + +type MemberKind = 'method' | 'get' | 'getset'; + +interface ApiMemberInfoOut { + decl: string; + kind: MemberKind; + type: string | null; + array: boolean; +} + +/** Classifies a member declaration as a method, read-only, or writable property. */ +function memberKind(decl: ts.Declaration): MemberKind { + if (ts.isMethodSignature(decl)) return 'method'; + if (ts.isPropertySignature(decl)) { + if (decl.type && ts.isFunctionTypeNode(decl.type)) return 'method'; + const readonly = decl.modifiers?.some( + (m) => m.kind === ts.SyntaxKind.ReadonlyKeyword, + ); + return readonly ? 'get' : 'getset'; + } + return 'getset'; +} + +for (const [name, decl] of interfaceDecls) { + const type = checker.getTypeAtLocation(decl); + const entries: Record = {}; + + for (const prop of checker.getPropertiesOfType(type)) { + const declaration = prop.declarations?.[0]; + if (!declaration) continue; + const parent = declaration.parent; + if (!parent || !ts.isInterfaceDeclaration(parent)) continue; + const declName = parent.name.text; + if (!knownInterfaces.has(declName)) continue; + + const propType = checker.getTypeOfSymbolAtLocation(prop, decl); + const signatures = propType.getCallSignatures(); + const resolved = resolveType( + signatures.length > 0 ? signatures[0].getReturnType() : propType, + ); + + entries[prop.name] = { + decl: declName, + kind: memberKind(declaration), + type: resolved.name, + array: resolved.array, + }; + } + + graph[name] = entries; +} + +// Object-literal aliases: all members are own (no inheritance), so the declaring +// interface is always the alias itself. +for (const [name, { decl, literal }] of objectAliases) { + const entries: Record = {}; + for (const member of literal.members) { + const m = memberName(member); + if (!m) continue; + const propType = checker.getTypeAtLocation(member); + const signatures = propType.getCallSignatures(); + const resolved = resolveType( + signatures.length > 0 ? signatures[0].getReturnType() : propType, + ); + entries[m] = { + decl: name, + kind: memberKind(member), + type: resolved.name, + array: resolved.array, + }; + } + graph[name] = entries; + void decl; +} + +// Union aliases + discriminants (literal `type` field -> variant interface). +const unions: Record = {}; + +interface UnionInfoOut { + variants: string[]; + discriminant: { field: string; map: Record } | null; +} + +function literalDiscriminant( + iface: ts.InterfaceDeclaration, + field: string, +): string | null { + for (const member of iface.members) { + if (memberName(member) !== field) continue; + if (ts.isPropertySignature(member) && member.type) { + if ( + ts.isLiteralTypeNode(member.type) && + ts.isStringLiteral(member.type.literal) + ) { + return member.type.literal.text; + } + } + } + return null; +} + +for (const [name, decl] of unionAliases) { + if (!ts.isUnionTypeNode(decl.type)) continue; + const variants: string[] = []; + for (const member of decl.type.types) { + if (ts.isTypeReferenceNode(member) && ts.isIdentifier(member.typeName)) { + const variantName = member.typeName.text; + if (knownInterfaces.has(variantName)) variants.push(variantName); + } + } + if (variants.length === 0) continue; + + // Build a discriminant map using the `type` literal of each variant. + const map: Record = {}; + for (const variant of variants) { + const lit = literalDiscriminant(interfaceDecls.get(variant)!, 'type'); + if (lit) map[lit] = variant; + } + + unions[name] = { + variants, + discriminant: Object.keys(map).length > 0 ? { field: 'type', map } : null, + }; +} + +const surface = { + interfaces: Object.fromEntries( + Object.entries(interfaces).sort(([a], [b]) => a.localeCompare(b)), + ), + graph: Object.fromEntries( + Object.entries(graph).sort(([a], [b]) => a.localeCompare(b)), + ), + unions: Object.fromEntries( + Object.entries(unions).sort(([a], [b]) => a.localeCompare(b)), + ), +}; + +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(outPath, JSON.stringify(surface, null, 2) + '\n'); + +const memberCount = Object.values(surface.interfaces).reduce( + (sum, members) => sum + members.length, + 0, +); +console.log( + `Wrote ${memberCount} members across ${Object.keys(surface.interfaces).length} ` + + `interfaces and ${Object.keys(surface.unions).length} unions to ${outPath}`, +); diff --git a/plugins/apps/plugin-api-test-suite/tsconfig.app.json b/plugins/apps/plugin-api-test-suite/tsconfig.app.json new file mode 100644 index 0000000000..951462f9d2 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node", "vite/client"] + }, + "include": ["src/**/*.ts", "../../libs/plugin-types/index.d.ts"] +} diff --git a/plugins/apps/plugin-api-test-suite/tsconfig.json b/plugins/apps/plugin-api-test-suite/tsconfig.json new file mode 100644 index 0000000000..8262ab74ac --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src"], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/plugins/apps/plugin-api-test-suite/tsconfig.node.json b/plugins/apps/plugin-api-test-suite/tsconfig.node.json new file mode 100644 index 0000000000..15543271a6 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": [ + "tools/**/*.ts", + "ci/**/*.ts", + "vite.config.ts", + "vite.config.headless.ts", + "vite.config.tests.ts", + "vite.config.iife.ts" + ] +} diff --git a/plugins/apps/plugin-api-test-suite/vite.config.headless.ts b/plugins/apps/plugin-api-test-suite/vite.config.headless.ts new file mode 100644 index 0000000000..c2fad314f5 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/vite.config.headless.ts @@ -0,0 +1,6 @@ +import { iifeConfig } from './vite.config.iife'; + +// Builds the CI test entry as a single self-executing (IIFE) bundle, evaluated +// inside the Penpot plugin sandbox via `globalThis.ɵloadPlugin({ code })` by the +// CI runner. See vite.config.iife.ts for the shared bundle config. +export default iifeConfig('headless', 'src/ci/headless.ts'); diff --git a/plugins/apps/plugin-api-test-suite/vite.config.iife.ts b/plugins/apps/plugin-api-test-suite/vite.config.iife.ts new file mode 100644 index 0000000000..b2ee8a2985 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/vite.config.iife.ts @@ -0,0 +1,34 @@ +import { defineConfig, type UserConfig } from 'vite'; + +/** + * Shared config for the two single-file IIFE bundles (`headless.js`, + * `tests-bundle.js`). Both are self-executing chunks with no `import`/`export` + * statements so they can be evaluated directly inside the Penpot plugin sandbox + * (via `globalThis.ɵloadPlugin({ code })` for headless, or the UI "Reload" + * button's `eval` for the tests bundle). They differ only by their entry module. + * + * `emptyOutDir` stays false so a `watch` rebuild of one bundle never wipes the + * sibling outputs in the shared `dist` directory. + */ +export function iifeConfig(name: string, entry: string): UserConfig { + return defineConfig({ + root: __dirname, + resolve: { + tsconfigPaths: true, + }, + build: { + outDir: '../../dist/apps/plugin-api-test-suite', + emptyOutDir: false, + reportCompressedSize: false, + rollupOptions: { + input: { + [name]: entry, + }, + output: { + format: 'iife', + entryFileNames: '[name].js', + }, + }, + }, + }); +} diff --git a/plugins/apps/plugin-api-test-suite/vite.config.tests.ts b/plugins/apps/plugin-api-test-suite/vite.config.tests.ts new file mode 100644 index 0000000000..39468d5209 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/vite.config.tests.ts @@ -0,0 +1,8 @@ +import { iifeConfig } from './vite.config.iife'; + +// Builds the test cases as a single self-executing (IIFE) bundle that publishes +// the discovered tests on `globalThis.__penpotReloadedTests`. The UI "Reload" +// button fetches this file and the plugin sandbox `eval`s it to pick up edited +// tests without reopening the plugin. Rebuilt on save by the `watch` script. +// See vite.config.iife.ts for the shared bundle config. +export default iifeConfig('tests-bundle', 'src/tests-bundle.ts'); diff --git a/plugins/apps/plugin-api-test-suite/vite.config.ts b/plugins/apps/plugin-api-test-suite/vite.config.ts new file mode 100644 index 0000000000..cc031bca49 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/vite.config.ts @@ -0,0 +1,39 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: __dirname, + server: { + port: 4202, + host: '0.0.0.0', + cors: true, + }, + preview: { + port: 4202, + host: '0.0.0.0', + cors: true, + }, + resolve: { + tsconfigPaths: true, + }, + build: { + outDir: '../../dist/apps/plugin-api-test-suite', + // Keep false so `watch` rebuilds don't wipe the sibling tests-bundle.js / + // headless.js outputs. The `build` script passes --emptyOutDir for a clean + // one-shot build. + emptyOutDir: false, + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + rollupOptions: { + input: { + plugin: 'src/plugin.ts', + index: './index.html', + }, + output: { + entryFileNames: '[name].js', + }, + }, + }, +}); diff --git a/plugins/package.json b/plugins/package.json index 7a97ee50da..8615dc1b95 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -17,8 +17,9 @@ "start:plugin:renamelayers": "pnpm --filter rename-layers-plugin run init", "start:plugin:colors-to-tokens": "pnpm --filter colors-to-tokens-plugin run init", "start:plugin:poc-tokens": "pnpm --filter poc-tokens-plugin run init", + "start:plugin:api-test-suite": "pnpm --filter plugin-api-test-suite run init", "build:runtime": "pnpm --filter @penpot/plugins-runtime build", - "build:plugins": "pnpm --parallel --filter './apps/*-plugin' --filter '!poc-state-plugin' build", + "build:plugins": "pnpm --parallel --filter './apps/*-plugin' --filter plugin-api-test-suite --filter '!poc-state-plugin' build", "build:styles-example": "pnpm --filter example-styles build", "lint": "pnpm -r --parallel lint", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,html,css}\"", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index f8760b69e3..8d6e63b12a 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 21.2.15(@angular/common@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.15(@angular/animations@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) axios: specifier: ^1.16.1 - version: 1.16.1 + version: 1.16.1(debug@4.4.3) feather-icons: specifier: ^4.29.2 version: 4.29.2 @@ -216,6 +216,12 @@ importers: apps/lorem-ipsum-plugin: {} + apps/plugin-api-test-suite: + devDependencies: + playwright: + specifier: ^1.61.0 + version: 1.61.0 + apps/poc-state-plugin: {} apps/poc-tokens-plugin: {} @@ -412,6 +418,7 @@ packages: '@angular/animations@21.2.15': resolution: {integrity: sha512-Z8AsLTwc++Fcu0fJnclAF9zMfumAd5KXrwtSdyECqLpqd+lEmmsOpeOl6P7loqdDz99KYh/8UF4eJxdMvnsaKw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + deprecated: '@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.' peerDependencies: '@angular/core': 21.2.15 @@ -4158,6 +4165,11 @@ packages: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5387,6 +5399,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -10122,7 +10144,7 @@ snapshots: axe-core@4.11.4: {} - axios@1.16.1: + axios@1.16.1(debug@4.4.3): dependencies: follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.5 @@ -11260,6 +11282,9 @@ snapshots: dependencies: minipass: 7.1.3 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12578,6 +12603,14 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.61.0: {} + + playwright@1.61.0: + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-loader@8.2.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(postcss@8.5.12)(typescript@6.0.3)(webpack@5.105.2(esbuild@0.27.3)(lightningcss@1.32.0)(postcss@8.5.12)): From b3b3ea97dbdbea060225ff54bd2134f6a2c14796 Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Tue, 30 Jun 2026 08:35:55 -0400 Subject: [PATCH 036/100] :books: Fix Angular plugin usage doc link (#10349) --- plugins/docs/create-angular-plugin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/docs/create-angular-plugin.md b/plugins/docs/create-angular-plugin.md index 9bc9119558..ea362705e8 100644 --- a/plugins/docs/create-angular-plugin.md +++ b/plugins/docs/create-angular-plugin.md @@ -156,4 +156,4 @@ pnpm --filter example-plugin build For more detailed information on plugin development, check out our guides: -- [Plugin Usage Documentation](,/plugin-usage.md) +- [Using plugins in Penpot](https://help.penpot.app/user-guide/plugins-integrations/) From 46f5346045f29214eac49732fb4bd6b129ae2aff Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Tue, 30 Jun 2026 14:37:27 +0200 Subject: [PATCH 037/100] :recycle: Merge :thumbnails and :thumbnails-meta into single state key (#10021) * :recycle: Merge :thumbnails and :thumbnails-meta into single state key :recycle: Unify thumbnail refs in a single ref :bug: Fix test * :recycle: Update tests --- frontend/src/app/main/data/workspace.cljs | 2 +- .../app/main/data/workspace/libraries.cljs | 6 +- .../app/main/data/workspace/thumbnails.cljs | 14 +- .../main/data/workspace/thumbnails_wasm.cljs | 2 +- frontend/src/app/main/refs.cljs | 11 +- .../app/main/ui/workspace/shapes/frame.cljs | 9 +- .../ui/workspace/sidebar/assets/common.cljs | 19 +- .../ui/workspace/viewport/interactions.cljs | 2 +- .../data/workspace_thumbnails_test.cljs | 491 +++++++++--------- .../test/frontend_tests/helpers/mock.cljc | 15 +- 10 files changed, 292 insertions(+), 279 deletions(-) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 39b28fbdc0..5a2d4446c6 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -275,7 +275,7 @@ ptk/UpdateEvent (update [_ state] (-> state - (assoc :thumbnails thumbnails) + (assoc :thumbnails (d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil}))) (update :files assoc file-id file))))) (defn zoom-to-frame diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index a3a93cf7cc..5225f5cecf 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -800,7 +800,8 @@ (ptk/reify ::library-thumbnails-fetched ptk/UpdateEvent (update [_ state] - (update state :thumbnails merge thumbnails)))) + (update state :thumbnails merge + (d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil})))))) (defn fetch-library-thumbnails [library-id] @@ -1541,7 +1542,8 @@ (->> (rp/cmd! :get-file-object-thumbnails {:file-id library-id :tag "component"}) (rx/map (fn [thumbnails] (fn [state] - (update state :thumbnails merge thumbnails)))))))))) + (update state :thumbnails merge + (d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil})))))))))))) (defn link-file-to-library [file-id library-id] diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs index 3770a24a4a..3526c1ed99 100644 --- a/frontend/src/app/main/data/workspace/thumbnails.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails.cljs @@ -134,12 +134,11 @@ ptk/UpdateEvent (update [_ state] - (let [uri (dm/get-in state [:thumbnails object-id])] + (let [uri (dm/get-in state [:thumbnails object-id :uri])] (l/dbg :hint "clear-thumbnail" :object-id object-id :uri uri) (-> state (update ::thumbnails-deletion-queue assoc object-id uri) - (update :thumbnails dissoc object-id) - (update :thumbnails-meta dissoc object-id)))) + (update :thumbnails dissoc object-id)))) ptk/WatchEvent (watch [_ _ stream] @@ -156,13 +155,12 @@ (ptk/reify ::assoc-thumbnail ptk/UpdateEvent (update [_ state] - (let [prev-uri (dm/get-in state [:thumbnails object-id]) - now (.now js/Date)] - (some->> prev-uri (vreset! prev-uri*)) + (let [prev-entry (dm/get-in state [:thumbnails object-id]) + now (ct/now)] + (some->> prev-entry :uri (vreset! prev-uri*)) (l/trc :hint "assoc thumbnail" :object-id object-id :uri uri) (-> state - (update :thumbnails assoc object-id uri) - (update :thumbnails-meta assoc object-id {:rendered-at now})))) + (update :thumbnails assoc object-id {:uri uri :rendered-at now})))) ptk/EffectEvent (effect [_ _ _] diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index 612bdd2e53..7d478c6448 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -168,7 +168,7 @@ (ptk/reify ::persist-thumbnail ptk/WatchEvent (watch [_ state _] - (let [data-uri (dm/get-in state [:thumbnails object-id])] + (let [data-uri (dm/get-in state [:thumbnails object-id :uri])] (if (and (some? data-uri) (str/starts-with? data-uri "data:")) (let [blob (wapi/data-uri->blob data-uri)] diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index fc14e846f1..cf6e9e17f9 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -580,14 +580,9 @@ [object-id] (l/derived (fn [state] - (some-> (dm/get-in state [:thumbnails object-id]) - (cf/resolve-media))) - st/state)) - -(defn workspace-thumbnail-rendered-at - [object-id] - (l/derived - #(dm/get-in % [:thumbnails-meta object-id :rendered-at]) + (when-let [entry (dm/get-in state [:thumbnails object-id])] + (cond-> entry + (:uri entry) (update :uri cf/resolve-media)))) st/state)) (def workspace-text-modifier diff --git a/frontend/src/app/main/ui/workspace/shapes/frame.cljs b/frontend/src/app/main/ui/workspace/shapes/frame.cljs index 2b09050dfe..27346b3178 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame.cljs @@ -136,10 +136,11 @@ width (dm/get-prop bounds :width) height (dm/get-prop bounds :height) - thumbnail-uri* (mf/with-memo [file-id page-id frame-id] - (let [object-id (thc/fmt-object-id file-id page-id frame-id "frame")] - (refs/workspace-thumbnail-by-id object-id))) - thumbnail-uri (mf/deref thumbnail-uri*) + thumbnail-data* (mf/with-memo [file-id page-id frame-id] + (let [object-id (thc/fmt-object-id file-id page-id frame-id "frame")] + (refs/workspace-thumbnail-by-id object-id))) + thumbnail-data (mf/deref thumbnail-data*) + thumbnail-uri (:uri thumbnail-data) modifiers-ref (mf/with-memo [frame-id] (refs/workspace-modifiers-by-frame-id frame-id)) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index 55d191d36a..086a21ae11 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -328,19 +328,18 @@ (mf/with-memo [file-id page-id root-id] (thc/fmt-object-id file-id page-id root-id "component")) - thumbnail-uri* + thumbnail-data* (mf/with-memo [object-id] (refs/workspace-thumbnail-by-id object-id)) + thumbnail-data + (mf/deref thumbnail-data*) + thumbnail-uri - (mf/deref thumbnail-uri*) + (:uri thumbnail-data) - rendered-at* - (mf/with-memo [object-id] - (refs/workspace-thumbnail-rendered-at object-id)) - - rendered-at - (mf/deref rendered-at*) + thumbnail-rendered-at + (:rendered-at thumbnail-data) modified-at (some-> (:modified-at component) (.getTime)) @@ -349,9 +348,9 @@ ;; or the component was modified after the last render stale? (and (some? thumbnail-uri) - (or (nil? rendered-at) + (or (nil? thumbnail-rendered-at) (and (some? modified-at) - (> modified-at rendered-at)))) + (> modified-at thumbnail-rendered-at)))) on-error (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs index 0a910d4730..6ffa2d32de 100644 --- a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs @@ -258,7 +258,7 @@ dest-shape (cond-> dest-shape (some? thumbnail-data) - (assoc :thumbnail thumbnail-data))] + (assoc :thumbnail (:uri thumbnail-data)))] [:g {:on-pointer-down start-move-position :on-pointer-enter #(reset! is-hover-disabled true) :on-pointer-leave #(reset! is-hover-disabled false)} diff --git a/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs b/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs index fd35d6c5fc..6aaa09e283 100644 --- a/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs @@ -9,6 +9,9 @@ [app.common.thumbnails :as thc] [app.common.uuid :as uuid] [app.main.data.workspace.thumbnails :as thumbnails] + [app.main.repo :as rp] + [app.util.timers :as tm] + [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.mock :as mock] @@ -42,257 +45,267 @@ :component-root true}}}}}] (t/is (= #{["frame" root-id] ["component" shape-b-id]} - (#'thumbnails/extract-frame-changes page-id [event [old-data new-data]])))) + (#'thumbnails/extract-frame-changes page-id [event [old-data new-data]]))))) - ;; --- Batch deletion queue state management --- +;; --- Batch deletion queue state management --- - (t/deftest clear-thumbnail-adds-to-deletion-queue - (let [file-id (uuid/next) - object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - uri "blob:http://localhost/test-thumb" - event (thumbnails/clear-thumbnail file-id object-id) - state {:thumbnails {object-id uri}} - result (ptk/update event state)] - ;; Thumbnail removed from the map - (t/is (nil? (get-in result [:thumbnails object-id]))) - ;; Object-id added to the deletion queue with its URI - (t/is (= uri (get-in result [deletion-queue-key object-id]))))) +(t/deftest clear-thumbnail-adds-to-deletion-queue + (let [file-id (uuid/next) + object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri "blob:http://localhost/test-thumb" + event (thumbnails/clear-thumbnail file-id object-id) + state {:thumbnails {object-id {:uri uri :rendered-at nil}}} + result (ptk/update event state)] + ;; Thumbnail removed from the map + (t/is (nil? (get-in result [:thumbnails object-id]))) + ;; Object-id added to the deletion queue with its URI + (t/is (= uri (get-in result [deletion-queue-key object-id]))))) - (t/deftest clear-thumbnail-keeps-other-thumbnails - (let [file-id (uuid/next) - object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - uri1 "blob:http://localhost/thumb-1" - uri2 "blob:http://localhost/thumb-2" - event (thumbnails/clear-thumbnail file-id object-id1) - state {:thumbnails {object-id1 uri1 object-id2 uri2}} - result (ptk/update event state)] - ;; Only the cleared thumbnail is removed - (t/is (nil? (get-in result [:thumbnails object-id1]))) - (t/is (= uri2 (get-in result [:thumbnails object-id2]))) - ;; Only the cleared thumbnail is queued - (t/is (= uri1 (get-in result [deletion-queue-key object-id1]))) - (t/is (nil? (get-in result [deletion-queue-key object-id2]))))) +(t/deftest clear-thumbnail-keeps-other-thumbnails + (let [file-id (uuid/next) + object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri1 "blob:http://localhost/thumb-1" + uri2 "blob:http://localhost/thumb-2" + event (thumbnails/clear-thumbnail file-id object-id1) + state {:thumbnails {object-id1 {:uri uri1 :rendered-at nil} + object-id2 {:uri uri2 :rendered-at nil}}} + result (ptk/update event state)] + ;; Only the cleared thumbnail is removed + (t/is (nil? (get-in result [:thumbnails object-id1]))) + (t/is (= uri2 (get-in result [:thumbnails object-id2 :uri]))) + ;; Only the cleared thumbnail is queued + (t/is (= uri1 (get-in result [deletion-queue-key object-id1]))) + (t/is (nil? (get-in result [deletion-queue-key object-id2]))))) - (t/deftest clear-thumbnail-accumulates-in-queue - (let [file-id (uuid/next) - object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - uri1 "blob:http://localhost/thumb-1" - uri2 "blob:http://localhost/thumb-2" - event1 (thumbnails/clear-thumbnail file-id object-id1) - event2 (thumbnails/clear-thumbnail file-id object-id2) - state {:thumbnails {object-id1 uri1 object-id2 uri2}} - state1 (ptk/update event1 state) - state2 (ptk/update event2 state1)] - ;; Both removed from thumbnails - (t/is (nil? (get-in state2 [:thumbnails object-id1]))) - (t/is (nil? (get-in state2 [:thumbnails object-id2]))) - ;; Both accumulated in the queue - (t/is (= uri1 (get-in state2 [deletion-queue-key object-id1]))) - (t/is (= uri2 (get-in state2 [deletion-queue-key object-id2]))) - (t/is (= 2 (count (get state2 deletion-queue-key)))))) +(t/deftest clear-thumbnail-accumulates-in-queue + (let [file-id (uuid/next) + object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri1 "blob:http://localhost/thumb-1" + uri2 "blob:http://localhost/thumb-2" + event1 (thumbnails/clear-thumbnail file-id object-id1) + event2 (thumbnails/clear-thumbnail file-id object-id2) + state {:thumbnails {object-id1 {:uri uri1 :rendered-at nil} + object-id2 {:uri uri2 :rendered-at nil}}} + state1 (ptk/update event1 state) + state2 (ptk/update event2 state1)] + ;; Both removed from thumbnails + (t/is (nil? (get-in state2 [:thumbnails object-id1]))) + (t/is (nil? (get-in state2 [:thumbnails object-id2]))) + ;; Both accumulated in the queue + (t/is (= uri1 (get-in state2 [deletion-queue-key object-id1]))) + (t/is (= uri2 (get-in state2 [deletion-queue-key object-id2]))) + (t/is (= 2 (count (get state2 deletion-queue-key)))))) - (t/deftest remove-from-deletion-queue-removes-entry - (let [file-id (uuid/next) - object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - event (thumbnails/remove-from-deletion-queue object-id) - state {deletion-queue-key {object-id "blob:http://localhost/thumb"}} - result (ptk/update event state)] - (t/is (nil? (get-in result [deletion-queue-key object-id]))) - (t/is (empty? (get result deletion-queue-key))))) +(t/deftest remove-from-deletion-queue-removes-entry + (let [file-id (uuid/next) + object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + event (thumbnails/remove-from-deletion-queue object-id) + state {deletion-queue-key {object-id "blob:http://localhost/thumb"}} + result (ptk/update event state)] + (t/is (nil? (get-in result [deletion-queue-key object-id]))) + (t/is (empty? (get result deletion-queue-key))))) - (t/deftest remove-from-deletion-queue-keeps-other-entries - (let [file-id (uuid/next) - object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - uri1 "blob:http://localhost/thumb-1" - uri2 "blob:http://localhost/thumb-2" - event (thumbnails/remove-from-deletion-queue object-id1) - state {deletion-queue-key {object-id1 uri1 - object-id2 uri2}} - result (ptk/update event state)] - ;; Only the specified entry is removed - (t/is (nil? (get-in result [deletion-queue-key object-id1]))) - (t/is (= uri2 (get-in result [deletion-queue-key object-id2]))) - (t/is (= 1 (count (get result deletion-queue-key)))))) +(t/deftest remove-from-deletion-queue-keeps-other-entries + (let [file-id (uuid/next) + object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri1 "blob:http://localhost/thumb-1" + uri2 "blob:http://localhost/thumb-2" + event (thumbnails/remove-from-deletion-queue object-id1) + state {deletion-queue-key {object-id1 uri1 + object-id2 uri2}} + result (ptk/update event state)] + ;; Only the specified entry is removed + (t/is (nil? (get-in result [deletion-queue-key object-id1]))) + (t/is (= uri2 (get-in result [deletion-queue-key object-id2]))) + (t/is (= 1 (count (get result deletion-queue-key)))))) - (t/deftest remove-before-clear-cancels-pending-delete +(t/deftest remove-before-clear-cancels-pending-delete + (let [file-id (uuid/next) + object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri "blob:http://localhost/thumb" + ;; Step 1: clear-thumbnail queues the delete + state1 (ptk/update (thumbnails/clear-thumbnail file-id object-id) + {:thumbnails {object-id {:uri uri :rendered-at nil}}}) + ;; Step 2: remove-from-deletion-queue cancels the pending delete + state2 (ptk/update (thumbnails/remove-from-deletion-queue object-id) + state1)] + ;; Thumbnail was removed from :thumbnails map by clear-thumbnail + (t/is (nil? (get-in state2 [:thumbnails object-id]))) + ;; But the deletion queue entry was cancelled by remove-from-deletion-queue + (t/is (nil? (get-in state2 [deletion-queue-key object-id]))) + (t/is (empty? (get state2 deletion-queue-key))))) + +(t/deftest clear-thumbnail-batch-drains-queue + (let [file-id (uuid/next) + object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri1 "blob:http://localhost/thumb-1" + uri2 "blob:http://localhost/thumb-2" + ;; Build up the queue state manually (simulating accumulated clear-thumbnails) + state {deletion-queue-key {object-id1 uri1 object-id2 uri2}} + event (#'thumbnails/clear-thumbnail-batch) + result (ptk/update event state)] + ;; The queue is drained from application state + (t/is (empty? (get result deletion-queue-key))))) + +(t/deftest clear-thumbnail-batch-empty-queue-noop + (let [state {deletion-queue-key {}} + event (#'thumbnails/clear-thumbnail-batch) + result (ptk/update event state)] + ;; Queue key removed from state; rest of state unchanged + (t/is (empty? (get result deletion-queue-key))) + (t/is (= (dissoc state deletion-queue-key) (dissoc result deletion-queue-key))))) + +(t/deftest assoc-thumbnail-adds-to-map + (let [object-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame") + uri "blob:http://localhost/new-thumb" + event (#'thumbnails/assoc-thumbnail object-id uri) + state {:thumbnails {}} + result (ptk/update event state)] + (t/is (= uri (get-in result [:thumbnails object-id :uri]))) + (t/is (some? (get-in result [:thumbnails object-id :rendered-at]))))) + +(t/deftest duplicate-thumbnail-copies-entry + (let [old-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame") + new-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame") + uri "blob:http://localhost/dup-thumb" + entry {:uri uri :rendered-at nil} + event (thumbnails/duplicate-thumbnail old-id new-id) + state {:thumbnails {old-id entry}} + result (ptk/update event state)] + (t/is (= entry (get-in result [:thumbnails old-id]))) + (t/is (= entry (get-in result [:thumbnails new-id]))))) + +;; --- Async WatchEvent tests --- + +(defn- make-obj-ids + "Helper to create n properly-formatted object-ids for a single file." + [file-id n] + (vec (repeatedly n #(thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")))) + +(t/deftest clear-thumbnail-batch-watch-calls-rpc-with-object-ids + (t/async + done + (let [file-id (uuid/next) + oids (make-obj-ids file-id 3) + state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))} + event (#'thumbnails/clear-thumbnail-batch)] + (ptk/update event state) + (mock/with-mocks + {rp/cmd! mock/rpc-cmd-mock + tm/schedule-on-idle mock/schedule-on-idle-mock} + (fn [done'] + (->> (ptk/watch event state nil) + (rx/reduce conj []) + (rx/subs! + (fn [_] nil) + (fn [err] (t/is (nil? err)) (done')) + (fn [_] + (t/is (= 1 (count @mock/rpc-calls))) + (let [[{:keys [cmd params]}] @mock/rpc-calls] + (t/is (= :delete-file-object-thumbnails cmd)) + (t/is (= (vec oids) (:object-ids params)))) + (done'))))) + done)))) + +(t/deftest clear-thumbnail-batch-watch-partitions-large-batch + (t/async + done + (let [file-id (uuid/next) + oids (make-obj-ids file-id 250) + state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))} + event (#'thumbnails/clear-thumbnail-batch)] + (ptk/update event state) + (mock/with-mocks + {rp/cmd! mock/rpc-cmd-mock + tm/schedule-on-idle mock/schedule-on-idle-mock} + (fn [done'] + (->> (ptk/watch event state nil) + (rx/reduce conj []) + (rx/subs! + (fn [_] nil) + (fn [err] (t/is (nil? err)) (done')) + (fn [_] + (t/is (= 2 (count @mock/rpc-calls))) + (let [[c1 c2] @mock/rpc-calls] + (t/is (= :delete-file-object-thumbnails (:cmd c1))) + (t/is (= :delete-file-object-thumbnails (:cmd c2))) + (t/is (= 200 (count (:object-ids (:params c1))))) + (t/is (= 50 (count (:object-ids (:params c2))))) + (t/is (= (set oids) + (set (concat (:object-ids (:params c1)) + (:object-ids (:params c2))))))) + (done'))))) + done)))) + +(t/deftest clear-thumbnail-batch-watch-revokes-blob-uris + (t/async + done + (let [file-id (uuid/next) + oids (make-obj-ids file-id 2) + uris ["blob:http://localhost/thumb-1" + "blob:http://localhost/thumb-2"] + state {deletion-queue-key (zipmap oids uris)} + event (#'thumbnails/clear-thumbnail-batch)] + (ptk/update event state) + (mock/with-mocks + {rp/cmd! mock/rpc-cmd-mock + wapi/revoke-uri mock/revoke-uri-mock + tm/schedule-on-idle mock/schedule-on-idle-mock} + (fn [done'] + (->> (ptk/watch event state nil) + (rx/reduce conj []) + (rx/subs! + (fn [_] nil) + (fn [err] (t/is (nil? err)) (done')) + (fn [_] + (t/is (= (set uris) (set @mock/revoked-uris))) + (done'))))) + done)))) + +(t/deftest clear-thumbnail-batch-watch-empty-queue-no-rpc + (t/async + done + (let [event (#'thumbnails/clear-thumbnail-batch) + state {}] + (ptk/update event state) + (mock/with-mocks + {rp/cmd! mock/rpc-cmd-mock + tm/schedule-on-idle mock/schedule-on-idle-mock} + (fn [done'] + (->> (ptk/watch event state nil) + (rx/reduce conj []) + (rx/subs! + (fn [_] nil) + (fn [err] (t/is (nil? err)) (done')) + (fn [_] + (t/is (empty? @mock/rpc-calls)) + (done'))))) + done)))) + +(t/deftest clear-thumbnail-watch-emits-batch-after-debounce + (t/async + done (let [file-id (uuid/next) object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") uri "blob:http://localhost/thumb" - ;; Step 1: clear-thumbnail queues the delete - state1 (ptk/update (thumbnails/clear-thumbnail file-id object-id) - {:thumbnails {object-id uri}}) - ;; Step 2: remove-from-deletion-queue cancels the pending delete - state2 (ptk/update (thumbnails/remove-from-deletion-queue object-id) - state1)] - ;; Thumbnail was removed from :thumbnails map by clear-thumbnail - (t/is (nil? (get-in state2 [:thumbnails object-id]))) - ;; But the deletion queue entry was cancelled by remove-from-deletion-queue - (t/is (nil? (get-in state2 [deletion-queue-key object-id]))) - (t/is (empty? (get state2 deletion-queue-key))))) - - (t/deftest clear-thumbnail-batch-drains-queue - (let [file-id (uuid/next) - object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - uri1 "blob:http://localhost/thumb-1" - uri2 "blob:http://localhost/thumb-2" - ;; Build up the queue state manually (simulating accumulated clear-thumbnails) - state {deletion-queue-key {object-id1 uri1 object-id2 uri2}} - event (#'thumbnails/clear-thumbnail-batch) - result (ptk/update event state)] - ;; The queue is drained from application state - (t/is (empty? (get result deletion-queue-key))))) - - (t/deftest clear-thumbnail-batch-empty-queue-noop - (let [state {deletion-queue-key {}} - event (#'thumbnails/clear-thumbnail-batch) - result (ptk/update event state)] - ;; State unchanged when queue is already empty - (t/is (empty? (get result deletion-queue-key))) - (t/is (= state (dissoc result deletion-queue-key))))) - - (t/deftest assoc-thumbnail-adds-to-map - (let [object-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame") - uri "blob:http://localhost/new-thumb" - event (#'thumbnails/assoc-thumbnail object-id uri) - state {:thumbnails {}} - result (ptk/update event state)] - (t/is (= uri (get-in result [:thumbnails object-id]))))) - - (t/deftest duplicate-thumbnail-copies-entry - (let [old-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame") - new-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame") - uri "blob:http://localhost/dup-thumb" - event (thumbnails/duplicate-thumbnail old-id new-id) - state {:thumbnails {old-id uri}} - result (ptk/update event state)] - (t/is (= uri (get-in result [:thumbnails old-id]))) - (t/is (= uri (get-in result [:thumbnails new-id]))))) - - ;; --- Async WatchEvent tests --- - - (defn- make-obj-ids - "Helper to create n properly-formatted object-ids for a single file." - [file-id n] - (vec (repeatedly n #(thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")))) - - (t/deftest clear-thumbnail-batch-watch-calls-rpc-with-object-ids - (t/async done - (let [file-id (uuid/next) - oids (make-obj-ids file-id 3) - state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))} - event (#'thumbnails/clear-thumbnail-batch)] - (ptk/update event state) - (mock/with-mocks - {app.main.repo/cmd! mock/rpc-cmd-mock - app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} - (fn [done'] - (->> (ptk/watch event state nil) + state {:thumbnails {object-id {:uri uri :rendered-at nil}}} + event (thumbnails/clear-thumbnail file-id object-id)] + (ptk/update event state) + (mock/with-mocks + {rx/timer mock/timer-mock} + (fn [done'] + (let [stream (rx/subject)] + (->> (ptk/watch event state stream) (rx/reduce conj []) (rx/subs! - (fn [_] nil) - (fn [err] (t/is (nil? err)) (done')) - (fn [_] - (t/is (= 1 (count @mock/rpc-calls))) - (let [[{:keys [cmd params]}] @mock/rpc-calls] - (t/is (= :delete-file-object-thumbnails cmd)) - (t/is (= (vec oids) (:object-ids params)))) - (done'))))) - done)))) - - (t/deftest clear-thumbnail-batch-watch-partitions-large-batch - (t/async done - (let [file-id (uuid/next) - oids (make-obj-ids file-id 250) - state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))} - event (#'thumbnails/clear-thumbnail-batch)] - (ptk/update event state) - (mock/with-mocks - {app.main.repo/cmd! mock/rpc-cmd-mock - app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} - (fn [done'] - (->> (ptk/watch event state nil) - (rx/reduce conj []) - (rx/subs! - (fn [_] nil) - (fn [err] (t/is (nil? err)) (done')) - (fn [_] - (t/is (= 2 (count @mock/rpc-calls))) - (let [[c1 c2] @mock/rpc-calls] - (t/is (= :delete-file-object-thumbnails (:cmd c1))) - (t/is (= :delete-file-object-thumbnails (:cmd c2))) - (t/is (= 200 (count (:object-ids (:params c1))))) - (t/is (= 50 (count (:object-ids (:params c2))))) - (t/is (= (set oids) - (set (concat (:object-ids (:params c1)) - (:object-ids (:params c2))))))) - (done'))))) - done)))) - - (t/deftest clear-thumbnail-batch-watch-revokes-blob-uris - (t/async done - (let [file-id (uuid/next) - oids (make-obj-ids file-id 2) - uris ["blob:http://localhost/thumb-1" - "blob:http://localhost/thumb-2"] - state {deletion-queue-key (zipmap oids uris)} - event (#'thumbnails/clear-thumbnail-batch)] - (ptk/update event state) - (mock/with-mocks - {app.main.repo/cmd! (fn [_ _] (rx/of nil)) - app.util.webapi/revoke-uri mock/revoke-uri-mock - app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} - (fn [done'] - (->> (ptk/watch event state nil) - (rx/reduce conj []) - (rx/subs! - (fn [_] nil) - (fn [err] (t/is (nil? err)) (done')) - (fn [_] - (t/is (= (set uris) (set @mock/revoked-uris))) - (done'))))) - done)))) - - (t/deftest clear-thumbnail-batch-watch-empty-queue-no-rpc - (t/async done - (let [event (#'thumbnails/clear-thumbnail-batch) - state {}] - (ptk/update event state) - (mock/with-mocks - {app.main.repo/cmd! mock/rpc-cmd-mock - app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} - (fn [done'] - (->> (ptk/watch event state nil) - (rx/reduce conj []) - (rx/subs! - (fn [_] nil) - (fn [err] (t/is (nil? err)) (done')) - (fn [_] - (t/is (empty? @mock/rpc-calls)) - (done'))))) - done)))) - - (t/deftest clear-thumbnail-watch-emits-batch-after-debounce - (t/async done - (let [file-id (uuid/next) - object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") - uri "blob:http://localhost/thumb" - state {:thumbnails {object-id uri}} - event (thumbnails/clear-thumbnail file-id object-id)] - (ptk/update event state) - (mock/with-mocks - {beicon.v2.core/timer mock/timer-mock} - (fn [done'] - (->> (ptk/watch event state nil) - (rx/reduce conj []) - (rx/subs! - (fn [_] nil) - (fn [err] (t/is (nil? err)) (done')) (fn [events] (t/is (= 1 (count events))) (t/is (ptk/event? (first events))) - (done'))))) - done))))) + (done')) + (fn [err] (t/is (nil? err)) (done')) + nil)))) + done)))) diff --git a/frontend/test/frontend_tests/helpers/mock.cljc b/frontend/test/frontend_tests/helpers/mock.cljc index fce14876a3..fad0e8c3d1 100644 --- a/frontend/test/frontend_tests/helpers/mock.cljc +++ b/frontend/test/frontend_tests/helpers/mock.cljc @@ -106,9 +106,12 @@ (defn rpc-cmd-mock "Records [cmd params] in [[rpc-calls]], returns `(rx/of nil)`." - [cmd params] - (swap! rpc-calls conj {:cmd cmd :params params}) - (rx/of nil)) + ([cmd params] + (swap! rpc-calls conj {:cmd cmd :params params}) + (rx/of nil)) + ([cmd params _opts] + (swap! rpc-calls conj {:cmd cmd :params params}) + (rx/of nil))) (defn revoke-uri-mock "Records `uri` in [[revoked-uris]]." @@ -117,8 +120,10 @@ (defn schedule-on-idle-mock "Calls `f` immediately instead of deferring to the idle queue." - [f] - (f)) + ([_ms f] + (f)) + ([f] + (f))) (defn timer-mock "Returns `(rx/of :immediate)` so debounce timers fire instantly From 8823f7ac4d5fa8c1a3e57342176af9518556f074 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 30 Jun 2026 14:50:50 +0200 Subject: [PATCH 038/100] :sparkles: Make v2 plugins default throw on error (#10433) --- common/src/app/common/types/plugins.cljc | 1 + frontend/src/app/main/data/plugins.cljs | 2 +- frontend/src/app/plugins/flags.cljs | 15 +++++++--- frontend/src/app/plugins/utils.cljs | 29 ++++++++++++++----- .../plugins/context_shapes_test.cljs | 22 +++++++------- .../frontend_tests/plugins/page_test.cljs | 7 +++-- .../poc-state-plugin/src/app/app.component.ts | 11 +++++++ plugins/apps/poc-state-plugin/src/plugin.ts | 25 ++++++++++++++++ plugins/libs/plugin-types/index.d.ts | 3 +- 9 files changed, 89 insertions(+), 26 deletions(-) diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index 1be3578cc1..7fe8a4c7d4 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -30,6 +30,7 @@ (def schema:registry-entry [:map [:plugin-id :string] + [:version {:optional true} :int] [:name :string] [:description {:optional true} :string] [:host :string] diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index 77f5d55013..6635fc070b 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -82,7 +82,7 @@ (defn- load-plugin! [{:keys [plugin-id name version description host code icon permissions]}] - (st/emit! (pflag/clear plugin-id) + (st/emit! (pflag/initialize plugin-id version) (save-current-plugin plugin-id)) (let [load-plugin (unchecked-get ug/global "ɵloadPlugin")] diff --git a/frontend/src/app/plugins/flags.cljs b/frontend/src/app/plugins/flags.cljs index ef0ca57ab6..b804df4d9c 100644 --- a/frontend/src/app/plugins/flags.cljs +++ b/frontend/src/app/plugins/flags.cljs @@ -6,17 +6,24 @@ (ns app.plugins.flags (:require + [app.common.data :as d] [app.main.store :as st] [app.plugins.utils :as u] [app.util.object :as obj] [potok.v2.core :as ptk])) -(defn clear - [id] - (ptk/reify ::reset +(defn initialize + "Initialize flags values for plugins" + [id version] + (ptk/reify ::initialize ptk/UpdateEvent (update [_ state] - (update-in state [:plugins :flags] assoc id {})))) + (let [version (d/nilv version 1)] + (update-in state [:plugins :flags] assoc id + {:natural-child-ordering false + ;; For version >= 2 harden the contract by throwing errors + ;; on validation failures + :throw-validation-errors (>= version 2)}))))) (defn- set-flag [id key value] diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index e08e792ada..2e0db5d324 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -323,14 +323,27 @@ message to the console." [plugin-id] (fn [cause] - (let [message - (if-let [explain (-> cause ex-data ::sm/explain)] - (do - (js/console.error (sm/humanize-explain explain)) - (error-messages explain)) - (ex-data cause))] - (js/console.log (.-stack cause)) - (not-valid plugin-id :error message)))) + (let [explain (-> cause ex-data ::sm/explain) + throw? (throw-validation-errors? plugin-id)] + (cond + ;; If it's a clojure error we throw as a validation error + (and throw? explain) + (throw-not-valid :error (error-messages explain)) + + ;; Unexpected errors we just propagate them + throw? + (throw cause) + + ;; If not throw is active we log the caught error + :else + (let [message + (if explain + (do + (js/console.error (sm/humanize-explain explain)) + (error-messages explain)) + (ex-data cause))] + (js/console.log (.-stack cause)) + (not-valid plugin-id :error message)))))) (defn is-main-component-proxy? [p] diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index dd1a7e3634..0bba39cd40 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -14,7 +14,8 @@ [app.util.object :as obj] [cljs.test :as t :include-macros true] [frontend-tests.helpers.state :as ths] - [frontend-tests.helpers.wasm :as thw])) + [frontend-tests.helpers.wasm :as thw] + [potok.v2.core :as ptk])) (t/deftest test-common-shape-properties (thw/with-wasm-mocks* @@ -25,6 +26,7 @@ ^js context (api/create-context "00000000-0000-0000-0000-000000000000") _ (set! st/state store) + _ (ptk/emit! store #(assoc-in % [:plugins :flags "00000000-0000-0000-0000-000000000000" :throw-validation-errors] true)) ^js file (. context -currentFile) ^js page (. context -currentPage) @@ -65,7 +67,7 @@ (t/is (= (.-x shape) 10)) (t/is (= (get-in @store (get-shape-path :x)) 10)) - (set! (.-x shape) "fail") + (t/is (thrown? js/Error (set! (.-x shape) "fail"))) (t/is (= (.-x shape) 10)) (t/is (= (get-in @store (get-shape-path :x)) 10))) @@ -74,7 +76,7 @@ (t/is (= (.-y shape) 50)) (t/is (= (get-in @store (get-shape-path :y)) 50)) - (set! (.-y shape) "fail") + (t/is (thrown? js/Error (set! (.-y shape) "fail"))) (t/is (= (.-y shape) 50)) (t/is (= (get-in @store (get-shape-path :y)) 50))) @@ -85,7 +87,7 @@ (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 (thrown? js/Error (.resize shape 0 0))) (t/is (= (.-width shape) 250)) (t/is (= (.-height shape) 300)) (t/is (= (get-in @store (get-shape-path :width)) 250)) @@ -115,7 +117,7 @@ (t/is (= (get-in @store (get-shape-path :proportion-lock)) true))) (t/testing " - constraintsHorizontal" - (set! (.-constraintsHorizontal shape) "fail") + (t/is (thrown? js/Error (set! (.-constraintsHorizontal shape) "fail"))) (t/is (not= (.-constraintsHorizontal shape) "fail")) (t/is (not= (get-in @store (get-shape-path :constraints-h)) "fail")) @@ -124,7 +126,7 @@ (t/is (= (get-in @store (get-shape-path :constraints-h)) :right))) (t/testing " - constraintsVertical" - (set! (.-constraintsVertical shape) "fail") + (t/is (thrown? js/Error (set! (.-constraintsVertical shape) "fail"))) (t/is (not= (.-constraintsVertical shape) "fail")) (t/is (not= (get-in @store (get-shape-path :constraints-v)) "fail")) @@ -175,7 +177,7 @@ (t/is (= (.-blendMode shape) "multiply")) (t/is (= (get-in @store (get-shape-path :blend-mode)) :multiply)) - (set! (.-blendMode shape) "fail") + (t/is (thrown? js/Error (set! (.-blendMode shape) "fail"))) (t/is (= (.-blendMode shape) "multiply")) (t/is (= (get-in @store (get-shape-path :blend-mode)) :multiply))) @@ -194,7 +196,7 @@ :color {:color "#fabada" :opacity 1} :hidden false}])))) (let [shadow #js {:style "fail"}] - (set! (.-shadows shape) #js [shadow]) + (t/is (thrown? js/Error (set! (.-shadows shape) #js [shadow]))) (t/is (= (-> (. shape -shadows) (aget 0) (aget "style")) "drop-shadow")))) (t/testing " - blur" @@ -211,7 +213,7 @@ (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 (thrown? js/Error (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" @@ -234,7 +236,7 @@ (t/is (= (get-in @store (get-shape-path :rotation)) 0))) (t/testing " - fills" - (set! (.-fills shape) #js [#js {:fillColor 100}]) + (t/is (thrown? js/Error (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")) diff --git a/frontend/test/frontend_tests/plugins/page_test.cljs b/frontend/test/frontend_tests/plugins/page_test.cljs index bcc59c4ce8..d29149e846 100644 --- a/frontend/test/frontend_tests/plugins/page_test.cljs +++ b/frontend/test/frontend_tests/plugins/page_test.cljs @@ -32,6 +32,7 @@ store (ths/setup-store file) _ (set! st/state store) _ (set! st/stream (ptk/input-stream store)) + _ (ptk/emit! store #(assoc-in % [:plugins :flags "00000000-0000-0000-0000-000000000000" :throw-validation-errors] true)) context (api/create-context "00000000-0000-0000-0000-000000000000")] {:file file :store store :context context})) @@ -89,9 +90,11 @@ ^js page2 (aget pages 1)] (t/is (instance? js/Promise (.openPage context page2 true))))) -(t/deftest test-open-page-invalid-arg-returns-nil +(t/deftest test-open-page-invalid-arg-throws + ;; With throwValidationErrors enabled an invalid argument surfaces as an + ;; exception instead of being silently logged. (let [^js context (:context (setup))] - (t/is (nil? (.openPage context "not-a-page"))))) + (t/is (thrown? js/Error (.openPage context "not-a-page"))))) (t/deftest test-open-page-resolves-when-page-changes (t/async done diff --git a/plugins/apps/poc-state-plugin/src/app/app.component.ts b/plugins/apps/poc-state-plugin/src/app/app.component.ts index 0eae67b448..7a293a1210 100644 --- a/plugins/apps/poc-state-plugin/src/app/app.component.ts +++ b/plugins/apps/poc-state-plugin/src/app/app.component.ts @@ -45,6 +45,13 @@ import type { Shape } from '@penpot/plugin-types'; +