Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-06-30 16:23:23 +02:00
commit c4e72fd7f9
163 changed files with 26611 additions and 8033 deletions

View File

@ -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

View File

@ -40,19 +40,13 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
pnpm install;
pnpm install -r;
- name: Run Lint
working-directory: ./plugins

View File

@ -6,7 +6,7 @@
org.clojure/clojure {:mvn/version "1.12.5"}
org.clojure/tools.namespace {:mvn/version "1.5.1"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-10"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-11"}
io.prometheus/simpleclient {:mvn/version "0.16.0"}
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
@ -39,7 +39,7 @@
metosin/reitit-core {:mvn/version "0.10.1"}
nrepl/nrepl {:mvn/version "1.7.0"}
org.postgresql/postgresql {:mvn/version "42.7.11"}
org.postgresql/postgresql {:mvn/version "42.7.12"}
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
@ -63,14 +63,16 @@
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.46.7"}
software.amazon.awssdk/sts {:mvn/version "2.46.7"}}
software.amazon.awssdk/s3 {:mvn/version "2.46.18"}
software.amazon.awssdk/sts {:mvn/version "2.46.18"}}
:paths ["src" "resources" "target/classes"]
:aliases
{:dev
{:extra-deps
{com.bhauman/rebel-readline {:mvn/version "0.1.7"}
{:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
"--enable-native-access=ALL-UNNAMED"]
:extra-deps
{com.bhauman/rebel-readline {:mvn/version "0.1.11"}
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
org.clojure/data.csv {:mvn/version "1.1.1"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
@ -84,9 +86,7 @@
:test
{:main-opts ["-m" "kaocha.runner"]
:jvm-opts ["-Dlog4j2.configurationFile=log4j2-devenv-repl.xml"
"--sun-misc-unsafe-memory-access=allow"
"--enable-native-access=ALL-UNNAMED"]
:jvm-opts ["-Dlog4j2.configurationFile=log4j2-devenv-repl.xml"]
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}}
:outdated

View File

@ -4,19 +4,19 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"dependencies": {
"luxon": "^3.4.4",
"sax": "^1.4.1"
"sax": "^1.6.0"
},
"devDependencies": {
"nodemon": "^3.1.2",
"nodemon": "^3.1.14",
"source-map-support": "^0.5.21",
"ws": "^8.17.0"
"ws": "^8.21.0"
},
"scripts": {
"lint": "clj-kondo --parallel --lint ../common/src src/",

84
backend/pnpm-lock.yaml generated
View File

@ -12,18 +12,18 @@ importers:
specifier: ^3.4.4
version: 3.7.2
sax:
specifier: ^1.4.1
version: 1.4.3
specifier: ^1.6.0
version: 1.6.0
devDependencies:
nodemon:
specifier: ^3.1.2
version: 3.1.11
specifier: ^3.1.14
version: 3.1.14
source-map-support:
specifier: ^0.5.21
version: 0.5.21
ws:
specifier: ^8.17.0
version: 8.18.3
specifier: ^8.21.0
version: 8.21.0
packages:
@ -31,15 +31,17 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@ -52,9 +54,6 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -104,14 +103,15 @@ packages:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nodemon@3.1.11:
resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==}
nodemon@3.1.14:
resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==}
engines: {node: '>=10'}
hasBin: true
@ -119,8 +119,8 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
pstree.remy@1.1.8:
@ -130,11 +130,12 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
sax@1.4.3:
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
@ -164,8 +165,8 @@ packages:
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@ -181,16 +182,15 @@ snapshots:
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
picomatch: 2.3.2
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
binary-extensions@2.3.0: {}
brace-expansion@1.1.12:
brace-expansion@5.0.7:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
balanced-match: 4.0.4
braces@3.0.3:
dependencies:
@ -210,8 +210,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
concat-map@0.0.1: {}
debug@4.4.3(supports-color@5.5.0):
dependencies:
ms: 2.1.3
@ -247,20 +245,20 @@ snapshots:
luxon@3.7.2: {}
minimatch@3.1.2:
minimatch@10.2.5:
dependencies:
brace-expansion: 1.1.12
brace-expansion: 5.0.7
ms@2.1.3: {}
nodemon@3.1.11:
nodemon@3.1.14:
dependencies:
chokidar: 3.6.0
debug: 4.4.3(supports-color@5.5.0)
ignore-by-default: 1.0.1
minimatch: 3.1.2
minimatch: 10.2.5
pstree.remy: 1.1.8
semver: 7.7.3
semver: 7.8.5
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.1
@ -268,21 +266,21 @@ snapshots:
normalize-path@3.0.0: {}
picomatch@2.3.1: {}
picomatch@2.3.2: {}
pstree.remy@1.1.8: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
picomatch: 2.3.2
sax@1.4.3: {}
sax@1.6.0: {}
semver@7.7.3: {}
semver@7.8.5: {}
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.3
semver: 7.8.5
source-map-support@0.5.21:
dependencies:
@ -303,4 +301,4 @@ snapshots:
undefsafe@2.0.5: {}
ws@8.18.3: {}
ws@8.21.0: {}

View File

@ -20,7 +20,13 @@
selmer/selmer {:mvn/version "1.13.4"}
criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "1.0.0"}
metosin/jsonista {:mvn/version "1.0.0"
:exclusions [com.fasterxml.jackson.core/jackson-core
com.fasterxml.jackson.core/jackson-databind]}
com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.0"}
com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.0"}
metosin/malli {:mvn/version "0.19.1"}
expound/expound {:mvn/version "0.9.0"}
@ -58,7 +64,7 @@
{org.clojure/tools.namespace {:mvn/version "1.5.1"}
thheller/shadow-cljs {:mvn/version "3.2.0"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
com.bhauman/rebel-readline {:mvn/version "0.1.5"}
com.bhauman/rebel-readline {:mvn/version "0.1.11"}
criterium/criterium {:mvn/version "0.4.6"}
mockery/mockery {:mvn/version "0.1.4"}}
:extra-paths ["test" "dev"]}

View File

@ -13,7 +13,7 @@
"devDependencies": {
"concurrently": "^10.0.3",
"nodemon": "^3.1.14",
"prettier": "3.8.4",
"prettier": "3.9.4",
"source-map-support": "^0.5.21",
"ws": "^8.21.0"
},

10
common/pnpm-lock.yaml generated
View File

@ -19,8 +19,8 @@ importers:
specifier: ^3.1.14
version: 3.1.14
prettier:
specifier: 3.8.4
version: 3.8.4
specifier: 3.9.4
version: 3.9.4
source-map-support:
specifier: ^0.5.21
version: 0.5.21
@ -161,8 +161,8 @@ packages:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
prettier@3.8.4:
resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==}
prettier@3.9.4:
resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==}
engines: {node: '>=14'}
hasBin: true
@ -378,7 +378,7 @@ snapshots:
picomatch@2.3.2: {}
prettier@3.8.4: {}
prettier@3.9.4: {}
pstree.remy@1.1.8: {}

View File

@ -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]

View File

@ -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]

View File

@ -30,6 +30,7 @@
(def schema:registry-entry
[:map
[:plugin-id :string]
[:version {:optional true} :int]
[:name :string]
[:description {:optional true} :string]
[:host :string]

View File

@ -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))

View File

@ -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)))))))

View File

@ -32,7 +32,7 @@ RUN set -ex; \
FROM base AS setup-node
ENV NODE_VERSION=v24.16.0 \
ENV NODE_VERSION=v24.18.0 \
PATH=/opt/node/bin:$PATH
RUN set -eux; \

View File

@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v24.16.0 \
NODE_VERSION=v24.18.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:/opt/imagick/bin:$PATH \
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers

View File

@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
NODE_VERSION=v24.18.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH \
PENPOT_MCP_SERVER_HOST=0.0.0.0

View File

@ -39,5 +39,5 @@
"markdown-it-anchor": "^9.0.1",
"markdown-it-plantuml": "^1.4.1"
},
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620"
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b"
}

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.5.3+sha512.7ac1c919341c213a34dc0d02afb7143c5c26ac26ee8c4782deea821b8ac64d2134a081fd8941dae6e29bbb48f58dfc2b7fbceeccc07cb2f09d219d342a4969ed",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
@ -18,7 +18,7 @@
"generic-pool": "^3.9.0",
"inflation": "^2.1.0",
"ioredis": "^5.11.1",
"playwright": "^1.61.0",
"playwright": "^1.61.1",
"raw-body": "^3.0.2",
"source-map-support": "^0.5.21",
"undici": "^8.5.0",

View File

@ -35,8 +35,8 @@ importers:
specifier: ^5.11.1
version: 5.11.1
playwright:
specifier: ^1.61.0
version: 1.61.0
specifier: ^1.61.1
version: 1.61.1
raw-body:
specifier: ^3.0.2
version: 3.0.2
@ -330,13 +330,13 @@ packages:
resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==}
engines: {node: '>=20.19.0'}
playwright-core@1.61.0:
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.0:
resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==}
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
@ -714,11 +714,11 @@ snapshots:
dependencies:
boolbase: 2.0.0
playwright-core@1.61.0: {}
playwright-core@1.61.1: {}
playwright@1.61.0:
playwright@1.61.1:
dependencies:
playwright-core: 1.61.0
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2

View File

@ -46,6 +46,9 @@
:dev
{:extra-paths ["dev"]
:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
"--enable-native-access=ALL-UNNAMED"]
:extra-deps
{thheller/shadow-cljs {:mvn/version "3.2.2"}
com.bhauman/rebel-readline {:mvn/version "RELEASE"}

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"browserslist": [
"defaults"
],
@ -58,18 +58,18 @@
"@penpot/tokenscript": "link:packages/tokenscript",
"@penpot/ua-parser": "penpot/ua-parser#1.0.0",
"@penpot/ui": "link:packages/ui",
"@playwright/test": "1.61.0",
"@playwright/test": "1.61.1",
"@storybook/addon-docs": "10.4.6",
"@storybook/addon-themes": "10.4.6",
"@storybook/addon-vitest": "10.4.6",
"@storybook/react-vite": "10.4.6",
"@tokens-studio/sd-transforms": "2.0.3",
"@types/node": "^25.9.3",
"@types/node": "^26.0.1",
"@vitest/browser": "4.1.9",
"@vitest/browser-playwright": "^4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@zip.js/zip.js": "2.8.26",
"autoprefixer": "^10.4.27",
"autoprefixer": "^10.5.2",
"compression": "^1.8.1",
"concurrently": "^10.0.3",
"date-fns": "^4.4.0",
@ -92,12 +92,12 @@
"npm-run-all": "^4.1.5",
"opentype.js": "^2.0.0",
"p-limit": "^7.3.0",
"playwright": "1.61.0",
"postcss": "^8.5.15",
"playwright": "1.61.1",
"postcss": "^8.5.16",
"postcss-clean": "^1.2.2",
"postcss-modules": "^6.0.1",
"postcss-scss": "^4.0.9",
"prettier": "3.8.4",
"prettier": "3.9.4",
"pretty-time": "^1.1.0",
"prop-types": "^15.8.1",
"randomcolor": "^0.6.2",
@ -114,15 +114,15 @@
"source-map-support": "^0.5.21",
"storybook": "10.4.6",
"style-dictionary": "5.4.4",
"stylelint": "^17.13.0",
"stylelint": "^17.14.0",
"stylelint-config-standard-scss": "^17.0.0",
"stylelint-scss": "^7.2.0",
"stylelint-plugin-logical-css": "^2.1.0",
"stylelint-scss": "^7.2.0",
"svg-sprite": "^2.0.4",
"tdigest": "^0.1.2",
"tinycolor2": "^1.6.0",
"typescript": "^6.0.2",
"vite": "^8.0.16",
"vite": "^8.1.0",
"vitest": "^4.1.9",
"wait-on": "^9.0.4",
"watcher": "^2.3.1",

View File

@ -4,12 +4,12 @@
"description": "Penpot Draft-JS Wrapper",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0",
"immutable": "^5.1.6"
"immutable": "^5.1.9"
},
"peerDependencies": {
"react": ">=0.17.0",

View File

@ -4,7 +4,7 @@
"description": "Simple library for handling keyboard shortcuts",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"author": "Craig Campbell",
"license": "Apache-2.0 WITH LLVM-exception"
}

View File

@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {

View File

@ -14,15 +14,15 @@
"build": "vite build"
},
"devDependencies": {
"@babel/core": "^8.0.0",
"@babel/preset-react": "^8.0.0",
"@babel/core": "^8.0.1",
"@babel/preset-react": "^8.0.1",
"@storybook/react": "10.4.6",
"@storybook/react-vite": "10.4.6",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^6.0.3",
"babel-plugin-react-compiler": "^1.0.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
@ -30,7 +30,7 @@
"eslint-plugin-react-hooks": "7.1.1",
"react-compiler-runtime": "^1.0.0",
"storybook": "10.4.6",
"vite-plugin-dts": "^5.0.2"
"vite-plugin-dts": "^5.0.3"
},
"peerDependencies": {
"react": ">=19.2",

View File

@ -0,0 +1 @@
{"~:total": 2}

View File

@ -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"
}
}
}
}

View File

@ -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"
}
]

View File

@ -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();

View File

@ -0,0 +1,86 @@
import { test, expect } from "@playwright/test";
import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
});
test("Open invite members modal from invitations section", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await dashboardPage.setupTeamInvitationsEmpty();
await dashboardPage.goToSecondTeamInvitationsSection();
await expect(page.getByRole("button", { name: "Invite people" })).toBeVisible();
await page.getByRole("button", { name: "Invite people" }).click();
await expect(page.getByText("Invite members to the team")).toBeVisible();
});
test("Invite a new member by email", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await dashboardPage.setupTeamInvitationsEmpty();
await DashboardPage.mockRPC(
page,
"create-team-invitations",
"dashboard/create-team-invitations.json",
{ method: "POST" },
);
await dashboardPage.goToSecondTeamInvitationsSection();
await page.getByRole("button", { name: "Invite people" }).click();
await expect(page.getByText("Invite members to the team")).toBeVisible();
const emailInput = page.getByRole("textbox", { name: "Emails, comma separated" });
await emailInput.fill("newmember@example.com");
await emailInput.press("Enter");
await page.getByRole("button", { name: "Send invitation" }).click();
await expect(page.getByText("Invitation sent successfully")).toBeVisible();
await expect(
page.getByText("Invite members to the team"),
).not.toBeVisible();
});
test("Show warning when inviting an existing member", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await dashboardPage.setupTeamInvitationsEmpty();
await dashboardPage.goToSecondTeamInvitationsSection();
await page.getByRole("button", { name: "Invite people" }).click();
await expect(page.getByText("Invite members to the team")).toBeVisible();
const emailInput = page.getByRole("textbox", { name: "Emails, comma separated" });
await emailInput.fill("foo@example.com");
await emailInput.press("Enter");
await expect(
page.getByText(
"Some members are already on the team. We'll invite the rest.",
),
).toBeVisible();
});
test("Disable send button when all entered emails are existing members", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await dashboardPage.setupTeamInvitationsEmpty();
await dashboardPage.goToSecondTeamInvitationsSection();
await page.getByRole("button", { name: "Invite people" }).click();
await expect(page.getByText("Invite members to the team")).toBeVisible();
const emailInput = page.getByRole("textbox", { name: "Emails, comma separated" });
await emailInput.fill("foo@example.com");
await emailInput.press("Enter");
const sendButton = page.getByRole("button", { name: "Send invitation" });
await expect(sendButton).toBeDisabled();
});

1170
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -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")]

View File

@ -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 [_]
@ -441,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?)
@ -775,44 +775,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]

View File

@ -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)

View File

@ -1380,7 +1380,7 @@
[]
(ptk/reify ::watch-component-changes
ptk/WatchEvent
(watch [_ _ stream]
(watch [_ state stream]
(let [stopper-s
(->> stream
(rx/map ptk/type)
@ -1450,7 +1450,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
@ -1458,7 +1458,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)

View File

@ -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?]

View File

@ -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)

View File

@ -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

View File

@ -459,7 +459,7 @@
(into [] (distinct) (conj coll item)))
(mf/defc multi-input
[{:keys [form label class name trim valid-item-fn caution-item-fn on-submit] :as props}]
[{:keys [form label class trim valid-item-fn caution-item-fn on-submit] :as props}]
(let [form (or form (mf/use-ctx form-ctx))
input-name (get props :name)
touched? (get-in @form [:touched input-name])
@ -610,6 +610,7 @@
[:div {:class klass}
[:input {:id (name input-name)
:name (name input-name)
:class in-klass
:type "text"
:auto-focus auto-focus?

View File

@ -168,10 +168,13 @@
::mf/register-as :invite-members
::mf/props :obj}
[{:keys [team origin invite-email]}]
(let [members (get team :members)
(let [teams (mf/deref refs/teams)
perms (get team :permissions)
team-id (get team :id)
members (get-in teams [team-id :members])
roles (mf/with-memo [perms]
(get-available-roles perms))
@ -824,6 +827,7 @@
[:div {:class (stl/css :empty-invitations-buttons)}
[:a
{:class (stl/css :btn-empty-invitations)
:role "button"
:on-click on-invite-member
:data-testid "invite-member"}
(tr "dashboard.invite-profile")]]

View File

@ -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

View File

@ -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)

View File

@ -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)]

View File

@ -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]

View File

@ -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

View File

@ -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 %)}

View File

@ -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]

View File

@ -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]

View File

@ -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))

View File

@ -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)

View File

@ -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

View File

@ -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 []

View File

@ -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]

View File

@ -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)}

View File

@ -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))]

View File

@ -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"))

View File

@ -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

View File

@ -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))))))}

View File

@ -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]

View File

@ -1073,66 +1073,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]

View File

@ -131,6 +131,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
;; ═══════════════════════════════════════════════════════════════

View File

@ -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))))))

View File

@ -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"))

View File

@ -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)))))

View File

@ -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))))

View File

@ -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)))))))

View File

@ -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))))))

View File

@ -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))))))

View File

@ -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

View File

@ -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])))))

View File

@ -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 [])))))

View File

@ -6,8 +6,18 @@
(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]
[cljs.test :as t :include-macros true]))
[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")
;; Regression coverage for issue #9780.
;;
@ -35,3 +45,71 @@
(t/is (not (valid? "abc")))
(t/is (not (valid? "1-2")))
(t/is (not (valid? "--1"))))
(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")))))))

View File

@ -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)))))

View File

@ -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))))

View File

@ -27,12 +27,18 @@
[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]
@ -45,7 +51,9 @@
[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]
[frontend-tests.util-range-tree-test]
[frontend-tests.util-simple-math-test]
@ -65,7 +73,8 @@
(.exit js/process 1)))
(def test-namespaces
['frontend-tests.code-gen-style-test
['frontend-tests.basic-shapes-test
'frontend-tests.code-gen-style-test
'frontend-tests.copy-as-svg-test
'frontend-tests.data.nitrate-test
'frontend-tests.data.repo-test
@ -87,12 +96,18 @@
'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
@ -105,13 +120,14 @@
'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
'frontend-tests.util-range-tree-test
'frontend-tests.util-simple-math-test
'frontend-tests.util-webapi-test
'frontend-tests.worker-snap-test
'frontend-tests.basic-shapes-test])
'frontend-tests.worker-snap-test])
(assert (every? find-ns-obj test-namespaces)
"test-namespaces contains a namespace that isn't required in runner.cljs")

View File

@ -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))))))

View File

@ -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)))))))

View File

@ -15,18 +15,18 @@
"test:watch:e2e": "vitest --browser"
},
"devDependencies": {
"@playwright/test": "1.61.0",
"@types/node": "^25.9.2",
"@playwright/test": "1.61.1",
"@types/node": "^26.0.1",
"@vitest/browser": "^4.1.9",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/ui": "^4.1.9",
"canvas": "^3.2.3",
"esbuild": "^0.28.0",
"jsdom": "^29.1.1",
"playwright": "1.61.0",
"prettier": "^3.8.4",
"vite": "^8.0.16",
"playwright": "1.61.1",
"prettier": "^3.9.4",
"vite": "^8.1.1",
"vitest": "^4.1.9"
},
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620"
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b"
}

View File

@ -3,7 +3,7 @@
"version": "1.2.0-RC1",
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"type": "module",
"repository": {
"type": "git",
@ -34,11 +34,11 @@
"watch": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch library"
},
"devDependencies": {
"@types/node": "^22.18.12",
"@zip.js/zip.js": "2.8.11",
"concurrently": "^9.2.1",
"date-fns": "^4.1.0",
"nodemon": "^3.1.10",
"@types/node": "^26.0.1",
"@zip.js/zip.js": "2.8.26",
"concurrently": "^10.0.3",
"date-fns": "^4.4.0",
"nodemon": "^3.1.14",
"source-map-support": "^0.5.21"
}
}

300
library/pnpm-lock.yaml generated
View File

@ -5,63 +5,63 @@ settings:
excludeLinksFromLockfile: false
patchedDependencies:
'@zip.js/zip.js@2.8.11':
hash: 7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95
path: patches/@zip.js__zip.js@2.8.11.patch
'@zip.js/zip.js@2.8.26': 7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95
importers:
.:
devDependencies:
'@types/node':
specifier: ^22.18.12
version: 22.19.3
specifier: ^26.0.1
version: 26.0.1
'@zip.js/zip.js':
specifier: 2.8.11
version: 2.8.11(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95)
specifier: 2.8.26
version: 2.8.26(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95)
concurrently:
specifier: ^9.2.1
version: 9.2.1
specifier: ^10.0.3
version: 10.0.3
date-fns:
specifier: ^4.1.0
version: 4.1.0
specifier: ^4.4.0
version: 4.4.0
nodemon:
specifier: ^3.1.10
version: 3.1.11
specifier: ^3.1.14
version: 3.1.14
source-map-support:
specifier: ^0.5.21
version: 0.5.21
packages:
'@types/node@22.19.3':
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
'@types/node@26.0.1':
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
'@zip.js/zip.js@2.8.11':
resolution: {integrity: sha512-0fztsk/0ryJ+2PPr9EyXS5/Co7OK8q3zY/xOoozEWaUsL5x+C0cyZ4YyMuUffOO2Dx/rAdq4JMPqW0VUtm+vzA==}
'@zip.js/zip.js@2.8.26':
resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==}
engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@ -70,35 +70,25 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
concurrently@9.2.1:
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
engines: {node: '>=18'}
concurrently@10.0.3:
resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==}
engines: {node: '>=22'}
hasBin: true
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
@ -109,8 +99,8 @@ packages:
supports-color:
optional: true
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
@ -129,6 +119,10 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.6.0:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@ -137,10 +131,6 @@ packages:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
@ -152,10 +142,6 @@ packages:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
@ -164,14 +150,15 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nodemon@3.1.11:
resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==}
nodemon@3.1.14:
resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==}
engines: {node: '>=10'}
hasBin: true
@ -179,8 +166,8 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
pstree.remy@1.1.8:
@ -190,20 +177,16 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
shell-quote@1.8.4:
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
engines: {node: '>= 0.4'}
simple-update-notifier@2.0.0:
@ -217,26 +200,22 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -255,52 +234,49 @@ packages:
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yargs@18.0.0:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
snapshots:
'@types/node@22.19.3':
'@types/node@26.0.1':
dependencies:
undici-types: 6.21.0
undici-types: 8.3.0
'@zip.js/zip.js@2.8.11(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95)': {}
'@zip.js/zip.js@2.8.26(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95)': {}
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
picomatch: 2.3.2
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
binary-extensions@2.3.0: {}
brace-expansion@1.1.12:
brace-expansion@5.0.7:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
balanced-match: 4.0.4
braces@3.0.3:
dependencies:
@ -308,10 +284,7 @@ snapshots:
buffer-from@1.1.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@5.6.2: {}
chokidar@3.6.0:
dependencies:
@ -325,30 +298,22 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
cliui@8.0.1:
cliui@9.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
string-width: 7.2.0
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
color-convert@2.0.1:
concurrently@10.0.3:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
concat-map@0.0.1: {}
concurrently@9.2.1:
dependencies:
chalk: 4.1.2
chalk: 5.6.2
rxjs: 7.8.2
shell-quote: 1.8.3
supports-color: 8.1.1
shell-quote: 1.8.4
supports-color: 10.2.2
tree-kill: 1.2.2
yargs: 17.7.2
yargs: 18.0.0
date-fns@4.1.0: {}
date-fns@4.4.0: {}
debug@4.4.3(supports-color@5.5.0):
dependencies:
@ -356,7 +321,7 @@ snapshots:
optionalDependencies:
supports-color: 5.5.0
emoji-regex@8.0.0: {}
emoji-regex@10.6.0: {}
escalade@3.2.0: {}
@ -369,14 +334,14 @@ snapshots:
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
has-flag@3.0.0: {}
has-flag@4.0.0: {}
ignore-by-default@1.0.1: {}
is-binary-path@2.1.0:
@ -385,28 +350,26 @@ snapshots:
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
minimatch@3.1.2:
minimatch@10.2.5:
dependencies:
brace-expansion: 1.1.12
brace-expansion: 5.0.7
ms@2.1.3: {}
nodemon@3.1.11:
nodemon@3.1.14:
dependencies:
chokidar: 3.6.0
debug: 4.4.3(supports-color@5.5.0)
ignore-by-default: 1.0.1
minimatch: 3.1.2
minimatch: 10.2.5
pstree.remy: 1.1.8
semver: 7.7.3
semver: 7.8.5
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.1
@ -414,27 +377,25 @@ snapshots:
normalize-path@3.0.0: {}
picomatch@2.3.1: {}
picomatch@2.3.2: {}
pstree.remy@1.1.8: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
require-directory@2.1.1: {}
picomatch: 2.3.2
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
semver@7.7.3: {}
semver@7.8.5: {}
shell-quote@1.8.3: {}
shell-quote@1.8.4: {}
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.3
semver: 7.8.5
source-map-support@0.5.21:
dependencies:
@ -443,28 +404,22 @@ snapshots:
source-map@0.6.1: {}
string-width@4.2.3:
string-width@7.2.0:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
emoji-regex: 10.6.0
get-east-asian-width: 1.6.0
strip-ansi: 7.2.0
strip-ansi@6.0.1:
strip-ansi@7.2.0:
dependencies:
ansi-regex: 5.0.1
ansi-regex: 6.2.2
supports-color@10.2.2: {}
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@ -477,24 +432,23 @@ snapshots:
undefsafe@2.0.5: {}
undici-types@6.21.0: {}
undici-types@8.3.0: {}
wrap-ansi@7.0.0:
wrap-ansi@9.0.2:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.2.0
y18n@5.0.8: {}
yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}
yargs@17.7.2:
yargs@18.0.0:
dependencies:
cliui: 8.0.1
cliui: 9.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
string-width: 7.2.0
y18n: 5.0.8
yargs-parser: 21.1.1
yargs-parser: 22.0.0

View File

@ -1,2 +1,2 @@
patchedDependencies:
'@zip.js/zip.js@2.8.11': patches/@zip.js__zip.js@2.8.11.patch
'@zip.js/zip.js@2.8.26': patches/@zip.js__zip.js@2.8.11.patch

View File

@ -22,9 +22,9 @@
"type": "git",
"url": "https://github.com/penpot/penpot.git"
},
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"devDependencies": {
"concurrently": "^10.0.3",
"prettier": "^3.8.4"
"prettier": "^3.9.1"
}
}

View File

@ -4,7 +4,7 @@
"description": "Shared type definitions and interfaces for Penpot MCP",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"scripts": {
"build": "tsc --build --clean && tsc --build",
"watch": "tsc --watch",
@ -12,7 +12,7 @@
"clean": "rm -rf dist/"
},
"devDependencies": {
"typescript": "^5.0.0"
"typescript": "^6.0.3"
},
"files": [
"dist/**/*"

View File

@ -12,13 +12,13 @@
"clean": "rm -rf dist/"
},
"dependencies": {
"@penpot/plugin-styles": "1.4.1",
"@penpot/plugin-types": "1.4.1"
"@penpot/plugin-styles": "1.4.2",
"@penpot/plugin-types": "1.4.2"
},
"devDependencies": {
"cross-env": "^7.0.3",
"typescript": "^5.8.3",
"vite": "^7.0.8",
"vite-live-preview": "^0.3.2"
"cross-env": "^10.1.0",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vite-live-preview": "^0.4.0"
}
}

View File

@ -24,35 +24,35 @@
],
"author": "",
"license": "MIT",
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.24.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"class-validator": "^0.15.1",
"express": "^5.1.0",
"ioredis": "^5.6.0",
"js-yaml": "^4.1.1",
"ioredis": "^5.11.1",
"js-yaml": "^5.2.0",
"nrepl-client": "^0.3.0",
"penpot-mcp": "file:..",
"pino": "^9.10.0",
"pino-loki": "^2.6.0",
"pino": "^10.3.1",
"pino-loki": "^3.0.0",
"pino-pretty": "^13.1.1",
"reflect-metadata": "^0.1.13",
"sharp": "^0.34.5",
"ws": "^8.18.0",
"zod": "^4.3.6"
"reflect-metadata": "^0.2.2",
"sharp": "^0.35.2",
"ws": "^8.21.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@penpot/mcp-common": "workspace:../common",
"@types/express": "^4.17.0",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@types/node": "^26.0.1",
"@types/ws": "^8.5.10",
"cross-env": "^7.0.3",
"esbuild": "^0.25.0",
"cross-env": "^10.1.0",
"esbuild": "^0.28.1",
"ts-node": "^10.9.2",
"tsx": "^4.22.3",
"typescript": "^5.0.0"
"typescript": "^6.0.3"
},
"ts-node": {
"esm": true

View File

@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",

2154
mcp/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
@ -16,10 +16,10 @@
"fmt": "./scripts/fmt"
},
"devDependencies": {
"@types/node": "^26.0.0",
"@types/node": "^26.0.1",
"esbuild": "^0.28.1",
"mdts": "^0.20.3",
"nrepl-client": "^0.3.0",
"opencode-ai": "^1.17.9"
"opencode-ai": "^1.17.11"
}
}

View File

@ -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<void>` 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)

View File

@ -10,7 +10,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/apps/contrast-plugin",
@ -62,7 +62,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": { "buildTarget": "contrast-plugin:build:production" },
"development": {
@ -82,7 +82,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/apps/icons-plugin",
@ -134,7 +134,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": { "buildTarget": "icons-plugin:build:production" },
"development": {
@ -154,7 +154,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/apps/lorem-ipsum-plugin",
@ -206,7 +206,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "lorem-ipsum-plugin:build:production"
@ -228,7 +228,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/apps/table-plugin",
@ -280,7 +280,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": { "buildTarget": "table-plugin:build:production" },
"development": {
@ -300,7 +300,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/apps/rename-layers-plugin",
@ -352,7 +352,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "rename-layers-plugin:build:production"
@ -374,7 +374,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/apps/colors-to-tokens-plugin",
@ -426,7 +426,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "colors-to-tokens-plugin:build:production"
@ -448,7 +448,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/apps/poc-state-plugin",
@ -499,7 +499,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "poc-state-plugin:build:production"
@ -521,7 +521,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/apps/poc-tokens-plugin",
@ -573,7 +573,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "poc-tokens-plugin:build:production"

View File

@ -35,9 +35,7 @@ export interface ResizePluginUIEvent {
}
export type PluginUIEvent =
| GETColorsPluginUIEvent
| ResizePluginUIEvent
| ResetPluginUIEvent;
GETColorsPluginUIEvent | ResizePluginUIEvent | ResetPluginUIEvent;
export interface ThemePluginEvent {
type: 'theme';

View File

@ -24,6 +24,4 @@ export interface ThemePluginEvent {
}
export type PluginMessageEvent =
| InitPluginEvent
| SelectionPluginEvent
| ThemePluginEvent;
InitPluginEvent | SelectionPluginEvent | ThemePluginEvent;

View File

@ -1,8 +1,5 @@
export type GenerationTypes =
| 'paragraphs'
| 'sentences'
| 'words'
| 'characters';
'paragraphs' | 'sentences' | 'words' | 'characters';
export interface InitPluginUIEvent {
type: 'ready';
@ -35,6 +32,4 @@ export interface ThemePluginEvent {
}
export type PluginMessageEvent =
| InitPluginEvent
| SelectionPluginEvent
| ThemePluginEvent;
InitPluginEvent | SelectionPluginEvent | ThemePluginEvent;

View File

@ -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<void>`; 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<Context, 'addListener' |
'removeListener'>` (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<Context, 'addListener' | 'removeListener'>`), 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/<name>.test.ts` and uses `test(...)` + `expect`,
ideally wrapped in a `describe('<Group>', …)`.
- [ ] 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/`.

View File

@ -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
}
]
}
}

View File

@ -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 = <root>/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<FileRpc> {
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<string, string> = {
'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<void> {
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<void> {
const created = new Set<string>();
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<string> }).__wsCreated = created;
}
async function openNotificationsWebSocket(page: Page): Promise<void> {
const created = (page as unknown as { __wsCreated: Set<string> }).__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<void> {
// 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<void>((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<void>((_, 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);
});

View File

@ -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',
],
},
];

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Plugin API Test Suite</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<main id="app" class="wrapper"></main>
<script type="module" src="/src/ui.ts"></script>
</body>
</html>

View File

@ -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"
}
}

View File

@ -0,0 +1,4 @@
/*
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type

Some files were not shown because too many files have changed in this diff Show More