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

This commit is contained in:
Andrey Antukh 2026-10-01 19:25:59 +02:00
commit 84c794c5b8
47 changed files with 1553 additions and 823 deletions

View File

@ -1,6 +1,6 @@
# Devenv startup and configuration # Devenv startup and configuration
Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Parallel instances share infra + Postgres + RustFS; each instance has its own `main` container, Valkey, source checkout, tmux session. Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Parallel instances share infra + Postgres + RustFS; each instance has its own `main` container, Valkey, source checkout, tmux session. The main database (`penpot`) and the S3 bucket (`penpot`) are shared by all instances on purpose; backend-test databases are isolated per instance (`penpot_test` on ws0, `penpot_test_wsN` on wsN) with per-instance Valkey DB numbers (`6+N`, since runtime backends occupy `0..5`).
## Compose project layout ## Compose project layout
@ -12,7 +12,8 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
## Source-of-truth files ## Source-of-truth files
- `docker/devenv/defaults.env`: ws0 baseline — container/volume names, runtime env, published host ports, tmux defaults. `manage.sh` aborts if unreadable. - `docker/devenv/defaults.env`: ws0 baseline — container/volume names, runtime env, published host ports, tmux defaults. `manage.sh` aborts if unreadable.
- For ws1+, `instance-env-overrides` computes the per-instance overrides (container/volume names, host ports offset `10000·N`, `PENPOT_PUBLIC_URI`, `PENPOT_REDIS_URI`, `PENPOT_BACKEND_WORKER=false`) and `instance-compose` injects them as env vars at compose time — never written to disk, recomputed each call so they can't drift. ws0 uses `defaults.env` as-is. - For ws1+, `instance-env-overrides` computes the per-instance overrides (container/volume names, host ports offset `10000·N`, `PENPOT_PUBLIC_URI`, `PENPOT_REDIS_URI`, `PENPOT_TEST_DATABASE_URI`, `PENPOT_TEST_REDIS_URI`) and `instance-compose` injects them as env vars at compose time — never written to disk, recomputed each call so they can't drift. ws0 uses `defaults.env` as-is.
- Per-instance test databases are created by `ensure-instance-test-database` (called from `start-instance`): `CREATE DATABASE` guarded by a `pg_database` existence check, so it is idempotent. Needed because `postgresql_init.sql` only runs on first volume creation. The backend applies migrations itself on first use, so an empty database is enough.
- `backend/scripts/_env`: backend-internal only — secret keys, `PENPOT_FLAGS` (with `enable-backend-worker` gated on `PENPOT_BACKEND_WORKER`), `JAVA_OPTS`, `setup_s3_bucket()`. Never duplicates `defaults.env`. - `backend/scripts/_env`: backend-internal only — secret keys, `PENPOT_FLAGS` (with `enable-backend-worker` gated on `PENPOT_BACKEND_WORKER`), `JAVA_OPTS`, `setup_s3_bucket()`. Never duplicates `defaults.env`.
- Compose files use pure `${VAR}` substitution; missing var = compose fails. - Compose files use pure `${VAR}` substitution; missing var = compose fails.

View File

@ -16,13 +16,20 @@ development.
# Default connection (penpot db, localhost) # Default connection (penpot db, localhost)
scripts/psql -c "SELECT version();" scripts/psql -c "SELECT version();"
# Test database # Test database (ws0)
scripts/psql --test -c "SELECT * FROM migrations;" scripts/psql --test -c "SELECT * FROM migrations;"
# Isolated test database of another instance (ws1+)
scripts/psql --test --ws 1 -c "SELECT * FROM migrations;"
# Custom host/user/database # Custom host/user/database
scripts/psql --host myhost --user myuser --db mydb scripts/psql --host myhost --user myuser --db mydb
``` ```
`--ws N` only applies together with `--test` (the main database is
shared by all instances): it selects `penpot_test` on `--ws 0` and
`penpot_test_wsN` on `--ws N`. `scripts/db-schema` accepts the same flags.
`scripts/psql` must be invoked from the repo root so the path resolves. `scripts/psql` must be invoked from the repo root so the path resolves.
## Native Tool Available (OpenCode V2) ## Native Tool Available (OpenCode V2)

View File

@ -51,4 +51,10 @@
{:permits 4} {:permits 4}
:import-binfile/by-profile :import-binfile/by-profile
{:permits 1 :queue 2}
:profile-plugin-ops/global
{:permits 4}
:profile-plugin-ops/by-profile
{:permits 1 :queue 2}} {:permits 1 :queue 2}}

View File

@ -221,6 +221,8 @@
;; ═══════════════════════════════════════════════ ;; ═══════════════════════════════════════════════
#{:main/update-profile #{:main/update-profile
:main/update-profile-props :main/update-profile-props
:main/add-profile-plugin
:main/remove-profile-plugin
:main/update-profile-photo :main/update-profile-photo
:main/update-profile-password :main/update-profile-password
:main/update-profile-notifications :main/update-profile-notifications

View File

@ -165,6 +165,9 @@
[:binfile-import-max-text-total-size {:optional true} ::sm/int] [:binfile-import-max-text-total-size {:optional true} ::sm/int]
[:binfile-import-max-zip-entries {:optional true} ::sm/int] [:binfile-import-max-zip-entries {:optional true} ::sm/int]
;; Max serialized size of profile props in bytes (default 2 MiB)
[:profile-props-max-size {:optional true} ::sm/int]
[:login-lockout-max-attempts {:optional true} ::sm/int] [:login-lockout-max-attempts {:optional true} ::sm/int]
[:login-lockout-window {:optional true} ::ct/duration] [:login-lockout-window {:optional true} ::ct/duration]

View File

@ -8,6 +8,7 @@
"A penpot notification service for file cooperative edition." "A penpot notification service for file cooperative edition."
(:require (:require
[app.binfile.common :as bfc] [app.binfile.common :as bfc]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.pprint :as pp] [app.common.pprint :as pp]
@ -18,6 +19,7 @@
[app.http.session :as session] [app.http.session :as session]
[app.metrics :as mtx] [app.metrics :as mtx]
[app.msgbus :as mbus] [app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc.commands.files :as files] [app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
[app.util.websocket :as ws] [app.util.websocket :as ws]
@ -42,13 +44,13 @@
(defn repl-get-connections-for-file (defn repl-get-connections-for-file
[file-id] [file-id]
(->> (vals @state) (->> (vals @state)
(filter #(= file-id (-> % deref ::file-subscription :file-id))) (filter #(= file-id (-> % ::ws/state deref ::file-subscription :file-id)))
(map ::ws/id))) (map ::ws/id)))
(defn repl-get-connections-for-team (defn repl-get-connections-for-team
[team-id] [team-id]
(->> (vals @state) (->> (vals @state)
(filter #(= team-id (-> % deref ::team-subscription :team-id))) (filter #(= team-id (-> % ::ws/state deref ::team-subscription :team-id)))
(map ::ws/id))) (map ::ws/id)))
(defn repl-close-connection (defn repl-close-connection
@ -60,15 +62,17 @@
(defn repl-get-connection-info (defn repl-get-connection-info
[id] [id]
(when-let [wsp (get @state id)] (when-let [wsp (get @state id)]
{:id id (let [subs (some-> wsp ::ws/state deref)]
:created-at (::created-at wsp) {:id id
:profile-id (::profile-id wsp) :created-at (::created-at wsp)
:session-id (::session-id wsp) :profile-id (::profile-id wsp)
:user-agent (::ws/user-agent wsp) :session-id (::session-id wsp)
:ip-addr (::ws/remote-addr wsp) :user-agent (::ws/user-agent wsp)
:last-activity-at (::ws/last-activity-at wsp) :ip-addr (::ws/remote-addr wsp)
:subscribed-file (-> wsp ::file-subscription :file-id) :last-activity-at (::ws/last-activity-at wsp)
:subscribed-team (-> wsp ::team-subscription :team-id)})) :subscribed-file (-> subs ::file-subscription :file-id)
:subscribed-team (-> subs ::team-subscription :team-id)
:subscribed-org (-> subs ::team-subscription :organization-id)})))
(defn repl-print-connection-info (defn repl-print-connection-info
[id] [id]
@ -133,18 +137,39 @@
(mbus/purge! msgbus [channel]) (mbus/purge! msgbus [channel])
(mbus/pub! msgbus :topic topic :message msg)))) (mbus/pub! msgbus :topic topic :message msg))))
(defn- get-team-organization-id
"Returns the id of the organization that owns `team-id`, or nil when
the team has no organization or nitrate cannot be reached."
[cfg team-id]
(try
(-> (nitrate/call cfg :get-team-organization {:team-id team-id})
(dm/get-in [:organization :id]))
(catch Throwable cause
(l/warn :hint "unable to resolve team organization"
:team-id team-id
:cause cause)
nil)))
(defmethod handle-message :subscribe-team (defmethod handle-message :subscribe-team
[cfg {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}] [cfg {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id) (l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
(teams/check-read-permissions! cfg profile-id team-id) (teams/check-read-permissions! cfg profile-id team-id)
(let [prev-subs (get @state ::team-subscription) (let [prev-subs (get @state ::team-subscription)
channel (sp/chan :buf (sp/dropping-buffer 64) organization-id (get-team-organization-id cfg team-id)
:xf (remove #(= (:session-id %) session-id)))] ;; Resolved server-side so a client only hears its readable team's org
topics (cond-> [team-id]
(some? organization-id)
(conj organization-id))
channel (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]
(sp/pipe channel output-ch false) (sp/pipe channel output-ch false)
(mbus/sub! (::mbus/msgbus cfg) :topic team-id :chan channel) (mbus/sub! (::mbus/msgbus cfg) :topics topics :chan channel)
(let [subs {:team-id team-id :channel channel :topic team-id}] (let [subs {:team-id team-id
:organization-id organization-id
:channel channel
:topic team-id}]
(swap! state assoc ::team-subscription subs)) (swap! state assoc ::team-subscription subs))
;; Close previous subscription if exists ;; Close previous subscription if exists

View File

@ -379,11 +379,12 @@
::setup/props (ig/ref ::setup/props)} ::setup/props (ig/ref ::setup/props)}
::http.ws/routes ::http.ws/routes
{::db/pool (ig/ref ::db/pool) {::db/pool (ig/ref ::db/pool)
::mtx/metrics (ig/ref ::mtx/metrics) ::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus) ::mbus/msgbus (ig/ref ::mbus/msgbus)
::setup/props (ig/ref ::setup/props) ::setup/props (ig/ref ::setup/props)
::session/manager (ig/ref ::session/manager)} ::session/manager (ig/ref ::session/manager)
:app.nitrate/client (ig/ref :app.nitrate/client)}
:app.http.assets/routes :app.http.assets/routes
{::http.assets/path (cf/get :assets-path) {::http.assets/path (cf/get :assets-path)

View File

@ -11,6 +11,7 @@
[app.common.types.plugins :as ctp] [app.common.types.plugins :as ctp]
[app.db :as db] [app.db :as db]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile] [app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.util.services :as sv])) [app.util.services :as sv]))
@ -33,6 +34,8 @@
(sv/defmethod ::add-profile-plugin (sv/defmethod ::add-profile-plugin
{::doc/added "2.18" {::doc/added "2.18"
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
[:profile-plugin-ops/global]]
::sm/params schema:add-profile-plugin ::sm/params schema:add-profile-plugin
::sm/result ctp/schema:registry-entry ::sm/result ctp/schema:registry-entry
::db/transaction true} ::db/transaction true}
@ -42,14 +45,22 @@
(let [profile (profile/get-profile conn profile-id ::db/for-update true) (let [profile (profile/get-profile conn profile-id ::db/for-update true)
plugins (get-in profile [:props :plugins] {:ids [] :data {}}) plugins (get-in profile [:props :plugins] {:ids [] :data {}})
plugin-id (:plugin-id plugin) plugin-id (:plugin-id plugin)
plugins (-> plugins exists? (contains? (set (:ids plugins)) plugin-id)]
(update :ids #(vec (distinct (conj % plugin-id)))) (when (and (not exists?) (>= (count (:ids plugins)) ctp/max-plugins))
(assoc-in [:data plugin-id] plugin))] (ex/raise :type :validation
(db/update! conn :profile :code :too-many-plugins
{:props (db/tjson (assoc (:props profile) :plugins plugins))} :hint "plugin registry exceeds maximum size"))
{:id profile-id} (let [plugins (-> plugins
{::db/return-keys false}) (update :ids #(vec (distinct (conj % plugin-id))))
plugin)) (assoc-in [:data plugin-id] plugin))
props (-> (:props profile)
(assoc :plugins plugins)
(profile/check-props-size))]
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
plugin)))
(def ^:private (def ^:private
schema:remove-profile-plugin schema:remove-profile-plugin
@ -58,18 +69,28 @@
(sv/defmethod ::remove-profile-plugin (sv/defmethod ::remove-profile-plugin
{::doc/added "2.18" {::doc/added "2.18"
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
[:profile-plugin-ops/global]]
::sm/params schema:remove-profile-plugin ::sm/params schema:remove-profile-plugin
::sm/result :nil ::sm/result :nil
::db/transaction true} ::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}] [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}]
(let [profile (profile/get-profile conn profile-id ::db/for-update true) (let [profile (profile/get-profile conn profile-id ::db/for-update true)
plugins (get-in profile [:props :plugins] {:ids [] :data {}}) plugins (get-in profile [:props :plugins] {:ids [] :data {}})
plugin-id-str (str plugin-id) plugin-id-str (str plugin-id)]
plugins (-> plugins (if-not (or (some #(= % plugin-id-str) (:ids plugins))
(update :ids #(vec (remove (partial = plugin-id-str) %))) (contains? (:data plugins) plugin-id-str))
(update :data dissoc plugin-id-str))] ;; Nothing to remove: no write
(db/update! conn :profile nil
{:props (db/tjson (assoc (:props profile) :plugins plugins))} (let [plugins (-> plugins
{:id profile-id} (update :ids #(vec (remove (partial = plugin-id-str) %)))
{::db/return-keys false}) (update :data dissoc plugin-id-str))
nil)) ;; Raises when the remaining props still exceed the size limit
props (-> (:props profile)
(assoc :plugins plugins)
(profile/check-props-size))]
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
nil))))

View File

@ -12,6 +12,7 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.transit :as t]
[app.common.types.plugins :as ctp] [app.common.types.plugins :as ctp]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
@ -36,6 +37,7 @@
[cuerdas.core :as str])) [cuerdas.core :as str]))
(declare check-profile-existence!) (declare check-profile-existence!)
(declare check-props-size)
(declare decode-row) (declare decode-row)
(declare filter-props) (declare filter-props)
(declare get-profile) (declare get-profile)
@ -283,7 +285,8 @@
props props
(-> (get profile :props) (-> (get profile :props)
(assoc :notifications notifications))] (assoc :notifications notifications)
(check-props-size))]
(db/update! conn :profile (db/update! conn :profile
{:props (db/tjson props)} {:props (db/tjson props)}
@ -474,18 +477,46 @@
[:map {:title "update-profile-props"} [:map {:title "update-profile-props"}
[:props schema:props-writeable]]) [:props schema:props-writeable]])
(def default-props-max-size
"Default total serialized size limit (in bytes) for profile props.
Overridable with the :profile-props-max-size config entry."
(* 1024 1024 2)) ;; 2 MiB
(defn- props-size
"Returns the serialized size in UTF-8 bytes of the props map."
[props]
(if props
(alength ^bytes (t/encode props {:type :json-verbose}))
0))
(defn check-props-size
"Raises :props-too-large when props exceed the total size limit.
Returns props unchanged so it can be threaded into the write.
Used by the user-facing props writers; system writers (OIDC login,
management subscription) write fixed-key props and skip it."
[props]
(let [limit (cf/get :profile-props-max-size default-props-max-size)
size (props-size props)]
(when (> size limit)
(ex/raise :type :validation
:code :props-too-large
:hint "profile props exceed maximum size"))
props))
(defn update-profile-props (defn update-profile-props
[{:keys [::db/conn] :as cfg} profile-id props] [{:keys [::db/conn] :as cfg} profile-id props]
(let [profile (get-profile conn profile-id ::db/for-update true) (let [profile (get-profile conn profile-id ::db/for-update true)
props (reduce-kv (fn [props k v] props (->> (apply dissoc props system-managed-props)
;; We don't accept namespaced keys (reduce-kv (fn [props k v]
(if (simple-ident? k) ;; We don't accept namespaced keys
(if (nil? v) (if (simple-ident? k)
(dissoc props k) (if (nil? v)
(assoc props k v)) (dissoc props k)
props)) (assoc props k v))
(:props profile) props))
(apply dissoc props system-managed-props))] (:props profile))
(check-props-size))]
(db/update! conn :profile (db/update! conn :profile
{:props (db/tjson props)} {:props (db/tjson props)}
@ -658,8 +689,41 @@
[props] [props]
(into {} (filter (fn [[k _]] (simple-ident? k))) props)) (into {} (filter (fn [[k _]] (simple-ident? k))) props))
(defn- truncate-string
"Cuts s to at most n chars without splitting a surrogate pair."
[^String s n]
(if (> (count s) n)
(let [n (if (Character/isHighSurrogate (.charAt s (dec n))) (dec n) n)]
(subs s 0 n))
s))
(defn- clamp-plugin-entry
[entry]
(reduce-kv (fn [entry k n]
(let [v (get entry k)]
(if (and (string? v) (> (count v) n))
(assoc entry k (truncate-string v n))
entry)))
entry
ctp/registry-entry-max-lengths))
(defn clamp-plugins-registry
"Fits a plugin registry into the registry schema caps: keeps the
first `ctp/max-plugins` plugins with data and truncates the bounded
strings. Applied on read, so a stored registry over the caps cannot
keep the profile over the props size limit."
[{:keys [ids data] :as plugins}]
(let [ids (->> ids
(filter #(contains? data %))
(distinct)
(take ctp/max-plugins)
(vec))
data (update-vals (select-keys data ids) clamp-plugin-entry)]
(assoc plugins :ids ids :data data)))
(defn decode-row (defn decode-row
[{:keys [props] :as row}] [{:keys [props] :as row}]
(cond-> row (cond-> row
(db/pgobject? props "jsonb") (db/pgobject? props "jsonb")
(assoc :props (db/decode-transit-pgobject props)))) (assoc :props (-> (db/decode-transit-pgobject props)
(d/update-when :plugins #(cond-> % (map? %) (clamp-plugins-registry)))))))

View File

@ -6,16 +6,14 @@
(ns app.rpc.notifications (ns app.rpc.notifications
(:require (:require
[app.common.uuid :as uuid]
[app.msgbus :as mbus])) [app.msgbus :as mbus]))
(defn notify-team-change (defn notify-team-change
[cfg team notification] [cfg team notification]
(let [msgbus (::mbus/msgbus cfg)] (let [msgbus (::mbus/msgbus cfg)
team-id (:id team)]
(mbus/pub! msgbus (mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications. :topic team-id
;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero
:message {:type :team-organization-change :message {:type :team-organization-change
:team team :team team
:notification notification}))) :notification notification})))
@ -37,7 +35,7 @@
[cfg organization-id organization-name teams deleted-teams] [cfg organization-id organization-name teams deleted-teams]
(let [msgbus (::mbus/msgbus cfg)] (let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus (mbus/pub! msgbus
:topic uuid/zero :topic organization-id
:message {:type :organization-deleted :message {:type :organization-deleted
:organization-id organization-id :organization-id organization-id
:organization-name organization-name :organization-name organization-name
@ -48,6 +46,6 @@
[cfg organization-id] [cfg organization-id]
(let [msgbus (::mbus/msgbus cfg)] (let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus (mbus/pub! msgbus
:topic uuid/zero :topic organization-id
:message {:type :organization-change-sso :message {:type :organization-change-sso
:organization-id organization-id}))) :organization-id organization-id})))

View File

@ -60,6 +60,13 @@
(def ^:dynamic *system* nil) (def ^:dynamic *system* nil)
(def ^:dynamic *pool* nil) (def ^:dynamic *pool* nil)
;; Fallback values used when no PENPOT_TEST_* env vars are set (e.g. CI sets
;; them explicitly, see .github/workflows/tests-backend.yml). Inside the
;; devenv each wsN container receives per-instance values via
;; manage.sh (PENPOT_TEST_DATABASE_URI=postgresql://postgres/penpot_test_wsN,
;; PENPOT_TEST_REDIS_URI=redis://valkey/<6+N>), so parallel test runs on
;; different workspaces never share a database. cf/read-config picks the env
;; vars up through the "penpot-test" prefix.
(def default (def default
{:database-uri "postgresql://postgres/penpot_test" {:database-uri "postgresql://postgres/penpot_test"
:redis-uri "redis://valkey/1" :redis-uri "redis://valkey/1"

View File

@ -10,6 +10,7 @@
[app.db :as db] [app.db :as db]
[app.http.websocket :as ws] [app.http.websocket :as ws]
[app.msgbus :as mbus] [app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.commands.files :as files] [app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
@ -68,6 +69,50 @@
(t/testing "permission check passes for authorized user" (t/testing "permission check passes for authorized user"
(t/is (nil? (teams/check-read-permissions! cfg (:id profile1) (:id team))))))) (t/is (nil? (teams/check-read-permissions! cfg (:id profile1) (:id team)))))))
(defn- subscribed-topics
"Runs :subscribe-team for `profile-id` on `team-id` with `nitrate-call`
standing in for nitrate; returns the topics and the stored subscription."
[profile-id team-id nitrate-call]
(let [state (atom {})
output-ch (sp/chan :buf (sp/dropping-buffer 64))
wsp (make-wsp profile-id state output-ch)
calls (atom [])]
(with-redefs [nitrate/call nitrate-call
mbus/sub! (fn [_ & {:keys [topics]}]
(swap! calls conj topics))]
((get-method ws/handle-message :subscribe-team)
th/*system* wsp {:team-id team-id}))
(some-> @state ::ws/team-subscription :channel sp/close!)
{:topics @calls
:subscription (::ws/team-subscription @state)}))
(t/deftest subscribe-team-subscribes-to-team-organization
(let [profile (th/create-profile* 1 {:is-active true})
team-id (:id (th/create-team* 1 {:profile-id (:id profile)}))
org-id (uuid/next)]
(t/testing "adds the organization topic when the team has one"
(let [{:keys [topics subscription]}
(subscribed-topics (:id profile) team-id
(fn [_ method params]
(when (= :get-team-organization method)
{:id (:team-id params)
:organization {:id org-id}})))]
(t/is (= [[team-id org-id]] topics))
(t/is (= org-id (:organization-id subscription)))))
(t/testing "subscribes only to the team when it has no organization"
(let [{:keys [topics subscription]}
(subscribed-topics (:id profile) team-id (constantly nil))]
(t/is (= [[team-id]] topics))
(t/is (nil? (:organization-id subscription)))))
(t/testing "subscribes to the team when nitrate fails"
(let [{:keys [topics]}
(subscribed-topics (:id profile) team-id
(fn [& _] (throw (ex-info "nitrate down" {}))))]
(t/is (= [[team-id]] topics))))))
(t/deftest pointer-update-validates-file-id (t/deftest pointer-update-validates-file-id
(let [profile (th/create-profile* 1 {:is-active true}) (let [profile (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile) file (th/create-file* 1 {:profile-id (:id profile)

View File

@ -316,7 +316,7 @@
:organization organization}))] :organization organization}))]
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= 1 (count @calls))) (t/is (= 1 (count @calls)))
(t/is (= uuid/zero (-> @calls first :topic))) (t/is (= team-id (-> @calls first :topic)))
(let [msg (-> @calls first :message)] (let [msg (-> @calls first :message)]
(t/is (= :team-organization-change (:type msg))) (t/is (= :team-organization-change (:type msg)))
(t/is (= nil (:notification msg))) (t/is (= nil (:notification msg)))
@ -340,7 +340,7 @@
:organization {:name organization-name}}))] :organization {:name organization-name}}))]
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= 1 (count @calls))) (t/is (= 1 (count @calls)))
(t/is (= uuid/zero (-> @calls first :topic))) (t/is (= team-id (-> @calls first :topic)))
(let [msg (-> @calls first :message)] (let [msg (-> @calls first :message)]
(t/is (= :team-organization-change (:type msg))) (t/is (= :team-organization-change (:type msg)))
(t/is (= "dashboard.team-no-longer-belong-organization" (:notification msg))) (t/is (= "dashboard.team-no-longer-belong-organization" (:notification msg)))
@ -548,7 +548,7 @@
;; --- Verify: exactly one organization-deleted event is published on the message bus --- ;; --- Verify: exactly one organization-deleted event is published on the message bus ---
(t/is (:called? @mbus-mock)) (t/is (:called? @mbus-mock))
(let [msg (apply hash-map (rest (:call-args @mbus-mock)))] (let [msg (apply hash-map (rest (:call-args @mbus-mock)))]
(t/is (= uuid/zero (:topic msg))) (t/is (= organization-id (:topic msg)))
(t/is (= :organization-deleted (:type (:message msg)))) (t/is (= :organization-deleted (:type (:message msg))))
(t/is (= organization-id (:organization-id (:message msg)))) (t/is (= organization-id (:organization-id (:message msg))))
(t/is (= organization-name (:organization-name (:message msg)))) (t/is (= organization-name (:organization-name (:message msg))))
@ -557,6 +557,24 @@
(t/is (= #{(:id empty-team)} (t/is (= #{(:id empty-team)}
(set (:deleted-teams (:message msg)))))))))) (set (:deleted-teams (:message msg))))))))))
(t/deftest notify-organization-change-sso-publishes-event
(let [organization-id (uuid/random)
calls (atom [])
out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}]
(swap! calls conj {:topic topic
:message message}))]
(th/management-command! {::th/type :notify-organization-sso-change
::rpc/profile-id (uuid/random)
:organization-id organization-id
:updated-props true
:announce-activation false}))]
(t/is (th/success? out))
(t/is (= 1 (count @calls)))
(t/is (= organization-id (-> @calls first :topic)))
(let [msg (-> @calls first :message)]
(t/is (= :organization-change-sso (:type msg)))
(t/is (= organization-id (:organization-id msg))))))
(t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-organization-events (t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-organization-events
;; --- Deferred owned-organizations: nil during setup, filled before RPC --- ;; --- Deferred owned-organizations: nil during setup, filled before RPC ---
(let [owned-organizations-ref (atom nil)] (let [owned-organizations-ref (atom nil)]
@ -664,7 +682,7 @@
;; --- Verify: one organization-deleted event per organization, all on correct topic --- ;; --- Verify: one organization-deleted event per organization, all on correct topic ---
(t/is (= 2 (count msgs))) (t/is (= 2 (count msgs)))
(t/is (every? #(= uuid/zero (:topic %)) (t/is (every? #(contains? #{organization-1-id organization-2-id} (:topic %))
(->> (:call-args-list @mbus-mock) (->> (:call-args-list @mbus-mock)
(map #(apply hash-map (rest %)))))) (map #(apply hash-map (rest %))))))
(t/is (= #{:organization-deleted} (set (map :type msgs)))) (t/is (= #{:organization-deleted} (set (map :type msgs))))

View File

@ -6,7 +6,11 @@
(ns backend-tests.rpc-plugins-test (ns backend-tests.rpc-plugins-test
(:require (:require
[app.common.schema :as sm]
[app.common.types.plugins :as ctp]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile] [app.rpc.commands.profile :as profile]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
@ -145,6 +149,140 @@
(t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name]))) (t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name])))
(t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name])))))) (t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name]))))))
(t/deftest add-profile-plugin-rejects-oversized-code
;; The merged props must not exceed :profile-props-max-size
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))
data {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin}]
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest add-profile-plugin-rejects-oversized-code-path
;; :code holds a manifest path, not content: overlong values are
;; rejected by the entry schema before the props size check runs
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :code (apply str (repeat 501 "x")))
data {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest remove-profile-plugin-allowed-on-oversized-profile
;; Removal shrinks props, so it passes even under a tight limit
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))]
;; Seed an oversized registry while the limit is high
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100000})]
(let [out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)))))
;; Removal under a tighter limit still passes: the seeded registry
;; is oversized against it, but the remaining props fit
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 300})]
(let [out (th/command! {::th/type :remove-profile-plugin
::rpc/profile-id (:id profile)
:plugin-id (uuid/uuid plugin-id-1)})]
(t/is (nil? (:error out)))))))
(t/deftest add-profile-plugin-rejects-51st-plugin
;; The registry holds at most 50 plugins; the 51st (new id) must fail
(let [profile (th/create-profile* 1)]
;; Seed 50 plugins
(doseq [i (range 50)]
(let [plugin (assoc valid-plugin
:plugin-id (str (uuid/next))
:name (str "Plugin " i))
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)) (str "seed plugin " i " should install"))))
;; The 51st must fail with a specific error
(let [extra (assoc valid-plugin
:plugin-id (str (uuid/next))
:name "One Too Many")
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin extra})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :too-many-plugins)))
;; And nothing extra was persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
(t/deftest add-profile-plugin-updates-existing-at-limit
;; Re-adding an existing id at the limit is an update, not a new entry
(let [profile (th/create-profile* 1)
ids (mapv (fn [_] (str (uuid/next))) (range 50))]
(doseq [[i pid] (map-indexed vector ids)]
(th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin (assoc valid-plugin :plugin-id pid :name (str "Plugin " i))}))
(let [out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin (assoc valid-plugin :plugin-id (first ids) :name "Renamed")})]
(t/is (nil? (:error out)))
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids]))))
(t/is (= "Renamed" (get-in props [:props :plugins :data (first ids) :name])))))))
(t/deftest add-profile-plugin-full-registry-reports-too-many-before-size
;; A full registry plus oversized content reports the count guard,
;; which runs before the size check
(let [profile (th/create-profile* 1)]
;; Seed 50 plugins under a generous limit
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 1000000})]
(doseq [i (range 50)]
(let [plugin (assoc valid-plugin
:plugin-id (str (uuid/next))
:name (str "Plugin " i))
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)) (str "seed plugin " i " should install")))))
;; Tight limit + 51st small plugin: count wins over size
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [extra (assoc valid-plugin
:plugin-id (str (uuid/next))
:name "One Too Many")
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin extra})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :too-many-plugins))))
;; And nothing extra was persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
(t/deftest remove-profile-plugin-noop-on-oversized-profile-without-plugins
;; Removing an absent id changes nothing: no write, no size failure,
;; and no :plugins key is manufactured
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [out (th/command! {::th/type :remove-profile-plugin
::rpc/profile-id (:id profile)
:plugin-id (uuid/next)})]
(t/is (nil? (:error out)))))
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (nil? (get-in props [:props :plugins])))
(t/is (= big (get props :props))))))
(t/deftest update-profile-props-rejects-plugins (t/deftest update-profile-props-rejects-plugins
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
data {::th/type :update-profile-props data {::th/type :update-profile-props
@ -160,3 +298,86 @@
props (profile/decode-row saved)] props (profile/decode-row saved)]
(t/is (nil? (get-in props [:props :plugins])) (t/is (nil? (get-in props [:props :plugins]))
":plugins must not be writable via update-profile-props")))) ":plugins must not be writable via update-profile-props"))))
;; --- Stored registries over the caps
(defn- legacy-entry
[plugin-id & {:as attrs}]
(merge valid-plugin {:plugin-id plugin-id :code "plugin.js"} attrs))
(defn- legacy-registry
[entries]
{:ids (mapv :plugin-id entries)
:data (into {} (map (juxt :plugin-id identity)) entries)})
(defn- stored-props
"Props as stored in the database, without the read-time clamping."
[profile-id]
(db/decode-transit-pgobject (:props (th/db-get :profile {:id profile-id}))))
(t/deftest get-profile-clamps-legacy-registry
(let [profile (th/create-profile* 1)
big (apply str (repeat 10000 "x"))
;; "😀" is two chars, so char 500 is a high surrogate
emoji (str (apply str (repeat 499 "n")) "😀")
entries (into [(legacy-entry plugin-id-1
:name emoji
:description big
:host big
:code big
:icon big)]
(map #(legacy-entry (str "extra-" %)))
(range 60))
stored (-> (legacy-registry entries)
(update :ids conj "dangling"))]
(th/db-update! :profile {:props (db/tjson {:plugins stored :renderer :wasm})}
{:id (:id profile)})
(let [out (th/command! {::th/type :get-profile ::rpc/profile-id (:id profile)})
props (get-in out [:result :props])
plugins (:plugins props)
entry (get-in plugins [:data plugin-id-1])]
(t/is (nil? (:error out)))
(t/is (= :wasm (:renderer props)) "other props are kept")
(t/is (= (mapv :plugin-id (take ctp/max-plugins entries)) (:ids plugins))
"keeps the first plugins in registry order, drops ids without data")
(t/is (= (set (:ids plugins)) (set (keys (:data plugins)))))
(t/is (= (apply str (repeat 499 "n")) (:name entry)) "does not split a surrogate pair")
(doseq [k [:description :host :code :icon]]
(t/is (= (get ctp/registry-entry-max-lengths k) (count (get entry k)))
(str k " truncated to its cap")))
(t/is (sm/validate ctp/schema:plugin-registry plugins)))
(t/is (= stored (:plugins (stored-props (:id profile))))
"reading does not write")))
(t/deftest get-profile-keeps-valid-registry
(let [profile (th/create-profile* 1)
stored (legacy-registry [(legacy-entry plugin-id-1)])]
(th/db-update! :profile {:props (db/tjson {:plugins stored})} {:id (:id profile)})
(let [out (th/command! {::th/type :get-profile ::rpc/profile-id (:id profile)})]
(t/is (= stored (get-in out [:result :props :plugins]))))))
(t/deftest oversized-legacy-profile-recovers-on-write
;; A stored registry over the caps pushes props past the size limit;
;; the clamped read lets writes through and saves the clamped registry
(let [profile (th/create-profile* 1)
code (apply str (repeat 3000 "c"))
entries [(legacy-entry plugin-id-1 :code code)
(legacy-entry plugin-id-2 :code code)]]
(th/db-update! :profile {:props (db/tjson {:plugins (legacy-registry entries)})}
{:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 5000})]
(t/is (thrown? Exception (profile/check-props-size (stored-props (:id profile))))
"the stored props exceed the limit")
(let [out (th/command! {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:workspace-visited true}})]
(t/is (nil? (:error out))))
(let [out (th/command! {::th/type :remove-profile-plugin
::rpc/profile-id (:id profile)
:plugin-id (uuid/uuid plugin-id-1)})]
(t/is (nil? (:error out)))))
(let [props (stored-props (:id profile))]
(t/is (true? (:workspace-visited props)))
(t/is (= [plugin-id-2] (get-in props [:plugins :ids])))
(t/is (= 500 (count (get-in props [:plugins :data plugin-id-2 :code])))
"the write saved the clamped registry"))))

View File

@ -1549,6 +1549,111 @@
(t/is (th/ex-of-code? (:error out) :params-validation)))) (t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest update-profile-props-rejects-oversized-props
;; The merged props must not exceed :profile-props-max-size
(let [profile (th/create-profile* 1)]
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest update-profile-props-enforces-hard-limit-on-oversized-profile
;; An already-oversized profile can only write back under the limit:
;; shrinking below it passes, staying above it fails
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
;; Seed an already-oversized profile directly in DB (bypasses RPC validation)
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
;; Shrinking below the limit passes
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob "small"}}}
out (th/command! data)]
(t/is (nil? (:error out))))
;; Staying above the limit fails
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 300 "x"))}}}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest check-props-size-measures-bytes-not-chars
;; The limit is in UTF-8 bytes: multibyte content that fits in chars
;; but exceeds the byte limit must be rejected
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 200})]
;; 70 ASCII chars (~87 bytes serialized) passes and returns props unchanged
(let [props {:blob (apply str (repeat 70 "x"))}]
(t/is (= props (profile/check-props-size props))))
;; 70 CJK chars (~227 bytes serialized, still 70 chars) raises
(try
(profile/check-props-size {:blob (apply str (repeat 70 "日"))})
(t/is false "should have thrown")
(catch clojure.lang.ExceptionInfo e
(t/is (= :validation (:type (ex-data e))))
(t/is (= :props-too-large (:code (ex-data e))))))))
(t/deftest update-profile-props-rejects-steady-size-on-oversized-profile
;; Same size (not smaller) on an oversized profile still exceeds
;; the limit, so it fails
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 200 "y"))}}}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest update-profile-notifications-rejects-growth-on-oversized-profile
;; The notifications write path goes through the same size check:
;; growing an oversized profile fails
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-notifications
::rpc/profile-id (:id profile)
:dashboard-comments :all
:email-comments :all
:email-invites :all}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest update-profile-notifications-rejects-steady-on-oversized-profile
;; Same-size notifications write on an oversized profile still exceeds
;; the limit, so it fails
(let [profile (th/create-profile* 1)
notifications {:dashboard-comments :all
:email-comments :all
:email-invites :all}
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}
:notifications notifications}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data (merge {::th/type :update-profile-notifications
::rpc/profile-id (:id profile)}
notifications)
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest prepare-register-profile-password-too-short (t/deftest prepare-register-profile-password-too-short
(let [data {::th/type :prepare-register-profile (let [data {::th/type :prepare-register-profile
:email "user@example.com" :email "user@example.com"

View File

@ -41,21 +41,34 @@
"Schema for plugin permissions - a set of valid permission strings." "Schema for plugin permissions - a set of valid permission strings."
[:set {:gen/max 11} (into [:enum] (sort valid-permissions))]) [:set {:gen/max 11} (into [:enum] (sort valid-permissions))])
(def max-plugins
"Maximum number of plugins a profile can hold."
50)
(def registry-entry-max-lengths
"Maximum length (in chars) of the bounded registry entry strings.
`:code` and `:icon` hold manifest paths, not content."
{:name 500
:description 4096
:host 500
:code 500
:icon 500})
(def schema:registry-entry (def schema:registry-entry
[:map [:map
[:plugin-id :string] [:plugin-id :string]
[:version {:optional true} :int] [:version {:optional true} :int]
[:name :string] [:name [:string {:max (:name registry-entry-max-lengths)}]]
[:description {:optional true} :string] [:description {:optional true} [:string {:max (:description registry-entry-max-lengths)}]]
[:host :string] [:host [:string {:max (:host registry-entry-max-lengths)}]]
[:code :string] [:code [:string {:max (:code registry-entry-max-lengths)}]]
[:icon {:optional true} :string] [:icon {:optional true} [:string {:max (:icon registry-entry-max-lengths)}]]
[:permissions schema:permissions]]) [:permissions schema:permissions]])
(def schema:plugin-registry (def schema:plugin-registry
[:map [:map
[:ids [:vector :string]] [:ids [:vector {:max max-plugins} :string]]
[:data [:data
[:map-of {:gen/max 5} [:map-of {:gen/max 5 :max max-plugins}
:string :string
schema:registry-entry]]]) schema:registry-entry]]])

View File

@ -209,14 +209,6 @@
[node] [node]
(= "root" (:type node))) (= "root" (:type node)))
(defn rtl-content?
"True when the content has paragraphs and all of them are `\"rtl\"`; picks the
growth anchor of auto-width text. Mixed, \"none\" and empty content are ltr."
[content]
(boolean
(when-let [paragraphs (node-seq is-paragraph-node? content)]
(every? #(= "rtl" (:text-direction %)) paragraphs))))
(defn is-node? (defn is-node?
[node] [node]
(or ^boolean (is-text-node? node) (or ^boolean (is-text-node? node)

View File

@ -84,6 +84,7 @@
[common-tests.types.objects-map-test] [common-tests.types.objects-map-test]
[common-tests.types.organization-test] [common-tests.types.organization-test]
[common-tests.types.path-data-test] [common-tests.types.path-data-test]
[common-tests.types.plugins-test]
[common-tests.types.shape-decode-encode-test] [common-tests.types.shape-decode-encode-test]
[common-tests.types.shape-interactions-test] [common-tests.types.shape-interactions-test]
[common-tests.types.shape-layout-test] [common-tests.types.shape-layout-test]
@ -167,6 +168,7 @@
'common-tests.types.objects-map-test 'common-tests.types.objects-map-test
'common-tests.types.organization-test 'common-tests.types.organization-test
'common-tests.types.path-data-test 'common-tests.types.path-data-test
'common-tests.types.plugins-test
'common-tests.types.shape-decode-encode-test 'common-tests.types.shape-decode-encode-test
'common-tests.types.shape-interactions-test 'common-tests.types.shape-interactions-test
'common-tests.types.shape-layout-test 'common-tests.types.shape-layout-test

View File

@ -0,0 +1,64 @@
;; 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 SUBSIDIARY SL
(ns common-tests.types.plugins-test
(:require
[app.common.schema :as sm]
[app.common.types.plugins :as ctp]
[clojure.test :as t]))
(def ^:private valid-entry
{:plugin-id "plugin-1"
:name "Test Plugin"
:description "A test plugin"
:host "https://example.com"
:code "(function() {})()"
:permissions #{"content:read"}})
(t/deftest registry-entry-accepts-valid-plugin
(t/is (true? (sm/validate ctp/schema:registry-entry valid-entry))))
(t/deftest registry-entry-rejects-oversized-code
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry
:code (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-rejects-oversized-name
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :name (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-rejects-oversized-host
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :host (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-rejects-oversized-description
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :description (apply str (repeat 4097 "x")))))))
(t/deftest registry-entry-rejects-oversized-icon
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :icon (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-accepts-values-at-max
(t/is (true? (sm/validate ctp/schema:registry-entry
(assoc valid-entry
:name (apply str (repeat 500 "x"))
:host (apply str (repeat 500 "x"))
:description (apply str (repeat 4096 "x"))
:icon (apply str (repeat 500 "x"))
:code (apply str (repeat 500 "x")))))))
(defn- make-registry
[n]
(let [ids (mapv #(str "plugin-" %) (range n))]
{:ids ids
:data (into {} (map (fn [id] [id (assoc valid-entry :plugin-id id)]) ids))}))
(t/deftest plugin-registry-accepts-fifty-plugins
(t/is (true? (sm/validate ctp/schema:plugin-registry (make-registry 50)))))
(t/deftest plugin-registry-rejects-more-than-fifty-plugins
(t/is (false? (sm/validate ctp/schema:plugin-registry (make-registry 51)))))

View File

@ -81,6 +81,11 @@ services:
- PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD} - PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD}
- PENPOT_DATABASE_MAX_POOL_SIZE=${PENPOT_DATABASE_MAX_POOL_SIZE} - PENPOT_DATABASE_MAX_POOL_SIZE=${PENPOT_DATABASE_MAX_POOL_SIZE}
- PENPOT_REDIS_URI=${PENPOT_REDIS_URI} - PENPOT_REDIS_URI=${PENPOT_REDIS_URI}
# Per-instance backend-test config. Values come from
# instance-env-overrides in manage.sh; the main database above stays
# shared by all instances on purpose.
- PENPOT_TEST_DATABASE_URI=${PENPOT_TEST_DATABASE_URI}
- PENPOT_TEST_REDIS_URI=${PENPOT_TEST_REDIS_URI}
- PENPOT_OBJECTS_STORAGE_BACKEND=${PENPOT_OBJECTS_STORAGE_BACKEND} - PENPOT_OBJECTS_STORAGE_BACKEND=${PENPOT_OBJECTS_STORAGE_BACKEND}
- PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT} - PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}
- PENPOT_OBJECTS_STORAGE_S3_BUCKET=${PENPOT_OBJECTS_STORAGE_S3_BUCKET} - PENPOT_OBJECTS_STORAGE_S3_BUCKET=${PENPOT_OBJECTS_STORAGE_S3_BUCKET}

View File

@ -274,6 +274,7 @@ hay
hubo hubo
importada importada
iniciarse iniciarse
instalarla
invitaciones invitaciones
lectores lectores
letras letras

View File

@ -50,18 +50,28 @@
(ptk/reify ::initialize (ptk/reify ::initialize
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(let [stopper (rx/filter (ptk/type? ::finalize) stream) (let [stopper (rx/filter (ptk/type? ::finalize) stream)
profile-id (:profile-id state)] profile-id (:profile-id state)
organization-id (dm/get-in state [:teams team-id :organization :id])
initmsg {:type :subscribe-team :team-id team-id}]
(->> (rx/merge (->> (rx/merge
(rx/of (fetch-projects team-id) (rx/of (fetch-projects team-id)
(df/fetch-fonts team-id)) (df/fetch-fonts team-id)
(dws/send initmsg))
;; On reconnect, send again the subscription message
(->> stream
(rx/filter (ptk/type? ::dws/opened))
(rx/map #(dws/send initmsg)))
(->> stream (->> stream
(rx/filter (ptk/type? ::dws/message)) (rx/filter (ptk/type? ::dws/message))
(rx/map deref) (rx/map deref)
(rx/filter (fn [{:keys [topic] :as msg}] (rx/filter (fn [{:keys [topic] :as msg}]
(or (= topic uuid/zero) (or (= topic uuid/zero)
(= topic profile-id)))) (= topic profile-id)
(= topic team-id)
(when (some? organization-id)
(= topic organization-id)))))
(rx/map process-message))) (rx/map process-message)))
(rx/take-until stopper)))))) (rx/take-until stopper))))))

View File

@ -52,6 +52,7 @@
(watch [_ state stream] (watch [_ state stream]
(let [stopper (rx/filter (ptk/type? ::finalize) stream) (let [stopper (rx/filter (ptk/type? ::finalize) stream)
profile-id (:profile-id state) profile-id (:profile-id state)
organization-id (dm/get-in state [:teams team-id :organization :id])
initmsg [{:type :subscribe-file initmsg [{:type :subscribe-file
:file-id file-id :file-id file-id
@ -75,7 +76,9 @@
(or (= topic uuid/zero) (or (= topic uuid/zero)
(= topic profile-id) (= topic profile-id)
(= topic team-id) (= topic team-id)
(= topic file-id)))) (= topic file-id)
(when (some? organization-id)
(= topic organization-id)))))
(rx/map process-message)) (rx/map process-message))
;; On reconnect, send again the subscription messages ;; On reconnect, send again the subscription messages

View File

@ -16,7 +16,6 @@
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.render-wasm.api.select :as wselect] [app.common.render-wasm.api.select :as wselect]
[app.common.types.modifiers :as ctm] [app.common.types.modifiers :as ctm]
[app.common.types.text :as ctt]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw] [app.main.data.workspace :as-alias dw]
[app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.modifiers :as dwm]
@ -65,16 +64,12 @@
([shape] ([shape]
(resize-wasm-text-modifiers shape (:content shape))) (resize-wasm-text-modifiers shape (:content shape)))
([{:keys [id points selrect grow-type] :as shape} content] ([{:keys [id points selrect] :as shape} content]
(when-let [new-size (get-wasm-text-new-size shape content)] (when-let [new-size (get-wasm-text-new-size shape content)]
(let [width-scale (/ (:width new-size) (:width selrect)) (let [width-scale (/ (:width new-size) (:width selrect))
height-scale (/ (:height new-size) (:height selrect)) height-scale (/ (:height new-size) (:height selrect))
resize-v (gpt/point width-scale height-scale) resize-v (gpt/point width-scale height-scale)
;; Rtl text grows leftward, so anchor it on the top-right. origin (first points)]
origin (if (and (= :auto-width grow-type)
(ctt/rtl-content? content))
(second points)
(first points))]
{id {id
{:modifiers {:modifiers
(ctm/resize-modifiers (ctm/resize-modifiers

View File

@ -719,6 +719,15 @@
:level :error :level :error
:timeout 3000}))) :timeout 3000})))
;; Rejected profile write: notify and keep the app running
(= code :props-too-large)
(let [message (tr "errors.profile-props-too-large")]
(st/async-emit!
(ntf/show {:content message
:type :toast
:level :error
:timeout 5000})))
(= code :snapshot-already-locked) (= code :snapshot-already-locked)
(let [message (tr "errors.version-already-locked")] (let [message (tr "errors.version-already-locked")]
(st/async-emit! (st/async-emit!

View File

@ -79,6 +79,7 @@
.modal-msg { .modal-msg {
@include use-typography("body-large"); @include use-typography("body-large");
overflow-wrap: anywhere;
color: var(--color-foreground-secondary); color: var(--color-foreground-secondary);
} }

View File

@ -806,7 +806,7 @@
:submenu-item true :submenu-item true
:disabled (not can-open?)) :disabled (not can-open?))
:on-key-down on-key-down} :on-key-down on-key-down}
[:span {:class (stl/css :item-name)} name] [:span {:class (stl/css :item-name :plugin-name) :title name} name]
(when-not can-open? (when-not can-open?
[:span {:title (tr "workspace.plugins.error.need-editor")} [:span {:title (tr "workspace.plugins.error.need-editor")}
[:> icon* {:icon-id i/help [:> icon* {:icon-id i/help

View File

@ -9,6 +9,7 @@
@use "ds/_borders.scss" as *; @use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *; @use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *; @use "ds/_utils.scss" as *;
@use "ds/mixins.scss" as *;
.base-menu { .base-menu {
position: absolute; position: absolute;
@ -70,6 +71,7 @@
} }
&.plugins { &.plugins {
max-width: $sz-364;
max-height: calc(100vh - $sz-200); max-height: calc(100vh - $sz-200);
overflow: hidden auto; overflow: hidden auto;
} }
@ -120,6 +122,12 @@
grid-area: name; grid-area: name;
} }
.plugin-name {
@include text-ellipsis;
min-width: 0;
}
.item-indicator { .item-indicator {
--menu-indicator-color: var(--color-foreground-secondary); --menu-indicator-color: var(--color-foreground-secondary);

View File

@ -56,7 +56,7 @@
icon)) icon))
(mf/defc plugin-entry* (mf/defc plugin-entry*
[{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin]}] [{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin remove-disabled]}]
(let [{:keys [plugin-id host icon name description permissions]} manifest (let [{:keys [plugin-id host icon name description permissions]} manifest
plugins-permissions-peek (deref refs/plugins-permissions-peek) plugins-permissions-peek (deref refs/plugins-permissions-peek)
@ -86,8 +86,8 @@
(icon-url host icon) (icon-url host icon)
(avatars/generate {:name name}))}]] (avatars/generate {:name name}))}]]
[:div {:class (stl/css :plugin-description)} [:div {:class (stl/css :plugin-description)}
[:div {:class (stl/css :plugin-title)} name] [:div {:class (stl/css :plugin-title) :title name} name]
[:div {:class (stl/css :plugin-summary)} (d/nilv description "")]] [:div {:class (stl/css :plugin-summary) :title description} (d/nilv description "")]]
[:> button* {:class (stl/css :open-button) [:> button* {:class (stl/css :open-button)
@ -100,6 +100,7 @@
[:> icon-button* {:variant "ghost" [:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.plugins.remove-plugin") :aria-label (tr "workspace.plugins.remove-plugin")
:on-click handle-delete-click :on-click handle-delete-click
:disabled remove-disabled
:icon i/delete}]])) :icon i/delete}]]))
(mf/defc plugin-management-dialog (mf/defc plugin-management-dialog
@ -126,6 +127,10 @@
fetching-manifest? fetching-manifest?
(mf/use-state false) (mf/use-state false)
;; Ids with a persist in flight; their remove button is disabled
in-flight*
(mf/use-state #{})
on-url-change on-url-change
(mf/use-fn (mf/use-fn
(fn [value] (fn [value]
@ -174,13 +179,35 @@
(mf/deps plugins-state) (mf/deps plugins-state)
(fn [plugin-index] (fn [plugin-index]
(let [plugins-list (preg/plugins-list) (let [plugins-list (preg/plugins-list)
plugin (nth plugins-list plugin-index)] plugin (nth plugins-list plugin-index)
(st/emit! (ev/event {::ev/name "remove-plugin" plugin-name (:name plugin)
:name (:name plugin) ;; Truncated so long names fit the confirm dialog
:host (:host plugin)})) plugin-name (if (> (count plugin-name) 60)
(dp/close-plugin! plugin) (str (subs plugin-name 0 60) "…")
(preg/remove-plugin! plugin) plugin-name)]
(reset! plugins-state* (preg/plugins-list)))))] (modal/show!
{:type :confirm
:title (tr "workspace.plugins.remove-confirmation.title")
:message (tr "workspace.plugins.remove-confirmation.message" plugin-name)
:accept-label (tr "workspace.plugins.remove-plugin")
:on-accept (fn [_]
(st/emit! (ev/event {::ev/name "remove-plugin"
:name (:name plugin)
:host (:host plugin)}))
(dp/close-plugin! plugin)
(preg/remove-plugin! plugin)
(modal/show! :plugin-management {}))
:on-cancel (fn [_]
(modal/show! :plugin-management {}))}))))]
(mf/with-effect []
(let [listener (preg/subscribe-in-flight! #(reset! in-flight* %))]
(partial preg/unsubscribe-in-flight! listener)))
;; Re-reads the list on every registry change, including rollbacks
(mf/with-effect []
(let [listener (preg/subscribe-registry! #(reset! plugins-state* (preg/plugins-list)))]
(partial preg/unsubscribe-registry! listener)))
[:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-management)} [:div {:class (stl/css :modal-dialog :plugin-management)}
@ -236,6 +263,7 @@
:index idx :index idx
:manifest manifest :manifest manifest
:user-can-edit user-can-edit? :user-can-edit user-can-edit?
:remove-disabled (contains? @in-flight* (:plugin-id manifest))
:on-open-plugin on-open-plugin :on-open-plugin on-open-plugin
:on-remove-plugin on-remove-plugin}])]])]]])) :on-remove-plugin on-remove-plugin}])]])]]]))

View File

@ -5,6 +5,7 @@
// Copyright (c) KALEIDOS SUBSIDIARY SL // Copyright (c) KALEIDOS SUBSIDIARY SL
@use "refactor/common-refactor.scss" as deprecated; @use "refactor/common-refactor.scss" as deprecated;
@use "ds/mixins.scss" as *;
.modal-overlay { .modal-overlay {
@extend %modal-overlay-base; @extend %modal-overlay-base;
@ -15,6 +16,7 @@
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
grid-template-columns: minmax(0, 1fr);
max-height: initial; max-height: initial;
&.plugin-permissions { &.plugin-permissions {
@ -160,17 +162,21 @@
flex-direction: column; flex-direction: column;
gap: deprecated.$s-8; gap: deprecated.$s-8;
width: 100%; width: 100%;
min-width: 0;
} }
.plugin-title { .plugin-title {
@include deprecated.body-medium-typography; @include deprecated.body-medium-typography;
@include text-ellipsis;
color: var(--color-foreground-primary); color: var(--color-foreground-primary);
} }
.plugin-summary { .plugin-summary {
@include deprecated.body-small-typography; @include deprecated.body-small-typography;
@include two-line-text-ellipsis;
overflow-wrap: anywhere;
color: var(--color-foreground-secondary); color: var(--color-foreground-secondary);
} }

View File

@ -25,18 +25,11 @@
;; finalize-only), so measure the live WASM text for the growing axes: ;; finalize-only), so measure the live WASM text for the growing axes:
;; width grows on auto-width, height on auto-width/auto-height. ;; width grows on auto-width, height on auto-width/auto-height.
grow-type (:grow-type shape) grow-type (:grow-type shape)
{live-x :x live-width :width live-height :height} (wasm.api/get-text-dimensions (:id shape)) {live-width :width live-height :height} (wasm.api/get-text-dimensions (:id shape))
sr-width (if (= grow-type :auto-width) live-width (:width selrect)) sr-width (if (= grow-type :auto-width) live-width (:width selrect))
sr-height (if (= grow-type :fixed) (:height selrect) live-height) sr-height (if (= grow-type :fixed) (:height selrect) live-height)]
;; Rtl auto-width text is anchored on its right edge, so use the origin
;; wasm reports. A zero measurement means it has no layout yet.
sr-x (if (and (= grow-type :auto-width)
(some? live-x)
(pos? live-width))
live-x
(:x selrect))]
[:rect.main.viewport-selrect [:rect.main.viewport-selrect
{:x sr-x {:x (:x selrect)
:y (:y selrect) :y (:y selrect)
:width sr-width :width sr-width
:height sr-height :height sr-height

View File

@ -214,7 +214,7 @@
(font-family-from-font-id (:font-id font))) fallback-fonts) (font-family-from-font-id (:font-id font))) fallback-fonts)
[{:keys [x y width height]} transform] [{:keys [x y width height]} transform]
(let [{text-x :x :keys [width height]} (wasm.api/get-text-dimensions shape-id) (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
selrect-transform (mf/deref refs/workspace-selrect) selrect-transform (mf/deref refs/workspace-selrect)
vbox (mf/deref refs/vbox) vbox (mf/deref refs/vbox)
[selrect transform] (dsh/get-selrect selrect-transform shape) [selrect transform] (dsh/get-selrect selrect-transform shape)
@ -231,20 +231,13 @@
overlay-width (if (= (:grow-type shape) :auto-width) overlay-width (if (= (:grow-type shape) :auto-width)
(+ max-width viewport-width) (+ max-width viewport-width)
max-width) max-width)
;; `on-pointer-down` feeds offsets within this element to wasm as
;; paragraph-local coords, so this edge must sit on the text's.
x (if (and (= (:grow-type shape) :auto-width)
(some? text-x)
(pos? width))
text-x
(:x selrect))
valign (-> shape :content :vertical-align) valign (-> shape :content :vertical-align)
y (:y selrect) y (:y selrect)
y (case valign y (case valign
"bottom" (+ y (- selrect-height height)) "bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2)) "center" (+ y (/ (- selrect-height height) 2))
y)] y)]
[(assoc selrect :x x :y y :width overlay-width :height max-height) transform]) [(assoc selrect :y y :width overlay-width :height max-height) transform])
on-composition-start on-composition-start
(mf/use-fn (mf/use-fn

View File

@ -112,6 +112,20 @@
manifest manifest
(.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest)))))) (.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest))))))
(defn subscribe-registry!
"Subscribes f, called with no arguments on every registry change.
Returns f."
[f]
(add-watch registry f (fn [_ _ old new]
(when-not (identical? old new)
(f))))
f)
(defn unsubscribe-registry!
[f]
(remove-watch registry f)
nil)
(defn load-from-store (defn load-from-store
[] []
(reset! registry (get-in @st/state [:profile :props :plugins] {}))) (reset! registry (get-in @st/state [:profile :props :plugins] {})))
@ -122,36 +136,128 @@
(declare remove-plugin!) (declare remove-plugin!)
;; Plugin ids with a persist in flight; install/remove calls on them are skipped
(defonce ^:private in-flight (atom #{}))
(defonce ^:private in-flight-listeners (atom #{}))
(defn subscribe-in-flight!
"Subscribes f, called with the in-flight id set on every change.
Calls f immediately with the current set. Returns f."
[f]
(swap! in-flight-listeners conj f)
(f @in-flight)
f)
(defn unsubscribe-in-flight!
[f]
(swap! in-flight-listeners disj f)
nil)
(defn- notify-in-flight!
[]
(let [ids @in-flight]
(doseq [f @in-flight-listeners]
(f ids))))
(defn- track!
[plugin-id]
(swap! in-flight conj plugin-id)
(notify-in-flight!))
(defn- release!
[plugin-id]
(swap! in-flight disj plugin-id)
(notify-in-flight!))
(defn- validation-error?
[err]
(= :validation (:type (ex-data err))))
(defn- drop-local!
[{:keys [plugin-id]}]
(swap! registry #(-> %
(update :ids (fn [ids] (vec (remove (partial = plugin-id) ids))))
(update :data dissoc plugin-id))))
(defn- insert-at
[ids idx id]
(let [v (vec ids)
idx (max 0 (min idx (count v)))]
(vec (concat (subvec v 0 idx) [id] (subvec v idx)))))
(defn- restore-local!
"Puts the stored plugin back into the registry at position idx."
[{:keys [plugin-id] :as plugin} idx]
(swap! registry #(-> %
(update :ids (fn [ids]
(insert-at (remove (partial = plugin-id) ids)
idx
plugin-id)))
(assoc-in [:data plugin-id] plugin))))
(defn install-plugin! (defn install-plugin!
[plugin] [plugin]
(letfn [(update-ids [ids] (let [plugin-id (:plugin-id plugin)
(conj previous (get-plugin plugin-id)
(->> ids (remove #(= % (:plugin-id plugin)))) prev-idx (.indexOf (vec (:ids @registry)) plugin-id)]
(:plugin-id plugin)))] (when-not (contains? @in-flight plugin-id)
(swap! registry #(-> % (track! plugin-id)
(update :ids update-ids) (letfn [(update-ids [ids]
(update :data assoc (:plugin-id plugin) plugin))) (conj
(->> (rp/cmd! :add-profile-plugin {:plugin plugin}) (->> ids (remove #(= % (:plugin-id plugin))))
(rx/subs! identity (:plugin-id plugin)))]
(fn [err] (swap! registry #(-> %
(remove-plugin! plugin) (update :ids update-ids)
(.error js/console "Failed to install plugin:" err)))))) (update :data assoc (:plugin-id plugin) plugin)))
(->> (rp/cmd! :add-profile-plugin {:plugin plugin})
(rx/subs! (fn [_]
(release! plugin-id))
(fn [err]
(release! plugin-id)
;; Restore the previous version in place, else drop it
(if previous
(restore-local! previous prev-idx)
(drop-local! plugin))
;; Other failures may have reached the server: undo it
;; once by re-saving the previous version or removing
;; the new entry, without further rollback.
(when-not (validation-error? err)
(->> (if previous
(rp/cmd! :add-profile-plugin {:plugin previous})
(rp/cmd! :remove-profile-plugin {:plugin-id plugin-id}))
(rx/subs! (fn [_] nil)
(fn [err2]
(.error js/console "Rollback failed:" err2)))))
(.error js/console "Failed to install plugin:" err))))))))
(defn remove-plugin! (defn remove-plugin!
[{:keys [plugin-id]}] [{:keys [plugin-id]}]
(let [plugin (get-plugin plugin-id)] (let [stored (get-plugin plugin-id)
(letfn [(update-ids [ids] prev-idx (.indexOf (vec (:ids @registry)) plugin-id)]
(->> ids (when-not (contains? @in-flight plugin-id)
(remove #(= % plugin-id))))] (track! plugin-id)
(swap! registry #(-> % (letfn [(update-ids [ids]
(update :ids update-ids) (->> ids
(update :data dissoc plugin-id))) (remove #(= % plugin-id))))]
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id}) (swap! registry #(-> %
(rx/subs! identity (update :ids update-ids)
(fn [err] (update :data dissoc plugin-id)))
(when plugin (->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
(install-plugin! plugin)) (rx/subs! (fn [_]
(.error js/console "Failed to remove plugin:" err))))))) (release! plugin-id))
(fn [err]
(release! plugin-id)
(when stored
;; Restore in place; validation errors keep it server-side
(restore-local! stored prev-idx)
;; Other failures: re-save it once, without further rollback
(when-not (validation-error? err)
(->> (rp/cmd! :add-profile-plugin {:plugin stored})
(rx/subs! (fn [_] nil)
(fn [err2]
(.error js/console "Rollback install failed:" err2))))))
(.error js/console "Failed to remove plugin:" err))))))))
(defn check-permission (defn check-permission
[plugin-id permission] [plugin-id permission]

View File

@ -9,10 +9,13 @@
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
[app.main.data.websocket :as dws]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.router :as rt] [app.main.router :as rt]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cljs.test :as t :include-macros true] [cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]
[frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
@ -82,3 +85,48 @@
(fn [] (fn []
(done'))))) (done')))))
done)))) done))))
(defn- sent-messages
"Runs the watch of `event` against `stream` with `dws/send` stubbed
and resolves to the messages it sends over the websocket."
[event state stream]
(let [sent (atom [])]
(-> (mock/with-mocks*
{dws/send (mock/stub (fn [msg] (swap! sent conj msg) msg))}
(await (async/observe (ptk/watch event state stream))))
(.then (fn [_] @sent)))))
(t/deftest ^:async dashboard-initialize-subscribes-to-team
(let [team-id (uuid/next)
state {:profile-id (uuid/next)
:teams {team-id {:id team-id :organization {:id (uuid/next)}}}}
sent (await (sent-messages (dd/initialize team-id) state (rx/empty)))]
(t/is (= [{:type :subscribe-team :team-id team-id}] sent))))
(t/deftest ^:async dashboard-initialize-resubscribes-on-reconnect
(let [team-id (uuid/next)
state {:profile-id (uuid/next)
:teams {team-id {:id team-id}}}
stream (rx/of (ptk/data-event ::dws/opened {})
(ptk/data-event ::dws/opened {}))
sent (await (sent-messages (dd/initialize team-id) state stream))]
(t/is (= 3 (count sent)))
(t/is (every? #(= {:type :subscribe-team :team-id team-id} %) sent))))
(t/deftest ^:async dashboard-initialize-accepts-team-organization-messages
(let [team-id (uuid/next)
org-id (uuid/next)
state {:profile-id (uuid/next)
:teams {team-id {:id team-id :organization {:id org-id}}}}
message (fn [topic]
(ptk/data-event ::dws/message
{:type :organization-change-sso
:topic topic
:organization-id org-id}))
stream (rx/of (message org-id) (message (uuid/next)))
processed (atom [])]
(await (mock/with-mocks*
{dws/send (mock/stub identity)}
(await (async/observe (ptk/watch (dd/initialize team-id) state stream)
:on-next #(swap! processed conj (ptk/type %))))))
(t/is (= 1 (count (filter #{::dcm/handle-organization-change-sso} @processed))))))

View File

@ -1,131 +0,0 @@
;; 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 SUBSIDIARY SL
(ns frontend-tests.data.wasm-text-test
"Growth anchor of auto-grow wasm text shapes (see `resize-wasm-text-modifiers`).
Tests stub the wasm bridge and assert on the shape the modifiers produce."
(:require
[app.common.geom.shapes :as gsh]
[app.common.types.modifiers :as ctm]
[app.common.types.shape :as cts]
[app.main.data.workspace.wasm-text :as dwwt]
[cljs.test :as t :include-macros true]))
;; ---------------------------------------------------------------------------
;; Helpers
;; ---------------------------------------------------------------------------
(defn- make-content
"Text content whose paragraphs carry the given `:text-direction` values."
[& directions]
{:type "root"
:children [{:type "paragraph-set"
:children (vec (for [direction directions]
{:type "paragraph"
:text-direction direction
:children [{:text "hello"}]}))}]})
(defn- make-text-shape
[& {:keys [x y width height grow-type rotation]
:or {x 100 y 50 width 60 height 20 grow-type :auto-width}}]
(let [shape (-> (cts/setup-shape {:type :text
:x x
:y y
:width width
:height height})
(assoc :grow-type grow-type))]
(if (some? rotation)
(gsh/transform-shape
shape
(ctm/rotation-modifiers shape (gsh/shape->center shape) rotation))
shape)))
(defn- resized
"Shape that results from `resize-wasm-text-modifiers` when the renderer
measures `new-size`."
[shape content new-size]
;; The stub needs the real fn's arities: a variadic `fn` has no `arity$2`
;; dispatch, so the 2-arity call site blows up.
(with-redefs [dwwt/get-wasm-text-new-size (fn ([_] new-size) ([_ _] new-size))]
(let [modifiers (dwwt/resize-wasm-text-modifiers shape content)]
(gsh/transform-shape shape (get-in modifiers [(:id shape) :modifiers])))))
(defn- close? [a b]
(< (abs (- a b)) 0.01))
;; ---------------------------------------------------------------------------
;; Growth anchor
;; ---------------------------------------------------------------------------
(t/deftest rtl-auto-width-keeps-its-right-edge-when-growing
(t/testing "an rtl auto-width shape extends leftward: the right and top edges
stay put and x moves left"
(let [shape (make-text-shape)
content (make-content "rtl")
before (:selrect shape)
after (:selrect (resized shape content {:width 120 :height 20}))]
(t/is (close? (+ (:x after) (:width after))
(+ (:x before) (:width before)))
"right edge preserved")
(t/is (close? (:y after) (:y before)) "top edge preserved")
(t/is (close? (:x after) 40) "x moved left by the growth")
(t/is (close? (:width after) 120)))))
(t/deftest rtl-auto-width-keeps-its-right-edge-when-shrinking
(t/testing "deleting text shrinks the box from the left, right edge preserved"
(let [shape (make-text-shape)
content (make-content "rtl")
before (:selrect shape)
after (:selrect (resized shape content {:width 30 :height 20}))]
(t/is (close? (+ (:x after) (:width after))
(+ (:x before) (:width before)))
"right edge preserved")
(t/is (close? (:x after) 130) "x moved right as the box narrowed"))))
(t/deftest ltr-auto-width-keeps-its-left-edge
(t/testing "ltr content is untouched: the left edge stays anchored"
(let [shape (make-text-shape)
content (make-content "ltr")
after (:selrect (resized shape content {:width 120 :height 20}))]
(t/is (close? (:x after) 100) "left edge preserved")
(t/is (close? (:width after) 120)))))
(t/deftest mixed-direction-auto-width-keeps-its-left-edge
(t/testing "a box with one rtl and one ltr paragraph keeps the previous
left-anchored growth"
(let [shape (make-text-shape)
content (make-content "rtl" "ltr")
after (:selrect (resized shape content {:width 120 :height 20}))]
(t/is (close? (:x after) 100) "left edge preserved"))))
(t/deftest rtl-auto-height-keeps-its-left-edge
(t/testing "auto-height only ever changes height, so the anchor is irrelevant
and x must not move"
(let [shape (make-text-shape :grow-type :auto-height)
content (make-content "rtl")
after (:selrect (resized shape content {:width 60 :height 80}))]
(t/is (close? (:x after) 100) "x preserved")
(t/is (close? (:width after) 60) "width preserved")
(t/is (close? (:height after) 80) "height grew"))))
(t/deftest rotated-rtl-auto-width-grows-along-its-own-axis
(t/testing "a rotated rtl shape keeps its own top-right corner, not the
axis-aligned one"
(let [shape (make-text-shape :rotation 30)
content (make-content "rtl")
before (:points shape)
after (:points (resized shape content {:width 120 :height 20}))]
;; points are [top-left top-right bottom-right bottom-left]
(t/is (close? (:x (second after)) (:x (second before)))
"top-right x preserved")
(t/is (close? (:y (second after)) (:y (second before)))
"top-right y preserved"))))
(t/deftest no-modifiers-when-the-renderer-has-no-size
(t/testing "a nil measurement (shape absent from wasm state) skips the resize"
(let [shape (make-text-shape)]
(with-redefs [dwwt/get-wasm-text-new-size (fn ([_] nil) ([_ _] nil))]
(t/is (nil? (dwwt/resize-wasm-text-modifiers shape (make-content "rtl"))))))))

View File

@ -581,53 +581,3 @@
(t/testing "a non-root content (e.g. paragraph) is left alone" (t/testing "a non-root content (e.g. paragraph) is left alone"
(let [node {:type "paragraph" :children []}] (let [node {:type "paragraph" :children []}]
(t/is (= node (dwt/ensure-valid-text-content node)))))) (t/is (= node (dwt/ensure-valid-text-content node))))))
;; ---------------------------------------------------------------------------
;; txt/rtl-content?
;; ---------------------------------------------------------------------------
(defn- make-content
"Text content whose paragraphs carry the given `:text-direction` values; a
`nil` entry leaves the attribute out entirely."
[& directions]
{:type "root"
:children [{:type "paragraph-set"
:children (vec (for [direction directions]
(cond-> {:type "paragraph"
:children [{:text "hello"}]}
(some? direction)
(assoc :text-direction direction))))}]})
(t/deftest rtl-content-single-rtl-paragraph
(t/testing "a lone rtl paragraph makes the content rtl"
(t/is (true? (txt/rtl-content? (make-content "rtl"))))))
(t/deftest rtl-content-every-paragraph-rtl
(t/testing "several paragraphs, all rtl"
(t/is (true? (txt/rtl-content? (make-content "rtl" "rtl" "rtl"))))))
(t/deftest rtl-content-mixed-directions
(t/testing "a mix of rtl and ltr is not rtl"
(t/is (false? (txt/rtl-content? (make-content "rtl" "ltr"))))))
(t/deftest rtl-content-missing-direction-on-one-paragraph
(t/testing "a paragraph without :text-direction defaults to ltr, so the
content is not rtl"
(t/is (false? (txt/rtl-content? (make-content "rtl" nil))))))
(t/deftest rtl-content-all-ltr
(t/testing "all-ltr content is not rtl"
(t/is (false? (txt/rtl-content? (make-content "ltr" "ltr"))))))
(t/deftest rtl-content-none-direction-is-ltr
(t/testing "\"none\" (the sidebar's un-toggled value) is ltr, matching what
translate-text-direction sends to the renderer"
(t/is (false? (txt/rtl-content? (make-content "none"))))))
(t/deftest rtl-content-without-paragraphs
(t/testing "a root with no paragraph nodes is not rtl"
(t/is (false? (txt/rtl-content? {:type "root" :children []})))))
(t/deftest rtl-content-nil
(t/testing "nil content is not rtl and does not throw"
(t/is (false? (txt/rtl-content? nil)))))

View File

@ -0,0 +1,322 @@
;; 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 SUBSIDIARY SL
(ns frontend-tests.plugins.register-test
(:require
[app.main.repo :as rp]
[app.plugins.register :as preg]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]))
(defn- record-cmd-mock
"Mock rp/cmd! that records calls in mock/rpc-calls and answers
with (response-fn cmd params)."
[response-fn]
(mock/stub
(fn [cmd params]
(swap! mock/rpc-calls conj {:cmd cmd :params params})
(response-fn cmd params))))
(defn- cmds
[]
(mapv :cmd @mock/rpc-calls))
;; --- install-plugin! ---
(t/deftest install-success-releases-id
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
(fn [done']
(let [plugin {:plugin-id "reg-install-ok"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin] (cmds)))
(t/is (= plugin (preg/get-plugin "reg-install-ok")))
;; id released: a second install issues a second RPC
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest install-while-in-flight-issues-no-second-rpc
(t/async done
(let [subjects (atom [])]
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _]
(let [sb (rx/subject)]
(swap! subjects conj sb)
sb)))}
(fn [done']
(let [plugin {:plugin-id "reg-install-dedupe"}]
(preg/install-plugin! plugin)
(preg/install-plugin! plugin)
(t/is (= 1 (count @mock/rpc-calls)) "second install while in flight is skipped")
;; complete the pending call; the id is released afterwards
(rx/push! (first @subjects) {:ok true})
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done))))
(t/deftest install-validation-error-cleans-local-only
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _] (rx/throw (ex-info "rejected" {:type :validation :code :props-too-large}))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-validation"}]
(preg/install-plugin! plugin)
(t/is (= 1 (count @mock/rpc-calls)) "no rollback write after validation rejection")
(t/is (nil? (preg/get-plugin "reg-install-validation")))
;; id released: the next install retries the RPC
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest install-validation-error-restores-previous-version
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ params]
(if (= "v2" (get-in params [:plugin :name]))
(rx/throw (ex-info "rejected" {:type :validation}))
(rx/of {:ok true}))))}
(fn [done']
(let [v1 {:plugin-id "reg-install-prev" :name "v1"}
v2 {:plugin-id "reg-install-prev" :name "v2"}]
(preg/install-plugin! v1)
(preg/install-plugin! v2)
(t/is (= 2 (count @mock/rpc-calls)))
(t/is (= v1 (preg/get-plugin "reg-install-prev"))
"the server-kept version is restored, not dropped")
(done')))
done)))
(t/deftest install-validation-error-keeps-original-position
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ params]
(if (= "v2" (get-in params [:plugin :name]))
(rx/throw (ex-info "rejected" {:type :validation}))
(rx/of {:ok true}))))}
(fn [done']
(let [own #{"reg-pos-a" "reg-pos-b" "reg-pos-c"}
v1 {:plugin-id "reg-pos-b" :name "v1"}
v2 {:plugin-id "reg-pos-b" :name "v2"}]
(preg/install-plugin! {:plugin-id "reg-pos-a"})
(preg/install-plugin! v1)
(preg/install-plugin! {:plugin-id "reg-pos-c"})
(preg/install-plugin! v2)
;; installs prepend, so newest-first; the rejected update
;; must preserve order and restore v1
(t/is (= ["reg-pos-c" "reg-pos-b" "reg-pos-a"]
(filterv own (mapv :plugin-id (preg/plugins-list)))))
(t/is (= v1 (preg/get-plugin "reg-pos-b")))
(done')))
done)))
(t/deftest install-persistent-failure-terminates
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _] (rx/throw (ex-info "boom" {:type :other}))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-hang"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))
"one-shot rollback: no further calls")
(t/is (nil? (preg/get-plugin "reg-install-hang")))
(done')))
done)))
(t/deftest install-non-validation-error-rolls-back-via-rpc
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/throw (ex-info "boom" {:type :other}))
(rx/of nil))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-rollback"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)))
(t/is (nil? (preg/get-plugin "reg-install-rollback")))
(done')))
done)))
(t/deftest install-non-validation-error-on-update-resaves-previous
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ params]
(if (= "v2" (get-in params [:plugin :name]))
(rx/throw (ex-info "busy" {:type :concurrency-limit}))
(rx/of {:ok true}))))}
(fn [done']
(let [own #{"reg-upd-a" "reg-upd-b" "reg-upd-c"}
v1 {:plugin-id "reg-upd-b" :name "v1"}
v2 {:plugin-id "reg-upd-b" :name "v2"}]
(preg/install-plugin! {:plugin-id "reg-upd-a"})
(preg/install-plugin! v1)
(preg/install-plugin! {:plugin-id "reg-upd-c"})
(reset! mock/rpc-calls [])
(preg/install-plugin! v2)
(t/is (= [:add-profile-plugin :add-profile-plugin] (cmds))
"the compensating write re-saves v1, never removes it")
(t/is (= v1 (get-in (second @mock/rpc-calls) [:params :plugin])))
(t/is (= v1 (preg/get-plugin "reg-upd-b")))
(t/is (= ["reg-upd-c" "reg-upd-b" "reg-upd-a"]
(filterv own (mapv :plugin-id (preg/plugins-list)))))
(done')))
done)))
;; --- remove-plugin! ---
(t/deftest remove-success-releases-id
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-ok"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)))
(t/is (nil? (preg/get-plugin "reg-remove-ok")))
;; id released: a second remove issues another RPC
(preg/remove-plugin! plugin)
(t/is (= 3 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest remove-while-in-flight-issues-no-second-rpc
(t/async done
(let [subjects (atom [])]
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(let [sb (rx/subject)]
(swap! subjects conj sb)
sb))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-dedupe"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)) "second remove while in flight is skipped")
;; complete the pending call; the id is released afterwards
(rx/push! (first @subjects) nil)
(preg/remove-plugin! plugin)
(t/is (= 3 (count @mock/rpc-calls)))
(done')))
done))))
(t/deftest remove-validation-error-restores-local-only
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "rejected" {:type :validation})))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-validation"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))
"no reinstall write after validation rejection")
(t/is (= plugin (preg/get-plugin "reg-remove-validation"))
"local entry is restored")
(done')))
done)))
(t/deftest remove-non-validation-error-reinstalls-via-rpc
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :remove-profile-plugin)
(rx/throw (ex-info "boom" {:type :other}))
(rx/of {:ok true}))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-rollback"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds)))
(t/is (= plugin (preg/get-plugin "reg-remove-rollback")))
(done')))
done)))
(t/deftest remove-persistent-failure-terminates
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "boom" {:type :other})))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-hang"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds))
"one-shot rollback: no further calls")
(t/is (= plugin (preg/get-plugin "reg-remove-hang")))
(done')))
done)))
(t/deftest remove-validation-error-keeps-original-position
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "rejected" {:type :validation})))))}
(fn [done']
(let [own #{"reg-idx-a" "reg-idx-b" "reg-idx-c"}
plugin {:plugin-id "reg-idx-b"}]
(preg/install-plugin! {:plugin-id "reg-idx-a"})
(preg/install-plugin! plugin)
(preg/install-plugin! {:plugin-id "reg-idx-c"})
(preg/remove-plugin! plugin)
;; installs prepend, so the order is newest-first;
;; the failed removal must preserve it exactly
(t/is (= ["reg-idx-c" "reg-idx-b" "reg-idx-a"]
(filterv own (mapv :plugin-id (preg/plugins-list)))))
(done')))
done)))
;; --- subscribe-registry! ---
(t/deftest registry-subscriber-sees-rollback
(t/async done
(let [subjects (atom [])
seen (atom [])
listener (fn [] (swap! seen conj (some? (preg/get-plugin "reg-watch"))))]
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _]
(let [sb (rx/subject)]
(swap! subjects conj sb)
sb)))}
(fn [done']
(preg/subscribe-registry! listener)
(preg/install-plugin! {:plugin-id "reg-watch"})
(rx/error! (first @subjects) (ex-info "rejected" {:type :validation}))
(t/is (= [true false] @seen)
"notified on the optimistic add and on the rollback")
(preg/unsubscribe-registry! listener)
(preg/install-plugin! {:plugin-id "reg-watch"})
(t/is (= [true false] @seen) "no calls after unsubscribing")
(done'))
done))))

View File

@ -21,7 +21,6 @@
[frontend-tests.data.svg-upload-test] [frontend-tests.data.svg-upload-test]
[frontend-tests.data.uploads-test] [frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test] [frontend-tests.data.viewer-test]
[frontend-tests.data.wasm-text-test]
[frontend-tests.data.workspace-colors-test] [frontend-tests.data.workspace-colors-test]
[frontend-tests.data.workspace-comments-test] [frontend-tests.data.workspace-comments-test]
[frontend-tests.data.workspace-context-menu-test] [frontend-tests.data.workspace-context-menu-test]
@ -72,6 +71,7 @@
[frontend-tests.plugins.page-active-validation-test] [frontend-tests.plugins.page-active-validation-test]
[frontend-tests.plugins.page-test] [frontend-tests.plugins.page-test]
[frontend-tests.plugins.parser-test] [frontend-tests.plugins.parser-test]
[frontend-tests.plugins.register-test]
[frontend-tests.plugins.shape-bugfixes-test] [frontend-tests.plugins.shape-bugfixes-test]
[frontend-tests.plugins.text-test] [frontend-tests.plugins.text-test]
[frontend-tests.plugins.tokens-test] [frontend-tests.plugins.tokens-test]
@ -156,7 +156,6 @@
'frontend-tests.data.svg-upload-test 'frontend-tests.data.svg-upload-test
'frontend-tests.data.uploads-test 'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test 'frontend-tests.data.viewer-test
'frontend-tests.data.wasm-text-test
'frontend-tests.data.workspace-colors-test 'frontend-tests.data.workspace-colors-test
'frontend-tests.data.workspace-comments-test 'frontend-tests.data.workspace-comments-test
'frontend-tests.data.workspace-context-menu-test 'frontend-tests.data.workspace-context-menu-test
@ -207,6 +206,7 @@
'frontend-tests.plugins.page-active-validation-test 'frontend-tests.plugins.page-active-validation-test
'frontend-tests.plugins.page-test 'frontend-tests.plugins.page-test
'frontend-tests.plugins.parser-test 'frontend-tests.plugins.parser-test
'frontend-tests.plugins.register-test
'frontend-tests.plugins.shape-bugfixes-test 'frontend-tests.plugins.shape-bugfixes-test
'frontend-tests.plugins.text-test 'frontend-tests.plugins.text-test
'frontend-tests.plugins.tokens-test 'frontend-tests.plugins.tokens-test

View File

@ -1438,11 +1438,11 @@ msgstr "Attention"
msgid "ds.component-subtitle" msgid "ds.component-subtitle"
msgstr "Components to update:" msgstr "Components to update:"
#: src/app/main/ui/workspace/plugins.cljs:379, src/app/main/ui/workspace/plugins.cljs:442 #: src/app/main/ui/workspace/plugins.cljs:407, src/app/main/ui/workspace/plugins.cljs:470
msgid "ds.confirm-allow" msgid "ds.confirm-allow"
msgstr "Allow" msgstr "Allow"
#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:373, src/app/main/ui/workspace/plugins.cljs:436 #: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:401, src/app/main/ui/workspace/plugins.cljs:464
msgid "ds.confirm-cancel" msgid "ds.confirm-cancel"
msgstr "Cancel" msgstr "Cancel"
@ -1524,7 +1524,7 @@ msgstr ""
"Clipboard access denied. Please allow clipboard permissions in your browser " "Clipboard access denied. Please allow clipboard permissions in your browser "
"to paste content" "to paste content"
#: src/app/main/errors.cljs:773 #: src/app/main/errors.cljs:782
msgid "errors.comment-error" msgid "errors.comment-error"
msgstr "There was an error with the comment" msgstr "There was an error with the comment"
@ -1532,21 +1532,21 @@ msgstr "There was an error with the comment"
msgid "errors.connection-error" msgid "errors.connection-error"
msgstr "Cannot reach the server. Check your connection and try again." msgstr "Cannot reach the server. Check your connection and try again."
#: src/app/main/errors.cljs:842 #: src/app/main/errors.cljs:851
msgid "errors.deprecated" msgid "errors.deprecated"
msgstr "" msgstr ""
"Sorry! This is an old file that uses a deprecated type of Penpot assets and " "Sorry! This is an old file that uses a deprecated type of Penpot assets and "
"you can't open it." "you can't open it."
#: src/app/main/errors.cljs:845 #: src/app/main/errors.cljs:854
msgid "errors.deprecated.contact.after" msgid "errors.deprecated.contact.after"
msgstr "so we can help you." msgstr "so we can help you."
#: src/app/main/errors.cljs:843 #: src/app/main/errors.cljs:852
msgid "errors.deprecated.contact.before" msgid "errors.deprecated.contact.before"
msgstr "Although Penpot no longer support this type of Assets, you can" msgstr "Although Penpot no longer support this type of Assets, you can"
#: src/app/main/errors.cljs:844 #: src/app/main/errors.cljs:853
msgid "errors.deprecated.contact.text" msgid "errors.deprecated.contact.text"
msgstr "contact us" msgstr "contact us"
@ -1582,13 +1582,13 @@ msgstr "The email «%s» has many permanent bounce reports."
msgid "errors.email-spam-or-permanent-bounces" msgid "errors.email-spam-or-permanent-bounces"
msgstr "The email «%s» has been reported as spam or permanently bounce." msgstr "The email «%s» has been reported as spam or permanently bounce."
#: src/app/main/errors.cljs:819 #: src/app/main/errors.cljs:828
msgid "errors.feature-mismatch" msgid "errors.feature-mismatch"
msgstr "" msgstr ""
"Looks like you are opening a file that has the feature '%s' enabled but the " "Looks like you are opening a file that has the feature '%s' enabled but the "
"current penpot version does not supports it or has it disabled." "current penpot version does not supports it or has it disabled."
#: src/app/main/errors.cljs:823, src/app/main/errors.cljs:837 #: src/app/main/errors.cljs:832, src/app/main/errors.cljs:846
msgid "errors.feature-not-supported" msgid "errors.feature-not-supported"
msgstr "Feature '%s' is not supported." msgstr "Feature '%s' is not supported."
@ -1608,7 +1608,7 @@ msgstr "Empty field"
msgid "errors.field-not-all-whitespace" msgid "errors.field-not-all-whitespace"
msgstr "The name must contain some character other than space." msgstr "The name must contain some character other than space."
#: src/app/main/errors.cljs:815 #: src/app/main/errors.cljs:824
msgid "errors.file-feature-mismatch" msgid "errors.file-feature-mismatch"
msgstr "" msgstr ""
"It seems that there is a mismatch between the enabled features and the " "It seems that there is a mismatch between the enabled features and the "
@ -1621,11 +1621,11 @@ msgstr ""
"The font family name can only contain letters, numbers, spaces, hyphens, " "The font family name can only contain letters, numbers, spaces, hyphens, "
"underscores, and dots." "underscores, and dots."
#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:158, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966 #: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:131, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966
msgid "errors.generic" msgid "errors.generic"
msgstr "Something wrong has happened." msgstr "Something wrong has happened."
#: src/app/main/errors.cljs:752 #: src/app/main/errors.cljs:761
msgid "errors.internal-worker-error" msgid "errors.internal-worker-error"
msgstr "Something wrong has happened with the web worker." msgstr "Something wrong has happened with the web worker."
@ -1675,7 +1675,7 @@ msgstr "This invite might be canceled or may be expired."
msgid "errors.ldap-disabled" msgid "errors.ldap-disabled"
msgstr "LDAP authentication is disabled." msgstr "LDAP authentication is disabled."
#: src/app/main/errors.cljs:831, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117 #: src/app/main/errors.cljs:840, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117
msgid "errors.max-quota-reached" msgid "errors.max-quota-reached"
msgstr "You have reached the '%s' quota. Contact support." msgstr "You have reached the '%s' quota. Contact support."
@ -1701,7 +1701,7 @@ msgstr "Seems that this is not a valid image."
msgid "errors.member-is-muted" msgid "errors.member-is-muted"
msgstr "The profile you inviting has emails muted (spam reports or high bounces)." msgstr "The profile you inviting has emails muted (spam reports or high bounces)."
#: src/app/main/errors.cljs:805 #: src/app/main/errors.cljs:814
msgid "errors.migration-in-progress" msgid "errors.migration-in-progress"
msgstr "Migration in progress" msgstr "Migration in progress"
@ -1745,6 +1745,12 @@ msgstr "The profile is blocked"
msgid "errors.profile-is-muted" msgid "errors.profile-is-muted"
msgstr "Your profile has emails muted (spam reports or high bounces)." msgstr "Your profile has emails muted (spam reports or high bounces)."
#: src/app/main/errors.cljs:724
msgid "errors.profile-props-too-large"
msgstr ""
"Your profile settings are too large to save. Remove some plugins and try "
"again."
#: src/app/main/data/auth.cljs:340, src/app/main/ui/auth/register.cljs:96 #: src/app/main/data/auth.cljs:340, src/app/main/ui/auth/register.cljs:96
msgid "errors.registration-disabled" msgid "errors.registration-disabled"
msgstr "The registration is currently disabled." msgstr "The registration is currently disabled."
@ -1759,11 +1765,11 @@ msgstr ""
msgid "errors.save-retrying" msgid "errors.save-retrying"
msgstr "Connection lost. Retrying to save your changes." msgstr "Connection lost. Retrying to save your changes."
#: src/app/main/errors.cljs:764 #: src/app/main/errors.cljs:773
msgid "errors.svg-parser.invalid-svg" msgid "errors.svg-parser.invalid-svg"
msgstr "SVG is invalid or malformed" msgstr "SVG is invalid or malformed"
#: src/app/main/errors.cljs:810 #: src/app/main/errors.cljs:819
msgid "errors.team-feature-mismatch" msgid "errors.team-feature-mismatch"
msgstr "Detected incompatible feature '%s'" msgstr "Detected incompatible feature '%s'"
@ -1936,7 +1942,7 @@ msgstr "An unexpected error occurred."
msgid "errors.unexpected-token" msgid "errors.unexpected-token"
msgstr "Unknown token" msgstr "Unknown token"
#: src/app/main/errors.cljs:723 #: src/app/main/errors.cljs:732
msgid "errors.version-already-locked" msgid "errors.version-already-locked"
msgstr "This version is already locked" msgstr "This version is already locked"
@ -1944,7 +1950,7 @@ msgstr "This version is already locked"
msgid "errors.version-locked" msgid "errors.version-locked"
msgstr "This version is locked and cannot be deleted by others" msgstr "This version is locked and cannot be deleted by others"
#: src/app/main/errors.cljs:827 #: src/app/main/errors.cljs:836
msgid "errors.version-not-supported" msgid "errors.version-not-supported"
msgstr "File has an incompatible version number" msgstr "File has an incompatible version number"
@ -8099,16 +8105,16 @@ msgstr "Snap nodes (%s)"
msgid "workspace.plugins.button-open" msgid "workspace.plugins.button-open"
msgstr "Open" msgstr "Open"
#: src/app/main/ui/workspace/plugins.cljs:215 #: src/app/main/ui/workspace/plugins.cljs:242
#, markdown #, markdown
msgid "workspace.plugins.discover" msgid "workspace.plugins.discover"
msgstr "Discover [more plugins](%s)" msgstr "Discover [more plugins](%s)"
#: src/app/main/ui/workspace/plugins.cljs:222 #: src/app/main/ui/workspace/plugins.cljs:249
msgid "workspace.plugins.empty-plugins" msgid "workspace.plugins.empty-plugins"
msgstr "No plugins installed yet" msgstr "No plugins installed yet"
#: src/app/main/ui/workspace/plugins.cljs:209 #: src/app/main/ui/workspace/plugins.cljs:236
msgid "workspace.plugins.error.manifest" msgid "workspace.plugins.error.manifest"
msgstr "The plugin manifest is incorrect." msgstr "The plugin manifest is incorrect."
@ -8120,15 +8126,15 @@ msgstr "You need to be an editor to use this plugin"
msgid "workspace.plugins.error.unreachable" msgid "workspace.plugins.error.unreachable"
msgstr "The plugin URL could not be reached." msgstr "The plugin URL could not be reached."
#: src/app/main/ui/workspace/plugins.cljs:205 #: src/app/main/ui/workspace/plugins.cljs:232
msgid "workspace.plugins.error.url" msgid "workspace.plugins.error.url"
msgstr "The plugin doesn't exist or the URL is not correct." msgstr "The plugin doesn't exist or the URL is not correct."
#: src/app/main/ui/workspace/plugins.cljs:201 #: src/app/main/ui/workspace/plugins.cljs:228
msgid "workspace.plugins.install" msgid "workspace.plugins.install"
msgstr "Install" msgstr "Install"
#: src/app/main/ui/workspace/plugins.cljs:231 #: src/app/main/ui/workspace/plugins.cljs:258
msgid "workspace.plugins.installed-plugins" msgid "workspace.plugins.installed-plugins"
msgstr "Installed plugins" msgstr "Installed plugins"
@ -8140,102 +8146,112 @@ msgstr "Plugins manager"
msgid "workspace.plugins.menu.title" msgid "workspace.plugins.menu.title"
msgstr "Plugins" msgstr "Plugins"
#: src/app/main/ui/workspace/plugins.cljs:424 #: src/app/main/ui/workspace/plugins.cljs:452
msgid "workspace.plugins.permissions-update.title" msgid "workspace.plugins.permissions-update.title"
msgstr "UPDATE THIS PLUGIN" msgstr "UPDATE THIS PLUGIN"
#: src/app/main/ui/workspace/plugins.cljs:428 #: src/app/main/ui/workspace/plugins.cljs:456
msgid "workspace.plugins.permissions-update.warning" msgid "workspace.plugins.permissions-update.warning"
msgstr "" msgstr ""
"The plugin has been modified since you last opened it. It now also wants to " "The plugin has been modified since you last opened it. It now also wants to "
"access:" "access:"
#: src/app/main/ui/workspace/plugins.cljs:297 #: src/app/main/ui/workspace/plugins.cljs:325
msgid "workspace.plugins.permissions.allow-download" msgid "workspace.plugins.permissions.allow-download"
msgstr "Start file downloads." msgstr "Start file downloads."
#: src/app/main/ui/workspace/plugins.cljs:304 #: src/app/main/ui/workspace/plugins.cljs:332
msgid "workspace.plugins.permissions.allow-localstorage" msgid "workspace.plugins.permissions.allow-localstorage"
msgstr "Store data in the browser." msgstr "Store data in the browser."
#: src/app/main/ui/workspace/plugins.cljs:317 #: src/app/main/ui/workspace/plugins.cljs:345
msgid "workspace.plugins.permissions.clipboard-read" msgid "workspace.plugins.permissions.clipboard-read"
msgstr "Read the contents of your clipboard." msgstr "Read the contents of your clipboard."
#: src/app/main/ui/workspace/plugins.cljs:311 #: src/app/main/ui/workspace/plugins.cljs:339
msgid "workspace.plugins.permissions.clipboard-write" msgid "workspace.plugins.permissions.clipboard-write"
msgstr "Read and write to your clipboard." msgstr "Read and write to your clipboard."
#: src/app/main/ui/workspace/plugins.cljs:290 #: src/app/main/ui/workspace/plugins.cljs:318
msgid "workspace.plugins.permissions.comment-read" msgid "workspace.plugins.permissions.comment-read"
msgstr "Read your comments and replies." msgstr "Read your comments and replies."
#: src/app/main/ui/workspace/plugins.cljs:284 #: src/app/main/ui/workspace/plugins.cljs:312
msgid "workspace.plugins.permissions.comment-write" msgid "workspace.plugins.permissions.comment-write"
msgstr "Read and modify your comments and reply in your name." msgstr "Read and modify your comments and reply in your name."
#: src/app/main/ui/workspace/plugins.cljs:257 #: src/app/main/ui/workspace/plugins.cljs:285
msgid "workspace.plugins.permissions.content-read" msgid "workspace.plugins.permissions.content-read"
msgstr "Read the content of files that users have access to." msgstr "Read the content of files that users have access to."
#: src/app/main/ui/workspace/plugins.cljs:251 #: src/app/main/ui/workspace/plugins.cljs:279
msgid "workspace.plugins.permissions.content-write" msgid "workspace.plugins.permissions.content-write"
msgstr "Read and modify the content of files that users have access to." msgstr "Read and modify the content of files that users have access to."
#: src/app/main/ui/workspace/plugins.cljs:366 #: src/app/main/ui/workspace/plugins.cljs:394
msgid "workspace.plugins.permissions.disclaimer" msgid "workspace.plugins.permissions.disclaimer"
msgstr "" msgstr ""
"Please note that this plugin is created by an external party, so ensure you " "Please note that this plugin is created by an external party, so ensure you "
"trust it before granting access. Your data privacy and security are " "trust it before granting access. Your data privacy and security are "
"important to us. If you have any concerns, please contact support." "important to us. If you have any concerns, please contact support."
#: src/app/main/ui/workspace/plugins.cljs:277 #: src/app/main/ui/workspace/plugins.cljs:305
msgid "workspace.plugins.permissions.library-read" msgid "workspace.plugins.permissions.library-read"
msgstr "Read your libraries and assets." msgstr "Read your libraries and assets."
#: src/app/main/ui/workspace/plugins.cljs:271 #: src/app/main/ui/workspace/plugins.cljs:299
msgid "workspace.plugins.permissions.library-write" msgid "workspace.plugins.permissions.library-write"
msgstr "Read and modify your libraries and assets." msgstr "Read and modify your libraries and assets."
#: src/app/main/ui/workspace/plugins.cljs:359 #: src/app/main/ui/workspace/plugins.cljs:387
msgid "workspace.plugins.permissions.title" msgid "workspace.plugins.permissions.title"
msgstr "'%s' PLUGIN WANTS ACCESS TO:" msgstr "'%s' PLUGIN WANTS ACCESS TO:"
#: src/app/main/ui/workspace/plugins.cljs:264 #: src/app/main/ui/workspace/plugins.cljs:292
msgid "workspace.plugins.permissions.user-read" msgid "workspace.plugins.permissions.user-read"
msgstr "Read the profile information of the current user." msgstr "Read the profile information of the current user."
#: src/app/main/ui/workspace/plugins.cljs:227 #: src/app/main/ui/workspace/plugins.cljs:254
msgid "workspace.plugins.plugin-list-link" msgid "workspace.plugins.plugin-list-link"
msgstr "Plugins List" msgstr "Plugins List"
#: src/app/main/ui/workspace/plugins.cljs:101 #: src/app/main/ui/workspace/plugins.cljs:191
msgid "workspace.plugins.remove-confirmation.message"
msgstr ""
"Are you sure you want to remove the plugin %s? You can install it again at "
"any time."
#: src/app/main/ui/workspace/plugins.cljs:190
msgid "workspace.plugins.remove-confirmation.title"
msgstr "Remove plugin"
#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:192
msgid "workspace.plugins.remove-plugin" msgid "workspace.plugins.remove-plugin"
msgstr "Remove plugin" msgstr "Remove plugin"
#: src/app/main/ui/workspace/plugins.cljs:195 #: src/app/main/ui/workspace/plugins.cljs:222
msgid "workspace.plugins.search-placeholder" msgid "workspace.plugins.search-placeholder"
msgstr "Write a plugin URL" msgstr "Write a plugin URL"
#: src/app/main/ui/workspace/plugins.cljs:188 #: src/app/main/ui/workspace/plugins.cljs:215
msgid "workspace.plugins.title" msgid "workspace.plugins.title"
msgstr "Plugins" msgstr "Plugins"
#: src/app/main/ui/workspace/plugins.cljs:494 #: src/app/main/ui/workspace/plugins.cljs:522
msgid "workspace.plugins.try-out.cancel" msgid "workspace.plugins.try-out.cancel"
msgstr "NOT NOW" msgstr "NOT NOW"
#: src/app/main/ui/workspace/plugins.cljs:487 #: src/app/main/ui/workspace/plugins.cljs:515
msgid "workspace.plugins.try-out.message" msgid "workspace.plugins.try-out.message"
msgstr "" msgstr ""
"Want to take a look? It will open in a new draft for your current team. (If " "Want to take a look? It will open in a new draft for your current team. (If "
"not, you can always find it in the installed plugins of any file.)" "not, you can always find it in the installed plugins of any file.)"
#: src/app/main/ui/workspace/plugins.cljs:483 #: src/app/main/ui/workspace/plugins.cljs:511
msgid "workspace.plugins.try-out.title" msgid "workspace.plugins.try-out.title"
msgstr "'%s' PLUGIN IS INSTALLED FOR YOUR USER!" msgstr "'%s' PLUGIN IS INSTALLED FOR YOUR USER!"
#: src/app/main/ui/workspace/plugins.cljs:500 #: src/app/main/ui/workspace/plugins.cljs:528
msgid "workspace.plugins.try-out.try" msgid "workspace.plugins.try-out.try"
msgstr "TRY PLUGIN" msgstr "TRY PLUGIN"

View File

@ -1452,11 +1452,11 @@ msgstr "Atención"
msgid "ds.component-subtitle" msgid "ds.component-subtitle"
msgstr "Componentes a actualizar:" msgstr "Componentes a actualizar:"
#: src/app/main/ui/workspace/plugins.cljs:379, src/app/main/ui/workspace/plugins.cljs:442 #: src/app/main/ui/workspace/plugins.cljs:407, src/app/main/ui/workspace/plugins.cljs:470
msgid "ds.confirm-allow" msgid "ds.confirm-allow"
msgstr "Permitir" msgstr "Permitir"
#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:373, src/app/main/ui/workspace/plugins.cljs:436 #: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:401, src/app/main/ui/workspace/plugins.cljs:464
msgid "ds.confirm-cancel" msgid "ds.confirm-cancel"
msgstr "Cancelar" msgstr "Cancelar"
@ -1538,25 +1538,25 @@ msgstr ""
"Acceso al portapapeles denegado. Permite el acceso al portapapeles en tu " "Acceso al portapapeles denegado. Permite el acceso al portapapeles en tu "
"navegador para pegar contenido." "navegador para pegar contenido."
#: src/app/main/errors.cljs:773 #: src/app/main/errors.cljs:782
msgid "errors.comment-error" msgid "errors.comment-error"
msgstr "Ha habido un error con el comentario" msgstr "Ha habido un error con el comentario"
#: src/app/main/errors.cljs:842 #: src/app/main/errors.cljs:851
msgid "errors.deprecated" msgid "errors.deprecated"
msgstr "" msgstr ""
"¡Lo sentimos! Este es un fichero antiguo que utiliza un tipo de recurso de " "¡Lo sentimos! Este es un fichero antiguo que utiliza un tipo de recurso de "
"Penpot obsoleto, y no puedes abrirlo." "Penpot obsoleto, y no puedes abrirlo."
#: src/app/main/errors.cljs:845 #: src/app/main/errors.cljs:854
msgid "errors.deprecated.contact.after" msgid "errors.deprecated.contact.after"
msgstr "para que podamos ayudarte." msgstr "para que podamos ayudarte."
#: src/app/main/errors.cljs:843 #: src/app/main/errors.cljs:852
msgid "errors.deprecated.contact.before" msgid "errors.deprecated.contact.before"
msgstr "Aunque Penpot ya no da soporte a este tipo de Recursos, puedes" msgstr "Aunque Penpot ya no da soporte a este tipo de Recursos, puedes"
#: src/app/main/errors.cljs:844 #: src/app/main/errors.cljs:853
msgid "errors.deprecated.contact.text" msgid "errors.deprecated.contact.text"
msgstr "contactar con nosotros" msgstr "contactar con nosotros"
@ -1592,13 +1592,13 @@ msgstr "El correo electrónico «%s» tiene varios reportes de rebote permanente
msgid "errors.email-spam-or-permanent-bounces" msgid "errors.email-spam-or-permanent-bounces"
msgstr "El email «%s» tiene reportes de spam o de rebote permanente." msgstr "El email «%s» tiene reportes de spam o de rebote permanente."
#: src/app/main/errors.cljs:819 #: src/app/main/errors.cljs:828
msgid "errors.feature-mismatch" msgid "errors.feature-mismatch"
msgstr "" msgstr ""
"Parece que está abriendo un archivo que tiene la función '%s' habilitada, " "Parece que está abriendo un archivo que tiene la función '%s' habilitada, "
"pero la versión actual de penpot no la admite o la tiene deshabilitada." "pero la versión actual de penpot no la admite o la tiene deshabilitada."
#: src/app/main/errors.cljs:823, src/app/main/errors.cljs:837 #: src/app/main/errors.cljs:832, src/app/main/errors.cljs:846
msgid "errors.feature-not-supported" msgid "errors.feature-not-supported"
msgstr "Caracteristica no soportada: '%s'." msgstr "Caracteristica no soportada: '%s'."
@ -1618,7 +1618,7 @@ msgstr "Campo vacio"
msgid "errors.field-not-all-whitespace" msgid "errors.field-not-all-whitespace"
msgstr "Debe contener algún carácter diferente de espacio." msgstr "Debe contener algún carácter diferente de espacio."
#: src/app/main/errors.cljs:815 #: src/app/main/errors.cljs:824
msgid "errors.file-feature-mismatch" msgid "errors.file-feature-mismatch"
msgstr "" msgstr ""
"Parece que hay discordancia entre las features habilitadas y las features " "Parece que hay discordancia entre las features habilitadas y las features "
@ -1631,11 +1631,11 @@ msgstr ""
"El nombre de la familia tipográfica solo puede contener letras, números, " "El nombre de la familia tipográfica solo puede contener letras, números, "
"espacios, guiones, guiones bajos y puntos." "espacios, guiones, guiones bajos y puntos."
#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:158, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966 #: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:131, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966
msgid "errors.generic" msgid "errors.generic"
msgstr "Ha ocurrido algún error." msgstr "Ha ocurrido algún error."
#: src/app/main/errors.cljs:752 #: src/app/main/errors.cljs:761
msgid "errors.internal-worker-error" msgid "errors.internal-worker-error"
msgstr "Ha ocurrido un problema con el web worker." msgstr "Ha ocurrido un problema con el web worker."
@ -1687,7 +1687,7 @@ msgstr "Esta invitación puede haber sido cancelada o ha expirado."
msgid "errors.ldap-disabled" msgid "errors.ldap-disabled"
msgstr "La autheticacion via LDAP esta deshabilitada." msgstr "La autheticacion via LDAP esta deshabilitada."
#: src/app/main/errors.cljs:831, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117 #: src/app/main/errors.cljs:840, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117
msgid "errors.max-quota-reached" msgid "errors.max-quota-reached"
msgstr "Ha alcalzando el maximo de la quota '%s'. Contacte con soporte tecnico." msgstr "Ha alcalzando el maximo de la quota '%s'. Contacte con soporte tecnico."
@ -1717,7 +1717,7 @@ msgstr ""
"El perfil que esta invitando tiene los emails silenciados (por reportes de " "El perfil que esta invitando tiene los emails silenciados (por reportes de "
"spam o alto índice de rebote)." "spam o alto índice de rebote)."
#: src/app/main/errors.cljs:805 #: src/app/main/errors.cljs:814
msgid "errors.migration-in-progress" msgid "errors.migration-in-progress"
msgstr "Migración en proceso" msgstr "Migración en proceso"
@ -1765,6 +1765,12 @@ msgstr ""
"Tu perfil tiene los emails silenciados (por reportes de spam o alto índice " "Tu perfil tiene los emails silenciados (por reportes de spam o alto índice "
"de rebote)." "de rebote)."
#: src/app/main/errors.cljs:724
msgid "errors.profile-props-too-large"
msgstr ""
"La configuración de tu perfil es demasiado grande y no se puede guardar. "
"Elimina algunas extensiones e inténtalo de nuevo."
#: src/app/main/data/auth.cljs:340, src/app/main/ui/auth/register.cljs:96 #: src/app/main/data/auth.cljs:340, src/app/main/ui/auth/register.cljs:96
msgid "errors.registration-disabled" msgid "errors.registration-disabled"
msgstr "El registro está actualmente desactivado." msgstr "El registro está actualmente desactivado."
@ -1776,11 +1782,11 @@ msgstr ""
"informar del error y recarga para continuar desde los últimos cambios " "informar del error y recarga para continuar desde los últimos cambios "
"guardados." "guardados."
#: src/app/main/errors.cljs:764 #: src/app/main/errors.cljs:773
msgid "errors.svg-parser.invalid-svg" msgid "errors.svg-parser.invalid-svg"
msgstr "El SVG no es válido o está mal formado" msgstr "El SVG no es válido o está mal formado"
#: src/app/main/errors.cljs:810 #: src/app/main/errors.cljs:819
msgid "errors.team-feature-mismatch" msgid "errors.team-feature-mismatch"
msgstr "Detectada funcionalidad incompatible '%s'" msgstr "Detectada funcionalidad incompatible '%s'"
@ -1964,7 +1970,7 @@ msgstr "Ha ocurrido un error inesperado."
msgid "errors.unexpected-token" msgid "errors.unexpected-token"
msgstr "Token desconocido" msgstr "Token desconocido"
#: src/app/main/errors.cljs:723 #: src/app/main/errors.cljs:732
msgid "errors.version-already-locked" msgid "errors.version-already-locked"
msgstr "Esta versión ya está bloqueada" msgstr "Esta versión ya está bloqueada"
@ -1972,7 +1978,7 @@ msgstr "Esta versión ya está bloqueada"
msgid "errors.version-locked" msgid "errors.version-locked"
msgstr "Esta versión está bloqueada y otras personas no pueden eliminarla" msgstr "Esta versión está bloqueada y otras personas no pueden eliminarla"
#: src/app/main/errors.cljs:827 #: src/app/main/errors.cljs:836
msgid "errors.version-not-supported" msgid "errors.version-not-supported"
msgstr "El fichero tiene un número de versión incompatible" msgstr "El fichero tiene un número de versión incompatible"
@ -8169,16 +8175,16 @@ msgstr "Alinear nodos (%s)"
msgid "workspace.plugins.button-open" msgid "workspace.plugins.button-open"
msgstr "Abrir" msgstr "Abrir"
#: src/app/main/ui/workspace/plugins.cljs:215 #: src/app/main/ui/workspace/plugins.cljs:242
#, markdown #, markdown
msgid "workspace.plugins.discover" msgid "workspace.plugins.discover"
msgstr "Descubre [más extensiones](%s)" msgstr "Descubre [más extensiones](%s)"
#: src/app/main/ui/workspace/plugins.cljs:222 #: src/app/main/ui/workspace/plugins.cljs:249
msgid "workspace.plugins.empty-plugins" msgid "workspace.plugins.empty-plugins"
msgstr "No se encuentran extensiones" msgstr "No se encuentran extensiones"
#: src/app/main/ui/workspace/plugins.cljs:209 #: src/app/main/ui/workspace/plugins.cljs:236
msgid "workspace.plugins.error.manifest" msgid "workspace.plugins.error.manifest"
msgstr "El manifiesto de la expansión es incorrecto." msgstr "El manifiesto de la expansión es incorrecto."
@ -8190,15 +8196,15 @@ msgstr "Debes ser un editor para usar este plugin"
msgid "workspace.plugins.error.unreachable" msgid "workspace.plugins.error.unreachable"
msgstr "No se ha podido acceder a la URL de la extensión." msgstr "No se ha podido acceder a la URL de la extensión."
#: src/app/main/ui/workspace/plugins.cljs:205 #: src/app/main/ui/workspace/plugins.cljs:232
msgid "workspace.plugins.error.url" msgid "workspace.plugins.error.url"
msgstr "La extensión no existe o la url no es correcta." msgstr "La extensión no existe o la url no es correcta."
#: src/app/main/ui/workspace/plugins.cljs:201 #: src/app/main/ui/workspace/plugins.cljs:228
msgid "workspace.plugins.install" msgid "workspace.plugins.install"
msgstr "Instalar" msgstr "Instalar"
#: src/app/main/ui/workspace/plugins.cljs:231 #: src/app/main/ui/workspace/plugins.cljs:258
msgid "workspace.plugins.installed-plugins" msgid "workspace.plugins.installed-plugins"
msgstr "Extensiones instaladas" msgstr "Extensiones instaladas"
@ -8210,49 +8216,49 @@ msgstr "Gestor de extensiones"
msgid "workspace.plugins.menu.title" msgid "workspace.plugins.menu.title"
msgstr "Extensiones" msgstr "Extensiones"
#: src/app/main/ui/workspace/plugins.cljs:424 #: src/app/main/ui/workspace/plugins.cljs:452
msgid "workspace.plugins.permissions-update.title" msgid "workspace.plugins.permissions-update.title"
msgstr "EXTENSIÓN ACTUALIZADA" msgstr "EXTENSIÓN ACTUALIZADA"
#: src/app/main/ui/workspace/plugins.cljs:428 #: src/app/main/ui/workspace/plugins.cljs:456
msgid "workspace.plugins.permissions-update.warning" msgid "workspace.plugins.permissions-update.warning"
msgstr "" msgstr ""
"La extensión ha cambiado desde la última vez que la abriste. Ahora quiere " "La extensión ha cambiado desde la última vez que la abriste. Ahora quiere "
"acceder a:" "acceder a:"
#: src/app/main/ui/workspace/plugins.cljs:297 #: src/app/main/ui/workspace/plugins.cljs:325
msgid "workspace.plugins.permissions.allow-download" msgid "workspace.plugins.permissions.allow-download"
msgstr "Comenzar descargas de ficheros." msgstr "Comenzar descargas de ficheros."
#: src/app/main/ui/workspace/plugins.cljs:304 #: src/app/main/ui/workspace/plugins.cljs:332
msgid "workspace.plugins.permissions.allow-localstorage" msgid "workspace.plugins.permissions.allow-localstorage"
msgstr "Guardar datos en el navegador." msgstr "Guardar datos en el navegador."
#: src/app/main/ui/workspace/plugins.cljs:317 #: src/app/main/ui/workspace/plugins.cljs:345
msgid "workspace.plugins.permissions.clipboard-read" msgid "workspace.plugins.permissions.clipboard-read"
msgstr "Leer el contenido de tu portapapeles." msgstr "Leer el contenido de tu portapapeles."
#: src/app/main/ui/workspace/plugins.cljs:311 #: src/app/main/ui/workspace/plugins.cljs:339
msgid "workspace.plugins.permissions.clipboard-write" msgid "workspace.plugins.permissions.clipboard-write"
msgstr "Leer y escribir en tu portapapeles." msgstr "Leer y escribir en tu portapapeles."
#: src/app/main/ui/workspace/plugins.cljs:290 #: src/app/main/ui/workspace/plugins.cljs:318
msgid "workspace.plugins.permissions.comment-read" msgid "workspace.plugins.permissions.comment-read"
msgstr "Leer tus comentarios y respuestas." msgstr "Leer tus comentarios y respuestas."
#: src/app/main/ui/workspace/plugins.cljs:284 #: src/app/main/ui/workspace/plugins.cljs:312
msgid "workspace.plugins.permissions.comment-write" msgid "workspace.plugins.permissions.comment-write"
msgstr "Leer y modificar tus comentarios y responder en tu nombre." msgstr "Leer y modificar tus comentarios y responder en tu nombre."
#: src/app/main/ui/workspace/plugins.cljs:257 #: src/app/main/ui/workspace/plugins.cljs:285
msgid "workspace.plugins.permissions.content-read" msgid "workspace.plugins.permissions.content-read"
msgstr "Leer el contenido de sus archivos." msgstr "Leer el contenido de sus archivos."
#: src/app/main/ui/workspace/plugins.cljs:251 #: src/app/main/ui/workspace/plugins.cljs:279
msgid "workspace.plugins.permissions.content-write" msgid "workspace.plugins.permissions.content-write"
msgstr "Leer y modificar el contenido de sus archivos." msgstr "Leer y modificar el contenido de sus archivos."
#: src/app/main/ui/workspace/plugins.cljs:366 #: src/app/main/ui/workspace/plugins.cljs:394
msgid "workspace.plugins.permissions.disclaimer" msgid "workspace.plugins.permissions.disclaimer"
msgstr "" msgstr ""
"Ten en cuenta que las extensiones están desarrolladas por terceros, " "Ten en cuenta que las extensiones están desarrolladas por terceros, "
@ -8260,54 +8266,64 @@ msgstr ""
"seguridad es importante para nosotros. Si tienes cualquier duda, contacta " "seguridad es importante para nosotros. Si tienes cualquier duda, contacta "
"con soporte." "con soporte."
#: src/app/main/ui/workspace/plugins.cljs:277 #: src/app/main/ui/workspace/plugins.cljs:305
msgid "workspace.plugins.permissions.library-read" msgid "workspace.plugins.permissions.library-read"
msgstr "Leer la información de sus bibliotecas y recursos." msgstr "Leer la información de sus bibliotecas y recursos."
#: src/app/main/ui/workspace/plugins.cljs:271 #: src/app/main/ui/workspace/plugins.cljs:299
msgid "workspace.plugins.permissions.library-write" msgid "workspace.plugins.permissions.library-write"
msgstr "Leer y modificar la información de sus bibliotecas y recursos." msgstr "Leer y modificar la información de sus bibliotecas y recursos."
#: src/app/main/ui/workspace/plugins.cljs:359 #: src/app/main/ui/workspace/plugins.cljs:387
msgid "workspace.plugins.permissions.title" msgid "workspace.plugins.permissions.title"
msgstr "LA EXTENSIÓN '%s' SOLICITA PERMISO PARA ACCEDER:" msgstr "LA EXTENSIÓN '%s' SOLICITA PERMISO PARA ACCEDER:"
#: src/app/main/ui/workspace/plugins.cljs:264 #: src/app/main/ui/workspace/plugins.cljs:292
msgid "workspace.plugins.permissions.user-read" msgid "workspace.plugins.permissions.user-read"
msgstr "Leer la información del usuario actual." msgstr "Leer la información del usuario actual."
#: src/app/main/ui/workspace/plugins.cljs:227 #: src/app/main/ui/workspace/plugins.cljs:254
msgid "workspace.plugins.plugin-list-link" msgid "workspace.plugins.plugin-list-link"
msgstr "Lista de extensiones" msgstr "Lista de extensiones"
#: src/app/main/ui/workspace/plugins.cljs:101 #: src/app/main/ui/workspace/plugins.cljs:191
msgid "workspace.plugins.remove-confirmation.message"
msgstr ""
"¿Seguro que quieres eliminar la extensión %s? Puedes instalarla de nuevo en "
"cualquier momento."
#: src/app/main/ui/workspace/plugins.cljs:190
msgid "workspace.plugins.remove-confirmation.title"
msgstr "Eliminar extensión"
#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:192
msgid "workspace.plugins.remove-plugin" msgid "workspace.plugins.remove-plugin"
msgstr "Eliminar extensión" msgstr "Eliminar extensión"
#: src/app/main/ui/workspace/plugins.cljs:195 #: src/app/main/ui/workspace/plugins.cljs:222
msgid "workspace.plugins.search-placeholder" msgid "workspace.plugins.search-placeholder"
msgstr "Intruduzca URL de la extensión" msgstr "Intruduzca URL de la extensión"
#: src/app/main/ui/workspace/plugins.cljs:188 #: src/app/main/ui/workspace/plugins.cljs:215
msgid "workspace.plugins.title" msgid "workspace.plugins.title"
msgstr "Extensiones" msgstr "Extensiones"
#: src/app/main/ui/workspace/plugins.cljs:494 #: src/app/main/ui/workspace/plugins.cljs:522
msgid "workspace.plugins.try-out.cancel" msgid "workspace.plugins.try-out.cancel"
msgstr "AHORA NO" msgstr "AHORA NO"
#: src/app/main/ui/workspace/plugins.cljs:487 #: src/app/main/ui/workspace/plugins.cljs:515
msgid "workspace.plugins.try-out.message" msgid "workspace.plugins.try-out.message"
msgstr "" msgstr ""
"¿Quieres echar un vistazo?. Crearemos un nuevo borrador en tu equipo " "¿Quieres echar un vistazo?. Crearemos un nuevo borrador en tu equipo "
"actual. (Si no, puedes encontrar los plugins instalados en cualquier " "actual. (Si no, puedes encontrar los plugins instalados en cualquier "
"fichero.)" "fichero.)"
#: src/app/main/ui/workspace/plugins.cljs:483 #: src/app/main/ui/workspace/plugins.cljs:511
msgid "workspace.plugins.try-out.title" msgid "workspace.plugins.try-out.title"
msgstr "¡LA EXTENSIÓN '%s' HA SIDO INSTALADA PARA TU USUARIO!" msgstr "¡LA EXTENSIÓN '%s' HA SIDO INSTALADA PARA TU USUARIO!"
#: src/app/main/ui/workspace/plugins.cljs:500 #: src/app/main/ui/workspace/plugins.cljs:528
msgid "workspace.plugins.try-out.try" msgid "workspace.plugins.try-out.try"
msgstr "PROBAR PLUGIN" msgstr "PROBAR PLUGIN"

View File

@ -94,6 +94,9 @@ set -e
# instance-compose wrap 'docker compose' for one instance's main # instance-compose wrap 'docker compose' for one instance's main
# project, injecting that instance's overrides # project, injecting that instance's overrides
# instance-env-overrides the per-instance KEY=VALUE overrides # instance-env-overrides the per-instance KEY=VALUE overrides
# instance-test-db-name test database name for one instance
# instance-test-redis-db Valkey DB number for one instance's tests
# ensure-instance-test-database create one instance's test database
# devenv-main-container resolve the 'main' container id via compose ps # devenv-main-container resolve the 'main' container id via compose ps
# devenv-main-running true if 'main' is up # devenv-main-running true if 'main' is up
# #
@ -301,6 +304,43 @@ function ensure-infra-up {
infra-compose up -d --wait --wait-timeout 60 --remove-orphans infra-compose up -d --wait --wait-timeout 60 --remove-orphans
} }
# Create the backend-test database for <instance> on the shared Postgres
# if it does not exist yet. Idempotent: safe to run on every bring-up.
# Needed because postgresql_init.sql only runs when the Postgres volume is
# created for the first time, so per-instance test databases (which did not
# exist back then) would otherwise never be created on existing volumes.
# The backend applies migrations itself on first use, so an empty database
# is enough here. (Two plain SQL round-trips on purpose: psql -c does not
# reliably mix SQL with psql-only commands like \gexec.)
function ensure-instance-test-database {
local instance="$1"
local dbname
dbname=$(instance-test-db-name "$instance")
local pg_container
pg_container=$(infra-compose ps -q postgres 2>/dev/null)
if [[ -z "$pg_container" ]]; then
echo "[${instance}] postgres container not found; is shared infra up?" >&2
return 1
fi
local -a psql_base=(docker exec
-e "PGPASSWORD=${PENPOT_DATABASE_PASSWORD:-penpot}"
"$pg_container"
psql -h 127.0.0.1 -U "${PENPOT_DATABASE_USERNAME:-penpot}"
-d postgres -v ON_ERROR_STOP=1 -tA)
local exists
exists=$("${psql_base[@]}" -c "SELECT 1 FROM pg_database WHERE datname = '${dbname}'")
if [[ "$exists" == "1" ]]; then
echo "[${instance}] test database ${dbname} already exists."
return 0
fi
echo "[${instance}] creating test database ${dbname} ..."
"${psql_base[@]}" -c "CREATE DATABASE \"${dbname}\""
}
# Refuse to sync workspaces if the live repo is in a fragile Git state. # Refuse to sync workspaces if the live repo is in a fragile Git state.
# Copying a partial rebase/merge/cherry-pick into all workspaces would leave # Copying a partial rebase/merge/cherry-pick into all workspaces would leave
# every instance in the same broken state. # every instance in the same broken state.
@ -333,6 +373,33 @@ function instance-port {
echo $(( base + n * PENPOT_INSTANCE_PORT_STRIDE )) echo $(( base + n * PENPOT_INSTANCE_PORT_STRIDE ))
} }
# Echo the backend-test database name for <instance>. ws0 keeps the
# historical name so existing flows stay untouched; ws1+ get an isolated
# database each on the shared Postgres server. The main database stays
# shared by all instances on purpose.
function instance-test-db-name {
local instance="$1"
local n=0
[[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
if (( n == 0 )); then
echo "penpot_test"
else
echo "penpot_test_ws${n}"
fi
}
# Echo the Valkey DB number reserved for the backend tests of <instance>.
# Runtime backends already occupy 0..PENPOT_MAX_WS_INDEX on the shared
# Valkey, so test DBs start right after to never share a DB with a
# running backend. (Resolved at call time; PENPOT_MAX_WS_INDEX is defined
# further below but always set before any call.)
function instance-test-redis-db {
local instance="$1"
local n=0
[[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
echo $(( PENPOT_MAX_WS_INDEX + 1 + n ))
}
# Echo the per-instance Compose variable overrides for a workspace, one # Echo the per-instance Compose variable overrides for a workspace, one
# KEY=VALUE per line, for instance-compose to inject into its `env -i` line. # KEY=VALUE per line, for instance-compose to inject into its `env -i` line.
# Compose gives shell-env precedence over --env-file, so these override the # Compose gives shell-env precedence over --env-file, so these override the
@ -358,11 +425,15 @@ function instance-env-overrides {
opencode=$(instance-port "$instance" "$PENPOT_PORT_BASE_OPENCODE") opencode=$(instance-port "$instance" "$PENPOT_PORT_BASE_OPENCODE")
mdts=$(instance-port "$instance" "$PENPOT_PORT_BASE_MDTS") mdts=$(instance-port "$instance" "$PENPOT_PORT_BASE_MDTS")
storybook=$(instance-port "$instance" "$PENPOT_PORT_BASE_STORYBOOK") storybook=$(instance-port "$instance" "$PENPOT_PORT_BASE_STORYBOOK")
test_db=$(instance-test-db-name "$instance")
test_redis_db=$(instance-test-redis-db "$instance")
printf '%s\n' \ printf '%s\n' \
"PENPOT_MAIN_CONTAINER_NAME=penpot-devenv-${instance}-main" \ "PENPOT_MAIN_CONTAINER_NAME=penpot-devenv-${instance}-main" \
"PENPOT_USER_DATA_VOLUME=penpotdev_${instance}_user_data" \ "PENPOT_USER_DATA_VOLUME=penpotdev_${instance}_user_data" \
"PENPOT_PUBLIC_URI=https://localhost:${public_https}" \ "PENPOT_PUBLIC_URI=https://localhost:${public_https}" \
"PENPOT_REDIS_URI=redis://valkey/${n}" \ "PENPOT_REDIS_URI=redis://valkey/${n}" \
"PENPOT_TEST_DATABASE_URI=postgresql://postgres/${test_db}" \
"PENPOT_TEST_REDIS_URI=redis://valkey/${test_redis_db}" \
"PENPOT_PUBLIC_HTTPS_PORT=${public_https}" \ "PENPOT_PUBLIC_HTTPS_PORT=${public_https}" \
"PENPOT_PUBLIC_HTTP_PORT=${public}" \ "PENPOT_PUBLIC_HTTP_PORT=${public}" \
"PENPOT_MCP_SERVER_PORT=${mcp}" \ "PENPOT_MCP_SERVER_PORT=${mcp}" \
@ -709,6 +780,8 @@ function start-instance {
local git_user_email="${4:-}" local git_user_email="${4:-}"
local agentic="${5:-true}" local agentic="${5:-true}"
ensure-instance-test-database "$instance"
instance-compose "$instance" up -d main instance-compose "$instance" up -d main
# Wait briefly for main to be reachable; the tmux session lives inside. # Wait briefly for main to be reachable; the tmux session lives inside.

View File

@ -434,26 +434,6 @@ pub fn resize_matrix(
child_bounds: &Bounds, child_bounds: &Bounds,
new_width: f32, new_width: f32,
new_height: f32, new_height: f32,
) -> Matrix {
resize_matrix_from(
parent_bounds,
child_bounds,
new_width,
new_height,
child_bounds.nw,
)
}
/*
* Like `resize_matrix`, but scaling about an explicit corner instead of `nw`.
* Rtl auto-width text anchors on `ne` so it extends leftward as it grows.
*/
pub fn resize_matrix_from(
parent_bounds: &Bounds,
child_bounds: &Bounds,
new_width: f32,
new_height: f32,
anchor: Point,
) -> Matrix { ) -> Matrix {
let mut result = Matrix::default(); let mut result = Matrix::default();
@ -475,7 +455,7 @@ pub fn resize_matrix_from(
parent_transform.pre_translate(-center); parent_transform.pre_translate(-center);
let parent_transform_inv = &parent_transform.invert().unwrap_or_default(); let parent_transform_inv = &parent_transform.invert().unwrap_or_default();
let origin = parent_transform_inv.map_point(anchor); let origin = parent_transform_inv.map_point(child_bounds.nw);
let mut scale = Matrix::scale((scale_width, scale_height)); let mut scale = Matrix::scale((scale_width, scale_height));
scale.post_translate(origin); scale.post_translate(origin);
@ -617,40 +597,4 @@ mod tests {
assert!((m.translate_x() - 0.0).abs() <= 0.1); assert!((m.translate_x() - 0.0).abs() <= 0.1);
assert!((m.translate_y() - 0.0).abs() <= 0.1); assert!((m.translate_y() - 0.0).abs() <= 0.1);
} }
fn axis_aligned_bounds(x: f32, y: f32, w: f32, h: f32) -> Bounds {
Bounds::new(
Point::new(x, y),
Point::new(x + w, y),
Point::new(x + w, y + h),
Point::new(x, y + h),
)
}
#[test]
fn resize_matrix_keeps_the_north_west_corner() {
let bounds = axis_aligned_bounds(100.0, 50.0, 60.0, 20.0);
let matrix = resize_matrix(&bounds, &bounds, 120.0, 20.0);
let resized = bounds.transform(&matrix);
assert!(is_close_to(resized.nw.x, 100.0));
assert!(is_close_to(resized.ne.x, 220.0));
}
#[test]
fn resize_matrix_from_north_east_keeps_the_right_edge() {
let bounds = axis_aligned_bounds(100.0, 50.0, 60.0, 20.0);
let matrix = resize_matrix_from(&bounds, &bounds, 120.0, 20.0, bounds.ne);
let resized = bounds.transform(&matrix);
assert!(is_close_to(resized.ne.x, 160.0), "right edge preserved");
assert!(is_close_to(resized.nw.x, 40.0), "grew leftward");
assert!(is_close_to(resized.nw.y, 50.0), "top edge preserved");
}
#[test]
fn resize_matrix_from_north_west_matches_the_default() {
let bounds = axis_aligned_bounds(100.0, 50.0, 60.0, 20.0);
let default = resize_matrix(&bounds, &bounds, 120.0, 40.0);
let explicit = resize_matrix_from(&bounds, &bounds, 120.0, 40.0, bounds.nw);
assert_eq!(default, explicit);
}
} }

View File

@ -2,25 +2,9 @@ use crate::render::options::RenderOptions;
use crate::shapes::{vertical_align_offset, Shape, TextContent, Type}; use crate::shapes::{vertical_align_offset, Shape, TextContent, Type};
use crate::state::{TextEditorState, TextSelection}; use crate::state::{TextEditorState, TextSelection};
use crate::view::Viewbox; use crate::view::Viewbox;
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle, TextBox, TextDirection}; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{BlendMode, Canvas, Color, Paint, Rect}; use skia_safe::{BlendMode, Canvas, Color, Paint, Rect};
/// Caret x where a character typed *before* this glyph would land.
fn leading_edge(text_box: &TextBox) -> f32 {
match text_box.direct {
TextDirection::RTL => text_box.rect.right(),
_ => text_box.rect.left(),
}
}
/// Caret x where a character typed *after* this glyph would land.
fn trailing_edge(text_box: &TextBox) -> f32 {
match text_box.direct {
TextDirection::RTL => text_box.rect.left(),
_ => text_box.rect.right(),
}
}
pub fn render_overlay( pub fn render_overlay(
canvas: &Canvas, canvas: &Canvas,
viewbox: &Viewbox, viewbox: &Viewbox,
@ -128,14 +112,6 @@ fn render_selection(
canvas.restore(); canvas.restore();
} }
/// Caret and selection rects are drawn through `shape.get_matrix()`, which
/// translates by the *selrect's* `left_top()`; this shifts them onto the text.
fn paragraphs_horizontal_offset(shape: &Shape, text_content: &TextContent) -> f32 {
let selrect = shape.selrect();
let width = text_content.get_width(selrect.width());
text_content.layout_origin_x(&selrect, width) - selrect.x()
}
fn paragraphs_vertical_offset( fn paragraphs_vertical_offset(
shape: &Shape, shape: &Shape,
layout_paragraphs: &[&skia_safe::textlayout::Paragraph], layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
@ -165,7 +141,6 @@ fn calculate_cursor_rect(
return None; return None;
} }
let x_offset = paragraphs_horizontal_offset(shape, text_content);
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs); let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph { if idx == cursor.paragraph {
@ -183,13 +158,8 @@ fn calculate_cursor_rect(
// Skia ranges are UTF-16 code units, not characters. // Skia ranges are UTF-16 code units, not characters.
let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 { let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 {
// No glyph to anchor to: sit where the first character will appear. // Empty paragraph - use default height
let empty_x = if para.text_direction() == TextDirection::RTL { (0.0, 0.0, 1.0, laid_out_para.height())
laid_out_para.max_width()
} else {
0.0
};
(empty_x, 0.0, 1.0, laid_out_para.height())
} else if char_pos == 0 { } else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range( let rects = laid_out_para.get_rects_for_range(
0..para.char_utf16_len_at(0), 0..para.char_utf16_len_at(0),
@ -198,7 +168,7 @@ fn calculate_cursor_rect(
); );
if !rects.is_empty() { if !rects.is_empty() {
let r = &rects[0].rect; let r = &rects[0].rect;
(leading_edge(&rects[0]), r.top(), r.width(), r.height()) (r.left(), r.top(), r.width(), r.height())
} else { } else {
(0.0, 0.0, 1.0, laid_out_para.height()) (0.0, 0.0, 1.0, laid_out_para.height())
} }
@ -212,15 +182,14 @@ fn calculate_cursor_rect(
); );
if !rects.is_empty() { if !rects.is_empty() {
let r = &rects[0].rect; let r = &rects[0].rect;
(trailing_edge(&rects[0]), r.top(), r.width(), r.height()) (r.right(), r.top(), r.width(), r.height())
} else if let Some(line) = laid_out_para.get_line_metrics().last() { } else if let Some(line) = laid_out_para.get_line_metrics().last() {
// No glyph box to measure: use the end of the line. (
let line_end = if para.text_direction() == TextDirection::RTL { line.left as f32 + line.width as f32,
line.left as f32 0.0,
} else { 1.0,
line.left as f32 + line.width as f32 laid_out_para.height(),
}; )
(line_end, 0.0, 1.0, laid_out_para.height())
} else { } else {
(0.0, 0.0, 1.0, laid_out_para.height()) (0.0, 0.0, 1.0, laid_out_para.height())
} }
@ -233,7 +202,7 @@ fn calculate_cursor_rect(
); );
if !rects.is_empty() { if !rects.is_empty() {
let r = &rects[0].rect; let r = &rects[0].rect;
(leading_edge(&rects[0]), r.top(), r.width(), r.height()) (r.left(), r.top(), r.width(), r.height())
} else { } else {
// Fallback: use glyph position // Fallback: use glyph position
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
@ -242,7 +211,7 @@ fn calculate_cursor_rect(
}; };
return Some(Rect::from_xywh( return Some(Rect::from_xywh(
x_offset + cursor_x, cursor_x,
y_offset + cursor_y, y_offset + cursor_y,
cursor_width, // cursor_width cursor_width, // cursor_width
cursor_height, cursor_height,
@ -267,7 +236,6 @@ fn calculate_selection_rects(
let paragraphs = text_content.paragraphs(); let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let x_offset = paragraphs_horizontal_offset(shape, text_content);
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs); let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() { for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
@ -310,7 +278,7 @@ fn calculate_selection_rects(
for text_box in text_boxes { for text_box in text_boxes {
let r = text_box.rect; let r = text_box.rect;
rects.push(Rect::from_xywh( rects.push(Rect::from_xywh(
x_offset + r.left(), r.left(),
y_offset + r.top(), y_offset + r.top(),
r.width(), r.width(),
r.height(), r.height(),
@ -323,92 +291,3 @@ fn calculate_selection_rects(
rects rects
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{FontFamily, FontStyle, GrowType, Paragraph, TextAlign, TextSpan};
use crate::uuid::Uuid;
fn rtl_span() -> TextSpan {
TextSpan::new(
"نام".to_string(),
FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
14.0,
1.2,
0.0,
None,
None,
TextDirection::RTL,
400,
Uuid::nil(),
vec![],
)
}
/// Auto-width rtl content measured wider than its stale selrect: mid-edit,
/// where the overlay origin and the selrect diverge.
fn grown_rtl_shape(measured_width: f32) -> Shape {
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let mut content = TextContent::new(selrect, GrowType::AutoWidth);
content.add_paragraph(Paragraph::new(
TextAlign::Right,
TextDirection::RTL,
None,
None,
1.2,
0.0,
vec![rtl_span()],
));
content.size.width = measured_width;
content.size.height = 20.0;
let mut shape = Shape::new(Uuid::nil());
shape.set_selrect(selrect.left, selrect.top, selrect.right, selrect.bottom);
shape.set_shape_type(Type::Text(content));
shape
}
fn text_content_of(shape: &Shape) -> &TextContent {
match &shape.shape_type {
Type::Text(content) => content,
_ => unreachable!(),
}
}
#[test]
fn horizontal_offset_shifts_the_overlay_onto_grown_rtl_text() {
let shape = grown_rtl_shape(120.0);
// right edge 160 - measured 120 = 40, i.e. 60 left of selrect.x
assert_eq!(
paragraphs_horizontal_offset(&shape, text_content_of(&shape)),
-60.0
);
}
#[test]
fn horizontal_offset_is_zero_once_the_selrect_is_committed() {
let shape = grown_rtl_shape(60.0);
assert_eq!(
paragraphs_horizontal_offset(&shape, text_content_of(&shape)),
0.0
);
}
#[test]
fn leading_and_trailing_edges_follow_the_run_direction() {
let rect = Rect::from_ltrb(10.0, 0.0, 30.0, 12.0);
let ltr = TextBox {
rect,
direct: TextDirection::LTR,
};
let rtl = TextBox {
rect,
direct: TextDirection::RTL,
};
assert_eq!(leading_edge(&ltr), 10.0);
assert_eq!(trailing_edge(&ltr), 30.0);
assert_eq!(leading_edge(&rtl), 30.0);
assert_eq!(trailing_edge(&rtl), 10.0);
}
}

View File

@ -320,18 +320,11 @@ fn propagate_transform(
} }
} }
} }
// Rtl text keeps its right edge, so scale about the ne corner. let resize_transform = math::resize_matrix(
let anchor = if text_content.is_rtl() {
shape_bounds_after.ne
} else {
shape_bounds_after.nw
};
let resize_transform = math::resize_matrix_from(
&shape_bounds_after, &shape_bounds_after,
&shape_bounds_after, &shape_bounds_after,
new_width, new_width,
new_height, new_height,
anchor,
); );
shape_bounds_after = shape_bounds_after.transform(&resize_transform); shape_bounds_after = shape_bounds_after.transform(&resize_transform);
transform.post_concat(&resize_transform); transform.post_concat(&resize_transform);

View File

@ -476,26 +476,18 @@ impl TextContent {
/// [`cached_layout_paint_offset`] on the canvas so both move together. /// [`cached_layout_paint_offset`] on the canvas so both move together.
pub fn cached_layout_paint_anchor(&self, selrect: &Rect) -> Point { pub fn cached_layout_paint_anchor(&self, selrect: &Rect) -> Point {
self.layout_paint_origin self.layout_paint_origin
.unwrap_or_else(|| Point::new(self.selrect_origin_x(selrect), selrect.y())) .unwrap_or_else(|| Point::new(selrect.x(), selrect.y()))
} }
/// Canvas translation from the baked paint origin to the current selrect. /// Canvas translation from the baked paint origin to the current selrect.
/// Zero when there is no recorded origin (fall back to painting at selrect). /// Zero when there is no recorded origin (fall back to painting at selrect).
pub fn cached_layout_paint_offset(&self, selrect: &Rect) -> Point { pub fn cached_layout_paint_offset(&self, selrect: &Rect) -> Point {
match self.layout_paint_origin { match self.layout_paint_origin {
Some(origin) => Point::new( Some(origin) => Point::new(selrect.x() - origin.x, selrect.y() - origin.y),
self.selrect_origin_x(selrect) - origin.x,
selrect.y() - origin.y,
),
None => Point::new(0.0, 0.0), None => Point::new(0.0, 0.0),
} }
} }
/// [`layout_origin_x`] for this content's own measured width.
fn selrect_origin_x(&self, selrect: &Rect) -> f32 {
self.layout_origin_x(selrect, self.size.width)
}
/// Text content for paint when [`Rect`] size may differ from stored bounds /// Text content for paint when [`Rect`] size may differ from stored bounds
/// (e.g. modifier transform). Reuses `self` when width/height match; otherwise /// (e.g. modifier transform). Reuses `self` when width/height match; otherwise
/// clones paragraphs into a rebound copy with an empty layout cache. /// clones paragraphs into a rebound copy with an empty layout cache.
@ -576,26 +568,6 @@ impl TextContent {
} }
} }
/// Non-empty and every paragraph RTL. Mixed content counts as LTR, mirroring
/// `app.common.types.text/rtl-content?` on the ClojureScript side.
pub fn is_rtl(&self) -> bool {
!self.paragraphs.is_empty()
&& self
.paragraphs
.iter()
.all(|paragraph| paragraph.text_direction() == TextDirection::RTL)
}
/// Left edge of the laid-out text of `width` inside `selrect`. RTL auto-width
/// text anchors right so the box extends leftward as it grows.
pub fn layout_origin_x(&self, selrect: &Rect, width: f32) -> f32 {
if self.grow_type() == GrowType::AutoWidth && self.is_rtl() {
selrect.right() - width
} else {
selrect.x()
}
}
/// Compute a tight text rect from laid-out Skia paragraphs using glyph /// Compute a tight text rect from laid-out Skia paragraphs using glyph
/// metrics (fm.top for overshoot, line descent for bottom, line left/width /// metrics (fm.top for overshoot, line descent for bottom, line left/width
/// for horizontal extent). /// for horizontal extent).
@ -748,6 +720,7 @@ impl TextContent {
} }
pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect {
let x = selrect.x();
let mut y = selrect.y(); let mut y = selrect.y();
let width = if self.grow_type() == GrowType::AutoWidth { let width = if self.grow_type() == GrowType::AutoWidth {
@ -756,8 +729,6 @@ impl TextContent {
selrect.width() selrect.width()
}; };
let x = self.layout_origin_x(selrect, width);
let height = if self.size.width.round() != width.round() { let height = if self.size.width.round() != width.round() {
self.get_height(width) self.get_height(width)
} else { } else {
@ -1182,8 +1153,8 @@ impl TextContent {
self.layout.set(result.0, result.1); self.layout.set(result.0, result.1);
self.size self.size
.copy_finite_size(result.2, default_width, default_height); .copy_finite_size(result.2, default_width, default_height);
// Absolute image/gradient shaders are built against `self.bounds()`, so the // Paragraph paints (incl. absolute image/gradient shaders) were built
// origin matches them and `cached_layout_paint_offset` carries any rtl shift. // against `self.bounds()` in `paragraph_builder_group_from_text`.
self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y())); self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y()));
} }
@ -1853,7 +1824,7 @@ pub fn calculate_text_layout_data(
let selrect_width = shape.selrect().width(); let selrect_width = shape.selrect().width();
let text_width = text_content.get_width(selrect_width); let text_width = text_content.get_width(selrect_width);
let selrect_height = shape.selrect().height(); let selrect_height = shape.selrect().height();
let x = text_content.layout_origin_x(&shape.selrect, text_width); let x = shape.selrect.x();
let base_y = shape.selrect.y(); let base_y = shape.selrect.y();
let mut position_data: Vec<PositionData> = Vec::new(); let mut position_data: Vec<PositionData> = Vec::new();
let mut previous_line_height = text_content.normalized_line_height(); let mut previous_line_height = text_content.normalized_line_height();
@ -2412,170 +2383,4 @@ mod tests {
layout.clear(); layout.clear();
assert!(layout.needs_update()); assert!(layout.needs_update());
} }
// -----------------------------------------------------------------------
// RTL auto-width growth anchor
// -----------------------------------------------------------------------
fn directed_paragraph(direction: TextDirection) -> Paragraph {
let span = TextSpan::new(
"hello".to_string(),
FontFamily::new(Uuid::nil(), 400, crate::shapes::FontStyle::Normal),
14.0,
1.2,
0.0,
None,
None,
direction,
400,
Uuid::nil(),
vec![],
);
Paragraph::new(TextAlign::Left, direction, None, None, 1.2, 0.0, vec![span])
}
/// Auto-width content measured wider than its stale selrect: mid-edit, where
/// the anchor is observable.
fn grown_content(directions: &[TextDirection], measured_width: f32) -> TextContent {
let bounds = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let mut content = TextContent::new(bounds, GrowType::AutoWidth);
for direction in directions {
content.add_paragraph(directed_paragraph(*direction));
}
content.size.width = measured_width;
content.size.height = 20.0;
content
}
#[test]
fn is_rtl_false_when_no_paragraphs() {
let content = TextContent::new(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), GrowType::AutoWidth);
assert!(!content.is_rtl());
}
#[test]
fn is_rtl_true_when_every_paragraph_is_rtl() {
let content = grown_content(&[TextDirection::RTL, TextDirection::RTL], 120.0);
assert!(content.is_rtl());
}
#[test]
fn is_rtl_false_when_directions_are_mixed() {
let content = grown_content(&[TextDirection::RTL, TextDirection::LTR], 120.0);
assert!(!content.is_rtl());
}
#[test]
fn is_rtl_false_when_every_paragraph_is_ltr() {
let content = grown_content(&[TextDirection::LTR], 120.0);
assert!(!content.is_rtl());
}
#[test]
fn layout_origin_x_right_anchors_rtl_auto_width() {
let content = grown_content(&[TextDirection::RTL], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
// right edge (160) minus the measured width (120)
assert_eq!(content.layout_origin_x(&selrect, 120.0), 40.0);
}
#[test]
fn layout_origin_x_left_anchors_ltr_auto_width() {
let content = grown_content(&[TextDirection::LTR], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0);
}
#[test]
fn layout_origin_x_left_anchors_mixed_direction_auto_width() {
let content = grown_content(&[TextDirection::RTL, TextDirection::LTR], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0);
}
#[test]
fn layout_origin_x_ignores_direction_for_fixed_and_auto_height() {
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
for grow_type in [GrowType::Fixed, GrowType::AutoHeight] {
let mut content = grown_content(&[TextDirection::RTL], 120.0);
content.set_grow_type(grow_type);
assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0);
}
}
#[test]
fn layout_origin_x_is_selrect_x_once_the_selrect_is_committed() {
let content = grown_content(&[TextDirection::RTL], 60.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
assert_eq!(content.layout_origin_x(&selrect, 60.0), selrect.x());
}
#[test]
fn content_rect_right_anchors_grown_rtl_auto_width() {
let content = grown_content(&[TextDirection::RTL], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let rect = content.content_rect(&selrect, VerticalAlign::Top);
assert_eq!(rect.x(), 40.0);
assert_eq!(rect.right(), selrect.right());
assert_eq!(rect.width(), 120.0);
assert_eq!(rect.y(), selrect.y());
}
#[test]
fn content_rect_left_anchors_grown_ltr_auto_width() {
let content = grown_content(&[TextDirection::LTR], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let rect = content.content_rect(&selrect, VerticalAlign::Top);
assert_eq!(rect.x(), selrect.x());
assert_eq!(rect.width(), 120.0);
}
#[test]
fn content_rect_unchanged_for_fixed_rtl_content() {
// A mismatch sends `content_rect` into `get_height`, which needs global
// font state tests do not have.
let mut content = grown_content(&[TextDirection::RTL], 60.0);
content.set_grow_type(GrowType::Fixed);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let rect = content.content_rect(&selrect, VerticalAlign::Top);
assert_eq!(rect.x(), selrect.x());
assert_eq!(rect.width(), selrect.width());
}
#[test]
fn cached_paint_anchor_plus_offset_lands_on_the_layout_origin() {
let mut content = grown_content(&[TextDirection::RTL], 120.0);
content.layout_paint_origin = Some(Point::new(100.0, 50.0));
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let anchor = content.cached_layout_paint_anchor(&selrect);
let offset = content.cached_layout_paint_offset(&selrect);
assert_eq!(
anchor.x + offset.x,
content.layout_origin_x(&selrect, 120.0)
);
}
#[test]
fn cached_paint_offset_stays_a_pure_translation_when_the_shape_moves() {
let mut content = grown_content(&[TextDirection::RTL], 120.0);
content.layout_paint_origin = Some(Point::new(100.0, 50.0));
let before = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let after = Rect::from_xywh(130.0, 70.0, 60.0, 20.0);
let moved_by = Point::new(
content.cached_layout_paint_offset(&after).x
- content.cached_layout_paint_offset(&before).x,
content.cached_layout_paint_offset(&after).y
- content.cached_layout_paint_offset(&before).y,
);
assert_eq!(moved_by, Point::new(30.0, 20.0));
}
#[test]
fn cached_paint_anchor_right_anchors_when_no_origin_was_baked() {
let content = grown_content(&[TextDirection::RTL], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
assert_eq!(content.layout_paint_origin, None);
assert_eq!(content.cached_layout_paint_anchor(&selrect).x, 40.0);
assert_eq!(content.cached_layout_paint_offset(&selrect).x, 0.0);
}
} }

View File

@ -5,11 +5,22 @@ HOST="${PENPOT_DB_HOST:-postgres}"
USER="${PENPOT_DB_USER:-penpot}" USER="${PENPOT_DB_USER:-penpot}"
PASSWORD="${PENPOT_DB_PASSWORD:-penpot}" PASSWORD="${PENPOT_DB_PASSWORD:-penpot}"
DB="${PENPOT_DB_NAME:-penpot}" DB="${PENPOT_DB_NAME:-penpot}"
TEST=false
WS=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--test) --test)
DB="penpot_test" DB="penpot_test"
TEST=true
shift
;;
--ws)
WS="$2"
shift 2
;;
--ws*)
WS="${1#--ws}"
shift shift
;; ;;
--host|-h) --host|-h)
@ -30,5 +41,25 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
# --ws selects one instance's isolated backend-test database
# (penpot_test on ws0, penpot_test_wsN on wsN). It only applies together
# with --test because the main database is shared by all instances.
if [[ -n "$WS" ]]; then
if [[ "$WS" =~ ^ws([0-9]+)$ ]]; then
WS="${BASH_REMATCH[1]}"
fi
if [[ ! "$WS" =~ ^[0-9]+$ ]]; then
echo "Invalid --ws value: '$WS' (expected a non-negative integer, e.g. --ws 1)" >&2
exit 1
fi
if [[ "$TEST" != "true" ]]; then
echo "--ws only applies together with --test (the main database is shared by all instances)." >&2
exit 1
fi
if [[ "$WS" != "0" ]]; then
DB="penpot_test_ws${WS}"
fi
fi
export PGPASSWORD="$PASSWORD" export PGPASSWORD="$PASSWORD"
exec pg_dump -h "$HOST" -U "$USER" -d "$DB" --schema-only --no-owner --no-privileges "$@" exec pg_dump -h "$HOST" -U "$USER" -d "$DB" --schema-only --no-owner --no-privileges "$@"

View File

@ -5,11 +5,22 @@ HOST="${PENPOT_DB_HOST:-postgres}"
USER="${PENPOT_DB_USER:-penpot}" USER="${PENPOT_DB_USER:-penpot}"
PASSWORD="${PENPOT_DB_PASSWORD:-penpot}" PASSWORD="${PENPOT_DB_PASSWORD:-penpot}"
DB="${PENPOT_DB_NAME:-penpot}" DB="${PENPOT_DB_NAME:-penpot}"
TEST=false
WS=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--test) --test)
DB="penpot_test" DB="penpot_test"
TEST=true
shift
;;
--ws)
WS="$2"
shift 2
;;
--ws*)
WS="${1#--ws}"
shift shift
;; ;;
--host|-h) --host|-h)
@ -30,5 +41,25 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
# --ws selects one instance's isolated backend-test database
# (penpot_test on ws0, penpot_test_wsN on wsN). It only applies together
# with --test because the main database is shared by all instances.
if [[ -n "$WS" ]]; then
if [[ "$WS" =~ ^ws([0-9]+)$ ]]; then
WS="${BASH_REMATCH[1]}"
fi
if [[ ! "$WS" =~ ^[0-9]+$ ]]; then
echo "Invalid --ws value: '$WS' (expected a non-negative integer, e.g. --ws 1)" >&2
exit 1
fi
if [[ "$TEST" != "true" ]]; then
echo "--ws only applies together with --test (the main database is shared by all instances)." >&2
exit 1
fi
if [[ "$WS" != "0" ]]; then
DB="penpot_test_ws${WS}"
fi
fi
export PGPASSWORD="$PASSWORD" export PGPASSWORD="$PASSWORD"
exec psql -h "$HOST" -U "$USER" -d "$DB" "$@" exec psql -h "$HOST" -U "$USER" -d "$DB" "$@"