diff --git a/.github/workflows/tests-composable-suite.yml b/.github/workflows/tests-composable-suite.yml new file mode 100644 index 0000000000..1ad48c0edf --- /dev/null +++ b/.github/workflows/tests-composable-suite.yml @@ -0,0 +1,69 @@ +name: "CI: Composable Test Suite" + +# Runs the composable component test suite (it exercises component semantics +# through the real Plugin API against the full frontend, so it needs the +# frontend bundle + the plugin runtime, but no backend): the driver serves the +# prebuilt frontend bundle and intercepts every backend RPC with Playwright +# fixtures. See plugins/apps/composable-test-suite/README.md ("Running in CI"). + +defaults: + run: + shell: bash + +on: + pull_request: + paths: + - 'plugins/**' + - 'frontend/**' + - 'common/**' + types: + - opened + - synchronize + - ready_for_review + + push: + branches: + - develop + - staging + paths: + - 'plugins/**' + - 'frontend/**' + - 'common/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + composable-test-suite: + if: ${{ !github.event.pull_request.draft }} + name: "Run composable test suite (mocked backend)" + 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: + - uses: actions/checkout@v6 + + # The driver serves the prebuilt bundle from frontend/resources/public. + - name: Build frontend bundle + working-directory: ./frontend + run: ./scripts/build + + - name: Install deps + working-directory: ./plugins + run: | + corepack enable; + corepack install; + pnpm install; + + - name: Install Playwright Chromium + working-directory: ./plugins + run: pnpm --filter composable-test-suite exec playwright install --with-deps chromium + + - name: Run composable test suite (mocked) + working-directory: ./plugins + run: pnpm --filter composable-test-suite run test:ci diff --git a/.serena/memories/frontend/composable-component-tests.md b/.serena/memories/frontend/composable-component-tests.md index 28c0605569..865a381133 100644 --- a/.serena/memories/frontend/composable-component-tests.md +++ b/.serena/memories/frontend/composable-component-tests.md @@ -1,316 +1,151 @@ # Composable component tests -A framework for systematically testing Penpot's component subsystem (synchronisation/propagation, -swaps, variant switches, nesting), plus the suite of cases built on it. Lives entirely in the -**frontend** test tree as `.cljs`; it is test-only code with a single consumer (the frontend test -suite, which runs the real app). There is nothing "common" about it — it is not under `app/common`. +A framework concept for systematically testing Penpot's component subsystem +(synchronisation/propagation, swaps, variant switches, nesting, overrides), implemented in TWO test +suites that share the principles below: -## Core idea -A test is a **composition of operations** over a **situation**, plus assertions. You describe a -test as data (a setup + a sequence of operations) rather than writing bespoke imperative code, and -coverage grows by COMPOSITION: a new variation is one combinator wrapped around existing pieces, -not a copied test. One written case stands for a whole matrix of concrete cases. +1. **ClojureScript suite** — in the frontend test tree (`frontend/test/frontend_tests/ + composable_tests/`), driving a minimally-assembled real app headlessly. The original. +2. **TypeScript suite** — a Penpot plugin (`plugins/apps/composable-test-suite/`), driving the FULL + production app end-to-end through the Plugin API, with a slightly more elaborate set of + abstractions. Runs interactively (panel), remotely (Playwright), and headlessly in CI. Its + README is the authoritative operational reference. -- **Situation** — the in-memory Penpot file value, plus named **roles** (meaningful shapes, e.g. - `:main-instance`/`:copy-instance`), a `:vars` map (named non-shape values), and an ordered - **applied-log** of what ran. -- **Operation** — a step, reified as a DATA record implementing `IOperation` (single method - `apply-to`; `apply` collides with core). Operations are printable, navigable, and enumerable. - Most transform the situation; some do not — hence the genus is "operation", not "transformation" - (`Test` asserts and returns the situation unchanged; `Skip` is a no-op). -- **Assertions** — inline `Test` operations placed in the sequence (assert at intermediate points) - and/or a trailing asserter (a `situation -> any` lambda calling `t/is`). The runner makes NO - judgment; it applies operations and returns the situation. Only *retrieval* helpers live outside - the test (role accessors, `has-property-of`, `applied?`). +## Shared core idea +A test is a **composition of operations** over a starting configuration, plus assertions. You +describe a test as data (a setup + a sequence of operations) rather than writing bespoke imperative +code, and coverage grows by COMPOSITION: a new variation is one combinator wrapped around existing +pieces, not a copied test. Choice points (one-of alternatives, optional steps) EXPAND the +composition into a full sweep of variants — one written case stands for a whole matrix of concrete +tests. -## Principles -- **Operations are data with identity.** Each node gets a unique id at construction (`assign-id`), - records what it did under that id (`record-application`), and is interrogated by identity - (`applied?`, `get-choice`). No flat keyword-tagged log. -- **Drive the real production pipeline.** Every operation routes through the actual production - change functions (`generate-update-shapes`, `generate-component-swap`, `generate-reset-component`, - `generate-sync-file-changes`, …) — never raw field writes, or propagation would have nothing to - react to. The test exercises genuine Penpot logic, not a reimplementation. -- **The frontend runs the real app.** Synchronous file-ops apply directly to the store; event-ops - dispatch the REAL workspace events and await settlement, so the production watcher's AUTOMATIC - propagation is what's under test. Observed semantics are genuine. -- **Roles resolve to ids at setup time.** The global label→id map (`thi`) is shared and - time-varying, so a role is captured as an id when the situation is built; resolving late (across - enumerated variants) would be unsound. Absence throws a diagnostic (never silent nil). -- **Targets resolve at apply-time and may be rebound.** A target is a `(situation -> id)` FUNCTION, - else a currently-bound ROLE, else a LABEL (`target-shape-id`). So one operation targeting a role - follows that role as state-building ops re-point it — which is what lets a single operation be - swept across depth. -- **Enumeration is authored, not exhaustive.** You compose only VALID cases (every `one-of` branch - must be valid against the setup), so outcomes are just pass / fail / error — no not-applicable - cells. Adding a variation across a matrix is a one-expression edit. +## Shared principles +- **Every producing object is the accessor interface to what it produces downstream.** An + operation — and related objects such as content-creation strategies — is not merely an action: + the SAME object instance the case holds is the typed interface through which everything it + created or changed is later retrieved, checked, and asserted, parameterized by the situation. A + foundation operation exposes accessors for the participants it built; an edit operation exposes + its dual check (`assertHasChangedProperty` / `has-property-of`); a choice is recovered by asking + the one-of object (`getChoice`/`get-choice`); "did this step run" is asked of the step + (`wasApplied`/`applied?`). NEVER reach into a situation (or the document) for something an + upstream object produced — ask the producer. This is what keeps sweeps sound (object identity + ties the question to the exact node that ran) and what keeps retrieval logic in exactly one + place. Particularly explicit in the TS OOP implementation, where these accessors are methods on + the operation/strategy classes; repeatedly violating it (reading the document directly, + duplicating retrieval) was the most common review correction while building the suites. +- **Operations are data with identity.** Each operation node has a unique id at construction and + records what it did under that id; interrogation is by identity. Bind an operation to a value + ONCE and reuse it in the composition and in every query about it. +- **Drive the real production pipeline.** Operations route through genuine Penpot logic — real + change functions / real workspace events / the real Plugin API, never raw field writes — so the + production watcher's AUTOMATIC propagation is what's under test. +- **Roles, not internals.** A starting configuration names its participants (roles). Role→id + capture happens when the configuration is built; operation TARGETS resolve at apply-time and may + be re-bound, so an operation targeting a role follows it as state-building ops re-point it — + which lets a single operation be swept across depth. +- **Enumeration is authored, not exhaustive.** Compose only VALID cases, so outcomes are just + pass / fail / error — no not-applicable cells. - **Naming discipline.** Penpot domain nouns ("component", "variant") must not name framework - abstractions (they collide with real domain concepts). Framework vocabulary is testing concepts - only; an operation may name the domain *action* it performs (`swap`, `propagate`). + abstractions; an operation may name the domain ACTION it performs. +- **Operator algebra** (same in both suites): sequence (cartesian product of the steps' variants), + one-of (union, choice recorded), optional(X) = one-of([X, skip]), inline assertion ops, trailing + asserters. +- **Case authoring:** a case carries a CamelCase identifier and a plain-terms description in three + parts — situation setup, actions/variations, asserted requirement. -## Composition operators -- `in-sequence` — ordered application, threads the situation; enumerates to the CARTESIAN PRODUCT - of its steps' variants. Operations do not commute, so order is explicit. The workhorse. -- `one-of` — exactly one branch; applying it throws (must be enumerated); enumerates to the UNION - of branches, each wrapped in a `RecordedChoice` so `get-choice` recovers which ran. -- `optional(X)` = `one-of([X, skip])` — sweeps "with and without X" (two variants). `skip` is the - identity operation. The workhorse for adding a state-building step as an axis over a case. -- `test-that [assert-fn]` — an inline `Test`: asserts at this point in the sequence, situation - unchanged. Lets checkpoints sit at intermediate steps. Engine stays clojure.test-free (it just - calls the supplied fn). -- `applied? [situation operation]` — whether that exact node ran (identity-based). Composes with - `optional`/`one-of` for free. REQUIREMENT: bind an operation to a value ONCE and reuse it (in the - composition AND any `Test` querying it), so the id you ask about is the id that ran. +--- -## Structure (frontend/test/frontend_tests/composable_tests/) -Two boundaries: the domain-agnostic **engine** and the **comp** subject library (about components; -naming the domain is correct there). +# ClojureScript suite (frontend test tree) -- `core.cljs` (ns `frontend-tests.composable-tests.core`) — the engine, one file: - - situation: `make-situation`, `file`/`with-file`, `with-aux-files`/`aux-files` (carry extra - files, e.g. a library for case H), the applied-log. - - identity & transcript: `assign-id`, `node-id`, `record-application` (also stores a `::kind` - from the record type), `node-data`, `describe-applied` (readable ordered transcript, attached - to every failure so a failing variant in a sweep is identifiable). - - roles & lookup: `role-shape` (strict, by stored id), `has-role?`, `rebind-role`/`rebind-role-id` - (re-point a role — used by state-building ops), `target-shape-id` (fn | role | label), - `shape-by-id` (read a shape whose id is held in a `:vars` object), `resolve-shape`/`-id`. - - protocols: `IOperation`(`apply-to`); `IEnumerable`(`-enumerate`) + `enumerate`. - - operators: `Sequence`/`in-sequence`; `OneOf`/`one-of`/`RecordedChoice`/`get-choice`; - `Skip`/`skip`; `optional`; `Test`/`test-that`; `applied?`; vars `set-var`/`get-var`. - - runners (clojure.test-free): `run-variant`, `run-all` (enumerate → vector, `thi/reset-idmap!` - per variant). - - per-op-interpreter helpers: `sequence-ops` (flatten a concrete variant — flattens `Sequence`, - keeps `RecordedChoice` as a unit), `recorded-choice?`, `choice-of`, `choice-one-of-id`. -- `comp/setups.cljs` — setup fns returning a situation, + role accessors (`main-instance`/ - `copy-instance`/`main-root`/`copy-root`, `copy-child` 1-based): `simple-component-with-copy`, - `simple-component-with-labeled-copy`, `component-with-many-children` (E, F), - `nested-component-with-copy`, `cross-file-component-with-copy` (H: main in a linked library, copy - in the consuming file; primary `:file` = consuming, aux = library), and `empty-situation` (empty - file, no roles — the `:setup` for the sweep cases, whose first operation is `create-component`). -- `comp/nodes.cljs` — the component OPERATIONS. General edit/structure ops: - `change-property [target property value]` (`:fills`/`:opacity`) with its dual `has-property-of` - (`IPropertyCheck`); `change-attr`/`has-attr?` are aliases. `add-child`/`remove-child`/`move-child` - (with `IStructuralCheck`). `sync-from-library` (H: `generate-sync-file-changes`, libraries = - primary + aux). `undo` (I; frontend `dwu/undo`). Plus the scenario building blocks below. -- `interpreter.cljs` (ns `frontend-tests.composable-tests.interpreter`) — runs a case against the - real frontend (see "Interpreter"). -- `comp/sync_test.cljs` (ns `frontend-tests.composable-tests.comp.sync-test`) — the cases; - registered in `frontend_tests/runner.cljs`. +Test-only `.cljs` code in the frontend test tree (nothing "common" about it). A **situation** = +the in-memory file value + named roles + `:vars` + an ordered applied-log. Operations are records +implementing `IOperation`/`apply-to` (`apply` collides with core). Assertions = inline `Test` ops +and/or a trailing asserter; the runner makes no judgment. Failures carry `describe-applied` (the +transcript), which is what makes a failing variant in a sweep identifiable. -## Scenario object model (behind the sweep cases) -Scenario ops track one or more named component LINEAGES as OBJECTS under `:vars :components` (keyed -by name, e.g. "main"). Grouping a lineage's fields into one object lets several lineages coexist -(a swap targets a DIFFERENT component) and makes each op "read object `name`, update, write back". +Layout: `core.cljs` (the domain-agnostic engine: situation, identity/transcript, roles/targets, +operators, runners), `comp/setups.cljs` (setups + role accessors), `comp/nodes.cljs` (the component +operations and their check duals), `interpreter.cljs` (runs cases against the real frontend), +`comp/sync_test.cljs` (the cases; registered in `frontend_tests/runner.cljs`). Case letters B..N; +the sweeps (K: depth × edit-precedence; L: swaps; M: variant switches; N: rotated-instance +geometry, on the #10109 fix branch until merged) are the flagship pattern — read them before +writing a new sweep. -A lineage object has: -- `:main-component-id` — the component the next instantiate/nest uses (advances to the new OUTER - component per `make-nested-component`). -- `:remote-head`/`:remote-rect` — the FIXED deepest origin (the original component everything is - derived from). For a plain lineage this is never re-pointed; a variant nesting DOES re-point it - (see below). -- `:main-head`/`:main-rect` — the current outer main (advances per nesting). -- `:nesting-count`; `:copies`, `:copy-head`/`:copy-rect` (from `instantiate-copy`). -- `:nesting-data` — vector, one entry per level i: `{:main-head, :nested-head, :nested-rect, - :nested-head-parent}`. - - `:nested-head` is the DEEPEST instance at level i — the descendant of the level's copy head - that corresponds to `:remote-head`, found by descending the `:shape-ref` chain (at level 0 the - inner copy itself; deeper, the corresponding shape nested within the outer wrapper). This is THE - SWAP / SWITCH TARGET; it carries a `:component-id`. - - `:nested-head-parent` is the SWAP-STABLE anchor: a swap/switch replaces the head in place but - keeps its parent, so assertions re-resolve parent → current head → rect. +**Scenario lineage model** (behind the sweeps): scenario ops track named component lineages as +objects under `:vars`, each holding the FIXED deepest origin (`:remote-*`), the ADVANCING outer +main (`:main-*`), and per-nesting-level data whose `:nested-head` (the deepest instance at that +level, found by descending the `:shape-ref` chain — matching chain MEMBERSHIP, not terminus) is +the swap/switch target, anchored by its swap-stable parent. Nesting seeks the FIXED origin, not +the advancing main — that is what makes each level's `:nested-head` land on the deepest instance. +A variant nesting re-points the lineage's remote to the chosen member. Construction lesson: +cross-level propagation requires progressively NESTED levels (one variant + plain wraps); sibling +nestings do not propagate between each other. -Accessors / targets (in nodes): `lineage-component-id`, `lineage-rect`, `lineage-copy-rect`, -`lineage-nesting`, `level-rect`/`level-rect-of` (level i's CURRENT rect via the parent anchor); -target-fns `remote-rect-of`/`main-rect-of`/`copy-rect-of` and `nested-head-of` (return a -`(situation -> id)` for use as an operation target). "Corresponds to" across layers follows the -`:shape-ref` CHAIN, matching on chain MEMBERSHIP not terminus (a copy rect refs its near-main, -which carries a further `:shape-ref`, so the terminus over-walks). Single-file setups, so the chain -resolves in the local page objects. +**Interpreter:** installs the situation's files into the global `st/state` (aux files tagged +`:library-of`), starts the real `watch-component-changes` (+ harness `watch-undo-stack`), maps +event-ops to REAL workspace events (`dwsh/update-shapes`, `dwl/component-swap`, +`dwv/variants-switch`, `dwt/increase-rotation` — which runs the `check-delta` placement +classification — `dwt/update-dimensions`, `dwu/undo`, `dwl/sync-file`, …) and runs sync-ops' +`apply-to` against the live store file; awaits settlement (idle-gap heuristic + per-op grace) and +re-reads `:file` each step so the shared accessors keep working. +STORE-SWAP IMMUNITY: other test namespaces `set!` `st/state`/`st/stream` and never restore, while +the `app.main.refs` lenses stay bound to the ORIGINAL atoms — propagation then dies silently. The +interpreter captures the atoms at namespace-load time and re-`set!`s them per variant. -Scenario operations (each takes a lineage `name`): -- `create-component [name color]` — a component (frame + rect child of `color`); remote == main, - count 0. -- `make-nested-component [name]` — wrap `name`'s component in a NEW OUTER component whose main - contains a COPY of it inside a board (add-frame → instantiate inside → make-component); the OUTER - becomes `:main-component-id`; advance `:main-*`; append a `:nesting-data` entry; bump count. - ITERABLE: `×N` = board-within-board nesting with one rect at the bottom. Each level's - `:nested-head` is the deepest instance there, so swapping/switching it propagates (via the - watcher) outward to copies of it in OUTER levels. -- `instantiate-copy [name]` — instantiate `name`'s current component; track `:copy-head` and the - rect corresponding to its main rect as `:copy-rect`. -- `reset-copy-instance [name]` — reset overrides on `:copy-head` (production - `generate-reset-component`, `:validate? false` — a file-op on the frontend too: the real reset - event reads browser globals and cannot run headless). -- `swap-component [name level target & {:keys [keep-touched?]}]` — swap level `level`'s - `:nested-head` for lineage `target`'s component, via production `generate-component-swap`. - Frontend = the REAL `dwl/component-swap` event, so the watcher AUTOMATICALLY propagates the swap - to copies (incl. deeper levels). A swap replaces the head in place: Penpot keeps the head id, - rewrites it to the new component, stamps a `:swap-slot-` touched group. `keep-touched?` - default false (discards overrides); true is the variant-switch flavour. -- Shared nesting helper `nest-in-new-outer-component [situation name op seek-rect-id seek-head-id - instantiate-inner-fn]` — the contain-outward mechanism behind BOTH nesting ops. Adds the outer - frame, calls `instantiate-inner-fn` to place the inner instance, makes the outer a component, - then computes this level's `:nested-rect`/`:nested-head` as the IMAGES (inside the new inner copy) - of `seek-rect-id`/`seek-head-id` — the FIXED deepest origin — and does the bookkeeping. A flavour - supplies only the two origin ids + the instantiate fn. Seeking the FIXED origin (not the advancing - `:main-*`) is what makes `:nested-head` land on the deepest instance at every level. - `self-or-descendant-corresponding-to` is the chain-descent that also matches the head itself - (needed at level 0, where the inner copy head IS the origin's image). +Running: `cd frontend && pnpm run build:test`, then +`node target/tests/test.js --focus frontend-tests.composable-tests.comp.sync-test` +(var-level focus for one case). -## Variant operations -A variant switch IS a keep-touched swap whose target is resolved by a property VALUE (the -production `variants-switch`/`variant-switch` reduces to `component-swap … keep-touched? true`), so -it routes through the SAME `generate-component-swap` and the watcher auto-propagates it across -nesting levels exactly like a swap. -- `make-variant-container [name members]` (sync-op) — build a variant SET synchronously, mirroring - the test-helpers' `add-variant` idiom: a container frame (`:is-variant-container`), each `members` - entry `[value color]` becoming a member component whose ROOT is a child of the container carrying - the shared `:variant-id`/`:variant-name`, then `update-component` stamps `:variant-id` + - `:variant-properties [{:name "Property 1" :value value}]` on the component. Read the container id - via `(thi/id container-label)` only AFTER adding the container frame (`thi/id` returns nil before - the shape exists). Records the set in `:vars` (`variant-set`/`variant-member`/ - `variant-member-component-id` read it back). -- `make-nested-component-with-variant [name set-name value]` (sync-op) — nest a chosen member via - the shared nesting helper, AND re-point the lineage's `:remote-head`/`:remote-rect` to that - member's root/rect: nesting a variant makes the member the new deepest origin, so subsequent plain - `make-nested-component` descends to the variant's image (its `:nested-head`) at every level. -- `switch-variant [target value]` (frontend event) — switch the variant copy head bound to `target` - to the sibling member with property value `value`, via the REAL `dwv/variants-switch` event (which - DISCOVERS the sibling in the container via `find-variant-components`). `target` uses the standard - resolution (role | label | fn), so the op knows nothing about nesting; cases supply - `nested-head-of name i`. +**Fidelity warning:** the harness drives a MINIMALLY-ASSEMBLED app — only some +`initialize-workspace` subscriptions are wired. Risk = SILENT UNDER-WIRING (e.g. undo needs the +harness `watch-undo-stack`). When a case needs app behaviour beyond a raw edit, check for an +unwired subscription and verify by PROBING store state, not by trusting a green assertion. -## Interpreter (interpreter.cljs) -Drives the real app: -- `op->events [op situation]` maps each event-dispatching operation to its real workspace event(s): - `ChangeProperty`→`dwsh/update-shapes` (the SHARED `set-property`, target via `target-shape-id`); - `MoveChild`→`dwsh/relocate-shapes`; `RemoveChild`→`dwsh/delete-shapes`; `AddChild`→`dwsh/add-shape`; - `SyncFromLibrary`→`dwl/sync-file`; `Undo`→`dwu/undo`; `SwapComponent`→`dwl/component-swap`; - `SwitchVariant`→`dwv/variants-switch {:shapes [head] :pos 0 :val value}`. All real events, so the - watcher auto-propagates. -- `sync-op?` ops (`MakeNestedComponent`, `CreateComponent`, `InstantiateCopy`, `ResetCopyInstance`, - `MakeVariantContainer`, `MakeNestedComponentWithVariant`, `Skip`, `Test`) are NOT dispatched as - events: `run-sync-op` runs the shared `apply-to` against a situation whose `:file` is the live - store file, then writes back synchronously. The property under test is still exercised by the - subsequent real-event ops + the watcher. -- It installs the situation's files into the global `st/state` store (primary as current; aux files - tagged `:library-of` the current file so the library-sync machinery treats them as linked), starts - the real `watch-component-changes` and the harness `watch-undo-stack`, then folds the operations: - dispatch events → await settlement → re-read the current file into `:file` → record. Re-reading - `:file` each step is why the shared role accessors keep working. -- `watch-undo-stack` mirrors the production undo-append subscription from `initialize-workspace` - (which the harness does not run); without it `dwu/undo` has an empty stack. -- Settlement (`await-settle`): subscribe to the commit stream, resolve on the first 60ms idle gap - after a commit (captures the edit commit and the watcher's follow-up sync commit), 2000ms timeout. - Debounce-based, not a deterministic per-op stopper. After settling, `op-grace-ms` adds a per-op - grace wait (currently only `SyncFromLibrary`, ~3.2s — see the Running note). -- Thumbnail rendering is stubbed for this suite (`install-thumbnail-noop!` no-ops - `dwth/update-thumbnail`): the propagation watcher schedules thumbnail renders that reach `window`, - absent headless. -- `check [done case-map asserter]` enumerates, runs each variant via the async fold, wraps the - asserter in `describe-applied`, and calls `done`. Per-variant isolation = id-map reset + global - state re-install (the global `st/state` is a shared `defonce`). -- STORE-SWAP IMMUNITY (`original-store`/`restore-global-store!`): many plugins-suite namespaces - `set!` `st/state`/`st/stream` to isolated stores and never restore them. The `app.main.refs` - lenses (through which the watcher observes commits) are okulary lenses bound to the ORIGINAL - atom instance at load time — after such a swap, events commit to a store the watcher cannot see - and ALL propagation dies silently (assertions see base/unsynced state; no error). The interpreter - therefore captures `st/state`/`st/stream` at namespace-LOAD time (before any test runs) and - re-`set!`s them at the start of every variant, making the harness immune to run order. Diagnosed - by bisecting the runner's deterministic execution order (it is NOT the `test-namespaces` vector - order: `t/test-vars-block` groups vars by namespace, and the group-by hash order decides — same - order locally and in CI). +**Caveats:** inline `Test` exceptions are UNCAUGHT on the frontend (crash the runner — assert in +the trailing asserter). `(optional (in-sequence …))` is not flattened for the interpreter — use +independent optionals. The Serena/clj-kondo cache for `nodes.cljs` goes stale (phantom symbols) — +trust the build. Cross-namespace global-state leaks land in this suite first; suspect them before +the framework on inexplicable full-run-only failures. Case H's `sync-file` schedules a delayed RPC +that fails headless (benign; absorbed by per-op grace). -## Cases -Asserters are inline; no `doseq`, no count assertions. -- **B** — an override on the copy survives a later main change (override present + `:touched` - contains the fill group + `:shape-ref` present). -- **C** — attribute sweep via `one-of {fills, opacity}`: assert `(has-attr? (get-choice …) copy)` - per enumerated variant. -- **D** — `add-child` to the main gives the copy a ref-integral child (`is-main-of?` + - `parent-of?` + untouched). -- **E** — `remove-child` of the MIDDLE of three: survivors keep order `[child1, child3]`, - `:shape-ref` intact, untouched (middle removal is where index maintenance is tested). -- **F** — `move-child` of child1 to index 2: copy mirrors `[child2, child1, child3]`, identity - preserved. -- **H** — locality (cross-file): main in a linked library, copy in the consuming (current) file, - library main diverged. The in-file watcher does not cross a library boundary; the cross-file - mechanism is the library-update action (`sync-from-library` → `sync-file file-id library-id`). - Setup order matters: instantiate the copy first (captures the old value), diverge the library main - after. -- **I** — undo: an edit then `undo`; copy and main return to baseline, copy untouched. A single - `dwu/undo` reverses the whole logical action — the edit AND its auto-propagation — because the two - commits share an undo-group. -- **K** — SYNC-SCENARIO SWEEP (the flagship). On `empty-situation`: `create-component` → two - `(optional make-nested-component)` (depths 0/1/2) → `instantiate-copy` → three `(optional change-*)` - over remote/main/copy → INLINE checkpoints: (1) override-precedence at the copy (copy wins, else - main, else remote — branch via `applied?`), (2) force a copy override and confirm it wins, (3) - after `reset-copy-instance`, copy reverts to main's value if main changed, else remote's. No - explicit propagate (the watcher auto-propagates at all depths, incl. chained remote→deep-copy). -- **L** — SWAP SWEEP. On `empty-situation`: `create-component` (base) + one swap-target lineage per - level → three `make-nested-component` → three `(optional (swap-component "main" i target_i))` → - one `Test` asserting each level's colour. A swap at level i auto-propagates to level i and every - OUTER level until a higher swap overrides; colour at level i = applied swap at highest j<=i, else - base. -- **M** — VARIANT-SWITCH SWEEP (case L with a variant switch instead of the plain swap, driving the - REAL variant-switch machinery). On `empty-situation`: `create-component` (base lineage "main") + - `make-variant-container` (4 peer members `v0..v3`) → ONE `make-nested-component-with-variant - "main" "vset" "v0"` (introduce the variant innermost) + two plain `make-nested-component` - (progressive wraps, so each outer level CONTAINS the one below) → three - `(optional (switch-variant (nested-head-of "main" i) v_{i+1}))` → one `Test` with case L's exact - precedence asserter. Because the single variant instance has a switchable `:nested-head` at EVERY - level and the levels are progressively nested, a switch at level i propagates outward like a swap. - NOTE on structure: ONE variant + plain wraps is required for cross-level propagation (the levels - nest WITHIN each other). Three independent `make-nested-component-with-variant` would nest SIBLING - variants (none a descendant of another), so switches would not propagate between them — the right - construction for a different test (one asserting switches DON'T cross unrelated instances). +--- -Running: `cd frontend && pnpm run build:test` then `node target/tests/test.js --focus -frontend-tests.composable-tests.comp.sync-test`. To run one case, use var-level focus, e.g. -`…/case-m-variant-switch-scenarios`. NOTE: the production `sync-file` event (case H) additionally -schedules a 3s-delayed `update-file-library-sync-status` RPC, which fails headless (no backend; a -swallowed URL-parse trace is benign). The interpreter absorbs it: `op-grace-ms` makes the run wait -~3.2s after a `SyncFromLibrary` settles, so the failure lands inside case H instead of leaking into -(and potentially destabilising) whichever test runs next. +# TypeScript suite (the plugin) — full e2e -## Frontend fidelity — read before extending -The frontend runs the REAL production logic from the dispatched event onward, so observed semantics -are genuine. But it drives a MINIMALLY-ASSEMBLED app: it installs a file into the global store and -starts only the watchers known to be needed. The real app assembles its workspace via -`initialize-workspace`, which wires many subscriptions; the harness reproduces only some. +`plugins/apps/composable-test-suite/` — same principles against the FULL production app through the +Plugin API (real frontend, real propagation). Continuation of the CLJS suite per issue #10584. +Operational details (build/run, connect URL, remote control, reading logs, auto-reload, CI): the +plugin README. -The risk is SILENT UNDER-WIRING — a behaviour that works in the real app can be silently absent in -the harness with no error (e.g. the undo stack is empty unless `watch-undo-stack` is started). -Therefore: when adding a case needing app behaviour beyond a raw edit (undo, persistence, selection, -layout, thumbnails, library auto-detection), first check whether that behaviour lives in an -`initialize-workspace` subscription the harness has not wired — and verify by PROBING store state, -not by trusting a green assertion. The harness hand-wires two stand-ins (`install-file-event`, -`watch-undo-stack`); track them for drift. A durable fix would be to drive the real -`initialize-workspace` headlessly (not done — full init may pull in machinery that doesn't run -cleanly headless). +Distinguishing abstractions (the OOP articulation of the shared principles): +- `TestCase {identifier, description, operation}` with the three-part description mandated in the + constructor docstring. +- The accessor-interface principle is class-level: foundation operations (e.g. + `OpCreateSimpleComponentWithCopy`) expose the roles they build; **content-creation strategies** + (pluggable: what content a foundation builds around) expose accessors for the content they + created; edit operations expose their checks (`OpChangeProperty.assertHasChangedProperty`); + `OpOneOf`/`OpOptional` are queried for what ran. Tests never grope the document for something a + producer can be asked for. +- `ShapeProp` model: property duals with numeric tolerance; rotation is a writable attr, height + goes via resize (readonly in the Plugin API). +- `TestSuite` enumerates cases into a `TestTree` with stable per-test ids; + `run(ids, TestRunObserver)` is the ONLY output channel — the framework is UI-free by + construction. `plugin.ts` (panel adapter), `main.ts` (panel UI) and `src/ci/headless.ts` + (CI adapter) are three thin consumers. +- Cases live in `src/composable-tests/cases/` as `case.ts` (e.g. `MainEditSyncs` — the + sweep that found #10109). +- Panel checkboxes carry stable DOM ids (case identifier / `Identifier-N` composites) for remote + control via Playwright; recipe in the README. -## Other caveats -- Cross-namespace leaks land in this suite first because it (correctly) uses the real global - store: besides the store swap above, `frontend-tests.helpers.wasm/teardown-wasm-mocks!` used to - `set!` every WASM fn to nil when run against an empty snapshot (double teardown / async misuse of - `with-wasm-mocks*`), which a leaked debounced `resize-wasm-text` event then tripped over during - our cases (a "Store error: initialized? is not a function"). The teardown is now guarded - (no-op on empty snapshot). If a new inexplicable full-run-only failure appears here, suspect - leaked global state from a preceding namespace before suspecting the framework. -- INLINE `Test` exceptions on the frontend are UNCAUGHT (they run during the async fold, not under - `check`'s try): a throwing checkpoint crashes the whole runner rather than failing one test. -- A `RecordedChoice` wrapping a `Sequence` (i.e. `(optional (in-sequence […]))`) is NOT flattened by - `sequence-ops`, so `op->events` chokes on the `Sequence`. Use independent optionals instead (as - case K does with two `(optional make-nested-component)`), or have the interpreter recurse into a - choice's composite alternative. -- The Serena symbol index / clj-kondo cache for `nodes.cljs` can go STALE and report PHANTOM symbols - or spurious "unresolved symbol" errors against code that compiles — TRUST THE BUILD (a real - `pnpm run build:test`), not the lint or the symbol overview, for this file. -- Label-after-the-fact resolution: `add-child`'s `added-shape` resolves `:new-label` via `thi/id`, - relying on the global label map still reflecting that run's setup — unsound if a structural node - is swept via `one-of`. Fix when needed by capturing the created shape's id at apply-time (as roles - already do). +## CI +Headless per-PR gate: `.github/workflows/tests-composable-suite.yml` runs +`pnpm --filter composable-test-suite run test:ci` — mocked backend (frontend e2e static server + +Playwright RPC fixtures, no backend/login), the in-sandbox bundle injected via `ɵloadPlugin`, +results streamed via console markers, `TEST_FILTER` by identifier substring. The mocked backend is +NOT a limitation for this suite (everything asserted is frontend store logic; empirically +confirmed against the interactive runs). Architecture mirrors `plugin-api-test-suite`'s CI driver; +the mock harness exists in THREE places that must stay in sync (provenance note in `ci/run-ci.ts`). +Details: README, "Running in CI". ## Substrate `mem:common/test-setup`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`, diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index 00252a7661..c7a449fea2 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -38,19 +38,19 @@ Include concise sections covering: PR descriptions follow this structure: ```markdown -**Note:** This PR was created with AI assistance. +**Note:** This PR was created with AI assistance as part of the Penpot self-improvement initiative. ## What - + ## Why - + ## How - + ``` The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR. @@ -59,6 +59,8 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR. - **Write for humans.** The diff shows what changed. The description explains why. - **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it? +- **Prefer bullets over paragraphs.** Short bullet items, grouped by area with bold lead-ins where helpful, are far easier to digest than prose; keep any remaining paragraph to a few sentences. +- **No manual line wraps.** Markdown renders adapting to the viewport; hard-wrapped lines degrade rendering. One line per paragraph or bullet, however long. - **Skip the obvious.** Don't explain what `git diff` already shows. ### What NOT to Include diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 2851124260..74e2eecb2d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -464,11 +464,14 @@ prop-vals (mf/with-memo [component-file-data component-page-objects variant-id] (cfv/extract-properties-values component-file-data component-page-objects variant-id)) - get-options - (mf/use-fn - (mf/deps prop-vals) - (fn [prop-name] - (get-variant-options prop-name prop-vals))) + ;; Per-property options memoized on the identity-stable property values, + ;; so select* keeps a stable options prop across renders. + options-by-name + (mf/with-memo [prop-vals] + (reduce (fn [acc prop-name] + (assoc acc prop-name (get-variant-options prop-name prop-vals))) + {} + (into #{} (map :name) props-first))) select-duplicated-comps (mf/use-fn @@ -523,9 +526,9 @@ [:div {:class (stl/css :variant-property-list)} (for [[pos prop] (map-indexed vector props-first)] (let [mixed-value? (not-every? #(= (:value prop) (:value (get % pos))) properties) - options (get-options (:name prop)) - boolean-pair (ctv/find-boolean-pair (mapv :id options)) - options (cond-> options + base-options (get options-by-name (:name prop)) + boolean-pair (ctv/find-boolean-pair (mapv :id base-options)) + options (cond-> base-options mixed-value? (conj {:id mixed-label :label mixed-label :dimmed true}))] diff --git a/plugins/.prettierrc b/plugins/.prettierrc index 544138be45..3baced4112 100644 --- a/plugins/.prettierrc +++ b/plugins/.prettierrc @@ -1,3 +1,4 @@ { - "singleQuote": true + "singleQuote": true, + "endOfLine": "auto" } diff --git a/plugins/apps/composable-test-suite/.gitignore b/plugins/apps/composable-test-suite/.gitignore new file mode 100644 index 0000000000..186871f845 --- /dev/null +++ b/plugins/apps/composable-test-suite/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Dependencies and build output +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/plugins/apps/composable-test-suite/.prettierrc b/plugins/apps/composable-test-suite/.prettierrc new file mode 100644 index 0000000000..3d58be61d9 --- /dev/null +++ b/plugins/apps/composable-test-suite/.prettierrc @@ -0,0 +1,20 @@ +{ + "tabWidth": 4, + "overrides": [ + { + "files": "*.yml", + "options": { + "tabWidth": 2 + } + } + ], + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "always", + "printWidth": 120, + "endOfLine": "auto" +} diff --git a/plugins/apps/composable-test-suite/README.md b/plugins/apps/composable-test-suite/README.md new file mode 100644 index 0000000000..f324a490ee --- /dev/null +++ b/plugins/apps/composable-test-suite/README.md @@ -0,0 +1,205 @@ +# Composable Penpot API Tests (Plugin) + +A Penpot plugin that runs a suite of component behaviour tests against the live +Penpot document, driving the public Plugin API exactly as a user's actions would. + +## What this is for + +Penpot components have subtle behaviour — overrides, propagation from a main to +its copies, nesting, precedence between competing changes. This plugin exercises +that behaviour through the same Plugin API that real integrations use, so the +tests act as a check that the API surfaces the expected component semantics end to +end. + +## Core principles + +These are the ideas the suite is built around. They are intended to outlast any +particular code structure. + +- **A test is a composition of operations over a situation.** A _situation_ is the + state a test acts on (the relevant shapes of a configuration, plus a record of + what has happened). An _operation_ is one step — an edit, a structural change, or + an assertion. Tests are built by composing small operations, not by writing + bespoke procedures, so behaviour is described declaratively and pieces are + reused across tests. + +- **Operations compose, and choice points expand into variants.** Operations can + be sequenced, and a test can express a _choice_ (do this, or skip it; pick one of + several). Before running, every choice is expanded into the full set of concrete + variants — so a single compact test definition becomes many independent runs + covering every combination. Each variant runs against a freshly built situation, + so variants never interfere. + +- **The real API, with real propagation.** Operations mutate the live document + through the Plugin API; propagation happens for real, and assertions read the + real resulting state. The suite does not simulate or model component behaviour — + it observes it. + +- **Foundation operations own what they expose.** A starting configuration is + built by a foundation operation — the first step of a test — which names the + participants a test refers to, so a test addresses parts of the configuration by + role rather than by reaching into internals. How a configuration is grown + (instantiated, nested) is offered by the foundation operation itself; what + content it is built around is supplied by a pluggable content-creation strategy. + +- **Results are addressed by stable identity.** Every test (every expanded variant) + has a stable identity assigned once. The UI renders the tests, and each result + streams back keyed by that identity, so what you select to run and what you see + reported always refer to the same thing. + +## Usage + +### Build and run the plugin + +The plugin lives in the plugins workspace (`plugins/apps/composable-test-suite`) +and runs like the other plugins there. From the `plugins/` directory (after a +workspace `pnpm install`): + +``` +pnpm run start:plugin:composable-test-suite +``` + +Alternatively, from this directory, self-contained (installs its own +dependencies, isolated from the surrounding workspace): + +``` +pnpm run bootstrap +``` + +Either way this builds the plugin and then keeps rebuilding on change while +serving it locally; a connected plugin panel reloads automatically on each +rebuild. The first build takes a little while before the server is ready. + +Other scripts: `pnpm run build` (one-off build), `pnpm start` (watch + serve), +`pnpm run init` (build, then watch + serve), `pnpm run types:check` (type-check +only). + +### Connect it in Penpot + +In Penpot, open the plugin manager and add a plugin by URL, using: + +``` +http://localhost:4202/manifest.json +``` + +(4202 is the conventional dev port shared by the plugins in this workspace, so +only one of them can be served at a time.) + +The plugin panel will open inside Penpot. + +### Run the tests + +The panel lists every case as a group, headed by the case's identifier and its +test count (e.g. `MainEditSyncs [4 tests]`), with passed/failed counts at the +right edge. From there you can: + +- **Run all**, or select individual tests (or whole groups) and **run selected**; + **clear selection** deselects everything in one step. +- Watch each test's status update live as it runs — pending, running, then passed + or failed. +- **Fold open a group** to read the case's description — what is set up, what is + varied, and what must hold — shown in its own box above the group's tests. +- **Fold open a test** to see the steps that were applied and, if it failed, the + failure message. (Details appear once a test has been run.) + +Because the tests create and modify shapes in the current document, run them in a +scratch file rather than one whose contents you care about. + +## Remote control (for agents and scripts) + +The panel can be driven programmatically as well as by hand. Every checkbox +carries a stable DOM id: a case's group checkbox has the case identifier (e.g. +`MainEditSyncs`), and each of its tests has the composite identifier with the +1-based index (e.g. `MainEditSyncs-2`). + +Driving the panel from outside requires a browser-automation bridge such as +Playwright with access to the running Penpot session — available, for example, in +Penpot's agentic devenv setup. The plugin renders inside a +`` element in the Penpot workspace, which +hosts the panel in a cross-origin iframe. Top-page selectors therefore do not +reach the panel's elements; go through a frame-scoped locator: + +```js +const frame = page.getByTitle("Composable Tests").locator("iframe").contentFrame(); + +// start from a clean slate: deselect everything +await frame.getByRole("button", { name: "Clear selection" }).click(); + +// select a whole case (its checkbox sits in the header — works while folded) +await frame.locator("#MainEditSyncs").click(); + +// select a single test: unfold its group first (click the header label), +// then click the test's checkbox +await frame.getByText("MainEditSyncs", { exact: true }).click(); +await frame.locator("#MainEditSyncs-2").click(); + +// run what is selected +await frame.getByRole("button", { name: "Run selected" }).click(); +``` + +State can be read back the same way — e.g. a checkbox's selection via +`isChecked()` (a group checkbox reports `indeterminate` for a partial selection), +or the per-group passed/failed counts from the header text. + +### Reading logs + +`console.log` statements anywhere in the plugin code — including test operations +and assertions, which run in the plugin sandbox — surface in the browser console +of the Penpot page, so a browser-automation bridge can read them (with +Playwright's MCP tools: `browser_console_messages`). The page console carries a +lot of unrelated traffic (Penpot itself, vite, other plugins), so prefix debug +logs with the case identifier, e.g. `[MainEditSyncs] …`, and filter for that. +Together with the panel state this closes the debug loop: add a log, run the +failing test by id, read the log. + +### Auto-reload on code changes + +While the dev server is running (`pnpm start` or `pnpm run bootstrap`), any code +change triggers a rebuild, and the live preview then reloads the plugin +automatically — sandbox included, so changed test code takes effect without any +manual reload step. Note that the reload resets the panel completely: all +checkboxes are cleared and previous results are gone, so re-select what you want +to run after changing code. + +## Running in CI (headless, mocked backend) + +The suite can run fully headless, without the panel and without a running +Penpot instance. From the `plugins/` directory: + +``` +pnpm --filter composable-test-suite run test:ci +``` + +This builds the in-sandbox entry (`src/ci/headless.ts`) as a single +self-executing bundle and hands it to the driver (`ci/run-ci.ts`), which +serves the prebuilt frontend bundle via the frontend e2e static server, +intercepts every backend RPC with Playwright fixtures (no backend, no login), +opens the mocked workspace file, injects the bundle directly into the plugin +sandbox, and streams each test's result from the page console — failing the +process if any test fails. The mocked backend is not a limitation here: +everything the suite asserts is frontend store logic executed in memory; the +backend's only role is persistence, which the mock answers with a canned +response. + +Prerequisites: the frontend bundle must exist at `frontend/resources/public` +(the devenv watch build suffices; CI builds it via `frontend/scripts/build`), +and the Playwright browser must be installed +(`pnpm --filter composable-test-suite exec playwright install chromium`). + +Options via environment variables: + +- `TEST_FILTER` — run only tests whose composite identifier contains the + given substring (case-insensitive), e.g. `TEST_FILTER=MainEditSyncs` for a + whole case or `TEST_FILTER=MainEditSyncs-2` for a single variant. +- `CI_TIMEOUT_MS` — overall timeout waiting for results (default 600000). + +## Adding a test + +A new test is a composition of operations over a starting configuration, added to +the set of cases the suite runs. Give the case a meaningful CamelCase identifier +(e.g. `MainEditSyncs`) and a plain-terms description in three parts: the situation +setup (what is created), the actions and variations applied to it, and the +requirement that is asserted. Reuse existing operations and content-creation +strategies where they fit; introduce a new one only when a genuinely new kind of +step or configuration is needed. Express variation through the suite's choice +operations rather than by writing out each combination by hand. diff --git a/plugins/apps/composable-test-suite/ci/fixtures/get-file.json b/plugins/apps/composable-test-suite/ci/fixtures/get-file.json new file mode 100644 index 0000000000..7b6e526953 --- /dev/null +++ b/plugins/apps/composable-test-suite/ci/fixtures/get-file.json @@ -0,0 +1,60 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "fdata/shape-data-type", + "fdata/path-data", + "components/v2", + "design-tokens/v1", + "variants/v1", + "plugins/runtime" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 1", + "~:revn": 11, + "~:modified-at": "~m1713873823633", + "~:id": "~uc7ce0794-0992-8105-8004-38f280443849", + "~:is-shared": false, + "~:version": 46, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1713536343369", + "~:data": { + "~:pages": ["~u66697432-c33d-8055-8006-2c62cc084cad"], + "~:pages-index": { + "~u66697432-c33d-8055-8006-2c62cc084cad": { + "~#penpot/pointer": [ + "~ude58c8f6-c5c2-8196-8004-3df9e2e52d88", + { + "~:created-at": "~m1713873823636" + } + ] + } + }, + "~:id": "~uc7ce0794-0992-8105-8004-38f280443849", + "~:options": { + "~:components-v2": true + }, + "~:recent-colors": [ + { + "~:color": "#0000ff", + "~:opacity": 1, + "~:id": null, + "~:file-id": null, + "~:image": null + } + ] + } +} diff --git a/plugins/apps/composable-test-suite/ci/run-ci.ts b/plugins/apps/composable-test-suite/ci/run-ci.ts new file mode 100644 index 0000000000..7f5820ccd1 --- /dev/null +++ b/plugins/apps/composable-test-suite/ci/run-ci.ts @@ -0,0 +1,301 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium, type Page } from "playwright"; + +// Out-of-sandbox CI driver (Node + Playwright) for the composable test suite, +// following the plugin-api-test-suite's CI driver. NOTE on provenance: the mock +// harness here (the RPC fixture table, the WebSocket mock, the get-file fixture) +// exists in three places that must stay in sync — the frontend e2e harness +// (frontend/playwright, the origin), the plugin-api-test-suite driver, and this +// file. If workspace loading changes and this driver times out waiting for the +// viewport, diff against those two first. It serves the prebuilt +// frontend bundle via the frontend e2e static server, intercepts every backend +// RPC with Playwright `page.route` (reusing the frontend e2e mock fixtures), +// injects the prebuilt `headless.js` bundle into the plugin sandbox via +// `globalThis.ɵloadPlugin`, and captures the results from the page console. +// +// No backend and no login are needed: everything the suite asserts (component +// synchronization, overrides, swap slots, variants) is frontend store logic, +// executed optimistically in memory; the backend's only role is persistence, +// which the mock answers with a canned 200. +// +// Optional env: +// - TEST_FILTER: run only tests whose composite identifier contains the given +// substring (case-insensitive), e.g. "MainEditSyncs" or "MainEditSyncs-2". +// - CI_TIMEOUT_MS: overall timeout waiting for results (default 600000). + +const here = dirname(fileURLToPath(import.meta.url)); +// here = /plugins/apps/composable-test-suite/ci +const repoRoot = resolve(here, "../../../../"); +const frontendDir = resolve(repoRoot, "frontend"); +const e2eDataDir = resolve(frontendDir, "playwright/data"); + +const BASE_URL = "http://localhost:3000"; + +const headlessBundlePath = resolve(here, "../dist/headless.js"); + +// Source the permissions from the same manifest the real plugin ships with, so +// the CI sandbox never drifts from what users actually grant. +const manifestPath = resolve(here, "../public/manifest.json"); +const PERMISSIONS: string[] = (JSON.parse(readFileSync(manifestPath, "utf-8")) as { permissions: string[] }) + .permissions; + +// Ids of the mocked full-feature file fixture (`ci/fixtures/get-file.json`), +// kept in sync with the frontend e2e fixtures. +const MOCK_TEAM_ID = "c7ce0794-0992-8105-8004-38e630f7920a"; +const MOCK_FILE_ID = "c7ce0794-0992-8105-8004-38f280443849"; +const MOCK_PAGE_ID = "66697432-c33d-8055-8006-2c62cc084cad"; + +// Workspace-load RPCs mirrored from the frontend e2e harness. Maps RPC glob -> +// fixture file relative to frontend/playwright/data. +const MOCK_RPCS: Record = { + "get-profile": "logged-in-user/get-profile-logged-in.json", + "get-teams": "get-teams.json", + "get-team?id=*": "workspace/get-team-default.json", + "get-team-members?team-id=*": "logged-in-user/get-team-members-your-penpot.json", + "get-team-users?file-id=*": "logged-in-user/get-team-users-single-user.json", + "get-project?id=*": "workspace/get-project-default.json", + "get-comment-threads?file-id=*": "workspace/get-comment-threads-empty.json", + "get-profiles-for-file-comments?file-id=*": "workspace/get-profile-for-file-comments.json", + "get-file-object-thumbnails?file-id=*": "workspace/get-file-object-thumbnails-blank.json", + "get-font-variants?team-id=*": "workspace/get-font-variants-empty.json", + "get-file-fragment?file-id=*": "workspace/get-file-fragment-blank.json", + "get-file-libraries?file-id=*": "workspace/get-file-libraries-empty.json", + "update-profile-props": "workspace/update-profile-empty.json", +}; + +// Persistence (`update-file`) response shape the frontend expects: it reads +// `revn`/`lagged` (persistence.cljs `update-file-revn`). `revn` is merged with +// `max`, so a low value is harmless. +const UPDATE_FILE_RESPONSE = JSON.stringify({ "~:revn": 1, "~:lagged": [] }); + +interface ReportedResult { + identifier: string; + name: string; + passed: boolean; + durationMs: number; + error?: string; + transcript?: string[]; +} + +async function waitForServer(url: string, timeoutMs = 30000): Promise { + const start = Date.now(); + for (;;) { + try { + const res = await fetch(url); + if (res.ok || res.status === 404) return; // static server is up + } catch { + /* not up yet */ + } + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for server at ${url}`); + } + await new Promise((r) => setTimeout(r, 250)); + } +} + +function startE2eServer(): ChildProcess { + // Reuse the frontend e2e static server: it serves frontend/resources/public + // on port 3000, which is also the host the app opens its notifications + // WebSocket against — so the WS mock below matches without extra config. + return spawn("node", ["scripts/e2e-server.js"], { + cwd: frontendDir, + stdio: "inherit", + }); +} + +// Install the frontend e2e WebSocket mock so the workspace's notifications +// socket can be "opened" without a backend. +async function installWebSocketMock(page: Page): Promise> { + const created = new Set(); + await page.exposeFunction("onMockWebSocketConstructor", (url: string) => { + created.add(url); + }); + await page.addInitScript({ + path: resolve(frontendDir, "playwright/scripts/MockWebSocket.js"), + }); + return created; +} + +async function openNotificationsWebSocket(page: Page, created: Set): Promise { + const start = Date.now(); + let wsUrl: string | undefined; + while (!wsUrl) { + wsUrl = [...created].find((u) => u.includes("ws/notifications")); + if (wsUrl) break; + if (Date.now() - start > 30000) { + throw new Error("Timed out waiting for notifications WebSocket"); + } + await new Promise((r) => setTimeout(r, 50)); + } + await page.evaluate((url) => { + (WebSocket as unknown as { getByURL: (u: string) => { mockOpen: () => void } | undefined }) + .getByURL(url) + ?.mockOpen(); + }, wsUrl); +} + +async function setupMockedRoutes(page: Page): Promise { + // Config flags: deterministic empty flags. + await page.route("**/js/config.js*", (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: 'var penpotFlags = "";\n', + }) + ); + + // Workspace-load RPCs from the frontend e2e fixtures. + for (const [rpc, fixture] of Object.entries(MOCK_RPCS)) { + await page.route(`**/api/main/methods/${rpc}`, (route) => + route.fulfill({ + status: 200, + contentType: "application/transit+json", + path: resolve(e2eDataDir, fixture), + }) + ); + } + + // get-file: the custom full-feature fixture (enables plugins/runtime, + // design-tokens/v1, variants/v1, ...). Without these features active the + // plugin runtime never initialises. + await page.route(/\/api\/main\/methods\/get-file\?/, (route) => + route.fulfill({ + status: 200, + contentType: "application/transit+json", + path: resolve(here, "fixtures/get-file.json"), + }) + ); + + // Blanket no-op persistence: the suite mutates the in-memory store + // optimistically, so a 200 `update-file` mock is enough. + await page.route(/\/api\/main\/methods\/update-file\b/, (route) => + route.fulfill({ + status: 200, + contentType: "application/transit+json", + body: UPDATE_FILE_RESPONSE, + }) + ); +} + +function fileUrl(): string { + return `${BASE_URL}/#/workspace?team-id=${MOCK_TEAM_ID}&file-id=${MOCK_FILE_ID}&page-id=${MOCK_PAGE_ID}`; +} + +function printReport(results: ReportedResult[]) { + // Each result is already printed live as it streams in; here we only recap + // the failures (with their transcripts) so they're easy to find at the + // bottom of a long run. + const failures = results.filter((r) => !r.passed); + if (failures.length > 0) { + console.log("\nFailures:"); + for (const r of failures) { + console.log(` ✗ ${r.identifier} — ${r.name} (${r.durationMs}ms)`); + if (r.error) console.log(` ${r.error}`); + for (const step of r.transcript ?? []) { + console.log(` · ${step}`); + } + } + } +} + +async function main() { + const bundle = readFileSync(headlessBundlePath, "utf-8"); + + const server = startE2eServer(); + await waitForServer(BASE_URL); + + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + const wsCreated = await installWebSocketMock(page); + await setupMockedRoutes(page); + + // The bundle runs inside an SES Compartment (its own `globalThis`), so a + // page `addInitScript` global can't reach it. Prepend the filter straight + // into the evaluated code. + const filter = process.env["TEST_FILTER"]; + const injectedCode = filter + ? `globalThis.__COMPOSABLE_SUITE_FILTER__ = ${JSON.stringify(filter)};\n${bundle}` + : bundle; + + const results: ReportedResult[] = []; + let fatal: string | null = null; + + console.log("\nRunning tests:"); + const done = new Promise((resolvePromise) => { + page.on("console", (msg) => { + const text = msg.text(); + if (text.startsWith("__TEST_RESULT__ ")) { + const result: ReportedResult = JSON.parse(text.slice("__TEST_RESULT__ ".length)); + results.push(result); + const icon = result.passed ? "✓" : "✗"; + console.log(` ${icon} ${result.identifier} — ${result.name} (${result.durationMs}ms)`); + if (!result.passed && result.error) { + console.log(` ${result.error}`); + } + } else if (text.startsWith("__TEST_DONE__ ")) { + resolvePromise(); + } else if (text.startsWith("__TEST_FATAL__ ")) { + fatal = (JSON.parse(text.slice("__TEST_FATAL__ ".length)) as { message: string }).message; + resolvePromise(); + } + }); + }); + + await page.goto(fileUrl()); + await openNotificationsWebSocket(page, wsCreated); + + await page.waitForSelector('[data-testid="viewport"]'); + // The plugin runtime initialises asynchronously after the file's features + // are active; wait for the loader to be exposed before injecting. + await page.waitForFunction( + () => typeof (globalThis as unknown as { ɵloadPlugin?: unknown }).ɵloadPlugin === "function", + { timeout: 30000 } + ); + + await page.evaluate( + ({ code, permissions }) => { + (globalThis as unknown as { ɵloadPlugin: (m: unknown) => void }).ɵloadPlugin({ + pluginId: "00000000-0000-0000-0000-000000000000", + name: "Composable Test Suite (CI)", + code, + icon: "", + description: "", + permissions, + }); + }, + { code: injectedCode, permissions: PERMISSIONS } + ); + + const timeoutMs = Number(process.env["CI_TIMEOUT_MS"] ?? 600000); + await Promise.race([ + done, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timed out waiting for test results")), timeoutMs) + ), + ]); + + await browser.close(); + server.kill(); + + printReport(results); + + if (fatal) { + console.error(`\nFatal error while running tests: ${fatal}`); + process.exit(1); + } + + const failed = results.filter((r) => !r.passed).length; + const passed = results.filter((r) => r.passed).length; + console.log(`\n${passed} passed, ${failed} failed.`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err: unknown) => { + console.error(err); + process.exit(1); +}); diff --git a/plugins/apps/composable-test-suite/index.html b/plugins/apps/composable-test-suite/index.html new file mode 100644 index 0000000000..a0a995f060 --- /dev/null +++ b/plugins/apps/composable-test-suite/index.html @@ -0,0 +1,21 @@ + + + + + + Composable Tests + + +
+
+ + + +
+
+
+
+ + + + diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json new file mode 100644 index 0000000000..36573fd34b --- /dev/null +++ b/plugins/apps/composable-test-suite/package.json @@ -0,0 +1,29 @@ +{ + "name": "composable-test-suite", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "vite build --watch", + "init": "pnpm run build && pnpm run start", + "build": "tsc && vite build", + "build:headless": "vite build --config vite.config.headless.ts", + "test:ci": "pnpm run build:headless && tsx ci/run-ci.ts", + "preview": "vite preview", + "bootstrap": "pnpm install --ignore-workspace && pnpm run build && pnpm run start", + "types:check": "tsc --noEmit", + "fmt": "prettier --write src ci index.html", + "clean": "rm -rf dist/" + }, + "dependencies": { + "@penpot/plugin-styles": "1.4.1", + "@penpot/plugin-types": "1.4.1" + }, + "devDependencies": { + "playwright": "^1.61.1", + "prettier": "^3.6.2", + "typescript": "^5.8.3", + "vite": "^7.0.8", + "vite-live-preview": "^0.3.2" + } +} diff --git a/plugins/apps/composable-test-suite/pnpm-lock.yaml b/plugins/apps/composable-test-suite/pnpm-lock.yaml new file mode 100644 index 0000000000..93ed3672c1 --- /dev/null +++ b/plugins/apps/composable-test-suite/pnpm-lock.yaml @@ -0,0 +1,857 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@penpot/plugin-styles': + specifier: 1.4.1 + version: 1.4.1 + '@penpot/plugin-types': + specifier: 1.4.1 + version: 1.4.1 + devDependencies: + playwright: + specifier: ^1.61.1 + version: 1.61.1 + prettier: + specifier: ^3.6.2 + version: 3.9.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vite: + specifier: ^7.0.8 + version: 7.3.6(@types/node@26.0.1) + vite-live-preview: + specifier: ^0.3.2 + version: 0.3.2(vite@7.3.6(@types/node@26.0.1)) + +packages: + + '@commander-js/extra-typings@12.1.0': + resolution: {integrity: sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==} + peerDependencies: + commander: ~12.1.0 + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@penpot/plugin-styles@1.4.1': + resolution: {integrity: sha512-6TuJqKQsq1Xmhn2A02R+kCOzIzIdqgFg5z6ncLH2PlAflKIX6aYsGiOF7yFx4RYgCegRVMFPnVis6/hwO+YGQg==} + + '@penpot/plugin-types@1.4.1': + resolution: {integrity: sha512-pHE2B3GI8M5JR03S/NdBoN+z6e1R1IEh3vpFbLG9LN0EZpQE6nEbmCo5jWAWI73Jqlg6CHG/RWVJNmWECnkDTA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/ansi-html@0.0.0': + resolution: {integrity: sha512-PEBpUlteD0VW02udY7UjjgjxHwVXmkdanhmRIMkzatGmORJGjzqKylrXVxz1G5xRTEECMxIkwTHpPmZ9Jb7ANQ==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + ansi-html@0.0.9: + resolution: {integrity: sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==} + engines: {'0': node >= 0.8.0} + hasBin: true + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-goat@4.0.0: + resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} + engines: {node: '>=12'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + p-defer@4.0.1: + resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} + engines: {node: '>=12'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + vite-live-preview@0.3.2: + resolution: {integrity: sha512-NrmGaAc85qvkx/+6FluiTo9rLnoY+/NOYnuUvcW5Yb5tSJzUxuloXYrCSS1dtxQB9YKUbpQ95JCb0GRuF//JEQ==} + hasBin: true + peerDependencies: + vite: '>=5.2.13' + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@commander-js/extra-typings@12.1.0(commander@12.1.0)': + dependencies: + commander: 12.1.0 + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@penpot/plugin-styles@1.4.1': {} + + '@penpot/plugin-types@1.4.1': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/ansi-html@0.0.0': {} + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree@1.0.9': {} + + '@types/ms@2.1.0': {} + + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.0.1 + + ansi-html@0.0.9: {} + + chalk@5.6.2: {} + + commander@12.1.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-goat@4.0.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + p-defer@4.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.4: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + typescript@5.9.3: {} + + undici-types@8.3.0: {} + + vite-live-preview@0.3.2(vite@7.3.6(@types/node@26.0.1)): + dependencies: + '@commander-js/extra-typings': 12.1.0(commander@12.1.0) + '@types/ansi-html': 0.0.0 + '@types/debug': 4.1.13 + '@types/ws': 8.18.1 + ansi-html: 0.0.9 + chalk: 5.6.2 + commander: 12.1.0 + debug: 4.4.3 + escape-goat: 4.0.0 + p-defer: 4.0.1 + vite: 7.3.6(@types/node@26.0.1) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + vite@7.3.6(@types/node@26.0.1): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.16 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.0.1 + fsevents: 2.3.3 + + ws@8.21.0: {} diff --git a/plugins/apps/composable-test-suite/pnpm-workspace.yaml b/plugins/apps/composable-test-suite/pnpm-workspace.yaml new file mode 100644 index 0000000000..c5739b7433 --- /dev/null +++ b/plugins/apps/composable-test-suite/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +ignoredBuiltDependencies: + - esbuild diff --git a/plugins/apps/composable-test-suite/public/icon.jpg b/plugins/apps/composable-test-suite/public/icon.jpg new file mode 100644 index 0000000000..9df8dd26a4 Binary files /dev/null and b/plugins/apps/composable-test-suite/public/icon.jpg differ diff --git a/plugins/apps/composable-test-suite/public/manifest.json b/plugins/apps/composable-test-suite/public/manifest.json new file mode 100644 index 0000000000..f65b00148a --- /dev/null +++ b/plugins/apps/composable-test-suite/public/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Composable Tests", + "code": "plugin.js", + "icon": "icon.jpg", + "version": 2, + "description": "Runs the composable component test suite against the current Penpot document.", + "permissions": ["content:read", "content:write", "library:read", "library:write"] +} diff --git a/plugins/apps/composable-test-suite/src/ci/headless.ts b/plugins/apps/composable-test-suite/src/ci/headless.ts new file mode 100644 index 0000000000..4de46d4707 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/ci/headless.ts @@ -0,0 +1,74 @@ +import { createTestSuite, TestRunObserver, TestResult } from "../composable-tests"; + +// In-sandbox CI entry point. Built as a standalone IIFE bundle (dist/headless.js) +// and evaluated inside a real Penpot plugin sandbox by the out-of-sandbox driver +// `ci/run-ci.ts` (note: a different `ci/` directory). It runs the composable test +// suite without any UI and reports each result through `console.log` markers that +// the Playwright driver parses. The marker protocol mirrors the one used by the +// plugin-api-test-suite. + +/** What the driver receives for each finished test. */ +interface ReportedResult { + /** The composite case identifier, e.g. "MainEditSyncs-2". */ + identifier: string; + /** The test's descriptive name (identifies the variant's choices). */ + name: string; + passed: boolean; + durationMs: number; + error?: string; + /** The applied steps, for diagnosing failures from the CI log. */ + transcript?: readonly string[]; +} + +async function main() { + const suite = createTestSuite(); + const tree = suite.tree(); + + // Composite identifiers ("Identifier-N", 1-based) for reporting, and the + // id order matching the tree — the same addressing the panel UI uses. + const identifierById = new Map(); + const orderedIds: string[] = []; + for (const group of tree.groups) { + group.tests.forEach((test, i) => { + identifierById.set(test.id, `${group.identifier}-${i + 1}`); + orderedIds.push(test.id); + }); + } + + // Set by the driver from TEST_FILTER: run only tests whose composite + // identifier contains the given substring (case-insensitive), e.g. + // "MainEditSyncs" (whole case) or "MainEditSyncs-2" (single variant). + const filter = ( + globalThis as unknown as { __COMPOSABLE_SUITE_FILTER__?: string } + ).__COMPOSABLE_SUITE_FILTER__?.toLowerCase(); + const ids = filter ? orderedIds.filter((id) => identifierById.get(id)!.toLowerCase().includes(filter)) : orderedIds; + + const startedAt = new Map(); + let failed = 0; + + const observer: TestRunObserver = { + onTestStarted(id: string): void { + startedAt.set(id, Date.now()); + }, + onTestFinished(id: string, result: TestResult): void { + if (!result.passed) failed++; + const reported: ReportedResult = { + identifier: identifierById.get(id) ?? id, + name: result.name, + passed: result.passed, + durationMs: Date.now() - (startedAt.get(id) ?? Date.now()), + error: result.errorMessage, + transcript: result.passed ? undefined : result.transcript, + }; + console.log("__TEST_RESULT__ " + JSON.stringify(reported)); + }, + }; + + await suite.run(ids, observer); + console.log("__TEST_DONE__ " + JSON.stringify({ total: ids.length, failed })); +} + +main().catch((err: unknown) => { + const message = err instanceof Error ? (err.stack ?? err.message) : String(err); + console.log("__TEST_FATAL__ " + JSON.stringify({ message })); +}); diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases.ts new file mode 100644 index 0000000000..e9150ec16c --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases.ts @@ -0,0 +1,23 @@ +import { TestCase } from "./test-suite/TestCase.ts"; +import { createTestCaseCopyOverrideSurvivesMainChange } from "./cases/caseCopyOverrideSurvivesMainChange.ts"; +import { createTestCaseMainEditSyncs } from "./cases/caseMainEditSyncs.ts"; +import { createTestCaseRemoteMainCopySyncNested } from "./cases/caseRemoteMainCopySyncNested.ts"; +import { createTestCaseVariantSwitchPropagates } from "./cases/caseVariantSwitchPropagates.ts"; +import { createTestCaseCopySubheadDeletePreservesSlots } from "./cases/caseCopySubheadDeletePreservesSlots.ts"; +import { createTestCaseMainReorderKeepsCopySlots } from "./cases/caseMainReorderKeepsCopySlots.ts"; + +/** + * All composable test cases currently defined. A factory (not a constant): each + * case captures live foundation state and is rebuilt per run, and the runner + * further rebuilds the configuration per enumerated variant. + */ +export function allCases(): readonly TestCase[] { + return [ + createTestCaseCopyOverrideSurvivesMainChange(), + createTestCaseMainEditSyncs(), + createTestCaseRemoteMainCopySyncNested(), + createTestCaseVariantSwitchPropagates(), + createTestCaseCopySubheadDeletePreservesSlots(), + createTestCaseMainReorderKeepsCopySlots(), + ]; +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases/caseCopyOverrideSurvivesMainChange.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseCopyOverrideSurvivesMainChange.ts new file mode 100644 index 0000000000..890a6d08e8 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseCopyOverrideSurvivesMainChange.ts @@ -0,0 +1,41 @@ +import { TestCase } from "../test-suite/TestCase.ts"; +import { Color } from "../model/Color"; +import { ShapePropFillColor } from "../model/ShapeProp.ts"; +import { OpChangeProperty } from "../operations/OpChangeProperty"; +import { OpAssert } from "../operations/OpAssert"; +import { OpCreateSimpleComponentWithCopy } from "../operations/OpCreateSimpleComponentWithCopy"; +import { OpSequence } from "../operations/OpSequence.ts"; + +// the three distinct fill colours the case uses (read-back values are lower-case) +const BASELINE = new Color("#aaaaaa"); +const OVERRIDE = new Color("#ff0000"); +const MAIN_CHANGE = new Color("#00ff00"); + +/** + * Case B — an override on a copy survives a later change to the main. + * + * Override the copy's child fill, then change the main's child fill to a + * different colour, and assert the copy still shows the override (a touched + * property is not overwritten by main propagation). + */ +export function createTestCaseCopyOverrideSurvivesMainChange(): TestCase { + const fillColor = new ShapePropFillColor(); + const opCreateComponent = new OpCreateSimpleComponentWithCopy(BASELINE); + const opOverrideCopy = new OpChangeProperty(opCreateComponent.roles.copyChild, fillColor, OVERRIDE); + return new TestCase( + "CopyOverrideSurvivesMainChange", + "A component containing a single rectangle is created, plus a copy of it. The copy's " + + "rectangle is then given an override: its fill colour is changed directly on the copy. " + + "Afterwards the main's rectangle is changed to yet another colour. The copy must keep " + + "its own overridden colour — a property touched on the copy is protected from being " + + "overwritten by later changes propagating from the main.", + new OpSequence( + opCreateComponent, + opOverrideCopy, + new OpChangeProperty(opCreateComponent.roles.mainChild, fillColor, MAIN_CHANGE), + new OpAssert("copy child keeps its override after the main changes", (situation) => + opOverrideCopy.assertHasChangedProperty(situation, opCreateComponent.roles.copyChild) + ) + ) + ); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases/caseCopySubheadDeletePreservesSlots.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseCopySubheadDeletePreservesSlots.ts new file mode 100644 index 0000000000..1ae5e6944d --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseCopySubheadDeletePreservesSlots.ts @@ -0,0 +1,62 @@ +import { Board, Shape } from "@penpot/plugin-types"; +import { TestCase } from "../test-suite/TestCase.ts"; +import { Situation } from "../core/Situation"; +import { Color } from "../model/Color"; +import { OpAssert } from "../operations/OpAssert"; +import { OpSequence } from "../operations/OpSequence.ts"; +import { OpOptional } from "../operations/OpOptional.ts"; +import { OpCreateNestableComponent } from "../operations/OpCreateNestableComponent"; +import { OpDeleteShape } from "../operations/OpDeleteShape"; +import { ContentCreationStrategySiblingInstances } from "../content-creation/ContentCreationStrategySiblingInstances"; +import { SlotIntegrity } from "../util/SlotIntegrity"; + +const BASELINE = new Color("#aaaaaa"); +const NESTED_COUNT = 3; +const LAYOUT = "grid" as const; + +/** + * Case D — CHARACTERIZATION: deleting a nested sub-head of a copy is well-behaved. + * + * Builds a component whose main holds several nested component instances, plus a + * copy of it, then SWEEPS whether one of the copy's nested sub-heads is deleted, + * asserting every remaining sub-head still references its positional slot in the + * main (see {@link SlotIntegrity}). + * + * This case PASSES for every layout (none / flex / grid): deleting a sub-head of a + * COPY only hides it (a deleted-subinstance), so the copy stays aligned and valid. + * We initially suspected the layout (flex, then grid) was the trigger; it is not — + * the copy-side delete is correct. The actual :missing-slot crash comes from a + * MAIN-side edit; see {@link ../cases/caseE} (and the clj regression test + * `common/test/.../comp_main_edit_breaks_copy_slots_test.cljc`). This case is kept + * as a characterization guard that copy-side deletes never corrupt the copy. + */ +export function createTestCaseCopySubheadDeletePreservesSlots(): TestCase { + const foundation = new OpCreateNestableComponent( + new ContentCreationStrategySiblingInstances(NESTED_COUNT, BASELINE, LAYOUT) + ); + // domain vocabulary for the generic roles: the outer component's main and its copy + const outerMain = foundation.roles.mainInstance; + const outerCopy = foundation.roles.copyInstance; + + // the copy's first nested sub-head, resolved at apply-time + const firstSubhead = (s: Situation): Shape => (s.get(outerCopy).children ?? [])[0]; + const deleteFirstSubhead = new OpDeleteShape(firstSubhead, "first copy sub-head"); + + return new TestCase( + "CopySubheadDeletePreservesSlots", + "A component whose main holds several nested component instances is created, plus a copy " + + "of it. One of the copy's nested sub-heads is optionally deleted. Every remaining " + + "sub-head of the copy must still reference the slot at its position in the main — a " + + "copy-side delete must not corrupt the positional slot matching that Penpot's file " + + "validation enforces.", + new OpSequence( + foundation, + foundation.createOpInstantiate(), + // sweep with/without the deletion (the delete variant is the repro) + new OpOptional(deleteFirstSubhead), + new OpAssert("every copy sub-head still references its positional slot in the main", (s) => { + SlotIntegrity.assertAligned(s.get(outerCopy) as Board, s.get(outerMain) as Board); + }) + ) + ); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainEditSyncs.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainEditSyncs.ts new file mode 100644 index 0000000000..292fd133fd --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainEditSyncs.ts @@ -0,0 +1,57 @@ +import { TestCase } from "../test-suite/TestCase.ts"; +import { Color } from "../model/Color"; +import { ShapePropFillColor, ShapePropHeight, ShapePropRotation } from "../model/ShapeProp.ts"; +import { Assert } from "../util/Assert.ts"; +import { OpAssert } from "../operations/OpAssert"; +import { OpSequence } from "../operations/OpSequence.ts"; +import { OpOneOf } from "../operations/OpOneOf.ts"; +import { OpOptional } from "../operations/OpOptional.ts"; +import { OpChangeProperty } from "../operations/OpChangeProperty"; +import { OpCreateSimpleComponentWithCopy } from "../operations/OpCreateSimpleComponentWithCopy"; + +const BASELINE = new Color("#aaaaaa"); +const SYNC_COLOR = new Color("#00ff00"); +const SYNC_HEIGHT = 80; // the rect starts at 50x50 +const ROTATION_DEGREES = 45; + +/** + * Case C — an edit to the main's rectangle syncs to the copy, also when the copy + * is rotated. + * + * Create a one-rectangle component with a copy, then sweep two choice points: + * OPTIONALLY rotate the entire copy root by 45°, and apply ONE of several edits to + * the MAIN's rectangle (fill colour, height). Assert that whichever edit was + * applied is reflected on the COPY's rectangle. The rotation sweep guards the + * regression class where a transformed copy stops receiving main propagation. + */ +export function createTestCaseMainEditSyncs(): TestCase { + const foundation = new OpCreateSimpleComponentWithCopy(BASELINE); + const { mainChild, copyChild, copyRoot } = foundation.roles; + + // optionally rotate the whole copy instance (its root board) + const rotateCopy = new OpChangeProperty(copyRoot, new ShapePropRotation(), ROTATION_DEGREES, "copy root"); + + // one of several edits to the main's rectangle + const mainEdits = [ + new OpChangeProperty(mainChild, new ShapePropFillColor(), SYNC_COLOR, "main rect"), + new OpChangeProperty(mainChild, new ShapePropHeight(), SYNC_HEIGHT, "main rect"), + ]; + + return new TestCase( + "MainEditSyncs", + "A component containing a single rectangle is created, plus a copy of it. The copy is " + + "optionally rotated by 45° as a whole. Then one of several properties (fill colour, " + + "height) is changed on the main's rectangle. The change must propagate to the copy's " + + "rectangle — also when the copy was rotated beforehand.", + new OpSequence( + foundation, + new OpOptional(rotateCopy), + new OpOneOf(...mainEdits), + new OpAssert("the applied main edit is reflected on the copy's rectangle", (s) => { + const applied = mainEdits.filter((edit) => s.wasApplied(edit)); + Assert.that(applied.length === 1, `exactly one main edit should be applied, found ${applied.length}`); + applied[0].assertHasChangedProperty(s, copyChild); + }) + ) + ); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainReorderKeepsCopySlots.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainReorderKeepsCopySlots.ts new file mode 100644 index 0000000000..65fcc8c75d --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainReorderKeepsCopySlots.ts @@ -0,0 +1,68 @@ +import { Board, Shape } from "@penpot/plugin-types"; +import { TestCase } from "../test-suite/TestCase.ts"; +import { Situation } from "../core/Situation"; +import { Color } from "../model/Color"; +import { OpAssert } from "../operations/OpAssert"; +import { OpSequence } from "../operations/OpSequence.ts"; +import { OpCreateNestableComponent } from "../operations/OpCreateNestableComponent"; +import { OpReorderShape } from "../operations/OpReorderShape"; +import { ContentCreationStrategySiblingInstances } from "../content-creation/ContentCreationStrategySiblingInstances"; +import { SlotIntegrity } from "../util/SlotIntegrity"; + +const BASELINE = new Color("#aaaaaa"); +const NESTED_COUNT = 3; + +/** + * Case E — reordering a sub-head IN THE MAIN must not break copies. + * + * This is the ACTUAL cause of the referential-integrity crash (:missing-slot). + * `find-near-match` matches a copy's nested sub-heads to the main's children BY + * POSITION. Reordering a sub-head inside the MAIN changes that order, but the + * copies keep their shape-refs and are NOT given swap slots — so every copy is + * now "swapped" relative to its position without a slot -> validation fails. + * + * The layout is irrelevant (the equivalent clj test reproduces it with no layout); + * the trigger is purely the main-side reorder. The copy-side edits in case D are + * correct — it's this main-side edit that corrupts. + * + * ⚠ WARNING — this case reproduces the bug through the LIVE app, and the app + * currently CHOKES on the corrupt state: reordering a main sub-head via the Plugin + * API hangs (and in the workspace UI it crashes the document). Because the test + * runner lives inside that same app, running this case will HANG the panel — it + * cannot report a normal pass/fail until the underlying bug is fixed. It is + * included as an executable, documented reproduction, NOT as a routine test; do + * NOT include it in a "run all". The reliable, non-hanging capture of this exact + * bug is the clj test `common/test/.../comp_main_edit_breaks_copy_slots_test.cljc`. + * Once Penpot propagates swap slots to copies on a main reorder, this case will + * complete and the assertion will pass. + */ +export function createTestCaseMainReorderKeepsCopySlots(): TestCase { + // layout "none": the bug does not depend on the layout, only on the main reorder + const foundation = new OpCreateNestableComponent( + new ContentCreationStrategySiblingInstances(NESTED_COUNT, BASELINE) + ); + // domain vocabulary for the generic roles: the outer component's main and its copy + const outerMain = foundation.roles.mainInstance; + const outerCopy = foundation.roles.copyInstance; + + // the MAIN's first nested sub-head, resolved at apply-time + const firstMainSubhead = (s: Situation): Shape => (s.get(outerMain).children ?? [])[0]; + + return new TestCase( + "MainReorderKeepsCopySlots", + "A component whose main holds several nested component instances is created, plus a copy " + + "of it. A nested sub-instance is then reordered INSIDE THE MAIN, which changes the " + + "positional matching that copies rely on. Penpot must keep the copies' slot references " + + "valid (assigning swap slots where needed); every copy sub-head must still reference " + + "its positional slot in the main afterwards.", + new OpSequence( + foundation, + foundation.createOpInstantiate(), + // move the main's first sub-head to the end — the corrupting operation + new OpReorderShape(firstMainSubhead, NESTED_COUNT - 1, "first MAIN sub-head"), + new OpAssert("every copy sub-head still references its positional slot in the main", (s) => { + SlotIntegrity.assertAligned(s.get(outerCopy) as Board, s.get(outerMain) as Board); + }) + ) + ); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases/caseRemoteMainCopySyncNested.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseRemoteMainCopySyncNested.ts new file mode 100644 index 0000000000..b0836fa2c6 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseRemoteMainCopySyncNested.ts @@ -0,0 +1,83 @@ +import { Shape } from "@penpot/plugin-types"; +import { TestCase } from "../test-suite/TestCase.ts"; +import { Situation } from "../core/Situation"; +import { Color } from "../model/Color"; +import { ShapePropFillColor } from "../model/ShapeProp.ts"; +import { OpChangeProperty } from "../operations/OpChangeProperty"; +import { OpAssert } from "../operations/OpAssert"; +import { OpCreateNestableComponent } from "../operations/OpCreateNestableComponent"; +import { ContentCreationStrategyRectangle } from "../content-creation/ContentCreationStrategyRectangle.ts"; +import { OpSequence } from "../operations/OpSequence.ts"; +import { OpOptional } from "../operations/OpOptional.ts"; + +// distinct fill colours (read-back values are lower-case) +const BASELINE = new Color("#aaaaaa"); +const RED = new Color("#ff0000"); // remote edit +const GREEN = new Color("#00ff00"); // main edit +const BLUE = new Color("#0000ff"); // copy edit + +/** + * Case K — the consolidated synchronisation sweep (precedence portion). + * + * On a freshly created component, sweep DEPTH 0/1/2 (two optional nestings) and + * WHICH EDITS were made (three independent optional edits to the remote, main and + * copy rect), then assert the override-precedence rule at the copy: a copy + * override wins; otherwise a main change; otherwise a remote change; otherwise the + * baseline. Propagation is AUTOMATIC — the trajectory contains only the edits, and + * the value that surfaces at the copy is what is checked. The edit targets are the + * tracked instance roles' rects (resolved via the strategy at the current depth), + * so the same composition holds at any depth. + * + * (The reset checkpoint of the original case is omitted: the Plugin API exposes no + * override-reset operation.) + */ +export function createTestCaseRemoteMainCopySyncNested(): TestCase { + const opCreateComponent = new OpCreateNestableComponent(new ContentCreationStrategyRectangle(BASELINE)); + const fillColor = new ShapePropFillColor(); + const { remoteInstance, mainInstance, copyInstance } = opCreateComponent.roles; + + // resolve each instance's rect at the current depth via the strategy + const rectOf = + (role: typeof remoteInstance) => + (s: Situation): Shape => + opCreateComponent.contentCreator.getRectangle(s.get(role)); + + const changeRemote = new OpChangeProperty(rectOf(remoteInstance), fillColor, RED, "remote rect"); + const changeMain = new OpChangeProperty(rectOf(mainInstance), fillColor, GREEN, "main rect"); + const changeCopy = new OpChangeProperty(rectOf(copyInstance), fillColor, BLUE, "copy rect"); + + // the colour expected at the copy under the precedence rule + const expectedAtCopy = (s: Situation): Color => { + if (s.wasApplied(changeCopy)) return BLUE; + if (s.wasApplied(changeMain)) return GREEN; + if (s.wasApplied(changeRemote)) return RED; + return BASELINE; + }; + + return new TestCase( + "RemoteMainCopySyncNested", + "A component containing a rectangle is created and a copy is instantiated from it. The " + + "configuration is optionally nested one or two levels deep: each nesting wraps the " + + "copy in a new outer component, whose main becomes the current main, while the " + + "originally created main remains as the remote. Any combination of three edits is " + + "then applied: colouring the rectangle on the remote, on the current main, and on the " + + "copy. The copy's rectangle must show the highest-precedence applied edit — a copy " + + "override wins over a main change, which wins over a remote change, which wins over " + + "the baseline — at every nesting depth.", + new OpSequence( + opCreateComponent, + opCreateComponent.createOpInstantiate(), + new OpOptional(opCreateComponent.createOpMakeNested()), + new OpOptional(opCreateComponent.createOpMakeNested()), + // sweep which edits were made + new OpOptional(changeRemote), + new OpOptional(changeMain), + new OpOptional(changeCopy), + // check the resulting colour + new OpAssert("scopy shows the highest-precedence applied edit", (s) => { + const copyRect = opCreateComponent.contentCreator.getRectangle(s.get(copyInstance)); + fillColor.assertEqual(fillColor.read(copyRect), expectedAtCopy(s)); + }) + ) + ); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases/caseVariantSwitchPropagates.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseVariantSwitchPropagates.ts new file mode 100644 index 0000000000..6a7c6e651f --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseVariantSwitchPropagates.ts @@ -0,0 +1,95 @@ +import { TestCase } from "../test-suite/TestCase.ts"; +import { Situation } from "../core/Situation"; +import { Color } from "../model/Color"; +import { ShapePropFillColor } from "../model/ShapeProp.ts"; +import { OpAssert } from "../operations/OpAssert"; +import { OpSequence } from "../operations/OpSequence.ts"; +import { OpOptional } from "../operations/OpOptional.ts"; +import { OpCreateVariantContainer } from "../operations/OpCreateVariantContainer"; +import { OpCreateNestableComponent } from "../operations/OpCreateNestableComponent"; +import { OpSwitchVariant } from "../operations/OpSwitchVariant"; +import { ContentCreationStrategyRectangle } from "../content-creation/ContentCreationStrategyRectangle.ts"; + +/** + * Case M — a variant switch propagates like a swap (the variant-switch flavour of + * the swap sweep). + * + * Build a variant SET of peer members (member i is a rectangle of colour + * COLORS[i]) and nest the base member at EVERY level, so each level has a variant + * head. Then OPTIONALLY switch the head at each level to a differently-coloured + * sibling (a switch at level i selects member i+1, colour SWAP_COLORS[i]) and + * assert the colour that surfaces at every level. A switch at level i propagates + * OUTWARD to level i and every outer level until a switch at a higher level + * overrides it — so the colour at level i is the switch applied at the HIGHEST + * index j ≤ i, else the base member's colour. + */ +export function createTestCaseVariantSwitchPropagates(): TestCase { + const fillColor = new ShapePropFillColor(); + // the variant members' colours: index 0 is the base member, 1..3 the siblings a + // switch can select. swapColors[i] is the colour a switch at level i selects. + const baseColor = new Color("#aaaaaa"); + const swapColors = [new Color("#ff0000"), new Color("#00ff00"), new Color("#0000ff")]; + const variantColors = [baseColor, ...swapColors]; // variant i has colour COLORS[i] + const nestingLevels = 3; + + // the variant set: one rectangle member per colour, addressed by index value + const opCreateVariantContainer = new OpCreateVariantContainer( + ...variantColors.map((color) => new ContentCreationStrategyRectangle(color)) + ); + + // nest the base member (variant 0) at every level: its instance is the head at + // each nesting level, exactly the target a per-level switch acts on + const opCreateComponent = new OpCreateNestableComponent(opCreateVariantContainer.createContentCreationStrategy(0)); + + // switch[i] switches level i's variant instance to member i+1 (colour SWAP_COLORS[i]). + // The nestable op yields the structural nested head; the variant container finds + // the variant instance within it — keeping variant knowledge out of the nestable op. + const opVariantSwitches = Array.from( + { length: nestingLevels }, + (_unused, i) => + new OpSwitchVariant( + (s: Situation) => + opCreateVariantContainer.getVariantInstance(opCreateComponent.getNestedInstance(s, i)), + opCreateVariantContainer.valueForVariant(i + 1) + ) + ); + + // the colour expected at level i: the switch applied at the highest j ≤ i, else base + const expectedAtLevel = (s: Situation, level: number): Color => { + for (let j = level; j >= 0; j--) { + if (s.wasApplied(opVariantSwitches[j])) return swapColors[j]; + } + return baseColor; + }; + + return new TestCase( + "VariantSwitchPropagates", + "A variant set with four differently coloured rectangle members is created. Its base " + + "member is placed inside a component, from which a copy is instantiated and then " + + "wrapped in further components, one nesting level at a time — so for each level there " + + "is a copy carrying the variant instance at that depth. At each level, that variant " + + "instance is optionally switched to a differently coloured member. Each level's " + + "rectangle must show the colour selected by the switch applied at the highest level " + + "at or below it: a variant switch must propagate through nesting exactly like a " + + "component swap.", + new OpSequence( + // foundations: create the variants, a component with an instance + // and nest the instance several times + opCreateVariantContainer, + opCreateComponent, // creates component with a variant instance + opCreateComponent.createOpInstantiate(), // level 0 + opCreateComponent.createOpMakeNested(), // level 1 + opCreateComponent.createOpMakeNested(), // level 2 + // optionally switch the variant to a unique colour at each nesting level + ...opVariantSwitches.map((sw) => new OpOptional(sw)), + // check the colour that surfaces at every level + new OpAssert("each level shows the highest-precedence applied switch", (s) => { + for (let level = 0; level < nestingLevels; level++) { + const nestedInstance = opCreateComponent.getNestedInstance(s, level); + const rect = ContentCreationStrategyRectangle.findRectangle(nestedInstance); + fillColor.assertEqual(fillColor.read(rect), expectedAtLevel(s, level)); + } + }) + ) + ); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategy.ts b/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategy.ts new file mode 100644 index 0000000000..6773cbb712 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategy.ts @@ -0,0 +1,18 @@ +import { Board } from "@penpot/plugin-types"; +import { Situation } from "../core/Situation.ts"; + +/** + * A strategy for creating the CONTENT of a component: given the board that will + * become a component's root, it fills the board with the component's child shapes. + * The abstract contract is just this one step — what a component contains. Anything + * needed to locate that content afterwards (e.g. in an instance's subtree) is the + * concrete strategy's own affair. + * + * The current situation is passed in so a strategy may resolve runtime values from + * it (e.g. a shape produced by an earlier operation, via a `ShapeTarget`); + * strategies that create content from scratch can ignore it. + */ +export abstract class ContentCreationStrategy { + /** Populates `board` with the component's content, drawing on `situation` as needed. */ + abstract createContent(situation: Situation, board: Board): void; +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategyInstantiateVariantContainer.ts b/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategyInstantiateVariantContainer.ts new file mode 100644 index 0000000000..0409912f75 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategyInstantiateVariantContainer.ts @@ -0,0 +1,52 @@ +import { Board, Shape, VariantContainer } from "@penpot/plugin-types"; +import { Situation } from "../core/Situation.ts"; +import { ShapeTarget, resolveTarget } from "../core/ShapeTarget.ts"; +import { ContentCreationStrategy } from "./ContentCreationStrategy.ts"; + +/** + * The name given to the variant-component instance this strategy creates, so it + * can be found again as the switch target — including as its image inside each + * nested copy (a component instance copies its descendants' names, so the name + * propagates through nesting). + */ +export const VARIANT_INSTANCE_NAME = "MyComponent"; + +/** + * A content-creation strategy that instantiates one variant of an existing variant + * container into the given board. The container is supplied as a `ShapeTarget` + * resolved from the situation at apply-time, so this strategy can be constructed + * before the container exists (e.g. wired to the op that will create it) and used + * once it does. `createContent` instantiates the chosen variant's component, + * names it (so it can be located later as the switch head), and appends it to the + * board. + */ +export class ContentCreationStrategyInstantiateVariantContainer extends ContentCreationStrategy { + /** + * @param container - resolves the variant container from the situation + * @param variantIndex - the 0-based index of the variant to instantiate + */ + constructor( + private readonly container: ShapeTarget, + private readonly variantIndex: number + ) { + super(); + } + + createContent(situation: Situation, board: Board): void { + const container = resolveTarget(this.container, situation) as VariantContainer; + const variants = container.variants; + if (variants === null) { + throw new Error("the resolved shape is not a variant container"); + } + + const components = variants.variantComponents(); + const component = components[this.variantIndex]; + if (component === undefined) { + throw new Error(`no variant at index ${this.variantIndex} (have ${components.length})`); + } + + const instance = component.instance() as Shape; + instance.name = VARIANT_INSTANCE_NAME; + board.appendChild(instance); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategyRectangle.ts b/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategyRectangle.ts new file mode 100644 index 0000000000..947218e663 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategyRectangle.ts @@ -0,0 +1,60 @@ +import { Board, Shape } from "@penpot/plugin-types"; +import { Situation } from "../core/Situation.ts"; +import { Color } from "../model/Color.ts"; +import { ShapeUtil } from "../util/ShapeUtil.ts"; +import { ContentCreationStrategy } from "./ContentCreationStrategy.ts"; + +/** + * A content-creation strategy whose component content is a single named rectangle. + * It locates that rectangle inside any instance of the component by name, so the + * "the rect of this instance" accessor works at any nesting depth (the rect is + * found by descending whatever instance is handed in). + */ +export class ContentCreationStrategyRectangle extends ContentCreationStrategy { + /** The name given to the single rectangle this strategy places in a component. */ + static readonly RECT_SHAPE_NAME = "Child"; + + /** + * @param baselineColor - the rectangle's initial fill colour + */ + constructor(private readonly baselineColor: Color) { + super(); + } + + createContent(_situation: Situation, board: Board): void { + const rect = penpot.createRectangle(); + rect.name = ContentCreationStrategyRectangle.RECT_SHAPE_NAME; + rect.resize(50, 50); + rect.fills = [{ fillColor: this.baselineColor.hex, fillOpacity: this.baselineColor.opacity }]; + board.appendChild(rect); + } + + /** + * Returns this strategy's rectangle as it appears inside the given root shape. + * Throws if it cannot be found. + * + * @param instance - the root shape/component instance to search within + */ + static findRectangle(instance: Shape): Shape { + const rect = ShapeUtil.findShape( + instance, + (shape) => shape.name === ContentCreationStrategyRectangle.RECT_SHAPE_NAME + ); + if (rect === null) { + throw new Error( + `Could not find child "${ContentCreationStrategyRectangle.RECT_SHAPE_NAME}" inside instance "${instance.name}"` + ); + } + return rect; + } + + /** + * Returns this strategy's rectangle as it appears inside the given root shape. + * Throws if it cannot be found. + * + * @param instance - the root shape/component instance to search within + */ + getRectangle(instance: Shape): Shape { + return ContentCreationStrategyRectangle.findRectangle(instance); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategySiblingInstances.ts b/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategySiblingInstances.ts new file mode 100644 index 0000000000..bcdbba5d0a --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/content-creation/ContentCreationStrategySiblingInstances.ts @@ -0,0 +1,67 @@ +import { Board } from "@penpot/plugin-types"; +import { Situation } from "../core/Situation"; +import { Color } from "../model/Color"; +import { ContentCreationStrategy } from "./ContentCreationStrategy"; + +/** The layout applied to the board holding the sibling instances. */ +export type SiblingLayout = "none" | "flex" | "grid"; + +/** + * A content-creation strategy that fills the board with several SIBLING instances + * of one inner component: it creates a small inner component (a board with a + * rectangle) and appends `count` instances of it side by side — breadth, in + * contrast to the depth-wise wrapping of the nesting operations. + * + * Used for the swap-slot integrity cases: each sibling instance is a nested + * sub-instance head whose `shape-ref` must positionally match the main's children + * (see {@link ../util/SlotIntegrity}). + * + * The `layout` controls how the board arranges the siblings, which determines what + * a later structural edit re-flows ("grid" re-runs `reorder-grid-children` on + * changes, "flex" only repositions, "none" does nothing). Note that copy-side + * deletes have proven safe under every layout (see the case D notes); the layout + * is offered to keep the sweep space available, not because it is known to break. + */ +export class ContentCreationStrategySiblingInstances extends ContentCreationStrategy { + /** + * @param count - how many sibling instances of the inner component to append + * @param baselineColor - the inner rectangle's fill colour + * @param layout - the board layout arranging the siblings (default "none") + */ + constructor( + private readonly count: number, + private readonly baselineColor: Color, + private readonly layout: SiblingLayout = "none" + ) { + super(); + } + + createContent(_situation: Situation, board: Board): void { + // inner component: a small board with a single rectangle + const innerBoard = penpot.createBoard(); + innerBoard.name = "Icon"; + innerBoard.resize(24, 24); + const rect = penpot.createRectangle(); + rect.name = "rect"; + rect.resize(24, 24); + rect.fills = [{ fillColor: this.baselineColor.hex, fillOpacity: this.baselineColor.opacity }]; + innerBoard.appendChild(rect); + const innerComponent = penpot.library.local.createComponent([innerBoard]); + + if (this.layout === "grid") { + // a 1-row, N-column grid with each instance in its own cell + const grid = board.addGridLayout(); + while (grid.rows.length < 1) grid.addRow("flex", 1); + while (grid.columns.length < this.count) grid.addColumn("flex", 1); + for (let i = 0; i < this.count; i++) { + grid.appendChild(innerComponent.instance(), 1, i + 1); + } + } else { + // add the flex layout BEFORE the children so they are laid out in order + if (this.layout === "flex") board.addFlexLayout(); + for (let i = 0; i < this.count; i++) { + board.appendChild(innerComponent.instance()); + } + } + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/core/DataKey.ts b/plugins/apps/composable-test-suite/src/composable-tests/core/DataKey.ts new file mode 100644 index 0000000000..34a5620fa7 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/core/DataKey.ts @@ -0,0 +1,27 @@ +/** + * A typed, identity-based key for arbitrary data held in a situation — the + * non-shape analogue of a `Role`. Where a role names a participant shape, a data + * key names a piece of operation state that is NOT a shape (e.g. a per-level list + * of nested instance heads). Unlike per-op data (keyed by a single operation's + * identity), a data key is meant to be SHARED by several cooperating operations + * that must read and write one common structure, so the key — not any one op — + * is the identity of the data. + * + * Keys are compared by reference identity (two distinct `DataKey` instances are + * different keys, even with the same label). A data key is an internal mechanism + * of the op class that owns it: construct it privately and expose typed accessors, + * never the key itself. The phantom type parameter `T` records the value type. + */ +export class DataKey { + /** Phantom marker carrying the value type; never read at runtime. */ + declare private readonly _valueType: T; + + /** + * @param label - a stable, human-readable label (used only in diagnostics) + */ + constructor(public readonly label: string) {} + + toString(): string { + return this.label; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/core/Operation.ts b/plugins/apps/composable-test-suite/src/composable-tests/core/Operation.ts new file mode 100644 index 0000000000..a8ec0b1ec9 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/core/Operation.ts @@ -0,0 +1,53 @@ +import { Situation } from "./Situation"; + +/** + * A single step in a test trajectory. Most operations transform the situation + * (an edit, a structural change); some only observe it (an assertion). Reified + * as an object (strategy pattern) so steps are composable and self-describing. + * + * Each operation carries a stable identity assigned at construction. Applying it + * records that identity in the situation, so `Situation.wasApplied` can later be + * asked whether a particular operation instance ran in the current trajectory — + * the basis for branching assertions over an enumerated sweep. (Bind an operation + * to a value once and reuse that value, so the identity asked about is the one + * that ran.) + * + * Application is asynchronous because Plugin API mutations and their propagation + * may settle asynchronously. An operation mutates the live document and the + * situation's bindings in place. + */ +export abstract class Operation { + /** Source of stable per-instance ids; incremented as each operation is created. */ + private static nextId = 0; + + /** This operation instance's stable identity. */ + readonly id: number = Operation.nextId++; + + /** Applies this operation to `situation`, mutating it in place. */ + abstract applyTo(situation: Situation): Promise; + + /** A short, human-readable representation, recorded in the applied-operation log. */ + abstract toString(): string; + + /** + * Indicates whether applying this operation should be recorded in the + * situation's applied-operation log. True for operations that do something; + * overridden to false by no-ops (e.g. the skip of an untaken optional branch), + * so the log reflects only the operations that were actually applied. + */ + isRecorded(): boolean { + return true; + } + + /** + * Expands this operation into the concrete variants it stands for — every + * combination of choices it (and its children) contain, with no branch points + * remaining. A plain operation has a single variant: itself. Composites that + * introduce choice (a sequence of sub-operations, a branch point) override this + * to combine their children's variants. The runner runs each returned variant + * against a freshly-built situation. + */ + enumerateVariants(): Operation[] { + return [this]; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/core/Role.ts b/plugins/apps/composable-test-suite/src/composable-tests/core/Role.ts new file mode 100644 index 0000000000..52ba27014f --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/core/Role.ts @@ -0,0 +1,22 @@ +import { Shape } from "@penpot/plugin-types"; + +/** + * A typed, named binding key for a meaningful shape in a situation. A role + * identifies a participant of the configuration under test (e.g. the copy's + * child shape) independently of its concrete id, so operations and assertions + * refer to participants by role rather than by raw handle. The phantom type + * parameter `T` records the kind of shape the role is expected to bind. + */ +export class Role { + /** Phantom marker carrying the role's shape type; never read at runtime. */ + declare private readonly _shapeType: T; + + /** + * @param name - a stable, human-readable role name (used in diagnostics) + */ + constructor(public readonly name: string) {} + + toString(): string { + return this.name; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/core/RoleBundle.ts b/plugins/apps/composable-test-suite/src/composable-tests/core/RoleBundle.ts new file mode 100644 index 0000000000..5f987dd3e0 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/core/RoleBundle.ts @@ -0,0 +1,18 @@ +import { Role } from "./Role"; + +/** + * Marker base class for a bundle of named roles. A concrete bundle declares the + * roles a particular configuration provides as `Role` fields; the foundation + * operation that creates the configuration owns an instance of the bundle and + * binds its roles into the situation, giving a case a typed handle to the + * participants it acts on. + */ +export abstract class RoleBundle { + /** + * All roles declared on this bundle. Defaults to every `Role`-valued own + * property, so concrete bundles need only declare their roles as fields. + */ + allRoles(): Role[] { + return Object.values(this).filter((value): value is Role => value instanceof Role); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/core/ShapeTarget.ts b/plugins/apps/composable-test-suite/src/composable-tests/core/ShapeTarget.ts new file mode 100644 index 0000000000..96ef670f85 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/core/ShapeTarget.ts @@ -0,0 +1,18 @@ +import { Shape } from "@penpot/plugin-types"; +import { Situation } from "./Situation"; +import { Role } from "./Role"; + +/** + * A way to obtain a shape from a situation at apply-time: either a `Role` (looked + * up in the situation) or a function that derives the shape from the situation + * (e.g. searching an instance's subtree via a creation strategy). Resolving + * late — rather than capturing a fixed handle — lets an operation follow a role + * that earlier operations re-point, and lets derived shapes reflect the current + * configuration. + */ +export type ShapeTarget = Role | ((situation: Situation) => Shape); + +/** Resolves `target` to a concrete shape against `situation`. */ +export function resolveTarget(target: ShapeTarget, situation: Situation): Shape { + return target instanceof Role ? situation.get(target) : target(situation); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/core/Situation.ts b/plugins/apps/composable-test-suite/src/composable-tests/core/Situation.ts new file mode 100644 index 0000000000..ecd3ee7e9d --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/core/Situation.ts @@ -0,0 +1,136 @@ +import { Shape } from "@penpot/plugin-types"; +import { Role } from "./Role"; +import { DataKey } from "./DataKey"; + +/** + * The mutable state a test trajectory operates on. Carries the role bindings + * (meaningful shapes of the configuration, resolved to live Plugin API handles) + * and the ordered log of operations applied so far. + * + * Unlike a pure in-memory model, the situation operates on the LIVE Penpot + * document: an operation mutates the document through the Plugin API and updates + * the bindings. Role lookup is strict — an unbound role throws diagnostically + * rather than returning a nullish value. + */ +export class Situation { + private static readonly SHAPE_SPACING = 10; // pixels to advance X for next shape + + private readonly roles = new Map(); + private readonly appliedLog: string[] = []; + private readonly appliedIds = new Set(); + private readonly opData = new Map(); + private readonly keyedData = new Map, unknown>(); + private nextShapeX = 0; + private nextShapeY = 0; + + constructor(shapePosY: number = 0) { + this.nextShapeY = shapePosY; + } + + /** + * Binds `role` to `shape`, replacing any existing binding. Returns this + * situation to allow fluent setup. + */ + bind(role: Role, shape: T): this { + this.roles.set(role.name, shape); + return this; + } + + /** + * Positions `shape` at the next designated coordinates, then advances the X + * coordinate for the next shape. + * Shapes are laid out in a horizontal row with a fixed spacing between them. + * + * @param shape - the shape to position + */ + applyPosAdvanceX(shape: Shape): void { + shape.x = this.nextShapeX; + shape.y = this.nextShapeY; + this.nextShapeX += shape.width + Situation.SHAPE_SPACING; // advance X for next shape + } + + /** + * Resolves `role` to its bound shape. Throws a diagnostic error naming the + * absent role and the roles that are bound, never returning nullish. + */ + get(role: Role): T { + const shape = this.roles.get(role.name); + if (shape === undefined) { + const present = Array.from(this.roles.keys()).join(", "); + throw new Error(`Unbound role "${role.name}". Bound roles: [${present}]`); + } + return shape as T; + } + + /** Indicates whether `role` is currently bound. */ + has(role: Role): boolean { + return this.roles.has(role.name); + } + + /** + * Records that an operation described by `description` was applied, appending + * it to the ordered log. + */ + recordApplication(description: string): void { + this.appliedLog.push(description); + } + + /** The ordered transcript of applied operations, for failure diagnostics. */ + get transcript(): readonly string[] { + return this.appliedLog; + } + + /** + * Marks `operation` as applied in this trajectory (by its stable identity). + * Accepts anything carrying an `id`, to avoid a dependency on the operation + * class. + */ + markApplied(operation: { id: number }): void { + this.appliedIds.add(operation.id); + } + + /** + * Indicates whether `operation` was applied in this trajectory. Used by + * assertions to branch on which optional steps a given enumerated variant + * took. + */ + wasApplied(operation: { id: number }): boolean { + return this.appliedIds.has(operation.id); + } + + /** + * Stores operation-specific data for `operation` (keyed by its stable identity), + * replacing any previous value. This is the situation's generic per-operation + * store; operations are expected to wrap it in typed accessors rather than + * callers reading it directly. The value type is the operation's own concern. + */ + setData(operation: { id: number }, value: unknown): void { + this.opData.set(operation.id, value); + } + + /** + * Retrieves the data previously stored for `operation`, or `undefined` if none. + * The caller (the owning operation) knows the value's type and narrows it. + */ + getData(operation: { id: number }): unknown { + return this.opData.get(operation.id); + } + + /** + * Stores data under `key`, replacing any previous value. This is the situation's + * shared keyed store: several cooperating operations may read and write one + * value under a common `DataKey`. The owning op class wraps this in typed + * accessors rather than exposing the key. The value type is `T`. + */ + setKeyedData(key: DataKey, value: T): void { + this.keyedData.set(key as DataKey, value); + } + + /** + * Retrieves the data stored under `key`, or `undefined` if none. The owning op + * class knows the value's type via the key's type parameter. + */ + getKeyedData(key: DataKey): T | undefined { + return this.keyedData.get(key as DataKey) as T | undefined; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/index.ts b/plugins/apps/composable-test-suite/src/composable-tests/index.ts new file mode 100644 index 0000000000..74ca4a3912 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/index.ts @@ -0,0 +1,18 @@ +import { TestSuite } from "./test-suite/TestSuite.ts"; +import { allCases } from "./cases"; + +/** + * Builds the enumerated test suite for the live Penpot document. The suite expands + * every case into its concrete variants once (assigning stable ids) and serves + * both the tree the UI renders and the runs it requests. Intended to be created + * once by the plugin, which then sends its `tree()` to the UI and runs selected + * ids on demand. + */ +export function createTestSuite(): TestSuite { + return new TestSuite(allCases()); +} + +export { TestSuite } from "./test-suite/TestSuite.ts"; +export type { TestRunObserver } from "./test-suite/TestRunObserver.ts"; +export type { TestTree, TestGroupInfo, TestInfo } from "./test-suite/TestTree.ts"; +export { TestResult } from "./test-suite/TestResult.ts"; diff --git a/plugins/apps/composable-test-suite/src/composable-tests/model/Color.ts b/plugins/apps/composable-test-suite/src/composable-tests/model/Color.ts new file mode 100644 index 0000000000..2854433370 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/model/Color.ts @@ -0,0 +1,24 @@ +/** + * An immutable solid colour value. Combines a hex colour string with an opacity, + * giving the tests a single comparable value for a shape's solid fill. + */ +export class Color { + /** + * @param hex - the colour as an upper-case hex string (e.g. "#FF5533") + * @param opacity - the opacity in [0, 1] (defaults to fully opaque) + */ + constructor( + public readonly hex: string, + public readonly opacity: number = 1 + ) {} + + /** Indicates whether this colour equals `other` (same hex, same opacity). */ + equals(other: Color): boolean { + return this.hex === other.hex && this.opacity === other.opacity; + } + + /** A human-readable representation, for assertion messages. */ + toString(): string { + return this.opacity === 1 ? this.hex : `${this.hex}@${this.opacity}`; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/model/ShapeProp.ts b/plugins/apps/composable-test-suite/src/composable-tests/model/ShapeProp.ts new file mode 100644 index 0000000000..0c90bf7c1c --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/model/ShapeProp.ts @@ -0,0 +1,139 @@ +import { Shape, Fill } from "@penpot/plugin-types"; +import { Color } from "./Color"; +import { Assert } from "../util/Assert.ts"; + +/** + * A typed, readable and writable property of a shape. Abstracts a single visual + * attribute (e.g. the solid fill colour) behind a uniform read/write/compare + * interface, so operations and assertions can be generic over the property + * rather than hard-coding one attribute. The type parameter `T` is the + * property's value type. + */ +export interface ShapeProp { + /** A short name for the property, for diagnostics (e.g. "fill-color"). */ + readonly name: string; + + /** Reads the property's current value from `shape`. */ + read(shape: Shape): T; + + /** Writes `value` as the property's value on `shape`. */ + write(shape: Shape, value: T): void; + + /** Indicates whether two values of this property are equal. */ + equals(a: T, b: T): boolean; + + /** Asserts that `actual` equals `expected`, failing with a value-rich message. */ + assertEqual(actual: T, expected: T): void; +} + +/** + * Base class for shape properties supplying the comparison-derived behaviour: + * given a subclass's `equals` and `render`, it provides `assertEqual`. Concrete + * properties implement only `name`, `read`, `write`, `equals`, and `render`. + */ +export abstract class ShapePropBase implements ShapeProp { + abstract readonly name: string; + abstract read(shape: Shape): T; + abstract write(shape: Shape, value: T): void; + abstract equals(a: T, b: T): boolean; + + /** Renders a value of this property for assertion messages. */ + protected abstract render(value: T): string; + + assertEqual(actual: T, expected: T): void { + Assert.equal( + actual, + expected, + (a, b) => this.equals(a, b), + (v) => this.render(v) + ); + } +} + +/** + * The solid fill colour of a shape. Reads and writes the shape's first fill as a + * single solid `Color`; intended for shapes whose fill is one solid colour. + */ +export class ShapePropFillColor extends ShapePropBase { + readonly name = "fill-color"; + + read(shape: Shape): Color { + const fills = shape.fills as Fill[]; + if (!Array.isArray(fills) || fills.length === 0) { + throw new Error(`Shape "${shape.name}" has no fills to read a colour from`); + } + const fill = fills[0]; + const hex = fill.fillColor; + if (hex === undefined) { + throw new Error(`Shape "${shape.name}"'s first fill is not a solid colour`); + } + return new Color(hex, fill.fillOpacity ?? 1); + } + + write(shape: Shape, value: Color): void { + shape.fills = [{ fillColor: value.hex, fillOpacity: value.opacity }]; + } + + equals(a: Color, b: Color): boolean { + return a.equals(b); + } + + protected render(value: Color): string { + return value.toString(); + } +} + +/** + * Base class for numeric shape properties, supplying tolerance-based equality + * (guarding against floating-point noise in values read back from the document) + * and plain-number rendering. Concrete properties implement `name`, `read`, and + * `write`. + */ +export abstract class ShapePropNumberBase extends ShapePropBase { + // Tolerance combining an absolute floor with a magnitude-scaled relative + // term, wide enough to absorb float32 geometry noise in values read back. + private static readonly ABS_EPSILON = 1e-4; + private static readonly REL_EPSILON = 1e-5; + + equals(a: number, b: number): boolean { + const tolerance = + ShapePropNumberBase.ABS_EPSILON + ShapePropNumberBase.REL_EPSILON * Math.max(Math.abs(a), Math.abs(b)); + return Math.abs(a - b) <= tolerance; + } + + protected render(value: number): string { + return String(value); + } +} + +/** + * The rotation of a shape, in degrees, with respect to its centre. Reads and + * writes the shape's `rotation` directly. + */ +export class ShapePropRotation extends ShapePropNumberBase { + readonly name = "rotation"; + + read(shape: Shape): number { + return shape.rotation; + } + + write(shape: Shape, value: number): void { + shape.rotation = value; + } +} + +/** + * The height of a shape. `height` is read-only in the Plugin API, so writing goes + * through `resize`, keeping the current width. + */ +export class ShapePropHeight extends ShapePropNumberBase { + readonly name = "height"; + + read(shape: Shape): number { + return shape.height; + } + + write(shape: Shape, value: number): void { + shape.resize(shape.width, value); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts new file mode 100644 index 0000000000..3158075b9d --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts @@ -0,0 +1,42 @@ +import { Operation } from "../core/Operation"; +import { Situation } from "../core/Situation"; +import { PenpotSync } from "../util/PenpotSync.ts"; + +/** + * An assertion step that observes the situation without changing it. Runs the + * supplied assertion function against the situation; the function performs the + * checks (via the `Assert` helpers) and throws on failure, which the runner + * captures. The inline-assertion analogue of an edit operation, so checks can be + * placed at any point in a trajectory. + * + * To tolerate reads that race ahead of an edit's not-yet-settled propagation, the + * assertion is tried once immediately (the fast path, paying no delay when the + * document is already consistent); only if it fails is propagation awaited and the + * assertion retried once, whose outcome is final. + */ +export class OpAssert extends Operation { + /** + * @param description - a short description of what is asserted + * @param assertion - performs the assertions against the situation + */ + constructor( + private readonly description: string, + private readonly assertion: (situation: Situation) => void + ) { + super(); + } + + async applyTo(situation: Situation): Promise { + try { + this.assertion(situation); + } catch { + // a read may have raced propagation; let it settle and check once more + await PenpotSync.awaitPropagation(); + this.assertion(situation); + } + } + + toString(): string { + return `assert ${this.description}`; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpChangeProperty.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpChangeProperty.ts new file mode 100644 index 0000000000..50a60e972a --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpChangeProperty.ts @@ -0,0 +1,53 @@ +import { Operation } from "../core/Operation"; +import { Situation } from "../core/Situation"; +import { ShapeTarget, resolveTarget } from "../core/ShapeTarget"; +import { ShapeProp } from "../model/ShapeProp.ts"; + +/** + * Sets a typed property to a given value on the shape a target resolves to. The + * edit operation: writes `value` via the `property` strategy onto the shape that + * `target` resolves to in the situation (a role, or a function deriving the shape + * from the situation). Generic over the property's value type `T`. + */ +export class OpChangeProperty extends Operation { + /** + * @param target - resolves the shape to edit (role or situation-derived) + * @param property - the property strategy used to write the value + * @param value - the value to write + * @param targetLabel - a human-readable name for the edited shape, used in the + * applied-operation log (a function target has no readable name of its own); + * defaults to the target's own string form + */ + constructor( + private readonly target: ShapeTarget, + private readonly property: ShapeProp, + private readonly value: T, + private readonly targetLabel: string = String(target) + ) { + super(); + } + + async applyTo(situation: Situation): Promise { + const shape = resolveTarget(this.target, situation); + this.property.write(shape, this.value); + } + + /** + * Asserts that the shape `target` resolves to now shows this operation's + * property change — i.e. that reading this op's property from that shape + * yields the value this op wrote. Lets a case check that an edit arrived on + * another shape (e.g. propagated from a main to its copy) without restating + * the property or the value. + * + * @param situation - the situation to resolve `target` against + * @param target - resolves the shape expected to show the change + */ + assertHasChangedProperty(situation: Situation, target: ShapeTarget): void { + const shape = resolveTarget(target, situation); + this.property.assertEqual(this.property.read(shape), this.value); + } + + toString(): string { + return `change ${this.property.name} of ${this.targetLabel} to ${String(this.value)}`; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateNestableComponent.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateNestableComponent.ts new file mode 100644 index 0000000000..a149c109c9 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateNestableComponent.ts @@ -0,0 +1,230 @@ +import { Board, Shape } from "@penpot/plugin-types"; +import { Situation } from "../core/Situation"; +import { Operation } from "../core/Operation"; +import { Role } from "../core/Role"; +import { RoleBundle } from "../core/RoleBundle"; +import { DataKey } from "../core/DataKey"; +import { ShapeUtil } from "../util/ShapeUtil.ts"; +import { ContentCreationStrategy } from "../content-creation/ContentCreationStrategy.ts"; + +/** The name of the board that becomes the original (innermost) component root. */ +const ORIGINAL_COMPONENT_NAME = "ComponentRoot"; + +/** The name of the board that wraps a copy to form an outer nesting level. */ +const NESTED_COMPONENT_NAME = "OuterComponentRoot"; + +/** + * The instance roles of a nestable-component configuration. Three component + * INSTANCES are tracked, with these meanings as the configuration evolves: + * - `remoteInstance` — the originally created main instance (the fixed origin); + * never re-pointed. + * - `mainInstance` — the CURRENT outermost main; re-pointed to the new outer + * component on each nesting. + * - `copyInstance` — the instance whose deep content reflects propagation; set + * by instantiate, and replaced on each nesting by the new outer instance. + * Content shapes (e.g. a child rect) are not roles here — they are found inside a + * given instance via the content-creation strategy. + */ +class RolesNestableComponent extends RoleBundle { + readonly remoteInstance = new Role("remote-instance"); + readonly mainInstance = new Role("main-instance"); + readonly copyInstance = new Role("copy-instance"); +} + +/** + * The mutable per-lineage state of a nestable component, accumulated as the + * configuration is built. Created once by the foundation operation and mutated in + * place by the operations it vends (instantiate, nest) — they alter this object + * rather than replacing it. Held in the situation under the foundation op's own + * key, and reached by related ops through the foundation op's accessor. + */ +export class NestingData { + /** + * The per-level copy instances, in nesting order: index 0 is the innermost + * (the first instantiated copy), each subsequent entry an outer level that + * wraps the one below. Appended to as nesting deepens. + */ + readonly copyInstances: Board[] = []; +} + +/** + * Instantiates the current main component and binds the result as the copy, and + * appends it as the innermost (level 0) copy of the lineage's nesting data. + */ +class OpInstantiateCopy extends Operation { + constructor(private readonly parent: OpCreateNestableComponent) { + super(); + } + + async applyTo(situation: Situation): Promise { + const roles = this.parent.roles; + const main = situation.get(roles.mainInstance); + const component = main.component(); + if (component === null) { + throw new Error(`"${main.name}" is not a component instance; cannot instantiate a copy`); + } + const copy = component.instance() as Board; + situation.bind(roles.copyInstance, copy); + situation.applyPosAdvanceX(copy); + + // the freshly instantiated copy is the innermost (level 0) copy + this.parent.getNestingData(situation).copyInstances.push(copy); + } + + toString(): string { + return "instantiate copy"; + } +} + +/** + * Adds a nesting level around the current copy. Wraps the `copyInstance` in a new + * outer board, turns that board into a component, re-points `mainInstance` to the + * new (outer) main, then instantiates the new component and binds it as the new + * `copyInstance`. `remoteInstance` is left untouched (the fixed origin). The new + * outer instance is appended as the next (outer) level's copy. + */ +class OpMakeNestedComponent extends Operation { + constructor(private readonly parent: OpCreateNestableComponent) { + super(); + } + + async applyTo(situation: Situation): Promise { + const roles = this.parent.roles; + const inner = situation.get(roles.copyInstance); + + // a new outer board containing the current copy, made into a component + const outerBoard = penpot.createBoard(); + outerBoard.name = NESTED_COMPONENT_NAME; + outerBoard.appendChild(inner); + const outerComponent = penpot.library.local.createComponent([outerBoard]); + situation.applyPosAdvanceX(outerComponent.mainInstance()); + + // the outer main becomes the current main; remote stays fixed + situation.bind(roles.mainInstance, outerComponent.mainInstance() as Board); + + // an instance of the outer component becomes the new copy + const outerInstance = outerComponent.instance() as Board; + situation.bind(roles.copyInstance, outerInstance); + + // the new outer instance is the copy for the newly added (outer) level + this.parent.getNestingData(situation).copyInstances.push(outerInstance); + } + + toString(): string { + return "make nested component"; + } +} + +/** + * The foundation operation for a component that can be instantiated and nested. As + * the first step of a trajectory it creates the component (its content supplied by + * a content-creation strategy), binds both `remoteInstance` and `mainInstance` to + * its main instance, and initialises the lineage's `NestingData`. It owns the + * three instance roles and provides the operations that grow the configuration — + * a case obtains them from this op (`op.createOpInstantiate()`, + * `op.createOpMakeNested()`). Those operations hold a reference to this op and + * mutate its nesting data, so the data's key stays private here. The strategy type + * `TStrategy` is preserved so its content accessors are available to the case. + * + * The lineage's per-level instances are exposed via `getNestedInstanceHead` / + * `getNestedInstance`, so a case can address the instance at any nesting level + * (e.g. as the target of a variant switch) without knowing how nesting is stored. + */ +export class OpCreateNestableComponent extends Operation { + readonly roles = new RolesNestableComponent(); + + /** Private key under which this lineage's (mutable) nesting data is stored. */ + private readonly nestingDataKey = new DataKey("nesting-data"); + + /** + * @param contentCreator - creates and locates the component's content + */ + constructor(public readonly contentCreator: TContentCreator) { + super(); + } + + async applyTo(situation: Situation): Promise { + // start this lineage's nesting data (mutated in place by related ops) + situation.setKeyedData(this.nestingDataKey, new NestingData()); + + // create the board, fill it via the strategy, and make it a component + const board = penpot.createBoard(); + board.name = ORIGINAL_COMPONENT_NAME; + board.resize(100, 100); + this.contentCreator.createContent(situation, board); + situation.applyPosAdvanceX(board); + + const component = penpot.library.local.createComponent([board]); + const main = component.mainInstance() as Board; + + // remote and main both start at the original main; copy is set by instantiate + situation.bind(this.roles.remoteInstance, main); + situation.bind(this.roles.mainInstance, main); + } + + toString(): string { + return "create nestable component"; + } + + /** An operation that instantiates the current main and binds it as the copy. */ + createOpInstantiate(): Operation { + return new OpInstantiateCopy(this); + } + + /** An operation that adds a nesting level around the current copy. */ + createOpMakeNested(): Operation { + return new OpMakeNestedComponent(this); + } + + /** + * This lineage's nesting data in `situation` — created by this op's application + * and mutated by the operations it vends. Throws if this op has not yet run. + */ + getNestingData(situation: Situation): NestingData { + const data = situation.getKeyedData(this.nestingDataKey); + if (data === undefined) { + throw new Error("nesting data not initialised; run the create-nestable-component op first"); + } + return data; + } + + /** + * @param situation - the situation + * @param level - the nesting level to retrieve (0 = initially created instance, least nested) + * @return the instance of the originally created component at nesting `level`, i.e. the descendant + * of the copy instance that corresponds to the instance of the originally created component. + * At level 0 this is the copy instance itself; at deeper levels it is the instance nested inside + * that level's copy, surrounded by the outer boards added by the nesting operations. + * Note: This is the shape that directly contains the content creation strategy's content. + */ + getNestedInstance(situation: Situation, level: number): Shape { + const copy = this.getCopyInstance(situation, level); + // the copy itself is the head at level 0; deeper levels wrap it, so descend + // to the (innermost) original component root + const head = + copy.name === ORIGINAL_COMPONENT_NAME + ? copy + : ShapeUtil.findShape(copy, (shape) => shape.name === ORIGINAL_COMPONENT_NAME); + if (head === null) { + throw new Error(`no nested head "${ORIGINAL_COMPONENT_NAME}" found at level ${level}`); + } + return head; + } + + /** + * @param situation - the situation + * @param level - the nesting level to retrieve (0 = initially created instance, least nested) + * @return the copy instance root shape at nesting `level`, i.e. the root of the copy instance that was created + * at that level. + * Note: A copy instance is created by the instantiate operation (first copy) and by each nesting + * operation. + */ + getCopyInstance(situation: Situation, level: number): Board { + const copies = this.getNestingData(situation).copyInstances; + const copy = copies[level]; + if (copy === undefined) { + throw new Error(`no copy at level ${level} (have ${copies.length})`); + } + return copy; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateSimpleComponentWithCopy.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateSimpleComponentWithCopy.ts new file mode 100644 index 0000000000..12b2c5a0e7 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateSimpleComponentWithCopy.ts @@ -0,0 +1,81 @@ +import { Shape, Board } from "@penpot/plugin-types"; +import { Situation } from "../core/Situation"; +import { Color } from "../model/Color"; +import { Operation } from "../core/Operation"; +import { Role } from "../core/Role"; +import { RoleBundle } from "../core/RoleBundle"; + +/** + * The roles exposed by a "component with a copy" configuration. The participants + * that take part in propagation are the CHILD shapes of the main and of the copy + * (the component roots are boards); these are named here, along with the copy's + * root. The foundation op owns an instance of this bundle and binds its roles. + */ +class RolesComponent extends RoleBundle { + /** The main component's child shape (the one an edit-to-main targets). */ + readonly mainChild = new Role("main-child"); + + /** The copy instance's corresponding child shape. */ + readonly copyChild = new Role("copy-child"); + + /** The copy instance's root (the instantiated board). */ + readonly copyRoot = new Role("copy-root"); +} + +/** + * The foundation operation for a "simple component with a copy" configuration: a + * one-child component (a board containing a single rectangle) plus one instance of + * it. As the first step of a trajectory it creates them and binds the main's and + * the copy's child rectangles to its `mainChild` / `copyChild` roles and the + * copy's root to `copyRoot`. The child rectangle starts with a known baseline + * fill, so a later "value followed" check is distinguishable from coincidence. + */ +export class OpCreateSimpleComponentWithCopy extends Operation { + readonly roles = new RolesComponent(); + + /** + * @param baselineColor - the child rectangle's initial fill colour + */ + constructor(private readonly baselineColor: Color) { + super(); + } + + async applyTo(situation: Situation): Promise { + // create the board + child rectangle that will become the component + const board = penpot.createBoard(); + board.name = "ComponentRoot"; + board.resize(100, 100); + + const rect = penpot.createRectangle(); + rect.name = "Child"; + rect.resize(50, 50); + rect.fills = [{ fillColor: this.baselineColor.hex, fillOpacity: this.baselineColor.opacity }]; + board.appendChild(rect); + + // turn the board into a component; its main instance is the board itself + const component = penpot.library.local.createComponent([board]); + const mainRoot = component.mainInstance(); + situation.applyPosAdvanceX(mainRoot); + + // instantiate a copy of the component on the current page + const copyRoot = component.instance(); + situation.applyPosAdvanceX(copyRoot); + + situation.bind(this.roles.mainChild, this.onlyChildOf(mainRoot)); + situation.bind(this.roles.copyChild, this.onlyChildOf(copyRoot)); + situation.bind(this.roles.copyRoot, copyRoot); + } + + toString(): string { + return "create simple component with a copy"; + } + + /** Returns the single child of `root`, failing if it does not have exactly one. */ + private onlyChildOf(root: Shape): Shape { + const children = (root as Board).children; + if (!children || children.length !== 1) { + throw new Error(`Expected "${root.name}" to have exactly one child, found ${children?.length ?? 0}`); + } + return children[0]; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateVariantContainer.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateVariantContainer.ts new file mode 100644 index 0000000000..7f9abfc8f0 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateVariantContainer.ts @@ -0,0 +1,115 @@ +import { Board, Shape, VariantContainer } from "@penpot/plugin-types"; +import { Situation } from "../core/Situation"; +import { Operation } from "../core/Operation"; +import { ShapeUtil } from "../util/ShapeUtil.ts"; +import { ContentCreationStrategy } from "../content-creation/ContentCreationStrategy"; +import { + ContentCreationStrategyInstantiateVariantContainer, + VARIANT_INSTANCE_NAME, +} from "../content-creation/ContentCreationStrategyInstantiateVariantContainer"; +import { VariantsUtil } from "../util/VariantsUtil.ts"; + +/** The single variant property this op defines; its values are 0-based indices. */ +const VARIANT_PROPERTY = "variantIdx"; + +/** + * The foundation operation that creates a variant container. Given one content- + * creation strategy per variant, it builds a component from each (its content + * supplied by that strategy) and combines them into a variant container, under a + * single property whose value is the variant's 0-based index. + * + * The created container is stored in the situation, keyed by this op; later steps + * reach it through this op's `getVariantContainer`. The op is the interface to its + * own data — callers ask the op, not the situation's raw store. + * + * For instantiating a variant of the created container, the op vends a content + * strategy via `contentStrategyForVariant`, wired to resolve this container at + * apply-time — so a later instantiate step needs nothing but the chosen index. + */ +export class OpCreateVariantContainer extends Operation { + private readonly variantStrategies: readonly ContentCreationStrategy[]; + + /** + * @param variantStrategies - one content-creation strategy per variant, in + * index order; each defines the content of that variant's component + */ + constructor(...variantStrategies: ContentCreationStrategy[]) { + super(); + this.variantStrategies = variantStrategies; + } + + async applyTo(situation: Situation): Promise { + // build one component per variant, its content supplied by the strategy + const mains = this.variantStrategies.map((strategy, index) => { + const board = penpot.createBoard(); + board.name = `Variant ${index}`; + board.resize(100, 100); + strategy.createContent(situation, board); + const component = penpot.library.local.createComponent([board]); + return component.mainInstance() as Board; + }); + + // combine them into a variant container, one property keyed by index + const container = VariantsUtil.create( + mains.map((shape, index) => ({ shape, properties: { [VARIANT_PROPERTY]: String(index) } })) + ); + + situation.setData(this, container); + situation.applyPosAdvanceX(container); + } + + toString(): string { + return "create variant container"; + } + + /** + * The variant container this op created, read from `situation`. Throws if this + * op has not yet run in the trajectory. + */ + getVariantContainer(situation: Situation): VariantContainer { + const container = situation.getData(this) as VariantContainer | undefined; + if (container === undefined) { + throw new Error("variant container not created yet; run OpCreateVariantContainer first"); + } + return container; + } + + /** + * The variant-component instance this op's strategy placed, located within + * `root` by name. Use it to turn a structural nested head (from the nestable + * op) into the actual variant instance to switch: + * `variantContainer.getVariantInstance(nestable.getNestedInstanceHead(s, level))`. + * Returns `root` itself if it is the variant instance. Throws if none is found. + */ + getVariantInstance(root: Shape): Shape { + const instance = + root.name === VARIANT_INSTANCE_NAME + ? root + : ShapeUtil.findShape(root, (shape) => shape.name === VARIANT_INSTANCE_NAME); + if (instance === null) { + throw new Error(`no variant instance "${VARIANT_INSTANCE_NAME}" found within "${root.name}"`); + } + return instance; + } + + /** + * The variant property's position (for `switchVariant`). A single property, so + * always position 0. + */ + get variantPropertyPosition(): number { + return 0; + } + + /** The property value identifying variant `index` (its 0-based index as a string). */ + valueForVariant(index: number): string { + return String(index); + } + + /** + * Returns a content creation strategy that instantiates variant `index` of this op's + * container. + */ + createContentCreationStrategy(index: number): ContentCreationStrategyInstantiateVariantContainer { + return new ContentCreationStrategyInstantiateVariantContainer((s) => this.getVariantContainer(s), index); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpDeleteShape.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpDeleteShape.ts new file mode 100644 index 0000000000..19be311fcd --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpDeleteShape.ts @@ -0,0 +1,36 @@ +import { Operation } from "../core/Operation"; +import { Situation } from "../core/Situation"; +import { ShapeTarget, resolveTarget } from "../core/ShapeTarget"; +import { PenpotSync } from "../util/PenpotSync"; + +/** + * Removes the shape a target resolves to from the document, via the Plugin API's + * `remove()`. The structural-deletion counterpart to {@link OpChangeProperty}: + * resolves `target` against the situation (a role, or a function deriving the + * shape) and deletes it. + */ +export class OpDeleteShape extends Operation { + /** + * @param target - resolves the shape to delete (role or situation-derived) + * @param targetLabel - a human-readable name for the deleted shape, used in + * the applied-operation log; defaults to the target's own string form + */ + constructor( + private readonly target: ShapeTarget, + private readonly targetLabel: string = String(target) + ) { + super(); + } + + async applyTo(situation: Situation): Promise { + const shape = resolveTarget(this.target, situation); + shape.remove(); + // A delete fires an async `:layout/update` reflow on the parent; let it + // settle so any resulting (in)consistency is visible to later steps. + await PenpotSync.awaitPropagation(); + } + + toString(): string { + return `delete ${this.targetLabel}`; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpInstantiateContent.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpInstantiateContent.ts new file mode 100644 index 0000000000..91df090fc9 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpInstantiateContent.ts @@ -0,0 +1,37 @@ +import { Board } from "@penpot/plugin-types"; +import { Operation } from "../core/Operation"; +import { Situation } from "../core/Situation"; +import { Role } from "../core/Role"; +import { ContentCreationStrategy } from "../content-creation/ContentCreationStrategy"; + +/** + * Instantiates content into a fresh board and binds that board to a role. Creates + * a board, fills it via the content-creation strategy (which may, for example, + * append an instance of an existing component), and binds the board to `target` + * so later steps can act on it. A general "place this content somewhere and track + * it" step. + */ +export class OpInstantiateContent extends Operation { + /** + * @param strategy - fills the new board with content + * @param target - the role to bind the created board to + */ + constructor( + private readonly strategy: ContentCreationStrategy, + private readonly target: Role + ) { + super(); + } + + async applyTo(situation: Situation): Promise { + const board = penpot.createBoard(); + board.name = "Content"; + board.resize(100, 100); + this.strategy.createContent(situation, board); + situation.bind(this.target, board); + } + + toString(): string { + return "instantiate content"; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpOneOf.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpOneOf.ts new file mode 100644 index 0000000000..0c406063bc --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpOneOf.ts @@ -0,0 +1,32 @@ +import { Operation } from "../core/Operation.ts"; +import { Situation } from "../core/Situation.ts"; + +/** + * A branch point: exactly one of its alternatives is taken. It is not applied + * directly — it exists to be ENUMERATED, expanding a composition into one + * concrete trajectory per alternative. Applying it directly is a usage error. + */ +export class OpOneOf extends Operation { + readonly alternatives: readonly Operation[]; + + constructor(...alternatives: Operation[]) { + super(); + this.alternatives = alternatives; + } + + async applyTo(_situation: Situation): Promise { + throw new Error("OneOf must be enumerated, not applied directly"); + } + + toString(): string { + return `one-of(${this.alternatives.map((a) => a.toString()).join(" | ")})`; + } + + /** + * A branch point's variants are the union of its alternatives' variants (each + * alternative is itself expanded, so nested choices flatten out). + */ + override enumerateVariants(): Operation[] { + return this.alternatives.flatMap((alternative) => alternative.enumerateVariants()); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpOptional.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpOptional.ts new file mode 100644 index 0000000000..e2dee70b3f --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpOptional.ts @@ -0,0 +1,16 @@ +import { Operation } from "../core/Operation.ts"; +import { OpSkip } from "./OpSkip.ts"; +import { OpOneOf } from "./OpOneOf.ts"; + +/** + * Sweeps "with and without `operation`": a branch point between applying it and + * skipping it (a `OneOf` over `[operation, Skip]`). Enumerates to two trajectories, + * one including `operation` and one not. The same `operation` instance can then be + * queried via `Situation.wasApplied` inside an assertion to branch on which + * trajectory ran. + */ +export class OpOptional extends OpOneOf { + constructor(operation: Operation) { + super(operation, new OpSkip()); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpReorderShape.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpReorderShape.ts new file mode 100644 index 0000000000..ef81be0058 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpReorderShape.ts @@ -0,0 +1,32 @@ +import { Operation } from "../core/Operation"; +import { Situation } from "../core/Situation"; +import { ShapeTarget, resolveTarget } from "../core/ShapeTarget"; + +/** + * Reorders the shape a target resolves to within its parent, via the Plugin API's + * `setParentIndex`. Used to reorder a sub-head IN THE MAIN of a component, which is + * the operation that breaks copies' swap-slot alignment (see {@link ../cases/caseE}). + */ +export class OpReorderShape extends Operation { + /** + * @param target - resolves the shape to reorder (role or situation-derived) + * @param toIndex - the new 0-based index within the parent + * @param targetLabel - a human-readable name for the reordered shape, for the log + */ + constructor( + private readonly target: ShapeTarget, + private readonly toIndex: number, + private readonly targetLabel: string = String(target) + ) { + super(); + } + + async applyTo(situation: Situation): Promise { + const shape = resolveTarget(this.target, situation); + shape.setParentIndex(this.toIndex); + } + + toString(): string { + return `reorder ${this.targetLabel} to index ${this.toIndex}`; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSequence.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSequence.ts new file mode 100644 index 0000000000..b123ce5b9c --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSequence.ts @@ -0,0 +1,45 @@ +import { Situation } from "../core/Situation.ts"; +import { Operation } from "../core/Operation.ts"; + +/** + * An ordered composition of operations. Applies its steps left-to-right against + * the same situation, threading the (mutated) situation through each, and marks + * each applied step in the situation. The primary composition operator. + */ +export class OpSequence extends Operation { + readonly steps: readonly Operation[]; + + constructor(...steps: Operation[]) { + super(); + this.steps = steps; + } + + async applyTo(situation: Situation): Promise { + for (const step of this.steps) { + await step.applyTo(situation); + if (step.isRecorded()) { + situation.markApplied(step); + situation.recordApplication(step.toString()); + } + } + } + + toString(): string { + return `sequence(${this.steps.map((s) => s.toString()).join(", ")})`; + } + + /** + * A sequence's variants are the cartesian product of its steps' variants: one + * concrete sequence per combination, each preserving the original order. + */ + override enumerateVariants(): Operation[] { + return cartesianProduct(this.steps.map((step) => step.enumerateVariants())).map( + (steps) => new OpSequence(...steps) + ); + } +} + +/** The cartesian product of the given lists, preserving order. */ +function cartesianProduct(lists: T[][]): T[][] { + return lists.reduce((acc, list) => acc.flatMap((prefix) => list.map((item) => [...prefix, item])), [[]]); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSkip.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSkip.ts new file mode 100644 index 0000000000..e9dbb41ffd --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSkip.ts @@ -0,0 +1,21 @@ +import { Situation } from "../core/Situation.ts"; +import { Operation } from "../core/Operation.ts"; + +/** + * The identity operation: applies nothing. Used as the "absent" branch of an + * optional step, so a sweep can include a variant in which that step did not run. + */ +export class OpSkip extends Operation { + async applyTo(_situation: Situation): Promise { + // intentionally does nothing + } + + toString(): string { + return "skip"; + } + + /** A skip did nothing, so it is left out of the applied-operation log. */ + override isRecorded(): boolean { + return false; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSwitchVariant.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSwitchVariant.ts new file mode 100644 index 0000000000..c4a2009050 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSwitchVariant.ts @@ -0,0 +1,40 @@ +import { Operation } from "../core/Operation"; +import { Situation } from "../core/Situation"; +import { ShapeTarget, resolveTarget } from "../core/ShapeTarget"; + +/** + * Switches a variant-component copy head to a sibling member. Resolves the + * instance to switch from a `ShapeTarget`, then switches it (on the single variant + * property, position 0) to the sibling member whose value is `value` — the + * `switchVariant` action discovers that sibling within the variant container. The + * switch propagates outward across nesting levels via the component watcher, + * exactly like a component swap. + * + * The target is resolved like any operation target (e.g. the nested instance head + * at a given level), so this op knows nothing about nesting — it just switches + * whatever head it is given. + */ +export class OpSwitchVariant extends Operation { + /** The single variant property's position (this framework uses one property). */ + private static readonly PROPERTY_POSITION = 0; + + /** + * @param target - resolves the variant-component instance head to switch + * @param value - the sibling member's selector value to switch to + */ + constructor( + private readonly target: ShapeTarget, + private readonly value: string + ) { + super(); + } + + async applyTo(situation: Situation): Promise { + const head = resolveTarget(this.target, situation); + head.switchVariant(OpSwitchVariant.PROPERTY_POSITION, this.value); + } + + toString(): string { + return `switch variant to ${this.value}`; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/test-suite/RunnableTest.ts b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/RunnableTest.ts new file mode 100644 index 0000000000..0367bf3a5f --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/RunnableTest.ts @@ -0,0 +1,36 @@ +import { Operation } from "../core/Operation.ts"; +import { Situation } from "../core/Situation.ts"; +import { TestResult } from "./TestResult.ts"; + +/** + * One concrete, runnable test: a single enumerated variant of a case, carrying a + * stable id (its identity across enumeration, the UI, and run requests), a display + * name, and the operation to apply. Running applies the operation to a fresh, + * empty situation (so variants do not interfere) and captures any failure as a + * failing result. The operation's first steps are responsible for laying the + * foundations (creating the starting configuration). + */ +export class RunnableTest { + /** + * @param id - stable opaque identity (used as the UI key and in run requests) + * @param name - display name (e.g. "instance #1") + * @param operation - the concrete (fully chosen) trajectory to apply + */ + constructor( + public readonly id: string, + public readonly name: string, + private readonly operation: Operation + ) {} + + /** Runs this test against a fresh situation, returning its result. */ + async run(posY: number): Promise { + const situation = new Situation(posY); + try { + await this.operation.applyTo(situation); + return TestResult.pass(this.name, situation.transcript); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return TestResult.fail(this.name, message, situation.transcript); + } + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestCase.ts b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestCase.ts new file mode 100644 index 0000000000..5858ef1072 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestCase.ts @@ -0,0 +1,26 @@ +import { Operation } from "../core/Operation.ts"; + +/** + * A complete test: a named operation — the full trajectory, including the step + * that lays the foundations (creating the starting configuration), any edits or + * structural changes, and the assertions. Running it applies the operation to a + * fresh, empty situation. + */ +export class TestCase { + /** + * @param identifier - a meaningful CamelCase case identifier, stating the + * tested behaviour (e.g. "MainEditSyncs") + * @param description - what the case tests, in plain, highly understandable + * terms and in three parts, in this order: (1) the situation SETUP — what + * configuration is created, so the reader can picture it; (2) the ACTIONS + * and variations applied to it; (3) the REQUIREMENT that is asserted. + * A few sentences, starting with an upper-case letter. + * @param operation - the trajectory to apply, foundations first, including + * assertions + */ + constructor( + public readonly identifier: string, + public readonly description: string, + public readonly operation: Operation + ) {} +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestResult.ts b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestResult.ts new file mode 100644 index 0000000000..41e10ad2b5 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestResult.ts @@ -0,0 +1,23 @@ +/** + * The outcome of running one concrete variant of a test case. An immutable record + * carrying the variant's display name, whether it passed, and — on failure — the + * error message and the transcript of operations that had been applied. + */ +export class TestResult { + private constructor( + public readonly name: string, + public readonly passed: boolean, + public readonly errorMessage: string | undefined, + public readonly transcript: readonly string[] + ) {} + + /** Creates a passing result named `name`. */ + static pass(name: string, transcript: readonly string[]): TestResult { + return new TestResult(name, true, undefined, transcript); + } + + /** Creates a failing result named `name` with `errorMessage` and `transcript`. */ + static fail(name: string, errorMessage: string, transcript: readonly string[]): TestResult { + return new TestResult(name, false, errorMessage, transcript); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestRunObserver.ts b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestRunObserver.ts new file mode 100644 index 0000000000..7c2b22558b --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestRunObserver.ts @@ -0,0 +1,16 @@ +import { TestResult } from "./TestResult.ts"; + +/** + * Receives streaming notifications as a selected set of tests runs, addressed by + * stable test id, so a UI can update the specific row for each test the moment its + * state changes. The set of tests to run is known up front (the UI selected them), + * so there is no separate "total" notification — each test reports when it starts + * and when it finishes. + */ +export interface TestRunObserver { + /** Called when the test with id `id` begins running. */ + onTestStarted(id: string): void; + + /** Called when the test with id `id` finishes, with its result. */ + onTestFinished(id: string, result: TestResult): void; +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestSuite.ts b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestSuite.ts new file mode 100644 index 0000000000..a38d98bccb --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestSuite.ts @@ -0,0 +1,75 @@ +import { TestCase } from "./TestCase.ts"; +import { RunnableTest } from "./RunnableTest.ts"; +import { TestRunObserver } from "./TestRunObserver.ts"; +import { TestTree } from "./TestTree.ts"; + +/** A group of runnable tests enumerated from one case. */ +interface Group { + identifier: string; + description: string; + tests: RunnableTest[]; +} + +/** + * The enumerated test suite. On construction it expands every case into its + * concrete variants ONCE, assigning each a stable opaque id, and holds them + * grouped by case and addressable by id. Both the rendered tree and any run are + * served from this single held enumeration, so the ids the UI sees and the ids it + * runs are guaranteed to refer to the same tests. + * + * Running a selected subset streams per-test notifications through a + * `TestRunObserver`; each test rebuilds its configuration fresh, so tests do not + * interfere and a failure does not stop the rest. + */ +export class TestSuite { + private readonly groups: Group[]; + private readonly byId: Map; + + constructor(cases: readonly TestCase[]) { + this.groups = TestSuite.enumerateGroups(cases); + this.byId = new Map(this.groups.flatMap((group) => group.tests.map((test) => [test.id, test]))); + } + + /** The serialisable tree (groups and their tests) for the UI to render. */ + tree(): TestTree { + return { + groups: this.groups.map((group) => ({ + identifier: group.identifier, + description: group.description, + tests: group.tests.map((test) => ({ id: test.id, name: test.name })), + })), + }; + } + + /** All test ids, in enumeration order (e.g. for a "run all"). */ + allIds(): string[] { + return this.groups.flatMap((group) => group.tests.map((test) => test.id)); + } + + /** + * Runs the tests with the given `ids` (unknown ids are skipped), reporting each + * test's start and finish through `observer`, in the given order. + */ + async run(ids: readonly string[], observer: TestRunObserver): Promise { + let posY = 0; + for (const id of ids) { + const test = this.byId.get(id); + if (test === undefined) continue; + observer.onTestStarted(id); + const result = await test.run(posY); + posY += 150; // advance y-position for next test + observer.onTestFinished(id, result); + } + } + + /** Expands each case into a group of runnable tests with stable ids. */ + private static enumerateGroups(cases: readonly TestCase[]): Group[] { + return cases.map((testCase, caseIndex) => { + const operations = testCase.operation.enumerateVariants(); + const tests = operations.map((operation, i) => { + return new RunnableTest(`t${caseIndex}_${i}`, `instance #${i + 1}`, operation); + }); + return { identifier: testCase.identifier, description: testCase.description, tests }; + }); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestTree.ts b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestTree.ts new file mode 100644 index 0000000000..41c8f60288 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/test-suite/TestTree.ts @@ -0,0 +1,26 @@ +/** + * The serialisable structure of the enumerated test suite, for the UI to render + * before anything runs. Groups correspond to test cases; each group lists its + * tests (enumerated variants) by stable id and display name. Ids are opaque — the + * UI uses them as keys and in run requests, and never parses them; grouping is + * driven by this structure, not by the id format. + */ +export interface TestTree { + groups: TestGroupInfo[]; +} + +/** One group in the tree: a test case and its enumerated tests. */ +export interface TestGroupInfo { + /** The case's CamelCase identifier (leads the group header). */ + identifier: string; + /** The case's plain-terms description (shown next to / below the header). */ + description: string; + /** The group's tests, in enumeration order. */ + tests: TestInfo[]; +} + +/** One test in the tree: its stable id and display name. */ +export interface TestInfo { + id: string; + name: string; +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/util/Assert.ts b/plugins/apps/composable-test-suite/src/composable-tests/util/Assert.ts new file mode 100644 index 0000000000..8d7f465e7d --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/util/Assert.ts @@ -0,0 +1,33 @@ +/** + * Signals a failed test assertion. Thrown by assertion helpers and caught by the + * test runner, which turns it into a failed result. + */ +export class AssertionError extends Error { + constructor(message: string) { + super(message); + this.name = "AssertionError"; + } +} + +/** + * Provides assertion helpers for use inside a test's assertion phase. Each helper + * throws an `AssertionError` on failure; the runner catches it. + */ +export class Assert { + /** Asserts that `condition` holds, failing with `message` otherwise. */ + static that(condition: boolean, message: string): void { + if (!condition) { + throw new AssertionError(message); + } + } + + /** + * Asserts that `actual` equals `expected` using `equals`, failing with a + * message that includes both values (rendered via `render`). + */ + static equal(actual: T, expected: T, equals: (a: T, b: T) => boolean, render: (v: T) => string = String): void { + if (!equals(actual, expected)) { + throw new AssertionError(`expected ${render(expected)} but was ${render(actual)}`); + } + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts b/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts new file mode 100644 index 0000000000..a2334ce832 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts @@ -0,0 +1,17 @@ +/** + * Timing utilities for the composable tests. + */ +export class PenpotSync { + /** The fixed settling delay, in milliseconds, used by `awaitPropagation`. */ + private static readonly PROPAGATION_MS = 200; + + /** + * Waits for component-change propagation to settle. Reads can otherwise race + * ahead of the propagation a preceding edit triggers. This is a pragmatic + * fixed delay; it is intended to be replaced once the Plugin API offers an + * explicit "wait for propagation" primitive. + */ + static awaitPropagation(): Promise { + return new Promise((resolve) => setTimeout(resolve, PenpotSync.PROPAGATION_MS)); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/util/ShapeUtil.ts b/plugins/apps/composable-test-suite/src/composable-tests/util/ShapeUtil.ts new file mode 100644 index 0000000000..1811f19143 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/util/ShapeUtil.ts @@ -0,0 +1,30 @@ +import { Shape } from "@penpot/plugin-types"; + +/** + * Tree-search helpers over the Penpot shape hierarchy. + */ +export class ShapeUtil { + /** + * Returns the first shape in `root`'s subtree (excluding `root` itself) that + * satisfies `predicate`, searching depth-first, or `null` if none match. + */ + static findShape(root: Shape, predicate: (shape: Shape) => boolean): Shape | null { + const children = ShapeUtil.childrenOf(root); + for (const child of children) { + if (predicate(child)) { + return child; + } + const found = ShapeUtil.findShape(child, predicate); + if (found !== null) { + return found; + } + } + return null; + } + + /** The children of `shape`, or an empty array if it has none. */ + private static childrenOf(shape: Shape): Shape[] { + const container = shape as { children?: Shape[] }; + return container.children ?? []; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/util/SlotIntegrity.ts b/plugins/apps/composable-test-suite/src/composable-tests/util/SlotIntegrity.ts new file mode 100644 index 0000000000..9db70c6b5e --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/util/SlotIntegrity.ts @@ -0,0 +1,55 @@ +import { Board, Shape } from "@penpot/plugin-types"; +import { Assert } from "./Assert"; + +/** + * Checks the positional swap-slot invariant that Penpot's file validator enforces + * for component copies. + * + * Background: in a copy of a component, each nested sub-instance head must, by + * position, reference (its `shape-ref`) the child at the SAME index in the + * component's main. Penpot finds the "near match" purely by position + * (`find-near-match` in `common/.../types/file.cljc`) and then requires a swap + * slot whenever a sub-head's `shape-ref` is not that positional near match + * (`check-required-swap-slot` in `common/.../files/validate.cljc`, error + * `:missing-slot` — "Shape has been swapped, should have swap slot"). + * + * When the copy's child order diverges from the main's (e.g. after deleting one + * sub-head, which shifts the rest) while their shape-refs still point to their + * original slots, the invariant breaks and — with no swap slot recorded — the file + * fails referential-integrity validation, crashing the project. + * + * This helper replicates that positional check through the Plugin API so a test + * can assert the invariant holds. + */ +export class SlotIntegrity { + /** + * Returns the indices of `copyRoot`'s children whose `shape-ref` does not point + * to the child at the same index in `mainRoot` (the positional near match) — + * i.e. the sub-heads that would require a swap slot. + */ + static misalignedIndices(copyRoot: Board, mainRoot: Board): number[] { + const copyKids: Shape[] = copyRoot.children ?? []; + const mainKids: Shape[] = mainRoot.children ?? []; + const bad: number[] = []; + copyKids.forEach((child, i) => { + const nearMatch = mainKids[i]; + if (!nearMatch) return; // no positional near match -> no slot required + const ref = child.componentRefShape ? child.componentRefShape() : null; + if (!ref || ref.id !== nearMatch.id) bad.push(i); + }); + return bad; + } + + /** + * Asserts that every sub-head of `copyRoot` references its positional slot in + * `mainRoot`; fails (listing the offending indices) if any does not. + */ + static assertAligned(copyRoot: Board, mainRoot: Board): void { + const bad = SlotIntegrity.misalignedIndices(copyRoot, mainRoot); + Assert.that( + bad.length === 0, + `copy sub-heads must reference their positional slot in the main; ` + + `mismatch (missing swap slot) at indices [${bad.join(", ")}]` + ); + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/util/VariantsUtil.ts b/plugins/apps/composable-test-suite/src/composable-tests/util/VariantsUtil.ts new file mode 100644 index 0000000000..45cef353ab --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/util/VariantsUtil.ts @@ -0,0 +1,81 @@ +import { Board, VariantContainer } from "@penpot/plugin-types"; + +/** One component to combine into a variant container: its board and property values. */ +export interface VariantComponentSpec { + /** The main-instance board on the canvas. */ + shape: Board; + /** This component's variant property values, as `{ propertyName: value }`. */ + properties: Record; +} + +/** + * Helpers for building variant containers through the Plugin API. + */ +export class VariantsUtil { + /** + * Creates a variant container from a list of main component instances and + * configures its property names and values in one call. Encapsulates the + * multi-step workflow: combine the components, name the properties, then set + * each component's property values. + * + * @param components - the main-instance boards to combine, each with the + * variant property values that identify it + * @returns the newly created variant container + */ + static create(components: readonly VariantComponentSpec[]): VariantContainer { + // collect all property names in first-seen order + const propNames: string[] = []; + for (const { properties } of components) { + for (const name of Object.keys(properties)) { + if (!propNames.includes(name)) propNames.push(name); + } + } + + // combine the components into a variant container + // (createVariantFromComponents is not in the pinned plugin-types, but exists at runtime) + const container = ( + penpot as unknown as { + createVariantFromComponents(shapes: Board[]): VariantContainer; + } + ).createVariantFromComponents(components.map((c) => c.shape)); + + const variants = container.variants; + if (variants === null) { + throw new Error( + "createVariantContainer: the created container has no `variants`; " + + "ensure every provided shape is a main component instance." + ); + } + + // name the properties (createVariantFromComponents starts with exactly one) + for (let i = 0; i < propNames.length; i++) { + if (i === 0) { + variants.renameProperty(0, propNames[0]); + } else { + variants.addProperty(); + variants.renameProperty(i, propNames[i]); + } + } + + // Set each component's property values driven by the INPUT specs, NOT by + // variants.variantComponents(): createVariantFromComponents reorders the + // variant components unpredictably relative to the input shapes, so iterating + // its output and pairing by position writes values onto the wrong members. + // Each input shape's id is preserved, so its own component() is the matching + // variant component — set its properties directly. + for (const { shape, properties } of components) { + const comp = shape.component(); + if (comp === null || !penpot.utils.types.isVariantComponent(comp)) { + throw new Error(`source shape "${shape.name}" is not a variant component after combining`); + } + for (let propIdx = 0; propIdx < propNames.length; propIdx++) { + const value = properties[propNames[propIdx]]; + if (value !== undefined) { + comp.setVariantProperty(propIdx, value); + } + } + } + + return container; + } +} diff --git a/plugins/apps/composable-test-suite/src/main.ts b/plugins/apps/composable-test-suite/src/main.ts new file mode 100644 index 0000000000..2013c49e50 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/main.ts @@ -0,0 +1,387 @@ +// The plugin UI (runs in the iframe; has the DOM, talks to the sandbox via +// postMessage). It renders the enumerated test tree as selectable, foldable rows +// grouped by case, and updates each row in place — addressed by stable id — as the +// sandbox streams test start/finish notifications. + +import "./style.css"; + +// ── theme (plugin-styles colour variables are gated on [data-theme]) ─────────── +function applyTheme(theme: string | null): void { + document.documentElement.setAttribute("data-theme", theme === "light" ? "light" : "dark"); +} +applyTheme(new URLSearchParams(window.location.search).get("theme")); + +// ── messages exchanged with the sandbox ──────────────────────────────────────── +interface TestInfo { + id: string; + name: string; +} +interface TestGroupInfo { + identifier: string; + description: string; + tests: TestInfo[]; +} +interface TestTree { + groups: TestGroupInfo[]; +} +interface ResultMessage { + passed: boolean; + errorMessage?: string; + transcript: string[]; +} + +type TestState = "idle" | "scheduled" | "running" | "passed" | "failed"; + +// ── a single test row ─────────────────────────────────────────────────────────── +class TestRowView { + readonly id: string; + readonly element: HTMLElement; + private readonly checkbox: HTMLInputElement; + private readonly circle: HTMLElement; + private readonly status: HTMLElement; + private readonly details: HTMLElement; + private state: TestState = "idle"; + private expanded = false; + + /** + * @param info - the test's wire info (stable id and display name) + * @param identifier - the test's composite case identifier (the case's + * identifier plus the 1-based index, e.g. "MainEditSyncs-2"); assigned + * to the checkbox as its DOM id, for direct addressing + */ + constructor(info: TestInfo, identifier: string) { + this.id = info.id; + + this.element = document.createElement("div"); + this.element.className = "row test-row"; + + const header = document.createElement("div"); + header.className = "row-header"; + + this.checkbox = document.createElement("input"); + this.checkbox.type = "checkbox"; + this.checkbox.className = "checkbox-input"; + this.checkbox.id = identifier; + + this.circle = document.createElement("span"); + this.circle.className = "status-circle"; + + const name = document.createElement("span"); + name.className = "row-name body-s"; + name.textContent = info.name; + + this.status = document.createElement("span"); + this.status.className = "row-status body-s"; + + // clicking the row (but not the checkbox) toggles the details fold + header.addEventListener("click", (event) => { + if (event.target !== this.checkbox) this.toggle(); + }); + + header.append(this.checkbox, this.circle, name, this.status); + + this.details = document.createElement("div"); + this.details.className = "row-details"; + this.details.hidden = true; + this.showPlaceholder(); + + this.element.append(header, this.details); + this.setState("idle"); + } + + get selected(): boolean { + return this.checkbox.checked; + } + + set selected(value: boolean) { + this.checkbox.checked = value; + } + + onSelectionChange(handler: () => void): void { + this.checkbox.addEventListener("change", handler); + } + + /** Sets the visual state (drives the status circle and the right-side label). */ + setState(state: TestState): void { + this.state = state; + this.element.dataset.state = state; + this.circle.dataset.state = state; + this.status.textContent = state === "scheduled" ? "Pending…" : state === "running" ? "Running…" : ""; + // a (re)scheduled test has no current result; show the placeholder again + if (state === "scheduled") this.showPlaceholder(); + } + + /** Shows the "not run yet" placeholder in the details fold. */ + private showPlaceholder(): void { + this.details.replaceChildren(); + const hint = document.createElement("div"); + hint.className = "details-hint body-s"; + hint.textContent = "Run this test to see details."; + this.details.appendChild(hint); + } + + /** Fills the fold with this test's applied steps and any failure message. */ + setDetails(result: ResultMessage): void { + this.setState(result.passed ? "passed" : "failed"); + this.details.replaceChildren(); + + if (!result.passed && result.errorMessage) { + const error = document.createElement("div"); + error.className = "details-error body-s"; + error.textContent = result.errorMessage; + this.details.appendChild(error); + } + + const steps = document.createElement("ol"); + steps.className = "details-steps body-s"; + for (const step of result.transcript) { + const item = document.createElement("li"); + item.textContent = step; + steps.appendChild(item); + } + this.details.appendChild(steps); + } + + isPassed(): boolean { + return this.state === "passed"; + } + isFailed(): boolean { + return this.state === "failed"; + } + /** Indicates whether the test takes part in a run that is still in flight. */ + isActive(): boolean { + return this.state === "running" || this.state === "scheduled"; + } + + private toggle(): void { + this.expanded = !this.expanded; + this.details.hidden = !this.expanded; + this.element.dataset.expanded = String(this.expanded); + } +} + +// ── a group of rows (one test case) ───────────────────────────────────────────── +class GroupView { + readonly element: HTMLElement; + readonly rows: TestRowView[]; + private readonly checkbox: HTMLInputElement; + private readonly circle: HTMLElement; + private readonly count: HTMLElement; + private readonly passFail: HTMLElement; + private readonly caret: HTMLElement; + private readonly rowsContainer: HTMLElement; + private expanded = false; + + constructor(info: TestGroupInfo, onSelectionChange: () => void) { + this.rows = info.tests.map((test, i) => new TestRowView(test, `${info.identifier}-${i + 1}`)); + + this.element = document.createElement("div"); + this.element.className = "group"; + + const header = document.createElement("div"); + header.className = "group-header"; + + this.caret = document.createElement("span"); + this.caret.className = "caret"; + + this.checkbox = document.createElement("input"); + this.checkbox.type = "checkbox"; + this.checkbox.className = "checkbox-input"; + this.checkbox.id = info.identifier; + this.checkbox.addEventListener("change", () => { + for (const row of this.rows) row.selected = this.checkbox.checked; + onSelectionChange(); + }); + + this.circle = document.createElement("span"); + this.circle.className = "status-circle"; + + const identifier = document.createElement("span"); + identifier.className = "group-name body-s"; + identifier.textContent = info.identifier; + + this.count = document.createElement("span"); + this.count.className = "group-count body-s"; + this.count.textContent = `[${info.tests.length} tests]`; + + // passed / failed counts, kept current by refresh() + this.passFail = document.createElement("span"); + this.passFail.className = "group-passfail body-s"; + + // clicking the header (but not the checkbox) folds the group open/closed + header.addEventListener("click", (event) => { + if (event.target !== this.checkbox) this.toggle(); + }); + + header.append(this.caret, this.checkbox, this.circle, identifier, this.count, this.passFail); + + // the full description, shown as its own box in the unfolded section + const description = document.createElement("div"); + description.className = "group-description body-s"; + description.textContent = info.description; + + this.rowsContainer = document.createElement("div"); + this.rowsContainer.className = "group-rows"; + this.rowsContainer.appendChild(description); + for (const row of this.rows) { + row.onSelectionChange(onSelectionChange); + this.rowsContainer.appendChild(row.element); + } + + this.element.append(header, this.rowsContainer); + this.setExpanded(false); + this.refresh(); + } + + /** Expands or collapses the group's rows. */ + private toggle(): void { + this.setExpanded(!this.expanded); + } + + private setExpanded(expanded: boolean): void { + this.expanded = expanded; + this.rowsContainer.hidden = !expanded; + this.element.dataset.expanded = String(expanded); + } + + /** Updates the group checkbox, aggregate circle, and count from its rows. */ + refresh(): void { + const selected = this.rows.filter((r) => r.selected).length; + this.checkbox.checked = selected === this.rows.length && this.rows.length > 0; + this.checkbox.indeterminate = selected > 0 && selected < this.rows.length; + + const passed = this.rows.filter((r) => r.isPassed()).length; + const failed = this.rows.filter((r) => r.isFailed()).length; + this.passFail.textContent = `${passed} / ${failed}`; + // while any of the group's tests is still running or queued, the aggregate + // shows "in progress"; the final verdict appears once the run settles + const active = this.rows.some((r) => r.isActive()); + this.circle.dataset.state = active + ? "running" + : failed > 0 + ? "failed" + : passed === this.rows.length && passed > 0 + ? "passed" + : "idle"; + } +} + +// ── controller: builds the tree, wires selection and the run buttons ──────────── +class TestPanel { + private readonly groups: GroupView[] = []; + private readonly rowsById = new Map(); + private readonly treeEl = document.getElementById("tree") as HTMLElement; + private readonly summaryEl = document.getElementById("summary") as HTMLElement; + private readonly runAllBtn = document.getElementById("run-all-btn") as HTMLButtonElement; + private readonly runSelectedBtn = document.getElementById("run-selected-btn") as HTMLButtonElement; + private readonly clearSelectionBtn = document.getElementById("clear-selection-btn") as HTMLButtonElement; + + constructor() { + this.runAllBtn.addEventListener("click", () => this.run(this.allIds())); + this.runSelectedBtn.addEventListener("click", () => this.run(this.selectedIds())); + this.clearSelectionBtn.addEventListener("click", () => this.clearSelection()); + } + + /** Deselects every test and updates the group aggregates. */ + private clearSelection(): void { + for (const row of this.rowsById.values()) row.selected = false; + this.onSelectionChange(); + } + + /** Renders the enumerated tree (all tests idle, none selected). */ + render(tree: TestTree): void { + this.groups.length = 0; + this.rowsById.clear(); + this.treeEl.replaceChildren(); + + for (const groupInfo of tree.groups) { + const group = new GroupView(groupInfo, () => this.onSelectionChange()); + this.groups.push(group); + for (const row of group.rows) this.rowsById.set(row.id, row); + this.treeEl.appendChild(group.element); + } + this.updateSummary(); + } + + markRunning(id: string): void { + this.rowsById.get(id)?.setState("running"); + this.refresh(); + } + + markFinished(id: string, result: ResultMessage): void { + this.rowsById.get(id)?.setDetails(result); + this.refresh(); + } + + runFinished(): void { + this.setRunning(false); + this.refresh(); + } + + private run(ids: string[]): void { + if (ids.length === 0) return; + this.setRunning(true); + for (const id of ids) this.rowsById.get(id)?.setState("scheduled"); + this.refresh(); + parent.postMessage({ type: "run-tests", ids }, "*"); + } + + private setRunning(running: boolean): void { + this.runAllBtn.disabled = running; + this.runSelectedBtn.disabled = running; + } + + private allIds(): string[] { + return [...this.rowsById.keys()]; + } + + private selectedIds(): string[] { + return [...this.rowsById.values()].filter((r) => r.selected).map((r) => r.id); + } + + private onSelectionChange(): void { + for (const group of this.groups) group.refresh(); + } + + private refresh(): void { + for (const group of this.groups) group.refresh(); + this.updateSummary(); + } + + private updateSummary(): void { + const rows = [...this.rowsById.values()]; + const passed = rows.filter((r) => r.isPassed()).length; + const failed = rows.filter((r) => r.isFailed()).length; + this.summaryEl.textContent = `${rows.length} tests · ${passed} passed · ${failed} failed`; + } +} + +// ── wire up ───────────────────────────────────────────────────────────────────── +const panel = new TestPanel(); + +window.addEventListener("message", (event: MessageEvent) => { + const data = event.data; + if (typeof data !== "object" || data === null) return; + + switch (data.type) { + case "theme": + applyTheme(data.theme as string); + break; + case "test-tree": + panel.render(data.tree as TestTree); + break; + case "test-started": + panel.markRunning(data.id as string); + break; + case "test-finished": + panel.markFinished(data.id as string, data.result as ResultMessage); + break; + case "run-complete": + case "run-error": + panel.runFinished(); + break; + } +}); + +// tell the sandbox we are ready to receive the tree +parent.postMessage({ type: "ready" }, "*"); diff --git a/plugins/apps/composable-test-suite/src/plugin.ts b/plugins/apps/composable-test-suite/src/plugin.ts new file mode 100644 index 0000000000..c51a015364 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/plugin.ts @@ -0,0 +1,59 @@ +import { createTestSuite, TestSuite, TestRunObserver, TestResult } from "./composable-tests"; + +// The plugin sandbox: it has the `penpot` API but no DOM. It enumerates the test +// suite once, sends the tree to the UI to render, and runs whichever tests the UI +// selects — forwarding each test's start/finish (addressed by stable id) so the UI +// can update the matching row. + +penpot.ui.open("Composable Tests", `?theme=${penpot.theme}`, { width: 420, height: 640 }); + +penpot.on("themechange", (theme) => { + penpot.ui.sendMessage({ type: "theme", theme }); +}); + +// enumerate once; the tree the UI renders and the runs it requests share these ids +const suite: TestSuite = createTestSuite(); + +/** Sends the enumerated tree for the UI to render (all tests, not yet run). */ +function sendTree(): void { + penpot.ui.sendMessage({ type: "test-tree", tree: suite.tree() }); +} + +/** A `TestRunObserver` that forwards each test's state change to the UI by id. */ +class UiReportingObserver implements TestRunObserver { + onTestStarted(id: string): void { + penpot.ui.sendMessage({ type: "test-started", id }); + } + + onTestFinished(id: string, result: TestResult): void { + penpot.ui.sendMessage({ + type: "test-finished", + id, + result: { + passed: result.passed, + errorMessage: result.errorMessage, + transcript: [...result.transcript], + }, + }); + } +} + +penpot.ui.onMessage<{ type?: string; ids?: string[] }>((message) => { + if (typeof message !== "object" || message === null) return; + + if (message.type === "ready") { + sendTree(); + } else if (message.type === "run-tests") { + const ids = message.ids ?? []; + suite + .run(ids, new UiReportingObserver()) + .then(() => penpot.ui.sendMessage({ type: "run-complete" })) + .catch((error: unknown) => { + const errorMessage = error instanceof Error ? error.message : String(error); + penpot.ui.sendMessage({ type: "run-error", errorMessage }); + }); + } +}); + +// in case the UI loaded before this handler was registered, also send the tree now +sendTree(); diff --git a/plugins/apps/composable-test-suite/src/style.css b/plugins/apps/composable-test-suite/src/style.css new file mode 100644 index 0000000000..4330de7903 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/style.css @@ -0,0 +1,190 @@ +@import "@penpot/plugin-styles/styles.css"; + +body { + margin: 0; + padding: 0; +} + +.plugin-container { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12) var(--spacing-8); + box-sizing: border-box; + color: var(--foreground-primary); + user-select: none; + -webkit-user-select: none; +} + +/* ── toolbar + summary ──────────────────────────────────────────── */ + +.toolbar { + display: flex; + gap: var(--spacing-8); +} + +.summary { + font-weight: 600; + color: var(--foreground-secondary); +} + +/* ── tree / groups ──────────────────────────────────────────────── */ + +.tree { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.group { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.group-header, +.row-header { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8); + border-radius: var(--spacing-8); + background-color: var(--background-tertiary); +} + +.group-header { + background-color: var(--background-quaternary); + font-weight: 600; +} + +.caret { + flex: 0 0 auto; + width: 8px; + color: var(--foreground-secondary); + transition: transform 0.1s ease; +} + +.caret::before { + content: "▸"; +} + +.group[data-expanded="true"] .caret { + transform: rotate(90deg); +} + +.row-header { + cursor: pointer; +} + +.row-header:hover { + background-color: var(--background-quaternary); +} + +.group-rows { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-left: var(--spacing-16); +} + +/* the [hidden] attribute must win over the display rules above */ +.group-rows[hidden], +.row-details[hidden] { + display: none; +} + +.row-name { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.group-name { + flex: 0 0 auto; + white-space: nowrap; +} + +/* passed / failed counts, at the right edge of the group header */ +.group-passfail { + flex: 0 0 auto; + margin-left: auto; + font-weight: 400; + color: var(--foreground-secondary); +} + +/* the full description in the unfolded section: its own box, wrapping freely + and growing to whatever height the text needs */ +.group-description { + padding: var(--spacing-8); + border-radius: var(--spacing-8); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + white-space: normal; +} + +.group-count, +.row-status { + flex: 0 0 auto; + color: var(--foreground-secondary); +} + +.row-status { + font-style: italic; +} + +/* ── status circle ──────────────────────────────────────────────── */ + +.status-circle { + flex: 0 0 auto; + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--background-quaternary); + border: 1px solid var(--foreground-secondary); +} + +/* "in progress": the theme's accent (as used by the buttons) — neutral between + passed (green) and failed (red); the palette's --warning-500 is a red-orange + that reads as a failure */ +.status-circle[data-state="running"] { + background-color: var(--accent-primary); + border-color: var(--accent-primary); +} + +.status-circle[data-state="passed"] { + background-color: var(--success-500); + border-color: var(--success-500); +} + +.status-circle[data-state="failed"] { + background-color: var(--error-500); + border-color: var(--error-500); +} + +/* ── fold-out details ───────────────────────────────────────────── */ + +.row-details { + padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) var(--spacing-24); + color: var(--foreground-secondary); +} + +.details-hint { + color: var(--foreground-secondary); + font-style: italic; +} + +.details-error { + color: var(--error-500); + white-space: pre-wrap; + margin-bottom: var(--spacing-8); +} + +.details-steps { + margin: 0; + padding-left: var(--spacing-16); +} + +.details-steps li { + margin: 0; +} diff --git a/plugins/apps/composable-test-suite/tsconfig.json b/plugins/apps/composable-test-suite/tsconfig.json new file mode 100644 index 0000000000..25d8ea13f3 --- /dev/null +++ b/plugins/apps/composable-test-suite/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "typeRoots": ["./node_modules/@types", "./node_modules/@penpot"], + "types": ["plugin-types"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/plugins/apps/composable-test-suite/vite.config.headless.ts b/plugins/apps/composable-test-suite/vite.config.headless.ts new file mode 100644 index 0000000000..e373c5303a --- /dev/null +++ b/plugins/apps/composable-test-suite/vite.config.headless.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; + +// Builds the CI test entry (src/ci/headless.ts) as a single self-executing +// (IIFE) bundle with no import/export statements, so it can be evaluated +// directly inside the Penpot plugin sandbox via `globalThis.ɵloadPlugin({ +// code })` by the CI driver (ci/run-ci.ts). `emptyOutDir` stays false so this +// build never wipes the regular plugin outputs in dist/. +export default defineConfig({ + base: "./", + build: { + outDir: "dist", + emptyOutDir: false, + reportCompressedSize: false, + rollupOptions: { + input: { + headless: "src/ci/headless.ts", + }, + output: { + format: "iife", + entryFileNames: "[name].js", + }, + }, + }, +}); diff --git a/plugins/apps/composable-test-suite/vite.config.ts b/plugins/apps/composable-test-suite/vite.config.ts new file mode 100644 index 0000000000..d8062cc22e --- /dev/null +++ b/plugins/apps/composable-test-suite/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from "vite"; +import livePreview from "vite-live-preview"; + +// Builds the plugin sandbox entry (plugin.js) and the UI (index.html) into dist/, +// alongside the static manifest + icon copied from public/. The live-preview +// plugin serves the built dist/ (so the manifest's `plugin.js` resolves) and +// reloads on rebuild, giving a watch-build-serve dev loop via `pnpm start`. +export default defineConfig({ + base: "./", + plugins: [ + livePreview({ + reload: true, + config: { + build: { + sourcemap: true, + }, + }, + }), + ], + build: { + rollupOptions: { + input: { + plugin: "src/plugin.ts", + index: "./index.html", + }, + output: { + entryFileNames: "[name].js", + }, + }, + }, + preview: { + // 4202 is the conventional dev port shared by the plugins in this workspace + port: 4202, + cors: true, + }, +}); diff --git a/plugins/package.json b/plugins/package.json index bfb9f1fcdc..77d9c5db54 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -18,8 +18,9 @@ "start:plugin:colors-to-tokens": "pnpm --filter colors-to-tokens-plugin run init", "start:plugin:poc-tokens": "pnpm --filter poc-tokens-plugin run init", "start:plugin:api-test-suite": "pnpm --filter plugin-api-test-suite run init", + "start:plugin:composable-test-suite": "pnpm --filter composable-test-suite run init", "build:runtime": "pnpm --filter @penpot/plugins-runtime build", - "build:plugins": "pnpm --parallel --filter './apps/*-plugin' --filter plugin-api-test-suite --filter '!poc-state-plugin' build", + "build:plugins": "pnpm --parallel --filter './apps/*-plugin' --filter './apps/*-test-suite' --filter '!poc-state-plugin' build", "build:styles-example": "pnpm --filter example-styles build", "lint": "pnpm -r --parallel lint", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,html,css}\"", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index 96379796c5..ec6129238c 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -200,6 +200,31 @@ importers: apps/colors-to-tokens-plugin: {} + apps/composable-test-suite: + dependencies: + '@penpot/plugin-styles': + specifier: 1.4.1 + version: 1.4.1 + '@penpot/plugin-types': + specifier: 1.4.1 + version: 1.4.1 + devDependencies: + playwright: + specifier: ^1.61.1 + version: 1.61.1 + prettier: + specifier: ^3.6.2 + version: 3.9.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vite: + specifier: ^7.0.8 + version: 7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.97.3)(sass@1.99.0)(terser@5.46.2)(tsx@4.22.4)(yaml@2.9.0) + vite-live-preview: + specifier: ^0.3.2 + version: 0.3.2(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.97.3)(sass@1.99.0)(terser@5.46.2)(tsx@4.22.4)(yaml@2.9.0)) + apps/contrast-plugin: {} apps/create-palette-plugin: {} @@ -584,6 +609,11 @@ packages: '@bufbuild/protobuf@2.12.1': resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + '@commander-js/extra-typings@12.1.0': + resolution: {integrity: sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==} + peerDependencies: + commander: ~12.1.0 + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -1567,6 +1597,12 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@penpot/plugin-styles@1.4.1': + resolution: {integrity: sha512-6TuJqKQsq1Xmhn2A02R+kCOzIzIdqgFg5z6ncLH2PlAflKIX6aYsGiOF7yFx4RYgCegRVMFPnVis6/hwO+YGQg==} + + '@penpot/plugin-types@1.4.1': + resolution: {integrity: sha512-pHE2B3GI8M5JR03S/NdBoN+z6e1R1IEh3vpFbLG9LN0EZpQE6nEbmCo5jWAWI73Jqlg6CHG/RWVJNmWECnkDTA==} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2124,12 +2160,18 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/ansi-html@0.0.0': + resolution: {integrity: sha512-PEBpUlteD0VW02udY7UjjgjxHwVXmkdanhmRIMkzatGmORJGjzqKylrXVxz1G5xRTEECMxIkwTHpPmZ9Jb7ANQ==} + '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2160,6 +2202,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} @@ -2436,6 +2481,11 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} + ansi-html@0.0.9: + resolution: {integrity: sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==} + engines: {'0': node >= 0.8.0} + hasBin: true + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -2665,6 +2715,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2928,6 +2982,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-goat@4.0.0: + resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} + engines: {node: '>=12'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -4082,6 +4140,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-defer@4.0.1: + resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} + engines: {node: '>=12'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -4915,6 +4977,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -5007,6 +5074,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-live-preview@0.3.2: + resolution: {integrity: sha512-NrmGaAc85qvkx/+6FluiTo9rLnoY+/NOYnuUvcW5Yb5tSJzUxuloXYrCSS1dtxQB9YKUbpQ95JCb0GRuF//JEQ==} + hasBin: true + peerDependencies: + vite: '>=5.2.13' + vite-plugin-checker@0.14.4: resolution: {integrity: sha512-Tw0U9UgHIRiZ+Yoe4Gh0RrYoBiCVmO9j4tomVdYr0KUjUsqXMPhqW8ouoSWmOzGp5Iimipbl3bNXZcK7OeP7Qg==} engines: {node: '>=20.19.0'} @@ -5798,6 +5871,10 @@ snapshots: '@bufbuild/protobuf@2.12.1': optional: true + '@commander-js/extra-typings@12.1.0(commander@12.1.0)': + dependencies: + commander: 12.1.0 + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -6572,6 +6649,10 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true + '@penpot/plugin-styles@1.4.1': {} + + '@penpot/plugin-types@1.4.1': {} + '@polka/url@1.0.0-next.29': {} '@puppeteer/browsers@3.0.5': @@ -6972,6 +7053,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/ansi-html@0.0.0': {} + '@types/argparse@1.0.38': optional: true @@ -6980,6 +7063,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -7010,6 +7097,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/ms@2.1.0': {} + '@types/node@26.0.1': dependencies: undici-types: 8.3.0 @@ -7393,6 +7482,8 @@ snapshots: dependencies: environment: 1.1.0 + ansi-html@0.0.9: {} + ansi-regex@6.2.2: {} ansi-styles@6.2.3: {} @@ -7676,6 +7767,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@12.1.0: {} + commander@2.20.3: optional: true @@ -8033,6 +8126,8 @@ snapshots: escalade@3.2.0: {} + escape-goat@4.0.0: {} + escape-html@1.0.3: {} escape-string-regexp@4.0.0: {} @@ -9353,6 +9448,8 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-defer@4.0.1: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -10318,6 +10415,8 @@ snapshots: typescript@5.8.2: optional: true + typescript@5.9.3: {} + typescript@6.0.3: {} uc.micro@2.1.0: {} @@ -10389,6 +10488,25 @@ snapshots: vary@1.1.2: {} + vite-live-preview@0.3.2(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.97.3)(sass@1.99.0)(terser@5.46.2)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@commander-js/extra-typings': 12.1.0(commander@12.1.0) + '@types/ansi-html': 0.0.0 + '@types/debug': 4.1.13 + '@types/ws': 8.18.1 + ansi-html: 0.0.9 + chalk: 5.6.2 + commander: 12.1.0 + debug: 4.4.3 + escape-goat: 4.0.0 + p-defer: 4.0.1 + vite: 7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.97.3)(sass@1.99.0)(terser@5.46.2)(tsx@4.22.4)(yaml@2.9.0) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + vite-plugin-checker@0.14.4(eslint@10.6.0(jiti@2.7.0))(optionator@0.9.4)(typescript@6.0.3)(vite@8.1.1(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.99.0)(terser@5.46.2)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@babel/code-frame': 7.29.7