Files
Dr. Dominik Jain caacd482af 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>
2026-07-24 09:59:18 +02:00

206 lines
9.2 KiB
Markdown

# 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.