mirror of
https://github.com/penpot/penpot.git
synced 2026-09-09 20:29:02 -04:00
Compare commits
20
Commits
issue-10948
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d715938f | ||
|
|
b6b1a47a7b | ||
|
|
6f35348c7c | ||
|
|
1d2c37e52c | ||
|
|
f7fd3e1cd5 | ||
|
|
f7bdc9786c | ||
|
|
88a52d1098 | ||
|
|
2318866f8d | ||
|
|
4da6499197 | ||
|
|
7ac61e0597 | ||
|
|
509f5395cb | ||
|
|
d835baefec | ||
|
|
d04cbf175e | ||
|
|
764b62906b | ||
|
|
30943f1074 | ||
|
|
b4659df5b2 | ||
|
|
1ae9334064 | ||
|
|
ef593514f2 | ||
|
|
040080749b | ||
|
|
fadb3124a0 |
No files matched your search
@@ -0,0 +1,58 @@
|
||||
name: "CI: Exporter"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'exporter/**'
|
||||
- 'common/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'exporter/**'
|
||||
- 'common/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-exporter:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Exporter Tests"
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./exporter
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt:clj
|
||||
pnpm run lint:clj
|
||||
|
||||
- name: Tests
|
||||
working-directory: ./exporter
|
||||
run: |
|
||||
./scripts/test
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
pnpm -r run build;
|
||||
pnpm -r run types:check;
|
||||
|
||||
# - name: Tests
|
||||
# working-directory: ./mcp
|
||||
# run: |
|
||||
# pnpm run test;
|
||||
- name: Tests
|
||||
working-directory: ./mcp
|
||||
run: |
|
||||
pnpm run test;
|
||||
@@ -212,6 +212,37 @@ superseded it:
|
||||
|
||||
Replace the reference in the changelog entry with the correct merged PR number.
|
||||
|
||||
### 5b. Security advisory (GHSA) entries
|
||||
|
||||
Security advisories fixed in a release are documented in the changelog even
|
||||
though they are **neither milestone issues nor PRs**. The GHSA ID and its
|
||||
description are supplied by the user or the release notes — they never come
|
||||
from the milestone fetch in step 2.
|
||||
|
||||
**Format** (matches the existing precedent in `CHANGES.md`, e.g. the
|
||||
`create-font-variant` arbitrary file read advisory):
|
||||
|
||||
```markdown
|
||||
- Fix <user-facing description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Place the entry under `### :bug: Bugs fixed`, with **no issue or PR link** —
|
||||
only the advisory URL.
|
||||
- The advisory may be **draft/unpublished** at changelog time (the URL 404s
|
||||
publicly). Do **not** web-fetch or verify the URL, and do **not** drop the
|
||||
entry because of that. Rely on the GHSA ID provided by the user.
|
||||
- Derive the description from the supplied advisory title, imperative mood and
|
||||
user-facing (e.g. `Fix command injection in SVG exporter via legacy fill-color`).
|
||||
- These entries are **invisible to the automation**: they are not returned by
|
||||
`gh.py issues`, not matched by `--compare` (step 3), not part of the PR
|
||||
cross-reference (step 10), and not scanned by the anomaly-report regexes
|
||||
(step 11, which only match `issues/` and `pull/` links). Add them manually.
|
||||
- During pre-flight checks (step 6a) apply only the **backport/duplicate**
|
||||
check: if the same GHSA already appears in an earlier version section, remove
|
||||
it from the current section. Their absence from milestone cross-references
|
||||
is expected, not an anomaly.
|
||||
|
||||
### 6. Read the current CHANGES.md
|
||||
|
||||
Read the top of `CHANGES.md` to understand the existing format and find the
|
||||
@@ -400,6 +431,8 @@ if closed:
|
||||
- ✅ Every merged milestone PR is either in the changelog or excluded by label
|
||||
- ✅ PR and issue counts are internally consistent
|
||||
- ✅ No false-positive PR-to-issue associations
|
||||
- ✅ Advisory (GHSA) entries are not milestone PRs — their absence from the
|
||||
cross-reference is intentional (see step 5b)
|
||||
|
||||
## Version section template
|
||||
|
||||
@@ -410,8 +443,12 @@ if closed:
|
||||
|
||||
- <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
|
||||
```
|
||||
|
||||
Advisory (GHSA) entries have no issue or PR link — just the advisory URL. See
|
||||
step 5b.
|
||||
|
||||
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
|
||||
|
||||
After all edits and cross-referencing are complete, generate a structured
|
||||
@@ -732,6 +769,14 @@ self-contained and clickable in any Markdown viewer.
|
||||
Taiga description text or by searching GitHub PRs that reference the Taiga
|
||||
URL. Replace the Taiga reference with the GitHub issue link and add the PR
|
||||
reference if applicable.
|
||||
- **Security advisory (GHSA) entries.** Advisories fixed in the release are
|
||||
listed under `### :bug: Bugs fixed` with the advisory URL and **no issue or
|
||||
PR link**, even though they are not in the milestone. The GHSA ID and
|
||||
description come from the user — do **not** fetch or verify the URL, and do
|
||||
not drop a draft (unpublished) advisory. Precedent:
|
||||
`- Fix arbitrary file read security issue on create-font-variant rpc method
|
||||
(https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)`.
|
||||
See step 5b.
|
||||
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
|
||||
before making edits, don't rely on cached data.
|
||||
- **Use `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
## Layout and commands
|
||||
|
||||
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
|
||||
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
|
||||
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`.
|
||||
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
|
||||
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
- Exporter test conventions and CI: `mem:exporter/testing`.
|
||||
|
||||
## HTTP and browser pool
|
||||
|
||||
@@ -31,4 +32,4 @@
|
||||
- WebP is produced by taking a PNG screenshot and converting it with ImageMagick.
|
||||
- SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths.
|
||||
- PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers.
|
||||
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
|
||||
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Exporter Testing
|
||||
|
||||
- READ `mem:testing` first.
|
||||
- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`.
|
||||
- Register every test namespace in `exporter-tests.runner`.
|
||||
- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests.
|
||||
- From `exporter/`: `pnpm run test` builds and runs tests with full output.
|
||||
- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output.
|
||||
- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`.
|
||||
- For iterative focused runs, build once and reuse the compiled bundle.
|
||||
- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`.
|
||||
- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`.
|
||||
- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`).
|
||||
- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs.
|
||||
- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting.
|
||||
- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting.
|
||||
@@ -30,7 +30,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
|
||||
|
||||
Include concise sections covering:
|
||||
- what changed and why;
|
||||
- related GitHub issues or Taiga stories (`Fixes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
|
||||
- related GitHub issues or Taiga stories (`Closes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
|
||||
- screenshots or recordings for UI-visible changes;
|
||||
- testing performed and residual risk;
|
||||
- breaking changes or migration notes, if any.
|
||||
|
||||
+26
-3
@@ -1,15 +1,38 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.17.1 (Unreleased)
|
||||
## 2.17.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix linear gradients in SVG text exports being emitted as radial gradients [#5972](https://github.com/penpot/penpot/issues/5972) (PR: [#11272](https://github.com/penpot/penpot/pull/11272))
|
||||
- Fix typography token becoming detached when editing text content [#11362](https://github.com/penpot/penpot/issues/11362) (PR: [#11366](https://github.com/penpot/penpot/pull/11366))
|
||||
- Fix command injection in SVG exporter via legacy fill-color (https://github.com/penpot/penpot/security/advisories/GHSA-4f36-m4hj-cv86)
|
||||
|
||||
## 2.17.1
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix overrides lost after switching component variant [#10588](https://github.com/penpot/penpot/issues/10588) (PR: [#10619](https://github.com/penpot/penpot/pull/10619))
|
||||
- Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645))
|
||||
- Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655))
|
||||
- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736))
|
||||
- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777))
|
||||
- Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778))
|
||||
- Fix frontend throwing raw TypeError on undefined .getData receivers across import, paste, drag, and text editor paths [#10709](https://github.com/penpot/penpot/issues/10709) (PR: [#10718](https://github.com/penpot/penpot/pull/10718))
|
||||
- Fix workspace crash with 'can't access dead object' in Firefox when navigating between pages [#10719](https://github.com/penpot/penpot/issues/10719) (PR: [#10721](https://github.com/penpot/penpot/pull/10721))
|
||||
- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736))
|
||||
- Fix dashboard sidebar throwing removeChild NotFoundError during rapid keyboard navigation [#10714](https://github.com/penpot/penpot/issues/10714) (PR: [#10715](https://github.com/penpot/penpot/pull/10715))
|
||||
- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777))
|
||||
- Fix import worker crashing when importing non-Penpot zip files [#10781](https://github.com/penpot/penpot/issues/10781) (PR: [#10782](https://github.com/penpot/penpot/pull/10782))
|
||||
- Fix viewer crash with WASM panic when opening URL with page-id [#10800](https://github.com/penpot/penpot/issues/10800) (PR: [#10805](https://github.com/penpot/penpot/pull/10805))
|
||||
- Fix backend returning 500 when JSON request body has unrecognized escape sequence [#10804](https://github.com/penpot/penpot/issues/10804) (PR: [#10808](https://github.com/penpot/penpot/pull/10808))
|
||||
- Fix color picker eyedropper crashing when viewport is unmounted during pointer move [#10811](https://github.com/penpot/penpot/issues/10811) (PR: [#10812](https://github.com/penpot/penpot/pull/10812))
|
||||
- Fix flex layout crash when dragging shapes with missing bounds [#10843](https://github.com/penpot/penpot/issues/10843) (PR: [#10845](https://github.com/penpot/penpot/pull/10845))
|
||||
- Fix export failing when shape has blank layer name [#10849](https://github.com/penpot/penpot/issues/10849) (PR: [#10852](https://github.com/penpot/penpot/pull/10852))
|
||||
- Fix area selection (marquee) being aborted by select-shapes interrupt [#10872](https://github.com/penpot/penpot/issues/10872) (PR: [#10870](https://github.com/penpot/penpot/pull/10870))
|
||||
- Fix gradient editor sending invalid stop offset when clicking outside gradient line [#10879](https://github.com/penpot/penpot/issues/10879) (PR: [#10881](https://github.com/penpot/penpot/pull/10881))
|
||||
- Fix audit event validation failing when error reports contain string profile-id and missing token context [#10897](https://github.com/penpot/penpot/issues/10897) (PR: [#10898](https://github.com/penpot/penpot/pull/10898))
|
||||
- Fix MCP tool call timeout being too low for some operations [#10953](https://github.com/penpot/penpot/issues/10953) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
- Fix MCP requests running into timeouts after leaving a file in Penpot [#10958](https://github.com/penpot/penpot/issues/10958) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
- Fix duplicate WebSocket MCP connection attempts deregistering the original connection's routing entries [#10961](https://github.com/penpot/penpot/issues/10961) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
|
||||
## 2.17.0
|
||||
|
||||
|
||||
@@ -65,12 +65,25 @@
|
||||
:else
|
||||
request)))
|
||||
|
||||
;; The specific-exception branches below (IAE,
|
||||
;; RequestTooBigException, EOFException) raise with
|
||||
;; `ex/raise` rather than calling `errors/handle` directly.
|
||||
;; This is intentional: the throw is caught by the
|
||||
;; top-level error handler in `app.http/router-handler`
|
||||
;; (`backend/src/app/http.clj`), which routes every
|
||||
;; uncaught exception through `errors/handle`. The
|
||||
;; per-route `wrap-errors` middleware in the route list
|
||||
;; is a defensive layer; correctness does not depend on
|
||||
;; it. Raising here keeps the cond uniform with the
|
||||
;; existing RequestTooBigException / EOFException
|
||||
;; branches.
|
||||
(handle-error [cause request]
|
||||
(cond
|
||||
(instance? RuntimeException cause)
|
||||
(if-let [cause (ex-cause cause)]
|
||||
(handle-error cause request)
|
||||
(errors/handle cause request))
|
||||
(instance? IllegalArgumentException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :malformed-json
|
||||
:hint (ex-message cause)
|
||||
:cause cause)
|
||||
|
||||
(instance? RequestTooBigException cause)
|
||||
(ex/raise :type :validation
|
||||
@@ -83,6 +96,11 @@
|
||||
:hint (ex-message cause)
|
||||
:cause cause)
|
||||
|
||||
(instance? RuntimeException cause)
|
||||
(if-let [cause (ex-cause cause)]
|
||||
(handle-error cause request)
|
||||
(errors/handle cause request))
|
||||
|
||||
:else
|
||||
(errors/handle cause request)))]
|
||||
|
||||
|
||||
@@ -21,19 +21,73 @@
|
||||
[clojure.test :as t]
|
||||
[mockery.core :refer [with-mocks]]
|
||||
[yetti.request :as yreq]
|
||||
[yetti.response :as yres]))
|
||||
[yetti.response :as yres])
|
||||
(:import
|
||||
io.undertow.server.RequestTooBigException))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(defrecord DummyRequest [headers cookies]
|
||||
(defrecord DummyRequest [headers cookies method body-stream
|
||||
remote-addr server-name server-port
|
||||
scheme protocol path query ssl-client-cert]
|
||||
yreq/IRequestCookies
|
||||
(get-cookie [_ name]
|
||||
{:value (get cookies name)})
|
||||
|
||||
yreq/IRequest
|
||||
(get-header [_ name]
|
||||
(get headers name)))
|
||||
(get headers name))
|
||||
(method [_] method)
|
||||
(body [_] body-stream)
|
||||
(path [_] path)
|
||||
(query [_] query)
|
||||
(server-port [_] server-port)
|
||||
(server-name [_] server-name)
|
||||
(remote-addr [_] remote-addr)
|
||||
(ssl-client-cert [_] ssl-client-cert)
|
||||
(scheme [_] scheme)
|
||||
(protocol [_] protocol))
|
||||
|
||||
(defn- make-dummy-request
|
||||
"Constructs a DummyRequest from an options map. Every key is
|
||||
optional; missing values fall back to sensible defaults. New
|
||||
fields added to DummyRequest won't break existing call sites
|
||||
as long as this constructor keeps its `:or` defaults in sync.
|
||||
|
||||
Recognized keys:
|
||||
:headers — map of header name → value
|
||||
:cookies — map of cookie name → value
|
||||
:method — HTTP method keyword (default :get)
|
||||
:body-stream — InputStream for the body (used directly)
|
||||
:body-bytes — bytes or string for the body; wrapped in a
|
||||
ByteArrayInputStream if :body-stream is not
|
||||
given
|
||||
:remote-addr — string (default \"127.0.0.1\")
|
||||
:server-name — string (default \"test\")
|
||||
:server-port — long (default 0)
|
||||
:scheme — keyword (default :http)
|
||||
:protocol — string (default \"HTTP/1.1\")
|
||||
:path — string (default \"/test\")
|
||||
:query — string or nil (default nil)
|
||||
:ssl-client-cert — X509Certificate or nil (default nil)"
|
||||
[{:keys [headers cookies method body-stream body-bytes
|
||||
remote-addr server-name server-port scheme protocol
|
||||
path query ssl-client-cert]
|
||||
:or {headers {} cookies {} method :get
|
||||
body-stream nil
|
||||
remote-addr "127.0.0.1" server-name "test" server-port 0
|
||||
scheme :http protocol "HTTP/1.1" path "/test" query nil
|
||||
ssl-client-cert nil}}]
|
||||
(let [body-stream (or body-stream
|
||||
(when body-bytes
|
||||
(java.io.ByteArrayInputStream.
|
||||
(if (string? body-bytes)
|
||||
(.getBytes ^String body-bytes "UTF-8")
|
||||
body-bytes))))]
|
||||
(->DummyRequest headers cookies method body-stream
|
||||
remote-addr server-name server-port
|
||||
scheme protocol path query ssl-client-cert)))
|
||||
|
||||
(t/deftest auth-middleware-1
|
||||
(let [request (volatile! nil)
|
||||
@@ -41,11 +95,11 @@
|
||||
(fn [req] (vreset! request req))
|
||||
{})]
|
||||
|
||||
(handler (->DummyRequest {} {}))
|
||||
(handler (make-dummy-request {}))
|
||||
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
(handler (->DummyRequest {"authorization" "Token aaaa"} {}))
|
||||
(handler (make-dummy-request {:headers {"authorization" "Token aaaa"}}))
|
||||
|
||||
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
|
||||
(t/is (= :token token-type))
|
||||
@@ -58,10 +112,10 @@
|
||||
(fn [req] (vreset! request req))
|
||||
{})]
|
||||
|
||||
(handler (->DummyRequest {} {}))
|
||||
(handler (make-dummy-request {}))
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
(handler (->DummyRequest {"authorization" "Bearer aaaa"} {}))
|
||||
(handler (make-dummy-request {:headers {"authorization" "Bearer aaaa"}}))
|
||||
|
||||
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
|
||||
(t/is (= :bearer token-type))
|
||||
@@ -74,10 +128,10 @@
|
||||
(fn [req] (vreset! request req))
|
||||
{})]
|
||||
|
||||
(handler (->DummyRequest {} {}))
|
||||
(handler (make-dummy-request {}))
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
(handler (->DummyRequest {} {"auth-token" "foobar"}))
|
||||
(handler (make-dummy-request {:cookies {"auth-token" "foobar"}}))
|
||||
|
||||
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
|
||||
(t/is (= :cookie token-type))
|
||||
@@ -89,16 +143,16 @@
|
||||
(fn [req] {::yres/status 200})
|
||||
{:test1 "secret-key"})]
|
||||
|
||||
(let [response (handler (->DummyRequest {} {}))]
|
||||
(let [response (handler (make-dummy-request {}))]
|
||||
(t/is (= 403 (::yres/status response))))
|
||||
|
||||
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key2"} {}))]
|
||||
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key2"}}))]
|
||||
(t/is (= 403 (::yres/status response))))
|
||||
|
||||
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
|
||||
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key"}}))]
|
||||
(t/is (= 403 (::yres/status response))))
|
||||
|
||||
(let [response (handler (->DummyRequest {"x-shared-key" "test1 secret-key"} {}))]
|
||||
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "test1 secret-key"}}))]
|
||||
(t/is (= 200 (::yres/status response))))))
|
||||
|
||||
(t/deftest access-token-authz
|
||||
@@ -209,7 +263,7 @@
|
||||
:user-agent "user agent"})
|
||||
(#'session/assign-token cfg))
|
||||
|
||||
response (handler (->DummyRequest {} {"auth-token" (:token session)}))
|
||||
response (handler (make-dummy-request {:cookies {"auth-token" (:token session)}}))
|
||||
|
||||
{:keys [token claims] token-type :type}
|
||||
(get response ::http/auth-data)]
|
||||
@@ -220,3 +274,127 @@
|
||||
(t/is (= "penpot" (:aud claims)))
|
||||
(t/is (= (:id session) (:sid claims)))
|
||||
(t/is (= (:id profile) (:uid claims)))))
|
||||
|
||||
(t/deftest parse-request-illegal-argument-exception
|
||||
;; clojure.data.json raises IllegalArgumentException (case
|
||||
;; fall-through) on several kinds of malformed input. The
|
||||
;; parse-request middleware should convert any such IAE into a
|
||||
;; 400 :malformed-json validation error rather than letting it
|
||||
;; surface as a 500 internal error. Because the conversion is
|
||||
;; done by raising an ex-info (caught by the top-level error
|
||||
;; handler in app.http/router-handler), this test asserts on
|
||||
;; the ex-info thrown by wrap-parse-request directly.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] {::yres/status 200 ::yres/body :ok}))
|
||||
;; Body contains the bytes for: {"x": "\}"} -- a string
|
||||
;; value with a backslash followed by '}', which
|
||||
;; clojure.data.json v0.5.x cannot handle.
|
||||
body (.getBytes "{\"x\": \"\\}\"}" "UTF-8")
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes body})
|
||||
ex (try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :malformed-json (-> ex ex-data :code)))
|
||||
(t/is (string? (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-request-too-big-exception
|
||||
;; When RequestTooBigException is raised (e.g. the request body
|
||||
;; exceeded the configured size limit), the middleware should
|
||||
;; convert it to a 413 :request-body-too-large validation
|
||||
;; error.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (RequestTooBigException. "too large"))))
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{}" "UTF-8")})
|
||||
ex (try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :request-body-too-large (-> ex ex-data :code)))
|
||||
(t/is (string? (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-eof-exception
|
||||
;; When java.io.EOFException is raised (e.g. the body stream
|
||||
;; was closed before the parser could read it), the middleware
|
||||
;; should convert it to a 400 :malformed-json validation error.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (java.io.EOFException. "stream closed"))))
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{}" "UTF-8")})
|
||||
ex (try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :malformed-json (-> ex ex-data :code)))
|
||||
(t/is (string? (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-runtime-exception-with-cause
|
||||
;; When a RuntimeException with a non-nil ex-cause is raised,
|
||||
;; the middleware should recurse on the cause and dispatch
|
||||
;; through the specific-exception branches. Here we wrap an
|
||||
;; IllegalArgumentException in a RuntimeException and verify
|
||||
;; it surfaces as :malformed-json.
|
||||
(let [iae (IllegalArgumentException. "No matching clause: 99")
|
||||
wrapped (doto (RuntimeException. "wrapped")
|
||||
(.initCause iae))
|
||||
handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw wrapped)))
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{}" "UTF-8")})
|
||||
ex (try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :malformed-json (-> ex ex-data :code)))))
|
||||
|
||||
(t/deftest parse-request-runtime-exception-without-cause
|
||||
;; When a bare RuntimeException (no ex-cause) is raised, the
|
||||
;; middleware should fall through to errors/handle's :default
|
||||
;; path and return a 500 with :type :server-error :code
|
||||
;; :unexpected. This is the "true internal error" path.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (RuntimeException. "boom"))))
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{}" "UTF-8")})
|
||||
response (handler request)
|
||||
body (::yres/body response)]
|
||||
(t/is (= 500 (::yres/status response)))
|
||||
(t/is (= :server-error (:type body)))
|
||||
(t/is (= :unexpected (:code body)))
|
||||
(t/is (= "boom" (:hint body)))))
|
||||
|
||||
(t/deftest parse-request-non-runtime-throwable
|
||||
;; When a non-RuntimeException Throwable is raised (e.g. an
|
||||
;; Error subclass or a non-RuntimeException checked-style
|
||||
;; exception), the middleware should fall through to the
|
||||
;; :else branch and call errors/handle. java.io.IOException
|
||||
;; has a dedicated handle-exception method that returns 500
|
||||
;; with :code :io-exception.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (java.io.IOException. "network gone"))))
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{}" "UTF-8")})
|
||||
response (handler request)
|
||||
body (::yres/body response)]
|
||||
(t/is (= 500 (::yres/status response)))
|
||||
(t/is (= :server-error (:type body)))
|
||||
(t/is (= :io-exception (:code body)))
|
||||
(t/is (= "network gone" (:hint body)))))
|
||||
@@ -10,12 +10,14 @@
|
||||
[app.common.files.changes-builder :as pcb]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.logging :as log]
|
||||
[app.common.path-names :as cpn]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.components-list :as ctkl]
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.pages-list :as ctpl]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.variant :as ctv]
|
||||
[app.common.uuid :as uuid]))
|
||||
|
||||
(log/set-level! :debug)
|
||||
@@ -35,7 +37,7 @@
|
||||
(assoc :width 0.01)
|
||||
(assoc :height 0.01)
|
||||
(cts/setup-rect)))]
|
||||
(log/dbg :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -48,7 +50,7 @@
|
||||
(log/debug :hint " -> set to " :parent-id uuid/zero)
|
||||
(assoc shape :parent-id uuid/zero))]
|
||||
|
||||
(log/dbg :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -61,7 +63,7 @@
|
||||
(log/debug :hint " -> add children to" :parent-id (:id parent-shape))
|
||||
(update parent-shape :shapes conj (:id shape)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:parent-id shape)] repair-shape))))
|
||||
@@ -74,7 +76,7 @@
|
||||
(log/debug :hint " -> remove duplicated children")
|
||||
(update shape :shapes distinct))]
|
||||
|
||||
(log/dbg :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -86,14 +88,14 @@
|
||||
(log/debug :hint " -> remove child" :child-id (:child-id args))
|
||||
(update parent-shape :shapes (fn [shapes]
|
||||
(d/removev #(= (:child-id args) %) shapes))))]
|
||||
(log/dbg :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :invalid-parent
|
||||
[_ {:keys [shape page-id args] :as error} file-data _]
|
||||
(log/dbg :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/change-parent (:parent-id args) [shape] nil {:allow-altering-copies true})))
|
||||
@@ -109,7 +111,7 @@
|
||||
(log/debug :hint " -> set to " :frame-id frame-id)
|
||||
(assoc shape :frame-id frame-id)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -125,7 +127,7 @@
|
||||
(log/debug :hint " -> set to " :frame-id frame-id)
|
||||
(assoc shape :frame-id frame-id)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -138,7 +140,7 @@
|
||||
(log/debug :hint " -> set :main-instance")
|
||||
(assoc shape :main-instance true))]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -155,7 +157,7 @@
|
||||
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
|
||||
;; shape)]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -174,7 +176,7 @@
|
||||
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
|
||||
;; shape)]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes shape-ids repair-shape))))
|
||||
@@ -194,7 +196,7 @@
|
||||
(log/debug :hint " -> detach shape" :shape-id (:id shape))
|
||||
(ctk/detach-shape shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(if (and (some? component) (not (:deleted component)))
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-library-data file-data)
|
||||
@@ -211,7 +213,7 @@
|
||||
;; Assign main instance in the component to current shape
|
||||
(log/debug :hint " -> assign main-instance-page" :component-id (:id component))
|
||||
(assoc component :main-instance-page page-id))]
|
||||
(log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:component-id shape) repair-component))))
|
||||
@@ -224,7 +226,7 @@
|
||||
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
|
||||
shape)]
|
||||
|
||||
(log/dbg :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -237,7 +239,7 @@
|
||||
(log/debug :hint " -> unset :main-instance")
|
||||
(dissoc shape :main-instance))]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -250,7 +252,7 @@
|
||||
(log/debug :hint " -> set :component-root")
|
||||
(assoc shape :component-root true))]
|
||||
|
||||
(log/dbg :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -263,7 +265,7 @@
|
||||
(log/debug :hint " -> unset :component-root")
|
||||
(dissoc shape :component-root))]
|
||||
|
||||
(log/dbg :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -310,7 +312,7 @@
|
||||
|
||||
;; If the shape still refers to the remote component, try to find the corresponding near one
|
||||
;; and link to it. If not, detach the shape.
|
||||
(log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(if (some? matching-shape)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
@@ -329,7 +331,7 @@
|
||||
(log/debug :hint " -> unhead shape")
|
||||
(ctk/unhead-shape shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -354,7 +356,7 @@
|
||||
(nil? (:component-file args))
|
||||
(dissoc :component-file)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -367,7 +369,7 @@
|
||||
(log/debug :hint " -> reroot shape")
|
||||
(ctk/rehead-shape shape (:component-file args) (:component-id args)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -396,7 +398,7 @@
|
||||
(assoc acc k v)))
|
||||
{}
|
||||
objects)))))]
|
||||
(log/dbg :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
|
||||
(log/debug :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
|
||||
(-> (pcb/empty-changes nil nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id shape) repair-component))))
|
||||
@@ -409,7 +411,7 @@
|
||||
(log/debug :hint " -> unset :shape-ref")
|
||||
(dissoc shape :shape-ref))]
|
||||
|
||||
(log/dbg :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -422,7 +424,7 @@
|
||||
(log/debug :hint " -> unset :component-root")
|
||||
(dissoc shape :component-root))]
|
||||
|
||||
(log/dbg :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -435,7 +437,7 @@
|
||||
(log/debug :hint " -> set :component-root")
|
||||
(assoc shape :component-root true))]
|
||||
|
||||
(log/dbg :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape)
|
||||
@@ -449,7 +451,7 @@
|
||||
(log/debug :hint " -> unset :component-root")
|
||||
(dissoc shape :component-root))]
|
||||
|
||||
(log/dbg :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -462,7 +464,7 @@
|
||||
(log/debug :hint " -> set :component-root")
|
||||
(assoc shape :component-root true))]
|
||||
|
||||
(log/dbg :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -475,7 +477,7 @@
|
||||
(log/debug :hint " -> detach shape" :shape-id (:id shape))
|
||||
(ctk/detach-shape shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -488,7 +490,7 @@
|
||||
(log/debug :hint " -> detach shape" :shape-id (:id shape))
|
||||
(ctk/detach-shape shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -501,7 +503,7 @@
|
||||
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
|
||||
shape)]
|
||||
|
||||
(log/dbg :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -520,7 +522,7 @@
|
||||
:r3 0
|
||||
:r4 0))]
|
||||
|
||||
(log/dbg :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -538,7 +540,7 @@
|
||||
(log/debug :hint " -> remove :objects")
|
||||
(dissoc component :objects))))]
|
||||
|
||||
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
|
||||
(log/debug :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
|
||||
(-> (pcb/empty-changes nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id component) repair-component))))
|
||||
@@ -554,7 +556,7 @@
|
||||
(dissoc component :objects))
|
||||
component))]
|
||||
|
||||
(log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
|
||||
(log/debug :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
|
||||
(-> (pcb/empty-changes nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id component) repair-component))))
|
||||
@@ -567,7 +569,7 @@
|
||||
(log/debug :hint " -> add :content-group to :touched-groups")
|
||||
(update shape :touched ctk/set-touched-group :content-group))]
|
||||
|
||||
(log/dbg :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -580,7 +582,7 @@
|
||||
(log/debug :hint " -> remove swap-slot")
|
||||
(ctk/remove-swap-slot shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -603,13 +605,11 @@
|
||||
(log/debug :hint " -> remove swap-slot" :child-id (:id shape))
|
||||
(ctk/remove-swap-slot shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes (map :id child-with-duplicate) repair-shape))))
|
||||
|
||||
|
||||
|
||||
(defmethod repair-error :component-duplicate-slot
|
||||
[_ {:keys [shape] :as error} file-data _]
|
||||
(let [main-shape (get-in shape [:objects (:main-instance-id shape)])
|
||||
@@ -633,7 +633,7 @@
|
||||
(:objects component))]
|
||||
(assoc component :objects objects)))]
|
||||
|
||||
(log/dbg :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape))
|
||||
(log/debug :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape))
|
||||
(-> (pcb/empty-changes nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id shape) repair-component))))
|
||||
@@ -649,50 +649,139 @@
|
||||
(ctk/set-swap-slot shape slot))
|
||||
shape)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :not-a-variant
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
(defmethod repair-error :main-instance-not-a-variant
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [repair-shape
|
||||
(fn [shape]
|
||||
(let [variant-id (:variant-id args)]
|
||||
;; Set the desired variant-id
|
||||
(log/debug :hint (str " -> set variant-id to " variant-id))
|
||||
(assoc shape :variant-id variant-id)))]
|
||||
|
||||
(defmethod repair-error :invalid-variant-id
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
(log/debug :hint "repairing shape :main-instance-not-a-variant" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :main-instance-invalid-variant-id
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [repair-shape
|
||||
(fn [shape]
|
||||
(let [variant-id (:variant-id args)]
|
||||
;; Set the desired variant-id
|
||||
(log/debug :hint (str " -> set variant-id to " variant-id))
|
||||
(assoc shape
|
||||
:variant-id variant-id)))]
|
||||
|
||||
(log/debug :hint "repairing shape :main-instance-invalid-variant-id" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :invalid-variant-properties
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [prop-names (:prop-names args)
|
||||
|
||||
component (get-in file-data [:components (:component-id shape)])
|
||||
prop-values (into {} (map (juxt :name :value)) (:variant-properties component))
|
||||
properties' (mapv (fn [name] {:name name :value (get prop-values name "")}) prop-names)
|
||||
variant-name (ctv/properties-to-name properties')
|
||||
|
||||
repair-component
|
||||
(fn [component]
|
||||
;; Rebuild component properties, removing any extra ones and adding missing ones with empty value
|
||||
(log/debug :hint " -> rebuild properties" :component-id (:id component) :prop-names (str prop-names))
|
||||
(assoc component :variant-properties properties'))
|
||||
|
||||
repair-shape
|
||||
(fn [shape]
|
||||
(log/debug :hint " -> set variant-name" :variant-name variant-name)
|
||||
(assoc shape :variant-name variant-name))]
|
||||
|
||||
(log/debug :hint "repairing shape :invalid-variant-properties" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:component-id shape) repair-component)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :variant-not-main
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
[_ {:keys [shape page-id]} file-data _]
|
||||
(let [page (ctpl/get-page file-data page-id)
|
||||
shape-ids (cfh/get-children-ids-with-self (:objects page) (:id shape))]
|
||||
(log/debug :hint "repairing shape :variant-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint " -> delete shapes" :shape-ids shape-ids)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/remove-objects shape-ids))))
|
||||
|
||||
(defmethod repair-error :parent-not-variant
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
[_ {:keys [shape page-id]} file-data _]
|
||||
(let [parent-id (:parent-id shape)
|
||||
repair-fn
|
||||
(fn [parent]
|
||||
(log/debug :hint " -> set :is-variant-container true")
|
||||
(assoc parent :is-variant-container true))]
|
||||
(log/debug :hint "repairing shape :parent-not-variant" :id (:id shape) :name (:name shape) :parent-id parent-id :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [parent-id] repair-fn))))
|
||||
|
||||
(defmethod repair-error :variant-bad-name
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
(defmethod repair-error :variant-main-bad-name
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [repair-fn
|
||||
(fn [shape]
|
||||
(log/debug :hint " -> set :name" :name (:variant-name args))
|
||||
(assoc shape :name (:variant-name args)))]
|
||||
(log/debug :hint "repairing shape :variant-main-bad-name" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-fn))))
|
||||
|
||||
(defmethod repair-error :variant-bad-variant-name
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
(defmethod repair-error :variant-main-bad-variant-name
|
||||
[_ {:keys [shape page-id]} file-data _]
|
||||
(let [component (get-in file-data [:components (:component-id shape)])
|
||||
variant-name (ctv/properties-to-name (:variant-properties component))
|
||||
repair-fn
|
||||
(fn [shape]
|
||||
(log/debug :hint " -> set :variant-name" :variant-name variant-name)
|
||||
(assoc shape :variant-name variant-name))]
|
||||
(log/dbg :hint "repairing shape :variant-main-bad-variant-name" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-fn))))
|
||||
|
||||
(defmethod repair-error :variant-component-bad-name
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [[path name] (cpn/split-group-name (:variant-container-name args))
|
||||
repair-fn
|
||||
(fn [component]
|
||||
(log/debug :hint " -> set :path and :name" :path path :name name)
|
||||
(assoc component :path path :name name))]
|
||||
(log/dbg :hint "repairing shape :variant-component-bad-name" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:component-id shape) repair-fn))))
|
||||
|
||||
(defmethod repair-error :variant-component-bad-id
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [repair-shape
|
||||
(fn [shape]
|
||||
(let [variant-id (:variant-id args)]
|
||||
;; Set the desired variant-id
|
||||
(log/debug :hint (str " -> set variant-id to " variant-id))
|
||||
(assoc shape
|
||||
:variant-id variant-id)))]
|
||||
|
||||
(log/debug :hint "repairing shape :variant-component-bad-id" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :default
|
||||
[_ error file _]
|
||||
@@ -701,7 +790,7 @@
|
||||
|
||||
(defn repair-file
|
||||
[{:keys [data id] :as file} libraries errors]
|
||||
(log/dbg :hint "repairing file" :id (str id) :errors (count errors))
|
||||
(log/debug :hint "repairing file" :id (str id) :errors (count errors))
|
||||
(let [{:keys [redo-changes]}
|
||||
(reduce (fn [changes error]
|
||||
(pcb/concat-changes changes
|
||||
|
||||
@@ -65,13 +65,13 @@
|
||||
:misplaced-slot
|
||||
:missing-slot
|
||||
:shape-ref-cycle
|
||||
:not-a-variant
|
||||
:invalid-variant-id
|
||||
:main-instance-not-a-variant
|
||||
:main-instance-invalid-variant-id
|
||||
:invalid-variant-properties
|
||||
:variant-not-main
|
||||
:parent-not-variant
|
||||
:variant-bad-name
|
||||
:variant-bad-variant-name
|
||||
:variant-main-bad-name
|
||||
:variant-main-bad-variant-name
|
||||
:variant-component-bad-name
|
||||
:variant-component-bad-id})
|
||||
|
||||
@@ -573,19 +573,23 @@
|
||||
(run! (fn [child-id]
|
||||
(when-let [child (get objects child-id)]
|
||||
(if (not (ctk/is-variant? child))
|
||||
(report-error :not-a-variant
|
||||
(str/ffmt "Shape % should be a variant" (:id child))
|
||||
child file page)
|
||||
(report-error :main-instance-not-a-variant
|
||||
(str/ffmt "Main instance shape % should be a variant" (:id child))
|
||||
child file page
|
||||
:variant-id shape-id)
|
||||
(do
|
||||
(when (not= (:variant-id child) shape-id)
|
||||
(report-error :invalid-variant-id
|
||||
(str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child))
|
||||
child file page))
|
||||
(report-error :main-instance-invalid-variant-id
|
||||
(str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child))
|
||||
child file page
|
||||
:variant-id shape-id))
|
||||
(when (not= prop-names (cfv/extract-properties-names child file-data))
|
||||
(report-error :invalid-variant-properties
|
||||
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
|
||||
child file page))))))
|
||||
child file page
|
||||
:prop-names prop-names))))))
|
||||
shapes)))
|
||||
|
||||
(defn- check-variant
|
||||
"Shape is a variant, so
|
||||
-it should be a main component
|
||||
@@ -594,9 +598,9 @@
|
||||
-its name should be the same as its parent's
|
||||
"
|
||||
[shape file page]
|
||||
(let [parent (ctst/get-shape page (:parent-id shape))
|
||||
component (ctkl/get-component (:data file) (:component-id shape) true)
|
||||
name (ctv/properties-to-name (:variant-properties component))]
|
||||
(let [parent (ctst/get-shape page (:parent-id shape))
|
||||
component (ctkl/get-component (:data file) (:component-id shape) true)
|
||||
variant-name (ctv/properties-to-name (:variant-properties component))]
|
||||
(when-not (ctk/main-instance? shape)
|
||||
(report-error :variant-not-main
|
||||
(str/ffmt "Variant % is not a main instance" (:id shape))
|
||||
@@ -605,23 +609,26 @@
|
||||
(report-error :parent-not-variant
|
||||
(str/ffmt "Variant % has an invalid parent" (:id shape))
|
||||
shape file page))
|
||||
|
||||
(when-not (= name (:variant-name shape))
|
||||
(report-error :variant-bad-variant-name
|
||||
(when-not (= variant-name (:variant-name shape))
|
||||
(report-error :variant-main-bad-variant-name
|
||||
(str/ffmt "Variant % has an invalid variant-name" (:id shape))
|
||||
shape file page))
|
||||
shape file page
|
||||
:variant-name variant-name))
|
||||
(when-not (= (:name parent) (:name shape))
|
||||
(report-error :variant-bad-name
|
||||
(str/ffmt "Variant % has an invalid name" (:id shape))
|
||||
shape file page))
|
||||
(report-error :variant-main-bad-name
|
||||
(str/ffmt "Main instance inside variant % has an invalid name" (:id shape))
|
||||
shape file page
|
||||
:variant-name (:name parent)))
|
||||
(when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component)))
|
||||
(report-error :variant-component-bad-name
|
||||
(str/ffmt "Component % has an invalid name" (:id shape))
|
||||
shape file page))
|
||||
shape file page
|
||||
:variant-container-name (:name parent)))
|
||||
(when-not (= (:variant-id component) (:variant-id shape))
|
||||
(report-error :variant-component-bad-id
|
||||
(str/ffmt "Variant % has adifferent variant-id than its component" (:id shape))
|
||||
shape file page))))
|
||||
shape file page
|
||||
:variant-id (:variant-id component)))))
|
||||
|
||||
(defn- check-shape
|
||||
"Validate referential integrity and semantic coherence of
|
||||
@@ -740,14 +747,15 @@
|
||||
-It should have at least one variant property"
|
||||
[component file]
|
||||
(let [component-page (ctf/get-component-page (:data file) component)
|
||||
main-component (if (:deleted component)
|
||||
main-instance (if (:deleted component)
|
||||
(dm/get-in component [:objects (:main-instance-id component)])
|
||||
(ctst/get-shape component-page (:main-instance-id component)))]
|
||||
(when (and main-component
|
||||
(not (ctk/is-variant? main-component)))
|
||||
(report-error :not-a-variant
|
||||
(str/ffmt "Shape % should be a variant" (:id main-component))
|
||||
main-component file component-page))))
|
||||
(when (and main-instance
|
||||
(not (ctk/is-variant? main-instance)))
|
||||
(report-error :main-instance-not-a-variant
|
||||
(str/ffmt "Main instance shape % should be a variant" (:id main-instance))
|
||||
main-instance file component-page
|
||||
:variant-id (:variant-id component)))))
|
||||
|
||||
(defn- check-main-inside-main
|
||||
[component file]
|
||||
|
||||
@@ -121,74 +121,79 @@
|
||||
(defn layout-content-points
|
||||
[bounds parent children objects]
|
||||
|
||||
(let [parent-id (dm/get-prop parent :id)
|
||||
parent-bounds @(get bounds parent-id)
|
||||
reverse? (ctl/reverse? parent)
|
||||
children (cond->> children (not reverse?) reverse)]
|
||||
(let [parent-id (dm/get-prop parent :id)
|
||||
parent-bounds (get bounds parent-id)]
|
||||
(when-let [parent-bounds (some-> parent-bounds deref)]
|
||||
(let [reverse? (ctl/reverse? parent)
|
||||
children (cond->> children (not reverse?) reverse)]
|
||||
|
||||
(loop [children (seq children)
|
||||
result (transient [])
|
||||
correct-v (gpt/point 0)]
|
||||
(loop [children (seq children)
|
||||
result (transient [])
|
||||
correct-v (gpt/point 0)]
|
||||
|
||||
(if (not children)
|
||||
(persistent! result)
|
||||
(if (not children)
|
||||
(persistent! result)
|
||||
|
||||
(let [child (first children)
|
||||
child-id (dm/get-prop child :id)
|
||||
child-bounds @(get bounds child-id)
|
||||
[margin-top margin-right margin-bottom margin-left] (ctl/child-margins child)
|
||||
(let [child (first children)
|
||||
child-id (dm/get-prop child :id)
|
||||
child-bounds-ref (get bounds child-id)
|
||||
child-bounds (some-> child-bounds-ref deref)
|
||||
[margin-top margin-right margin-bottom margin-left] (ctl/child-margins child)
|
||||
|
||||
[child-bounds correct-v]
|
||||
(if (or (ctl/fill-width? child) (ctl/fill-height? child))
|
||||
(child-layout-bound-points parent child parent-bounds child-bounds correct-v bounds objects)
|
||||
[(->> child-bounds (map #(gpt/add % correct-v))) correct-v])
|
||||
[child-bounds correct-v]
|
||||
(if (and child-bounds
|
||||
(or (ctl/fill-width? child) (ctl/fill-height? child)))
|
||||
(child-layout-bound-points parent child parent-bounds child-bounds correct-v bounds objects)
|
||||
[(when child-bounds
|
||||
(->> child-bounds (map #(gpt/add % correct-v))))
|
||||
correct-v])
|
||||
|
||||
child-bounds
|
||||
(when (d/not-empty? child-bounds)
|
||||
(-> (gpo/parent-coords-bounds child-bounds parent-bounds)
|
||||
(gpo/pad-points (- margin-top) (- margin-right) (- margin-bottom) (- margin-left))))]
|
||||
child-bounds
|
||||
(when (d/not-empty? child-bounds)
|
||||
(-> (gpo/parent-coords-bounds child-bounds parent-bounds)
|
||||
(gpo/pad-points (- margin-top) (- margin-right) (- margin-bottom) (- margin-left))))]
|
||||
|
||||
(recur (next children)
|
||||
(cond-> result (some? child-bounds) (conj! child-bounds))
|
||||
correct-v))))))
|
||||
(recur (next children)
|
||||
(cond-> result (some? child-bounds) (conj! child-bounds))
|
||||
correct-v))))))))
|
||||
|
||||
(defn layout-content-bounds
|
||||
[bounds {:keys [layout-padding] :as parent} children objects]
|
||||
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds @(get bounds parent-id)
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds (get bounds parent-id)]
|
||||
(when-let [parent-bounds (some-> parent-bounds deref)]
|
||||
(let [row? (ctl/row? parent)
|
||||
col? (ctl/col? parent)
|
||||
space-around? (ctl/space-around? parent)
|
||||
space-evenly? (ctl/space-evenly? parent)
|
||||
content-evenly? (ctl/content-evenly? parent)
|
||||
[layout-gap-row layout-gap-col] (ctl/gaps parent)
|
||||
|
||||
row? (ctl/row? parent)
|
||||
col? (ctl/col? parent)
|
||||
space-around? (ctl/space-around? parent)
|
||||
space-evenly? (ctl/space-evenly? parent)
|
||||
content-evenly? (ctl/content-evenly? parent)
|
||||
[layout-gap-row layout-gap-col] (ctl/gaps parent)
|
||||
row-pad (if (or (and col? space-evenly?)
|
||||
(and col? space-around?)
|
||||
(and row? content-evenly?))
|
||||
layout-gap-row
|
||||
0)
|
||||
|
||||
row-pad (if (or (and col? space-evenly?)
|
||||
(and col? space-around?)
|
||||
(and row? content-evenly?))
|
||||
layout-gap-row
|
||||
0)
|
||||
col-pad (if (or (and row? space-evenly?)
|
||||
(and row? space-around?)
|
||||
(and col? content-evenly?))
|
||||
layout-gap-col
|
||||
0)
|
||||
|
||||
col-pad (if (or (and row? space-evenly?)
|
||||
(and row? space-around?)
|
||||
(and col? content-evenly?))
|
||||
layout-gap-col
|
||||
0)
|
||||
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
|
||||
pad-top (+ (or pad-top 0) row-pad)
|
||||
pad-right (+ (or pad-right 0) col-pad)
|
||||
pad-bottom (+ (or pad-bottom 0) row-pad)
|
||||
pad-left (+ (or pad-left 0) col-pad)
|
||||
|
||||
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
|
||||
pad-top (+ (or pad-top 0) row-pad)
|
||||
pad-right (+ (or pad-right 0) col-pad)
|
||||
pad-bottom (+ (or pad-bottom 0) row-pad)
|
||||
pad-left (+ (or pad-left 0) col-pad)
|
||||
layout-points
|
||||
(layout-content-points bounds parent children objects)]
|
||||
|
||||
layout-points
|
||||
(layout-content-points bounds parent children objects)]
|
||||
|
||||
(if (d/not-empty? layout-points)
|
||||
(-> layout-points
|
||||
(gpo/merge-parent-coords-bounds parent-bounds)
|
||||
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
|
||||
;; Cannot create some bounds from the children so we return the parent's
|
||||
parent-bounds)))
|
||||
(if (d/not-empty? layout-points)
|
||||
(-> layout-points
|
||||
(gpo/merge-parent-coords-bounds parent-bounds)
|
||||
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
|
||||
;; Cannot create some bounds from the children so we return the parent's
|
||||
parent-bounds)))))
|
||||
@@ -12,36 +12,36 @@
|
||||
|
||||
(defn layout-content-points
|
||||
[bounds parent {:keys [row-tracks column-tracks]}]
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds @(get bounds parent-id)
|
||||
|
||||
hv #(gpo/start-hv parent-bounds %)
|
||||
vv #(gpo/start-vv parent-bounds %)]
|
||||
(d/concat-vec
|
||||
(->> row-tracks
|
||||
(mapcat #(vector (:start-p %)
|
||||
(gpt/add (:start-p %) (vv (:size %))))))
|
||||
(->> column-tracks
|
||||
(mapcat #(vector (:start-p %)
|
||||
(gpt/add (:start-p %) (hv (:size %)))))))))
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds (get bounds parent-id)]
|
||||
(when-let [parent-bounds (some-> parent-bounds deref)]
|
||||
(let [hv #(gpo/start-hv parent-bounds %)
|
||||
vv #(gpo/start-vv parent-bounds %)]
|
||||
(d/concat-vec
|
||||
(->> row-tracks
|
||||
(mapcat #(vector (:start-p %)
|
||||
(gpt/add (:start-p %) (vv (:size %))))))
|
||||
(->> column-tracks
|
||||
(mapcat #(vector (:start-p %)
|
||||
(gpt/add (:start-p %) (hv (:size %)))))))))))
|
||||
|
||||
(defn layout-content-bounds
|
||||
[bounds {:keys [layout-padding] :as parent} layout-data]
|
||||
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds @(get bounds parent-id)
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds (get bounds parent-id)]
|
||||
(when-let [parent-bounds (some-> parent-bounds deref)]
|
||||
(let [{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
|
||||
pad-top (or pad-top 0)
|
||||
pad-right (or pad-right 0)
|
||||
pad-bottom (or pad-bottom 0)
|
||||
pad-left (or pad-left 0)
|
||||
|
||||
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
|
||||
pad-top (or pad-top 0)
|
||||
pad-right (or pad-right 0)
|
||||
pad-bottom (or pad-bottom 0)
|
||||
pad-left (or pad-left 0)
|
||||
layout-points (layout-content-points bounds parent layout-data)]
|
||||
|
||||
layout-points (layout-content-points bounds parent layout-data)]
|
||||
|
||||
(if (d/not-empty? layout-points)
|
||||
(-> layout-points
|
||||
(gpo/merge-parent-coords-bounds parent-bounds)
|
||||
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
|
||||
;; Cannot create some bounds from the children so we return the parent's
|
||||
parent-bounds)))
|
||||
(if (d/not-empty? layout-points)
|
||||
(-> layout-points
|
||||
(gpo/merge-parent-coords-bounds parent-bounds)
|
||||
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
|
||||
;; Cannot create some bounds from the children so we return the parent's
|
||||
parent-bounds)))))
|
||||
@@ -31,13 +31,17 @@
|
||||
|
||||
(and (ctl/fill-width? child)
|
||||
(ctl/grid-layout? child))
|
||||
(let [children
|
||||
(->> (cfh/get-immediate-children objects (:id child))
|
||||
(remove ctl/position-absolute?)
|
||||
(map #(vector @(get bounds (:id %)) %)))
|
||||
layout-data (gd/calc-layout-data child @(get bounds (:id child)) children bounds objects true)]
|
||||
(max (ctl/child-min-width child)
|
||||
(gpo/width-points (gb/layout-content-bounds bounds child layout-data))))
|
||||
(let [child-bounds-ref (get bounds (:id child))]
|
||||
(if child-bounds-ref
|
||||
(let [children
|
||||
(->> (cfh/get-immediate-children objects (:id child))
|
||||
(remove ctl/position-absolute?)
|
||||
(keep #(when-let [b (get bounds (:id %))]
|
||||
[@b %])))
|
||||
layout-data (gd/calc-layout-data child @child-bounds-ref children bounds objects true)]
|
||||
(max (ctl/child-min-width child)
|
||||
(gpo/width-points (gb/layout-content-bounds bounds child layout-data))))
|
||||
(ctl/child-min-width child)))
|
||||
|
||||
(ctl/fill-width? child)
|
||||
(ctl/child-min-width child)
|
||||
@@ -63,11 +67,15 @@
|
||||
(let [children
|
||||
(->> (cfh/get-immediate-children objects (dm/get-prop child :id))
|
||||
(remove ctl/position-absolute?)
|
||||
(map (fn [child] [@(get bounds (:id child)) child])))
|
||||
(keep (fn [c]
|
||||
(when-let [b (get bounds (:id c))]
|
||||
[@b c]))))
|
||||
layout-data (gd/calc-layout-data child (:points child) children bounds objects true)
|
||||
auto-bounds (gb/layout-content-bounds bounds child layout-data)]
|
||||
(max (ctl/child-min-height child)
|
||||
(gpo/height-points auto-bounds)))
|
||||
(if auto-bounds
|
||||
(max (ctl/child-min-height child)
|
||||
(gpo/height-points auto-bounds))
|
||||
(ctl/child-min-height child)))
|
||||
|
||||
(ctl/fill-height? child)
|
||||
(ctl/child-min-height child)
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
[app.common.types.text :as txt]))
|
||||
|
||||
(defn add-variant
|
||||
"Add a variant component to a file with two variants, each with a root shape.
|
||||
:variant-label [:name Board]
|
||||
{:root2-label} [:name Board] # [Component :component2-label]
|
||||
{:root1-label} [:name Board] # [Component :component1-label]
|
||||
"
|
||||
[file variant-label component1-label root1-label component2-label root2-label
|
||||
& {:keys [variant1-params variant2-params]
|
||||
:or {variant1-params {} variant2-params {}}}]
|
||||
|
||||
@@ -901,8 +901,10 @@
|
||||
(let [shape (get objects shape-id)]
|
||||
(println (str/pad (str (str/repeat " " level)
|
||||
(when (:main-instance shape) "{")
|
||||
(when (:is-variant-container shape) "{{")
|
||||
(:name shape)
|
||||
(when (:main-instance shape) "}")
|
||||
(when (:is-variant-container shape) "}}")
|
||||
(when (seq (:touched shape)) "*")
|
||||
(when show-ids (str/format " %s" (:id shape))))
|
||||
{:length 20
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.flags :as flags]
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[clojure.set :as set]
|
||||
@@ -217,7 +218,10 @@
|
||||
attributes or other things that may be attached).
|
||||
- Consider nil values, empty strings or empty lists all equal.
|
||||
- Normalize numeric values (legacy) into strings.
|
||||
- No value is equal than the default value."
|
||||
- No value is equal than the default value.
|
||||
- Numeric attrs (e.g. line-height) compare with float tolerance so
|
||||
editor/WASM round-trips like \"1.3333333333333333\" vs \"1.33333\"
|
||||
do not count as a real style change (avoids detaching tokens)."
|
||||
[key value1 value2]
|
||||
(when (text-node-attr? key)
|
||||
(let [default-value (get default-text-attrs key)
|
||||
@@ -229,7 +233,16 @@
|
||||
$)))
|
||||
value1' (normalize-value value1)
|
||||
value2' (normalize-value value2)]
|
||||
(not= value1' value2'))))
|
||||
(cond
|
||||
(= value1' value2')
|
||||
false
|
||||
|
||||
:else
|
||||
(let [n1 (when (string? value1') (d/parse-double value1'))
|
||||
n2 (when (string? value2') (d/parse-double value2'))]
|
||||
(if (and (some? n1) (some? n2))
|
||||
(not (mth/close? n1 n2))
|
||||
true))))))
|
||||
|
||||
(defn- compare-text-content
|
||||
"Given two content text structures, conformed by maps and vectors,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns common-tests.files.repair-test
|
||||
"Tests for the validate / repair functions in app.common.files.validate
|
||||
and app.common.files.repair.
|
||||
|
||||
The tests generate cases of broken files and check that the validation functions
|
||||
generate accurate errors, and that the repair functions return the file to
|
||||
a stable state."
|
||||
(:require
|
||||
[app.common.files.repair :as cfr]
|
||||
[app.common.files.validate :as cfv]
|
||||
[app.common.test-helpers.components :as thc]
|
||||
[app.common.test-helpers.files :as thf]
|
||||
[app.common.test-helpers.ids-map :as thi]
|
||||
[app.common.test-helpers.shapes :as ths]
|
||||
[app.common.test-helpers.variants :as thv]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :each thi/test-fixture)
|
||||
|
||||
(t/deftest repair-main-instance-not-a-variant
|
||||
(t/testing "detect and repair a variant component whose root shape is not a variant"
|
||||
(let [file (-> (thf/sample-file :file1 :page-label :page1)
|
||||
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
|
||||
(ths/update-shape :root1 :variant-id nil))
|
||||
|
||||
errors (cfv/validate-file file {})
|
||||
changes (cfr/repair-file file {} errors)
|
||||
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
|
||||
errors' (cfv/validate-file file' {})
|
||||
|
||||
root1' (ths/get-shape file' :root1 :page-label :page1)]
|
||||
|
||||
(t/is (= 2 (count errors))) ;; There are two different checks that detect the same problem
|
||||
(t/is (= :main-instance-not-a-variant (:code (first errors))))
|
||||
|
||||
(t/is (nil? errors'))
|
||||
(t/is (= (thi/id :variant1) (:variant-id root1'))))))
|
||||
|
||||
(t/deftest repair-invalid-variant-id-variant-component-bad-id
|
||||
(t/testing "detect and repair a variant component whose variant id does not match the container's id"
|
||||
(let [file (-> (thf/sample-file :file1 :page-label :page1)
|
||||
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
|
||||
(ths/update-shape :root1 :variant-id (uuid/next)))
|
||||
|
||||
errors (cfv/validate-file file {})
|
||||
changes (cfr/repair-file file {} errors)
|
||||
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
|
||||
errors' (cfv/validate-file file' {})
|
||||
|
||||
root1' (ths/get-shape file' :root1 :page-label :page1)]
|
||||
|
||||
(t/is (= 2 (count errors))) ;; There are two different validation that actually check the same problem
|
||||
(t/is (= :main-instance-invalid-variant-id (:code (first errors))))
|
||||
(t/is (= :variant-component-bad-id (:code (second errors))))
|
||||
|
||||
(t/is (nil? errors'))
|
||||
(t/is (= (thi/id :variant1) (:variant-id root1'))))))
|
||||
|
||||
(t/deftest repair-invalid-variant-properties
|
||||
(t/testing "detect and repair a second variant component whose properties do not match the first variant component's properties"
|
||||
(let [file (-> (thf/sample-file :file1 :page-label :page1)
|
||||
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
|
||||
;; Component1 has ["Property 1", "Property 2"], component2 gets ["Property 1", "Property 3"]
|
||||
;; This breaks validation: prop-names mismatch (missing "Property 2", extra "Property 3")
|
||||
(thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"}
|
||||
{:name "Property 2" :value "ValueA"}]})
|
||||
(thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"}
|
||||
{:name "Property 3" :value "ValueB"}]})
|
||||
(ths/update-shape :root1 :variant-name "Value1, ValueA")
|
||||
(ths/update-shape :root2 :variant-name "Value2, ValueB"))
|
||||
|
||||
errors (cfv/validate-file file {})
|
||||
changes (cfr/repair-file file {} errors)
|
||||
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
|
||||
errors' (cfv/validate-file file' {})
|
||||
|
||||
comp1' (thc/get-component file' :component1)
|
||||
comp2' (thc/get-component file' :component2)
|
||||
root1' (ths/get-shape file' :root1)
|
||||
root2' (ths/get-shape file' :root2)]
|
||||
|
||||
(t/is (= 1 (count errors)))
|
||||
(t/is (= :invalid-variant-properties (:code (first errors))))
|
||||
|
||||
(t/is (nil? errors'))
|
||||
|
||||
;; After repair, component1's properties are rebuilt to match component2's property names
|
||||
;; (the first child in the variant container is root2, so prop-names come from component2)
|
||||
;; "Property 1" keeps its value, "Property 3" is added with empty value, "Property 2" is removed
|
||||
(t/is (= [{:name "Property 1" :value "Value1"}
|
||||
{:name "Property 3" :value ""}]
|
||||
(:variant-properties comp1')))
|
||||
|
||||
(t/is (= "Value1" (:variant-name root1')))
|
||||
|
||||
;; Component2 is unchanged (it was the reference for the property names)
|
||||
(t/is (= [{:name "Property 1" :value "Value2"}
|
||||
{:name "Property 3" :value "ValueB"}]
|
||||
(:variant-properties comp2')))
|
||||
|
||||
(t/is (= "Value2, ValueB" (:variant-name root2'))))))
|
||||
|
||||
(t/deftest repair-variant-not-main
|
||||
(t/testing "detect and repair a non-main-instance shape inside a variant container"
|
||||
(let [file (-> (thf/sample-file :file1 :page-label :page1)
|
||||
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
|
||||
;; Add a third child to the variant container with :variant-id but NOT a main-instance
|
||||
(ths/add-sample-shape :bad-shape
|
||||
:type :frame
|
||||
:parent-label :variant1
|
||||
:variant-id (thi/id :variant1)
|
||||
:variant-name "")
|
||||
;; Add a child to the bad shape (to verify the repair deletes it too)
|
||||
(ths/add-sample-shape :bad-child
|
||||
:type :rect
|
||||
:parent-label :bad-shape))
|
||||
|
||||
errors (cfv/validate-file file {})
|
||||
changes (cfr/repair-file file {} errors)
|
||||
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
|
||||
errors' (cfv/validate-file file' {})
|
||||
|
||||
bad-shape' (ths/get-shape file' :bad-shape)
|
||||
bad-child' (ths/get-shape file' :bad-child)]
|
||||
|
||||
(t/is (= 4 (count errors))) ;; The bad container also triggers other errors
|
||||
(t/is (= :invalid-variant-properties (:code (nth errors 0))))
|
||||
(t/is (= :variant-not-main (:code (nth errors 1))))
|
||||
(t/is (= :variant-component-bad-name (:code (nth errors 2))))
|
||||
(t/is (= :variant-component-bad-id (:code (nth errors 3))))
|
||||
(t/is (nil? errors'))
|
||||
|
||||
(t/is (nil? bad-shape'))
|
||||
(t/is (nil? bad-child')))))
|
||||
|
||||
(t/deftest repair-parent-not-variant
|
||||
(t/testing "detect and repair a variant shape whose parent is not a variant-container"
|
||||
(let [file (-> (thf/sample-file :file1 :page-label :page1)
|
||||
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
|
||||
;; Break the variant container
|
||||
(ths/update-shape :variant1 :is-variant-container false))
|
||||
|
||||
errors (cfv/validate-file file {})
|
||||
changes (cfr/repair-file file {} errors)
|
||||
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
|
||||
errors' (cfv/validate-file file' {})
|
||||
|
||||
container' (ths/get-shape file' :variant1)]
|
||||
|
||||
(t/is (= 2 (count errors))) ;; The error is detected twice, once for each child of the variant container
|
||||
(t/is (= :parent-not-variant (:code (first errors))))
|
||||
(t/is (= :parent-not-variant (:code (second errors))))
|
||||
(t/is (nil? errors'))
|
||||
|
||||
(t/is (true? (:is-variant-container container'))))))
|
||||
|
||||
(t/deftest repair-variant-main-bad-name
|
||||
(t/testing "detect and repair a main instance whose name doesn't match the variant container's name"
|
||||
(let [file (-> (thf/sample-file :file1 :page-label :page1)
|
||||
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
|
||||
;; Change root1's name so it doesn't match the container
|
||||
(ths/update-shape :root1 :name "WrongName"))
|
||||
|
||||
errors (cfv/validate-file file {})
|
||||
changes (cfr/repair-file file {} errors)
|
||||
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
|
||||
errors' (cfv/validate-file file' {})
|
||||
root1' (ths/get-shape file' :root1)]
|
||||
|
||||
(t/is (= 1 (count errors)))
|
||||
(t/is (= :variant-main-bad-name (:code (first errors))))
|
||||
(t/is (nil? errors'))
|
||||
(t/is (= "Board" (:name root1'))))))
|
||||
|
||||
(t/deftest repair-variant-main-bad-variant-name
|
||||
(t/testing "detect and repair a variant shape whose :variant-name doesn't match the component's properties"
|
||||
(let [file (-> (thf/sample-file :file1 :page-label :page1)
|
||||
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
|
||||
(thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"}
|
||||
{:name "Property 2" :value "ValueA"}]})
|
||||
(thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"}
|
||||
{:name "Property 2" :value "ValueB"}]})
|
||||
;; Change root1's :variant-name to something wrong
|
||||
(ths/update-shape :root1 :variant-name "WrongVariantName")
|
||||
(ths/update-shape :root2 :variant-name "Value2, ValueB"))
|
||||
|
||||
errors (cfv/validate-file file {})
|
||||
changes (cfr/repair-file file {} errors)
|
||||
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
|
||||
errors' (cfv/validate-file file' {})
|
||||
|
||||
root1' (ths/get-shape file' :root1)]
|
||||
|
||||
(t/is (= 1 (count errors)))
|
||||
(t/is (= :variant-main-bad-variant-name (:code (first errors))))
|
||||
(t/is (nil? errors'))
|
||||
(t/is (= "Value1, ValueA" (:variant-name root1'))))))
|
||||
|
||||
(t/deftest repair-variant-component-bad-name
|
||||
(t/testing "detect and repair a variant component whose path/name doesn't match the container name"
|
||||
(let [file (-> (thf/sample-file :file1 :page-label :page1)
|
||||
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
|
||||
;; Update names to have path structure
|
||||
(ths/update-shape :variant1 :name "Group / Subgroup / Component")
|
||||
(ths/update-shape :root1 :name "Group / Subgroup / Component")
|
||||
(ths/update-shape :root2 :name "Group / Subgroup / Component")
|
||||
;; Update component paths and names
|
||||
(thc/update-component :component1 {:path "Group / Subgroup" :name "Component"})
|
||||
(thc/update-component :component2 {:path "Group / Subgroup" :name "Component"})
|
||||
;; Break component1's name
|
||||
(thc/update-component :component1 {:name "WrongName"}))
|
||||
|
||||
errors (cfv/validate-file file {})
|
||||
changes (cfr/repair-file file {} errors)
|
||||
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
|
||||
errors' (cfv/validate-file file' {})
|
||||
comp1' (thc/get-component file' :component1)]
|
||||
|
||||
(t/is (= 1 (count errors)))
|
||||
(t/is (= :variant-component-bad-name (:code (first errors))))
|
||||
(t/is (nil? errors'))
|
||||
(t/is (= "Group / Subgroup" (:path comp1')))
|
||||
(t/is (= "Component" (:name comp1'))))))
|
||||
@@ -0,0 +1,213 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns common-tests.geom-bounds-layout-nil-test
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.geom.bounds-map :as gbm]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.geom.shapes.flex-layout.bounds :as fb]
|
||||
[app.common.geom.shapes.grid-layout.bounds :as gb]
|
||||
[app.common.geom.shapes.min-size-layout :as msl]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
;; ---- Helpers ----
|
||||
|
||||
(defn- make-rect
|
||||
[id x y w h]
|
||||
(-> (cts/setup-shape {:id id
|
||||
:type :rect
|
||||
:name (str "rect-" id)
|
||||
:x x :y y :width w :height h})
|
||||
(assoc :parent-id uuid/zero
|
||||
:frame-id uuid/zero)))
|
||||
|
||||
(defn- make-flex-frame
|
||||
[id child-ids & {:keys [x y w h dir]
|
||||
:or {x 0 y 0 w 200 h 200 dir :row}}]
|
||||
(-> (cts/setup-shape {:id id
|
||||
:type :frame
|
||||
:name (str "flex-" id)
|
||||
:layout :flex
|
||||
:layout-flex-dir dir
|
||||
:x x :y y :width w :height h})
|
||||
(assoc :parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:shapes (vec child-ids))))
|
||||
|
||||
(defn- make-grid-frame
|
||||
[id child-ids & {:keys [x y w h dir]
|
||||
:or {x 0 y 0 w 200 h 200 dir :row}}]
|
||||
(let [cell-id (uuid/next)]
|
||||
(-> (cts/setup-shape {:id id
|
||||
:type :frame
|
||||
:name (str "grid-" id)
|
||||
:layout :grid
|
||||
:layout-grid-dir dir
|
||||
:layout-grid-columns [{:type :flex :value 1}]
|
||||
:layout-grid-rows [{:type :flex :value 1}]
|
||||
:layout-grid-cells
|
||||
{cell-id {:id cell-id
|
||||
:row 1
|
||||
:row-span 1
|
||||
:column 1
|
||||
:column-span 1
|
||||
:shapes (vec child-ids)}}
|
||||
:layout-padding-type :multiple
|
||||
:layout-padding {:p1 0 :p2 0 :p3 0 :p4 0}
|
||||
:layout-gap {:column-gap 0 :row-gap 0}
|
||||
:x x :y y :width w :height h})
|
||||
(assoc :parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:shapes (vec child-ids)))))
|
||||
|
||||
(defn- make-objects
|
||||
[shapes]
|
||||
(let [shape-map (into {} (map (fn [s] [(:id s) s]) shapes))]
|
||||
(reduce-kv (fn [m _id shape]
|
||||
(if (contains? shape :shapes)
|
||||
(reduce (fn [m' child-id]
|
||||
(assoc-in m' [child-id :parent-id] (:id shape)))
|
||||
m
|
||||
(:shapes shape))
|
||||
m))
|
||||
shape-map
|
||||
shape-map)))
|
||||
|
||||
(defn- bounds-map-from-objects
|
||||
"Build a bounds map from objects, optionally excluding some IDs."
|
||||
[objects & {:keys [exclude-ids]}]
|
||||
(let [full (gbm/objects->bounds-map objects)]
|
||||
(if (seq exclude-ids)
|
||||
(apply dissoc full exclude-ids)
|
||||
full)))
|
||||
|
||||
;; ---- Tests for flex layout bounds with nil bounds ----
|
||||
|
||||
(t/deftest layout-content-points-with-missing-parent-bounds
|
||||
(t/testing "layout-content-points returns nil when parent is not in bounds map"
|
||||
(let [child-id (uuid/next)
|
||||
parent-id (uuid/next)
|
||||
child (make-rect child-id 10 10 50 50)
|
||||
parent (make-flex-frame parent-id [child-id])
|
||||
objects (make-objects [parent child])
|
||||
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})]
|
||||
|
||||
(t/is (nil? (fb/layout-content-points bounds parent [child] objects))))))
|
||||
|
||||
(t/deftest layout-content-points-with-missing-child-bounds
|
||||
(t/testing "layout-content-points skips children with missing bounds"
|
||||
(let [child1-id (uuid/next)
|
||||
child2-id (uuid/next)
|
||||
parent-id (uuid/next)
|
||||
child1 (make-rect child1-id 10 10 50 50)
|
||||
child2 (make-rect child2-id 70 10 50 50)
|
||||
parent (make-flex-frame parent-id [child1-id child2-id])
|
||||
objects (make-objects [parent child1 child2])
|
||||
bounds (bounds-map-from-objects objects :exclude-ids #{child1-id})]
|
||||
|
||||
(let [result (fb/layout-content-points bounds parent [child1 child2] objects)]
|
||||
(t/is (some? result))
|
||||
;; Only child2's bounds should be in the result
|
||||
(t/is (pos? (count result)))))))
|
||||
|
||||
(t/deftest layout-content-bounds-with-missing-parent-bounds
|
||||
(t/testing "layout-content-bounds returns nil when parent is not in bounds map"
|
||||
(let [child-id (uuid/next)
|
||||
parent-id (uuid/next)
|
||||
child (make-rect child-id 10 10 50 50)
|
||||
parent (make-flex-frame parent-id [child-id])
|
||||
objects (make-objects [parent child])
|
||||
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})]
|
||||
|
||||
(t/is (nil? (fb/layout-content-bounds bounds parent [child] objects))))))
|
||||
|
||||
;; ---- Tests for grid layout bounds with nil bounds ----
|
||||
|
||||
(t/deftest grid-layout-content-points-with-missing-parent-bounds
|
||||
(t/testing "grid layout-content-points returns nil when parent is not in bounds map"
|
||||
(let [parent-id (uuid/next)
|
||||
parent (make-grid-frame parent-id [])
|
||||
objects (make-objects [parent])
|
||||
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})
|
||||
layout-data {:row-tracks [{:start-p (gpt/point 0 0) :size 100}]
|
||||
:column-tracks [{:start-p (gpt/point 0 0) :size 100}]}]
|
||||
|
||||
(t/is (nil? (gb/layout-content-points bounds parent layout-data))))))
|
||||
|
||||
(t/deftest grid-layout-content-bounds-with-missing-parent-bounds
|
||||
(t/testing "grid layout-content-bounds returns nil when parent is not in bounds map"
|
||||
(let [parent-id (uuid/next)
|
||||
parent (make-grid-frame parent-id [])
|
||||
objects (make-objects [parent])
|
||||
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})
|
||||
layout-data {:row-tracks [{:start-p (gpt/point 0 0) :size 100}]
|
||||
:column-tracks [{:start-p (gpt/point 0 0) :size 100}]}]
|
||||
|
||||
(t/is (nil? (gb/layout-content-bounds bounds parent layout-data))))))
|
||||
|
||||
;; ---- Tests for min-size-layout with nil bounds ----
|
||||
|
||||
(t/deftest child-min-width-grid-with-missing-child-bounds
|
||||
(t/testing "child-min-width falls back when grid layout child bounds are missing"
|
||||
(let [grandchild-id (uuid/next)
|
||||
child-id (uuid/next)
|
||||
grandchild (make-rect grandchild-id 0 0 30 30)
|
||||
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
|
||||
(assoc :layout-grid-dir :row
|
||||
:layout-item-h-sizing :fill))
|
||||
objects (make-objects [child grandchild])
|
||||
;; Exclude grandchild from bounds to simulate missing entry
|
||||
bounds (bounds-map-from-objects objects :exclude-ids #{grandchild-id})
|
||||
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
|
||||
|
||||
(let [result (msl/child-min-width child child-bounds bounds objects)]
|
||||
(t/is (= (ctl/child-min-width child) result))))))
|
||||
|
||||
(t/deftest child-min-height-grid-with-missing-child-bounds
|
||||
(t/testing "child-min-height falls back when grid layout child bounds are missing"
|
||||
(let [grandchild-id (uuid/next)
|
||||
child-id (uuid/next)
|
||||
grandchild (make-rect grandchild-id 0 0 30 30)
|
||||
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
|
||||
(assoc :layout-grid-dir :column
|
||||
:layout-item-v-sizing :fill))
|
||||
objects (make-objects [child grandchild])
|
||||
bounds (bounds-map-from-objects objects :exclude-ids #{grandchild-id})
|
||||
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
|
||||
|
||||
(let [result (msl/child-min-height child child-bounds bounds objects)]
|
||||
(t/is (= (ctl/child-min-height child) result))))))
|
||||
|
||||
(t/deftest child-min-width-grid-with-present-child-bounds
|
||||
(t/testing "child-min-width handles bounded children in a fill-width grid"
|
||||
(let [grandchild-id (uuid/next)
|
||||
child-id (uuid/next)
|
||||
grandchild (make-rect grandchild-id 0 0 30 30)
|
||||
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
|
||||
(assoc :layout-item-h-sizing :fill))
|
||||
objects (make-objects [child grandchild])
|
||||
bounds (bounds-map-from-objects objects)
|
||||
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
|
||||
|
||||
(t/is (number? (msl/child-min-width child child-bounds bounds objects))))))
|
||||
|
||||
(t/deftest child-min-height-grid-with-present-child-bounds
|
||||
(t/testing "child-min-height handles bounded children in a fill-height grid"
|
||||
(let [grandchild-id (uuid/next)
|
||||
child-id (uuid/next)
|
||||
grandchild (make-rect grandchild-id 0 0 30 30)
|
||||
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
|
||||
(assoc :layout-item-v-sizing :fill))
|
||||
objects (make-objects [child grandchild])
|
||||
bounds (bounds-map-from-objects objects)
|
||||
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
|
||||
|
||||
(t/is (number? (msl/child-min-height child child-bounds bounds objects))))))
|
||||
@@ -22,6 +22,7 @@
|
||||
[common-tests.files.shapes-builder-test]
|
||||
[common-tests.files.validate-test]
|
||||
[common-tests.geom-align-test]
|
||||
[common-tests.geom-bounds-layout-nil-test]
|
||||
[common-tests.geom-bounds-map-test]
|
||||
[common-tests.geom-flex-layout-test]
|
||||
[common-tests.geom-grid-layout-test]
|
||||
@@ -95,6 +96,7 @@
|
||||
'common-tests.files-migrations-test
|
||||
'common-tests.files.validate-test
|
||||
'common-tests.geom-align-test
|
||||
'common-tests.geom-bounds-layout-nil-test
|
||||
'common-tests.geom-bounds-map-test
|
||||
'common-tests.geom-flex-layout-test
|
||||
'common-tests.geom-grid-layout-test
|
||||
|
||||
@@ -78,6 +78,14 @@
|
||||
(def content-changed-line-height
|
||||
(assoc-in content-base [:children 0 :children 0 :line-height] "1.5"))
|
||||
|
||||
;; Token/WASM may store full float precision; editor round-trips often
|
||||
;; truncate (e.g. CSS / f32). These must compare as equal.
|
||||
(def content-line-height-full-precision
|
||||
(assoc-in content-base [:children 0 :children 0 :line-height] "1.3333333333333333"))
|
||||
|
||||
(def content-line-height-truncated
|
||||
(assoc-in content-base [:children 0 :children 0 :line-height] "1.33333"))
|
||||
|
||||
(def content-redundant-span-line-height
|
||||
(assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5"))
|
||||
|
||||
@@ -208,6 +216,8 @@
|
||||
;; Other text-node-attr categories
|
||||
attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family)
|
||||
attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height)
|
||||
attrs-line-height-precision (cttx/get-diff-attrs content-line-height-full-precision
|
||||
content-line-height-truncated)
|
||||
attrs-span-line-height (cttx/get-diff-attrs content-base content-redundant-span-line-height)
|
||||
attrs-roundtrip-line-height (cttx/get-diff-attrs content-token-like-line-height
|
||||
content-after-editor-roundtrip)
|
||||
@@ -242,6 +252,7 @@
|
||||
;; Each text-node-attr category reports correct attr key
|
||||
(t/is (= #{:font-family} attrs-font-family))
|
||||
(t/is (= #{:line-height} attrs-line-height))
|
||||
(t/is (= #{} attrs-line-height-precision))
|
||||
(t/is (= #{} attrs-span-line-height))
|
||||
(t/is (= #{} attrs-roundtrip-line-height))
|
||||
(t/is (= #{} attrs-nil-typography-refs))
|
||||
|
||||
@@ -78,7 +78,7 @@ services:
|
||||
# - "443:443"
|
||||
|
||||
penpot-frontend:
|
||||
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
|
||||
image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}"
|
||||
restart: always
|
||||
ports:
|
||||
- 9001:8080
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# PENPOT_DISABLE_IPV6_LISTEN: "true"
|
||||
|
||||
penpot-backend:
|
||||
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
|
||||
image: "penpotapp/backend:${PENPOT_VERSION:-2.17}"
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
@@ -180,13 +180,13 @@ services:
|
||||
PENPOT_SMTP_SSL: "false"
|
||||
|
||||
penpot-mcp:
|
||||
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
|
||||
image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}"
|
||||
restart: always
|
||||
networks:
|
||||
- penpot
|
||||
|
||||
penpot-exporter:
|
||||
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
|
||||
image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}"
|
||||
restart: always
|
||||
|
||||
depends_on:
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca
|
||||
title="Quick demo: Penpot MCP server in action"
|
||||
width="100%"
|
||||
height="480"
|
||||
src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0"
|
||||
src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
|
||||
@@ -588,7 +588,7 @@ PENPOT_FLAGS: [...] enable-auto-file-snapshot # Enable automatic v
|
||||
|
||||
# Backend
|
||||
PENPOT_AUTO_FILE_SNAPSHOT_EVERY: 5 # How many save operations trigger the auto-save-version?
|
||||
PENPOT_AUTO_FILE_SNAPSHOT_TIIMEOUT: "1h" # How often is an automatic save forced even if the `every` trigger is not met?
|
||||
PENPOT_AUTO_FILE_SNAPSHOT_TIMEOUT: "1h" # How often is an automatic save forced even if the `every` trigger is not met?
|
||||
```
|
||||
|
||||
Setting custom values for auto-file-snapshot does not change the behaviour for manual versions.
|
||||
|
||||
@@ -31,4 +31,10 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
|
||||
<p>Useful resources to better understand Penpot</p>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user-guide/first-steps/migration-guide">
|
||||
<h2>Migration Guide →</h2>
|
||||
<p>Move a design system from Figma to Penpot</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Migration Guide
|
||||
order: 6
|
||||
desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF.
|
||||
---
|
||||
|
||||
<h1 id="migration-guide">Migration Guide</h1>
|
||||
|
||||
<p class="main-paragraph">If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.</p>
|
||||
|
||||
<div class="advice">
|
||||
<p><strong>Open the full guide (PDF)</strong></p>
|
||||
<p><a href="https://nextcloud.kaleidos.net/index.php/s/mKordyz62QF3PQ4?dir=/&editing=false&openfile=true" target="_blank" rel="noopener"><strong>The Enterprise Guide to Migrating Design Systems from Figma to Penpot</strong></a></p>
|
||||
</div>
|
||||
|
||||
<h2 id="what-the-guide-covers">What the guide covers</h2>
|
||||
<p>The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Before you export:</strong> audit critical files, component chains, token usage, and plugins that will not come along. Split oversized files and clean unused libraries while you are still in Figma.</li>
|
||||
<li><strong>Static assets:</strong> export SVG, PNG, or JPG from Figma and place them in Penpot.</li>
|
||||
<li><strong>Complex files and libraries:</strong> use the Penpot Exporter plugin for Figma (design files, slides, components, variants, auto layout, styles, variables, and libraries). Expect some layout cleanup, Figma Auto Layout becomes Flex and Grid in Penpot.</li>
|
||||
<li><strong>Tokens:</strong> if you already use Tokens Studio, export JSON and import it in Penpot. Native Figma Variables can go through Tokens Studio, or through the Exporter plugin.</li>
|
||||
<li><strong>Validate before you scale:</strong> migrate one representative file (or a sandbox library), write down recurring cleanup, then roll the same checklist out to the rest of the workspace.</li>
|
||||
<li><strong>People and pilots:</strong> the second half of the guide has paths for designers, frontend developers, DesignOps, design-system leads, and product/engineering pilots, including how Penpot MCP can help with post-import cleanup.</li>
|
||||
</ul>
|
||||
|
||||
<p>The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.</p>
|
||||
|
||||
<h2 id="discuss-the-guide">Questions and discussion</h2>
|
||||
<p>If you want to ask about a migration, or share how yours is going, use the Community post <a href="https://community.penpot.app/t/the-enterprise-guide-to-migrating-design-systems-to-penpot/10768" target="_blank" rel="noopener">The Enterprise Guide to Migrating Design Systems to Penpot</a>.</p>
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Troubleshooting WebGL
|
||||
order: 5
|
||||
order: 7
|
||||
desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Beta), and troubleshoot browser, GPU, and system checks.
|
||||
---
|
||||
|
||||
|
||||
@@ -34,8 +34,11 @@
|
||||
"watch": "pnpm run watch:app",
|
||||
"build:app": "clojure -M:dev:shadow-cljs release main",
|
||||
"build": "pnpm run clear:shadow-cache && pnpm run build:app",
|
||||
"fmt": "cljfmt fix --parallel=true src/",
|
||||
"check-fmt": "cljfmt check --parallel=true src/",
|
||||
"lint": "clj-kondo --parallel --lint src/"
|
||||
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
|
||||
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
|
||||
"lint:clj": "clj-kondo --parallel --lint src/ test/",
|
||||
"build:test": "clojure -M:dev:shadow-cljs compile test",
|
||||
"test": "pnpm run build:test && node target/tests/test.js",
|
||||
"test:quiet": "node ./scripts/test-quiet.js"
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run test;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const BUILD_STEPS = [
|
||||
{ label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] },
|
||||
];
|
||||
|
||||
const progress = (msg) => process.stderr.write(`${msg}\n`);
|
||||
|
||||
for (const step of BUILD_STEPS) {
|
||||
progress(`${step.label}...`);
|
||||
const result = spawnSync(step.cmd, step.args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
progress(`${step.label} failed`);
|
||||
if (result.stdout?.length) process.stdout.write(result.stdout);
|
||||
if (result.stderr?.length) process.stderr.write(result.stderr);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
progress("Running tests...");
|
||||
const result = spawnSync(
|
||||
"node",
|
||||
["target/tests/test.js", ...process.argv.slice(2)],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -31,4 +31,12 @@
|
||||
:pseudo-names true
|
||||
:pretty-print true
|
||||
:anon-fn-naming-policy :off
|
||||
:source-map-detail-level :all}}}}}
|
||||
:source-map-detail-level :all}}}
|
||||
|
||||
:test
|
||||
{:target :esm
|
||||
:output-dir "target/tests"
|
||||
:runtime :node
|
||||
:js-options {:js-provider :import}
|
||||
:modules
|
||||
{:test {:init-fn exporter-tests.runner/-main}}}}}
|
||||
@@ -117,7 +117,7 @@
|
||||
[file-id paths]
|
||||
(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))
|
||||
(apply sh/run-cmd! "pdfunite" (conj (vec paths) path))
|
||||
path))
|
||||
|
||||
(defn- move-file
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
: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))))
|
||||
(sh/run-cmd! "convert" png-path "-quality" "100" (str "WEBP:" path))))
|
||||
(on-object (assoc object :path path))))
|
||||
|
||||
(render [uri page]
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
["xml-js" :as xml]
|
||||
[app.browser :as bw]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.types.color :as ctc]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.renderer.svg-gradient :as svg-gradient]
|
||||
[app.util.mime :as mime]
|
||||
[app.util.shell :as sh]
|
||||
[clojure.walk :as walk]
|
||||
@@ -125,19 +128,23 @@
|
||||
(letfn [(convert-to-ppm [pngpath]
|
||||
(let [ppmpath (str/concat pngpath "origin.ppm")]
|
||||
(l/trace :fn :convert-to-ppm :path ppmpath)
|
||||
(-> (sh/run-cmd! (str "convert " pngpath " " ppmpath))
|
||||
(-> (sh/run-cmd! "convert" pngpath ppmpath)
|
||||
(p/then (constantly ppmpath)))))
|
||||
|
||||
(trace-color-mask [pbmpath]
|
||||
(l/trace :fn :trace-color-mask :pbmpath pbmpath)
|
||||
(let [svgpath (str/concat pbmpath ".svg")]
|
||||
(-> (sh/run-cmd! (str "potrace --flat -b svg " pbmpath " -o " svgpath))
|
||||
(-> (sh/run-cmd! "potrace" "--flat" "-b" "svg" pbmpath "-o" svgpath)
|
||||
(p/then (constantly svgpath)))))
|
||||
|
||||
(generate-color-layer [ppmpath color]
|
||||
(when-not (ctc/hex-color-string? color)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-color
|
||||
:hint (str "invalid hex color: " color)))
|
||||
(l/trace :fn :generate-color-layer :ppmpath ppmpath :color color)
|
||||
(let [pbmpath (str/concat ppmpath ".mask-" (subs color 1) ".pbm")]
|
||||
(-> (sh/run-cmd! (str/format "ppmcolormask \"%s\" %s" color ppmpath))
|
||||
(-> (sh/run-cmd! "ppmcolormask" color ppmpath)
|
||||
(p/then (fn [stdout]
|
||||
(-> (sh/write-file! pbmpath stdout)
|
||||
(p/then (constantly pbmpath)))))
|
||||
@@ -166,33 +173,11 @@
|
||||
:else
|
||||
(update node "attributes" assoc "fill" color))))
|
||||
|
||||
(get-stops [data]
|
||||
(->> (get-in data ["gradient" "stops"])
|
||||
(mapv (fn [stop-data]
|
||||
{"type" "element"
|
||||
"name" "stop"
|
||||
"attributes" {"offset" (get stop-data "offset")
|
||||
"stop-color" (get stop-data "color")
|
||||
"stop-opacity" (get stop-data "opacity")}}))))
|
||||
|
||||
(data->gradient-def [id [color data]]
|
||||
(let [id (str "gradient-" id "-" (subs color 1))]
|
||||
(if (= type "linear")
|
||||
{"type" "element"
|
||||
"name" "linearGradient"
|
||||
"attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"}
|
||||
"elements" (get-stops data)}
|
||||
|
||||
{"type" "element"
|
||||
"name" "radialGradient"
|
||||
"attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"}
|
||||
"elements" (get-stops data)})))
|
||||
|
||||
(get-gradients [id mapping]
|
||||
(->> mapping
|
||||
(filter (fn [[_color data]]
|
||||
(= (get data "type") "gradient")))
|
||||
(mapv (partial data->gradient-def id))))
|
||||
(mapv (partial svg-gradient/data->gradient-def id))))
|
||||
|
||||
(join-color-layers [{:keys [id x y width height mapping] :as node} layers]
|
||||
(l/trace :fn :join-color-layers :mapping mapping)
|
||||
@@ -369,4 +354,3 @@
|
||||
(assoc :query (u/map->query-string params)))]
|
||||
(bw/exec! (prepare-options uri)
|
||||
(partial render uri)))))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.renderer.svg-gradient)
|
||||
|
||||
(defn- get-stops
|
||||
[data]
|
||||
(->> (get-in data ["gradient" "stops"])
|
||||
(mapv (fn [stop-data]
|
||||
{"type" "element"
|
||||
"name" "stop"
|
||||
"attributes" {"offset" (get stop-data "offset")
|
||||
"stop-color" (get stop-data "color")
|
||||
"stop-opacity" (get stop-data "opacity")}}))))
|
||||
|
||||
(defn data->gradient-def
|
||||
[id [color data]]
|
||||
(let [id (str "gradient-" id "-" (subs color 1))
|
||||
gradient-type (get-in data ["gradient" "type"])]
|
||||
(if (= gradient-type "linear")
|
||||
{"type" "element"
|
||||
"name" "linearGradient"
|
||||
"attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"}
|
||||
"elements" (get-stops data)}
|
||||
|
||||
{"type" "element"
|
||||
"name" "radialGradient"
|
||||
"attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"}
|
||||
"elements" (get-stops data)})))
|
||||
@@ -94,14 +94,14 @@
|
||||
(.readFile fs/promises fpath))
|
||||
|
||||
(defn run-cmd!
|
||||
[cmd]
|
||||
[cmd & args]
|
||||
(p/create
|
||||
(fn [resolve reject]
|
||||
(l/trace :fn :run-cmd :cmd cmd)
|
||||
(proc/exec cmd #js {:encoding "buffer"}
|
||||
(fn [error stdout _stderr]
|
||||
;; (l/trace :fn :run-cmd :stdout stdout)
|
||||
(if error
|
||||
(reject error)
|
||||
(resolve stdout)))))))
|
||||
(l/trace :fn :run-cmd :cmd cmd :args args)
|
||||
(proc/execFile cmd (clj->js args) #js {:encoding "buffer"}
|
||||
(fn [error stdout _stderr]
|
||||
;; (l/trace :fn :run-cmd :stdout stdout)
|
||||
(if error
|
||||
(reject error)
|
||||
(resolve stdout)))))))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns exporter-tests.renderer-svg-test
|
||||
(:require
|
||||
[app.renderer.svg-gradient :as svg-gradient]
|
||||
[cljs.test :refer [deftest is testing]]))
|
||||
|
||||
(def gradient-stops
|
||||
[{"color" "#000000" "offset" 0 "opacity" 1}
|
||||
{"color" "#ffffff" "offset" 1 "opacity" 1}])
|
||||
|
||||
(deftest creates-the-correct-gradient-element
|
||||
(doseq [[gradient-type element-name]
|
||||
[["linear" "linearGradient"]
|
||||
["radial" "radialGradient"]]]
|
||||
(testing gradient-type
|
||||
(let [gradient-data {"type" "gradient"
|
||||
"gradient" {"type" gradient-type
|
||||
"stops" gradient-stops}}
|
||||
result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])]
|
||||
(is (= element-name (get result "name")))))))
|
||||
@@ -0,0 +1,172 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns exporter-tests.runner
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[cljs.test :as t]
|
||||
[clojure.string :as str]
|
||||
[clojure.tools.cli :refer [parse-opts]]
|
||||
[exporter-tests.renderer-svg-test]
|
||||
[exporter-tests.shell-test]
|
||||
[goog.object :as gobj]))
|
||||
|
||||
(enable-console-print!)
|
||||
|
||||
(def test-namespaces
|
||||
['exporter-tests.renderer-svg-test
|
||||
'exporter-tests.shell-test])
|
||||
|
||||
(assert (every? find-ns-obj test-namespaces)
|
||||
"test-namespaces contains a namespace that isn't required in runner.cljs")
|
||||
|
||||
(defmethod t/report [:cljs.test/default :begin-test-var]
|
||||
[m]
|
||||
(let [v (:var m)]
|
||||
(println (str " ▸ " (:ns (meta v)) "/" (:name (meta v))))))
|
||||
|
||||
(defmethod t/report [:cljs.test/default :end-run-tests]
|
||||
[result]
|
||||
(.exit js/process (if (cljs.test/successful? result) 0 1)))
|
||||
|
||||
(def ^:private log-levels
|
||||
#{:trace :debug :info :warn :error})
|
||||
|
||||
(def cli-options
|
||||
[["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. exporter-tests.renderer-svg-test/creates-the-correct-gradient-element"]
|
||||
["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error"
|
||||
:parse-fn keyword
|
||||
:validate [log-levels "must be one of trace, debug, info, warn, error"]]
|
||||
["-h" "--help"]])
|
||||
|
||||
(defn- argv
|
||||
[]
|
||||
(let [args (->> (.-argv js/process)
|
||||
(array-seq)
|
||||
(drop 2))]
|
||||
;; `pnpm run test -- --focus ...` forwards the separator to the node
|
||||
;; process, so drop one leading `--` before handing args to tools.cli.
|
||||
(cond-> args
|
||||
(= "--" (first args)) rest)))
|
||||
|
||||
(defn- usage
|
||||
[summary]
|
||||
(str "Usage: node target/tests/test.js [options]\n\n"
|
||||
"Options:\n"
|
||||
summary "\n\n"
|
||||
"Build first with: pnpm run build:test\n\n"
|
||||
"Focus examples:\n"
|
||||
" node target/tests/test.js --focus exporter-tests.renderer-svg-test\n"
|
||||
" node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element\n\n"
|
||||
"Log level example:\n"
|
||||
" node target/tests/test.js --focus exporter-tests.renderer-svg-test --log-level warn"))
|
||||
|
||||
(defn- fail!
|
||||
[message]
|
||||
(js/console.error message)
|
||||
(.exit js/process 1))
|
||||
|
||||
(defn- parse-focus
|
||||
[focus]
|
||||
(let [[ns-name test-name & extra] (str/split focus #"/")]
|
||||
(cond
|
||||
(or (str/blank? ns-name) (seq extra))
|
||||
(fail! (str "Invalid --focus value: " focus))
|
||||
|
||||
(some? test-name)
|
||||
{:ns (symbol ns-name) :test test-name}
|
||||
|
||||
:else
|
||||
{:ns (symbol ns-name)})))
|
||||
|
||||
(defn- fixture-value
|
||||
[ns-obj fixture-name]
|
||||
(let [value (gobj/get ns-obj (munge fixture-name))]
|
||||
(when-not (undefined? value)
|
||||
value)))
|
||||
|
||||
(defn- ns-test-vars
|
||||
[ns-sym]
|
||||
(when-let [ns-obj (find-ns-obj ns-sym)]
|
||||
(->> (js-keys ns-obj)
|
||||
(keep (fn [key]
|
||||
(some-> (gobj/get ns-obj key)
|
||||
(.-cljs$lang$var))))
|
||||
(filter (comp :test meta))
|
||||
(sort-by (comp :line meta)))))
|
||||
|
||||
(defn- ns-fixtures
|
||||
[ns-sym vars]
|
||||
(when-let [ns-obj (find-ns-obj ns-sym)]
|
||||
(let [ns-key (or (some-> vars first meta :ns) ns-sym)
|
||||
once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures")
|
||||
each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")]
|
||||
{:once (when once-fixtures {ns-key once-fixtures})
|
||||
:each (when each-fixtures {ns-key each-fixtures})})))
|
||||
|
||||
(defn- selected-tests
|
||||
[{:keys [ns test]}]
|
||||
(when-not (some #{ns} test-namespaces)
|
||||
(fail! (str "Unknown test namespace: " ns)))
|
||||
(let [vars (vec (ns-test-vars ns))]
|
||||
(when (empty? vars)
|
||||
(fail! (str "No tests found in namespace: " ns)))
|
||||
(if test
|
||||
(let [test-sym (symbol test)
|
||||
test-var (some #(when (= test-sym (:name (meta %))) %) vars)]
|
||||
(if test-var
|
||||
{:vars [test-var]
|
||||
:fixtures (ns-fixtures ns [test-var])}
|
||||
(fail! (str "Unknown test var: " ns "/" test))))
|
||||
{:vars vars
|
||||
:fixtures (ns-fixtures ns vars)})))
|
||||
|
||||
(defn- merge-fixtures
|
||||
[fixtures]
|
||||
{:once (apply merge (keep :once fixtures))
|
||||
:each (apply merge (keep :each fixtures))})
|
||||
|
||||
(defn- run-test-vars!
|
||||
[tests]
|
||||
(let [vars (vec (mapcat :vars tests))
|
||||
fixtures (merge-fixtures (map :fixtures tests))
|
||||
env (assoc (t/empty-env)
|
||||
:once-fixtures (:once fixtures)
|
||||
:each-fixtures (:each fixtures))
|
||||
summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})]
|
||||
(t/set-env! env)
|
||||
(t/run-block
|
||||
(concat (t/test-vars-block vars)
|
||||
[(fn []
|
||||
(vswap! summary
|
||||
(partial merge-with +)
|
||||
(:report-counters (t/get-current-env))))
|
||||
(fn []
|
||||
(t/report @summary)
|
||||
(t/report (assoc @summary :type :end-run-tests)))]))))
|
||||
|
||||
(defn- run-focused-test!
|
||||
[focus]
|
||||
(run-test-vars! [(selected-tests (parse-focus focus))]))
|
||||
|
||||
(defn -main
|
||||
[]
|
||||
(let [{:keys [options errors summary]} (parse-opts (argv) cli-options)]
|
||||
(cond
|
||||
(seq errors)
|
||||
(fail! (str/join "\n" errors))
|
||||
|
||||
(:help options)
|
||||
(do
|
||||
(println (usage summary))
|
||||
(.exit js/process 0))
|
||||
|
||||
:else
|
||||
(do
|
||||
(l/setup! {:app (or (:log-level options) :warn)})
|
||||
(if (:focus options)
|
||||
(run-focused-test! (:focus options))
|
||||
(run-test-vars! (map #(selected-tests {:ns %}) test-namespaces)))))))
|
||||
@@ -0,0 +1,70 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns exporter-tests.shell-test
|
||||
"Tests to verify GHSA-4f36-m4hj-cv86 is fixed: OS Command Injection in SVG exporter.
|
||||
These tests prove that:
|
||||
1. execFile does NOT interpret shell metacharacters (safe execution)
|
||||
2. Malicious colors fail validation regex
|
||||
3. The injection does NOT execute commands (no RCE)"
|
||||
(:require
|
||||
["node:child_process" :as proc]
|
||||
["node:fs" :as fs]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
(def ^:private hex-color-rx
|
||||
#"^#(?:[0-9a-fA-F]{3}){1,2}$")
|
||||
|
||||
(defn- valid-hex-color?
|
||||
[color]
|
||||
(and (string? color)
|
||||
(some? (re-matches hex-color-rx color))))
|
||||
|
||||
(t/deftest execfile-does-not-interpret-shell-metacharacters
|
||||
(t/testing "Proves execFile passes arguments literally (no shell interpretation)"
|
||||
(t/async done
|
||||
(let [cmd "echo"
|
||||
args #js ["$(echo PWNED)"]]
|
||||
(proc/execFile cmd args #js {:encoding "buffer"}
|
||||
(fn [error stdout _stderr]
|
||||
(if error
|
||||
(do
|
||||
(t/is false (str "unexpected error: " (.-message error)))
|
||||
(done))
|
||||
(let [output (.toString stdout "utf8")]
|
||||
(t/is (= "$(echo PWNED)\n" output)
|
||||
"execFile passes $(...) literally, no shell interpretation")
|
||||
(done)))))))))
|
||||
|
||||
(t/deftest malicious-color-fails-validation
|
||||
(t/testing "Proves malicious colors are rejected by validation"
|
||||
(let [malicious "#000000$(echo PWNED)"
|
||||
valid-color "#000000"
|
||||
short-valid "#abc"]
|
||||
(t/is (not (valid-hex-color? malicious))
|
||||
"malicious color with $(...) fails validation")
|
||||
(t/is (valid-hex-color? valid-color)
|
||||
"valid 6-digit hex color passes validation")
|
||||
(t/is (valid-hex-color? short-valid)
|
||||
"valid 3-digit hex color passes validation"))))
|
||||
|
||||
(t/deftest execfile-does-not-execute-injected-commands
|
||||
(t/testing "Proves execFile does NOT execute injected commands (no RCE)"
|
||||
(t/async done
|
||||
(let [marker "/tmp/penpot-exporter-rce-test"
|
||||
malicious (str "#000000$(touch " marker ")")
|
||||
cmd "echo"
|
||||
args #js [malicious]]
|
||||
(when (fs/existsSync marker)
|
||||
(fs/unlinkSync marker))
|
||||
(proc/execFile cmd args #js {:encoding "buffer"}
|
||||
(fn [_error _stdout _stderr]
|
||||
;; Command completes (or fails), but no injection occurs
|
||||
(t/is (not (fs/existsSync marker))
|
||||
"no RCE: marker file was NOT created")
|
||||
(when (fs/existsSync marker)
|
||||
(fs/unlinkSync marker))
|
||||
(done)))))))
|
||||
@@ -20,10 +20,27 @@
|
||||
[app.util.dom :as dom]
|
||||
[app.util.websocket :as ws]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(def default-timeout 5000)
|
||||
|
||||
(defn normalize-export
|
||||
[{:keys [object-id name] :as export}]
|
||||
(assoc export :name (if (str/blank? name)
|
||||
(str object-id)
|
||||
name)))
|
||||
|
||||
(defn- normalize-exports
|
||||
[exports]
|
||||
(mapv normalize-export exports))
|
||||
|
||||
(defn- normalize-export-shapes-params
|
||||
[{:keys [exports] :as params}]
|
||||
(cond-> params
|
||||
(seq exports)
|
||||
(assoc :exports (normalize-exports exports))))
|
||||
|
||||
(defn toggle-detail-visibililty
|
||||
[]
|
||||
(ptk/reify ::toggle-detail-visibililty
|
||||
@@ -181,104 +198,106 @@
|
||||
|
||||
(defn request-simple-export
|
||||
[{:keys [export]}]
|
||||
(ptk/reify ::request-simple-export
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(cond-> state
|
||||
(not (use-wasm-export? state export))
|
||||
(update :export assoc :in-progress true :id uuid/zero)))
|
||||
(let [export (normalize-export export)]
|
||||
(ptk/reify ::request-simple-export
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(cond-> state
|
||||
(not (use-wasm-export? state export))
|
||||
(update :export assoc :in-progress true :id uuid/zero)))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(if (use-wasm-export? state export)
|
||||
(do
|
||||
(case (:type export)
|
||||
:pdf (wasm.exports/export-pdf export)
|
||||
(wasm.exports/export-image export))
|
||||
(rx/empty))
|
||||
(let [profile-id (:profile-id state)
|
||||
params {:exports [export]
|
||||
:profile-id profile-id
|
||||
:cmd :export-shapes
|
||||
:wait true
|
||||
:is-wasm (wasm-export-enabled? state)}]
|
||||
(rx/concat
|
||||
(dwp/force-persist-and-wait 400)
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(if (use-wasm-export? state export)
|
||||
(do
|
||||
(case (:type export)
|
||||
:pdf (wasm.exports/export-pdf export)
|
||||
(wasm.exports/export-image export))
|
||||
(rx/empty))
|
||||
(let [profile-id (:profile-id state)
|
||||
params (normalize-export-shapes-params {:exports [export]
|
||||
:profile-id profile-id
|
||||
:cmd :export-shapes
|
||||
:wait true
|
||||
:is-wasm (wasm-export-enabled? state)})]
|
||||
(rx/concat
|
||||
(dwp/force-persist-and-wait 400)
|
||||
|
||||
(->> (rp/cmd! :export params)
|
||||
(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)))))))))))
|
||||
(->> (rp/cmd! :export params)
|
||||
(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 name]
|
||||
:or {cmd :export-shapes}
|
||||
:as params}]
|
||||
(ptk/reify ::request-multiple-export
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [resource-id (volatile! nil)
|
||||
profile-id (:profile-id state)
|
||||
ws-conn (:ws-conn state)
|
||||
params (cond->
|
||||
{:exports exports
|
||||
:cmd cmd
|
||||
:profile-id profile-id
|
||||
:force-multiple true
|
||||
:is-wasm (wasm-export-enabled? state)}
|
||||
(some? name)
|
||||
(assoc :name name))
|
||||
(let [exports (normalize-exports exports)]
|
||||
(ptk/reify ::request-multiple-export
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [resource-id (volatile! nil)
|
||||
profile-id (:profile-id state)
|
||||
ws-conn (:ws-conn state)
|
||||
params (cond->
|
||||
{:exports exports
|
||||
:cmd cmd
|
||||
:profile-id profile-id
|
||||
:force-multiple true
|
||||
:is-wasm (wasm-export-enabled? state)}
|
||||
(some? name)
|
||||
(assoc :name name))
|
||||
|
||||
progress-stream
|
||||
(->> (ws/get-rcv-stream ws-conn)
|
||||
(rx/filter ws/message-event?)
|
||||
(rx/map :payload)
|
||||
(rx/filter #(= :export-update (:type %)))
|
||||
(rx/filter #(= @resource-id (:resource-id %)))
|
||||
(rx/share))
|
||||
progress-stream
|
||||
(->> (ws/get-rcv-stream ws-conn)
|
||||
(rx/filter ws/message-event?)
|
||||
(rx/map :payload)
|
||||
(rx/filter #(= :export-update (:type %)))
|
||||
(rx/filter #(= @resource-id (:resource-id %)))
|
||||
(rx/share))
|
||||
|
||||
stopper
|
||||
(rx/filter #(or (= "ended" (:status %))
|
||||
(= "error" (:status %)))
|
||||
progress-stream)]
|
||||
stopper
|
||||
(rx/filter #(or (= "ended" (:status %))
|
||||
(= "error" (:status %)))
|
||||
progress-stream)]
|
||||
|
||||
(swap! st/ongoing-tasks conj :export)
|
||||
(swap! st/ongoing-tasks conj :export)
|
||||
|
||||
(rx/merge
|
||||
;; Force that all data is persisted; best effort.
|
||||
(rx/of ::dwp/force-persist)
|
||||
(rx/merge
|
||||
;; Force that all data is persisted; best effort.
|
||||
(rx/of ::dwp/force-persist)
|
||||
|
||||
;; Launch the exportation process and stores the resource id
|
||||
;; locally.
|
||||
(->> (rp/cmd! :export params)
|
||||
(rx/map (fn [{:keys [id] :as resource}]
|
||||
(vreset! resource-id id)
|
||||
(initialize-export-status exports cmd resource))))
|
||||
;; Launch the exportation process and stores the resource id
|
||||
;; locally.
|
||||
(->> (rp/cmd! :export params)
|
||||
(rx/map (fn [{:keys [id] :as resource}]
|
||||
(vreset! resource-id id)
|
||||
(initialize-export-status exports cmd resource))))
|
||||
|
||||
;; We proceed to update the export state with incoming
|
||||
;; progress updates. We delay the stopper for give some time
|
||||
;; to update the status with ended or errored status before
|
||||
;; close the stream.
|
||||
(->> progress-stream
|
||||
(rx/map update-export-status)
|
||||
(rx/take-until (rx/delay 500 stopper))
|
||||
(rx/finalize (fn []
|
||||
(swap! st/ongoing-tasks disj :export))))
|
||||
;; We proceed to update the export state with incoming
|
||||
;; progress updates. We delay the stopper for give some time
|
||||
;; to update the status with ended or errored status before
|
||||
;; close the stream.
|
||||
(->> progress-stream
|
||||
(rx/map update-export-status)
|
||||
(rx/take-until (rx/delay 500 stopper))
|
||||
(rx/finalize (fn []
|
||||
(swap! st/ongoing-tasks disj :export))))
|
||||
|
||||
;; We hide need to hide the ui elements of the export after
|
||||
;; some interval. We also delay a little bit more the stopper
|
||||
;; for ensure that after some security time, the stream is
|
||||
;; completely closed.
|
||||
(->> progress-stream
|
||||
(rx/filter #(= "ended" (:status %)))
|
||||
(rx/take 1)
|
||||
(rx/delay default-timeout)
|
||||
(rx/map #(clear-export-state @resource-id))
|
||||
(rx/take-until (rx/delay 6000 stopper))))))))
|
||||
;; We hide need to hide the ui elements of the export after
|
||||
;; some interval. We also delay a little bit more the stopper
|
||||
;; for ensure that after some security time, the stream is
|
||||
;; completely closed.
|
||||
(->> progress-stream
|
||||
(rx/filter #(= "ended" (:status %)))
|
||||
(rx/take 1)
|
||||
(rx/delay default-timeout)
|
||||
(rx/map #(clear-export-state @resource-id))
|
||||
(rx/take-until (rx/delay 6000 stopper)))))))))
|
||||
|
||||
(defn request-export
|
||||
[{:keys [exports] :as params}]
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
[app.config :as cf]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.exports.assets :as de]
|
||||
[app.main.data.exports.wasm :as wasm.exports]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.notifications :as ntf]
|
||||
@@ -1147,16 +1148,16 @@
|
||||
page-id (:current-page-id state)
|
||||
selected (first (dsh/lookup-selected state))
|
||||
|
||||
export {:file-id file-id
|
||||
:page-id page-id
|
||||
:object-id selected
|
||||
;; webp would be preferrable, but PNG is the most supported image MIME type by clipboard APIs.
|
||||
:type :png
|
||||
;; Always use 2 to ensure good enough quality for wireframes.
|
||||
:scale 2
|
||||
:suffix ""
|
||||
:enabled true
|
||||
:name ""}
|
||||
export (de/normalize-export {:file-id file-id
|
||||
:page-id page-id
|
||||
:object-id selected
|
||||
;; webp would be preferrable, but PNG is the most supported image MIME type by clipboard APIs.
|
||||
:type :png
|
||||
;; Always use 2 to ensure good enough quality for wireframes.
|
||||
:scale 2
|
||||
:suffix ""
|
||||
:enabled true
|
||||
:name ""})
|
||||
|
||||
;; Create a deferred promise immediately, before any async operations.
|
||||
;; Registering the clipboard write NOW preserves the user-gesture security
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.math :as mth]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.fills :as types.fills]
|
||||
@@ -950,7 +951,8 @@
|
||||
(or (not cap-stops?) (< (count stops) types.fills/MAX-GRADIENT-STOPS))]
|
||||
|
||||
(if can-add-stop?
|
||||
(let [new-stop (-> (clr/interpolate-gradient stops offset)
|
||||
(let [offset (mth/clamp offset 0 1)
|
||||
new-stop (-> (clr/interpolate-gradient stops offset)
|
||||
(split-color-components))
|
||||
stops (conj stops new-stop)
|
||||
stops (into [] (sort-by :offset stops))
|
||||
@@ -973,7 +975,8 @@
|
||||
stops (mapv split-color-components
|
||||
(if cap-stops?
|
||||
(take types.fills/MAX-GRADIENT-STOPS stops)
|
||||
stops))]
|
||||
stops))
|
||||
stops (mapv #(update % :offset (fn [o] (mth/clamp o 0 1))) stops)]
|
||||
(-> state
|
||||
(assoc :current-color (get stops stop))
|
||||
(assoc :stops stops))))))))
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
delta (if prev-t
|
||||
(str "(+" (ct/diff-ms prev-t t) "ms)")
|
||||
"(+0ms)")
|
||||
delta-pad (str/pad delta {:length 10 :type :right})]
|
||||
delta-pad (str/pad delta {:length 10 :type :right})]
|
||||
(recur t
|
||||
(next xs)
|
||||
(conj! out (str iso " " delta-pad " " name))))
|
||||
|
||||
@@ -79,15 +79,21 @@
|
||||
{:type :gradient
|
||||
:gradient fill-color-gradient}
|
||||
|
||||
(and (string? fill-color) (some? fill-opacity) (not= fill-opacity 1))
|
||||
(and (string? fill-color)
|
||||
(cc/hex-color-string? fill-color)
|
||||
(some? fill-opacity)
|
||||
(not= fill-opacity 1))
|
||||
{:type :transparent
|
||||
:hex fill-color
|
||||
:opacity fill-opacity}
|
||||
|
||||
(string? fill-color)
|
||||
(and (string? fill-color)
|
||||
(cc/hex-color-string? fill-color))
|
||||
{:type :solid
|
||||
:hex fill-color
|
||||
:map-to fill-color}))
|
||||
:map-to fill-color}
|
||||
|
||||
:else nil))
|
||||
|
||||
(defn- retrieve-colors
|
||||
"Given a text shape returns a triple with the values:
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
(mf/deps on-add-stop-preview)
|
||||
(fn [^js e]
|
||||
(let [offset (-> (event->offset e)
|
||||
(mth/clamp 0 1)
|
||||
(mth/precision 2))]
|
||||
(when on-add-stop-preview
|
||||
(on-add-stop-preview offset)))))
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
lv (-> (gpt/to-vec from-p to-p) (gpt/unit))
|
||||
nv (gpt/normal-left lv)
|
||||
offset (-> (gsp/project-t position [from-p to-p] nv)
|
||||
(mth/clamp 0 1)
|
||||
(mth/precision 2))
|
||||
new-stop (cc/interpolate-gradient stops offset)
|
||||
stops (conj stops new-stop)
|
||||
|
||||
@@ -56,51 +56,52 @@
|
||||
|
||||
(defn process-pointer-move
|
||||
[viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y]
|
||||
(when-let [image-data (mf/ref-val canvas-image-data)]
|
||||
(when-let [zoom-view-node (dom/get-element "picker-detail")]
|
||||
(when-not (mf/ref-val zoom-view-context)
|
||||
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
|
||||
(let [canvas-width 260
|
||||
canvas-height 140
|
||||
{brx :left bry :top} (dom/get-bounding-rect viewport-node)
|
||||
(when viewport-node
|
||||
(when-let [image-data (mf/ref-val canvas-image-data)]
|
||||
(when-let [zoom-view-node (dom/get-element "picker-detail")]
|
||||
(when-not (mf/ref-val zoom-view-context)
|
||||
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
|
||||
(let [canvas-width 260
|
||||
canvas-height 140
|
||||
{brx :left bry :top} (dom/get-bounding-rect viewport-node)
|
||||
|
||||
x (mth/floor (- client-x brx))
|
||||
y (mth/floor (- client-y bry))
|
||||
x (mth/floor (- client-x brx))
|
||||
y (mth/floor (- client-y bry))
|
||||
|
||||
img-width (unchecked-get image-data "width")
|
||||
img-height (unchecked-get image-data "height")
|
||||
img-width (unchecked-get image-data "width")
|
||||
img-height (unchecked-get image-data "height")
|
||||
|
||||
zoom-context (mf/ref-val zoom-view-context)
|
||||
zoom-context (mf/ref-val zoom-view-context)
|
||||
|
||||
sx (- x 32)
|
||||
sy (if (cfg/check-browser? :safari) y (- y 17))
|
||||
sw 65
|
||||
sh 35
|
||||
dx 0
|
||||
dy 0
|
||||
dw canvas-width
|
||||
dh canvas-height]
|
||||
sx (- x 32)
|
||||
sy (if (cfg/check-browser? :safari) y (- y 17))
|
||||
sw 65
|
||||
sh 35
|
||||
dx 0
|
||||
dy 0
|
||||
dw canvas-width
|
||||
dh canvas-height]
|
||||
|
||||
(when (obj/get zoom-context "imageSmoothingEnabled")
|
||||
(obj/set! zoom-context "imageSmoothingEnabled" false))
|
||||
(.clearRect zoom-context 0 0 canvas-width canvas-height)
|
||||
(.drawImage zoom-context canvas sx sy sw sh dx dy dw dh)
|
||||
(when (obj/get zoom-context "imageSmoothingEnabled")
|
||||
(obj/set! zoom-context "imageSmoothingEnabled" false))
|
||||
(.clearRect zoom-context 0 0 canvas-width canvas-height)
|
||||
(.drawImage zoom-context canvas sx sy sw sh dx dy dw dh)
|
||||
|
||||
;; Only pick color when cursor is within canvas bounds to avoid garbage pixels
|
||||
(when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height))
|
||||
(let [offset (* (+ (* y img-width) x) 4)
|
||||
rgba (unchecked-get image-data "data")
|
||||
r (d/check-num (obj/get rgba (+ 0 offset)) 255)
|
||||
g (d/check-num (obj/get rgba (+ 1 offset)) 255)
|
||||
b (d/check-num (obj/get rgba (+ 2 offset)) 255)
|
||||
a (d/check-num (obj/get rgba (+ 3 offset)) 255)
|
||||
color [r g b a]]
|
||||
;; Store latest color synchronously so the click handler always reads
|
||||
;; the correct pixel even before the rAF fires (fixes race condition)
|
||||
(mf/set-ref-val! last-picked-color color)
|
||||
(timers/raf
|
||||
(fn []
|
||||
(st/emit! (dwc/pick-color color))))))))))
|
||||
;; Only pick color when cursor is within canvas bounds to avoid garbage pixels
|
||||
(when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height))
|
||||
(let [offset (* (+ (* y img-width) x) 4)
|
||||
rgba (unchecked-get image-data "data")
|
||||
r (d/check-num (obj/get rgba (+ 0 offset)) 255)
|
||||
g (d/check-num (obj/get rgba (+ 1 offset)) 255)
|
||||
b (d/check-num (obj/get rgba (+ 2 offset)) 255)
|
||||
a (d/check-num (obj/get rgba (+ 3 offset)) 255)
|
||||
color [r g b a]]
|
||||
;; Store latest color synchronously so the click handler always reads
|
||||
;; the correct pixel even before the rAF fires (fixes race condition)
|
||||
(mf/set-ref-val! last-picked-color color)
|
||||
(timers/raf
|
||||
(fn []
|
||||
(st/emit! (dwc/pick-color color)))))))))))
|
||||
|
||||
|
||||
(mf/defc pixel-overlay*
|
||||
@@ -260,18 +261,19 @@
|
||||
(defn- viewport->canvas-coords
|
||||
"Maps client (viewport) coordinates to device-pixel canvas coordinates."
|
||||
[viewport-node client-x client-y]
|
||||
(let [{brx :left bry :top} (dom/get-bounding-rect viewport-node)
|
||||
dpr (wasm.api/get-dpr)
|
||||
x (mth/floor (- client-x brx))
|
||||
y (mth/floor (- client-y bry))]
|
||||
[(mth/floor (* x dpr))
|
||||
(mth/floor (* y dpr))]))
|
||||
(when viewport-node
|
||||
(let [{brx :left bry :top} (dom/get-bounding-rect viewport-node)
|
||||
dpr (wasm.api/get-dpr)
|
||||
x (mth/floor (- client-x brx))
|
||||
y (mth/floor (- client-y bry))]
|
||||
[(mth/floor (* x dpr))
|
||||
(mth/floor (* y dpr))])))
|
||||
|
||||
(defn process-pointer-move-wasm
|
||||
"Updates the magnifier loupe with the canvas region under the cursor. The
|
||||
actual color is only read on click (see `pick-color-at-wasm`)."
|
||||
[viewport-node canvas zoom-view-context client-x client-y]
|
||||
(when canvas
|
||||
(when (and canvas viewport-node)
|
||||
(when-let [zoom-view-node (dom/get-element "picker-detail")]
|
||||
(when-not (mf/ref-val zoom-view-context)
|
||||
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
|
||||
@@ -309,7 +311,7 @@
|
||||
the correct color even on GPUs where a raw WebGL `readPixels` returned
|
||||
values with their byte order swapped."
|
||||
[viewport-node canvas client-x client-y]
|
||||
(when canvas
|
||||
(when (and canvas viewport-node)
|
||||
(let [[canvas-x canvas-y] (viewport->canvas-coords viewport-node client-x client-y)
|
||||
img-width (.-width canvas)
|
||||
img-height (.-height canvas)]
|
||||
@@ -370,7 +372,7 @@
|
||||
handle-draw-picker-canvas
|
||||
(mf/use-callback
|
||||
(fn []
|
||||
(when canvas
|
||||
(when (and canvas viewport-node)
|
||||
;; Read current mouse position from ref so the loupe refreshes on
|
||||
;; each render even without a mouse-move.
|
||||
(let [{mx :x my :y} (mf/ref-val initial-mouse-pos)]
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.data.exports.assets :as de]
|
||||
[app.main.data.exports.wasm :as wasm.exports]
|
||||
[app.main.data.persistence :as dwp]
|
||||
[app.main.data.plugins :as dp]
|
||||
@@ -1547,13 +1548,13 @@
|
||||
:profile-id (:profile-id @st/state)
|
||||
:wait true
|
||||
:is-wasm false
|
||||
:exports [{:file-id file-id
|
||||
:page-id page-id
|
||||
:object-id id
|
||||
:name (:name shape)
|
||||
:type (:type value :png)
|
||||
:suffix (:suffix value "")
|
||||
:scale (:scale value 1)}]}]
|
||||
:exports [(de/normalize-export {:file-id file-id
|
||||
:page-id page-id
|
||||
:object-id id
|
||||
:name (:name shape)
|
||||
:type (:type value :png)
|
||||
:suffix (:suffix value "")
|
||||
:scale (:scale value 1)})]}]
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
;; The exporter renders the file from its persisted
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns frontend-tests.data.exports-assets-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.exports.assets :as de]
|
||||
[app.main.data.persistence :as dwp]
|
||||
[app.main.repo :as repo]
|
||||
[app.main.store :as st]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.websocket :as ws]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.events :as the]
|
||||
[frontend-tests.helpers.mock :as mock]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(def ^:private export {:id (uuid/next)
|
||||
:object-id (uuid/next)
|
||||
:type :png
|
||||
:suffix ""
|
||||
:scale 1})
|
||||
|
||||
(defn- export-with-name
|
||||
[name]
|
||||
(merge export {:name name}))
|
||||
|
||||
(defn- test-state
|
||||
[]
|
||||
{:profile-id (:id export)
|
||||
:ws-conn nil})
|
||||
|
||||
(t/deftest normalize-export-preserves-existing-name
|
||||
(t/is (= (export-with-name "Layer 1")
|
||||
(de/normalize-export (export-with-name "Layer 1")))))
|
||||
|
||||
(t/deftest normalize-export-replaces-nil-name-with-object-id
|
||||
(t/is (= (export-with-name (str (:object-id export)))
|
||||
(de/normalize-export (assoc export :name nil)))))
|
||||
|
||||
(t/deftest normalize-export-replaces-empty-name-with-object-id
|
||||
(t/is (= (export-with-name (str (:object-id export)))
|
||||
(de/normalize-export (assoc export :name "")))))
|
||||
|
||||
(t/deftest request-simple-export-sends-normalized-export
|
||||
(t/async done
|
||||
(let [export (export-with-name "")
|
||||
observed (atom nil)]
|
||||
(mock/with-mocks {repo/cmd! (mock/stub (fn [_ params]
|
||||
(reset! observed params)
|
||||
(rx/of {:filename "export.png"
|
||||
:mtype "image/png"
|
||||
:uri "blob:export"})))
|
||||
dwp/force-persist-and-wait (mock/stub (fn [_] (rx/of ::force-persisted)))
|
||||
dom/trigger-download-uri (mock/stub (fn [& _] nil))}
|
||||
(fn [done']
|
||||
(let [completed (fn [_state]
|
||||
(t/is (= (export-with-name (str (:object-id export)))
|
||||
(-> @observed :exports first))))]
|
||||
(ptk/emit! (the/prepare-store (test-state) done' completed)
|
||||
(de/request-simple-export {:export export})
|
||||
:the/end)))
|
||||
done))))
|
||||
|
||||
(t/deftest request-multiple-export-sends-normalized-enabled-exports
|
||||
(t/async done
|
||||
(let [exports [{:id "enabled-1"
|
||||
:object-id "enabled-1"
|
||||
:shape {:id "enabled-1"}
|
||||
:type :png
|
||||
:suffix ""
|
||||
:scale 1
|
||||
:enabled true
|
||||
:name ""}]
|
||||
observed (atom nil)]
|
||||
(mock/with-mocks {repo/cmd! (mock/stub (fn [_ params]
|
||||
(reset! observed params)
|
||||
(rx/of {:id (:id export)})))
|
||||
ws/get-rcv-stream (mock/stub (fn [_] (rx/empty)))
|
||||
st/ongoing-tasks (atom #{})}
|
||||
(fn [done']
|
||||
(let [completed (fn [_state]
|
||||
(t/is (= [{:id "enabled-1"
|
||||
:object-id "enabled-1"
|
||||
:shape {:id "enabled-1"}
|
||||
:type :png
|
||||
:suffix ""
|
||||
:scale 1
|
||||
:enabled true
|
||||
:name "enabled-1"}]
|
||||
(:exports @observed))))]
|
||||
(ptk/emit! (the/prepare-store (test-state) done' completed)
|
||||
(de/request-multiple-export {:exports exports})
|
||||
:the/end)))
|
||||
done))))
|
||||
@@ -7,6 +7,7 @@
|
||||
[frontend-tests.basic-shapes-test]
|
||||
[frontend-tests.code-gen-style-test]
|
||||
[frontend-tests.copy-as-svg-test]
|
||||
[frontend-tests.data.exports-assets-test]
|
||||
[frontend-tests.data.nitrate-test]
|
||||
[frontend-tests.data.repo-test]
|
||||
[frontend-tests.data.store-test]
|
||||
@@ -88,6 +89,7 @@
|
||||
'frontend-tests.data.nitrate-test
|
||||
'frontend-tests.data.repo-test
|
||||
'frontend-tests.data.store-test
|
||||
'frontend-tests.data.exports-assets-test
|
||||
'frontend-tests.errors-test
|
||||
'frontend-tests.main-errors-test
|
||||
'frontend-tests.data.uploads-test
|
||||
|
||||
@@ -272,6 +272,7 @@ The Penpot MCP server can be configured using environment variables.
|
||||
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
|
||||
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
|
||||
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` |
|
||||
| `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` |
|
||||
| `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) |
|
||||
| `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) |
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ export class PenpotMcpServer {
|
||||
public readonly port: number;
|
||||
public readonly webSocketPort: number;
|
||||
public readonly replPort: number;
|
||||
public readonly penpotApiUrl: string;
|
||||
private sessionTimeoutInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
/**
|
||||
@@ -127,8 +126,8 @@ export class PenpotMcpServer {
|
||||
this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10);
|
||||
this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10);
|
||||
this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10);
|
||||
this.penpotApiUrl = process.env.PENPOT_API_URL ?? "http://localhost:6060";
|
||||
this.tenant = process.env.PENPOT_TENANT ?? "default";
|
||||
const toolTimeoutSecs = parseInt(process.env.PENPOT_MCP_TOOL_TIMEOUT_S ?? "120", 10);
|
||||
|
||||
this.configLoader = new ConfigurationLoader(process.cwd());
|
||||
this.apiDocs = new ApiDocs();
|
||||
@@ -149,7 +148,7 @@ export class PenpotMcpServer {
|
||||
this.redisBridge = new RedisBridge(redisUri, this.tenant);
|
||||
}
|
||||
|
||||
this.pluginBridge = new PluginBridge(this, this.webSocketPort, this.redisBridge);
|
||||
this.pluginBridge = new PluginBridge(this, this.webSocketPort, toolTimeoutSecs, this.redisBridge);
|
||||
this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,11 @@ import type { PenpotMcpServer } from "./PenpotMcpServer";
|
||||
import type { RedisBridge } from "./RedisBridge";
|
||||
|
||||
const KEEP_ALIVE_TIME = 30000; // 30 seconds
|
||||
const TOKEN_REVALIDATION_INTERVAL = 300000; // 5 minutes
|
||||
const TOKEN_VALIDATION_TIMEOUT = 5000; // 5 seconds
|
||||
|
||||
interface ClientConnection {
|
||||
socket: WebSocket;
|
||||
userToken: string | null;
|
||||
pingInterval: NodeJS.Timeout;
|
||||
revalidationInterval?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,8 +20,11 @@ interface ClientConnection {
|
||||
* over these connections.
|
||||
*/
|
||||
export class PluginBridge {
|
||||
public static readonly MULTIUSER_CONNECTION_ERROR_MESSAGE = `No Penpot instance connected for user token. Please ensure that Penpot is connected and that the MCP client connection is using the correct token.`;
|
||||
|
||||
private readonly logger = createLogger("PluginBridge");
|
||||
private readonly wsServer: WebSocketServer;
|
||||
|
||||
private readonly connectedClients: Map<WebSocket, ClientConnection> = new Map();
|
||||
private readonly clientsByToken: Map<string, ClientConnection> = new Map();
|
||||
private readonly pendingTasks: Map<string, AbstractPluginTask<any, any>> = new Map();
|
||||
@@ -40,50 +40,18 @@ export class PluginBridge {
|
||||
* holding the relevant plugin's WebSocket connection (which may be this same
|
||||
* instance) via Redis, rather than dispatched directly over a local socket.
|
||||
* @param taskTimeoutSecs - Timeout, in seconds, for plugin task execution
|
||||
* (defaults to {@link DEFAULT_TASK_TIMEOUT_SECS})
|
||||
*/
|
||||
constructor(
|
||||
public readonly mcpServer: PenpotMcpServer,
|
||||
private port: number,
|
||||
private readonly redisBridge?: RedisBridge,
|
||||
private taskTimeoutSecs: number = 30
|
||||
private readonly taskTimeoutSecs: number,
|
||||
private readonly redisBridge?: RedisBridge
|
||||
) {
|
||||
this.wsServer = new WebSocketServer({ port: port });
|
||||
this.setupWebSocketHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a user token against the Penpot backend.
|
||||
*
|
||||
* Makes an HTTP request to the backend's get-profile endpoint to verify
|
||||
* that the token is valid, not expired, and not revoked.
|
||||
*
|
||||
* @param userToken - The token to validate
|
||||
* @returns Promise resolving to true if valid, false otherwise
|
||||
*/
|
||||
private async validateUserToken(userToken: string): Promise<boolean> {
|
||||
const apiUrl = this.mcpServer.penpotApiUrl;
|
||||
const url = `${apiUrl}/api/rpc/command/get-profile`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TOKEN_VALIDATION_TIMEOUT);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Token ${userToken}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
this.logger.error(error, "Token validation failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up WebSocket connection handlers for plugin communication.
|
||||
*
|
||||
@@ -91,7 +59,7 @@ export class PluginBridge {
|
||||
* channel between the MCP mcpServer and Penpot plugin instances.
|
||||
*/
|
||||
private setupWebSocketHandlers(): void {
|
||||
this.wsServer.on("connection", async (ws: WebSocket, request: http.IncomingMessage) => {
|
||||
this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => {
|
||||
// extract userToken from query parameters
|
||||
const url = new URL(request.url!, `ws://${request.headers.host}`);
|
||||
const userToken = url.searchParams.get("userToken");
|
||||
@@ -103,17 +71,6 @@ export class PluginBridge {
|
||||
return;
|
||||
}
|
||||
|
||||
// validate token against backend in multi-user mode
|
||||
if (this.mcpServer.isMultiUserMode() && userToken) {
|
||||
const isValid = await this.validateUserToken(userToken);
|
||||
if (!isValid) {
|
||||
this.logger.warn("Connection attempt with invalid or revoked token - rejecting");
|
||||
ws.close(1008, "Invalid or revoked token");
|
||||
return;
|
||||
}
|
||||
this.logger.info("Token validated successfully");
|
||||
}
|
||||
|
||||
if (userToken) {
|
||||
this.logger.info("New WebSocket connection established (token provided)");
|
||||
} else {
|
||||
@@ -127,20 +84,6 @@ export class PluginBridge {
|
||||
|
||||
// register the client connection with both indexes
|
||||
const connection: ClientConnection = { socket: ws, userToken, pingInterval };
|
||||
|
||||
// start periodic token revalidation in multi-user mode
|
||||
if (this.mcpServer.isMultiUserMode() && userToken) {
|
||||
const revalidationInterval = setInterval(async () => {
|
||||
const isValid = await this.validateUserToken(userToken);
|
||||
if (!isValid) {
|
||||
this.logger.warn("Token revalidation failed - closing connection");
|
||||
this.removeConnection(ws);
|
||||
ws.close(1008, "Token revoked");
|
||||
}
|
||||
}, TOKEN_REVALIDATION_INTERVAL);
|
||||
connection.revalidationInterval = revalidationInterval;
|
||||
}
|
||||
|
||||
this.connectedClients.set(ws, connection);
|
||||
if (userToken) {
|
||||
// ensure only one connection per userToken
|
||||
@@ -192,9 +135,10 @@ export class PluginBridge {
|
||||
/**
|
||||
* Removes a client connection and releases all resources associated with it.
|
||||
*
|
||||
* Clears the per-connection keep-alive interval and removes the connection
|
||||
* from both the socket-keyed and token-keyed indexes. Safe to call with a
|
||||
* socket that is not (or no longer) registered.
|
||||
* Clears the per-connection keep-alive interval and removes the connection from the
|
||||
* socket-keyed index. The token-keyed index entry (and, in multi-instance mode, the
|
||||
* token's Redis task subscription) is removed only if it is owned by the given
|
||||
* connection. Safe to call with a socket that is not (or no longer) registered.
|
||||
*
|
||||
* @param ws - The WebSocket whose connection state should be removed
|
||||
*/
|
||||
@@ -204,17 +148,20 @@ export class PluginBridge {
|
||||
return;
|
||||
}
|
||||
clearInterval(connection.pingInterval);
|
||||
if (connection.revalidationInterval) {
|
||||
clearInterval(connection.revalidationInterval);
|
||||
}
|
||||
this.connectedClients.delete(ws);
|
||||
if (connection.userToken) {
|
||||
this.clientsByToken.delete(connection.userToken);
|
||||
// Perform the token-keyed cleanup only if this connection owns the token registration.
|
||||
// A connection rejected as a duplicate carries the same token but must not remove token associations.
|
||||
if (this.clientsByToken.get(connection.userToken) !== connection) {
|
||||
this.logger.debug("Removed connection does not own its token registration; skipping token cleanup");
|
||||
} else {
|
||||
this.clientsByToken.delete(connection.userToken);
|
||||
|
||||
if (this.redisBridge) {
|
||||
this.redisBridge
|
||||
.unsubscribeFromTasks(connection.userToken)
|
||||
.catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel"));
|
||||
if (this.redisBridge) {
|
||||
this.redisBridge
|
||||
.unsubscribeFromTasks(connection.userToken)
|
||||
.catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +200,35 @@ export class PluginBridge {
|
||||
this.logger.info(`Task ${response.id} completed: success=${response.success}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a still-pending task with the given error, releasing its correlation state.
|
||||
*
|
||||
* Clears the task's timeout (if armed) and removes the task from the pending-task
|
||||
* index before rejecting its promise. Safe to call for a task that has already been
|
||||
* settled (e.g. by a response or a timeout), in which case nothing happens.
|
||||
*
|
||||
* @param taskId - The ID of the task to reject
|
||||
* @param error - The error with which to reject the task
|
||||
* @returns Whether the task was still pending and has been rejected
|
||||
*/
|
||||
private rejectPendingTask(taskId: string, error: Error): boolean {
|
||||
const pendingTask = this.pendingTasks.get(taskId);
|
||||
if (!pendingTask) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeoutHandle = this.taskTimeouts.get(taskId);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
this.taskTimeouts.delete(taskId);
|
||||
}
|
||||
this.pendingTasks.delete(taskId);
|
||||
|
||||
pendingTask.rejectWithError(error);
|
||||
this.logger.info(`Task ${taskId} rejected: ${error.message}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the client connection to use for executing a task.
|
||||
*
|
||||
@@ -271,9 +247,7 @@ export class PluginBridge {
|
||||
|
||||
const connection = this.clientsByToken.get(sessionContext.userToken);
|
||||
if (!connection) {
|
||||
throw new Error(
|
||||
`No plugin instance connected for user token. Please ensure the plugin is running and connected with the correct token.`
|
||||
);
|
||||
throw new Error(PluginBridge.MULTIUSER_CONNECTION_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
return connection;
|
||||
@@ -321,6 +295,10 @@ export class PluginBridge {
|
||||
* `resolveWithResult`/`rejectWithError` methods. The same correlation and timeout
|
||||
* handling therefore applies regardless of the transport.
|
||||
*
|
||||
* When routing via Redis, the task is rejected immediately (rather than timing out)
|
||||
* if the published request reached no instance, i.e. if no instance holds a plugin
|
||||
* connection for the session's user token, or if publishing fails outright.
|
||||
*
|
||||
* @param task - The task to dispatch
|
||||
* @param useRedis - Whether to route the request via Redis (multi-instance) rather
|
||||
* than directly over the local WebSocket connection
|
||||
@@ -343,9 +321,17 @@ export class PluginBridge {
|
||||
|
||||
// register the task for result correlation, then publish the request via Redis
|
||||
this.pendingTasks.set(task.id, task);
|
||||
void redisBridge.sendTaskRequest(userToken, task.toRequest(), (response) =>
|
||||
this.handlePluginTaskResponse(response)
|
||||
);
|
||||
void redisBridge
|
||||
.sendTaskRequest(userToken, task.toRequest(), (response) => this.handlePluginTaskResponse(response))
|
||||
.then((receiverCount) => {
|
||||
// fail fast when no instance received the request (no connection with matching user token in any instance)
|
||||
if (receiverCount === 0) {
|
||||
this.rejectPendingTask(task.id, new Error(PluginBridge.MULTIUSER_CONNECTION_ERROR_MESSAGE));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.rejectPendingTask(task.id, error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
|
||||
// on timeout, release the response-channel subscription, since no response
|
||||
// will arrive to trigger its self-unsubscribe.
|
||||
@@ -364,14 +350,13 @@ export class PluginBridge {
|
||||
|
||||
// Set up a timeout to reject the task if no response is received
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
const pendingTask = this.pendingTasks.get(task.id);
|
||||
if (pendingTask) {
|
||||
this.pendingTasks.delete(task.id);
|
||||
this.taskTimeouts.delete(task.id);
|
||||
onTimeout?.();
|
||||
pendingTask.rejectWithError(
|
||||
if (
|
||||
this.rejectPendingTask(
|
||||
task.id,
|
||||
new Error(`Task ${task.id} timed out after ${this.taskTimeoutSecs} seconds`)
|
||||
);
|
||||
)
|
||||
) {
|
||||
onTimeout?.();
|
||||
}
|
||||
}, this.taskTimeoutSecs * 1000);
|
||||
|
||||
|
||||
@@ -91,12 +91,16 @@ export class RedisBridge {
|
||||
* @param userToken - The user token identifying the target plugin's request channel
|
||||
* @param request - The serialized plugin task request, passed through verbatim
|
||||
* @param onResponse - Handler invoked with the response when it arrives
|
||||
* @returns The number of instances that received the request. A count of 0 means no
|
||||
* instance is subscribed to the token's request channel (i.e. the plugin is not
|
||||
* connected anywhere); the request was dropped, no response will ever arrive, and
|
||||
* the response subscription has already been released.
|
||||
*/
|
||||
async sendTaskRequest(
|
||||
userToken: string,
|
||||
request: PluginTaskRequest,
|
||||
onResponse: TaskResponseHandler
|
||||
): Promise<void> {
|
||||
): Promise<number> {
|
||||
const responseChannel = this.responseChannel(request.id);
|
||||
const requestChannel = this.requestChannel(userToken);
|
||||
|
||||
@@ -113,7 +117,19 @@ export class RedisBridge {
|
||||
|
||||
await this.subscriber.subscribe(responseChannel);
|
||||
// publish only once the response subscription is confirmed
|
||||
await this.publisher.publish(requestChannel, JSON.stringify(request));
|
||||
let receiverCount: number;
|
||||
try {
|
||||
receiverCount = await this.publisher.publish(requestChannel, JSON.stringify(request));
|
||||
} catch (error) {
|
||||
// the request was never delivered, so no response can arrive
|
||||
await this.unsubscribeFromResponse(request.id);
|
||||
throw error;
|
||||
}
|
||||
if (receiverCount === 0) {
|
||||
// no subscriber received the request, so no response can arrive
|
||||
await this.unsubscribeFromResponse(request.id);
|
||||
}
|
||||
return receiverCount;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-4
@@ -26,7 +26,7 @@ declare -A LINT_CMD=(
|
||||
[backend]="pnpm run lint"
|
||||
[common]="pnpm run lint:clj"
|
||||
[render-wasm]="./lint"
|
||||
[exporter]="pnpm run lint"
|
||||
[exporter]="pnpm run lint:clj"
|
||||
[mcp]=""
|
||||
[plugins]="pnpm run lint"
|
||||
[library]="pnpm run lint"
|
||||
@@ -37,7 +37,7 @@ declare -A TEST_CMD=(
|
||||
[backend]="clojure -M:dev:test"
|
||||
[common]="clojure -M:dev:test && pnpm run test:quiet"
|
||||
[render-wasm]="./test"
|
||||
[exporter]=""
|
||||
[exporter]="pnpm run test:quiet"
|
||||
[mcp]="pnpm run test"
|
||||
[plugins]="pnpm run test"
|
||||
[library]="pnpm run test"
|
||||
@@ -48,7 +48,7 @@ declare -A FMT_CHECK_CMD=(
|
||||
[backend]="pnpm run check-fmt"
|
||||
[common]="pnpm run check-fmt:clj && pnpm run check-fmt:js"
|
||||
[render-wasm]="cargo fmt --check"
|
||||
[exporter]="pnpm run check-fmt"
|
||||
[exporter]="pnpm run check-fmt:clj"
|
||||
[mcp]="pnpm run fmt:check"
|
||||
[plugins]="pnpm run format:check"
|
||||
[library]="pnpm run check-fmt"
|
||||
@@ -59,7 +59,7 @@ declare -A FMT_FIX_CMD=(
|
||||
[backend]="pnpm run fmt"
|
||||
[common]="pnpm run fmt:clj && pnpm run fmt:js"
|
||||
[render-wasm]="cargo fmt"
|
||||
[exporter]="pnpm run fmt"
|
||||
[exporter]="pnpm run fmt:clj"
|
||||
[mcp]="pnpm run fmt"
|
||||
[plugins]="pnpm run format"
|
||||
[library]="pnpm run fmt"
|
||||
|
||||
Reference in new issue
Block a user