mirror of
https://github.com/penpot/penpot.git
synced 2026-01-04 12:28:52 -05:00
Compare commits
134 Commits
elenatorro
...
test-inner
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
369979ffe6 | ||
|
|
db0cbbbc2e | ||
|
|
48304bd26f | ||
|
|
60e32bbc71 | ||
|
|
54451608dc | ||
|
|
b7727122d5 | ||
|
|
8880f07a6a | ||
|
|
aaca2c41d8 | ||
|
|
33417a4b20 | ||
|
|
2640889dc8 | ||
|
|
dd5f3396d1 | ||
|
|
dedeae8641 | ||
|
|
a7552d412a | ||
|
|
f58475a7c9 | ||
|
|
00bbb0bfb6 | ||
|
|
d93fe89c12 | ||
|
|
6e44330af4 | ||
|
|
624805fd6b | ||
|
|
9b6bb77422 | ||
|
|
9b8e04bb3c | ||
|
|
2e919809c9 | ||
|
|
645e123e3a | ||
|
|
cfb94d17b6 | ||
|
|
e9cb409ca4 | ||
|
|
8a0cd75257 | ||
|
|
fae488b15a | ||
|
|
b82828632e | ||
|
|
bf24e22588 | ||
|
|
7399b4d423 | ||
|
|
77b9eee6bd | ||
|
|
55896db49e | ||
|
|
f4c569d619 | ||
|
|
ca2cf18a49 | ||
|
|
6e352c167c | ||
|
|
3ec001de44 | ||
|
|
a1f11c89f2 | ||
|
|
33d70f0e45 | ||
|
|
4f24a8f5f1 | ||
|
|
b03cfffb9e | ||
|
|
956ad88e51 | ||
|
|
76f5c73de6 | ||
|
|
c6dd3e0eeb | ||
|
|
fde73f30b9 | ||
|
|
9d35a4317c | ||
|
|
e7ccfeccbf | ||
|
|
aa043d284f | ||
|
|
537dd171c0 | ||
|
|
c2026918a4 | ||
|
|
0120a5335b | ||
|
|
d0d2f43ca1 | ||
|
|
7e33a7c1a7 | ||
|
|
c13b58f42a | ||
|
|
a5c9f9e454 | ||
|
|
d73be5832b | ||
|
|
e1f2fca4af | ||
|
|
37d5a31589 | ||
|
|
177bdaa72c | ||
|
|
38ab2c61b9 | ||
|
|
cc32b22e8a | ||
|
|
d331c5ad83 | ||
|
|
6c6c2c3012 | ||
|
|
81632a03dd | ||
|
|
4fddf3d986 | ||
|
|
57aa9a585b | ||
|
|
f71f491590 | ||
|
|
6ae2401c5e | ||
|
|
53d8a2d6d7 | ||
|
|
bd65f3932e | ||
|
|
59845b756f | ||
|
|
b8c0c5c310 | ||
|
|
cfa8c21ee6 | ||
|
|
624bdaec88 | ||
|
|
24745bed40 | ||
|
|
d26c08f8e2 | ||
|
|
36adbd9118 | ||
|
|
0a3fe9836a | ||
|
|
fef0c11503 | ||
|
|
7e858784a1 | ||
|
|
203368c2ee | ||
|
|
4f54469629 | ||
|
|
5343e799f8 | ||
|
|
51e54a6bad | ||
|
|
f609747322 | ||
|
|
26ad039d99 | ||
|
|
3136096123 | ||
|
|
122d3bc41c | ||
|
|
3b52051113 | ||
|
|
32e1b55658 | ||
|
|
e9d177eae3 | ||
|
|
d42c65b9ca | ||
|
|
86ad56797b | ||
|
|
63497b8930 | ||
|
|
94719eebf8 | ||
|
|
9532dea2c6 | ||
|
|
40e1e27bf0 | ||
|
|
4338f97e9f | ||
|
|
2c4ec43d5f | ||
|
|
3d782a322d | ||
|
|
407d28d187 | ||
|
|
bf582ec55f | ||
|
|
858bc05ed5 | ||
|
|
cd01386210 | ||
|
|
3b2bb5f225 | ||
|
|
fe3bc96d0d | ||
|
|
28f23f397e | ||
|
|
a487dfe004 | ||
|
|
4f29156929 | ||
|
|
ce2d3d1652 | ||
|
|
3639ff9dbc | ||
|
|
ca5ec734a0 | ||
|
|
b08da4c3ff | ||
|
|
c9bec3924d | ||
|
|
6e725a75e1 | ||
|
|
81c3b84972 | ||
|
|
5868f7f6b2 | ||
|
|
653567d7de | ||
|
|
ce651fa0a9 | ||
|
|
e8a26ef83b | ||
|
|
8fd17c9c84 | ||
|
|
d03f5c10fb | ||
|
|
3eb0f1c225 | ||
|
|
48c9fb5690 | ||
|
|
4cdf1eed0c | ||
|
|
69c4a8932a | ||
|
|
f6e77c09b3 | ||
|
|
e7b8ad8ee2 | ||
|
|
ccb7b41b3a | ||
|
|
597fba79cc | ||
|
|
43b03b9714 | ||
|
|
4739c4730c | ||
|
|
603bb860ba | ||
|
|
55d9ca1439 | ||
|
|
a2f397c329 | ||
|
|
ada4e72c27 |
@@ -114,7 +114,7 @@ jobs:
|
||||
# uses the same cache as this task so we prepopulate it
|
||||
command: |
|
||||
yarn install
|
||||
yarn run playwright install chromium
|
||||
yarn run playwright install chromium --with-deps
|
||||
|
||||
- run:
|
||||
name: "lint scss on frontend"
|
||||
@@ -207,51 +207,6 @@ 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
|
||||
@@ -347,5 +302,4 @@ workflows:
|
||||
- lint: success
|
||||
|
||||
- lint
|
||||
- test-integration
|
||||
- test-render-wasm
|
||||
|
||||
315
.github/workflows/tests.yml
vendored
Normal file
315
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,315 @@
|
||||
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
|
||||
27
CHANGES.md
27
CHANGES.md
@@ -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 previou PATH is preserved for backward
|
||||
`/api/main/methods/<name>`. The previous 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,15 +45,33 @@ 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
|
||||
|
||||
- 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 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)
|
||||
- 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
|
||||
|
||||
@@ -68,6 +86,7 @@ provider dinamically.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
<Logger name="app.storage.tmp" level="info" />
|
||||
<Logger name="app.worker" level="trace" />
|
||||
<Logger name="app.msgbus" level="info" />
|
||||
<Logger name="app.http.websocket" level="info" />
|
||||
<Logger name="app.http.sse" level="info" />
|
||||
<Logger name="app.http" level="info" />
|
||||
<Logger name="app.util.websocket" level="info" />
|
||||
<Logger name="app.redis" level="info" />
|
||||
<Logger name="app.rpc.rlimit" level="info" />
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
<Logger name="app.storage.tmp" level="info" />
|
||||
<Logger name="app.worker" level="trace" />
|
||||
<Logger name="app.msgbus" level="info" />
|
||||
<Logger name="app.http.websocket" level="info" />
|
||||
<Logger name="app.http.sse" level="info" />
|
||||
<Logger name="app.http" level="info" />
|
||||
<Logger name="app.util.websocket" level="info" />
|
||||
<Logger name="app.redis" level="info" />
|
||||
<Logger name="app.rpc.rlimit" level="info" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
export PENPOT_MANAGEMENT_API_SHARED_KEY=super-secret-management-api-key
|
||||
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||
export PENPOT_HOST=devenv
|
||||
|
||||
|
||||
@@ -255,6 +255,8 @@
|
||||
|
||||
(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))
|
||||
@@ -279,6 +281,8 @@
|
||||
|
||||
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)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.config
|
||||
"A configuration management."
|
||||
(:refer-clojure :exclude [get])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
@@ -103,7 +102,7 @@
|
||||
[:http-server-io-threads {:optional true} ::sm/int]
|
||||
[:http-server-max-worker-threads {:optional true} ::sm/int]
|
||||
|
||||
[:management-api-shared-key {:optional true} :string]
|
||||
[:management-api-key {:optional true} :string]
|
||||
|
||||
[:telemetry-uri {:optional true} :string]
|
||||
[:telemetry-with-taiga {:optional true} ::sm/boolean] ;; DELETE
|
||||
|
||||
@@ -50,23 +50,27 @@
|
||||
(db/tx-run! cfg handler request)))))})
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ cfg]
|
||||
["" {:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]
|
||||
[default-system cfg]
|
||||
[transaction]]}
|
||||
["/authenticate"
|
||||
{:handler authenticate
|
||||
:allowed-methods #{:post}}]
|
||||
[_ {:keys [::setup/props] :as cfg}]
|
||||
|
||||
["/get-customer"
|
||||
{:handler get-customer
|
||||
:transaction true
|
||||
:allowed-methods #{:post}}]
|
||||
(let [management-key (or (cf/get :management-api-key)
|
||||
(get props :management-key))]
|
||||
|
||||
["/update-customer"
|
||||
{:handler update-customer
|
||||
:allowed-methods #{:post}
|
||||
:transaction true}]])
|
||||
["" {: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}]]))
|
||||
|
||||
;; ---- HELPERS
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
[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]
|
||||
@@ -242,7 +244,6 @@
|
||||
(handler request)
|
||||
{::yres/status 405}))))))})
|
||||
|
||||
|
||||
(defn- wrap-auth
|
||||
[handler decoders]
|
||||
(let [token-re
|
||||
@@ -272,9 +273,24 @@
|
||||
process-request
|
||||
(fn [request]
|
||||
(if-let [{:keys [type token] :as auth} (get-token request)]
|
||||
(if-let [decode-fn (get decoders type)]
|
||||
(assoc request ::http/auth-data (assoc auth :claims (decode-fn token)))
|
||||
(assoc request ::http/auth-data auth))
|
||||
(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))))
|
||||
|
||||
request))]
|
||||
|
||||
(fn [request]
|
||||
@@ -287,11 +303,14 @@
|
||||
(defn- wrap-shared-key-auth
|
||||
[handler shared-key]
|
||||
(if shared-key
|
||||
(fn [request]
|
||||
(let [key (yreq/get-header request "x-shared-key")]
|
||||
(if (= key shared-key)
|
||||
(handler request)
|
||||
{::yres/status 403})))
|
||||
(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 [_ _]
|
||||
{::yres/status 403})))
|
||||
|
||||
|
||||
@@ -93,15 +93,15 @@
|
||||
(update-session [_ session]
|
||||
(let [modified-at (ct/now)]
|
||||
(if (string? (:id session))
|
||||
(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/insert! pool :http-session-v2
|
||||
(-> session
|
||||
(assoc :id (uuid/next))
|
||||
(assoc :created-at modified-at)
|
||||
(assoc :modified-at modified-at)))
|
||||
(db/update! pool :http-session-v2
|
||||
{:modified-at modified-at}
|
||||
{:id (:id session)}))))
|
||||
{:id (:id session)}
|
||||
{::db/return-keys true}))))
|
||||
|
||||
(delete-session [_ id]
|
||||
(if (string? id)
|
||||
@@ -158,14 +158,15 @@
|
||||
|
||||
(defn- assign-token
|
||||
[cfg session]
|
||||
(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)})]
|
||||
(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)]
|
||||
(assoc session :token token)))
|
||||
|
||||
(defn create-fn
|
||||
@@ -225,13 +226,14 @@
|
||||
[handler {:keys [::manager] :as cfg}]
|
||||
(assert (manager? manager) "expected valid session manager")
|
||||
(fn [request]
|
||||
(let [{:keys [type token claims]} (get request ::http/auth-data)]
|
||||
(let [{:keys [type token claims metadata]} (get request ::http/auth-data)]
|
||||
(cond
|
||||
(= type :cookie)
|
||||
(let [session (if-let [sid (:sid claims)]
|
||||
(read-session manager sid)
|
||||
(let [session (case (:ver metadata)
|
||||
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
|
||||
(read-session manager token))
|
||||
0 (read-session manager token)
|
||||
1 (some->> (:sid claims) (read-session manager))
|
||||
nil)
|
||||
|
||||
request (cond-> request
|
||||
(some? session)
|
||||
@@ -240,7 +242,7 @@
|
||||
|
||||
response (handler request)]
|
||||
|
||||
(if (renew-session? session)
|
||||
(if (and session (renew-session? session))
|
||||
(let [session (->> session
|
||||
(update-session manager)
|
||||
(assign-token cfg))]
|
||||
@@ -248,11 +250,11 @@
|
||||
response))
|
||||
|
||||
(= type :bearer)
|
||||
(let [session (if-let [sid (:sid claims)]
|
||||
(read-session manager sid)
|
||||
(let [session (case (:ver metadata)
|
||||
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
|
||||
(read-session manager token))
|
||||
|
||||
0 (read-session manager token)
|
||||
1 (some->> (:sid claims) (read-session manager))
|
||||
nil)
|
||||
request (cond-> request
|
||||
(some? session)
|
||||
(-> (assoc ::profile-id (:profile-id session))
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
ctx (-> context
|
||||
(assoc :tenant (cf/get :tenant))
|
||||
(assoc :host (cf/get :host))
|
||||
(assoc :public-uri (cf/get :public-uri))
|
||||
(assoc :public-uri (str (cf/get :public-uri)))
|
||||
(assoc :logger/name logger)
|
||||
(assoc :logger/level level)
|
||||
(dissoc :request/params :value :params :data))]
|
||||
|
||||
@@ -295,7 +295,8 @@
|
||||
[cfg]
|
||||
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
|
||||
(->> (sv/scan-ns
|
||||
'app.rpc.management.subscription)
|
||||
'app.rpc.management.subscription
|
||||
'app.rpc.management.exporter)
|
||||
(map (partial process-method cfg "management" wrap-management))
|
||||
(into {}))))
|
||||
|
||||
@@ -346,14 +347,16 @@
|
||||
(assert (valid-methods? (::management-methods params)) "expect valid methods map"))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [::methods ::management-methods] :as cfg}]
|
||||
[_ {: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))]
|
||||
|
||||
(let [public-uri (cf/get :public-uri)]
|
||||
["/api"
|
||||
|
||||
["/management"
|
||||
["/methods/:type"
|
||||
{:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]
|
||||
{:middleware [[mw/shared-key-auth management-key]
|
||||
[session/authz cfg]]
|
||||
:handler (make-rpc-handler management-methods)}]
|
||||
|
||||
|
||||
@@ -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,10 +25,12 @@
|
||||
[app.rpc.commands.projects :as projects]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.tasks.file-gc]
|
||||
[app.util.services :as sv]
|
||||
[app.worker :as-alias wrk]))
|
||||
[app.worker :as-alias wrk]
|
||||
[datoteka.fs :as fs]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -38,52 +40,42 @@
|
||||
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 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))))))
|
||||
(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-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))))))
|
||||
(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)))))
|
||||
|
||||
(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 version file-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(files/check-read-permissions! pool profile-id file-id)
|
||||
(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))))
|
||||
(sse/response (partial export-binfile cfg params)))
|
||||
|
||||
;; --- Command: import-binfile
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
fullname (str "Demo User " sem)
|
||||
|
||||
password (-> (bn/random-bytes 16)
|
||||
(bc/bytes->b64u)
|
||||
(bc/bytes->b64 true)
|
||||
(bc/bytes->str))
|
||||
|
||||
params {:email email
|
||||
|
||||
@@ -1209,7 +1209,7 @@
|
||||
;; --- MUTATION COMMAND: restore-files-immediatelly
|
||||
|
||||
(def ^:private sql:resolve-editable-files
|
||||
"SELECT f.id
|
||||
"SELECT f.id, f.project_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,18 +1250,38 @@
|
||||
{: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)
|
||||
|
||||
(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)])))
|
||||
{: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))
|
||||
|
||||
(def ^:private schema:restore-deleted-team-files
|
||||
[:map {:title "restore-deleted-team-files"}
|
||||
@@ -1269,8 +1289,8 @@
|
||||
[:ids [::sm/set ::sm/uuid]]])
|
||||
|
||||
(sv/defmethod ::restore-deleted-team-files
|
||||
"Removes the deletion mark from the specified files (and respective projects)."
|
||||
|
||||
"Removes the deletion mark from the specified files (and respective
|
||||
projects) on the specified team."
|
||||
{::doc/added "2.12"
|
||||
::sse/stream? true
|
||||
::sm/params schema:restore-deleted-team-files}
|
||||
|
||||
@@ -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}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [data id] :as file} strip-frames-with-thumbnails]
|
||||
(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.
|
||||
:always
|
||||
strip-frames-with-thumbnails
|
||||
(update :objects assoc-thumbnails page-id thumbs)))))
|
||||
|
||||
(def ^:private
|
||||
@@ -186,7 +186,8 @@
|
||||
[:map {:title "PartialFile"}
|
||||
[:id ::sm/uuid]
|
||||
[:revn {:min 0} ::sm/int]
|
||||
[:page [:map-of :keyword ::sm/any]]])
|
||||
[:page [:map-of :keyword ::sm/any]]
|
||||
[:strip-frames-with-thumbnails {:optional true} ::sm/boolean]])
|
||||
|
||||
(sv/defmethod ::get-file-data-for-thumbnail
|
||||
"Retrieves the data for generate the thumbnail of the file. Used
|
||||
@@ -195,7 +196,7 @@
|
||||
::doc/module :files
|
||||
::sm/params schema:get-file-data-for-thumbnail
|
||||
::sm/result schema:partial-file}
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
[cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
|
||||
@@ -205,14 +206,18 @@
|
||||
|
||||
file (bfc/get-file cfg file-id
|
||||
:realize? true
|
||||
:read-only? 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))]
|
||||
|
||||
(-> (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)}))))
|
||||
:page (get-file-data-for-thumbnail cfg file strip-frames-with-thumbnails)}))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; MUTATION COMMANDS
|
||||
|
||||
@@ -169,12 +169,19 @@
|
||||
;; --- MUTATION: Create Project
|
||||
|
||||
(defn- create-project
|
||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||
(let [project (teams/create-project conn params)]
|
||||
[{: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)]
|
||||
(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)))
|
||||
|
||||
@@ -39,9 +39,8 @@
|
||||
(defn- encode
|
||||
[s]
|
||||
(-> s
|
||||
bh/blake2b-256
|
||||
bc/bytes->b64u
|
||||
bc/bytes->str))
|
||||
(bh/blake2b-256)
|
||||
(bc/bytes->b64-str true)))
|
||||
|
||||
(defn- fmt-key
|
||||
[s]
|
||||
|
||||
49
backend/src/app/rpc/management/exporter.clj
Normal file
49
backend/src/app/rpc/management/exporter.clj
Normal file
@@ -0,0 +1,49 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(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))))}))
|
||||
@@ -22,8 +22,7 @@
|
||||
(defn- generate-random-key
|
||||
[]
|
||||
(-> (bn/random-bytes 64)
|
||||
(bc/bytes->b64u)
|
||||
(bc/bytes->str)))
|
||||
(bc/bytes->b64-str true)))
|
||||
|
||||
(defn- get-all-props
|
||||
[conn]
|
||||
@@ -85,12 +84,11 @@
|
||||
(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)))))))
|
||||
|
||||
;; FIXME
|
||||
(sm/register! ::props :any)
|
||||
(sm/register! ::props [:map-of :keyword ::sm/any])
|
||||
|
||||
@@ -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}}]
|
||||
(us/assert! ::us/not-empty-string secret-key)
|
||||
(assert (string? secret-key) "expect string")
|
||||
(assert (seq secret-key) "expect string")
|
||||
(let [engine (bk/engine {:key secret-key
|
||||
:salt salt
|
||||
:alg :hkdf
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"file-object-thumbnail"
|
||||
"file-thumbnail"
|
||||
"profile"
|
||||
"tempfile"
|
||||
"file-data"
|
||||
"file-data-fragment"
|
||||
"file-change"})
|
||||
@@ -163,9 +164,6 @@
|
||||
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
|
||||
@@ -177,9 +175,15 @@
|
||||
(keyword (:backend res))
|
||||
mdata)))
|
||||
|
||||
(defn- retrieve-database-object
|
||||
(def ^:private sql:get-storage-object
|
||||
"SELECT *
|
||||
FROM storage_object
|
||||
WHERE id = ?
|
||||
AND (deleted_at IS NULL)")
|
||||
|
||||
(defn- get-database-object
|
||||
[conn id]
|
||||
(some-> (db/exec-one! conn [sql:retrieve-storage-object id])
|
||||
(some-> (db/exec-one! conn [sql:get-storage-object id])
|
||||
(row->storage-object)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -202,7 +206,7 @@
|
||||
(defn get-object
|
||||
[{:keys [::db/connectable] :as storage} id]
|
||||
(assert (valid-storage? storage))
|
||||
(retrieve-database-object connectable id))
|
||||
(get-database-object connectable id))
|
||||
|
||||
(defn put-object!
|
||||
"Creates a new object with the provided content."
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
(into #{} (map :id))
|
||||
(not-empty))))
|
||||
|
||||
|
||||
(def ^:private sql:delete-sobjects
|
||||
"DELETE FROM storage_object
|
||||
WHERE id = ANY(?::uuid[])")
|
||||
@@ -77,47 +76,37 @@
|
||||
(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 < now() - ?::interval
|
||||
AND s.deleted_at <= ?
|
||||
ORDER BY s.deleted_at ASC")
|
||||
|
||||
(defn- get-buckets
|
||||
[conn min-age]
|
||||
(let [age (db/interval min-age)]
|
||||
[conn]
|
||||
(let [now (ct/now)]
|
||||
(sequence
|
||||
(comp (partition-all 25)
|
||||
(mapcat group-by-backend))
|
||||
(db/cursor conn [sql:get-deleted-sobjects age]))))
|
||||
|
||||
(db/cursor conn [sql:get-deleted-sobjects now]))))
|
||||
|
||||
(defn- clean-deleted!
|
||||
[{:keys [::db/conn ::min-age] :as cfg}]
|
||||
[{:keys [::db/conn] :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 min-age)))
|
||||
(get-buckets conn)))
|
||||
|
||||
(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
|
||||
[_ {: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}))))))
|
||||
[_ cfg]
|
||||
(fn [_]
|
||||
(db/tx-run! cfg (fn [cfg]
|
||||
(let [total (clean-deleted! cfg)]
|
||||
(l/inf :hint "task finished" :total total)
|
||||
{:deleted total})))))
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
[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]
|
||||
@@ -101,14 +103,15 @@
|
||||
|
||||
(def ^:private sql:mark-delete-in-bulk
|
||||
"UPDATE storage_object
|
||||
SET deleted_at = now(),
|
||||
SET deleted_at = ?,
|
||||
touched_at = NULL
|
||||
WHERE id = ANY(?::uuid[])")
|
||||
|
||||
(defn- mark-delete-in-bulk!
|
||||
[conn ids]
|
||||
(let [ids (db/create-array conn "uuid" ids)]
|
||||
(db/exec-one! conn [sql:mark-delete-in-bulk ids])))
|
||||
[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])))
|
||||
|
||||
;; NOTE: A getter that retrieves the key which will be used for group
|
||||
;; ids; previously we have no value, then we introduced the
|
||||
@@ -137,18 +140,20 @@
|
||||
(if-let [{:keys [id] :as object} (first objects)]
|
||||
(if (has-refs? conn object)
|
||||
(do
|
||||
(l/debug :id (str id)
|
||||
:status "freeze"
|
||||
:bucket bucket)
|
||||
(l/dbg :id (str id)
|
||||
:status "freeze"
|
||||
:bucket bucket)
|
||||
(recur (conj to-freeze id) to-delete (rest objects)))
|
||||
(do
|
||||
(l/debug :id (str id)
|
||||
:status "delete"
|
||||
:bucket bucket)
|
||||
(l/dbg :id (str id)
|
||||
:status "delete"
|
||||
:bucket bucket)
|
||||
(recur to-freeze (conj to-delete id) (rest objects))))
|
||||
(do
|
||||
(let [deletion-delay (if (= bucket "tempfile")
|
||||
(ct/duration {:hours 2})
|
||||
(cf/get-deletion-delay))]
|
||||
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
|
||||
(some->> (seq to-delete) (mark-delete-in-bulk! conn))
|
||||
(some->> (seq to-delete) (mark-delete-in-bulk! conn deletion-delay))
|
||||
[(count to-freeze) (count to-delete)]))))
|
||||
|
||||
(defn- process-bucket!
|
||||
@@ -160,6 +165,7 @@
|
||||
"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))))
|
||||
@@ -173,27 +179,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]
|
||||
(->> (db/exec! conn [sql:get-touched-storage-objects])
|
||||
[conn timestamp]
|
||||
(->> (db/exec! conn [sql:get-touched-storage-objects timestamp])
|
||||
(map impl/decode-row)
|
||||
(not-empty)))
|
||||
|
||||
(defn- process-touched!
|
||||
[{:keys [::db/pool] :as cfg}]
|
||||
[{:keys [::db/pool ::timestamp] :as cfg}]
|
||||
(loop [freezed 0
|
||||
deleted 0]
|
||||
(if-let [chunk (get-chunk pool)]
|
||||
(if-let [chunk (get-chunk pool timestamp)]
|
||||
(let [[nfo ndo] (db/tx-run! cfg process-chunk! chunk)]
|
||||
(recur (long (+ freezed nfo))
|
||||
(long (+ deleted ndo))))
|
||||
@@ -209,5 +215,6 @@
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ cfg]
|
||||
(fn [_] (process-touched! cfg)))
|
||||
(fn [_]
|
||||
(process-touched! (assoc cfg ::timestamp (ct/now)))))
|
||||
|
||||
|
||||
@@ -79,14 +79,17 @@
|
||||
;; API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn tempfile
|
||||
[& {:keys [suffix prefix min-age]
|
||||
(defn tempfile*
|
||||
[& {:keys [suffix prefix]
|
||||
:or {prefix "penpot."
|
||||
suffix ".tmp"}}]
|
||||
(let [attrs (fs/make-permissions "rw-r--r--")
|
||||
path (fs/join default-tmp-dir (str prefix (uuid/next) suffix))
|
||||
path (Files/createFile path attrs)]
|
||||
(fs/delete-on-exit! path)
|
||||
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)]
|
||||
(sp/offer! queue [path (some-> min-age ct/duration)])
|
||||
path))
|
||||
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
(def ^:private sql:get-profiles
|
||||
"SELECT id, photo_id FROM profile
|
||||
WHERE deleted_at IS NOT NULL
|
||||
AND deleted_at < now() + ?::interval
|
||||
AND deleted_at <= ?
|
||||
ORDER BY deleted_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn- delete-profiles!
|
||||
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-profiles deletion-threshold chunk-size] {:fetch-size 5})
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-profiles timestamp 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 < now() + ?::interval
|
||||
AND deleted_at <= ?
|
||||
ORDER BY deleted_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn- delete-teams!
|
||||
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-teams deletion-threshold chunk-size] {:fetch-size 5})
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-teams timestamp 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 < now() + ?::interval
|
||||
AND deleted_at <= ?
|
||||
ORDER BY deleted_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn- delete-fonts!
|
||||
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-fonts deletion-threshold chunk-size] {:fetch-size 5})
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-fonts timestamp 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 < now() + ?::interval
|
||||
AND deleted_at <= ?
|
||||
ORDER BY deleted_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn- delete-projects!
|
||||
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-projects deletion-threshold chunk-size] {:fetch-size 5})
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-projects timestamp 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 < now() + ?::interval
|
||||
AND f.deleted_at <= ?
|
||||
ORDER BY f.deleted_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn- delete-files!
|
||||
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-files deletion-threshold chunk-size] {:fetch-size 5})
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-files timestamp 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 < now() + ?::interval
|
||||
AND deleted_at <= ?
|
||||
ORDER BY deleted_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn delete-file-thumbnails!
|
||||
[{: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})
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-file-thumbnails timestamp 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 < now() + ?::interval
|
||||
AND deleted_at <= ?
|
||||
ORDER BY deleted_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn delete-file-object-thumbnails!
|
||||
[{: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})
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-file-object-thumbnails timestamp 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 < now() + ?::interval
|
||||
AND deleted_at <= ?
|
||||
ORDER BY deleted_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn- delete-file-media-objects!
|
||||
[{: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})
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-file-media-objects timestamp 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,16 +231,15 @@
|
||||
"SELECT file_id, id, type, deleted_at, metadata, backend
|
||||
FROM file_data
|
||||
WHERE deleted_at IS NOT NULL
|
||||
AND deleted_at < now() + ?::interval
|
||||
AND deleted_at <= ?
|
||||
ORDER BY deleted_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn- delete-file-data!
|
||||
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
|
||||
|
||||
(->> (db/plan conn [sql:get-file-data deletion-threshold chunk-size] {:fetch-size 5})
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-file-data timestamp chunk-size] {:fetch-size 5})
|
||||
(reduce (fn [total {:keys [file-id id type deleted-at metadata backend]}]
|
||||
|
||||
(some->> metadata
|
||||
@@ -266,15 +265,15 @@
|
||||
"SELECT id, file_id, deleted_at
|
||||
FROM file_change
|
||||
WHERE deleted_at IS NOT NULL
|
||||
AND deleted_at < now() + ?::interval
|
||||
AND deleted_at <= ?
|
||||
ORDER BY deleted_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn- delete-file-changes!
|
||||
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-file-change deletion-threshold chunk-size] {:fetch-size 5})
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
|
||||
(->> (db/plan conn [sql:get-file-change timestamp chunk-size] {:fetch-size 5})
|
||||
(reduce (fn [total {:keys [id file-id deleted-at] :as xlog}]
|
||||
(l/trc :obj "file-change"
|
||||
:id (str id)
|
||||
@@ -322,9 +321,8 @@
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ cfg]
|
||||
(fn [{:keys [props] :as task}]
|
||||
(let [threshold (ct/duration (get props :deletion-threshold 0))
|
||||
cfg (assoc cfg ::deletion-threshold (db/interval threshold))]
|
||||
(fn [_]
|
||||
(let [cfg (assoc cfg ::timestamp (ct/now))]
|
||||
(loop [procs (map deref deletion-proc-vars)
|
||||
total 0]
|
||||
(if-let [proc-fn (first procs)]
|
||||
|
||||
@@ -15,19 +15,25 @@
|
||||
[buddy.sign.jwe :as jwe]))
|
||||
|
||||
(defn generate
|
||||
[{:keys [::setup/props] :as cfg} claims]
|
||||
(assert (contains? cfg ::setup/props))
|
||||
([cfg claims] (generate cfg claims nil))
|
||||
([{:keys [::setup/props] :as cfg} claims header]
|
||||
(assert (contains? props :tokens-key) "expect props to have tokens-key")
|
||||
|
||||
(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})))
|
||||
(jwe/encrypt payload tokens-key {:alg :a256kw :enc :a256gcm :header header}))))
|
||||
|
||||
(defn decode-header
|
||||
[token]
|
||||
(ex/ignoring
|
||||
(jwe/decode-header token)))
|
||||
|
||||
(defn decode
|
||||
[{:keys [::setup/props] :as cfg} token]
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
(throw (IllegalArgumentException. "Missing arguments on `defmethod` macro.")))
|
||||
|
||||
(let [mdata (assoc mdata
|
||||
::docstring (some-> docs str/<<-)
|
||||
::docstring (some-> docs str/unindent)
|
||||
::spec sname
|
||||
::name (name sname))
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
[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]
|
||||
@@ -185,15 +186,17 @@
|
||||
(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)
|
||||
|
||||
(db/run! system
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(->> (merge {:id (mk-uuid "project" i)
|
||||
:name (str "project" i)}
|
||||
params)
|
||||
(#'teams/create-project conn))))))
|
||||
(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)))))))
|
||||
|
||||
(defn create-file*
|
||||
([i params]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[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]
|
||||
@@ -16,6 +17,7 @@
|
||||
[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]
|
||||
@@ -132,9 +134,10 @@
|
||||
;; this will run pending task triggered by deleting user snapshot
|
||||
(th/run-pending-tasks!)
|
||||
|
||||
(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))))))))
|
||||
(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)))))))))
|
||||
|
||||
(t/deftest snapshots-locking
|
||||
(let [profile-1 (th/create-profile* 1 {:is-active true})
|
||||
|
||||
@@ -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 {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(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 {:deletion-threshold 0})]
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
;; 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 {:deletion-threshold 0})]
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
;; 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 {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(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 {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(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 {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(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" {:min-age 0})]
|
||||
(let [res (th/run-task! "storage-gc-touched" {})]
|
||||
(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 {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 1 (:delete res))))
|
||||
|
||||
;; check file media objects
|
||||
@@ -922,8 +922,9 @@
|
||||
(t/is (= 0 (:processed result))))
|
||||
|
||||
;; run permanent deletion
|
||||
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
|
||||
(t/is (= 3 (:processed result))))
|
||||
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
|
||||
(let [result (th/run-task! :objects-gc {})]
|
||||
(t/is (= 3 (:processed result)))))
|
||||
|
||||
;; query the list of file libraries of a after hard deletion
|
||||
(let [data {::th/type :get-file-libraries
|
||||
@@ -1134,7 +1135,7 @@
|
||||
(th/sleep 300)
|
||||
|
||||
;; run the task
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||
|
||||
;; check that object thumbnails are still here
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})]
|
||||
@@ -1163,7 +1164,7 @@
|
||||
(t/is (= 2 (count rows))))
|
||||
|
||||
;; run the task again
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||
|
||||
;; check that we have all object thumbnails
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})]
|
||||
@@ -1226,7 +1227,7 @@
|
||||
(t/is (= 2 (count rows)))))
|
||||
|
||||
(t/testing "gc task"
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||
|
||||
(let [rows (th/db-query :file-thumbnail {:file-id (:id file)})]
|
||||
(t/is (= 2 (count rows)))
|
||||
@@ -1273,7 +1274,7 @@
|
||||
;; The FileGC task will schedule an inner taskq
|
||||
(th/run-pending-tasks!)
|
||||
|
||||
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 2 (:freeze res)))
|
||||
(t/is (= 0 (:delete res))))
|
||||
|
||||
@@ -1367,7 +1368,7 @@
|
||||
|
||||
;; we ensure that once object-gc is passed and marked two storage
|
||||
;; objects to delete
|
||||
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 2 (:delete res))))
|
||||
|
||||
@@ -1489,7 +1490,7 @@
|
||||
(t/is (some? (not-empty (:objects component))))))
|
||||
|
||||
;; Re-run the file-gc task
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||
(let [row (th/db-get :file {:id (:id file)})]
|
||||
(t/is (true? (:has-media-trimmed row))))
|
||||
|
||||
@@ -1519,7 +1520,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 {:min-age 0 :file-id (:id file)})))
|
||||
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||
|
||||
;; Check that component is properly removed
|
||||
(let [data {::th/type :get-file
|
||||
@@ -1610,8 +1611,8 @@
|
||||
:component-id c-id})}])
|
||||
|
||||
;; Run the file-gc on file and library
|
||||
(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)})))
|
||||
(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)})))
|
||||
|
||||
;; Check that component exists
|
||||
(let [data {::th/type :get-file
|
||||
@@ -1684,7 +1685,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 {:min-age 0 :file-id (:id file-1)})))
|
||||
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-1)})))
|
||||
|
||||
;; Check that component is properly removed
|
||||
(let [data {::th/type :get-file
|
||||
@@ -1833,8 +1834,8 @@
|
||||
(t/is (not= (:id fill) (:id fmedia)))))
|
||||
|
||||
;; Run the file-gc on file and library
|
||||
(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)})))
|
||||
(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)})))
|
||||
|
||||
;; Now proceed to delete file and absorb it
|
||||
(let [data {::th/type :delete-file
|
||||
@@ -1925,7 +1926,7 @@
|
||||
(let [row (th/db-exec-one! ["select * from file where id = ?" file-id])]
|
||||
(t/is (= (:deleted-at row) now)))))))
|
||||
|
||||
(t/deftest deleted-files-restore
|
||||
(t/deftest restore-deleted-files
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
proj-id (:default-project-id prof)
|
||||
@@ -1988,3 +1989,78 @@
|
||||
|
||||
(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))))))))
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
(: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]
|
||||
@@ -83,7 +85,8 @@
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
;; run the task again
|
||||
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
|
||||
(let [res (binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 31}))]
|
||||
(th/run-task! "storage-gc-touched" {}))]
|
||||
(t/is (= 2 (:freeze res))))
|
||||
|
||||
(let [[row1 row2 :as rows] (th/db-query :file-tagged-object-thumbnail
|
||||
@@ -114,9 +117,9 @@
|
||||
|
||||
;; Run the File GC task that should remove unused file object
|
||||
;; thumbnails
|
||||
(th/run-task! :file-gc {:min-age 0 :file-id (:id file)})
|
||||
(th/run-task! :file-gc {:file-id (:id file)})
|
||||
|
||||
(let [result (th/run-task! :objects-gc {:min-age 0})]
|
||||
(let [result (th/run-task! :objects-gc {})]
|
||||
(t/is (= 3 (:processed result))))
|
||||
|
||||
;; check if row2 related thumbnail row still exists
|
||||
@@ -133,7 +136,8 @@
|
||||
(t/is (some? (sto/get-object storage (:media-id row2))))
|
||||
|
||||
;; run the task again
|
||||
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||
(let [res (binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 31}))]
|
||||
(th/run-task! :storage-gc-touched {}))]
|
||||
(t/is (= 1 (:delete res)))
|
||||
(t/is (= 0 (:freeze res))))
|
||||
|
||||
@@ -143,8 +147,9 @@
|
||||
|
||||
;; Run the storage gc deleted task, it should permanently delete
|
||||
;; all storage objects related to the deleted thumbnails
|
||||
(let [result (th/run-task! :storage-gc-deleted {:min-age 0})]
|
||||
(t/is (= 1 (:deleted result))))
|
||||
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
|
||||
(let [res (th/run-task! :storage-gc-deleted {})]
|
||||
(t/is (= 1 (:deleted res)))))
|
||||
|
||||
(t/is (nil? (sto/get-object storage (:media-id row1))))
|
||||
(t/is (some? (sto/get-object storage (:media-id row2))))
|
||||
@@ -216,9 +221,9 @@
|
||||
|
||||
;; Run the File GC task that should remove unused file object
|
||||
;; thumbnails
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||
|
||||
(let [result (th/run-task! :objects-gc {:min-age 0})]
|
||||
(let [result (th/run-task! :objects-gc {})]
|
||||
(t/is (= 2 (:processed result))))
|
||||
|
||||
;; check if row1 related thumbnail row still exists
|
||||
@@ -230,7 +235,7 @@
|
||||
(t/is (= (:object-id data1) (:object-id row)))
|
||||
(t/is (uuid? (:media-id row1))))
|
||||
|
||||
(let [result (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||
(let [result (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 1 (:delete result))))
|
||||
|
||||
;; Check if storage objects still exists after file-gc
|
||||
@@ -242,8 +247,9 @@
|
||||
|
||||
;; Run the storage gc deleted task, it should permanently delete
|
||||
;; all storage objects related to the deleted thumbnails
|
||||
(let [result (th/run-task! :storage-gc-deleted {:min-age 0})]
|
||||
(t/is (= 1 (:deleted result))))
|
||||
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
|
||||
(let [result (th/run-task! :storage-gc-deleted {})]
|
||||
(t/is (= 1 (:deleted result)))))
|
||||
|
||||
(t/is (some? (sto/get-object storage (:media-id row2)))))))
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
|
||||
(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]
|
||||
@@ -129,7 +131,7 @@
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 6 (:freeze res))))
|
||||
|
||||
(let [params {::th/type :delete-font
|
||||
@@ -141,16 +143,17 @@
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 0 (:delete res))))
|
||||
|
||||
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
|
||||
(t/is (= 2 (:processed 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! :storage-gc-touched {:min-age 0})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 6 (:delete res))))))
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 6 (:delete res)))))))
|
||||
|
||||
(t/deftest font-deletion-2
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
@@ -189,7 +192,7 @@
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 6 (:freeze res))))
|
||||
|
||||
(let [params {::th/type :delete-font
|
||||
@@ -201,16 +204,17 @@
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 0 (:delete res))))
|
||||
|
||||
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
|
||||
(t/is (= 1 (:processed 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! :storage-gc-touched {:min-age 0})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 3 (:delete res))))))
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 3 (:delete res)))))))
|
||||
|
||||
(t/deftest font-deletion-3
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
@@ -248,7 +252,7 @@
|
||||
(t/is (nil? (:error out1)))
|
||||
(t/is (nil? (:error out2)))
|
||||
|
||||
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 6 (:freeze res))))
|
||||
|
||||
(let [params {::th/type :delete-font-variant
|
||||
@@ -260,13 +264,14 @@
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 0 (:delete res))))
|
||||
|
||||
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
|
||||
(t/is (= 1 (:processed 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! :storage-gc-touched {:min-age 0})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 3 (:delete res))))))
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 3 (:delete res)))))))
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
|
||||
(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]))
|
||||
|
||||
@@ -226,8 +228,9 @@
|
||||
(t/is (= 0 (count result)))))
|
||||
|
||||
;; run permanent deletion
|
||||
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
|
||||
(t/is (= 1 (:processed result))))
|
||||
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
|
||||
(let [result (th/run-task! :objects-gc {})]
|
||||
(t/is (= 1 (:processed result)))))
|
||||
|
||||
;; query the list of files of a after hard deletion
|
||||
(let [data {::th/type :get-project-files
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
[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]
|
||||
@@ -525,8 +526,9 @@
|
||||
(t/is (= :not-found (:type edata)))))
|
||||
|
||||
;; run permanent deletion
|
||||
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
|
||||
(t/is (= 2 (:processed result))))
|
||||
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
|
||||
(let [result (th/run-task! :objects-gc {})]
|
||||
(t/is (= 2 (:processed result)))))
|
||||
|
||||
;; query the list of projects of a after hard deletion
|
||||
(let [data {::th/type :get-projects
|
||||
@@ -581,8 +583,9 @@
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (ct/inst? (:deleted-at (first rows)))))
|
||||
|
||||
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
|
||||
(t/is (= 7 (:processed result))))))
|
||||
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
|
||||
(let [result (th/run-task! :objects-gc {})]
|
||||
(t/is (= 7 (:processed result)))))))
|
||||
|
||||
(t/deftest create-team-access-request
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
[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]
|
||||
@@ -53,19 +54,13 @@
|
||||
(configure-storage-backend))
|
||||
content (sto/content "content")
|
||||
object (sto/put-object! storage {::sto/content content
|
||||
::sto/expired-at (ct/in-future {:seconds 1})
|
||||
::sto/expired-at (ct/in-future {:hours 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 (= 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/is (nil? (sto/get-object storage (:id object))))))
|
||||
|
||||
(t/deftest put-and-delete-object
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
@@ -98,20 +93,25 @@
|
||||
::sto/expired-at (ct/now)
|
||||
:content-type "text/plain"})
|
||||
object2 (sto/put-object! storage {::sto/content content2
|
||||
::sto/expired-at (ct/in-past {:hours 2})
|
||||
::sto/expired-at (ct/in-future {:hours 2})
|
||||
:content-type "text/plain"})
|
||||
object3 (sto/put-object! storage {::sto/content content3
|
||||
::sto/expired-at (ct/in-past {:hours 1})
|
||||
::sto/expired-at (ct/in-future {:hours 1})
|
||||
:content-type "text/plain"})]
|
||||
|
||||
|
||||
(th/sleep 200)
|
||||
|
||||
(let [res (th/run-task! :storage-gc-deleted {})]
|
||||
(t/is (= 1 (:deleted res))))
|
||||
(binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 0}))]
|
||||
(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))))))
|
||||
(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/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 {:min-age 0})]
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
(t/is (= 1 (:processed res))))
|
||||
|
||||
;; check that we still have all the storage objects
|
||||
@@ -182,7 +182,6 @@
|
||||
(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))
|
||||
@@ -243,11 +242,12 @@
|
||||
{:id (:id result-2)})
|
||||
|
||||
;; run the objects gc task for permanent deletion
|
||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
(t/is (= 1 (:processed res))))
|
||||
|
||||
;; revert touched state to all storage objects
|
||||
(th/db-exec-one! ["update storage_object set touched_at=now()"])
|
||||
|
||||
(th/db-exec-one! ["update storage_object set touched_at=?" (ct/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=now()"])
|
||||
(th/db-exec! ["update storage_object set touched_at=?" (ct/now)])
|
||||
|
||||
;; run the touched gc task
|
||||
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(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 = now()"])
|
||||
(th/db-exec! ["update file_media_object set deleted_at = ?" (ct/now)])
|
||||
|
||||
(let [res (th/run-task! "objects-gc" {:min-age 0})]
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
(t/is (= 2 (:processed res))))
|
||||
|
||||
;; run the touched gc task
|
||||
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 2 (:delete res))))
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"plugins/runtime"
|
||||
"tokens/numeric-input"
|
||||
"design-tokens/v1"
|
||||
"text-editor/v2-html-paste"
|
||||
"text-editor/v2"
|
||||
"render-wasm/v1"
|
||||
"variants/v1"})
|
||||
@@ -75,6 +76,7 @@
|
||||
(def frontend-only-features
|
||||
#{"styles/v2"
|
||||
"plugins/runtime"
|
||||
"text-editor/v2-html-paste"
|
||||
"text-editor/v2"
|
||||
"tokens/numeric-input"
|
||||
"render-wasm/v1"})
|
||||
@@ -124,6 +126,7 @@
|
||||
: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"
|
||||
|
||||
@@ -371,7 +371,7 @@
|
||||
[:set-tokens-lib
|
||||
[:map {:title "SetTokensLib"}
|
||||
[:type [:= :set-tokens-lib]]
|
||||
[:tokens-lib ctob/schema:tokens-lib]]]
|
||||
[:tokens-lib [:maybe ctob/schema:tokens-lib]]]]
|
||||
|
||||
[:set-token
|
||||
[:map {:title "SetTokenChange"}
|
||||
@@ -463,35 +463,16 @@
|
||||
|
||||
;; Changes Processing Impl
|
||||
|
||||
(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!))))
|
||||
#_: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- process-touched-change
|
||||
[data {:keys [id page-id component-id]}]
|
||||
@@ -518,14 +499,8 @@
|
||||
(check-changes items))
|
||||
|
||||
(binding [*touched-changes* (volatile! #{})]
|
||||
(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))))
|
||||
(let [result (reduce #(or (process-change %1 %2) %1) data items)]
|
||||
(reduce process-touched-change result @*touched-changes*)))))
|
||||
|
||||
;; --- Comment Threads
|
||||
|
||||
@@ -613,9 +588,10 @@
|
||||
|
||||
(defmethod process-change :add-obj
|
||||
[data {:keys [id obj page-id component-id 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))]
|
||||
;; 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)]
|
||||
|
||||
(when *state*
|
||||
(swap! *state* collect-shape-media-refs obj page-id))
|
||||
@@ -638,6 +614,9 @@
|
||||
(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))
|
||||
@@ -692,8 +671,6 @@
|
||||
(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
|
||||
@@ -722,48 +699,60 @@
|
||||
|
||||
(update-group [group objects]
|
||||
(let [lookup (d/getf objects)
|
||||
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
|
||||
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
|
||||
|
||||
(= :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)))))]
|
||||
:else
|
||||
(->> (map lookup children)
|
||||
(gsh/update-group-selrect group)))]
|
||||
#?(:clj (validate-shape group page-id))
|
||||
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
|
||||
[data {:keys [parent-id shapes index page-id component-id ignore-touched after-shape allow-altering-copies syncing]}]
|
||||
;; 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]}]
|
||||
(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 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
|
||||
(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))))))))
|
||||
|
||||
(insert-items [prev-shapes index shapes]
|
||||
(let [prev-shapes (or prev-shapes [])]
|
||||
@@ -772,17 +761,13 @@
|
||||
(cfh/append-at-the-end prev-shapes shapes))))
|
||||
|
||||
(add-to-parent [parent index shapes]
|
||||
(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))))
|
||||
(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)))))
|
||||
|
||||
(remove-from-old-parent [old-objects objects shape-id]
|
||||
(let [prev-parent-id (dm/get-in old-objects [shape-id :parent-id])]
|
||||
@@ -790,58 +775,63 @@
|
||||
;; the new destination target parent id.
|
||||
(if (= prev-parent-id parent-id)
|
||||
objects
|
||||
(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))))))))
|
||||
(d/update-in-when objects [prev-parent-id :shapes]
|
||||
(fn [shapes]
|
||||
(-> shapes
|
||||
(d/without-obj shape-id)
|
||||
(d/vec-without-nils)))))))
|
||||
|
||||
(update-parent-id [objects id]
|
||||
(-> objects
|
||||
(d/update-when id assoc :parent-id parent-id)))
|
||||
(d/update-when objects id assoc :parent-id parent-id))
|
||||
|
||||
;; Updates the frame-id references that might be outdated
|
||||
(assign-frame-id [frame-id objects id]
|
||||
(let [objects (d/update-when objects id assoc :frame-id frame-id)
|
||||
obj (get objects id)]
|
||||
(update-frame-id [frame-id objects id]
|
||||
(let [obj (some-> (get objects id)
|
||||
(assoc :frame-id frame-id))]
|
||||
(cond-> 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))))))
|
||||
(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)
|
||||
|
||||
(move-objects [objects]
|
||||
(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))]
|
||||
(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))
|
||||
|
||||
(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))
|
||||
;; Update each individual shape link to the new parent
|
||||
(reduce update-parent-id $ shapes)
|
||||
|
||||
;; Update each individual shape link to the new parent
|
||||
(reduce update-parent-id $ 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)
|
||||
|
||||
;; 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 update-frame-id frame-id) $ shapes)
|
||||
|
||||
;; Perform validation of the affected shapes
|
||||
(reduce validate-shape $ 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
|
||||
|
||||
@@ -120,11 +120,8 @@
|
||||
: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
|
||||
@@ -132,7 +129,6 @@
|
||||
:v2-migration
|
||||
:webhooks
|
||||
;; TODO: deprecate this flag and consolidate the code
|
||||
:export-file-v3
|
||||
:render-wasm-dpr
|
||||
:hide-release-modal
|
||||
:subscriptions
|
||||
@@ -172,9 +168,8 @@
|
||||
:enable-google-fonts-provider
|
||||
:enable-component-thumbnails
|
||||
:enable-render-wasm-dpr
|
||||
:enable-token-units
|
||||
:enable-token-typography-types
|
||||
:enable-token-typography-composite
|
||||
:enable-token-color
|
||||
:enable-inspect-styles
|
||||
:enable-feature-fdata-objects-map])
|
||||
|
||||
(defn parse
|
||||
|
||||
@@ -1512,7 +1512,7 @@
|
||||
:shapes [(:id shape)]
|
||||
:index index-after
|
||||
:ignore-touched true
|
||||
:syncing true}))
|
||||
:allow-altering-copies true}))
|
||||
(update :undo-changes conj (make-change
|
||||
container
|
||||
{:type :mov-objects
|
||||
@@ -1520,7 +1520,7 @@
|
||||
:shapes [(:id shape)]
|
||||
:index index-before
|
||||
:ignore-touched true
|
||||
:syncing true})))]
|
||||
:allow-altering-copies true})))]
|
||||
|
||||
(if (and (ctk/touched-group? parent :shapes-group) omit-touched?)
|
||||
changes
|
||||
|
||||
@@ -1082,33 +1082,35 @@
|
||||
|
||||
detach-shape
|
||||
(fn [objects shape]
|
||||
(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)
|
||||
(let [shape' (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)))
|
||||
(= :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'))
|
||||
|
||||
detach-objects
|
||||
(fn [objects]
|
||||
(update-vals objects #(detach-shape objects %)))
|
||||
(d/update-vals objects #(detach-shape objects %)))
|
||||
|
||||
detach-pages
|
||||
(fn [pages-index]
|
||||
(update-vals pages-index #(update % :objects detach-objects)))]
|
||||
(d/update-vals pages-index #(update % :objects detach-objects)))]
|
||||
|
||||
(-> file
|
||||
(update-in [:data :pages-index] detach-pages))))
|
||||
(update-in file [:data :pages-index] detach-pages)))
|
||||
|
||||
;; Base font size
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
[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]
|
||||
@@ -49,12 +50,19 @@
|
||||
(= 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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -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:fill fill->color]]
|
||||
[app.common.types.fills :refer [schema:fills fill->color]]
|
||||
[app.common.types.grid :as ctg]
|
||||
[app.common.types.path :as path]
|
||||
[app.common.types.plugins :as ctpg]
|
||||
@@ -192,8 +192,7 @@
|
||||
[:locked {:optional true} :boolean]
|
||||
[:hidden {:optional true} :boolean]
|
||||
[:masked-group {:optional true} :boolean]
|
||||
[:fills {:optional true}
|
||||
[:vector {:gen/max 2} schema:fill]]
|
||||
[:fills {:optional true} schema:fills]
|
||||
[:proportion {:optional true} ::sm/safe-number]
|
||||
[:proportion-lock {:optional true} :boolean]
|
||||
[:constraints-h {:optional true}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
(ns app.common.types.shape.text
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.fills :refer [schema:fill]]))
|
||||
[app.common.types.fills :refer [schema:fills]]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SCHEMA
|
||||
@@ -32,8 +32,7 @@
|
||||
[:type [:= "paragraph"]]
|
||||
[:key {:optional true} :string]
|
||||
[:fills {:optional true}
|
||||
[:maybe
|
||||
[:vector {:gen/max 2} schema:fill]]]
|
||||
[:maybe schema:fills]]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
@@ -49,8 +48,7 @@
|
||||
[:text :string]
|
||||
[:key {:optional true} :string]
|
||||
[:fills {:optional true}
|
||||
[:maybe
|
||||
[:vector {:gen/max 2} schema:fill]]]
|
||||
[:maybe schema:fills]]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
@@ -71,7 +69,7 @@
|
||||
[:y ::sm/safe-number]
|
||||
[:width ::sm/safe-number]
|
||||
[:height ::sm/safe-number]
|
||||
[:fills [:vector {:gen/max 2} schema:fill]]
|
||||
[:fills schema:fills]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
|
||||
@@ -90,6 +90,10 @@
|
||||
[{: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"
|
||||
|
||||
@@ -266,10 +266,6 @@
|
||||
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])
|
||||
|
||||
@@ -1679,7 +1679,7 @@ Will return a value that matches this schema:
|
||||
["id" {:optional true} :string]
|
||||
["name" :string]
|
||||
["description" :string]
|
||||
["isSource" :boolean]
|
||||
["isSource" {:optional true} :boolean]
|
||||
["selectedTokenSets"
|
||||
[:map-of :string [:enum "enabled" "disabled"]]]]]]
|
||||
["$metadata" {:optional true}
|
||||
@@ -1794,17 +1794,19 @@ Will return a value that matches this schema:
|
||||
data (without any case transformation). Used as schema decoder and
|
||||
in the SDK."
|
||||
[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)
|
||||
(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)
|
||||
|
||||
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."
|
||||
|
||||
@@ -311,16 +311,22 @@
|
||||
[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 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}]]
|
||||
"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)]
|
||||
(when (= (count v) 2)
|
||||
(some (fn [b]
|
||||
(when (and (contains? b (first v))
|
||||
(contains? b (last v)))
|
||||
b))
|
||||
bool-values))))
|
||||
(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))))
|
||||
|
||||
@@ -163,9 +163,11 @@
|
||||
|
||||
(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 ["on" "off"]) {"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 [" 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" "other"]) 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))))
|
||||
(t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil))))
|
||||
|
||||
@@ -82,6 +82,11 @@ services:
|
||||
- 9000:9000
|
||||
- 9001:9001
|
||||
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- minio
|
||||
|
||||
postgres:
|
||||
image: postgres:16.8
|
||||
command: postgres -c config_file=/etc/postgresql.conf
|
||||
@@ -110,6 +115,11 @@ services:
|
||||
volumes:
|
||||
- "valkey_data:/data"
|
||||
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- redis
|
||||
|
||||
mailer:
|
||||
image: sj26/mailcatcher:latest
|
||||
restart: always
|
||||
@@ -118,6 +128,12 @@ services:
|
||||
ports:
|
||||
- "1080:1080"
|
||||
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- mailer
|
||||
|
||||
|
||||
# https://github.com/rroemhild/docker-test-openldap
|
||||
ldap:
|
||||
image: rroemhild/test-openldap:2.1
|
||||
@@ -131,3 +147,9 @@ services:
|
||||
nofile:
|
||||
soft: 1024
|
||||
hard: 1024
|
||||
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- ldap
|
||||
|
||||
|
||||
@@ -141,6 +141,10 @@ 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";
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
##
|
||||
## You can read more about all available flags and other
|
||||
## environment variables here:
|
||||
## https://help.penpot.app/technical-guide/configuration/#advanced-configuration
|
||||
## https://help.penpot.app/technical-guide/configuration/#penpot-configuration
|
||||
#
|
||||
# WARNING: if you're exposing Penpot to the internet, you should remove the flags
|
||||
# 'disable-secure-session-cookies' and 'disable-email-verification'
|
||||
@@ -37,6 +37,15 @@ 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:
|
||||
@@ -45,7 +54,6 @@ volumes:
|
||||
penpot_postgres_v15:
|
||||
penpot_assets:
|
||||
# penpot_traefik:
|
||||
# penpot_minio:
|
||||
|
||||
services:
|
||||
## Traefik service declaration example. Consider using it if you are going to expose
|
||||
@@ -120,20 +128,7 @@ services:
|
||||
## Configuration envronment variables for the backend container.
|
||||
|
||||
environment:
|
||||
<< : [*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
|
||||
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-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
|
||||
@@ -159,13 +154,12 @@ 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
|
||||
## service like MiniIO. Look below for minio service setup.
|
||||
## Also can be configured to to use a S3 compatible storage.
|
||||
|
||||
# AWS_ACCESS_KEY_ID: <KEY_ID>
|
||||
# AWS_SECRET_ACCESS_KEY: <ACCESS_KEY>
|
||||
# PENPOT_ASSETS_STORAGE_BACKEND: assets-s3
|
||||
# PENPOT_STORAGE_ASSETS_S3_ENDPOINT: http://penpot-minio:9000
|
||||
# PENPOT_STORAGE_ASSETS_S3_ENDPOINT: <ENDPOINT>
|
||||
# PENPOT_STORAGE_ASSETS_S3_BUCKET: <BUKET_NAME>
|
||||
|
||||
## Telemetry. When enabled, a periodical process will send anonymous data about this
|
||||
@@ -202,6 +196,7 @@ 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
|
||||
@@ -265,22 +260,3 @@ 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
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<div class="footer-text"><span>Kaleidos © 2024 | Made with LOVE and Open Source</span></div>
|
||||
<div class="footer-text"><span>Kaleidos © 2025 | 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">
|
||||
|
||||
@@ -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">smpt</code> flag is disabled, the email will be
|
||||
By default, <code class="language-bash">smtp</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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
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 #
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
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>
|
||||
|
||||
6
exporter/scripts/run
Executable file
6
exporter/scripts/run
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCRIPT_DIR=$(dirname $0);
|
||||
source $SCRIPT_DIR/../../backend/scripts/_env;
|
||||
|
||||
exec node target/app.js
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/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;
|
||||
node target/app.js
|
||||
|
||||
exec node target/app.js
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
(ns app.config
|
||||
(:refer-clojure :exclude [get])
|
||||
(:require
|
||||
["process" :as process]
|
||||
["node:buffer" :as buffer]
|
||||
["node:crypto" :as crypto]
|
||||
["node:process" :as process]
|
||||
[app.common.data :as d]
|
||||
[app.common.flags :as flags]
|
||||
[app.common.schema :as sm]
|
||||
@@ -21,13 +23,14 @@
|
||||
:host "localhost"
|
||||
:http-server-port 6061
|
||||
:http-server-host "0.0.0.0"
|
||||
:tempdir "/tmp/penpot-exporter"
|
||||
:tempdir "/tmp/penpot"
|
||||
: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]]
|
||||
@@ -93,3 +96,10 @@
|
||||
(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")))))
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
[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]))
|
||||
@@ -54,7 +53,7 @@
|
||||
|
||||
:else
|
||||
(let [data {:type :server-error
|
||||
:code type
|
||||
:code code
|
||||
:hint (ex-message error)
|
||||
:data data}]
|
||||
(l/error :hint "unexpected internal error" :cause error)
|
||||
@@ -71,7 +70,6 @@
|
||||
|
||||
(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]
|
||||
@@ -83,7 +81,6 @@
|
||||
(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
|
||||
|
||||
@@ -43,90 +43,78 @@
|
||||
;; 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
|
||||
[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)))
|
||||
[{:keys [:request/auth-token] :as exchange} {:keys [exports name profile-id] :as params}]
|
||||
(let [topic (str profile-id)
|
||||
file-id (-> exports first :file-id)
|
||||
|
||||
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))))
|
||||
resource
|
||||
(rsc/create :pdf (or name (-> exports first :name)))
|
||||
|
||||
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-progress
|
||||
(fn [done]
|
||||
(let [data {:type :export-update
|
||||
:resource-id (:id resource)
|
||||
:status "running"
|
||||
:done done}]
|
||||
(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-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)))
|
||||
|
||||
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)))))
|
||||
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)))
|
||||
|
||||
(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 [])
|
||||
result-cache
|
||||
(atom [])
|
||||
|
||||
on-object
|
||||
(fn [{:keys [path] :as object}]
|
||||
(let [res (swap! result conj path)]
|
||||
(on-progress {:done (count res)})))]
|
||||
(let [res (swap! result-cache conj path)]
|
||||
(on-progress (count res))))
|
||||
|
||||
(-> (p/loop [exports (seq exports)]
|
||||
(when-let [export (first exports)]
|
||||
(p/do
|
||||
(rd/render export on-object)
|
||||
(p/recur (rest exports)))))
|
||||
procs
|
||||
(->> (seq exports)
|
||||
(map #(rd/render % on-object)))]
|
||||
|
||||
(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)))))))
|
||||
(->> (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))))
|
||||
|
||||
(defn- join-pdf
|
||||
[file-id paths]
|
||||
(p/let [prefix (str/concat "penpot.tmp.pdfunite." file-id ".")
|
||||
(p/let [prefix (str/concat "penpot.pdfunite." file-id ".")
|
||||
path (sh/tempfile :prefix prefix :suffix ".pdf")]
|
||||
(sh/run-cmd! (str "pdfunite " (str/join " " paths) " " path))
|
||||
path))
|
||||
|
||||
@@ -60,46 +60,26 @@
|
||||
(handle-multiple-export exchange (assoc params :exports exports)))))
|
||||
|
||||
(defn- handle-single-export
|
||||
[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)))
|
||||
[{: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)]
|
||||
|
||||
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)))))
|
||||
(->> (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))))))
|
||||
|
||||
(defn- handle-multiple-export
|
||||
[exchange {:keys [exports wait profile-id name skip-children] :as params}]
|
||||
[{:keys [:request/auth-token] :as exchange} {:keys [exports wait profile-id name] :as params}]
|
||||
(let [resource (rsc/create :zip (or name (-> exports first :name)))
|
||||
total (count exports)
|
||||
topic (str profile-id)
|
||||
@@ -113,15 +93,6 @@
|
||||
: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
|
||||
@@ -132,30 +103,35 @@
|
||||
: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 object}]
|
||||
append (fn [{:keys [filename path] :as resource}]
|
||||
(rsc/add-to-zip! zip path (str/replace filename sanitize-file-regex "_")))
|
||||
|
||||
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))]
|
||||
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))]
|
||||
(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]
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
(: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]
|
||||
@@ -25,44 +29,20 @@
|
||||
(defn create
|
||||
"Generates ephimeral resource object."
|
||||
[type name]
|
||||
(let [task-id (uuid/next)]
|
||||
{:path (get-path type task-id)
|
||||
(let [task-id (uuid/next)
|
||||
path (-> (get-path type task-id)
|
||||
(sh/schedule-deletion))]
|
||||
{:path path
|
||||
:mtype (mime/get type)
|
||||
:name name
|
||||
:filename (str/concat name (mime/get-extension type))
|
||||
: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))))))
|
||||
:id task-id}))
|
||||
|
||||
(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)
|
||||
@@ -80,3 +60,29 @@
|
||||
(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))))))
|
||||
|
||||
@@ -29,13 +29,13 @@
|
||||
:userAgent bw/default-user-agent})
|
||||
|
||||
(render-object [page {:keys [id] :as object}]
|
||||
(p/let [path (sh/tempfile :prefix "penpot.tmp.render.bitmap." :suffix (mime/get-extension type))
|
||||
(p/let [path (sh/tempfile :prefix "penpot.tmp.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.render.bitmap." :suffix ".png")]
|
||||
:webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.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,6 +52,7 @@
|
||||
;; 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
|
||||
|
||||
@@ -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.render.pdf." :suffix (mime/get-extension type))]
|
||||
path (sh/tempfile :prefix "penpot.tmp.pdf." :suffix (mime/get-extension type))]
|
||||
(l/info :uri uri)
|
||||
(bw/nav! page uri)
|
||||
(p/let [dom (bw/select page (dm/str "#screenshot-" id))]
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
(ns app.util.shell
|
||||
"Shell & FS utilities."
|
||||
(:require
|
||||
["child_process" :as proc]
|
||||
["fs" :as fs]
|
||||
["path" :as path]
|
||||
["node:child_process" :as proc]
|
||||
["node:fs" :as fs]
|
||||
["node: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]
|
||||
@@ -19,7 +20,8 @@
|
||||
|
||||
(l/set-level! :trace)
|
||||
|
||||
(def tempfile-minage (* 1000 60 60 1)) ;; 1h
|
||||
(def ^:const default-deletion-delay
|
||||
(* 60 60 1)) ;; 1h
|
||||
|
||||
(def tmpdir
|
||||
(let [path (cf/get :tempdir)]
|
||||
@@ -28,16 +30,28 @@
|
||||
(fs/mkdirSync path #js {:recursive true}))
|
||||
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 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 tempfile
|
||||
[& {:keys [prefix suffix]
|
||||
@@ -48,9 +62,7 @@
|
||||
(let [path (path/join tmpdir (str/concat prefix (uuid/next) "-" i suffix))]
|
||||
(if (fs/existsSync path)
|
||||
(recur (inc i))
|
||||
(do
|
||||
(schedule-deletion! path)
|
||||
path)))
|
||||
(schedule-deletion path)))
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-locate-temporal-file
|
||||
:hint "unable to find a tempfile candidate"))))
|
||||
@@ -61,11 +73,12 @@
|
||||
|
||||
(defn stat
|
||||
[path]
|
||||
(-> (.stat fs/promises path)
|
||||
(p/then (fn [data]
|
||||
{:created-at (inst-ms (.-ctime ^js data))
|
||||
:size (.-size data)}))
|
||||
(p/catch (constantly nil))))
|
||||
(->> (.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)))))
|
||||
|
||||
(defn rmdir!
|
||||
[path]
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"nodemon": "^3.1.10",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"p-limit": "^6.2.0",
|
||||
"playwright": "1.52.0",
|
||||
"playwright": "1.56.1",
|
||||
"postcss": "^8.5.4",
|
||||
"postcss-clean": "^1.2.2",
|
||||
"prettier": "3.5.3",
|
||||
|
||||
@@ -11,6 +11,7 @@ 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. */
|
||||
@@ -20,20 +21,19 @@ 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 ? 20000 : 5000,
|
||||
timeout: process.env.CI ? 30000 : 5000,
|
||||
},
|
||||
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
reporter: "list",
|
||||
/* 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,6 +45,10 @@ export default defineConfig({
|
||||
name: "default",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
testDir: "./playwright/ui/specs",
|
||||
use: {
|
||||
video: 'retain-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "ds",
|
||||
|
||||
@@ -887,4 +887,4 @@
|
||||
"~:base-font-size": "16px"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"~: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"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,6 @@
|
||||
"~:revn": 2,
|
||||
"~:created-at": "~m1730199694953",
|
||||
"~:created-by": "user",
|
||||
"~:profile-id": "~u4678a621-b446-818a-8004-e7b734def799"
|
||||
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
"~:revn": 2,
|
||||
"~:created-at": "~m1730199694953",
|
||||
"~:created-by": "user",
|
||||
"~:profile-id": "~u4678a621-b446-818a-8004-e7b734def799"
|
||||
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -12,7 +13,15 @@ export class WasmWorkspacePage extends WorkspacePage {
|
||||
await WorkspacePage.mockConfigFlags(page, WASM_FLAGS);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
document.addEventListener("wasm:set-objects-finished", () => {
|
||||
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", () => {
|
||||
window.wasmSetObjectsFinished = true;
|
||||
});
|
||||
});
|
||||
@@ -23,19 +32,20 @@ export class WasmWorkspacePage extends WorkspacePage {
|
||||
this.canvas = page.getByTestId("canvas-wasm-shapes");
|
||||
}
|
||||
|
||||
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" });
|
||||
async waitForFirstRender() {
|
||||
await this.pageName.waitFor();
|
||||
await this.canvas.waitFor();
|
||||
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();
|
||||
|
||||
@@ -67,9 +67,11 @@ 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"]`,
|
||||
@@ -243,14 +245,20 @@ 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({ has: this.page.getByText(name) });
|
||||
await layer.getByRole("button").click(clickOptions);
|
||||
.getByTestId("layer-row")
|
||||
.filter({ hasText: name });
|
||||
const button = layer.getByRole("button");
|
||||
|
||||
await button.waitFor();
|
||||
await button.click(clickOptions);
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async expectSelectedLayer(name) {
|
||||
|
||||
@@ -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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
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({ hideUI: false });
|
||||
await workspace.waitForFirstRender();
|
||||
|
||||
const canvasBackgroundInput = workspace.page.getByRole("textbox", {
|
||||
name: "Color",
|
||||
@@ -166,9 +166,6 @@ 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();
|
||||
});
|
||||
|
||||
@@ -192,7 +189,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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
@@ -208,7 +205,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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
@@ -224,7 +221,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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
@@ -238,7 +235,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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
@@ -256,7 +253,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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
@@ -272,7 +269,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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 360 KiB |
@@ -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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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({ hideUI: false });
|
||||
await workspace.waitForFirstRender();
|
||||
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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({ hideUI: false });
|
||||
await workspace.waitForFirstRender();
|
||||
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({ hideUI: false });
|
||||
await workspace.waitForFirstRender();
|
||||
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({ hideUI: false });
|
||||
await workspace.waitForFirstRender();
|
||||
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.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
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({ hideUI: false });
|
||||
await workspace.waitForFirstRender();
|
||||
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({ hideUI: false });
|
||||
await workspace.waitForFirstRender();
|
||||
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({ hideUI: false });
|
||||
await workspace.waitForFirstRender();
|
||||
await workspace.clickLeafLayer("Text 1");
|
||||
|
||||
const textOptionsButton = workspace.page.getByTestId(
|
||||
|
||||
@@ -90,7 +90,8 @@ test.describe("Shape attributes", () => {
|
||||
await expect(workspace.page.getByTestId("add-fill")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("Cannot add a new text fill when the limit has been reached", async ({
|
||||
// FIXME: flaky
|
||||
test.skip("Cannot add a new text fill when the limit has been reached", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page);
|
||||
|
||||
@@ -42,9 +42,7 @@ 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 ({
|
||||
@@ -58,12 +56,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);
|
||||
|
||||
@@ -87,7 +85,9 @@ 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,7 +107,9 @@ 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();
|
||||
@@ -119,7 +121,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");
|
||||
@@ -144,7 +146,9 @@ 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);
|
||||
|
||||
@@ -163,7 +167,9 @@ 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 }) => {
|
||||
@@ -188,4 +194,3 @@ test.describe("Export frames to PDF", () => {
|
||||
await expect(page.getByText("0 of 1 elements selected")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ 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");
|
||||
@@ -60,6 +61,17 @@ 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",
|
||||
});
|
||||
@@ -118,6 +130,17 @@ 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");
|
||||
|
||||
@@ -66,6 +66,7 @@ const copyShorthand = async (panel) => {
|
||||
const panelShorthandButton = panel.getByRole("button", {
|
||||
name: "Copy CSS shorthand to clipboard",
|
||||
});
|
||||
await panelShorthandButton.waitFor();
|
||||
await panelShorthandButton.click();
|
||||
};
|
||||
|
||||
@@ -79,6 +80,7 @@ const copyPropertyFromPropertyRow = async (panel, property) => {
|
||||
.getByTestId("property-row")
|
||||
.filter({ hasText: property });
|
||||
const copyButton = propertyRow.getByRole("button");
|
||||
await copyButton.waitFor();
|
||||
await copyButton.click();
|
||||
};
|
||||
|
||||
@@ -91,6 +93,7 @@ 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;
|
||||
};
|
||||
|
||||
@@ -106,6 +109,7 @@ const selectLayer = async (workspacePage, layerName, parentLayerName) => {
|
||||
await workspacePage.clickToggableLayer(parentLayerName);
|
||||
}
|
||||
await workspacePage.clickLeafLayer(layerName);
|
||||
await workspacePage.page.waitForTimeout(500);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -117,9 +121,17 @@ 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");
|
||||
@@ -131,7 +143,7 @@ const selectColorSpace = async (workspacePage, colorSpace) => {
|
||||
};
|
||||
|
||||
test.describe("Inspect tab - Styles", () => {
|
||||
test("Open Inspect tab", async ({ page }) => {
|
||||
test.skip("Open Inspect tab", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
@@ -231,7 +243,8 @@ test.describe("Inspect tab - Styles", () => {
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
test("Shape Shadow - Composite shadow", async ({ page }) => {
|
||||
// FIXME: flaky/random (depends on trace ?)
|
||||
test.skip("Shape Shadow - Composite shadow", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
@@ -247,9 +260,12 @@ 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 });
|
||||
|
||||
@@ -3,13 +3,9 @@ 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("BUG 10867 - Crash when loading comments", async ({ page }) => {
|
||||
test.skip("BUG 10867 - Crash when loading comments", async ({ page }) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.goToWorkspace();
|
||||
@@ -20,7 +16,7 @@ test("BUG 10867 - Crash when loading comments", async ({ page }) => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("BUG 12164 - Crash when trying to fetch a missing font", async ({
|
||||
test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({
|
||||
page,
|
||||
}) => {
|
||||
// mock fetching a missing font
|
||||
@@ -55,7 +51,8 @@ test("BUG 12164 - Crash when trying to fetch a missing font", async ({
|
||||
pageId: "2b7f0188-51a1-8193-8006-e05bad87b74d",
|
||||
});
|
||||
|
||||
await workspacePage.waitForFirstRender({ hideUI: false });
|
||||
await workspacePage.page.waitForTimeout(1000)
|
||||
await workspacePage.waitForFirstRender();
|
||||
|
||||
await expect(
|
||||
workspacePage.page.getByText("Internal Error"),
|
||||
|
||||
@@ -94,30 +94,28 @@ 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", () => {
|
||||
@@ -199,7 +197,9 @@ 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 nameField.press("Enter");
|
||||
await tokensUpdateCreateModal.getByRole("button", {name: "Save"}).click();
|
||||
|
||||
await expect(tokensUpdateCreateModal).not.toBeVisible();
|
||||
|
||||
@@ -497,8 +497,9 @@ 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("Token value cannot be empty"),
|
||||
tokensUpdateCreateModal.getByText("Empty field"),
|
||||
).toBeVisible();
|
||||
await valueSaturationSelector.click({ position: { x: 50, y: 50 } });
|
||||
await expect(valueField).toHaveValue(/^#[A-Fa-f\d]+$/);
|
||||
@@ -828,16 +829,14 @@ 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");
|
||||
|
||||
@@ -853,7 +852,7 @@ test.describe("Tokens: Themes modal", () => {
|
||||
tokenThemeUpdateCreateModal.getByText("New Group name"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
test("Add new theme", async ({ page }) => {
|
||||
const { tokenThemeUpdateCreateModal, workspacePage } =
|
||||
await setupTokensFile(page);
|
||||
@@ -871,7 +870,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");
|
||||
|
||||
@@ -917,13 +916,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
|
||||
|
||||
@@ -936,12 +935,8 @@ 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"]')
|
||||
@@ -956,9 +951,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();
|
||||
@@ -975,42 +970,9 @@ test.describe("Tokens: Themes modal", () => {
|
||||
});
|
||||
|
||||
test.describe("Tokens: Apply token", () => {
|
||||
// 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);
|
||||
|
||||
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();
|
||||
|
||||
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 setupTokensFile(page);
|
||||
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
|
||||
@@ -1109,12 +1071,10 @@ 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);
|
||||
@@ -1149,9 +1109,7 @@ 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(
|
||||
@@ -1248,9 +1206,15 @@ 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" });
|
||||
|
||||
@@ -1301,7 +1265,9 @@ 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", {
|
||||
@@ -1349,18 +1315,32 @@ 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");
|
||||
@@ -1375,11 +1355,21 @@ 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");
|
||||
@@ -1399,14 +1389,16 @@ 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
|
||||
@@ -1414,11 +1406,21 @@ 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");
|
||||
@@ -1427,11 +1429,21 @@ 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");
|
||||
|
||||
@@ -35,27 +35,62 @@ 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, num_variant) => {
|
||||
const container = await workspacePage.layers
|
||||
const findVariant = async (workspacePage, index) => {
|
||||
const container = workspacePage.layers
|
||||
.getByTestId("layer-row")
|
||||
.filter({ has: workspacePage.page.getByText("Rectangle") })
|
||||
.filter({ hasText: "Rectangle" })
|
||||
.filter({ has: workspacePage.page.getByTestId("icon-component") })
|
||||
.nth(num_variant);
|
||||
.nth(index);
|
||||
|
||||
const variant1 = await workspacePage.layers
|
||||
const variant1 = workspacePage.layers
|
||||
.getByTestId("layer-row")
|
||||
.filter({ has: workspacePage.page.getByText("Value 1") })
|
||||
.filter({ hasText: "Value 1" })
|
||||
.filter({ has: workspacePage.page.getByTestId("icon-variant") })
|
||||
.nth(num_variant);
|
||||
.nth(index);
|
||||
|
||||
const variant2 = await workspacePage.layers
|
||||
const variant2 = workspacePage.layers
|
||||
.getByTestId("layer-row")
|
||||
.filter({ has: workspacePage.page.getByText("Value 2") })
|
||||
.filter({ hasText: "Value 2" })
|
||||
.filter({ has: workspacePage.page.getByTestId("icon-variant") })
|
||||
.nth(num_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);
|
||||
|
||||
return {
|
||||
container: container,
|
||||
@@ -138,27 +173,33 @@ test("User copy paste a variant container", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupVariantsFileWithVariant(workspacePage);
|
||||
|
||||
const variant = await findVariant(workspacePage, 0);
|
||||
const variant = findVariantNoWait(workspacePage, 0);
|
||||
|
||||
// await variant.container.waitFor();
|
||||
|
||||
// Select the variant container
|
||||
await variant.container.click();
|
||||
|
||||
//Copy the variant container
|
||||
await workspacePage.page.waitForTimeout(1000);
|
||||
|
||||
// Copy the variant container
|
||||
await workspacePage.page.keyboard.press("Control+c");
|
||||
|
||||
//Paste the variant container
|
||||
await workspacePage.clickAt(500, 500);
|
||||
// Paste the variant container
|
||||
await workspacePage.clickAt(400, 400);
|
||||
await workspacePage.page.keyboard.press("Control+v");
|
||||
|
||||
const variant_original = await findVariant(workspacePage, 1);
|
||||
const variant_duplicate = await findVariant(workspacePage, 0);
|
||||
const variantDuplicate = findVariantNoWait(workspacePage, 0);
|
||||
const variantOriginal = findVariantNoWait(workspacePage, 1);
|
||||
|
||||
// Expand the layers
|
||||
await variant_duplicate.container.getByRole("button").first().click();
|
||||
await variantDuplicate.container.waitFor();
|
||||
await variantDuplicate.container.locator("button").first().click();
|
||||
|
||||
// The variants are valid
|
||||
await validateVariant(variant_original);
|
||||
await validateVariant(variant_duplicate);
|
||||
// // The variants are valid
|
||||
// // await variantOriginal.container.waitFor();
|
||||
await validateVariant(variantOriginal);
|
||||
await validateVariant(variantDuplicate);
|
||||
});
|
||||
|
||||
test("User cut paste a variant container", async ({ page }) => {
|
||||
@@ -172,21 +213,23 @@ 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 variant_pasted = await findVariant(workspacePage, 0);
|
||||
const variantPasted = await findVariant(workspacePage, 0);
|
||||
|
||||
// Expand the layers
|
||||
await variant_pasted.container.getByRole("button").first().click();
|
||||
await variantPasted.container.locator("button").first().click();
|
||||
|
||||
// The variants are valid
|
||||
await validateVariant(variant_pasted);
|
||||
await validateVariant(variantPasted);
|
||||
});
|
||||
|
||||
test("[Bugfixing] User cut paste a variant container into a board, and undo twice", async ({
|
||||
test("User cut paste a variant container into a board, and undo twice", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
@@ -205,6 +248,7 @@ test("[Bugfixing] User cut paste a variant container into a board, and undo twic
|
||||
|
||||
//Cut the variant container
|
||||
await workspacePage.page.keyboard.press("Control+x");
|
||||
await workspacePage.page.waitForTimeout(500);
|
||||
|
||||
//Select the board
|
||||
await workspacePage.clickLeafLayer("Board");
|
||||
@@ -215,11 +259,12 @@ test("[Bugfixing] User cut paste a variant container into a board, and undo twic
|
||||
//Undo twice
|
||||
await workspacePage.page.keyboard.press("Control+z");
|
||||
await workspacePage.page.keyboard.press("Control+z");
|
||||
await workspacePage.page.waitForTimeout(500);
|
||||
|
||||
const variant_after_undo = await findVariant(workspacePage, 0);
|
||||
const variantAfterUndo = await findVariant(workspacePage, 0);
|
||||
|
||||
// The variants are valid
|
||||
await validateVariant(variant_after_undo);
|
||||
await validateVariant(variantAfterUndo);
|
||||
});
|
||||
|
||||
test("User copy paste a variant", async ({ page }) => {
|
||||
@@ -364,7 +409,7 @@ test("User drag and drop a component with path inside a variant", async ({
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupVariantsFileWithVariant(workspacePage);
|
||||
|
||||
const variant = await findVariant(workspacePage, 0);
|
||||
const variant = findVariantNoWait(workspacePage, 0);
|
||||
|
||||
//Create a component
|
||||
await workspacePage.ellipseShapeButton.click();
|
||||
@@ -404,11 +449,12 @@ 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 variant_origin = await findVariant(workspacePage, 1);
|
||||
const variant_target = await findVariant(workspacePage, 0);
|
||||
const variantOrigin = await findVariantNoWait(workspacePage, 1);
|
||||
|
||||
// Select the variant1
|
||||
await variant_origin.variant1.click();
|
||||
await variantOrigin.variant1.waitFor();
|
||||
await variantOrigin.variant1.click();
|
||||
await variantOrigin.variant1.click();
|
||||
|
||||
//Cut the variant
|
||||
await workspacePage.page.keyboard.press("Control+x");
|
||||
@@ -417,7 +463,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 = await workspacePage.layers
|
||||
const variant3 = workspacePage.layers
|
||||
.getByTestId("layer-row")
|
||||
.filter({ has: workspacePage.page.getByText("Value 1, rectangle") })
|
||||
.filter({ has: workspacePage.page.getByTestId("icon-variant") })
|
||||
|
||||
@@ -46,6 +46,9 @@ 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",
|
||||
@@ -56,18 +59,21 @@ 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");
|
||||
|
||||
@@ -76,14 +82,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
|
||||
|
||||
@@ -248,14 +248,6 @@ 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=*",
|
||||
|
||||
@@ -161,4 +161,4 @@ test.describe("Palette", () => {
|
||||
workspace.palette.getByRole("button", { name: "#7798ff" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,7 +180,7 @@ export async function watch(baseDir, predicate, callback) {
|
||||
});
|
||||
}
|
||||
|
||||
async function readManifestFile(path) {
|
||||
async function readManifestFile() {
|
||||
const manifestPath = "resources/public/js/manifest.json";
|
||||
let content = await fs.readFile(manifestPath, { encoding: "utf8" });
|
||||
return JSON.parse(content);
|
||||
@@ -189,27 +189,19 @@ async function readManifestFile(path) {
|
||||
async function readShadowManifest() {
|
||||
const ts = Date.now();
|
||||
try {
|
||||
const content1 = await readManifestFile(
|
||||
"resources/public/js/manifest.json",
|
||||
);
|
||||
const content2 = await readManifestFile(
|
||||
"resources/public/js/worker/manifest.json",
|
||||
);
|
||||
const content = await readManifestFile();
|
||||
|
||||
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 content1) {
|
||||
for (let item of content) {
|
||||
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 {
|
||||
|
||||
@@ -33,7 +33,7 @@ const config = {
|
||||
bundle: true,
|
||||
format: "iife",
|
||||
banner: {
|
||||
js: '"use strict";',
|
||||
js: '"use strict"; var global = globalThis;',
|
||||
},
|
||||
outfile: "resources/public/js/libs.js",
|
||||
plugins: [fixReactVirtualized, rebuildNotify],
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
:source-map-detail-level :all}}}
|
||||
|
||||
:worker
|
||||
{:target :browser
|
||||
{:target :esm
|
||||
:output-dir "resources/public/js/worker/"
|
||||
:asset-path "/js/worker"
|
||||
:devtools {:browser-inject :main
|
||||
|
||||
@@ -104,7 +104,6 @@
|
||||
(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.
|
||||
|
||||
@@ -301,6 +301,4 @@
|
||||
:width 1280
|
||||
:height 720}])
|
||||
|
||||
(def zoom-half-pixel-precision 8)
|
||||
|
||||
(def max-input-length 255)
|
||||
|
||||
@@ -195,7 +195,9 @@
|
||||
(ptk/reify ::login-from-token
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rx/of (logged-in (with-meta profile {::ev/source "login-with-token"})))
|
||||
(->> (dp/on-fetch-profile-success profile)
|
||||
(rx/map (fn [profile]
|
||||
(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)))))
|
||||
|
||||
@@ -99,16 +99,18 @@
|
||||
(watch [_ state _]
|
||||
(let [file-id (:current-file-id state)
|
||||
page-id (:current-page-id state)
|
||||
exports (for [frame frames]
|
||||
{:enabled true
|
||||
:page-id page-id
|
||||
:file-id file-id
|
||||
:object-id (:id frame)
|
||||
:shape frame
|
||||
:name (:name frame)})]
|
||||
exports (mapv (fn [frame]
|
||||
{:enabled true
|
||||
:page-id page-id
|
||||
:file-id file-id
|
||||
:object-id (:id frame)
|
||||
:shape frame
|
||||
:name (:name frame)})
|
||||
frames)]
|
||||
|
||||
(rx/of (modal/show :export-frames
|
||||
{:exports (vec exports) :origin "workspace:menu"}))))))
|
||||
{:exports exports
|
||||
:origin "workspace:menu"}))))))
|
||||
|
||||
(defn- initialize-export-status
|
||||
[exports cmd resource]
|
||||
@@ -127,7 +129,7 @@
|
||||
:cmd cmd}))))
|
||||
|
||||
(defn- update-export-status
|
||||
[{:keys [done status resource-id filename] :as data}]
|
||||
[{:keys [done status resource-uri filename mtype] :as data}]
|
||||
(ptk/reify ::update-export-status
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
@@ -146,9 +148,7 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(when (= status "ended")
|
||||
(->> (rp/cmd! :export {:cmd :get-resource :blob? true :id resource-id})
|
||||
(rx/delay 500)
|
||||
(rx/map #(dom/trigger-download filename %)))))))
|
||||
(dom/trigger-download-uri filename mtype resource-uri)))))
|
||||
|
||||
(defn request-simple-export
|
||||
[{:keys [export]}]
|
||||
@@ -174,17 +174,14 @@
|
||||
(rx/timeout 400 (rx/empty)))
|
||||
|
||||
(->> (rp/cmd! :export params)
|
||||
(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/map (fn [{:keys [filename mtype uri]}]
|
||||
(dom/trigger-download-uri filename mtype uri)
|
||||
(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
Reference in New Issue
Block a user