Compare commits

..

1 Commits

Author SHA1 Message Date
Elena Torro
4baa894ee4 Support undo and redo on text 2025-11-18 13:58:28 +01:00
267 changed files with 27888 additions and 28118 deletions

View File

@@ -114,7 +114,7 @@ jobs:
# uses the same cache as this task so we prepopulate it
command: |
yarn install
yarn run playwright install chromium --with-deps
yarn run playwright install chromium
- run:
name: "lint scss on frontend"
@@ -207,6 +207,51 @@ jobs:
"npx http-server storybook-static --port 6006 --silent" \
"npx wait-on tcp:6006 && yarn test:storybook"
test-integration:
docker:
- image: penpotapp/devenv:latest
working_directory: ~/repo
resource_class: large
environment:
JAVA_OPTS: -Xmx6g -Xms2g
NODE_OPTIONS: --max-old-space-size=4096
steps:
- checkout
# Download and cache dependencies
- restore_cache:
keys:
- v1-dependencies-{{ checksum "frontend/deps.edn"}}-{{ checksum "frontend/yarn.lock" }}
# Build frontend
- run:
name: "frontend build"
working_directory: "./frontend"
command: |
yarn install
yarn run build:app:assets
yarn run build:app
yarn run build:app:libs
# Build the wasm bundle
- run:
name: "wasm build"
working_directory: "./render-wasm"
command: |
EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh
./build release
# Run integration tests
- run:
name: "integration tests"
working_directory: "./frontend"
command: |
yarn run playwright install chromium
yarn run test:e2e -x --workers=4
test-backend:
docker:
- image: penpotapp/devenv:latest
@@ -302,4 +347,5 @@ workflows:
- lint: success
- lint
- test-integration
- test-render-wasm

View File

@@ -1,315 +0,0 @@
name: "CI"
defaults:
run:
shell: bash
on:
pull_request:
types:
- opened
- edited
- reopened
- synchronize
push:
branches:
- develop
- staging
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: "Code Linter"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check clojure code format
run: |
corepack enable;
corepack install;
yarn install
yarn run fmt:clj:check
test-common:
name: "Common Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run tests on JVM
working-directory: ./common
run: |
clojure -M:dev:test
- name: Run tests on NODE
working-directory: ./common
run: |
corepack enable;
corepack install;
yarn install;
yarn run test;
test-frontend:
name: "Frontend Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Unit Tests
working-directory: ./frontend
run: |
corepack enable;
corepack install;
yarn install;
yarn run test;
- name: Component Tests
working-directory: ./frontend
run: |
yarn run playwright install chromium --with-deps;
yarn run build:storybook
npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
"npx http-server storybook-static --port 6006 --silent" \
"npx wait-on tcp:6006 && yarn test:storybook"
- name: Check SCSS Format
working-directory: ./frontend
run: |
yarn run lint:scss;
test-backend:
name: "Backend Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
run: |
clojure -M:dev:test --reporter kaocha.report/documentation
test-library:
name: "Library Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run tests
working-directory: ./library
run: |
corepack enable;
corepack install;
yarn install;
yarn run build:bundle;
yarn run test;
build-integration:
name: "Build Integration Bundle"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Bundle
working-directory: ./frontend
run: |
corepack enable;
corepack install;
yarn install
yarn run build:app:assets
yarn run build:app
yarn run build:app:libs
- name: Build WASM
working-directory: "./render-wasm"
run: |
./build release
- name: Store Bundle Cache
uses: actions/cache@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration-1:
name: "Integration Tests 1/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
corepack enable;
corepack install;
yarn install;
yarn run playwright install chromium --with-deps;
yarn run test:e2e -x --workers=2 --reporter=list --shard="1/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-1
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-2:
name: "Integration Tests 2/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
corepack enable;
corepack install;
yarn install;
yarn run playwright install chromium --with-deps;
yarn run test:e2e -x --workers=2 --reporter=list --shard "2/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-2
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-3:
name: "Integration Tests 3/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
corepack enable;
corepack install;
yarn install;
yarn run playwright install chromium --with-deps;
yarn run test:e2e -x --workers=2 --reporter=list --shard "3/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-3
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-4:
name: "Integration Tests 3/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
corepack enable;
corepack install;
yarn install;
yarn run playwright install chromium --with-deps;
yarn run test:e2e -x --workers=2 --reporter=list --shard "4/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-4
path: frontend/test-results/
overwrite: true
retention-days: 3

View File

@@ -7,7 +7,7 @@
#### Backend RPC API changes
The backend RPC API URLS are changed from `/api/rpc/command/<name>` to
`/api/main/methods/<name>`. The previous PATH is preserved for backward
`/api/main/methods/<name>` (the previou PATH is preserved for backward
compatibility; however, if you are a user of this API, it is strongly
recommended that you adapt your code to use the new PATH.
@@ -35,7 +35,7 @@ If you have SSO/Social-Auth configured on your on-premise instance,
the following actions are required before update:
Update your OAuth or SSO provider configuration (e.g., Okta, Google,
Azure AD, etc.) to use the new callback URL. Failure to update may
Azure AD, etc.) to use the new callback URL. Failure to update may
result in authentication failures after upgrading.
**Reason for change:**
@@ -45,33 +45,15 @@ and makis it more modular, enabling the ability to configure SSO auth
provider dinamically.
#### Changes on default docker compose
We have updated the `docker/images/docker-compose.yaml` with a small
change related to the `PENPOT_SECRET_KEY`. Since this version, this
environment variable is also required on exporter. So if you are using
penpot on-premise you will need to apply the same changes on your own
`docker-compose.yaml` file.
We have removed the Minio server from the `docker/images/docker-compose.yml`
example. It's still usable as before, we just removed the example.
### :rocket: Epics and highlights
### :heart: Community contributions (Thank you!)
- Ensure consistent snap behavior across all zoom levels [Github #7774](https://github.com/penpot/penpot/pull/7774) by [@Tokytome](https://github.com/Tokytome)
### :sparkles: New features & Enhancements
- Add the ability to select boards to export as PDF [Taiga #12320](https://tree.taiga.io/project/penpot/issue/12320)
- Add toggle for switching boolean property values [Taiga #12341](https://tree.taiga.io/project/penpot/us/12341)
- Make the file export process more reliable [Taiga #12555](https://tree.taiga.io/project/penpot/us/12555)
- Select boards to export as PDF [Taiga #12320](https://tree.taiga.io/project/penpot/issue/12320)
- Toggle for switching boolean property values [Taiga #12341](https://tree.taiga.io/project/penpot/us/12341)
- Add auth flow changes [Taiga #12333](https://tree.taiga.io/project/penpot/us/12333)
- Add new shape validation mechanism for shapes [Github #7696](https://github.com/penpot/penpot/pull/7696)
- Apply color tokens from sidebar [Taiga #11353](https://tree.taiga.io/project/penpot/us/11353)
- Display tokens in the inspect tab [Taiga #9313](https://tree.taiga.io/project/penpot/us/9313)
- Refactor clipboard behavior to assess some minor inconsistencies and make pasting binary data faster. [Taiga #12571](https://tree.taiga.io/project/penpot/task/12571)
### :bug: Bugs fixed
@@ -86,7 +68,6 @@ example. It's still usable as before, we just removed the example.
- Fix shortcut conflict in text editor (increase/decrease font size vs word selection)
- Fix problem with plugins generating code for pages different than current one [Taiga #12312](https://tree.taiga.io/project/penpot/issue/12312)
- Fix input confirmation behavior is not uniform [Taiga #12294](https://tree.taiga.io/project/penpot/issue/12294)
- Fix copy/pasting application/transit+json [Taiga #12721](https://tree.taiga.io/project/penpot/issue/12721)
## 2.11.1

View File

@@ -25,7 +25,8 @@
<Logger name="app.storage.tmp" level="info" />
<Logger name="app.worker" level="trace" />
<Logger name="app.msgbus" level="info" />
<Logger name="app.http" level="info" />
<Logger name="app.http.websocket" level="info" />
<Logger name="app.http.sse" level="info" />
<Logger name="app.util.websocket" level="info" />
<Logger name="app.redis" level="info" />
<Logger name="app.rpc.rlimit" level="info" />

View File

@@ -25,7 +25,8 @@
<Logger name="app.storage.tmp" level="info" />
<Logger name="app.worker" level="trace" />
<Logger name="app.msgbus" level="info" />
<Logger name="app.http" level="info" />
<Logger name="app.http.websocket" level="info" />
<Logger name="app.http.sse" level="info" />
<Logger name="app.util.websocket" level="info" />
<Logger name="app.redis" level="info" />
<Logger name="app.rpc.rlimit" level="info" />

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env bash
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_MANAGEMENT_API_SHARED_KEY=super-secret-management-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key
export PENPOT_HOST=devenv

View File

@@ -255,8 +255,6 @@
(write-entry! output path params)
(events/tap :progress {:section :storage-object :id id})
(with-open [input (sto/get-object-data storage sobject)]
(.putNextEntry ^ZipOutputStream output (ZipEntry. (str "objects/" id ext)))
(io/copy input output :size (:size sobject))
@@ -281,8 +279,6 @@
thumbnails (bfc/get-file-object-thumbnails cfg file-id)]
(events/tap :progress {:section :file :id file-id})
(vswap! bfc/*state* update :files assoc file-id
{:id file-id
:name (:name file)

View File

@@ -5,6 +5,7 @@
;; Copyright (c) KALEIDOS INC
(ns app.config
"A configuration management."
(:refer-clojure :exclude [get])
(:require
[app.common.data :as d]
@@ -102,7 +103,7 @@
[:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int]
[:management-api-key {:optional true} :string]
[:management-api-shared-key {:optional true} :string]
[:telemetry-uri {:optional true} :string]
[:telemetry-with-taiga {:optional true} ::sm/boolean] ;; DELETE

View File

@@ -50,27 +50,23 @@
(db/tx-run! cfg handler request)))))})
(defmethod ig/init-key ::routes
[_ {:keys [::setup/props] :as cfg}]
[_ cfg]
["" {:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]
[default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
(let [management-key (or (cf/get :management-api-key)
(get props :management-key))]
["/get-customer"
{:handler get-customer
:transaction true
:allowed-methods #{:post}}]
["" {:middleware [[mw/shared-key-auth management-key]
[default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
["/get-customer"
{:handler get-customer
:transaction true
:allowed-methods #{:post}}]
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]]))
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]])
;; ---- HELPERS

View File

@@ -14,9 +14,7 @@
[app.config :as cf]
[app.http :as-alias http]
[app.http.errors :as errors]
[app.tokens :as tokens]
[app.util.pointer-map :as pmap]
[buddy.core.codecs :as bc]
[cuerdas.core :as str]
[yetti.adapter :as yt]
[yetti.middleware :as ymw]
@@ -244,6 +242,7 @@
(handler request)
{::yres/status 405}))))))})
(defn- wrap-auth
[handler decoders]
(let [token-re
@@ -273,24 +272,9 @@
process-request
(fn [request]
(if-let [{:keys [type token] :as auth} (get-token request)]
(let [decode-fn (get decoders type)]
(if (or (= type :cookie) (= type :bearer))
(let [metadata (tokens/decode-header token)]
;; NOTE: we only proceed to decode claims on new
;; cookie tokens. The old cookies dont need to be
;; decoded because they use the token string as ID
(if (and (= (:kid metadata) 1)
(= (:ver metadata) 1)
(some? decode-fn))
(assoc request ::http/auth-data (assoc auth
:claims (decode-fn token)
:metadata metadata))
(assoc request ::http/auth-data (assoc auth :metadata {:ver 0}))))
(if decode-fn
(assoc request ::http/auth-data (assoc auth :claims (decode-fn token)))
(assoc request ::http/auth-data auth))))
(if-let [decode-fn (get decoders type)]
(assoc request ::http/auth-data (assoc auth :claims (decode-fn token)))
(assoc request ::http/auth-data auth))
request))]
(fn [request]
@@ -303,14 +287,11 @@
(defn- wrap-shared-key-auth
[handler shared-key]
(if shared-key
(let [shared-key (if (string? shared-key)
shared-key
(bc/bytes->b64-str shared-key true))]
(fn [request]
(let [key (yreq/get-header request "x-shared-key")]
(if (= key shared-key)
(handler request)
{::yres/status 403}))))
(fn [request]
(let [key (yreq/get-header request "x-shared-key")]
(if (= key shared-key)
(handler request)
{::yres/status 403})))
(fn [_ _]
{::yres/status 403})))

View File

@@ -93,15 +93,15 @@
(update-session [_ session]
(let [modified-at (ct/now)]
(if (string? (:id session))
(db/insert! pool :http-session-v2
(-> session
(assoc :id (uuid/next))
(assoc :created-at modified-at)
(assoc :modified-at modified-at)))
(let [params (-> session
(assoc :id (uuid/next))
(assoc :created-at modified-at)
(assoc :modified-at modified-at))]
(db/insert! pool :http-session-v2 params))
(db/update! pool :http-session-v2
{:modified-at modified-at}
{:id (:id session)}
{::db/return-keys true}))))
{:id (:id session)}))))
(delete-session [_ id]
(if (string? id)
@@ -158,15 +158,14 @@
(defn- assign-token
[cfg session]
(let [claims {:iss "authentication"
:aud "penpot"
:sid (:id session)
:iat (:modified-at session)
:uid (:profile-id session)
:sso-provider-id (:sso-provider-id session)
:sso-session-id (:sso-session-id session)}
header {:kid 1 :ver 1}
token (tokens/generate cfg claims header)]
(let [token (tokens/generate cfg
{:iss "authentication"
:aud "penpot"
:sid (:id session)
:iat (:modified-at session)
:uid (:profile-id session)
:sso-provider-id (:sso-provider-id session)
:sso-session-id (:sso-session-id session)})]
(assoc session :token token)))
(defn create-fn
@@ -226,14 +225,13 @@
[handler {:keys [::manager] :as cfg}]
(assert (manager? manager) "expected valid session manager")
(fn [request]
(let [{:keys [type token claims metadata]} (get request ::http/auth-data)]
(let [{:keys [type token claims]} (get request ::http/auth-data)]
(cond
(= type :cookie)
(let [session (case (:ver metadata)
(let [session (if-let [sid (:sid claims)]
(read-session manager sid)
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
0 (read-session manager token)
1 (some->> (:sid claims) (read-session manager))
nil)
(read-session manager token))
request (cond-> request
(some? session)
@@ -242,7 +240,7 @@
response (handler request)]
(if (and session (renew-session? session))
(if (renew-session? session)
(let [session (->> session
(update-session manager)
(assign-token cfg))]
@@ -250,11 +248,11 @@
response))
(= type :bearer)
(let [session (case (:ver metadata)
(let [session (if-let [sid (:sid claims)]
(read-session manager sid)
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
0 (read-session manager token)
1 (some->> (:sid claims) (read-session manager))
nil)
(read-session manager token))
request (cond-> request
(some? session)
(-> (assoc ::profile-id (:profile-id session))

View File

@@ -49,7 +49,7 @@
ctx (-> context
(assoc :tenant (cf/get :tenant))
(assoc :host (cf/get :host))
(assoc :public-uri (str (cf/get :public-uri)))
(assoc :public-uri (cf/get :public-uri))
(assoc :logger/name logger)
(assoc :logger/level level)
(dissoc :request/params :value :params :data))]

View File

@@ -295,8 +295,7 @@
[cfg]
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
(->> (sv/scan-ns
'app.rpc.management.subscription
'app.rpc.management.exporter)
'app.rpc.management.subscription)
(map (partial process-method cfg "management" wrap-management))
(into {}))))
@@ -347,16 +346,14 @@
(assert (valid-methods? (::management-methods params)) "expect valid methods map"))
(defmethod ig/init-key ::routes
[_ {:keys [::methods ::management-methods ::setup/props] :as cfg}]
(let [public-uri (cf/get :public-uri)
management-key (or (cf/get :management-api-key)
(get props :management-key))]
[_ {:keys [::methods ::management-methods] :as cfg}]
(let [public-uri (cf/get :public-uri)]
["/api"
["/management"
["/methods/:type"
{:middleware [[mw/shared-key-auth management-key]
{:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]
[session/authz cfg]]
:handler (make-rpc-handler management-methods)}]

View File

@@ -11,9 +11,9 @@
[app.binfile.v1 :as bf.v1]
[app.binfile.v3 :as bf.v3]
[app.common.features :as cfeat]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.http.sse :as sse]
@@ -25,12 +25,10 @@
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.rpc.helpers :as rph]
[app.tasks.file-gc]
[app.util.services :as sv]
[app.worker :as-alias wrk]
[datoteka.fs :as fs]))
[app.worker :as-alias wrk]))
(set! *warn-on-reflection* true)
@@ -40,42 +38,52 @@
schema:export-binfile
[:map {:title "export-binfile"}
[:file-id ::sm/uuid]
[:version {:optional true} ::sm/int]
[:include-libraries ::sm/boolean]
[:embed-assets ::sm/boolean]])
(defn- export-binfile
[{:keys [::sto/storage] :as cfg} {:keys [file-id include-libraries embed-assets]}]
(let [output (tmp/tempfile*)]
(try
(-> cfg
(assoc ::bfc/ids #{file-id})
(assoc ::bfc/embed-assets embed-assets)
(assoc ::bfc/include-libraries include-libraries)
(bf.v3/export-files! output))
(defn stream-export-v1
[cfg {:keys [file-id include-libraries embed-assets] :as params}]
(rph/stream
(fn [_ output-stream]
(try
(-> cfg
(assoc ::bfc/ids #{file-id})
(assoc ::bfc/embed-assets embed-assets)
(assoc ::bfc/include-libraries include-libraries)
(bf.v1/export-files! output-stream))
(catch Throwable cause
(l/err :hint "exception on exporting file"
:file-id (str file-id)
:cause cause))))))
(let [data (sto/content output)
object (sto/put-object! storage
{::sto/content data
::sto/touched-at (ct/in-future {:minutes 60})
:content-type "application/zip"
:bucket "tempfile"})]
(-> (cf/get :public-uri)
(u/join "/assets/by-id/")
(u/join (str (:id object)))))
(finally
(fs/delete output)))))
(defn stream-export-v3
[cfg {:keys [file-id include-libraries embed-assets] :as params}]
(rph/stream
(fn [_ output-stream]
(try
(-> cfg
(assoc ::bfc/ids #{file-id})
(assoc ::bfc/embed-assets embed-assets)
(assoc ::bfc/include-libraries include-libraries)
(bf.v3/export-files! output-stream))
(catch Throwable cause
(l/err :hint "exception on exporting file"
:file-id (str file-id)
:cause cause))))))
(sv/defmethod ::export-binfile
"Export a penpot file in a binary format."
{::doc/added "1.15"
::doc/changes [["2.12" "Remove version parameter, only one version is supported"]]
::webhooks/event? true
::sm/params schema:export-binfile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id version file-id] :as params}]
(files/check-read-permissions! pool profile-id file-id)
(sse/response (partial export-binfile cfg params)))
(let [version (or version 1)]
(case (int version)
1 (stream-export-v1 cfg params)
2 (throw (ex-info "not-implemented" {}))
3 (stream-export-v3 cfg params))))
;; --- Command: import-binfile

View File

@@ -39,7 +39,7 @@
fullname (str "Demo User " sem)
password (-> (bn/random-bytes 16)
(bc/bytes->b64 true)
(bc/bytes->b64u)
(bc/bytes->str))
params {:email email

View File

@@ -1209,7 +1209,7 @@
;; --- MUTATION COMMAND: restore-files-immediatelly
(def ^:private sql:resolve-editable-files
"SELECT f.id, f.project_id
"SELECT f.id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
JOIN team AS t ON (t.id = p.team_id)
@@ -1250,38 +1250,18 @@
{:file-id file-id}
{::db/return-keys false}))
(def ^:private sql:restore-projects
"UPDATE project SET deleted_at = null WHERE id = ANY(?::uuid[])")
(defn- restore-projects
[conn project-ids]
(let [project-ids (db/create-array conn "uuid" project-ids)]
(->> (db/exec-one! conn [sql:restore-projects project-ids])
(db/get-update-count))))
(defn- restore-deleted-team-files
[{:keys [::db/conn]} {:keys [::rpc/profile-id team-id ids]}]
(teams/check-edition-permissions! conn profile-id team-id)
(let [total-files
(count ids)
{:keys [files projects]}
(reduce (fn [result {:keys [id project-id]}]
(let [index (-> result :files count)]
(events/tap :progress {:file-id id :index index :total total-files})
(restore-file conn id)
(-> result
(update :files conj id)
(update :projects conj project-id))))
{:files #{} :projectes #{}}
(db/plan conn [sql:resolve-editable-files team-id
(db/create-array conn "uuid" ids)]))]
(restore-projects conn projects)
files))
(reduce (fn [affected {:keys [id]}]
(let [index (inc (count affected))]
(events/tap :progress {:file-id id :index index :total (count ids)})
(restore-file conn id)
(conj affected id)))
#{}
(db/plan conn [sql:resolve-editable-files team-id
(db/create-array conn "uuid" ids)])))
(def ^:private schema:restore-deleted-team-files
[:map {:title "restore-deleted-team-files"}
@@ -1289,8 +1269,8 @@
[:ids [::sm/set ::sm/uuid]]])
(sv/defmethod ::restore-deleted-team-files
"Removes the deletion mark from the specified files (and respective
projects) on the specified team."
"Removes the deletion mark from the specified files (and respective projects)."
{::doc/added "2.12"
::sse/stream? true
::sm/params schema:restore-deleted-team-files}

View File

@@ -96,7 +96,7 @@
;; loading all pages into memory for find the frame set for thumbnail.
(defn get-file-data-for-thumbnail
[{:keys [::db/conn] :as cfg} {:keys [data id] :as file} strip-frames-with-thumbnails]
[{:keys [::db/conn] :as cfg} {:keys [data id] :as file}]
(letfn [;; function responsible on finding the frame marked to be
;; used as thumbnail; the returned frame always have
;; the :page-id set to the page that it belongs.
@@ -173,7 +173,7 @@
;; Assoc the available thumbnails and prune not visible shapes
;; for avoid transfer unnecessary data.
strip-frames-with-thumbnails
:always
(update :objects assoc-thumbnails page-id thumbs)))))
(def ^:private
@@ -186,8 +186,7 @@
[:map {:title "PartialFile"}
[:id ::sm/uuid]
[:revn {:min 0} ::sm/int]
[:page [:map-of :keyword ::sm/any]]
[:strip-frames-with-thumbnails {:optional true} ::sm/boolean]])
[:page [:map-of :keyword ::sm/any]]])
(sv/defmethod ::get-file-data-for-thumbnail
"Retrieves the data for generate the thumbnail of the file. Used
@@ -196,7 +195,7 @@
::doc/module :files
::sm/params schema:get-file-data-for-thumbnail
::sm/result schema:partial-file}
[cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}]
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-read-permissions! conn profile-id file-id)
@@ -206,18 +205,14 @@
file (bfc/get-file cfg file-id
:realize? true
:read-only? true)
strip-frames-with-thumbnails
(or (nil? strip-frames-with-thumbnails) ;; if not present, default to true
(true? strip-frames-with-thumbnails))]
:read-only? true)]
(-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-file-features! (:features file)))
{:file-id file-id
:revn (:revn file)
:page (get-file-data-for-thumbnail cfg file strip-frames-with-thumbnails)}))))
:page (get-file-data-for-thumbnail cfg file)}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MUTATION COMMANDS

View File

@@ -169,19 +169,12 @@
;; --- MUTATION: Create Project
(defn- create-project
[{:keys [::db/conn] :as cfg} {:keys [::rpc/request-at profile-id team-id] :as params}]
(assert (ct/inst? request-at) "expect request-at assigned")
(let [params (-> params
(assoc :created-at request-at)
(assoc :modified-at request-at))
project (teams/create-project conn params)
timestamp (::rpc/request-at params)]
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
(let [project (teams/create-project conn params)]
(teams/create-project-role conn profile-id (:id project) :owner)
(db/insert! conn :team-project-profile-rel
{:project-id (:id project)
:profile-id profile-id
:created-at timestamp
:modified-at timestamp
:team-id team-id
:is-pinned false})
(assoc project :is-pinned false)))

View File

@@ -39,8 +39,9 @@
(defn- encode
[s]
(-> s
(bh/blake2b-256)
(bc/bytes->b64-str true)))
bh/blake2b-256
bc/bytes->b64u
bc/bytes->str))
(defn- fmt-key
[s]

View File

@@ -1,49 +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 INC
(ns app.rpc.management.exporter
(:require
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.media :refer [schema:upload]]
[app.rpc :as-alias rpc]
[app.rpc.doc :as doc]
[app.storage :as sto]
[app.util.services :as sv]))
;; ---- RPC METHOD: UPLOAD-TEMPFILE
(def ^:private
schema:upload-tempfile-params
[:map {:title "upload-templfile-params"}
[:content schema:upload]])
(def ^:private
schema:upload-tempfile-result
[:map {:title "upload-templfile-result"}])
(sv/defmethod ::upload-tempfile
{::doc/added "2.12"
::sm/params schema:upload-tempfile-params
::sm/result schema:upload-tempfile-result}
[cfg {:keys [::rpc/profile-id content]}]
(let [storage (sto/resolve cfg)
hash (sto/calculate-hash (:path content))
data (-> (sto/content (:path content))
(sto/wrap-with-hash hash))
content {::sto/content data
::sto/deduplicate? true
::sto/touched-at (ct/in-future {:minutes 10})
:profile-id profile-id
:content-type (:mtype content)
:bucket "tempfile"}
object (sto/put-object! storage content)]
{:id (:id object)
:uri (-> (cf/get :public-uri)
(u/join "/assets/by-id/")
(u/join (str (:id object))))}))

View File

@@ -22,7 +22,8 @@
(defn- generate-random-key
[]
(-> (bn/random-bytes 64)
(bc/bytes->b64-str true)))
(bc/bytes->b64u)
(bc/bytes->str)))
(defn- get-all-props
[conn]
@@ -84,11 +85,12 @@
(l/warn :hint (str "using autogenerated secret-key, it will change on each restart and will invalidate "
"all sessions on each restart, it is highly recommended setting up the "
"PENPOT_SECRET_KEY environment variable")))
(let [secret (or key (generate-random-key))]
(-> (get-all-props conn)
(assoc :secret-key secret)
(assoc :tokens-key (keys/derive secret :salt "tokens"))
(assoc :management-key (keys/derive secret :salt "management"))
(update :instance-id handle-instance-id conn (db/read-only? pool)))))))
(sm/register! ::props [:map-of :keyword ::sm/any])
;; FIXME
(sm/register! ::props :any)

View File

@@ -8,13 +8,13 @@
"Keys derivation service."
(:refer-clojure :exclude [derive])
(:require
[app.common.spec :as us]
[buddy.core.kdf :as bk]))
(defn derive
"Derive a key from secret-key"
[secret-key & {:keys [salt size] :or {size 32}}]
(assert (string? secret-key) "expect string")
(assert (seq secret-key) "expect string")
(us/assert! ::us/not-empty-string secret-key)
(let [engine (bk/engine {:key secret-key
:salt salt
:alg :hkdf

View File

@@ -41,7 +41,6 @@
"file-object-thumbnail"
"file-thumbnail"
"profile"
"tempfile"
"file-data"
"file-data-fragment"
"file-change"})
@@ -164,6 +163,9 @@
backend
(:metadata result))))
(def ^:private sql:retrieve-storage-object
"select * from storage_object where id = ? and (deleted_at is null or deleted_at > now())")
(defn row->storage-object [res]
(let [mdata (or (some-> (:metadata res) (db/decode-transit-pgobject)) {})]
(impl/storage-object
@@ -175,15 +177,9 @@
(keyword (:backend res))
mdata)))
(def ^:private sql:get-storage-object
"SELECT *
FROM storage_object
WHERE id = ?
AND (deleted_at IS NULL)")
(defn- get-database-object
(defn- retrieve-database-object
[conn id]
(some-> (db/exec-one! conn [sql:get-storage-object id])
(some-> (db/exec-one! conn [sql:retrieve-storage-object id])
(row->storage-object)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -206,7 +202,7 @@
(defn get-object
[{:keys [::db/connectable] :as storage} id]
(assert (valid-storage? storage))
(get-database-object connectable id))
(retrieve-database-object connectable id))
(defn put-object!
"Creates a new object with the provided content."

View File

@@ -37,6 +37,7 @@
(into #{} (map :id))
(not-empty))))
(def ^:private sql:delete-sobjects
"DELETE FROM storage_object
WHERE id = ANY(?::uuid[])")
@@ -76,37 +77,47 @@
(d/group-by (comp keyword :backend) :id #{} items))
(def ^:private sql:get-deleted-sobjects
"SELECT s.*
FROM storage_object AS s
"SELECT s.* FROM storage_object AS s
WHERE s.deleted_at IS NOT NULL
AND s.deleted_at <= ?
AND s.deleted_at < now() - ?::interval
ORDER BY s.deleted_at ASC")
(defn- get-buckets
[conn]
(let [now (ct/now)]
[conn min-age]
(let [age (db/interval min-age)]
(sequence
(comp (partition-all 25)
(mapcat group-by-backend))
(db/cursor conn [sql:get-deleted-sobjects now]))))
(db/cursor conn [sql:get-deleted-sobjects age]))))
(defn- clean-deleted!
[{:keys [::db/conn] :as cfg}]
[{:keys [::db/conn ::min-age] :as cfg}]
(reduce (fn [total [backend-id ids]]
(let [deleted (delete-in-bulk! cfg backend-id ids)]
(+ total (or deleted 0))))
0
(get-buckets conn)))
(get-buckets conn min-age)))
(defmethod ig/assert-key ::handler
[_ params]
(assert (sto/valid-storage? (::sto/storage params)) "expect valid storage")
(assert (db/pool? (::db/pool params)) "expect valid storage"))
(defmethod ig/expand-key ::handler
[k v]
{k (assoc v ::min-age (ct/duration {:hours 2}))})
(defmethod ig/init-key ::handler
[_ cfg]
(fn [_]
(db/tx-run! cfg (fn [cfg]
(let [total (clean-deleted! cfg)]
(l/inf :hint "task finished" :total total)
{:deleted total})))))
[_ {:keys [::min-age] :as cfg}]
(fn [{:keys [props] :as task}]
(let [min-age (ct/duration (or (:min-age props) min-age))]
(db/tx-run! cfg (fn [cfg]
(let [cfg (assoc cfg ::min-age min-age)
total (clean-deleted! cfg)]
(l/inf :hint "task finished"
:min-age (ct/format-duration min-age)
:total total)
{:deleted total}))))))

View File

@@ -22,8 +22,6 @@
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
[app.storage :as-alias sto]
[app.storage.impl :as impl]
@@ -103,15 +101,14 @@
(def ^:private sql:mark-delete-in-bulk
"UPDATE storage_object
SET deleted_at = ?,
SET deleted_at = now(),
touched_at = NULL
WHERE id = ANY(?::uuid[])")
(defn- mark-delete-in-bulk!
[conn deletion-delay ids]
(let [ids (db/create-array conn "uuid" ids)
now (ct/plus (ct/now) deletion-delay)]
(db/exec-one! conn [sql:mark-delete-in-bulk now ids])))
[conn ids]
(let [ids (db/create-array conn "uuid" ids)]
(db/exec-one! conn [sql:mark-delete-in-bulk ids])))
;; NOTE: A getter that retrieves the key which will be used for group
;; ids; previously we have no value, then we introduced the
@@ -140,20 +137,18 @@
(if-let [{:keys [id] :as object} (first objects)]
(if (has-refs? conn object)
(do
(l/dbg :id (str id)
:status "freeze"
:bucket bucket)
(l/debug :id (str id)
:status "freeze"
:bucket bucket)
(recur (conj to-freeze id) to-delete (rest objects)))
(do
(l/dbg :id (str id)
:status "delete"
:bucket bucket)
(l/debug :id (str id)
:status "delete"
:bucket bucket)
(recur to-freeze (conj to-delete id) (rest objects))))
(let [deletion-delay (if (= bucket "tempfile")
(ct/duration {:hours 2})
(cf/get-deletion-delay))]
(do
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
(some->> (seq to-delete) (mark-delete-in-bulk! conn deletion-delay))
(some->> (seq to-delete) (mark-delete-in-bulk! conn))
[(count to-freeze) (count to-delete)]))))
(defn- process-bucket!
@@ -165,7 +160,6 @@
"file-thumbnail" (process-objects! conn has-file-thumbnails-refs? bucket objects)
"profile" (process-objects! conn has-profile-refs? bucket objects)
"file-data" (process-objects! conn has-file-data-refs? bucket objects)
"tempfile" (process-objects! conn (constantly false) bucket objects)
(ex/raise :type :internal
:code :unexpected-unknown-reference
:hint (dm/fmt "unknown reference '%'" bucket))))
@@ -179,27 +173,27 @@
[0 0]
(d/group-by lookup-bucket identity #{} chunk)))
(def ^:private sql:get-touched-storage-objects
(def ^:private
sql:get-touched-storage-objects
"SELECT so.*
FROM storage_object AS so
WHERE so.touched_at IS NOT NULL
AND so.touched_at <= ?
ORDER BY touched_at ASC
FOR UPDATE
SKIP LOCKED
LIMIT 10")
(defn get-chunk
[conn timestamp]
(->> (db/exec! conn [sql:get-touched-storage-objects timestamp])
[conn]
(->> (db/exec! conn [sql:get-touched-storage-objects])
(map impl/decode-row)
(not-empty)))
(defn- process-touched!
[{:keys [::db/pool ::timestamp] :as cfg}]
[{:keys [::db/pool] :as cfg}]
(loop [freezed 0
deleted 0]
(if-let [chunk (get-chunk pool timestamp)]
(if-let [chunk (get-chunk pool)]
(let [[nfo ndo] (db/tx-run! cfg process-chunk! chunk)]
(recur (long (+ freezed nfo))
(long (+ deleted ndo))))
@@ -215,6 +209,5 @@
(defmethod ig/init-key ::handler
[_ cfg]
(fn [_]
(process-touched! (assoc cfg ::timestamp (ct/now)))))
(fn [_] (process-touched! cfg)))

View File

@@ -79,17 +79,14 @@
;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn tempfile*
[& {:keys [suffix prefix]
(defn tempfile
[& {:keys [suffix prefix min-age]
:or {prefix "penpot."
suffix ".tmp"}}]
(let [attrs (fs/make-permissions "rw-r--r--")
path (fs/join default-tmp-dir (str prefix (uuid/next) suffix))]
(Files/createFile path attrs)))
(defn tempfile
[& {:keys [min-age] :as opts}]
(let [path (tempfile* opts)]
path (fs/join default-tmp-dir (str prefix (uuid/next) suffix))
path (Files/createFile path attrs)]
(fs/delete-on-exit! path)
(sp/offer! queue [path (some-> min-age ct/duration)])
path))

View File

@@ -18,15 +18,15 @@
(def ^:private sql:get-profiles
"SELECT id, photo_id FROM profile
WHERE deleted_at IS NOT NULL
AND deleted_at <= ?
AND deleted_at < now() + ?::interval
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-profiles!
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-profiles timestamp chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-profiles deletion-threshold chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id photo-id]}]
(l/trc :obj "profile" :id (str id))
@@ -41,15 +41,15 @@
(def ^:private sql:get-teams
"SELECT deleted_at, id, photo_id FROM team
WHERE deleted_at IS NOT NULL
AND deleted_at <= ?
AND deleted_at < now() + ?::interval
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-teams!
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-teams timestamp chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-teams deletion-threshold chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id photo-id deleted-at]}]
(l/trc :obj "team"
:id (str id)
@@ -68,15 +68,15 @@
"SELECT id, team_id, deleted_at, woff1_file_id, woff2_file_id, otf_file_id, ttf_file_id
FROM team_font_variant
WHERE deleted_at IS NOT NULL
AND deleted_at <= ?
AND deleted_at < now() + ?::interval
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-fonts!
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-fonts timestamp chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-fonts deletion-threshold chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id team-id deleted-at] :as font}]
(l/trc :obj "font-variant"
:id (str id)
@@ -98,15 +98,15 @@
"SELECT id, deleted_at, team_id
FROM project
WHERE deleted_at IS NOT NULL
AND deleted_at <= ?
AND deleted_at < now() + ?::interval
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-projects!
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-projects timestamp chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-projects deletion-threshold chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id team-id deleted-at]}]
(l/trc :obj "project"
:id (str id)
@@ -124,15 +124,15 @@
f.project_id
FROM file AS f
WHERE f.deleted_at IS NOT NULL
AND f.deleted_at <= ?
AND f.deleted_at < now() + ?::interval
ORDER BY f.deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-files!
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-files timestamp chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-files deletion-threshold chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id deleted-at project-id] :as file}]
(l/trc :obj "file"
:id (str id)
@@ -148,15 +148,15 @@
"SELECT file_id, revn, media_id, deleted_at
FROM file_thumbnail
WHERE deleted_at IS NOT NULL
AND deleted_at <= ?
AND deleted_at < now() + ?::interval
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn delete-file-thumbnails!
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-thumbnails timestamp chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-thumbnails deletion-threshold chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [file-id revn media-id deleted-at]}]
(l/trc :obj "file-thumbnail"
:file-id (str file-id)
@@ -175,15 +175,15 @@
"SELECT file_id, object_id, media_id, deleted_at
FROM file_tagged_object_thumbnail
WHERE deleted_at IS NOT NULL
AND deleted_at <= ?
AND deleted_at < now() + ?::interval
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn delete-file-object-thumbnails!
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-object-thumbnails timestamp chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-object-thumbnails deletion-threshold chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [file-id object-id media-id deleted-at]}]
(l/trc :obj "file-object-thumbnail"
:file-id (str file-id)
@@ -203,15 +203,15 @@
"SELECT id, file_id, media_id, thumbnail_id, deleted_at
FROM file_media_object
WHERE deleted_at IS NOT NULL
AND deleted_at <= ?
AND deleted_at < now() + ?::interval
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-file-media-objects!
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-media-objects timestamp chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-media-objects deletion-threshold chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id file-id deleted-at] :as fmo}]
(l/trc :obj "file-media-object"
:id (str id)
@@ -231,15 +231,16 @@
"SELECT file_id, id, type, deleted_at, metadata, backend
FROM file_data
WHERE deleted_at IS NOT NULL
AND deleted_at <= ?
AND deleted_at < now() + ?::interval
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-file-data!
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-file-data timestamp chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-file-data deletion-threshold chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [file-id id type deleted-at metadata backend]}]
(some->> metadata
@@ -265,15 +266,15 @@
"SELECT id, file_id, deleted_at
FROM file_change
WHERE deleted_at IS NOT NULL
AND deleted_at <= ?
AND deleted_at < now() + ?::interval
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-file-changes!
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-file-change timestamp chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-file-change deletion-threshold chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id file-id deleted-at] :as xlog}]
(l/trc :obj "file-change"
:id (str id)
@@ -321,8 +322,9 @@
(defmethod ig/init-key ::handler
[_ cfg]
(fn [_]
(let [cfg (assoc cfg ::timestamp (ct/now))]
(fn [{:keys [props] :as task}]
(let [threshold (ct/duration (get props :deletion-threshold 0))
cfg (assoc cfg ::deletion-threshold (db/interval threshold))]
(loop [procs (map deref deletion-proc-vars)
total 0]
(if-let [proc-fn (first procs)]

View File

@@ -15,25 +15,19 @@
[buddy.sign.jwe :as jwe]))
(defn generate
([cfg claims] (generate cfg claims nil))
([{:keys [::setup/props] :as cfg} claims header]
(assert (contains? props :tokens-key) "expect props to have tokens-key")
[{:keys [::setup/props] :as cfg} claims]
(assert (contains? cfg ::setup/props))
(let [tokens-key
(get props :tokens-key)
(let [tokens-key
(get props :tokens-key)
payload
(-> claims
(update :iat (fn [v] (or v (ct/now))))
(d/without-nils)
(t/encode))]
payload
(-> claims
(update :iat (fn [v] (or v (ct/now))))
(d/without-nils)
(t/encode))]
(jwe/encrypt payload tokens-key {:alg :a256kw :enc :a256gcm :header header}))))
(defn decode-header
[token]
(ex/ignoring
(jwe/decode-header token)))
(jwe/encrypt payload tokens-key {:alg :a256kw :enc :a256gcm})))
(defn decode
[{:keys [::setup/props] :as cfg} token]

View File

@@ -27,7 +27,7 @@
(throw (IllegalArgumentException. "Missing arguments on `defmethod` macro.")))
(let [mdata (assoc mdata
::docstring (some-> docs str/unindent)
::docstring (some-> docs str/<<-)
::spec sname
::name (name sname))

View File

@@ -30,7 +30,6 @@
[app.rpc.commands.files :as files]
[app.rpc.commands.files-create :as files.create]
[app.rpc.commands.files-update :as files.update]
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
[app.rpc.helpers :as rph]
[app.util.blob :as blob]
@@ -186,17 +185,15 @@
(defn create-project*
([i params] (create-project* *system* i params))
([system i {:keys [profile-id team-id] :as params}]
(us/assert uuid? profile-id)
(us/assert uuid? team-id)
(assert (uuid? profile-id))
(assert (uuid? team-id))
(let [timestamp (ct/now)]
(db/run! system
(fn [cfg]
(->> (merge {:id (mk-uuid "project" i)
:name (str "project" i)}
params
{::rpc/request-at timestamp})
(#'projects/create-project cfg)))))))
(db/run! system
(fn [{:keys [::db/conn]}]
(->> (merge {:id (mk-uuid "project" i)
:name (str "project" i)}
params)
(#'teams/create-project conn))))))
(defn create-file*
([i params]

View File

@@ -9,7 +9,6 @@
[app.common.features :as cfeat]
[app.common.pprint :as pp]
[app.common.thumbnails :as thc]
[app.common.time :as ct]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.config :as cf]
@@ -17,7 +16,6 @@
[app.db.sql :as sql]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
@@ -134,10 +132,9 @@
;; this will run pending task triggered by deleting user snapshot
(th/run-pending-tasks!)
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
;; delete 2 snapshots and 2 file data entries
(t/is (= 4 (:processed res)))))))))
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
;; delete 2 snapshots and 2 file data entries
(t/is (= 4 (:processed res))))))))
(t/deftest snapshots-locking
(let [profile-1 (th/create-profile* 1 {:is-active true})

View File

@@ -313,7 +313,7 @@
;; freeze because of the deduplication (we have uploaded 2 times
;; the same files).
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 2 (:freeze res)))
(t/is (= 0 (:delete res))))
@@ -372,14 +372,14 @@
(th/db-exec! ["update file_change set deleted_at = now() where file_id = ? and label is not null" (:id file)])
(th/db-exec! ["update file set has_media_trimmed = false where id = ?" (:id file)])
(let [res (th/run-task! :objects-gc {})]
(let [res (th/run-task! :objects-gc {:deletion-threshold 0})]
;; this will remove the file change and file data entries for two snapshots
(t/is (= 4 (:processed res))))
;; Rerun the file-gc and objects-gc
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(let [res (th/run-task! :objects-gc {})]
(let [res (th/run-task! :objects-gc {:deletion-threshold 0})]
;; this will remove the file media objects marked as deleted
;; on prev file-gc
(t/is (= 2 (:processed res))))
@@ -387,7 +387,7 @@
;; Now that file-gc have deleted the file-media-object usage,
;; lets execute the touched-gc task, we should see that two of
;; them are marked to be deleted
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))
@@ -572,7 +572,7 @@
;; Now that file-gc have deleted the file-media-object usage,
;; lets execute the touched-gc task, we should see that two of
;; them are marked to be deleted.
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))
@@ -665,7 +665,7 @@
;; because of the deduplication (we have uploaded 2 times the
;; same files).
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 1 (:freeze res)))
(t/is (= 0 (:delete res))))
@@ -715,7 +715,7 @@
;; Now that objects-gc have deleted the object thumbnail lets
;; execute the touched-gc task
(let [res (th/run-task! "storage-gc-touched" {})]
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
(t/is (= 1 (:freeze res))))
;; check file media objects
@@ -750,7 +750,7 @@
;; Now that file-gc have deleted the object thumbnail lets
;; execute the touched-gc task
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 1 (:delete res))))
;; check file media objects
@@ -922,9 +922,8 @@
(t/is (= 0 (:processed result))))
;; run permanent deletion
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed result)))))
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 3 (:processed result))))
;; query the list of file libraries of a after hard deletion
(let [data {::th/type :get-file-libraries
@@ -1135,7 +1134,7 @@
(th/sleep 300)
;; run the task
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
;; check that object thumbnails are still here
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})]
@@ -1164,7 +1163,7 @@
(t/is (= 2 (count rows))))
;; run the task again
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
;; check that we have all object thumbnails
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})]
@@ -1227,7 +1226,7 @@
(t/is (= 2 (count rows)))))
(t/testing "gc task"
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
(let [rows (th/db-query :file-thumbnail {:file-id (:id file)})]
(t/is (= 2 (count rows)))
@@ -1274,7 +1273,7 @@
;; The FileGC task will schedule an inner taskq
(th/run-pending-tasks!)
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 2 (:freeze res)))
(t/is (= 0 (:delete res))))
@@ -1368,7 +1367,7 @@
;; we ensure that once object-gc is passed and marked two storage
;; objects to delete
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))
@@ -1490,7 +1489,7 @@
(t/is (some? (not-empty (:objects component))))))
;; Re-run the file-gc task
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
(let [row (th/db-get :file {:id (:id file)})]
(t/is (true? (:has-media-trimmed row))))
@@ -1520,7 +1519,7 @@
;; Now, we have deleted the usage of component if we pass file-gc,
;; that component should be deleted
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
;; Check that component is properly removed
(let [data {::th/type :get-file
@@ -1611,8 +1610,8 @@
:component-id c-id})}])
;; Run the file-gc on file and library
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-1)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-2)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-2)})))
;; Check that component exists
(let [data {::th/type :get-file
@@ -1685,7 +1684,7 @@
;; Now, we have deleted the usage of component if we pass file-gc,
;; that component should be deleted
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-1)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
;; Check that component is properly removed
(let [data {::th/type :get-file
@@ -1834,8 +1833,8 @@
(t/is (not= (:id fill) (:id fmedia)))))
;; Run the file-gc on file and library
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-1)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-2)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-2)})))
;; Now proceed to delete file and absorb it
(let [data {::th/type :delete-file
@@ -1926,7 +1925,7 @@
(let [row (th/db-exec-one! ["select * from file where id = ?" file-id])]
(t/is (= (:deleted-at row) now)))))))
(t/deftest restore-deleted-files
(t/deftest deleted-files-restore
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
@@ -1989,78 +1988,3 @@
(let [row (th/db-exec-one! ["select * from file where id = ?" file-id])]
(t/is (nil? (:deleted-at row)))))))
(t/deftest restore-deleted-files-and-projets
(let [profile (th/create-profile* 1 {:is-active true})
team-id (:default-team-id profile)
now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (clock/fixed now)]
(let [project (th/create-project* 1 {:profile-id (:id profile)
:team-id team-id})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
data {::th/type :delete-project
:id (:id project)
::rpc/profile-id (:id profile)}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
(th/run-pending-tasks!)
;; get deleted files
(let [data {::th/type :get-team-deleted-files
::rpc/profile-id (:id profile)
:team-id team-id}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [[row1 :as result] (:result out)]
(t/is (= 1 (count result)))
(t/is (= (:will-be-deleted-at row1) #penpot/inst "2025-11-07T00:00:00Z"))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (= (:modified-at row1) #penpot/inst "2025-10-31T00:00:00Z"))))
;; Check if project is deleted
(let [[row1 :as rows] (th/db-query :project {:id (:id project)})]
;; (pp/pprint rows)
(t/is (= 1 (count rows)))
(t/is (= (:deleted-at row1) #penpot/inst "2025-11-07T00:00:00Z"))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (= (:modified-at row1) #penpot/inst "2025-10-31T00:00:00Z")))
;; Restore files
(let [data {::th/type :restore-deleted-team-files
::rpc/profile-id (:id profile)
:team-id team-id
:ids #{(:id file)}}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (fn? result))
(let [events (th/consume-sse result)]
;; (pp/pprint events)
(t/is (= 2 (count events)))
(t/is (= :end (first (last events))))
(t/is (= (:ids data) (last (last events)))))))
(let [[row1 :as rows] (th/db-query :file {:project-id (:id project)})]
;; (pp/pprint rows)
(t/is (= 1 (count rows)))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (nil? (:deleted-at row1))))
;; Check if project is restored
(let [[row1 :as rows] (th/db-query :project {:id (:id project)})]
;; (pp/pprint rows)
(t/is (= 1 (count rows)))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (nil? (:deleted-at row1))))))))

View File

@@ -8,14 +8,12 @@
(:require
[app.common.pprint :as pp]
[app.common.thumbnails :as thc]
[app.common.time :as ct]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.auth :as cauth]
[app.setup.clock :as clock]
[app.storage :as sto]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
@@ -85,8 +83,7 @@
(t/is (map? (:result out))))
;; run the task again
(let [res (binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 31}))]
(th/run-task! "storage-gc-touched" {}))]
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
(t/is (= 2 (:freeze res))))
(let [[row1 row2 :as rows] (th/db-query :file-tagged-object-thumbnail
@@ -117,9 +114,9 @@
;; Run the File GC task that should remove unused file object
;; thumbnails
(th/run-task! :file-gc {:file-id (:id file)})
(th/run-task! :file-gc {:min-age 0 :file-id (:id file)})
(let [result (th/run-task! :objects-gc {})]
(let [result (th/run-task! :objects-gc {:min-age 0})]
(t/is (= 3 (:processed result))))
;; check if row2 related thumbnail row still exists
@@ -136,8 +133,7 @@
(t/is (some? (sto/get-object storage (:media-id row2))))
;; run the task again
(let [res (binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 31}))]
(th/run-task! :storage-gc-touched {}))]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 1 (:delete res)))
(t/is (= 0 (:freeze res))))
@@ -147,9 +143,8 @@
;; Run the storage gc deleted task, it should permanently delete
;; all storage objects related to the deleted thumbnails
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res)))))
(let [result (th/run-task! :storage-gc-deleted {:min-age 0})]
(t/is (= 1 (:deleted result))))
(t/is (nil? (sto/get-object storage (:media-id row1))))
(t/is (some? (sto/get-object storage (:media-id row2))))
@@ -221,9 +216,9 @@
;; Run the File GC task that should remove unused file object
;; thumbnails
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
(let [result (th/run-task! :objects-gc {})]
(let [result (th/run-task! :objects-gc {:min-age 0})]
(t/is (= 2 (:processed result))))
;; check if row1 related thumbnail row still exists
@@ -235,7 +230,7 @@
(t/is (= (:object-id data1) (:object-id row)))
(t/is (uuid? (:media-id row1))))
(let [result (th/run-task! :storage-gc-touched {})]
(let [result (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 1 (:delete result))))
;; Check if storage objects still exists after file-gc
@@ -247,9 +242,8 @@
;; Run the storage gc deleted task, it should permanently delete
;; all storage objects related to the deleted thumbnails
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [result (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted result)))))
(let [result (th/run-task! :storage-gc-deleted {:min-age 0})]
(t/is (= 1 (:deleted result))))
(t/is (some? (sto/get-object storage (:media-id row2)))))))

View File

@@ -6,13 +6,11 @@
(ns backend-tests.rpc-font-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
@@ -131,7 +129,7 @@
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
@@ -143,17 +141,16 @@
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res))))
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 2 (:processed res))))
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 6 (:delete res)))))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 6 (:delete res))))))
(t/deftest font-deletion-2
(let [prof (th/create-profile* 1 {:is-active true})
@@ -192,7 +189,7 @@
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
@@ -204,17 +201,16 @@
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res))))
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 1 (:processed res))))
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res))))))
(t/deftest font-deletion-3
(let [prof (th/create-profile* 1 {:is-active true})
@@ -252,7 +248,7 @@
(t/is (nil? (:error out1)))
(t/is (nil? (:error out2)))
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font-variant
@@ -264,14 +260,13 @@
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res))))
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 1 (:processed res))))
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res))))))

View File

@@ -6,13 +6,11 @@
(ns backend-tests.rpc-project-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[backend-tests.helpers :as th]
[clojure.test :as t]))
@@ -228,9 +226,8 @@
(t/is (= 0 (count result)))))
;; run permanent deletion
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed result)))))
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 1 (:processed result))))
;; query the list of files of a after hard deletion
(let [data {::th/type :get-project-files

View File

@@ -13,7 +13,6 @@
[app.db :as db]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
@@ -526,9 +525,8 @@
(t/is (= :not-found (:type edata)))))
;; run permanent deletion
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed result)))))
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 2 (:processed result))))
;; query the list of projects of a after hard deletion
(let [data {::th/type :get-projects
@@ -583,9 +581,8 @@
(t/is (= 1 (count rows)))
(t/is (ct/inst? (:deleted-at (first rows)))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})]
(t/is (= 7 (:processed result)))))))
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 7 (:processed result))))))
(t/deftest create-team-access-request
(with-mocks [mock {:target 'app.email/send! :return nil}]

View File

@@ -11,7 +11,6 @@
[app.common.uuid :as uuid]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
@@ -54,13 +53,19 @@
(configure-storage-backend))
content (sto/content "content")
object (sto/put-object! storage {::sto/content content
::sto/expired-at (ct/in-future {:hours 1})
::sto/expired-at (ct/in-future {:seconds 1})
:content-type "text/plain"})]
(t/is (sto/object? object))
(t/is (ct/inst? (:expired-at object)))
(t/is (ct/is-after? (:expired-at object) (ct/now)))
(t/is (nil? (sto/get-object storage (:id object))))))
(t/is (= object (sto/get-object storage (:id object))))
(th/sleep 1000)
(t/is (nil? (sto/get-object storage (:id object))))
(t/is (nil? (sto/get-object-data storage object)))
(t/is (nil? (sto/get-object-url storage object)))
(t/is (nil? (sto/get-object-path storage object)))))
(t/deftest put-and-delete-object
(let [storage (-> (:app.storage/storage th/*system*)
@@ -93,25 +98,20 @@
::sto/expired-at (ct/now)
:content-type "text/plain"})
object2 (sto/put-object! storage {::sto/content content2
::sto/expired-at (ct/in-future {:hours 2})
::sto/expired-at (ct/in-past {:hours 2})
:content-type "text/plain"})
object3 (sto/put-object! storage {::sto/content content3
::sto/expired-at (ct/in-future {:hours 1})
::sto/expired-at (ct/in-past {:hours 1})
:content-type "text/plain"})]
(binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 0}))]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res)))))
(th/sleep 200)
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res))))
(let [res (th/db-exec-one! ["select count(*) from storage_object;"])]
(t/is (= 2 (:count res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 61}))]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res)))))
(let [res (th/db-exec-one! ["select count(*) from storage_object;"])]
(t/is (= 1 (:count res))))))
(t/is (= 2 (:count res))))))
(t/deftest touched-gc-task-1
(let [storage (-> (:app.storage/storage th/*system*)
@@ -158,7 +158,7 @@
{:id (:id result-1)})
;; run the objects gc task for permanent deletion
(let [res (th/run-task! :objects-gc {})]
(let [res (th/run-task! :objects-gc {:min-age 0})]
(t/is (= 1 (:processed res))))
;; check that we still have all the storage objects
@@ -182,6 +182,7 @@
(let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])]
(t/is (= 0 (:count res)))))))
(t/deftest touched-gc-task-2
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
@@ -242,12 +243,11 @@
{:id (:id result-2)})
;; run the objects gc task for permanent deletion
(let [res (th/run-task! :objects-gc {})]
(let [res (th/run-task! :objects-gc {:min-age 0})]
(t/is (= 1 (:processed res))))
;; revert touched state to all storage objects
(th/db-exec-one! ["update storage_object set touched_at=?" (ct/now)])
(th/db-exec-one! ["update storage_object set touched_at=now()"])
;; Run the task again
(let [res (th/run-task! :storage-gc-touched {})]
@@ -293,10 +293,10 @@
result-2 (:result out2)]
;; now we proceed to manually mark all storage objects touched
(th/db-exec! ["update storage_object set touched_at=?" (ct/now)])
(th/db-exec! ["update storage_object set touched_at=now()"])
;; run the touched gc task
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
(t/is (= 2 (:freeze res)))
(t/is (= 0 (:delete res))))
@@ -305,13 +305,13 @@
(t/is (= 2 (count rows)))))
;; now we proceed to manually delete all file_media_object
(th/db-exec! ["update file_media_object set deleted_at = ?" (ct/now)])
(th/db-exec! ["update file_media_object set deleted_at = now()"])
(let [res (th/run-task! :objects-gc {})]
(let [res (th/run-task! "objects-gc" {:min-age 0})]
(t/is (= 2 (:processed res))))
;; run the touched gc task
(let [res (th/run-task! :storage-gc-touched {})]
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))

View File

@@ -53,7 +53,6 @@
"plugins/runtime"
"tokens/numeric-input"
"design-tokens/v1"
"text-editor/v2-html-paste"
"text-editor/v2"
"render-wasm/v1"
"variants/v1"})
@@ -76,7 +75,6 @@
(def frontend-only-features
#{"styles/v2"
"plugins/runtime"
"text-editor/v2-html-paste"
"text-editor/v2"
"tokens/numeric-input"
"render-wasm/v1"})
@@ -126,7 +124,6 @@
:feature-plugins "plugins/runtime"
:feature-design-tokens "design-tokens/v1"
:feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"

View File

@@ -371,7 +371,7 @@
[:set-tokens-lib
[:map {:title "SetTokensLib"}
[:type [:= :set-tokens-lib]]
[:tokens-lib [:maybe ctob/schema:tokens-lib]]]]
[:tokens-lib ctob/schema:tokens-lib]]]
[:set-token
[:map {:title "SetTokenChange"}
@@ -463,16 +463,35 @@
;; Changes Processing Impl
#_:clj-kondo/ignore
(defn- validate-shape
[{:keys [id] :as shape} page-id]
(when-not (cts/valid-shape? shape)
(ex/raise :type :assertion
:code :data-validation
:hint (str "invalid shape found '" id "'")
:page-id page-id
:shape-id id
::sm/explain (cts/explain-shape shape))))
(defn validate-shapes!
[data-old data-new items]
(letfn [(validate-shape! [[page-id id]]
(let [shape-old (dm/get-in data-old [:pages-index page-id :objects id])
shape-new (dm/get-in data-new [:pages-index page-id :objects id])]
;; If object has changed or is new verify is correct
(when (and (some? shape-new)
(not= shape-old shape-new))
(when-not (and (cts/valid-shape? shape-new)
(cts/shape? shape-new))
(ex/raise :type :assertion
:code :data-validation
:hint (str "invalid shape found after applying changes on file "
(:id data-new))
:file-id (:id data-new)
::sm/explain (cts/explain-shape shape-new))))))]
(->> (into #{} (map :page-id) items)
(mapcat (fn [page-id]
(filter #(= page-id (:page-id %)) items)))
(mapcat (fn [{:keys [type id page-id] :as item}]
(sequence
(map (partial vector page-id))
(case type
(:add-obj :mod-obj :del-obj) (cons id nil)
(:mov-objects :reg-objects) (:shapes item)
nil))))
(run! validate-shape!))))
(defn- process-touched-change
[data {:keys [id page-id component-id]}]
@@ -499,8 +518,14 @@
(check-changes items))
(binding [*touched-changes* (volatile! #{})]
(let [result (reduce #(or (process-change %1 %2) %1) data items)]
(reduce process-touched-change result @*touched-changes*)))))
(let [result (reduce #(or (process-change %1 %2) %1) data items)
result (reduce process-touched-change result @*touched-changes*)]
;; Validate result shapes (only on the backend)
;;
;; TODO: (PERF) add changed shapes tracking and only validate
;; the tracked changes instead of iterate over all shapes
#?(:clj (validate-shapes! data result items))
result))))
;; --- Comment Threads
@@ -588,10 +613,9 @@
(defmethod process-change :add-obj
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]}]
;; NOTE: we only perform hard validation on backend
#?(:clj (validate-shape obj page-id))
(let [update-container #(ctst/add-shape id obj % frame-id parent-id index ignore-touched)]
(let [update-container
(fn [container]
(ctst/add-shape id obj container frame-id parent-id index ignore-touched))]
(when *state*
(swap! *state* collect-shape-media-refs obj page-id))
@@ -614,9 +638,6 @@
(when (and *state* page-id)
(swap! *state* collect-shape-media-refs shape page-id))
;; NOTE: we only perform hard validation on backend
#?(:clj (validate-shape shape page-id))
(assoc objects id shape))
objects))
@@ -671,6 +692,8 @@
(d/update-in-when data [:pages-index page-id] fix-container)
(d/update-in-when data [:components component-id] fix-container))))
;; FIXME: remove, seems like this method is already unused
;; reg-objects operation "regenerates" the geometry and selrect of the parent groups
(defmethod process-change :reg-objects
[data {:keys [page-id component-id shapes]}]
;; FIXME: Improve performance
@@ -699,60 +722,48 @@
(update-group [group objects]
(let [lookup (d/getf objects)
children (get group :shapes)
group (cond
;; If the group is empty we don't make any changes. Will be removed by a later process
(empty? children)
group
children (get group :shapes)]
(cond
;; If the group is empty we don't make any changes. Will be removed by a later process
(empty? children)
group
(= :bool (:type group))
(path/update-bool-shape group objects)
(= :bool (:type group))
(path/update-bool-shape group objects)
(:masked-group group)
(->> (map lookup children)
(set-mask-selrect group))
(:masked-group group)
(->> (map lookup children)
(set-mask-selrect group))
:else
(->> (map lookup children)
(gsh/update-group-selrect group)))]
#?(:clj (validate-shape group page-id))
group))]
:else
(->> (map lookup children)
(gsh/update-group-selrect group)))))]
(if page-id
(d/update-in-when data [:pages-index page-id :objects] reg-objects)
(d/update-in-when data [:components component-id :objects] reg-objects))))
(defmethod process-change :mov-objects
;; FIXME: ignore-touched is no longer used, so we can consider it deprecated
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]}]
[data {:keys [parent-id shapes index page-id component-id ignore-touched after-shape allow-altering-copies syncing]}]
(letfn [(calculate-invalid-targets [objects shape-id]
(let [reduce-fn #(into %1 (calculate-invalid-targets objects %2))]
(->> (get-in objects [shape-id :shapes])
(reduce reduce-fn #{shape-id}))))
;; Avoid placing a shape as a direct or indirect child of itself, or
;; inside its main component if it's in a copy, or inside a copy, or
;; from a copy
;; Avoid placing a shape as a direct or indirect child of itself,
;; or inside its main component if it's in a copy,
;; or inside a copy, or from a copy
(is-valid-move? [objects shape-id]
(let [invalid-targets (calculate-invalid-targets objects shape-id)
shape (get objects shape-id)]
(and shape
(not (invalid-targets parent-id))
(not (cfh/components-nesting-loop? objects shape-id parent-id))
(or
;; In some cases (like a component
;; swap) it's allowed to change the
;; structure of a copy
allow-altering-copies
;; DEPRECATED, remove once v2.12 released
syncing
(and
;; We don't want to change the structure of component copies
(not (ctk/in-component-copy? (get objects (:parent-id shape))))
;; We need to check the origin and target frames
(not (ctk/in-component-copy? (get objects parent-id))))))))
(or allow-altering-copies ;; In some cases (like a component swap) it's allowed to change the structure of a copy
syncing ;; If we are syncing the changes of a main component, it's allowed to change the structure of a copy
(and
(not (ctk/in-component-copy? (get objects (:parent-id shape)))) ;; We don't want to change the structure of component copies
(not (ctk/in-component-copy? (get objects parent-id)))))))) ;; We need to check the origin and target frames
(insert-items [prev-shapes index shapes]
(let [prev-shapes (or prev-shapes [])]
@@ -761,13 +772,17 @@
(cfh/append-at-the-end prev-shapes shapes))))
(add-to-parent [parent index shapes]
(update parent :shapes
(fn [parent-shapes]
(-> parent-shapes
(insert-items index shapes)
;; We need to ensure that no `nil` in the shapes list
;; after adding all the incoming shapes to the parent.
(d/vec-without-nils)))))
(let [parent (-> parent
(update :shapes insert-items index shapes)
;; We need to ensure that no `nil` in the
;; shapes list after adding all the
;; incoming shapes to the parent.
(update :shapes d/vec-without-nils))]
(cond-> parent
(and (:shape-ref parent)
(#{:group :frame} (:type parent))
(not ignore-touched))
(dissoc :remote-synced))))
(remove-from-old-parent [old-objects objects shape-id]
(let [prev-parent-id (dm/get-in old-objects [shape-id :parent-id])]
@@ -775,63 +790,58 @@
;; the new destination target parent id.
(if (= prev-parent-id parent-id)
objects
(d/update-in-when objects [prev-parent-id :shapes]
(fn [shapes]
(-> shapes
(d/without-obj shape-id)
(d/vec-without-nils)))))))
(let [sid shape-id
pid prev-parent-id
obj (get objects pid)
component? (and (:shape-ref obj)
(= (:type obj) :group)
(not ignore-touched))]
(-> objects
(d/update-in-when [pid :shapes] d/without-obj sid)
(d/update-in-when [pid :shapes] d/vec-without-nils)
(cond-> component? (d/update-when pid #(dissoc % :remote-synced))))))))
(update-parent-id [objects id]
(d/update-when objects id assoc :parent-id parent-id))
(-> objects
(d/update-when id assoc :parent-id parent-id)))
;; Updates the frame-id references that might be outdated
(update-frame-id [frame-id objects id]
(let [obj (some-> (get objects id)
(assoc :frame-id frame-id))]
(assign-frame-id [frame-id objects id]
(let [objects (d/update-when objects id assoc :frame-id frame-id)
obj (get objects id)]
(cond-> objects
(some? obj)
(assoc id obj)
;; If we moving a frame, we DO NOT NEED update
;; children because the children will point correctly
;; to the frame what we are currently moving
(not (cfh/frame-shape? obj))
(as-> $$ (reduce (partial update-frame-id frame-id) $$ (:shapes obj))))))
(validate-shape [objects #_:clj-kondo/ignore shape-id]
#?(:clj (when-let [shape (get objects shape-id)]
(validate-shape shape page-id)))
objects)
;; If we moving frame, the parent frame is the root
;; and we DO NOT NEED update children because the
;; children will point correctly to the frame what we
;; are currently moving
(not= :frame (:type obj))
(as-> $$ (reduce (partial assign-frame-id frame-id) $$ (:shapes obj))))))
(move-objects [objects]
(let [parent (get objects parent-id)]
;; Do not proceed with the move if parent does not
;; exists; this can happen on a race condition when an
;; inflight move operations lands when parent is deleted
(if (and (seq shapes) (every? (partial is-valid-move? objects) shapes) parent)
(let [index (or (some-> (d/index-of (:shapes parent) after-shape) inc) index)
frame-id (if (cfh/frame-shape? parent)
(:id parent)
(:frame-id parent))]
(as-> objects $
;; Add the new shapes to the parent object.
(d/update-when $ parent-id #(add-to-parent % index shapes))
(let [valid? (every? (partial is-valid-move? objects) shapes)
parent (get objects parent-id)
after-shape-index (d/index-of (:shapes parent) after-shape)
index (if (nil? after-shape-index) index (inc after-shape-index))
frame-id (if (= :frame (:type parent))
(:id parent)
(:frame-id parent))]
;; Update each individual shape link to the new parent
(reduce update-parent-id $ shapes)
(if (and valid? (seq shapes))
(as-> objects $
;; Add the new shapes to the parent object.
(d/update-when $ parent-id #(add-to-parent % index shapes))
;; Analyze the old parents and clear the old links
;; only if the new parent is different form old
;; parent.
(reduce (partial remove-from-old-parent objects) $ shapes)
;; Update each individual shape link to the new parent
(reduce update-parent-id $ shapes)
;; Ensure that all shapes of the new parent has a
;; correct link to the topside frame.
(reduce (partial update-frame-id frame-id) $ shapes)
;; Perform validation of the affected shapes
(reduce validate-shape $ shapes)))
;; Analyze the old parents and clear the old links
;; only if the new parent is different form old
;; parent.
(reduce (partial remove-from-old-parent objects) $ shapes)
;; Ensure that all shapes of the new parent has a
;; correct link to the topside frame.
(reduce (partial assign-frame-id frame-id) $ shapes))
objects)))]
(if page-id

View File

@@ -120,8 +120,11 @@
:terms-and-privacy-checkbox
;; Only for developtment.
:tiered-file-data-storage
:token-units
:token-base-font-size
:token-color
:token-typography-types
:token-typography-composite
:token-shadow
:transit-readable-response
:user-feedback
@@ -129,6 +132,7 @@
:v2-migration
:webhooks
;; TODO: deprecate this flag and consolidate the code
:export-file-v3
:render-wasm-dpr
:hide-release-modal
:subscriptions
@@ -168,8 +172,9 @@
:enable-google-fonts-provider
:enable-component-thumbnails
:enable-render-wasm-dpr
:enable-token-color
:enable-inspect-styles
:enable-token-units
:enable-token-typography-types
:enable-token-typography-composite
:enable-feature-fdata-objects-map])
(defn parse

View File

@@ -1512,7 +1512,7 @@
:shapes [(:id shape)]
:index index-after
:ignore-touched true
:allow-altering-copies true}))
:syncing true}))
(update :undo-changes conj (make-change
container
{:type :mov-objects
@@ -1520,7 +1520,7 @@
:shapes [(:id shape)]
:index index-before
:ignore-touched true
:allow-altering-copies true})))]
:syncing true})))]
(if (and (ctk/touched-group? parent :shapes-group) omit-touched?)
changes

View File

@@ -1082,35 +1082,33 @@
detach-shape
(fn [objects shape]
(let [shape' (cond-> shape
(not= file-id (:fill-color-ref-file shape))
(dissoc :fill-color-ref-id :fill-color-ref-file)
(l/debug :hint "detach-shape"
:file-id file-id
:component-ref-file (get-component-ref-file objects shape)
::l/sync? true)
(cond-> shape
(not= file-id (:fill-color-ref-file shape))
(dissoc :fill-color-ref-id :fill-color-ref-file)
(not= file-id (:stroke-color-ref-file shape))
(dissoc :stroke-color-ref-id :stroke-color-ref-file)
(not= file-id (:stroke-color-ref-file shape))
(dissoc :stroke-color-ref-id :stroke-color-ref-file)
(not= file-id (get-component-ref-file objects shape))
(dissoc :component-id :component-file :shape-ref :component-root)
(not= file-id (get-component-ref-file objects shape))
(dissoc :component-id :component-file :shape-ref :component-root)
(= :text (:type shape))
(update :content detach-text))]
(when (not= shape shape')
(l/dbg :hint "detach shape"
:file-id (str file-id)
:shape-id (str (:id shape))))
shape'))
(= :text (:type shape))
(update :content detach-text)))
detach-objects
(fn [objects]
(d/update-vals objects #(detach-shape objects %)))
(update-vals objects #(detach-shape objects %)))
detach-pages
(fn [pages-index]
(d/update-vals pages-index #(update % :objects detach-objects)))]
(update-vals pages-index #(update % :objects detach-objects)))]
(update-in file [:data :pages-index] detach-pages)))
(-> file
(update-in [:data :pages-index] detach-pages))))
;; Base font size

View File

@@ -11,7 +11,6 @@
[app.common.exceptions :as ex]
[app.common.flags :as flags]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.types.color :as types.color]
[app.common.types.fills.impl :as impl]
[clojure.core :as c]
@@ -50,19 +49,12 @@
(= 1 (count result))))
(def schema:fill
[:and schema:fill-attrs [:fn has-valid-fill-attrs?]])
[:and schema:fill-attrs
[:fn has-valid-fill-attrs?]])
(def check-fill
(sm/check-fn schema:fill))
(def ^:private schema:fills-as-vector
[:vector {:gen/max 2} schema:fill])
(def schema:fills
[:or {:gen/gen (sg/generator schema:fills-as-vector)}
schema:fills-as-vector
[:fn impl/fills?]])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CONSTRUCTORS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -19,7 +19,7 @@
[app.common.schema.generators :as sg]
[app.common.transit :as t]
[app.common.types.color :as clr]
[app.common.types.fills :refer [schema:fills fill->color]]
[app.common.types.fills :refer [schema:fill fill->color]]
[app.common.types.grid :as ctg]
[app.common.types.path :as path]
[app.common.types.plugins :as ctpg]
@@ -192,7 +192,8 @@
[:locked {:optional true} :boolean]
[:hidden {:optional true} :boolean]
[:masked-group {:optional true} :boolean]
[:fills {:optional true} schema:fills]
[:fills {:optional true}
[:vector {:gen/max 2} schema:fill]]
[:proportion {:optional true} ::sm/safe-number]
[:proportion-lock {:optional true} :boolean]
[:constraints-h {:optional true}

View File

@@ -7,7 +7,7 @@
(ns app.common.types.shape.text
(:require
[app.common.schema :as sm]
[app.common.types.fills :refer [schema:fills]]))
[app.common.types.fills :refer [schema:fill]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA
@@ -32,7 +32,8 @@
[:type [:= "paragraph"]]
[:key {:optional true} :string]
[:fills {:optional true}
[:maybe schema:fills]]
[:maybe
[:vector {:gen/max 2} schema:fill]]]
[:font-family {:optional true} ::sm/text]
[:font-size {:optional true} ::sm/text]
[:font-style {:optional true} ::sm/text]
@@ -48,7 +49,8 @@
[:text :string]
[:key {:optional true} :string]
[:fills {:optional true}
[:maybe schema:fills]]
[:maybe
[:vector {:gen/max 2} schema:fill]]]
[:font-family {:optional true} ::sm/text]
[:font-size {:optional true} ::sm/text]
[:font-style {:optional true} ::sm/text]
@@ -69,7 +71,7 @@
[:y ::sm/safe-number]
[:width ::sm/safe-number]
[:height ::sm/safe-number]
[:fills schema:fills]
[:fills [:vector {:gen/max 2} schema:fill]]
[:font-family {:optional true} ::sm/text]
[:font-size {:optional true} ::sm/text]
[:font-style {:optional true} ::sm/text]

View File

@@ -90,10 +90,6 @@
[{:fill-color clr/black
:fill-opacity 1}])
(def default-paragraph-attrs
{:text-align "left"
:text-direction "ltr"})
(def default-text-attrs
{:font-id "sourcesanspro"
:font-family "sourcesanspro"

View File

@@ -266,6 +266,10 @@
typography-token-keys
#{:line-height}))
;; TODO: Created to extract the font-size feature from the typography feature flag.
;; Delete this once the typography feature flag is removed.
(def ff-typography-keys (set/difference typography-keys font-size-keys))
(def ^:private schema:number
(-> (reduce mu/union [[:map [:line-height {:optional true} token-name-ref]]
schema:rotation])

View File

@@ -1679,7 +1679,7 @@ Will return a value that matches this schema:
["id" {:optional true} :string]
["name" :string]
["description" :string]
["isSource" {:optional true} :boolean]
["isSource" :boolean]
["selectedTokenSets"
[:map-of :string [:enum "enabled" "disabled"]]]]]]
["$metadata" {:optional true}
@@ -1794,19 +1794,17 @@ Will return a value that matches this schema:
data (without any case transformation). Used as schema decoder and
in the SDK."
[data]
(if (instance? TokensLib data)
data
(let [data (if (string? data)
(json/decode data :key-fn identity)
data)
data #?(:cljs (if (object? data)
(json/->clj data :key-fn identity)
data)
:clj data)
(let [data (if (string? data)
(json/decode data :key-fn identity)
data)
data #?(:cljs (if (object? data)
(json/->clj data :key-fn identity)
data)
:clj data)
data (decode-multi-set-dtcg-data data)]
(-> (check-multi-set-dtcg-data data)
(parse-multi-set-dtcg-json)))))
data (decode-multi-set-dtcg-data data)]
(-> (check-multi-set-dtcg-data data)
(parse-multi-set-dtcg-json))))
(defn- parse-multi-set-legacy-json
"Parse a decoded json file with multi sets in legacy format into a TokensLib."

View File

@@ -311,22 +311,16 @@
[variant]
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
(def ^:private boolean-pairs
[["on" "off"]
["yes" "no"]
["true" "false"]])
(defn find-boolean-pair
"Given a vector, return a map that contains the boolean equivalency if the values match
with any of the boolean pairs. Returns nil if none match."
[[a b :as v]]
(let [a' (-> a str/trim str/lower)
b' (-> b str/trim str/lower)]
"Given a vector, return the map from 'bool-values' that contains both as keys.
Returns nil if none match."
[v]
(let [bool-values [{"on" true "off" false}
{"yes" true "no" false}
{"true" true "false" false}]]
(when (= (count v) 2)
(some (fn [[t f]]
(cond (and (= a' t)
(= b' f)) {a true b false}
(and (= b' t)
(= a' f)) {b true a false}
:else nil))
boolean-pairs))))
(some (fn [b]
(when (and (contains? b (first v))
(contains? b (last v)))
b))
bool-values))))

View File

@@ -163,11 +163,9 @@
(t/deftest find-boolean-pair
(t/testing "find-boolean-pair"
(t/is (= (ctv/find-boolean-pair ["off" "on"]) {"on" true "off" false}))
(t/is (= (ctv/find-boolean-pair ["OfF" "oN"]) {"oN" true "OfF" false}))
(t/is (= (ctv/find-boolean-pair [" ofF" "oN "]) {"oN " true " ofF" false}))
(t/is (= (ctv/find-boolean-pair ["on" "off"]) {"on" true "off" false}))
(t/is (= (ctv/find-boolean-pair ["yes" "no"]) {"yes" true "no" false}))
(t/is (= (ctv/find-boolean-pair ["false" "true"]) {"true" true "false" false}))
(t/is (= (ctv/find-boolean-pair ["off" "on"]) {"on" true "off" false}))
(t/is (= (ctv/find-boolean-pair ["on" "off"]) {"on" true "off" false}))
(t/is (= (ctv/find-boolean-pair ["off" "on" "other"]) nil))
(t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil))))
(t/is (= (ctv/find-boolean-pair ["yes" "no"]) {"yes" true "no" false}))
(t/is (= (ctv/find-boolean-pair ["false" "true"]) {"true" true "false" false}))
(t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil))))

View File

@@ -82,11 +82,6 @@ services:
- 9000:9000
- 9001:9001
networks:
default:
aliases:
- minio
postgres:
image: postgres:16.8
command: postgres -c config_file=/etc/postgresql.conf
@@ -115,11 +110,6 @@ services:
volumes:
- "valkey_data:/data"
networks:
default:
aliases:
- redis
mailer:
image: sj26/mailcatcher:latest
restart: always
@@ -128,12 +118,6 @@ services:
ports:
- "1080:1080"
networks:
default:
aliases:
- mailer
# https://github.com/rroemhild/docker-test-openldap
ldap:
image: rroemhild/test-openldap:2.1
@@ -147,9 +131,3 @@ services:
nofile:
soft: 1024
hard: 1024
networks:
default:
aliases:
- ldap

View File

@@ -141,10 +141,6 @@ http {
proxy_pass http://127.0.0.1:5000;
}
location /nitrate/ {
proxy_pass http://127.0.0.1:3000/;
}
location /playground {
alias /home/penpot/penpot/experiments/;
add_header Cache-Control "no-cache, max-age=0";

View File

@@ -19,7 +19,7 @@
##
## You can read more about all available flags and other
## environment variables here:
## https://help.penpot.app/technical-guide/configuration/#penpot-configuration
## https://help.penpot.app/technical-guide/configuration/#advanced-configuration
#
# WARNING: if you're exposing Penpot to the internet, you should remove the flags
# 'disable-secure-session-cookies' and 'disable-email-verification'
@@ -37,15 +37,6 @@ x-body-size: &penpot-http-body-size
# Max multipart body size (350MiB)
PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems
## (eg http sessions, or invitations) are derived.
##
## We recommend to use a trully randomly generated
## 512 bits base64 encoded string here. You can generate one with:
##
## python3 -c "import secrets; print(secrets.token_urlsafe(64))"
x-secret-key: &penpot-secret-key
PENPOT_SECRET_KEY: change-this-insecure-key
networks:
penpot:
@@ -54,6 +45,7 @@ volumes:
penpot_postgres_v15:
penpot_assets:
# penpot_traefik:
# penpot_minio:
services:
## Traefik service declaration example. Consider using it if you are going to expose
@@ -128,7 +120,20 @@ services:
## Configuration envronment variables for the backend container.
environment:
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key]
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size]
## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems
## (eg http sessions, or invitations) are derived.
##
## If you leave it commented, all created sessions and invitations will
## become invalid on container restart.
##
## If you going to uncomment this, we recommend to use a trully randomly generated
## 512 bits base64 encoded string here. You can generate one with:
##
## python3 -c "import secrets; print(secrets.token_urlsafe(64))"
# PENPOT_SECRET_KEY: my-insecure-key
## The PREPL host. Mainly used for external programatic access to penpot backend
## (example: admin). By default it will listen on `localhost` but if you are going to use
@@ -154,12 +159,13 @@ services:
PENPOT_ASSETS_STORAGE_BACKEND: assets-fs
PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets
## Also can be configured to to use a S3 compatible storage.
## Also can be configured to to use a S3 compatible storage
## service like MiniIO. Look below for minio service setup.
# AWS_ACCESS_KEY_ID: <KEY_ID>
# AWS_SECRET_ACCESS_KEY: <ACCESS_KEY>
# PENPOT_ASSETS_STORAGE_BACKEND: assets-s3
# PENPOT_STORAGE_ASSETS_S3_ENDPOINT: <ENDPOINT>
# PENPOT_STORAGE_ASSETS_S3_ENDPOINT: http://penpot-minio:9000
# PENPOT_STORAGE_ASSETS_S3_BUCKET: <BUKET_NAME>
## Telemetry. When enabled, a periodical process will send anonymous data about this
@@ -196,7 +202,6 @@ services:
- penpot
environment:
<< : [*penpot-secret-key]
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_PUBLIC_URI: http://penpot-frontend:8080
@@ -260,3 +265,22 @@ services:
- "1080:1080"
networks:
- penpot
## Example configuration of MiniIO (S3 compatible object storage service); If you don't
## have preference, then just use filesystem, this is here just for the completeness.
# minio:
# image: "minio/minio:latest"
# command: minio server /mnt/data --console-address ":9001"
# restart: always
#
# volumes:
# - "penpot_minio:/mnt/data"
#
# environment:
# - MINIO_ROOT_USER=minioadmin
# - MINIO_ROOT_PASSWORD=minioadmin
#
# ports:
# - 9000:9000
# - 9001:9001

View File

@@ -120,7 +120,7 @@
</ul>
</div>
<div class="footer-bottom">
<div class="footer-text"><span>Kaleidos © 2025 | Made with LOVE and Open Source</span></div>
<div class="footer-text"><span>Kaleidos © 2024 | Made with LOVE and Open Source</span></div>
<div class="github-widget">
<a class="github-link" href="https://github.com/penpot/penpot" rel="noopener" target="_blank" aria-label="Star penpot/penpot on GitHub">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 58.208 58.208" version="1.1">

View File

@@ -314,7 +314,7 @@ If you're using the official <code class="language-bash">docker-compose.yml</cod
## Email configuration
By default, <code class="language-bash">smtp</code> flag is disabled, the email will be
By default, <code class="language-bash">smpt</code> flag is disabled, the email will be
printed to the console, which means that the emails will be shown in the stdout.
Note that if you plan to invite members to a team, it is recommended that you enable SMTP

View File

@@ -1,6 +1,5 @@
---
title: 3.07. Abstraction levels
desc: "Penpot Technical Guide: organize data and logic in clear abstraction layers—ADTs, file ops, event-sourced changes, business rules, and data events."
---
# Code organization in abstraction levels

View File

@@ -1,6 +1,5 @@
---
title: 3.06. Backend Guide
desc: "Penpot Technical Guide: Backend basics - REPL setup, loading fixtures, database migrations, and clj-kondo linting to speed development workflows."
---
# Backend guide #

View File

@@ -1,6 +1,5 @@
---
title: 1.2 Install with Elestio
desc: "Step-by-step guide to deploy a self-hosted Penpot on Elestio: 3-minute setup, managed DNS/SMTP/SSL/backups, Docker Compose config, updates & support."
---
# Install with Elestio

View File

@@ -1,7 +1,6 @@
---
title: Design Tokens
order: 5
desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG format, with sets, themes, aliases, equations and JSON import/export.
---
<h1 id="design-tokens">Design Tokens</h1>

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
SCRIPT_DIR=$(dirname $0);
source $SCRIPT_DIR/../../backend/scripts/_env;
exec node target/app.js

View File

@@ -1,10 +1,6 @@
#!/usr/bin/env bash
SCRIPT_DIR=$(dirname $0);
source $SCRIPT_DIR/../../backend/scripts/_env;
bb -i '(babashka.wait/wait-for-port "localhost" 9630)';
bb -i '(babashka.wait/wait-for-path "target/app.js")';
sleep 2;
exec node target/app.js
node target/app.js

View File

@@ -7,9 +7,7 @@
(ns app.config
(:refer-clojure :exclude [get])
(:require
["node:buffer" :as buffer]
["node:crypto" :as crypto]
["node:process" :as process]
["process" :as process]
[app.common.data :as d]
[app.common.flags :as flags]
[app.common.schema :as sm]
@@ -23,14 +21,13 @@
:host "localhost"
:http-server-port 6061
:http-server-host "0.0.0.0"
:tempdir "/tmp/penpot"
:tempdir "/tmp/penpot-exporter"
:redis-uri "redis://redis/0"})
(def ^:private schema:config
(def ^:private
schema:config
[:map {:title "config"}
[:secret-key :string]
[:public-uri {:optional true} ::sm/uri]
[:management-api-key {:optional true} :string]
[:host {:optional true} :string]
[:tenant {:optional true} :string]
[:flags {:optional true} [::sm/set :keyword]]
@@ -96,10 +93,3 @@
(c/get config key))
([key default]
(c/get config key default)))
(def management-key
(or (c/get config :management-api-key)
(let [secret-key (c/get config :secret-key)
derived-key (crypto/hkdfSync "blake2b512" secret-key, "management" "" 32)]
(-> (.from buffer/Buffer derived-key)
(.toString "base64url")))))

View File

@@ -12,6 +12,7 @@
[app.common.spec :as us]
[app.handlers.export-frames :as export-frames]
[app.handlers.export-shapes :as export-shapes]
[app.handlers.resources :as resources]
[app.util.transit :as t]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]))
@@ -53,7 +54,7 @@
:else
(let [data {:type :server-error
:code code
:code type
:hint (ex-message error)
:data data}]
(l/error :hint "unexpected internal error" :cause error)
@@ -70,6 +71,7 @@
(defmethod command-spec :export-shapes [_] ::export-shapes/params)
(defmethod command-spec :export-frames [_] ::export-frames/params)
(defmethod command-spec :get-resource [_] (s/keys :req-un [::id]))
(s/def ::params
(s/and (s/keys :req-un [::cmd]
@@ -81,6 +83,7 @@
(let [{:keys [cmd] :as params} (us/conform ::params params)]
(l/debug :hint "process-request" :cmd cmd)
(case cmd
:get-resource (resources/handler exchange)
:export-shapes (export-shapes/handler exchange params)
:export-frames (export-frames/handler exchange params)
(ex/raise :type :internal

View File

@@ -43,78 +43,90 @@
;; datastructure preparation uses it for creating the groups.
(let [exports (-> (map #(assoc % :type :pdf :scale 1 :suffix "") exports)
(prepare-exports auth-token))]
(handle-export exchange (assoc params :exports exports))))
(defn handle-export
[{:keys [:request/auth-token] :as exchange} {:keys [exports name profile-id] :as params}]
(let [topic (str profile-id)
file-id (-> exports first :file-id)
[exchange {:keys [exports wait name profile-id] :as params}]
(let [total (count exports)
topic (str profile-id)
resource (rsc/create :pdf (or name (-> exports first :name)))
resource
(rsc/create :pdf (or name (-> exports first :name)))
on-progress (fn [{:keys [done]}]
(when-not wait
(let [data {:type :export-update
:resource-id (:id resource)
:name (:name resource)
:filename (:filename resource)
:status "running"
:total total
:done done}]
(redis/pub! topic data))))
on-progress
(fn [done]
(let [data {:type :export-update
:resource-id (:id resource)
:status "running"
:done done}]
(redis/pub! topic data)))
on-complete (fn []
(when-not wait
(let [data {:type :export-update
:resource-id (:id resource)
:name (:name resource)
:filename (:filename resource)
:status "ended"}]
(redis/pub! topic data))))
on-complete
(fn [resource]
(let [data {:type :export-update
:resource-id (:id resource)
:resource-uri (:uri resource)
:name (:name resource)
:filename (:filename resource)
:mtype (:mtype resource)
:status "ended"}]
(redis/pub! topic data)))
on-error (fn [cause]
(l/error :hint "unexpected error on frames exportation" :cause cause)
(if wait
(p/rejected cause)
(let [data {:type :export-update
:resource-id (:id resource)
:name (:name resource)
:filename (:filename resource)
:status "error"
:cause (ex-message cause)}]
(redis/pub! topic data))))
on-error
(fn [cause]
(l/error :hint "unexpected error on frames exportation" :cause cause)
(let [data {:type :export-update
:resource-id (:id resource)
:name (:name resource)
:filename (:filename resource)
:status "error"
:cause (ex-message cause)}]
(redis/pub! topic data)))
proc (create-pdf :resource resource
:exports exports
:on-progress on-progress
:on-complete on-complete
:on-error on-error)]
(if wait
(p/then proc #(assoc exchange :response/body (dissoc % :path)))
(assoc exchange :response/body (dissoc resource :path)))))
result-cache
(atom [])
(defn create-pdf
[& {:keys [resource exports on-progress on-complete on-error]
:or {on-progress (constantly nil)
on-complete (constantly nil)
on-error p/rejected}}]
(let [file-id (-> exports first :file-id)
result (atom [])
on-object
(fn [{:keys [path] :as object}]
(let [res (swap! result-cache conj path)]
(on-progress (count res))))
(let [res (swap! result conj path)]
(on-progress {:done (count res)})))]
procs
(->> (seq exports)
(map #(rd/render % on-object)))]
(-> (p/loop [exports (seq exports)]
(when-let [export (first exports)]
(p/do
(rd/render export on-object)
(p/recur (rest exports)))))
(->> (p/all procs)
(p/fmap (fn [] @result-cache))
(p/mcat (partial join-pdf file-id))
(p/mcat (partial move-file resource))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/mcat (fn [resource]
(->> (sh/stat (:path resource))
(p/fmap #(merge resource %)))))
(p/merr on-error)
(p/fnly (fn [resource cause]
(when-not cause
(on-complete resource)))))
(assoc exchange :response/body (dissoc resource :path))))
(p/then (fn [_] (deref result)))
(p/then (partial join-pdf file-id))
(p/then (partial move-file resource))
(p/then (constantly resource))
(p/then (fn [resource]
(-> (sh/stat (:path resource))
(p/then #(merge resource %)))))
(p/catch on-error)
(p/finally (fn [_ cause]
(when-not cause
(on-complete)))))))
(defn- join-pdf
[file-id paths]
(p/let [prefix (str/concat "penpot.pdfunite." file-id ".")
(p/let [prefix (str/concat "penpot.tmp.pdfunite." file-id ".")
path (sh/tempfile :prefix prefix :suffix ".pdf")]
(sh/run-cmd! (str "pdfunite " (str/join " " paths) " " path))
path))

View File

@@ -60,26 +60,46 @@
(handle-multiple-export exchange (assoc params :exports exports)))))
(defn- handle-single-export
[{:keys [:request/auth-token] :as exchange} {:keys [export name skip-children] :as params}]
(let [resource (rsc/create (:type export) (or name (:name export)))
export (assoc export :skip-children skip-children)]
[exchange {:keys [export wait profile-id name skip-children] :as params}]
(let [topic (str profile-id)
resource (rsc/create (:type export) (or name (:name export)))
(->> (rd/render export
(fn [{:keys [path] :as object}]
(sh/move! path (:path resource))))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/fmap (fn [resource]
(dissoc resource :path)))
(p/fmap (fn [resource]
(assoc exchange :response/body resource)))
(p/merr (fn [cause]
(l/error :hint "unexpected error on export multiple"
:cause cause)
(p/rejected cause))))))
on-progress (fn [{:keys [path] :as object}]
(p/do
;; Move the generated path to the resource
;; path destination.
(sh/move! path (:path resource))
(when-not wait
(redis/pub! topic {:type :export-update
:resource-id (:id resource)
:status "running"
:total 1
:done 1})
(redis/pub! topic {:type :export-update
:resource-id (:id resource)
:filename (:filename resource)
:name (:name resource)
:status "ended"}))))
on-error (fn [cause]
(l/error :hint "unexpected error on export multiple"
:cause cause)
(if wait
(p/rejected cause)
(redis/pub! topic {:type :export-update
:resource-id (:id resource)
:status "error"
:cause (ex-message cause)})))
export (assoc export :skip-children skip-children)
proc (-> (rd/render export on-progress)
(p/then (constantly resource))
(p/catch on-error))]
(if wait
(p/then proc #(assoc exchange :response/body (dissoc % :path)))
(assoc exchange :response/body (dissoc resource :path)))))
(defn- handle-multiple-export
[{:keys [:request/auth-token] :as exchange} {:keys [exports wait profile-id name] :as params}]
[exchange {:keys [exports wait profile-id name skip-children] :as params}]
(let [resource (rsc/create :zip (or name (-> exports first :name)))
total (count exports)
topic (str profile-id)
@@ -93,6 +113,15 @@
:done done}]
(redis/pub! topic data))))
on-complete (fn []
(when-not wait
(let [data {:type :export-update
:name (:name resource)
:filename (:filename resource)
:resource-id (:id resource)
:status "ended"}]
(redis/pub! topic data))))
on-error (fn [cause]
(l/error :hint "unexpected error on multiple exportation" :cause cause)
(if wait
@@ -103,35 +132,30 @@
:cause (ex-message cause)})))
zip (rsc/create-zip :resource resource
:on-complete on-complete
:on-error on-error
:on-progress on-progress)
append (fn [{:keys [filename path] :as resource}]
append (fn [{:keys [filename path] :as object}]
(rsc/add-to-zip! zip path (str/replace filename sanitize-file-regex "_")))
proc (->> exports
(map (fn [export] (rd/render export append)))
(p/all)
(p/fnly (fn [_] (.finalize zip)))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/fmap (fn [resource]
(let [data {:type :export-update
:name (:name resource)
:filename (:filename resource)
:resource-id (:id resource)
:resource-uri (:uri resource)
:mtype (:mtype resource)
:status "ended"}]
(p/do (redis/pub! topic data)
(assoc exchange :response/body resource)))))
(p/merr on-error))]
proc (-> (p/do
(p/loop [exports (seq exports)]
(when-let [export (some-> (first exports)
(assoc :skip-children skip-children))]
(p/do
(rd/render export append)
(p/recur (rest exports)))))
(.finalize zip))
(p/then (constantly resource))
(p/catch on-error))]
(if wait
(p/then proc #(assoc exchange :response/body (dissoc % :path)))
(assoc exchange :response/body (dissoc resource :path)))))
(defn- assoc-file-name
"A transducer that assocs a candidate filename and avoid duplicates"
"A transducer that assocs a candidate filename and avoid duplicates."
[]
(letfn [(find-candidate [params used]
(loop [index 0]

View File

@@ -9,13 +9,9 @@
(:require
["archiver$default" :as arc]
["node:fs" :as fs]
["node:fs/promises" :as fsp]
["node:path" :as path]
[app.common.exceptions :as ex]
[app.common.transit :as t]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.util.mime :as mime]
[app.util.shell :as sh]
[cljs.core :as c]
@@ -29,20 +25,44 @@
(defn create
"Generates ephimeral resource object."
[type name]
(let [task-id (uuid/next)
path (-> (get-path type task-id)
(sh/schedule-deletion))]
{:path path
(let [task-id (uuid/next)]
{:path (get-path type task-id)
:mtype (mime/get type)
:name name
:filename (str/concat name (mime/get-extension type))
:id task-id}))
:id (str/concat (c/name type) "." task-id)}))
(defn- lookup
[id]
(p/let [[type task-id] (str/split id "." 2)
path (get-path type task-id)
mtype (mime/get (keyword type))
stat (sh/stat path)]
(when-not stat
(ex/raise :type :not-found))
{:stream (fs/createReadStream path)
:headers {"content-type" mtype
"content-length" (:size stat)}}))
(defn handler
[{:keys [:request/params] :as exchange}]
(when-not (contains? params :id)
(ex/raise :type :validation
:code :missing-id))
(-> (lookup (get params :id))
(p/then (fn [{:keys [stream headers] :as resource}]
(-> exchange
(assoc :response/status 200)
(assoc :response/body stream)
(assoc :response/headers headers))))))
(defn create-zip
[& {:keys [resource on-complete on-progress on-error]}]
(let [^js zip (arc/create "zip")
^js out (fs/createWriteStream (:path resource))
on-complete (or on-complete (constantly nil))
progress (atom 0)]
(.on zip "error" on-error)
(.on zip "end" on-complete)
@@ -60,29 +80,3 @@
(defn close-zip!
[zip]
(.finalize ^js zip))
(defn upload-resource
[auth-token resource]
(->> (fsp/readFile (:path resource))
(p/fmap (fn [buffer]
(new js/Blob #js [buffer] #js {:type (:mtype resource)})))
(p/mcat (fn [blob]
(let [fdata (new js/FormData)
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]
(.append fdata "content" blob (:filename resource))
(js/fetch uri #js {:headers #js {"X-Shared-Key" cf/management-key
"Authorization" (str "Bearer " auth-token)}
:method "POST"
:body fdata}))))
(p/mcat (fn [response]
(if (not= (.-status response) 200)
(ex/raise :type :internal
:code :unable-to-upload-resource
:response-status (.-status response))
(.text response))))
(p/fmap t/decode-str)
(p/fmap (fn [result]
(merge resource (dissoc result :id))))))

View File

@@ -29,13 +29,13 @@
:userAgent bw/default-user-agent})
(render-object [page {:keys [id] :as object}]
(p/let [path (sh/tempfile :prefix "penpot.tmp.bitmap." :suffix (mime/get-extension type))
(p/let [path (sh/tempfile :prefix "penpot.tmp.render.bitmap." :suffix (mime/get-extension type))
node (bw/select page (str/concat "#screenshot-" id))]
(bw/wait-for node)
(case type
:png (bw/screenshot node {:omit-background? true :type type :path path})
:jpeg (bw/screenshot node {:omit-background? false :type type :path path})
:webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.bitmap." :suffix ".png")]
:webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.render.bitmap." :suffix ".png")]
;; playwright only supports jpg and png, we need to convert it afterwards
(bw/screenshot node {:omit-background? true :type :png :path png-path})
(sh/run-cmd! (str "convert " png-path " -quality 100 WEBP:" path))))
@@ -52,7 +52,6 @@
;; take the screnshot of requested objects, one by one
(p/run (partial render-object page) objects)
nil))]
(p/let [params {:file-id file-id
:page-id page-id
:share-id share-id

View File

@@ -40,7 +40,7 @@
(render-object [page base-uri {:keys [id] :as object}]
(p/let [uri (prepare-uri base-uri id)
path (sh/tempfile :prefix "penpot.tmp.pdf." :suffix (mime/get-extension type))]
path (sh/tempfile :prefix "penpot.tmp.render.pdf." :suffix (mime/get-extension type))]
(l/info :uri uri)
(bw/nav! page uri)
(p/let [dom (bw/select page (dm/str "#screenshot-" id))]

View File

@@ -7,12 +7,11 @@
(ns app.util.shell
"Shell & FS utilities."
(:require
["node:child_process" :as proc]
["node:fs" :as fs]
["node:path" :as path]
["child_process" :as proc]
["fs" :as fs]
["path" :as path]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[cuerdas.core :as str]
@@ -20,8 +19,7 @@
(l/set-level! :trace)
(def ^:const default-deletion-delay
(* 60 60 1)) ;; 1h
(def tempfile-minage (* 1000 60 60 1)) ;; 1h
(def tmpdir
(let [path (cf/get :tempdir)]
@@ -30,28 +28,16 @@
(fs/mkdirSync path #js {:recursive true}))
path))
(defn schedule-deletion
([path] (schedule-deletion path default-deletion-delay))
([path delay]
(let [remove-path
(fn []
(try
(when (fs/existsSync path)
(fs/rmSync path #js {:recursive true})
(l/trc :hint "tempfile permanently deleted" :path path))
(catch :default cause
(l/err :hint "error on deleting temporal file"
:path path
:cause cause))))
scheduled-at
(-> (ct/now) (ct/plus #js {:seconds delay}))]
(l/trc :hint "schedule tempfile deletion"
:path path
:scheduled-at (ct/format-inst scheduled-at))
(js/setTimeout remove-path (* delay 1000))
path)))
(defn- schedule-deletion!
[path]
(letfn [(remote-tempfile []
(when (fs/existsSync path)
(l/trace :hint "permanently remove tempfile" :path path)
(fs/rmSync path #js {:recursive true})))]
(l/trace :hint "schedule tempfile deletion"
:path path
:scheduled-at (.. (js/Date. (+ (js/Date.now) tempfile-minage)) toString))
(js/setTimeout remote-tempfile tempfile-minage)))
(defn tempfile
[& {:keys [prefix suffix]
@@ -62,7 +48,9 @@
(let [path (path/join tmpdir (str/concat prefix (uuid/next) "-" i suffix))]
(if (fs/existsSync path)
(recur (inc i))
(schedule-deletion path)))
(do
(schedule-deletion! path)
path)))
(ex/raise :type :internal
:code :unable-to-locate-temporal-file
:hint "unable to find a tempfile candidate"))))
@@ -73,12 +61,11 @@
(defn stat
[path]
(->> (.stat fs/promises path)
(p/fmap (fn [data]
{:created-at (inst-ms (.-ctime ^js data))
:size (.-size data)}))
(p/merr (fn [_cause]
(p/resolved nil)))))
(-> (.stat fs/promises path)
(p/then (fn [data]
{:created-at (inst-ms (.-ctime ^js data))
:size (.-size data)}))
(p/catch (constantly nil))))
(defn rmdir!
[path]

View File

@@ -82,7 +82,7 @@
"nodemon": "^3.1.10",
"npm-run-all": "^4.1.5",
"p-limit": "^6.2.0",
"playwright": "1.56.1",
"playwright": "1.52.0",
"postcss": "^8.5.4",
"postcss-clean": "^1.2.2",
"prettier": "3.5.3",

View File

@@ -11,7 +11,6 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
testDir: "./playwright",
outputDir: './test-results',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -21,19 +20,20 @@ export default defineConfig({
/* Opt out of parallel tests by default; can be overriden with --workers */
workers: 1,
/* Timeout for expects (longer in CI) */
timeout: 60000,
expect: {
timeout: process.env.CI ? 30000 : 5000,
timeout: process.env.CI ? 20000 : 5000,
},
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "list",
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
locale: "en-US",
permissions: ["clipboard-write", "clipboard-read"],
@@ -45,10 +45,6 @@ export default defineConfig({
name: "default",
use: { ...devices["Desktop Chrome"] },
testDir: "./playwright/ui/specs",
use: {
video: 'retain-on-failure',
trace: 'retain-on-failure',
}
},
{
name: "ds",

View File

@@ -887,4 +887,4 @@
"~:base-font-size": "16px"
}
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"~:id": "~ue179d9df-de35-80bf-8005-2861e849b3f7",
"~:file-id": "~ue179d9df-de35-80bf-8005-283bbd5516b0",
"~:created-at": "~m1729604566293",
"~:data": {
"~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8": {
"~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8",
"~:name": "F",
"~:path": "",
"~:modified-at": "~m1729604566311",
"~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc",
"~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1"
},
"~u6ad3e6b9-c5a0-80cf-8005-283bbe39bb51": {
"~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe39bb51",
"~:name": "E",
"~:path": "",
"~:modified-at": "~m1729604566311",
"~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd",
"~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1"
},
"~u6ad3e6b9-c5a0-80cf-8005-283bbe3a9014": {
"~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3a9014",
"~:name": "C",
"~:path": "",
"~:modified-at": "~m1729604566311",
"~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf",
"~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1"
},
"~u6ad3e6b9-c5a0-80cf-8005-283bbe3b1793": {
"~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3b1793",
"~:name": "B",
"~:path": "",
"~:modified-at": "~m1729604566311",
"~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0",
"~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1"
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,6 @@
"~:revn": 2,
"~:created-at": "~m1730199694953",
"~:created-by": "user",
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b"
"~:profile-id": "~u4678a621-b446-818a-8004-e7b734def799"
}
]

View File

@@ -5,6 +5,6 @@
"~:revn": 2,
"~:created-at": "~m1730199694953",
"~:created-by": "user",
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b"
"~:profile-id": "~u4678a621-b446-818a-8004-e7b734def799"
}
]

View File

@@ -4,7 +4,6 @@ import { WorkspacePage } from "./WorkspacePage";
export const WASM_FLAGS = [
"enable-feature-render-wasm",
"enable-render-wasm-dpr",
"enable-feature-text-editor-v2",
];
export class WasmWorkspacePage extends WorkspacePage {
@@ -13,15 +12,7 @@ export class WasmWorkspacePage extends WorkspacePage {
await WorkspacePage.mockConfigFlags(page, WASM_FLAGS);
await page.addInitScript(() => {
document.addEventListener("penpot:wasm:loaded", () => {
window.wasmModuleLoaded = true;
});
document.addEventListener("penpot:wasm:render", () => {
window.wasmRenderCount = (window.wasmRenderCount || 0) + 1;
});
document.addEventListener("penpot:wasm:set-objects", () => {
document.addEventListener("wasm:set-objects-finished", () => {
window.wasmSetObjectsFinished = true;
});
});
@@ -32,20 +23,19 @@ export class WasmWorkspacePage extends WorkspacePage {
this.canvas = page.getByTestId("canvas-wasm-shapes");
}
async waitForFirstRender() {
await this.pageName.waitFor();
await this.canvas.waitFor();
async waitForFirstRender(config = {}) {
const options = { hideUI: true, ...config };
await expect(this.pageName).toHaveText("Page 1");
if (options.hideUI) {
await this.hideUI();
}
await this.canvas.waitFor({ state: "visible" });
await this.page.waitForFunction(() => {
console.log("RAF:", window.wasmSetObjectsFinished);
return window.wasmSetObjectsFinished;
});
}
async waitForFirstRenderWithoutUI() {
await waitForFirstRender();
await this.hideUI();
}
async hideUI() {
await this.page.keyboard.press("\\");
await expect(this.pageName).not.toBeVisible();

View File

@@ -67,11 +67,9 @@ export class WorkspacePage extends BaseWebSocketPage {
constructor(page) {
super(page);
this.pageName = page.getByTestId("page-name");
this.presentUserListItems = page
.getByTestId("active-users-list")
.getByAltText("Princesa Leia");
this.viewport = page.getByTestId("viewport");
this.rootShape = page.locator(
`[id="shape-00000000-0000-0000-0000-000000000000"]`,
@@ -245,20 +243,14 @@ export class WorkspacePage extends BaseWebSocketPage {
async clickLeafLayer(name, clickOptions = {}) {
const layer = this.layers.getByText(name).first();
await layer.waitFor();
await layer.click(clickOptions);
await this.page.waitForTimeout(500);
}
async clickToggableLayer(name, clickOptions = {}) {
const layer = this.layers
.getByTestId("layer-row")
.filter({ hasText: name });
const button = layer.getByRole("button");
await button.waitFor();
await button.click(clickOptions);
await this.page.waitForTimeout(500);
.getByTestId("layer-row")
.filter({ has: this.page.getByText(name) });
await layer.getByRole("button").click(clickOptions);
}
async expectSelectedLayer(name) {

View File

@@ -20,7 +20,7 @@ test("Renders a file with basic shapes, boards and groups", async ({
id: "53a7ff09-2228-81d3-8006-4b5eac177245",
pageId: "53a7ff09-2228-81d3-8006-4b5eac177246",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -44,7 +44,7 @@ test("Renders a file with solid, gradient and image fills", async ({
id: "1ebcea38-f1bf-8101-8006-4c8ec4a9bffe",
pageId: "1ebcea38-f1bf-8101-8006-4c8ec4a9bfff",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -67,7 +67,7 @@ test("Renders a file with strokes", async ({ page }) => {
id: "202c1104-9385-81d3-8006-507413ff2c99",
pageId: "202c1104-9385-81d3-8006-507413ff2c9a",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -81,7 +81,7 @@ test("Renders a file with mutliple strokes", async ({ page }) => {
id: "c0939f58-37bc-805d-8006-51cc78297208",
pageId: "c0939f58-37bc-805d-8006-51cc78297209",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -100,7 +100,7 @@ test("Renders a file with shapes with multiple fills", async ({ page }) => {
id: "c0939f58-37bc-805d-8006-51cd3a51c255",
pageId: "c0939f58-37bc-805d-8006-51cd3a51c256",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -116,7 +116,7 @@ test("Renders shapes taking into account blend modes", async ({ page }) => {
id: "c0939f58-37bc-805d-8006-51cdf8e18e76",
pageId: "c0939f58-37bc-805d-8006-51cdf8e18e77",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -144,7 +144,7 @@ test("Renders shapes with exif rotated images fills and strokes", async ({
id: "27270c45-35b4-80f3-8006-63a3912bdce8",
pageId: "27270c45-35b4-80f3-8006-63a3912bdce9",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -158,7 +158,7 @@ test("Updates canvas background", async ({ page }) => {
id: "3b0d758a-8c9d-8013-8006-52c8337e5c72",
pageId: "3b0d758a-8c9d-8013-8006-52c8337e5c73",
});
await workspace.waitForFirstRender();
await workspace.waitForFirstRender({ hideUI: false });
const canvasBackgroundInput = workspace.page.getByRole("textbox", {
name: "Color",
@@ -166,6 +166,9 @@ test("Updates canvas background", async ({ page }) => {
await canvasBackgroundInput.fill("FABADA");
await workspace.page.keyboard.press("Enter");
// can't hide UI cause this will trigger a re-render
// await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -189,7 +192,7 @@ test("Renders a file with blurs applied to any kind of shape", async ({
id: "aa0a383a-7553-808a-8006-ae1237b52cf9",
pageId: "aa0a383a-7553-808a-8006-ae160ba8bd86",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -205,7 +208,7 @@ test("Renders a file with shadows applied to any kind of shape", async ({
id: "9502081a-e1a4-80bc-8006-c2b968723199",
pageId: "9502081a-e1a4-80bc-8006-c2b96872319a",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -221,7 +224,7 @@ test("Renders a file with a closed path shape with multiple segments using strok
id: "3f7c3cc4-556d-80fa-8006-da2505231c2b",
pageId: "3f7c3cc4-556d-80fa-8006-da2505231c2c",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -235,7 +238,7 @@ test("Renders a file with paths and svg attrs", async ({ page }) => {
id: "4732f3e3-7a1a-807e-8006-ff76066e631d",
pageId: "4732f3e3-7a1a-807e-8006-ff76066e631e",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -253,7 +256,7 @@ test("Renders a file with nested frames with inherited blur", async ({
id: "58c5cc60-d124-81bd-8007-0ee4e5030609",
pageId: "58c5cc60-d124-81bd-8007-0ee4e503060a",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -269,7 +272,7 @@ test("Renders a clipped frame with a large blur drop shadow", async ({
id: "b4133204-a015-80ed-8007-192a65398b0c",
pageId: "b4133204-a015-80ed-8007-192a65398b0d",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 335 KiB

View File

@@ -51,7 +51,7 @@ test("Renders a file with texts", async ({ page }) => {
id: "3b0d758a-8c9d-8013-8006-52c8337e5c72",
pageId: "3b0d758a-8c9d-8013-8006-52c8337e5c73",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -64,7 +64,7 @@ test("Updates a text font", async ({ page }) => {
id: "3b0d758a-8c9d-8013-8006-52c8337e5c72",
pageId: "3b0d758a-8c9d-8013-8006-52c8337e5c73",
});
await workspace.waitForFirstRender();
await workspace.waitForFirstRender({ hideUI: false });
await workspace.clickLeafLayer("this is a text");
await page.keyboard.press("Control+b");
@@ -88,7 +88,7 @@ test("Renders a file with texts that use google fonts", async ({ page }) => {
id: "434b0541-fa2f-802f-8006-5981e47bd732",
pageId: "434b0541-fa2f-802f-8006-5981e47bd733",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -114,7 +114,7 @@ test("Renders a file with texts that use custom fonts", async ({ page }) => {
id: "434b0541-fa2f-802f-8006-59827d964a9b",
pageId: "434b0541-fa2f-802f-8006-59827d964a9c",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -128,7 +128,7 @@ test("Renders a file with styled texts", async ({ page }) => {
id: "6bd7c17d-4f59-815e-8006-5c2559af4939",
pageId: "6bd7c17d-4f59-815e-8006-5c2559af493a",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -152,7 +152,7 @@ test("Renders a file with texts with images", async ({ page }) => {
id: "6bd7c17d-4f59-815e-8006-5e96453952b0",
pageId: "6bd7c17d-4f59-815e-8006-5e96453952b1",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -170,7 +170,7 @@ test("Renders a file with texts with emoji and different symbols", async ({
id: "74d31005-5d0c-81fe-8006-949a8226e8c4",
pageId: "74d31005-5d0c-81fe-8006-949a8226e8c5",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -191,7 +191,7 @@ test("Renders a file with text decoration", async ({ page }) => {
id: "d6c33e7b-7b64-80f3-8006-785098582f1d",
pageId: "d6c33e7b-7b64-80f3-8006-785098582f1e",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -208,7 +208,7 @@ test("Renders a file with emoji and text decoration", async ({ page }) => {
id: "82d128e1-d3b1-80a5-8006-ae60fedcd5e7",
pageId: "82d128e1-d3b1-80a5-8006-ae60fedcd5e8",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -225,7 +225,7 @@ test("Renders a file with multiple emoji", async ({ page }) => {
pageId: "6bd7c17d-4f59-815e-8006-5e999f38f211",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -243,7 +243,7 @@ test("Renders a file with multiple text shadows, strokes, and blur combinations"
id: "15b74473-2908-8094-8006-bdb4fbd2c6a3",
pageId: "15b74473-2908-8094-8006-bdb4fbd2c6a4",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -261,7 +261,7 @@ test("Renders a file with different text leaves decoration", async ({
pageId: "b4cb802d-4245-807d-8006-b4a4b90b79cd",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -279,7 +279,7 @@ test("Renders a file with different text shadows combinations", async ({
pageId: "15b74473-2908-8094-8006-bc90c3982c74",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -293,7 +293,7 @@ test("Renders a file with multiple text shadows in order", async ({ page }) => {
pageId: "48ffa82f-6950-81b5-8006-e49a2a396580",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -311,7 +311,7 @@ test("Renders a file with text in frames and different strokes, shadows, and blu
pageId: "44471494-966a-8178-8006-c5bd93f0fe73",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -326,7 +326,7 @@ test("Renders a file with texts with different alignments", async ({
id: "692f368b-63ca-8141-8006-62925640b827",
pageId: "692f368b-63ca-8141-8006-62925640b828",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -343,7 +343,7 @@ test("Renders a file with texts with with text spans of different sizes", async
id: "a0b1a70e-0d02-8082-8006-ff6d160f15ce",
pageId: "a0b1a70e-0d02-8082-8006-ff6d160f15cf",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -375,7 +375,7 @@ test.skip("Renders a file with texts with tabs", async ({ page }) => {
pageId: "55ed444c-1179-8175-8007-09da51f502e8",
});
await workspace.waitForFirstRender();
await workspace.waitForFirstRender({ hideUI: false });
await workspace.clickLeafLayer("shape-list");
await workspace.hideUI();
await workspace.page.keyboard.press("Enter");
@@ -394,7 +394,7 @@ test.skip("Renders a file with texts with empty lines", async ({ page }) => {
pageId: "15222a7a-d3bc-80f1-8007-0d8e166e650f",
});
await workspace.waitForFirstRender();
await workspace.waitForFirstRender({ hideUI: false });
await workspace.clickLeafLayer("text-with-empty-lines-2");
await workspace.hideUI();
await workspace.page.keyboard.press("Enter");
@@ -413,7 +413,7 @@ test.skip("Renders a file with texts with breaking words", async ({ page }) => {
pageId: "15222a7a-d3bc-80f1-8007-0d8e166e650f",
});
await workspace.waitForFirstRender();
await workspace.waitForFirstRender({ hideUI: false });
await workspace.clickLeafLayer("text-with-empty-lines-3");
await workspace.hideUI();
await workspace.page.keyboard.press("Enter");
@@ -433,7 +433,7 @@ test("Renders a file with group with text with inherited shadows", async ({
pageId: "58c5cc60-d124-81bd-8007-0f30f1ac452b",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -446,7 +446,7 @@ test.skip("Updates text alignment edition - part 1", async ({ page }) => {
id: "6bd7c17d-4f59-815e-8006-5c1f68846e43",
pageId: "f8b42814-8653-81cf-8006-638aacdc3ffb",
});
await workspace.waitForFirstRender();
await workspace.waitForFirstRender({ hideUI: false });
await workspace.clickLeafLayer("Text 1");
const textOptionsButton = workspace.page.getByTestId(
@@ -490,7 +490,7 @@ test.skip("Updates text alignment edition - part 2", async ({ page }) => {
id: "6bd7c17d-4f59-815e-8006-5c1f68846e43",
pageId: "f8b42814-8653-81cf-8006-638aacdc3ffb",
});
await workspace.waitForFirstRender();
await workspace.waitForFirstRender({ hideUI: false });
await workspace.clickLeafLayer("Text 1");
const textOptionsButton = workspace.page.getByTestId(
@@ -542,7 +542,7 @@ test.skip("Updates text alignment edition - part 3", async ({ page }) => {
id: "6bd7c17d-4f59-815e-8006-5c1f68846e43",
pageId: "f8b42814-8653-81cf-8006-638aacdc3ffb",
});
await workspace.waitForFirstRender();
await workspace.waitForFirstRender({ hideUI: false });
await workspace.clickLeafLayer("Text 1");
const textOptionsButton = workspace.page.getByTestId(

View File

@@ -90,8 +90,7 @@ test.describe("Shape attributes", () => {
await expect(workspace.page.getByTestId("add-fill")).toBeDisabled();
});
// FIXME: flaky
test.skip("Cannot add a new text fill when the limit has been reached", async ({
test("Cannot add a new text fill when the limit has been reached", async ({
page,
}) => {
const workspace = new WorkspacePage(page);

View File

@@ -42,7 +42,9 @@ test.describe("Export frames to PDF", () => {
await page.getByText("file").last().click();
// The "Export frames to PDF" option should NOT be visible when there are no frames
await expect(page.locator("#file-menu-export-frames")).not.toBeVisible();
await expect(
page.locator("#file-menu-export-frames"),
).not.toBeVisible();
});
test("Export frames menu option is visible when there are frames (even if not selected)", async ({
@@ -56,12 +58,12 @@ test.describe("Export frames to PDF", () => {
await page.getByText("file").last().click();
// The "Export frames to PDF" option should be visible when there are frames on the page
await expect(page.locator("#file-menu-export-frames")).toBeVisible();
await expect(
page.locator("#file-menu-export-frames"),
).toBeVisible();
});
test("Export frames modal shows all frames when none are selected", async ({
page,
}) => {
test("Export frames modal shows all frames when none are selected", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupWorkspaceWithFrames(workspacePage);
@@ -85,9 +87,7 @@ test.describe("Export frames to PDF", () => {
await expect(page.getByText("2 of 2 elements selected")).toBeVisible();
});
test("Export frames modal shows only the selected frames", async ({
page,
}) => {
test("Export frames modal shows only the selected frames", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupWorkspaceWithFrames(workspacePage);
@@ -107,9 +107,7 @@ test.describe("Export frames to PDF", () => {
// Only Frame 1 should appear in the list
// await page.getByRole("button", { name: "Board 1" }),
await expect(page.getByRole("button", { name: "Board 1" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Board 2" }),
).not.toBeVisible();
await expect(page.getByRole("button", { name: "Board 2" })).not.toBeVisible();
// The selection counter should show "1 of 1"
await expect(page.getByText("1 of 1 elements selected")).toBeVisible();
@@ -121,7 +119,7 @@ test.describe("Export frames to PDF", () => {
// Select Frame 1
await workspacePage.clickLeafLayer("Board 1");
// Add Frame 2 to selection
await page.keyboard.down("Shift");
await workspacePage.clickLeafLayer("Board 2");
@@ -146,9 +144,7 @@ test.describe("Export frames to PDF", () => {
await expect(exportButton).toBeEnabled();
});
test("Export button is disabled when all frames are deselected", async ({
page,
}) => {
test("Export button is disabled when all frames are deselected", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupWorkspaceWithFrames(workspacePage);
@@ -167,9 +163,7 @@ test.describe("Export frames to PDF", () => {
await expect(page.getByText("0 of 1 elements selected")).toBeVisible();
// // The export button should be disabled
await expect(
page.getByRole("button", { name: "Export", exact: true }),
).toBeDisabled();
await expect(page.getByRole("button", { name: "Export" , exact: true})).toBeDisabled();
});
test("User can cancel the export modal", async ({ page }) => {
@@ -194,3 +188,4 @@ test.describe("Export frames to PDF", () => {
await expect(page.getByText("0 of 1 elements selected")).not.toBeVisible();
});
});

View File

@@ -50,7 +50,6 @@ test("[Taiga #9116] Copy CSS background color in the selected format in the INSP
});
await inspectButton.click();
// Open color space selector combobox and change to RGBA format
const colorDropdown = workspacePage.page
.getByRole("combobox")
.getByText("HEX");
@@ -61,17 +60,6 @@ test("[Taiga #9116] Copy CSS background color in the selected format in the INSP
});
await rgbaFormatButton.click();
// Open info tab selector and select the computed tab
const infoTabSelector = workspacePage.page
.getByRole("combobox")
.getByText("Styles");
await infoTabSelector.click();
const infoTabSelectorButton = workspacePage.page.getByRole("option", {
name: "Computed",
});
await infoTabSelectorButton.click();
const copyColorButton = workspacePage.page.getByRole("button", {
name: "Copy color",
});
@@ -130,17 +118,6 @@ test("[Taiga #10630] [INSPECT] Style assets not being displayed on info tab", as
});
await inspectButton.click();
// Open info tab selector and select the computed tab
const infoTabSelector = workspacePage.page
.getByRole("combobox")
.getByText("Styles");
await infoTabSelector.click();
const infoTabSelectorButton = workspacePage.page.getByRole("option", {
name: "Computed",
});
await infoTabSelectorButton.click();
const colorLibraryName = workspacePage.page.getByTestId("color-library-name");
await expect(colorLibraryName).toHaveText("test-color-187cd5");

View File

@@ -66,7 +66,6 @@ const copyShorthand = async (panel) => {
const panelShorthandButton = panel.getByRole("button", {
name: "Copy CSS shorthand to clipboard",
});
await panelShorthandButton.waitFor();
await panelShorthandButton.click();
};
@@ -80,7 +79,6 @@ const copyPropertyFromPropertyRow = async (panel, property) => {
.getByTestId("property-row")
.filter({ hasText: property });
const copyButton = propertyRow.getByRole("button");
await copyButton.waitFor();
await copyButton.click();
};
@@ -93,7 +91,6 @@ const getPanelByTitle = async (workspacePage, title) => {
const sidebar = workspacePage.page.getByTestId("right-sidebar");
const article = sidebar.getByRole("article");
const panel = article.filter({ hasText: title });
await panel.waitFor();
return panel;
};
@@ -109,7 +106,6 @@ const selectLayer = async (workspacePage, layerName, parentLayerName) => {
await workspacePage.clickToggableLayer(parentLayerName);
}
await workspacePage.clickLeafLayer(layerName);
await workspacePage.page.waitForTimeout(500);
};
/**
@@ -121,17 +117,9 @@ const openInspectTab = async (workspacePage) => {
const inspectButton = workspacePage.page.getByRole("tab", {
name: "Inspect",
});
await inspectButton.waitFor();
await inspectButton.click();
await workspacePage.page.waitForTimeout(500);
};
/**
* @typedef {'hex' | 'rgba' | 'hsla'} ColorSpace
*
* @param {WorkspacePage} workspacePage - The workspace page instance
* @param {ColorSpace} colorSpace - The color space to select
*/
const selectColorSpace = async (workspacePage, colorSpace) => {
const sidebar = workspacePage.page.getByTestId("right-sidebar");
const colorSpaceSelector = sidebar.getByLabel("Select color space");
@@ -143,7 +131,7 @@ const selectColorSpace = async (workspacePage, colorSpace) => {
};
test.describe("Inspect tab - Styles", () => {
test.skip("Open Inspect tab", async ({ page }) => {
test("Open Inspect tab", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
@@ -243,8 +231,7 @@ test.describe("Inspect tab - Styles", () => {
expect(propertyRowCount).toBeGreaterThanOrEqual(4);
});
// FIXME: flaky/random (depends on trace ?)
test.skip("Shape Shadow - Composite shadow", async ({ page }) => {
test("Shape Shadow - Composite shadow", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
@@ -260,12 +247,9 @@ test.describe("Inspect tab - Styles", () => {
expect(propertyRowCount).toBeGreaterThanOrEqual(3);
const compositeShadowRow = propertyRow.first();
await compositeShadowRow.waitFor();
await expect(compositeShadowRow).toBeVisible();
const compositeShadowTerm = compositeShadowRow.locator("dt");
const compositeShadowDefinition = compositeShadowRow.locator("dd");
expect(compositeShadowTerm).toHaveText("Shadow", { exact: true });

View File

@@ -3,9 +3,13 @@ import { WasmWorkspacePage, WASM_FLAGS } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockConfigFlags(page, [
...WASM_FLAGS,
"enable-feature-text-editor-v2",
]);
});
test.skip("BUG 10867 - Crash when loading comments", async ({ page }) => {
test("BUG 10867 - Crash when loading comments", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -16,7 +20,7 @@ test.skip("BUG 10867 - Crash when loading comments", async ({ page }) => {
).toBeVisible();
});
test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({
test("BUG 12164 - Crash when trying to fetch a missing font", async ({
page,
}) => {
// mock fetching a missing font
@@ -51,8 +55,7 @@ test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({
pageId: "2b7f0188-51a1-8193-8006-e05bad87b74d",
});
await workspacePage.page.waitForTimeout(1000)
await workspacePage.waitForFirstRender();
await workspacePage.waitForFirstRender({ hideUI: false });
await expect(
workspacePage.page.getByText("Internal Error"),

View File

@@ -94,28 +94,30 @@ const setupTypographyTokensFile = async (page, options = {}) => {
return setupTokensFile(page, {
file: "workspace/get-file-typography-tokens.json",
fileFragment: "workspace/get-file-fragment-typography-tokens.json",
flags: [
"enable-token-typography-types",
"enable-token-typography-composite",
],
...options,
});
};
const checkInputFieldWithError = async (
tokenThemeUpdateCreateModal,
inputLocator,
) => {
await expect(inputLocator).toHaveAttribute("aria-invalid", "true");
const checkInputFieldWithError = async (tokenThemeUpdateCreateModal, inputLocator) => {
await expect(inputLocator).toHaveAttribute("aria-invalid", "true");
const errorMessageId = await inputLocator.getAttribute("aria-describedby");
await expect(
tokenThemeUpdateCreateModal.locator(`#${errorMessageId}`),
).toBeVisible();
const errorMessageId = await inputLocator.getAttribute("aria-describedby");
await expect(
tokenThemeUpdateCreateModal.locator(`#${errorMessageId}`),
).toBeVisible();
};
const checkInputFieldWithoutError = async (
tokenThemeUpdateCreateModal,
inputLocator,
) => {
expect(await inputLocator.getAttribute("aria-invalid")).toBeNull();
expect(await inputLocator.getAttribute("aria-describedby")).toBeNull();
const checkInputFieldWithoutError = async (tokenThemeUpdateCreateModal, inputLocator) => {
expect(
await inputLocator.getAttribute("aria-invalid")
).toBeNull();
expect(
await inputLocator.getAttribute("aria-describedby")
).toBeNull();
};
test.describe("Tokens: Tokens Tab", () => {
@@ -197,9 +199,7 @@ test.describe("Tokens: Tokens Tab", () => {
).toBeEnabled();
// Tokens tab panel should have two tokens with the color red / #ff0000
await expect(
tokensTabPanel.getByRole("button", { name: "#ff0000" }),
).toHaveCount(2);
await expect(tokensTabPanel.getByRole("button", {name: "#ff0000"})).toHaveCount(2);
// Global set has been auto created and is active
await expect(
@@ -303,7 +303,7 @@ test.describe("Tokens: Tokens Tab", () => {
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.pressSequentially(".changed");
await tokensUpdateCreateModal.getByRole("button", {name: "Save"}).click();
await nameField.press("Enter");
await expect(tokensUpdateCreateModal).not.toBeVisible();
@@ -497,9 +497,8 @@ test.describe("Tokens: Tokens Tab", () => {
// Clearing the input field should pick hex
await valueField.fill("");
// TODO: We need to fix this translation
await expect(
tokensUpdateCreateModal.getByText("Empty field"),
tokensUpdateCreateModal.getByText("Token value cannot be empty"),
).toBeVisible();
await valueSaturationSelector.click({ position: { x: 50, y: 50 } });
await expect(valueField).toHaveValue(/^#[A-Fa-f\d]+$/);
@@ -829,14 +828,16 @@ test.describe("Tokens: Themes modal", () => {
.first()
.click();
await expect(tokenThemeUpdateCreateModal).toBeVisible();
await expect(
tokenThemeUpdateCreateModal
).toBeVisible();
const groupInput = tokenThemeUpdateCreateModal.getByLabel("Group");
const nameInput = tokenThemeUpdateCreateModal.getByLabel("Theme");
const saveButton = tokenThemeUpdateCreateModal.getByRole("button", {
name: "Save theme",
});
await groupInput.fill("New Group name");
await nameInput.fill("New Theme name");
@@ -852,7 +853,7 @@ test.describe("Tokens: Themes modal", () => {
tokenThemeUpdateCreateModal.getByText("New Group name"),
).toBeVisible();
});
test("Add new theme", async ({ page }) => {
const { tokenThemeUpdateCreateModal, workspacePage } =
await setupTokensFile(page);
@@ -870,7 +871,7 @@ test.describe("Tokens: Themes modal", () => {
const saveButton = tokenThemeUpdateCreateModal.getByRole("button", {
name: "Save theme",
});
await groupInput.fill("Core"); // Invalid because "Core / Light" theme already exists
await nameInput.fill("Light");
@@ -916,13 +917,13 @@ test.describe("Tokens: Themes modal", () => {
const saveButton = tokenThemeUpdateCreateModal.getByRole("button", {
name: "Save theme",
});
await groupInput.fill("Core"); // Invalid because "Core / Dark" theme already exists
await nameInput.fill("Dark");
await checkInputFieldWithError(tokenThemeUpdateCreateModal, nameInput);
await expect(saveButton).toBeDisabled();
await groupInput.fill("Core"); // Valid because "Core / Light" theme already exists
await nameInput.fill("Light"); // but it's the same theme we are editing
@@ -935,8 +936,12 @@ test.describe("Tokens: Themes modal", () => {
await checkInputFieldWithoutError(tokenThemeUpdateCreateModal, nameInput);
await expect(saveButton).not.toBeDisabled();
expect(await nameInput.getAttribute("aria-invalid")).toBeNull();
expect(await nameInput.getAttribute("aria-describedby")).toBeNull();
expect(
await nameInput.getAttribute("aria-invalid")
).toBeNull();
expect(
await nameInput.getAttribute("aria-describedby")
).toBeNull();
const checkboxes = await tokenThemeUpdateCreateModal
.locator('[role="checkbox"]')
@@ -951,9 +956,9 @@ test.describe("Tokens: Themes modal", () => {
}
const firstButton = await tokenThemeUpdateCreateModal
.getByTestId("tokens-set-item")
.getByTestId('tokens-set-item')
.first();
await firstButton.click();
await expect(saveButton).not.toBeDisabled();
@@ -970,7 +975,10 @@ test.describe("Tokens: Themes modal", () => {
});
test.describe("Tokens: Apply token", () => {
test("User applies color token to a shape", async ({ page }) => {
// When deleting the "enable-token-color" flag, permanently remove this test.
test("User applies color token to a shape without tokens on design-tab", async ({
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
@@ -994,6 +1002,36 @@ test.describe("Tokens: Apply token", () => {
.click({ button: "right" });
await tokenContextMenuForToken.getByText("Fill").click();
const inputColor = workspacePage.page.getByRole("textbox", {
name: "Color",
});
await expect(inputColor).toHaveValue("000000");
});
test("User applies color token to a shape", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page, { flags: ["enable-token-color"] });
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Button" })
.click();
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
await tokensSidebar
.getByRole("button")
.filter({ hasText: "Color" })
.click();
await tokensSidebar
.getByRole("button", { name: "colors.black" })
.click({ button: "right" });
await tokenContextMenuForToken.getByText("Fill").click();
await expect(
workspacePage.page.getByLabel("Name: colors.black"),
).toBeVisible();
@@ -1071,10 +1109,12 @@ test.describe("Tokens: Apply token", () => {
// Fill in values for all fields and verify they persist when switching tabs
await fontSizeField.fill("16");
const fontWeightField = tokensUpdateCreateModal.getByLabel(/Font Weight/i);
const fontWeightField =
tokensUpdateCreateModal.getByLabel(/Font Weight/i);
const letterSpacingField =
tokensUpdateCreateModal.getByLabel(/Letter Spacing/i);
const lineHeightField = tokensUpdateCreateModal.getByLabel(/Line Height/i);
const lineHeightField =
tokensUpdateCreateModal.getByLabel(/Line Height/i);
const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i);
const textDecorationField =
tokensUpdateCreateModal.getByLabel(/Text Decoration/i);
@@ -1109,7 +1149,9 @@ test.describe("Tokens: Apply token", () => {
await expect(fontSizeField).toHaveValue(originalValues.fontSize);
await expect(fontFamilyField).toHaveValue(originalValues.fontFamily);
await expect(fontWeightField).toHaveValue(originalValues.fontWeight);
await expect(letterSpacingField).toHaveValue(originalValues.letterSpacing);
await expect(letterSpacingField).toHaveValue(
originalValues.letterSpacing,
);
await expect(lineHeightField).toHaveValue(originalValues.lineHeight);
await expect(textCaseField).toHaveValue(originalValues.textCase);
await expect(textDecorationField).toHaveValue(
@@ -1206,15 +1248,9 @@ test.describe("Tokens: Apply token", () => {
await expect(newToken).toBeVisible();
});
test("User adds shadow token with multiple shadows and applies it to shape", async ({
page,
}) => {
const {
tokensUpdateCreateModal,
tokensSidebar,
workspacePage,
tokenContextMenuForToken,
} = await setupTokensFile(page, { flags: ["enable-token-shadow"] });
test("User adds shadow token with multiple shadows and applies it to shape", async ({ page, }) => {
const { tokensUpdateCreateModal, tokensSidebar, workspacePage, tokenContextMenuForToken } =
await setupTokensFile(page, { flags: ["enable-token-shadow"] });
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1265,9 +1301,7 @@ test.describe("Tokens: Apply token", () => {
await expect(firstColorValue).toMatch(/^rgb(.*)$/);
// Wait for validation to complete
await expect(
tokensUpdateCreateModal.getByText(/Resolved value:/).first(),
).toBeVisible();
await expect(tokensUpdateCreateModal.getByText(/Resolved value:/).first()).toBeVisible();
// Save button should be enabled
const submitButton = tokensUpdateCreateModal.getByRole("button", {
@@ -1315,32 +1349,18 @@ test.describe("Tokens: Apply token", () => {
await thirdColorInput.fill("#FF0000");
// User removes the 2nd shadow
const removeButton2 = secondShadowFields.getByTestId(
"shadow-remove-button-1",
);
const removeButton2 = secondShadowFields.getByTestId("shadow-remove-button-1");
await removeButton2.click();
// Verify second shadow is removed
await expect(
secondShadowFields.getByTestId("shadow-add-button-3"),
).not.toBeVisible();
await expect(secondShadowFields.getByTestId("shadow-add-button-3")).not.toBeVisible();
// Verify that the first shadow kept its values
const firstOffsetXValue = await firstShadowFields
.getByLabel("X")
.inputValue();
const firstOffsetYValue = await firstShadowFields
.getByLabel("Y")
.inputValue();
const firstBlurValue = await firstShadowFields
.getByLabel("Blur")
.inputValue();
const firstSpreadValue = await firstShadowFields
.getByLabel("Spread")
.inputValue();
const firstColorValueAfter = await firstShadowFields
.getByLabel("Color")
.inputValue();
const firstOffsetXValue = await firstShadowFields.getByLabel("X").inputValue();
const firstOffsetYValue = await firstShadowFields.getByLabel("Y").inputValue();
const firstBlurValue = await firstShadowFields.getByLabel("Blur").inputValue();
const firstSpreadValue = await firstShadowFields.getByLabel("Spread").inputValue();
const firstColorValueAfter = await firstShadowFields.getByLabel("Color").inputValue();
await expect(firstOffsetXValue).toBe("2");
await expect(firstOffsetYValue).toBe("2");
@@ -1355,21 +1375,11 @@ test.describe("Tokens: Apply token", () => {
);
await expect(newSecondShadowFields).toBeVisible();
const secondOffsetXValue = await newSecondShadowFields
.getByLabel("X")
.inputValue();
const secondOffsetYValue = await newSecondShadowFields
.getByLabel("Y")
.inputValue();
const secondBlurValue = await newSecondShadowFields
.getByLabel("Blur")
.inputValue();
const secondSpreadValue = await newSecondShadowFields
.getByLabel("Spread")
.inputValue();
const secondColorValue = await newSecondShadowFields
.getByLabel("Color")
.inputValue();
const secondOffsetXValue = await newSecondShadowFields.getByLabel("X").inputValue();
const secondOffsetYValue = await newSecondShadowFields.getByLabel("Y").inputValue();
const secondBlurValue = await newSecondShadowFields.getByLabel("Blur").inputValue();
const secondSpreadValue = await newSecondShadowFields.getByLabel("Spread").inputValue();
const secondColorValue = await newSecondShadowFields.getByLabel("Color").inputValue();
await expect(secondOffsetXValue).toBe("10");
await expect(secondOffsetYValue).toBe("10");
@@ -1389,16 +1399,14 @@ test.describe("Tokens: Apply token", () => {
const firstColorValue = await colorInput.inputValue();
// Switch to reference tab
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
const referenceTabButton = tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
// Verify we're in reference mode - the composite fields should not be visible
await expect(firstShadowFields).not.toBeVisible();
// Switch back to composite tab
const compositeTabButton =
tokensUpdateCreateModal.getByTestId("composite-opt");
const compositeTabButton = tokensUpdateCreateModal.getByTestId("composite-opt");
await compositeTabButton.click();
// Verify that shadows are restored
@@ -1406,21 +1414,11 @@ test.describe("Tokens: Apply token", () => {
await expect(newSecondShadowFields).toBeVisible();
// Verify first shadow values are still there
const restoredFirstOffsetX = await firstShadowFields
.getByLabel("X")
.inputValue();
const restoredFirstOffsetY = await firstShadowFields
.getByLabel("Y")
.inputValue();
const restoredFirstBlur = await firstShadowFields
.getByLabel("Blur")
.inputValue();
const restoredFirstSpread = await firstShadowFields
.getByLabel("Spread")
.inputValue();
const restoredFirstColor = await firstShadowFields
.getByLabel("Color")
.inputValue();
const restoredFirstOffsetX = await firstShadowFields.getByLabel("X").inputValue();
const restoredFirstOffsetY = await firstShadowFields.getByLabel("Y").inputValue();
const restoredFirstBlur = await firstShadowFields.getByLabel("Blur").inputValue();
const restoredFirstSpread = await firstShadowFields.getByLabel("Spread").inputValue();
const restoredFirstColor = await firstShadowFields.getByLabel("Color").inputValue();
await expect(restoredFirstOffsetX).toBe("2");
await expect(restoredFirstOffsetY).toBe("2");
@@ -1429,21 +1427,11 @@ test.describe("Tokens: Apply token", () => {
await expect(restoredFirstColor).toBe(firstColorValue);
// Verify second shadow values are still there
const restoredSecondOffsetX = await newSecondShadowFields
.getByLabel("X")
.inputValue();
const restoredSecondOffsetY = await newSecondShadowFields
.getByLabel("Y")
.inputValue();
const restoredSecondBlur = await newSecondShadowFields
.getByLabel("Blur")
.inputValue();
const restoredSecondSpread = await newSecondShadowFields
.getByLabel("Spread")
.inputValue();
const restoredSecondColor = await newSecondShadowFields
.getByLabel("Color")
.inputValue();
const restoredSecondOffsetX = await newSecondShadowFields.getByLabel("X").inputValue();
const restoredSecondOffsetY = await newSecondShadowFields.getByLabel("Y").inputValue();
const restoredSecondBlur = await newSecondShadowFields.getByLabel("Blur").inputValue();
const restoredSecondSpread = await newSecondShadowFields.getByLabel("Spread").inputValue();
const restoredSecondColor = await newSecondShadowFields.getByLabel("Color").inputValue();
await expect(restoredSecondOffsetX).toBe("10");
await expect(restoredSecondOffsetY).toBe("10");

View File

@@ -35,62 +35,27 @@ const setupVariantsFileWithVariant = async (workspacePage) => {
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.waitForTimeout(500);
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.waitForTimeout(500);
// We wait until layer-row starts looking like it an component
await workspacePage.page
.getByTestId("layer-row")
.filter({ hasText: "Rectangle" })
.getByTestId("icon-component")
.waitFor();
};
const findVariant = async (workspacePage, index) => {
const container = workspacePage.layers
const findVariant = async (workspacePage, num_variant) => {
const container = await workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Rectangle" })
.filter({ has: workspacePage.page.getByText("Rectangle") })
.filter({ has: workspacePage.page.getByTestId("icon-component") })
.nth(index);
.nth(num_variant);
const variant1 = workspacePage.layers
const variant1 = await workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Value 1" })
.filter({ has: workspacePage.page.getByText("Value 1") })
.filter({ has: workspacePage.page.getByTestId("icon-variant") })
.nth(index);
.nth(num_variant);
const variant2 = workspacePage.layers
const variant2 = await workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Value 2" })
.filter({ has: workspacePage.page.getByText("Value 2") })
.filter({ has: workspacePage.page.getByTestId("icon-variant") })
.nth(index);
await container.waitFor();
return {
container: container,
variant1: variant1,
variant2: variant2,
};
};
const findVariantNoWait = (workspacePage, index) => {
const container = workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Rectangle" })
.filter({ has: workspacePage.page.getByTestId("icon-component") })
.nth(index);
const variant1 = workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Value 1" })
.nth(index);
const variant2 = workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Value 2" })
.nth(index);
.nth(num_variant);
return {
container: container,
@@ -173,33 +138,27 @@ test("User copy paste a variant container", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupVariantsFileWithVariant(workspacePage);
const variant = findVariantNoWait(workspacePage, 0);
// await variant.container.waitFor();
const variant = await findVariant(workspacePage, 0);
// Select the variant container
await variant.container.click();
await workspacePage.page.waitForTimeout(1000);
// Copy the variant container
//Copy the variant container
await workspacePage.page.keyboard.press("Control+c");
// Paste the variant container
await workspacePage.clickAt(400, 400);
//Paste the variant container
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
const variantDuplicate = findVariantNoWait(workspacePage, 0);
const variantOriginal = findVariantNoWait(workspacePage, 1);
const variant_original = await findVariant(workspacePage, 1);
const variant_duplicate = await findVariant(workspacePage, 0);
// Expand the layers
await variantDuplicate.container.waitFor();
await variantDuplicate.container.locator("button").first().click();
await variant_duplicate.container.getByRole("button").first().click();
// // The variants are valid
// // await variantOriginal.container.waitFor();
await validateVariant(variantOriginal);
await validateVariant(variantDuplicate);
// The variants are valid
await validateVariant(variant_original);
await validateVariant(variant_duplicate);
});
test("User cut paste a variant container", async ({ page }) => {
@@ -213,23 +172,21 @@ test("User cut paste a variant container", async ({ page }) => {
//Cut the variant container
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
//Paste the variant container
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.page.waitForTimeout(500);
const variantPasted = await findVariant(workspacePage, 0);
const variant_pasted = await findVariant(workspacePage, 0);
// Expand the layers
await variantPasted.container.locator("button").first().click();
await variant_pasted.container.getByRole("button").first().click();
// The variants are valid
await validateVariant(variantPasted);
await validateVariant(variant_pasted);
});
test("User cut paste a variant container into a board, and undo twice", async ({
test("[Bugfixing] User cut paste a variant container into a board, and undo twice", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
@@ -248,7 +205,6 @@ test("User cut paste a variant container into a board, and undo twice", async ({
//Cut the variant container
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
//Select the board
await workspacePage.clickLeafLayer("Board");
@@ -259,12 +215,11 @@ test("User cut paste a variant container into a board, and undo twice", async ({
//Undo twice
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.waitForTimeout(500);
const variantAfterUndo = await findVariant(workspacePage, 0);
const variant_after_undo = await findVariant(workspacePage, 0);
// The variants are valid
await validateVariant(variantAfterUndo);
await validateVariant(variant_after_undo);
});
test("User copy paste a variant", async ({ page }) => {
@@ -409,7 +364,7 @@ test("User drag and drop a component with path inside a variant", async ({
const workspacePage = new WorkspacePage(page);
await setupVariantsFileWithVariant(workspacePage);
const variant = findVariantNoWait(workspacePage, 0);
const variant = await findVariant(workspacePage, 0);
//Create a component
await workspacePage.ellipseShapeButton.click();
@@ -449,12 +404,11 @@ test("User cut paste a variant into another container", async ({ page }) => {
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("Control+k");
const variantOrigin = await findVariantNoWait(workspacePage, 1);
const variant_origin = await findVariant(workspacePage, 1);
const variant_target = await findVariant(workspacePage, 0);
// Select the variant1
await variantOrigin.variant1.waitFor();
await variantOrigin.variant1.click();
await variantOrigin.variant1.click();
await variant_origin.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+x");
@@ -463,7 +417,7 @@ test("User cut paste a variant into another container", async ({ page }) => {
await workspacePage.layers.getByText("Ellipse").first().click();
await workspacePage.page.keyboard.press("Control+v");
const variant3 = workspacePage.layers
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
.filter({ has: workspacePage.page.getByText("Value 1, rectangle") })
.filter({ has: workspacePage.page.getByTestId("icon-variant") })

View File

@@ -46,9 +46,6 @@ test("Save and restore version", async ({ page }) => {
await page.getByLabel("History").click();
const saveVersionButton = page.getByRole("button", { name: "Save version" });
await saveVersionButton.waitFor();
await workspacePage.mockRPC(
"create-file-snapshot",
"workspace/versions-take-snapshot-1.json",
@@ -59,21 +56,18 @@ test("Save and restore version", async ({ page }) => {
"workspace/versions-snapshot-2.json",
);
await page.getByRole("button", { name: "Save version" }).click();
await workspacePage.mockRPC(
"update-file-snapshot",
"workspace/versions-update-snapshot-1.json",
);
await saveVersionButton.click();
await workspacePage.mockRPC(
"get-file-snapshots?file-id=*",
"workspace/versions-snapshot-3.json",
);
const textbox = page.getByRole("textbox");
await textbox.waitFor();
await page.getByRole("textbox").fill("INIT");
await page.getByRole("textbox").press("Enter");
@@ -82,14 +76,14 @@ test("Save and restore version", async ({ page }) => {
.locator("div")
.nth(3)
.hover();
await page.getByRole("button", { name: "Open version menu" }).click();
await page.getByRole("button", { name: "Restore" }).click();
await workspacePage.mockRPC(
"restore-file-snapshot",
"workspace/versions-restore-snapshot-1.json",
);
await page.getByRole("button", { name: "Open version menu" }).click();
await page.getByRole("button", { name: "Restore" }).click();
await page.getByRole("button", { name: "Restore" }).click();
// check that the history panel is closed after restore

View File

@@ -248,6 +248,14 @@ test("Bug 9066 - Problem with grid layout", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9066.json");
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=e179d9df-de35-80bf-8005-2861e849b3f7",
"workspace/get-file-fragment-9066-1.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=e179d9df-de35-80bf-8005-2861e849785e",
"workspace/get-file-fragment-9066-2.json",
);
await workspacePage.mockRPC(
"update-file?id=*",

View File

@@ -161,4 +161,4 @@ test.describe("Palette", () => {
workspace.palette.getByRole("button", { name: "#7798ff" }),
).toBeVisible();
});
});
});

View File

@@ -180,7 +180,7 @@ export async function watch(baseDir, predicate, callback) {
});
}
async function readManifestFile() {
async function readManifestFile(path) {
const manifestPath = "resources/public/js/manifest.json";
let content = await fs.readFile(manifestPath, { encoding: "utf8" });
return JSON.parse(content);
@@ -189,19 +189,27 @@ async function readManifestFile() {
async function readShadowManifest() {
const ts = Date.now();
try {
const content = await readManifestFile();
const content1 = await readManifestFile(
"resources/public/js/manifest.json",
);
const content2 = await readManifestFile(
"resources/public/js/worker/manifest.json",
);
const index = {
ts: ts,
config: "js/config.js?ts=" + ts,
polyfills: "js/polyfills.js?ts=" + ts,
worker_main: "js/worker/main.js?ts=" + ts,
};
for (let item of content) {
for (let item of content1) {
index[item.name] = "js/" + item["output-name"];
}
for (let item of content2) {
index["worker_" + item.name] = "js/worker/" + item["output-name"];
}
return index;
} catch (cause) {
return {

View File

@@ -33,7 +33,7 @@ const config = {
bundle: true,
format: "iife",
banner: {
js: '"use strict"; var global = globalThis;',
js: '"use strict";',
},
outfile: "resources/public/js/libs.js",
plugins: [fixReactVirtualized, rebuildNotify],

View File

@@ -83,7 +83,7 @@
:source-map-detail-level :all}}}
:worker
{:target :esm
{:target :browser
:output-dir "resources/public/js/worker/"
:asset-path "/js/worker"
:devtools {:browser-inject :main

View File

@@ -104,6 +104,7 @@
(def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" [])))
(def templates-uri (obj/get global "penpotTemplatesUri" "https://penpot.github.io/penpot-files/"))
;; We set the current parsed flags under common for make
;; it available for common code without the need to pass
;; the flags all arround on parameters.

View File

@@ -301,4 +301,6 @@
:width 1280
:height 720}])
(def zoom-half-pixel-precision 8)
(def max-input-length 255)

View File

@@ -195,9 +195,7 @@
(ptk/reify ::login-from-token
ptk/WatchEvent
(watch [_ _ _]
(->> (dp/on-fetch-profile-success profile)
(rx/map (fn [profile]
(logged-in (with-meta profile {::ev/source "login-with-token"}))))
(->> (rx/of (logged-in (with-meta profile {::ev/source "login-with-token"})))
;; NOTE: we need this to be asynchronous because the effect
;; should be called before proceed with the login process
(rx/observe-on :async)))))

View File

@@ -99,18 +99,16 @@
(watch [_ state _]
(let [file-id (:current-file-id state)
page-id (:current-page-id state)
exports (mapv (fn [frame]
{:enabled true
:page-id page-id
:file-id file-id
:object-id (:id frame)
:shape frame
:name (:name frame)})
frames)]
exports (for [frame frames]
{:enabled true
:page-id page-id
:file-id file-id
:object-id (:id frame)
:shape frame
:name (:name frame)})]
(rx/of (modal/show :export-frames
{:exports exports
:origin "workspace:menu"}))))))
{:exports (vec exports) :origin "workspace:menu"}))))))
(defn- initialize-export-status
[exports cmd resource]
@@ -129,7 +127,7 @@
:cmd cmd}))))
(defn- update-export-status
[{:keys [done status resource-uri filename mtype] :as data}]
[{:keys [done status resource-id filename] :as data}]
(ptk/reify ::update-export-status
ptk/UpdateEvent
(update [_ state]
@@ -148,7 +146,9 @@
ptk/WatchEvent
(watch [_ _ _]
(when (= status "ended")
(dom/trigger-download-uri filename mtype resource-uri)))))
(->> (rp/cmd! :export {:cmd :get-resource :blob? true :id resource-id})
(rx/delay 500)
(rx/map #(dom/trigger-download filename %)))))))
(defn request-simple-export
[{:keys [export]}]
@@ -174,14 +174,17 @@
(rx/timeout 400 (rx/empty)))
(->> (rp/cmd! :export params)
(rx/map (fn [{:keys [filename mtype uri]}]
(dom/trigger-download-uri filename mtype uri)
(clear-export-state uuid/zero)))
(rx/mapcat (fn [{:keys [id filename]}]
(->> (rp/cmd! :export {:cmd :get-resource :blob? true :id id})
(rx/map (fn [data]
(dom/trigger-download filename data)
(clear-export-state uuid/zero))))))
(rx/catch (fn [cause]
(rx/concat
(rx/of (clear-export-state uuid/zero))
(rx/throw cause))))))))))
(defn request-multiple-export
[{:keys [exports cmd]
:or {cmd :export-shapes}

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