mirror of
https://github.com/penpot/penpot.git
synced 2026-07-24 23:18:06 +00:00
✨ 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:
parent
4f46700c94
commit
caacd482af
69
.github/workflows/tests-composable-suite.yml
vendored
Normal file
69
.github/workflows/tests-composable-suite.yml
vendored
Normal 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
|
||||
@ -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`,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}))]
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
"singleQuote": true,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
|
||||
24
plugins/apps/composable-test-suite/.gitignore
vendored
Normal file
24
plugins/apps/composable-test-suite/.gitignore
vendored
Normal 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?
|
||||
20
plugins/apps/composable-test-suite/.prettierrc
Normal file
20
plugins/apps/composable-test-suite/.prettierrc
Normal 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"
|
||||
}
|
||||
205
plugins/apps/composable-test-suite/README.md
Normal file
205
plugins/apps/composable-test-suite/README.md
Normal 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.
|
||||
60
plugins/apps/composable-test-suite/ci/fixtures/get-file.json
Normal file
60
plugins/apps/composable-test-suite/ci/fixtures/get-file.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
301
plugins/apps/composable-test-suite/ci/run-ci.ts
Normal file
301
plugins/apps/composable-test-suite/ci/run-ci.ts
Normal 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);
|
||||
});
|
||||
21
plugins/apps/composable-test-suite/index.html
Normal file
21
plugins/apps/composable-test-suite/index.html
Normal 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>
|
||||
29
plugins/apps/composable-test-suite/package.json
Normal file
29
plugins/apps/composable-test-suite/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
857
plugins/apps/composable-test-suite/pnpm-lock.yaml
generated
Normal file
857
plugins/apps/composable-test-suite/pnpm-lock.yaml
generated
Normal 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: {}
|
||||
2
plugins/apps/composable-test-suite/pnpm-workspace.yaml
Normal file
2
plugins/apps/composable-test-suite/pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
BIN
plugins/apps/composable-test-suite/public/icon.jpg
Normal file
BIN
plugins/apps/composable-test-suite/public/icon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
8
plugins/apps/composable-test-suite/public/manifest.json
Normal file
8
plugins/apps/composable-test-suite/public/manifest.json
Normal 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"]
|
||||
}
|
||||
74
plugins/apps/composable-test-suite/src/ci/headless.ts
Normal file
74
plugins/apps/composable-test-suite/src/ci/headless.ts
Normal 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 }));
|
||||
});
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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));
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -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)}`;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -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])), [[]]);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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 ?? [];
|
||||
}
|
||||
}
|
||||
@ -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(", ")}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
387
plugins/apps/composable-test-suite/src/main.ts
Normal file
387
plugins/apps/composable-test-suite/src/main.ts
Normal 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" }, "*");
|
||||
59
plugins/apps/composable-test-suite/src/plugin.ts
Normal file
59
plugins/apps/composable-test-suite/src/plugin.ts
Normal 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();
|
||||
190
plugins/apps/composable-test-suite/src/style.css
Normal file
190
plugins/apps/composable-test-suite/src/style.css
Normal 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;
|
||||
}
|
||||
21
plugins/apps/composable-test-suite/tsconfig.json
Normal file
21
plugins/apps/composable-test-suite/tsconfig.json
Normal 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"]
|
||||
}
|
||||
24
plugins/apps/composable-test-suite/vite.config.headless.ts
Normal file
24
plugins/apps/composable-test-suite/vite.config.headless.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
36
plugins/apps/composable-test-suite/vite.config.ts
Normal file
36
plugins/apps/composable-test-suite/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -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
118
plugins/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user