mirror of
https://github.com/penpot/penpot.git
synced 2026-07-03 12:55:04 +00:00
392 lines
18 KiB
Markdown
392 lines
18 KiB
Markdown
# Plugin API Test Suite
|
|
|
|
A Penpot plugin that is a launcher + runner for a battery of tests exercising the
|
|
Penpot **Plugin API** against a live Penpot instance. It doubles as living
|
|
documentation of what the public API actually does at runtime.
|
|
|
|
- A plain TypeScript + Vite Penpot plugin living in `plugins/apps/plugin-api-test-suite`.
|
|
- The UI (an iframe) lists auto-discovered tests and lets you run all / a subset /
|
|
one. Each test shows green (pass) or red (fail, with the error message).
|
|
- It reports **API coverage**: which members of the public Plugin API the tests
|
|
exercised, measured against `libs/plugin-types/index.d.ts`.
|
|
- The same test files run both in the plugin UI and in a headless CI runner, so a
|
|
test is never written twice.
|
|
|
|
This document is the context a developer (or agent) needs to add tests. Read it
|
|
fully before writing any test.
|
|
|
|
## The one rule that matters most
|
|
|
|
> **Always call the API through `ctx.penpot`, never the global `penpot`.**
|
|
|
|
`ctx.penpot` is a recording proxy. Calls made through it are what count towards
|
|
coverage and are correctly attributed to the right interface. Calls on the global
|
|
`penpot` still work but are invisible to coverage. Same for shapes: operate on the
|
|
objects returned by `ctx.penpot.*` (and on `ctx.board`), not on objects obtained
|
|
some other way.
|
|
|
|
## Running and iterating
|
|
|
|
From `plugins/`:
|
|
|
|
- Dev server: `pnpm run start:plugin:api-test-suite` (serves on port 4202).
|
|
- In Penpot: open the Plugin Manager (Ctrl+Alt+P) and install
|
|
`http://localhost:4202/manifest.json`.
|
|
- **Hot-reloading tests:** after editing a `*.test.ts`, click **Reload** in the
|
|
plugin UI. It fetches the freshly built test bundle and swaps in your changes —
|
|
no need to close/reopen the plugin. (The dev server rebuilds the bundle on save.)
|
|
- **Adding a _new_ test file:** tests are discovered via `import.meta.glob` at
|
|
build time, and `vite build --watch` does not reliably pick up a brand-new file
|
|
(only edits to files already in its graph). After creating a new `*.test.ts`,
|
|
**restart the watch process** (`pnpm run watch` or `pnpm run init`) and then
|
|
click **Reload** (or reopen the plugin). Editing an existing test file does not
|
|
need this.
|
|
- The UI: tests are shown in **collapsible groups** (from `describe`) with per-group
|
|
passed/failed/total counts. Run with **Run all**, **Run selected** (per-test or
|
|
per-group checkboxes), the per-group **Run group**, or the per-row **Run** button.
|
|
Failures expand to show the error. The coverage panel shows the percentage, a
|
|
progress bar, and per-interface get/set/call targets.
|
|
|
|
## Running in CI
|
|
|
|
A headless runner executes the same tests against a live instance via Playwright:
|
|
|
|
```
|
|
E2E_LOGIN_EMAIL=… E2E_LOGIN_PASSWORD=… \
|
|
pnpm --filter plugin-api-test-suite run test:ci
|
|
```
|
|
|
|
- It builds `headless.js`, logs in, creates a scratch file, injects the test
|
|
bundle, and prints per-test results + the coverage report.
|
|
- Exit code is non-zero iff any test failed (coverage does not affect it).
|
|
- Optional env: `PENPOT_BASE_URL` (default `https://localhost:3449`). Against a
|
|
local devenv with a self-signed certificate, prefix the command with
|
|
`NODE_TLS_REJECT_UNAUTHORIZED=0` to avoid a `fetch failed` TLS error.
|
|
- `PRINT_UNCOVERED=1` dumps the uncovered targets per interface; `PRINT_STATIC=1`
|
|
dumps the statically-covered ones (see [Coverage](#how-coverage-works-and-how-to-write-tests-that-move-it)).
|
|
|
|
CI entry points reuse the exact same test files (`src/ci/headless.ts` discovers
|
|
them the same way the plugin does).
|
|
|
|
### Mocked-backend mode
|
|
|
|
The same runner can run without a live instance — it serves the prebuilt
|
|
frontend via the frontend e2e static server and intercepts every backend RPC
|
|
with Playwright `page.route`, reusing the frontend e2e mock fixtures:
|
|
|
|
```
|
|
pnpm --filter plugin-api-test-suite run test:ci:mocked
|
|
```
|
|
|
|
(equivalently `MOCK_BACKEND=1 … run test:ci`). No login or backend is needed.
|
|
This validates the frontend Plugin API binding + in-memory store only, so it
|
|
can't faithfully reproduce results that depend on real backend behaviour
|
|
(validation, persistence, generated ids, …). Tests that need the real backend
|
|
opt out of this mode by tagging themselves `skipIfMocked`:
|
|
|
|
```ts
|
|
test.skipIfMocked('depends on backend validation', (ctx) => {
|
|
/* … */
|
|
});
|
|
|
|
// or a whole group:
|
|
describe.skipIfMocked('Backend-dependent', () => {
|
|
/* … */
|
|
});
|
|
```
|
|
|
|
Skipped tests are listed in the runner output. The wiring (fixtures, RPC mocks,
|
|
WebSocket mock) lives in `ci/run-ci.ts`; mocked-mode fidelity is its main
|
|
limitation, so prefer the live `test:ci` for anything backend-sensitive.
|
|
|
|
## Anatomy of a test
|
|
|
|
Tests live in `src/tests/*.test.ts` and are **auto-discovered** (via
|
|
`import.meta.glob`) — just create a file matching that glob, no registration list
|
|
to update. A file registers one or more tests by calling `test(name, fn)`.
|
|
|
|
```ts
|
|
import { expect } from '../framework/expect';
|
|
import { test } from '../framework/registry';
|
|
|
|
test('creates a rectangle', (ctx) => {
|
|
const rect = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(rect);
|
|
|
|
expect(rect.type).toBe('rectangle');
|
|
rect.name = 'sample-rect';
|
|
expect(rect.name).toBe('sample-rect');
|
|
});
|
|
```
|
|
|
|
### Grouping tests
|
|
|
|
Wrap related tests in `describe(groupName, fn)` to group them. In the UI each group
|
|
is a **collapsible section** showing its own passed / failed / total counts, with a
|
|
"Run group" button and a select-all checkbox. Tests not inside any `describe` fall
|
|
into the `General` group.
|
|
|
|
```ts
|
|
import { expect } from '../framework/expect';
|
|
import { describe, test } from '../framework/registry';
|
|
|
|
describe('Shapes', () => {
|
|
test('creates a rectangle', (ctx) => {
|
|
/* … */
|
|
});
|
|
|
|
test('creates an ellipse', (ctx) => {
|
|
/* … */
|
|
});
|
|
});
|
|
```
|
|
|
|
`describe` blocks may be nested in a file. Nested names are **joined into a single
|
|
group path** with `" / "`, so the group reveals the file/area it lives in — e.g.
|
|
`describe('Layout', () => describe('Flex', …))` produces the group `Layout / Flex`.
|
|
Wrap each file's tests in a top-level `describe` named after its area so every
|
|
group is recognizable. Several files may contribute to the same group path (they
|
|
merge in the UI). Prefer one clear group per feature area.
|
|
|
|
In the UI each group header shows an aggregate **status dot** rolled up from its
|
|
tests: it turns purple while any test in the group is running, red if any failed,
|
|
green only once every test passed, and grey until then.
|
|
|
|
### The test context (`ctx`)
|
|
|
|
`fn` receives a `TestContext` (`src/framework/types.ts`):
|
|
|
|
- `ctx.penpot` — the recording proxy over the real `penpot` global. Use it for
|
|
every API call.
|
|
- `ctx.board` — a **fresh scratch `Board`** created for this test and
|
|
**removed automatically afterwards**. Append shapes you create to it
|
|
(`ctx.board.appendChild(shape)`) so the user's canvas is left clean. Do not rely
|
|
on it persisting between tests.
|
|
|
|
The runner also resets shared state between tests: the selection is cleared and the
|
|
active page is restored to whatever was active when the run started (both through
|
|
the raw `penpot`, so they aren't credited toward coverage). A test that changes the
|
|
active page therefore won't leak into later tests.
|
|
|
|
### Sync or async
|
|
|
|
`fn` may be `void` or `Promise<void>`; async tests are awaited. Use `async (ctx) =>`
|
|
and `await` when the API call is asynchronous (e.g. `uploadMediaUrl`,
|
|
`library.availableLibraries()`, token application — see notes below).
|
|
|
|
### Naming
|
|
|
|
The test name becomes its id (slugified) and is shown in the UI. Keep names unique
|
|
and descriptive; duplicates are de-duplicated automatically but that's confusing.
|
|
|
|
## Assertions
|
|
|
|
Import `expect` from `../framework/expect`. It is a small, dependency-free,
|
|
jest-like matcher set (it must stay dependency-free — it runs inside the SES
|
|
sandbox). Available matchers:
|
|
|
|
- `toBe(expected)` — `Object.is` equality
|
|
- `toEqual(expected)` — deep structural equality
|
|
- `toBeTruthy()` / `toBeFalsy()`
|
|
- `toBeNull()` / `toBeUndefined()` / `toBeDefined()`
|
|
- `toContain(item)` — substring or array membership
|
|
- `toHaveLength(n)`
|
|
- `toBeGreaterThan(n)` / `toBeLessThan(n)`
|
|
- `toBeCloseTo(n, numDigits?)` — for floats
|
|
- `toThrow(expected?)` — `expected` is a substring or `RegExp` matched against the
|
|
error message; pass a function as the value: `expect(() => …).toThrow('msg')`
|
|
- `.not` negates any matcher: `expect(x).not.toBeNull()`
|
|
|
|
For asynchronous failures use `expectReject(promiseOrThunk, expected?)`: `toThrow`
|
|
calls its argument synchronously, so it can't catch a rejected promise, whereas
|
|
`expectReject` awaits and asserts the rejection (string includes / RegExp on the
|
|
message).
|
|
|
|
A failing matcher throws; the runner turns that into a red test with the message.
|
|
You can also just `throw new Error('…')` to fail a test.
|
|
|
|
> Do not add other assertion libraries. Anything imported here is bundled into the
|
|
> sandbox and must be SES-safe and dependency-free.
|
|
|
|
## How coverage works (and how to write tests that move it)
|
|
|
|
Coverage is **type-aware** and tracks three separate targets per member:
|
|
|
|
- **`name (get)`** — reading a property (`const n = shape.name`)
|
|
- **`name (set)`** — writing a property (`shape.name = 'x'`)
|
|
- **`appendChild()`** — calling a method (credited only when actually **called**,
|
|
not when merely referenced)
|
|
|
|
Implications when writing tests:
|
|
|
|
- A property has independent get/set targets. To cover both, read it _and_ write
|
|
it. Read-only properties (declared `readonly` in the d.ts) only have a get
|
|
target; methods only have a call target.
|
|
- Accessing a member through a value you got from `ctx.penpot` is what counts.
|
|
Reaching a nested object also counts: e.g. `ctx.board.children[0].type` records
|
|
`Board.children (get)` and then the element's `type` get, resolved to the
|
|
concrete shape type at runtime.
|
|
- Coverage **accumulates across a run**. Running all tests aggregates every test's
|
|
accesses. Running a single test shows only that test's accesses.
|
|
|
|
### Recorded vs. effective coverage
|
|
|
|
The report distinguishes three states per target:
|
|
|
|
- **Covered (recorded)** — credited by the recording proxy (green).
|
|
- **Statically covered** — exercised behaviourally by the tests but the proxy
|
|
_structurally cannot_ credit it (shown in a distinct colour). These come from a
|
|
curated allowlist in `src/framework/static-coverage.ts`, keyed by
|
|
`Interface.member#mode`. See [Coverage notes](#coverage-notes) for which members
|
|
and why.
|
|
- **Uncovered** — neither.
|
|
|
|
The header shows two numbers: the **recorded** percentage (what the proxy actually
|
|
credited) and the **effective** percentage (recorded + statically covered).
|
|
Recorded coverage always wins, so listing a target in the static allowlist that
|
|
turns out to be recorded is harmless — it simply never shows as static. Coverage is
|
|
report-only; it never fails a run or the build.
|
|
|
|
The denominator comes from `src/generated/api-surface.json`, generated from
|
|
`libs/plugin-types/index.d.ts`. If the Plugin API types change, regenerate it:
|
|
|
|
```
|
|
pnpm --filter plugin-api-test-suite run gen:api
|
|
```
|
|
|
|
## Runtime details you need to know
|
|
|
|
- **Shape `type` values** returned at runtime: `Board` → `'board'`,
|
|
`Rectangle` → `'rectangle'`, `Ellipse` → `'ellipse'`, plus `'text'`, `'path'`,
|
|
`'group'`, `'image'`, `'svg-raw'`. (`createRectangle().type === 'rectangle'`.)
|
|
- `createText(str)` returns `Text | null` — guard the result (`if (text) { … }`).
|
|
- `width`/`height` are read-only; use `resize(w, h)`. `x`/`y` are writable.
|
|
- The plugin manifest already requests broad permissions (`content:*`,
|
|
`library:*`, `user:read`, `comment:*`, `allow:downloads`, `allow:localstorage`),
|
|
so most of the API is callable from tests without changes.
|
|
- The runner sets `throwValidationErrors = true` and `naturalChildOrdering = true`,
|
|
so invalid API usage throws (surfacing as a red test) and `children` is always in
|
|
z-index order.
|
|
- The runtime is SES-sandboxed: no Node APIs, no DOM, no extra npm deps inside
|
|
tests. Stick to the Plugin API, `expect`, and plain JS.
|
|
|
|
## Coverage notes
|
|
|
|
The suite covers a large majority of the type surface. The remaining members are
|
|
uncovered or only _statically_ covered for the reasons below — **not** missing
|
|
tests. Note these notes can drift as the API is fixed: when in doubt, write the
|
|
test asserting the documented correct behaviour and run `test:ci` to see what
|
|
actually happens.
|
|
|
|
### Exercised behaviourally but not creditable by the recorder (statically covered)
|
|
|
|
Listed in `src/framework/static-coverage.ts`:
|
|
|
|
- **`ContextTypesUtils.*` and `ContextGeometryUtils.center`** — `penpot.utils.types`
|
|
and `penpot.utils.geometry` are frozen (SES) data properties, so the recording
|
|
proxy must return them raw and cannot wrap their members. Both are exercised
|
|
behaviourally in `platform.test.ts`.
|
|
- **`ColorShapeInfo.shapesInfo`, `ColorShapeInfoEntry.*`** — `shapesColors()` has an
|
|
unresolved return type in the generated surface (`type: null`), so the recorder
|
|
hands the result back raw and can't attribute nested access. Exercised in
|
|
`colors.test.ts`. (Alternatively, resolving the return type in
|
|
`tools/gen-api-surface.ts` would make these genuinely recorded.)
|
|
- **`EventsMap.*`** — a type map, not a runtime object. `on`/`off` are credited on
|
|
`Penpot`, never as `EventsMap` members. The deterministic events
|
|
(`selectionchange`, `shapechange`) are exercised in `events.test.ts`.
|
|
- **`ShapeBase.fills`** — every concrete shape redeclares `fills`, so accesses are
|
|
attributed to the concrete type (`Rectangle.fills`, …); the base-interface target
|
|
is never the attribution.
|
|
- **`LibraryVariantComponent.*`** — the recorder types a component as
|
|
`LibraryComponent` and can't narrow to `LibraryVariantComponent` via the
|
|
`isVariant()` type-guard. The behaviour is exercised via `VariantContainer.variants`
|
|
in `variants.test.ts`.
|
|
|
|
### Read-only at runtime
|
|
|
|
Members that have no setter in the runtime binding (`frontend/src/app/plugins/*.cljs`)
|
|
are now marked `readonly` in the Plugin API d.ts (`Font.*`, `FontVariant.*`,
|
|
`FontsContext.all`, `Image/Ellipse/SvgRaw.type`, `File.name/pages/revn`, `Page.root`,
|
|
`TokenTheme.activeSets`, `Variants.properties`, `ImageData.*`, and the board guide
|
|
value objects `GuideColumn/GuideRow/GuideSquare` and their params — `board.guides`
|
|
returns a formatted snapshot, so guides are reconfigured by reassigning the whole
|
|
array, not by mutating a returned guide), the `Point`/`Bounds` value objects, the
|
|
`Penpot.ui`/`Penpot.utils` subcontexts, and the derived `Boolean` path data
|
|
(`d`/`content`/`commands` are computed from the operands — a `Boolean` isn't editable
|
|
like a `Path`). They therefore have only a `(get)` target and need no runtime
|
|
assertion — the type system enforces the contract.
|
|
|
|
Members that **do** have a runtime setter stay writable, even when the setter
|
|
rejects some inputs (that's input validation, not read-only-ness): `Board.children`
|
|
(assigning a reordered array reorders the children), `Path.d/content/commands`
|
|
(editing the path), and `FileVersion.label` (relabels the version).
|
|
|
|
### Excluded from coverage
|
|
|
|
`tools/gen-api-surface.ts` drops two categories from the denominator so they never
|
|
count:
|
|
|
|
- **`@deprecated` interfaces and members** — the legacy `Image` shape interface
|
|
(images live in a `Fill` via `fillImage`), `Color.refId`/`refFile`, and the
|
|
`Boolean`/`Path` `toD()`/`content` path accessors.
|
|
- **Members removed by the public interface via `Omit`** — `Context` is the
|
|
internal interface and the public `Penpot` is `Omit<Context, 'addListener' |
|
|
'removeListener'>` (those are superseded by `on`/`off`). The generator honors the
|
|
`Omit`, so `Context.addListener`/`removeListener` aren't reachable surface and
|
|
don't count.
|
|
|
|
### Red tests pinning confirmed API bugs
|
|
|
|
When a member is confirmed broken, add a test that asserts its **correct** behaviour
|
|
and comment it as blocked-by-bug; it stays red until the API is fixed and then turns
|
|
green (at which point drop the "API bug" framing). There are currently no such red
|
|
tests — e.g. the `fontFamilies` token `resolvedValue` bug (it used to leak the raw
|
|
tokenscript structure instead of `string[]`) has since been fixed.
|
|
|
|
### d.ts / runtime mismatches
|
|
|
|
`strokeStyle: 'none'` is listed in the d.ts but rejected at runtime ("Value not
|
|
valid"); `fills-strokes.test.ts` pins this with a `toThrow`.
|
|
|
|
### External state / not reachable headless
|
|
|
|
- **`ActiveUser.position/zoom`** — needs a second collaborator in the file.
|
|
- **`LibrarySummary.*`, `LibraryContext.connectLibrary`** — need a published shared
|
|
library.
|
|
- **`FileVersion.restore`, `Penpot.closePlugin`, `Penpot.ui`, `Context.openViewer`** —
|
|
tear down or navigate away from the running plugin/workspace.
|
|
- **`FileVersion.pin`** — only converts a _system_ autosave to a permanent version;
|
|
a plugin can only create manual versions (`saveVersion`), so `pin()` always
|
|
rejects.
|
|
- **`Context.addListener/removeListener`** — omitted from the `penpot` global
|
|
(`Omit<Context, 'addListener' | 'removeListener'>`), so unreachable via `penpot`.
|
|
- **`EventsMap` events `pagechange/filechange/themechange/contentsave/finish`** —
|
|
can't be triggered deterministically in the headless runner.
|
|
|
|
## Checklist before finishing
|
|
|
|
- [ ] Test file is `src/tests/<name>.test.ts` and uses `test(...)` + `expect`,
|
|
ideally wrapped in a `describe('<Group>', …)`.
|
|
- [ ] All API calls go through `ctx.penpot`; shapes are appended to `ctx.board`.
|
|
- [ ] Created shapes don't leak (rely on the scratch board cleanup; don't touch the
|
|
user's existing content).
|
|
- [ ] Lint/format/typecheck pass:
|
|
`pnpm --filter plugin-api-test-suite run lint` and, from `plugins/`,
|
|
`pnpm exec prettier --check "apps/plugin-api-test-suite/**/*.{ts,css,json}"`.
|
|
- [ ] If you relied on new API members, `gen:api` was re-run so coverage reflects
|
|
them.
|
|
|
|
## Where things live (for deeper changes)
|
|
|
|
- `src/framework/registry.ts` — `test()`, `describe()`, `getTests()`, `setTests()` (reload).
|
|
- `src/framework/runner.ts` — runs tests, scratch board lifecycle, per-test state reset, coverage.
|
|
- `src/framework/coverage.ts` — the recording proxy + coverage computation.
|
|
- `src/framework/static-coverage.ts` — the statically-covered allowlist.
|
|
- `src/framework/expect.ts` — the assertion library.
|
|
- `src/framework/types.ts` — `TestContext`, `TestResult`, `CoverageReport`, etc.
|
|
- `tools/gen-api-surface.ts` — generates `src/generated/api-surface.json`.
|
|
- `src/plugin.ts` (sandbox), `src/ui.ts` (iframe), `src/model.ts` (messages).
|
|
- `src/ci/headless.ts` + `ci/run-ci.ts` — CI path.
|
|
|
|
Writing tests should only ever require touching `src/tests/`.
|