* 🐛 Add regression test: main-side component edits break copy swap slots Reproduces the :missing-slot referential-integrity failure ("Shape has been swapped, should have swap slot") that crashes files with component copies. Root cause: reordering or deleting a nested sub-head IN THE MAIN of a component, while copies exist, does not propagate swap slots to the copies. find-near-match matches a copy's sub-heads to the main's children by POSITION, so once the main's order changes the copies' shape-refs no longer match their position and, lacking a swap slot, fail referential-integrity validation. - Copy-side edits are handled correctly (characterization tests, pass today). - The two main-side tests fail today with :missing-slot and go green once the sync assigns swap slots to copies on a main reorder/delete. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🐛 Fix integrity crashes from copy/main child-order divergence Copy sub-heads were matched to their main's children purely by position (find-near-match), while several code paths reorder or mutilate one side only. Any of them made file validation fail with :missing-slot ("Shape has been swapped, should have swap slot"), crashing the workspace on the next validated commit, or persisting a corrupt file whose later edits crash. Reproduced live: deleting a sub-head inside a copy (which only hides it) and then reflowing the copy's grid moved the hidden (cell-less) child to the front of :shapes, knocking every sibling out of its positional slot. Four fixes, one per divergence path: - validate/check-required-swap-slot: a sub-head whose shape-ref is still a child of the near main parent is a REORDER (the component sync realigns it), not a swap; a swap slot is required only when the ref points outside the near main parent (a real swap). This matches how the sync engine itself pairs children (by shape-ref, not by position). comp-processors/fix-missing-swap-slots (migration 0019) is aligned: adding slots to merely-reordered sub-heads would freeze them out of normal synchronization. - changes/:reorder-children now refuses to alter the child structure of component copies unless allow-altering-copies is set, mirroring the is-valid-move? rule of :mov-objects; that structure is owned by the component sync engine. Grid reflows emitted this change type with no guard. pcb/reorder-grid-children also skips copy grids producer-side. - layout/reorder-grid-children keeps children that participate in no cell (hidden or absolute positioned) at their original index instead of lumping them at the front: moving them gratuitously changed their z-order and, in copies, broke the positional matching. Note :shapes stays reversed relative to the sorted cell order for in-cell children. - logic/generate-delete-shapes: deleting shapes from inside a main (without deleting the main root, whose copies keep working against the deleted component) now also deletes the copy shapes that reference them, transitively (copies of copies) and across all pages of the file, so no dangling shape-refs remain. Skipped for allow-altering-copies flows (component swap replaces the shape and the sync reconciles copies via swap slots). Cross-page removals build redo/undo changes against that page's objects directly, since the changes-builder mounts only the current page; their undo mov-objects carry allow-altering-copies so restoring inside a copy is not rejected by the new guard. The regression tests assert the fixed semantics: main-side reorders and deletes keep copies valid, the previously crashing full chain (unvalidated main reorder + later copy edit) stays healthy, :reorder-children cannot scramble copies, and reorder-grid-children keeps cell-less children in place. The namespace is now also registered in the JS test runner. AI-assisted-by: Claude Opus 4.8 (1M context) * ✨ Extend composable slot cases to the grid-reflow crash sweep Case D (CopySubheadDeletePreservesSlots) now sweeps which copy sub-head is deleted (first or last) and whether the copy root is resized afterwards, forcing a grid reflow: the reflow used to move the hidden (cell-less) child to the front of the copy's children, shifting every sibling out of its positional slot. Deleting the FIRST sub-head masked the bug (moving it to the front is a no-op), which is why the case passed before this sweep. The foundation layout is grid accordingly. Case E (MainReorderKeepsCopySlots) no longer hangs the app now that a main-side reorder leaves a valid file, so its warning docstring is replaced: it runs as a routine test (verified headless, passing) and is safe in a "run all". SlotIntegrity's doc is updated to the new validator semantics (a slot is required only for real swaps, not reorders); the positional alignment it asserts remains the correct, stronger steady-state invariant for these cases. The lockfile change materializes the playwright devDependency already declared in package.json. AI-assisted-by: Claude Opus 4.8 (1M context) * 📚 Record copy/main order-divergence invariants in memories Swap-slot semantics (membership, not positional; slots only for real swaps), the copy-structure guards on :mov-objects/:reorder-children, the grid reorder stability for cell-less children, and the main-side delete propagation in generate-delete-shapes. AI-assisted-by: Claude Opus 4.8 (1M context) * 🐛 Add adjustements to the code --------- Co-authored-by: Michael Panchenko <michael.panchenko@oraios-ai.de> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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:
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=MainEditSyncsfor a whole case orTEST_FILTER=MainEditSyncs-2for 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.