From 4da649919740568d4a41bdd7063c98da068868f4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 19 Aug 2026 13:29:04 +0200 Subject: [PATCH 1/3] :bug: Fix linear gradients in SVG text exports (#11272) * :bug: Use gradient type instead of export type in SVG renderer data->gradient-def was comparing the render `type` parameter (:svg, :png, :pdf) against "linear" to decide between linearGradient and radialGradient elements. Since the export type is never "linear", the comparison always fell through to radialGradient, causing all linear gradients to be exported as radial in SVG output. Read the gradient type from the data map instead: (get-in data ["gradient" "type"]) Closes #5972 * :bug: Add SVG gradient export regression test Extract SVG gradient definition generation from the renderer so it can be tested directly. Add exporter test build wiring and cover both linear and radial gradient output. AI-assisted-by: gpt-5.6-luna * :sparkles: Standardize exporter testing workflow Align exporter scripts with the frontend testing pattern. Add a dedicated GitHub Actions workflow and document the canonical exporter commands in Serena memories. AI-assisted-by: gpt-5.6-luna * :sparkles: Add focused exporter test execution Mirror frontend test-runner behavior for focused namespaces and test vars. Support --focus, --log-level, and --help, and document the commands. AI-assisted-by: gpt-5.6-luna * :bug: Replace shell exec with execFile in exporter Replace child_process.exec with execFile to eliminate shell interpretation. Add hex color validation in exporter and frontend to reject malformed input before command construction. This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated OS command injection vulnerability where malicious fill-color values could execute arbitrary commands in the exporter container. Defense in depth: - Layer 1: execFile passes arguments directly without shell parsing - Layer 2: Exporter validates colors with strict hex regex - Layer 3: Frontend filters invalid colors before DOM emission All three independent reporters' attack vectors are addressed: - Quote breakout (lyhtheori) - Command substitution (B1gN0Se) - Path traversal (KimiSecurityTeam) AI-assisted-by: qwen3.7-plus * :bug: Use existing hex-color-string? and fix test path mismatch Address code review feedback: - Replace duplicated hex-color-rx and valid-hex-color? with existing hex-color-string? from app.common.types.color - Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned AI-assisted-by: qwen3.7-plus --------- Co-authored-by: Sumit Ridhal --- .github/workflows/tests-exporter.yml | 58 ++++++ .serena/memories/exporter/core.md | 5 +- .serena/memories/exporter/testing.md | 16 ++ exporter/package.json | 9 +- exporter/scripts/test | 7 + exporter/scripts/test-quiet.js | 29 +++ exporter/shadow-cljs.edn | 10 +- exporter/src/app/handlers/export_frames.cljs | 2 +- exporter/src/app/renderer/bitmap.cljs | 2 +- exporter/src/app/renderer/svg.cljs | 38 ++-- exporter/src/app/renderer/svg_gradient.cljs | 32 ++++ exporter/src/app/util/shell.cljs | 16 +- .../exporter_tests/renderer_svg_test.cljs | 25 +++ exporter/test/exporter_tests/runner.cljs | 172 ++++++++++++++++++ exporter/test/exporter_tests/shell_test.cljs | 70 +++++++ .../src/app/main/ui/shapes/text/fo_text.cljs | 12 +- scripts/ci | 8 +- 17 files changed, 461 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/tests-exporter.yml create mode 100644 .serena/memories/exporter/testing.md create mode 100755 exporter/scripts/test create mode 100644 exporter/scripts/test-quiet.js create mode 100644 exporter/src/app/renderer/svg_gradient.cljs create mode 100644 exporter/test/exporter_tests/renderer_svg_test.cljs create mode 100644 exporter/test/exporter_tests/runner.cljs create mode 100644 exporter/test/exporter_tests/shell_test.cljs diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml new file mode 100644 index 0000000000..1ed37d95c8 --- /dev/null +++ b/.github/workflows/tests-exporter.yml @@ -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 diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md index 3bcf784f49..9b7078045b 100644 --- a/.serena/memories/exporter/core.md +++ b/.serena/memories/exporter/core.md @@ -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 ` and Bearer auth. \ No newline at end of file +- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter ` and Bearer auth. diff --git a/.serena/memories/exporter/testing.md b/.serena/memories/exporter/testing.md new file mode 100644 index 0000000000..189c1e852c --- /dev/null +++ b/.serena/memories/exporter/testing.md @@ -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. diff --git a/exporter/package.json b/exporter/package.json index fce5a1fad9..142709a363 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -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" } } diff --git a/exporter/scripts/test b/exporter/scripts/test new file mode 100755 index 0000000000..6402c5afd1 --- /dev/null +++ b/exporter/scripts/test @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -ex +corepack enable; +corepack install; +pnpm install; +pnpm run test; diff --git a/exporter/scripts/test-quiet.js b/exporter/scripts/test-quiet.js new file mode 100644 index 0000000000..b1be0dd682 --- /dev/null +++ b/exporter/scripts/test-quiet.js @@ -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); diff --git a/exporter/shadow-cljs.edn b/exporter/shadow-cljs.edn index ae963cf311..076e6a10f6 100644 --- a/exporter/shadow-cljs.edn +++ b/exporter/shadow-cljs.edn @@ -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}}}}} diff --git a/exporter/src/app/handlers/export_frames.cljs b/exporter/src/app/handlers/export_frames.cljs index cf263d8e00..658456059d 100644 --- a/exporter/src/app/handlers/export_frames.cljs +++ b/exporter/src/app/handlers/export_frames.cljs @@ -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 diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index c2720eb025..e04c60076b 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -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] diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index c9fee2f764..0db4bc0cf8 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -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))))) - diff --git a/exporter/src/app/renderer/svg_gradient.cljs b/exporter/src/app/renderer/svg_gradient.cljs new file mode 100644 index 0000000000..2efaca2c0d --- /dev/null +++ b/exporter/src/app/renderer/svg_gradient.cljs @@ -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)}))) diff --git a/exporter/src/app/util/shell.cljs b/exporter/src/app/util/shell.cljs index 60dc1bd6b8..8331888b00 100644 --- a/exporter/src/app/util/shell.cljs +++ b/exporter/src/app/util/shell.cljs @@ -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))))))) diff --git a/exporter/test/exporter_tests/renderer_svg_test.cljs b/exporter/test/exporter_tests/renderer_svg_test.cljs new file mode 100644 index 0000000000..d680b344ab --- /dev/null +++ b/exporter/test/exporter_tests/renderer_svg_test.cljs @@ -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"))))))) diff --git a/exporter/test/exporter_tests/runner.cljs b/exporter/test/exporter_tests/runner.cljs new file mode 100644 index 0000000000..a0aa4481e0 --- /dev/null +++ b/exporter/test/exporter_tests/runner.cljs @@ -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))))))) diff --git a/exporter/test/exporter_tests/shell_test.cljs b/exporter/test/exporter_tests/shell_test.cljs new file mode 100644 index 0000000000..e232a4ed67 --- /dev/null +++ b/exporter/test/exporter_tests/shell_test.cljs @@ -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))))))) diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index 5bb224b673..9a78b482e3 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -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: diff --git a/scripts/ci b/scripts/ci index df693a9cd0..d13b7bff1a 100755 --- a/scripts/ci +++ b/scripts/ci @@ -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" From 9f6878d118facf71c0999014db06916e3fa6cbd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Thu, 20 Aug 2026 13:19:35 +0200 Subject: [PATCH 2/3] :bug: Fix disabled invitation explanatory title (#11295) --- frontend/src/app/main/ui/dashboard/team.cljs | 26 ++++++++++++++++---- frontend/src/app/main/ui/dashboard/team.scss | 4 +++ frontend/translations/en.po | 6 +++++ frontend/translations/es.po | 6 +++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 027238e4b8..730cf08f66 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -39,6 +39,7 @@ [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] [app.main.ui.forms :as fc] [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.badge :refer [badge-notification]] @@ -139,11 +140,26 @@ [:a {:on-click on-nav-settings} (tr "labels.settings")]]]] [:div {:class (stl/css :dashboard-buttons)} (when (and (or invitations-section? members-section?) (not-empty invitations)) - [:> button* {:variant "secondary" - :on-click on-invite-member - :disabled (not can-invite?) - :data-testid "invite-member"} - (tr "dashboard.invite-profile")])]])) + (let [organization (:organization team) + owners-only-invites? (and (contains? cfg/flags :admin-console) + organization + (= (get-in organization [:permissions :send-invitations]) "owners")) + title-text (if owners-only-invites? + (tr "dashboard.invite-profile-disabled.owners-only" (:name organization)) + (tr "dashboard.invite-profile-disabled")) + invite-button (mf/html + [:> button* {:class (stl/css :invite-button) + :variant "secondary" + :on-click on-invite-member + :disabled (not can-invite?) + :data-testid "invite-member"} + (tr "dashboard.invite-profile")])] + (if can-invite? + invite-button + [:> tooltip* {:content title-text + :id "invite-member-disabled-tooltip" + :tab-index 0} + invite-button])))]])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INVITATIONS MODAL diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 14366781c4..3408a4697e 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -1062,6 +1062,10 @@ } } +.invite-button:disabled { + --button-disabled-bg-color: var(--color-background-tertiary); +} + a { color: var(--modal-link-foreground-color); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 3175d5a1c3..828353e3c9 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10352,3 +10352,9 @@ msgstr "Sign-in with your organization's identity provider didn't complete. The msgid "labels.sso-error.retry" msgstr "Try again" + +msgid "dashboard.invite-profile-disabled" +msgstr "You don't have permission to invite people to this team" + +msgid "dashboard.invite-profile-disabled.owners-only" +msgstr "Only team owners can invite within %s" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 4044119275..53cdbdc91e 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9997,3 +9997,9 @@ msgstr "El inicio de sesión con el proveedor de identidad de tu organización n msgid "labels.sso-error.retry" msgstr "Intentar de nuevo" + +msgid "dashboard.invite-profile-disabled" +msgstr "No tienes permiso para invitar a personas a este equipo" + +msgid "dashboard.invite-profile-disabled.owners-only" +msgstr "Solo los propietarios del equipo pueden invitar dentro de %s" \ No newline at end of file From 2318866f8de9f9350feab32a3c7b3945724d193b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 21 Aug 2026 11:17:25 +0200 Subject: [PATCH 3/3] :tada: Add repair functions for variant validation errors (#10768) (#11309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :tada: Add repair functions for variant validation errors * :books: Fix copyright notice Co-authored-by: Andrés Moya --- common/src/app/common/files/repair.cljc | 227 +++++++++++------ common/src/app/common/files/validate.cljc | 66 ++--- .../src/app/common/test_helpers/variants.cljc | 5 + common/src/app/common/types/file.cljc | 2 + .../test/common_tests/files/repair_test.cljc | 230 ++++++++++++++++++ 5 files changed, 432 insertions(+), 98 deletions(-) create mode 100644 common/test/common_tests/files/repair_test.cljc diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc index 6714bfce04..95f3fda22f 100644 --- a/common/src/app/common/files/repair.cljc +++ b/common/src/app/common/files/repair.cljc @@ -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 diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index ca7b469d4b..d04a22b164 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -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] diff --git a/common/src/app/common/test_helpers/variants.cljc b/common/src/app/common/test_helpers/variants.cljc index bf2e6fc973..07b4a58be7 100644 --- a/common/src/app/common/test_helpers/variants.cljc +++ b/common/src/app/common/test_helpers/variants.cljc @@ -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 {}}}] diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index accda94bc5..79e7050e25 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -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 diff --git a/common/test/common_tests/files/repair_test.cljc b/common/test/common_tests/files/repair_test.cljc new file mode 100644 index 0000000000..a5021baf03 --- /dev/null +++ b/common/test/common_tests/files/repair_test.cljc @@ -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'))))))