Add composable test framework plugin (applied to component tests, runs in CI) (#10679)

*  Add plugin with composable test framework and component tests

The plugin provides a framework for writing composable tests against
the Plugin API, and applies it to systematic end-to-end testing of
component semantics.

The framework's core ideas: a test is written once as a composition of
operations over a starting configuration; choice points among the
operations (optional steps, alternatives) expand the composition into
a full sweep of test variants, so a single case definition yields
broad combinatorial coverage; and the operations drive the real Plugin
API with real change propagation, testing the full production
implementation.

The initial application is a suite of component test cases covering
synchronization, overrides, swap slots and variants — the
TypeScript/e2e continuation of the ClojureScript composable test suite
(frontend_tests.composable_tests). Several cases originate from
reproducing real defects (e.g. #10109 and the swap-slot corruptions).

Tests run from an interactive panel in Penpot: cases are listed with
plain-language descriptions, tests can be run selectively, results
stream in live, and every checkbox carries a stable DOM id — so the
panel can equally be driven programmatically (the basis for running
the suite in CI), as documented in the plugin's README.

Lives at plugins/apps/composable-test-suite as a regular member of the
plugins workspace (init script, start:plugin:composable-test-suite,
shared dev port 4202, covered by build:plugins via the new
./apps/*-test-suite filter).

Related to #10584.

AI-assisted-by: claude-fable-5

*  Run the composable test suite headlessly in CI

Adds a headless run mode for the composable test suite, following the
plugin-api-test-suite's CI architecture, and a workflow that runs it as
a per-PR gate.

An in-sandbox entry (src/ci/headless.ts) runs the suite without the
panel UI — the framework's runner was UI-free by construction, so no
refactoring was needed — and streams each result through console
markers, addressed by the same composite identifiers the panel uses
(e.g. MainEditSyncs-2), with durations and, on failure, the error and
the applied-steps transcript. It is built as a single self-executing
bundle and evaluated directly inside a real Penpot plugin sandbox by
the driver (ci/run-ci.ts), so no plugin dev server or port is involved.

The driver needs no backend and no login: it serves the prebuilt
frontend bundle via the frontend e2e static server and intercepts every
backend RPC with Playwright fixtures. The mocked backend is not a
limitation for this suite — everything it asserts is frontend store
logic executed in memory — which the full run confirms: all 48 tests
behave identically to the interactive panel, including variants and
swap slots, with the single (currently expected) failure of
MainEditSyncs-2 reproducing bug #10109 under the mock.

TEST_FILTER selects tests by identifier substring; CI_TIMEOUT_MS bounds
the run. The mock harness mirrors the frontend e2e harness (see the
provenance note in the driver).

Related to #10584.

AI-assisted-by: claude-fable-5

* 📚 Restructure the composable-tests memory around both suites

Present the composable component tests top-down: the shared framework
principles upfront, then the two implementations — the ClojureScript
suite in the frontend test tree and the TypeScript suite in the plugin,
which tests fully end-to-end with a slightly more elaborate set of
abstractions — and the plugin's headless CI run, pointing to the
plugin's README for operational details. Also records this session's
additions (geometry operations, case N, the CI harness).

AI-assisted-by: claude-fable-5

* 📎 Refine the PR-description conventions in the creating-prs memory

Encourage digestible descriptions: bullet items over prose (grouped by
area with bold lead-ins for larger PRs) and no manual line wraps, since
the rendered markdown adapts to the viewport. Also drop the outdated
'MCP' from the standard Note line.

AI-assisted-by: claude-fable-5

* 🔧 Set Prettier endOfLine to auto in plugins workspace

Prettier defaults to endOfLine "lf", which is incompatible with
checkouts on Windows that use core.autocrlf=true

* 🐛 Fix problems with suite

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
This commit is contained in:
Dr. Dominik Jain 2026-07-24 09:59:18 +02:00 committed by GitHub
parent 4f46700c94
commit caacd482af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
69 changed files with 4933 additions and 307 deletions

View File

@ -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

View File

@ -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-<uuid>` 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<Identifier>.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`,

View File

@ -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
<one paragraph: the problem or feature, user-facing impact>
<the problem or feature and its user-facing impact short bullet items where there is more than one point>
## Why
<root cause or motivation, why this change was necessary>
<root cause or motivation a short paragraph or bullets>
## How
<high-level approach, key technical decisions>
<high-level approach and key decisions bullet items, grouped by area (bold lead-ins) for larger PRs>
```
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

View File

@ -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}))]

View File

@ -1,3 +1,4 @@
{
"singleQuote": true
"singleQuote": true,
"endOfLine": "auto"
}

View File

@ -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?

View File

@ -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"
}

View File

@ -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
`<plugin-modal title="Composable Tests">` 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.

View File

@ -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
}
]
}
}

View File

@ -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 = <root>/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<string, string> = {
"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<void> {
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<Set<string>> {
const created = new Set<string>();
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<string>): Promise<void> {
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<void> {
// 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<void>((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<void>((_, 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);
});

View File

@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Composable Tests</title>
</head>
<body>
<div class="plugin-container">
<div class="toolbar">
<button type="button" id="run-all-btn" data-appearance="primary">Run all</button>
<button type="button" id="run-selected-btn" data-appearance="secondary">Run selected</button>
<button type="button" id="clear-selection-btn" data-appearance="secondary">Clear selection</button>
</div>
<div id="summary" class="summary body-s"></div>
<div id="tree" class="tree"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -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"
}
}

View File

@ -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: {}

View File

@ -0,0 +1,2 @@
ignoredBuiltDependencies:
- esbuild

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -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"]
}

View File

@ -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<string, string>();
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<string, number>();
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 }));
});

View File

@ -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(),
];
}

View File

@ -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)
)
)
);
}

View File

@ -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);
})
)
);
}

View File

@ -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);
})
)
);
}

View File

@ -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);
})
)
);
}

View File

@ -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));
})
)
);
}

View File

@ -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));
}
})
)
);
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}
}
}

View File

@ -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<T> {
/** 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;
}
}

View File

@ -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<void>;
/** 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];
}
}

View File

@ -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<T extends Shape = Shape> {
/** 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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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<string, Shape>();
private readonly appliedLog: string[] = [];
private readonly appliedIds = new Set<number>();
private readonly opData = new Map<number, unknown>();
private readonly keyedData = new Map<DataKey<unknown>, 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<T extends Shape>(role: Role<T>, 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<T extends Shape>(role: Role<T>): 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<T>(key: DataKey<T>, value: T): void {
this.keyedData.set(key as DataKey<unknown>, 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<T>(key: DataKey<T>): T | undefined {
return this.keyedData.get(key as DataKey<unknown>) as T | undefined;
}
}

View File

@ -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";

View File

@ -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}`;
}
}

View File

@ -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<T> {
/** 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<T> implements ShapeProp<T> {
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<Color> {
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<number> {
// 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);
}
}

View File

@ -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<void> {
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}`;
}
}

View File

@ -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<T> 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<T>,
private readonly value: T,
private readonly targetLabel: string = String(target)
) {
super();
}
async applyTo(situation: Situation): Promise<void> {
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)}`;
}
}

View File

@ -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<Board>("remote-instance");
readonly mainInstance = new Role<Board>("main-instance");
readonly copyInstance = new Role<Board>("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<ContentCreationStrategy>) {
super();
}
async applyTo(situation: Situation): Promise<void> {
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<ContentCreationStrategy>) {
super();
}
async applyTo(situation: Situation): Promise<void> {
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<TContentCreator extends ContentCreationStrategy> extends Operation {
readonly roles = new RolesNestableComponent();
/** Private key under which this lineage's (mutable) nesting data is stored. */
private readonly nestingDataKey = new DataKey<NestingData>("nesting-data");
/**
* @param contentCreator - creates and locates the component's content
*/
constructor(public readonly contentCreator: TContentCreator) {
super();
}
async applyTo(situation: Situation): Promise<void> {
// 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;
}
}

View File

@ -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<Shape>("main-child");
/** The copy instance's corresponding child shape. */
readonly copyChild = new Role<Shape>("copy-child");
/** The copy instance's root (the instantiated board). */
readonly copyRoot = new Role<Board>("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<void> {
// 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];
}
}

View File

@ -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<void> {
// 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);
}
}

View File

@ -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<void> {
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}`;
}
}

View File

@ -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<Board>
) {
super();
}
async applyTo(situation: Situation): Promise<void> {
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";
}
}

View File

@ -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<void> {
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());
}
}

View File

@ -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());
}
}

View File

@ -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<void> {
const shape = resolveTarget(this.target, situation);
shape.setParentIndex(this.toIndex);
}
toString(): string {
return `reorder ${this.targetLabel} to index ${this.toIndex}`;
}
}

View File

@ -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<void> {
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<T>(lists: T[][]): T[][] {
return lists.reduce<T[][]>((acc, list) => acc.flatMap((prefix) => list.map((item) => [...prefix, item])), [[]]);
}

View File

@ -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<void> {
// 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;
}
}

View File

@ -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<void> {
const head = resolveTarget(this.target, situation);
head.switchVariant(OpSwitchVariant.PROPERTY_POSITION, this.value);
}
toString(): string {
return `switch variant to ${this.value}`;
}
}

View File

@ -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<TestResult> {
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);
}
}
}

View File

@ -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
) {}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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<string, RunnableTest>;
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<void> {
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 };
});
}
}

View File

@ -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;
}

View File

@ -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<T>(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)}`);
}
}
}

View File

@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, PenpotSync.PROPAGATION_MS));
}
}

View File

@ -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 ?? [];
}
}

View File

@ -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(", ")}]`
);
}
}

View File

@ -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<string, string>;
}
/**
* 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;
}
}

View File

@ -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<string, TestRowView>();
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" }, "*");

View File

@ -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();

View File

@ -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;
}

View File

@ -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"]
}

View File

@ -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",
},
},
},
});

View File

@ -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,
},
});

View File

@ -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}\"",

118
plugins/pnpm-lock.yaml generated
View File

@ -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