Plugin for api testing (#10410)

This commit is contained in:
Alonso Torres 2026-06-30 14:01:38 +02:00 committed by GitHub
parent ca81776d04
commit e0be9f7ade
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 20394 additions and 3 deletions

View File

@ -0,0 +1,133 @@
name: "CI: Plugin API Test Suite"
# Runs the Plugin API Test Suite (it exercises the real Penpot Plugin API, so it
# needs a running frontend + the plugin runtime). Two jobs:
#
# - api-test-suite-mocked (pull_request / push): the per-PR gate. Serves the
# prebuilt frontend bundle and intercepts every backend RPC with Playwright
# (MOCK_BACKEND=1). No backend / no login. Validates the frontend Plugin API
# binding + in-memory store; backend-result-dependent tests are skipped via the
# `skipIfMocked` tag. See plugins/apps/plugin-api-test-suite/README.md.
#
# - api-test-suite-live (workflow_dispatch): true end-to-end against a LIVE
# instance. Point PENPOT_BASE_URL at a reachable instance and provide login
# credentials via repo secrets. Manual because the CI runner has no Docker to
# stand up a full stack.
defaults:
run:
shell: bash
on:
workflow_dispatch:
inputs:
base_url:
description: "Penpot base URL (e.g. https://localhost:3449)"
required: false
default: "https://localhost:3449"
pull_request:
paths:
- 'plugins/**'
- 'frontend/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'plugins/**'
- 'frontend/src/app/plugins/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
api-test-suite-mocked:
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
name: "Run Plugin API Test Suite (mocked)"
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
# Mocked mode 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 plugin-api-test-suite exec playwright install --with-deps chromium
- name: Generate API surface
working-directory: ./plugins
run: pnpm --filter plugin-api-test-suite run gen:api
- name: Run API test suite (mocked)
working-directory: ./plugins
env:
MOCK_BACKEND: "1"
run: pnpm --filter plugin-api-test-suite run test:ci
## The following job will launch the whole suite of tests but we need
## to have a full environment in the CI for this to work.
# api-test-suite-live:
# if: ${{ github.event_name == 'workflow_dispatch' }}
# name: Run Plugin API Test Suite (live)
# runs-on: penpot-runner-02
# container:
# image: penpotapp/devenv:latest
#
# env:
# PENPOT_BASE_URL: ${{ github.event.inputs.base_url }}
# E2E_LOGIN_EMAIL: ${{ secrets.E2E_LOGIN_EMAIL }}
# E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }}
#
# steps:
# - uses: actions/checkout@v6
#
# - name: Setup Node
# uses: actions/setup-node@v6
# with:
# node-version-file: .nvmrc
#
# - name: Install deps
# working-directory: ./plugins
# run: |
# corepack enable;
# corepack install;
# pnpm install;
#
# - name: Install Playwright Chromium
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium
#
# - name: Generate API surface
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite run gen:api
#
# # Note: requires a running Penpot instance reachable at PENPOT_BASE_URL.
# - name: Run API test suite
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite run test:ci

View File

@ -0,0 +1,391 @@
# 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/`.

View File

@ -0,0 +1,60 @@
{
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"fdata/shape-data-type",
"fdata/path-data",
"components/v2",
"design-tokens/v1",
"variants/v1",
"plugins/runtime"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "New File 1",
"~:revn": 11,
"~:modified-at": "~m1713873823633",
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
"~:is-shared": false,
"~:version": 46,
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
"~:created-at": "~m1713536343369",
"~:data": {
"~:pages": ["~u66697432-c33d-8055-8006-2c62cc084cad"],
"~:pages-index": {
"~u66697432-c33d-8055-8006-2c62cc084cad": {
"~#penpot/pointer": [
"~ude58c8f6-c5c2-8196-8004-3df9e2e52d88",
{
"~:created-at": "~m1713873823636"
}
]
}
},
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
"~:options": {
"~:components-v2": true
},
"~:recent-colors": [
{
"~:color": "#0000ff",
"~:opacity": 1,
"~:id": null,
"~:file-id": null,
"~:image": null
}
]
}
}

View File

@ -0,0 +1,475 @@
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';
import type { CoverageReport, TestResult } from '../src/framework/types';
// Out-of-sandbox CI driver (Node + Playwright). Injects the prebuilt
// `headless.js` bundle (built from the in-sandbox entry `src/ci/headless.ts` —
// note: a different `ci/` directory) into the plugin sandbox via
// `globalThis.ɵloadPlugin` and captures results/coverage from the page console.
// Two modes:
//
// - LIVE (default): logs into a real Penpot instance (devenv), creates a scratch
// file, and drives the real backend + frontend end-to-end.
// Required env: E2E_LOGIN_EMAIL, E2E_LOGIN_PASSWORD.
// Optional env: PENPOT_BASE_URL (default https://localhost:3449).
//
// - MOCKED (`MOCK_BACKEND=1`): serves the prebuilt frontend bundle via the e2e
// static server and intercepts every backend RPC with Playwright `page.route`,
// reusing the frontend e2e mock fixtures. No backend/login needed. Validates
// the frontend Plugin API binding + in-memory store only; results that depend
// on real backend behaviour are not faithfully reproduced, so those tests are
// skipped via the `skipIfMocked` tag.
const here = dirname(fileURLToPath(import.meta.url));
// here = <root>/plugins/apps/plugin-api-test-suite/ci
const repoRoot = resolve(here, '../../../../');
const frontendDir = resolve(repoRoot, 'frontend');
const e2eDataDir = resolve(frontendDir, 'playwright/data');
const MOCKED = !!process.env['MOCK_BACKEND'];
const MOCK_BASE_URL = 'http://localhost:3000';
const apiUrl = MOCKED
? MOCK_BASE_URL
: (process.env['PENPOT_BASE_URL'] ?? 'https://localhost:3449');
const headlessBundlePath = resolve(
here,
'../../../dist/apps/plugin-api-test-suite/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;
function cleanId(id: string): string {
return id.replace('~u', '');
}
interface FileRpc {
'~:id': string;
'~:project-id': string;
'~:data': { '~:pages': string[] };
}
async function login() {
const email = process.env['E2E_LOGIN_EMAIL'];
const password = process.env['E2E_LOGIN_PASSWORD'];
if (!email || !password) {
throw new Error('E2E_LOGIN_EMAIL / E2E_LOGIN_PASSWORD must be set');
}
const response = await fetch(
`${apiUrl}/api/main/methods/login-with-password`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
},
);
const loginData = await response.json();
const authToken = response.headers
.getSetCookie()
.find((cookie) => cookie.startsWith('auth-token='))
?.split(';')[0];
if (!authToken)
throw new Error('Login failed: no auth-token cookie returned');
return { authToken, defaultProjectId: loginData['~:default-project-id'] };
}
async function createFile(
authToken: string,
projectId: string,
): Promise<FileRpc> {
const response = await fetch(`${apiUrl}/api/main/methods/create-file`, {
method: 'POST',
headers: {
'Content-Type': 'application/transit+json',
cookie: authToken,
},
body: JSON.stringify({
'~:name': `api-test-suite ${new Date().toISOString()}`,
'~:project-id': projectId,
'~:features': {
'~#set': [
'fdata/objects-map',
'fdata/pointer-map',
'fdata/shape-data-type',
'fdata/path-data',
'design-tokens/v1',
'variants/v1',
'components/v2',
'styles/v2',
'layout/grid',
'plugins/runtime',
],
},
}),
});
return (await response.json()) as FileRpc;
}
function getFileUrl(file: FileRpc): string {
const projectId = cleanId(file['~:project-id']);
const fileId = cleanId(file['~:id']);
const pageId = cleanId(file['~:data']['~:pages'][0]);
return `${apiUrl}/#/workspace/${projectId}/${fileId}?page-id=${pageId}`;
}
// --- Mocked mode setup -------------------------------------------------------
// 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
// (WorkspacePage.init + setupEmptyFile). 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': [] });
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 (ws://localhost:3000/ws/notifications) — so the WS mock
// below matches without extra config.
const child = spawn('node', ['scripts/e2e-server.js'], {
cwd: frontendDir,
stdio: 'inherit',
});
return child;
}
// Install the frontend e2e WebSocket mock so the workspace's notifications
// socket can be "opened" without a backend.
async function installWebSocketMock(page: Page): Promise<void> {
const created = new Set<string>();
await page.exposeFunction('onMockWebSocketConstructor', (url: string) => {
created.add(url);
});
await page.addInitScript({
path: resolve(frontendDir, 'playwright/scripts/MockWebSocket.js'),
});
// Stash the helper on the page object for later use.
(page as unknown as { __wsCreated: Set<string> }).__wsCreated = created;
}
async function openNotificationsWebSocket(page: Page): Promise<void> {
const created = (page as unknown as { __wsCreated: Set<string> }).__wsCreated;
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 (mirror BasePage.mockConfigFlags).
await page.route('**/js/config.js*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/javascript',
body: 'var penpotFlags = "";\n',
}),
);
// Workspace-load RPCs from 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: most of the Plugin API mutates the in-memory
// store optimistically, so a 200 `update-file` mock is enough for the bulk of
// the suite to run against in-memory state.
await page.route(/\/api\/main\/methods\/update-file\b/, (route) =>
route.fulfill({
status: 200,
contentType: 'application/transit+json',
body: UPDATE_FILE_RESPONSE,
}),
);
}
function mockedFileUrl(): string {
return `${MOCK_BASE_URL}/#/workspace?team-id=${MOCK_TEAM_ID}&file-id=${MOCK_FILE_ID}&page-id=${MOCK_PAGE_ID}`;
}
// --- Reporting ---------------------------------------------------------------
function printReport(
results: TestResult[],
coverage: CoverageReport | null,
skipped: string[],
) {
// Each result is already printed live as it streams in; here we only recap the
// failures so they're easy to find at the bottom of a long run.
const failures = results.filter((r) => r.status === 'fail');
if (failures.length > 0) {
console.log('\nFailures:');
for (const r of failures) {
console.log(`${r.name} (${r.durationMs}ms)`);
if (r.error) {
console.log(` ${r.error}`);
}
}
}
if (skipped.length > 0) {
console.log(`\nSkipped (mocked mode): ${skipped.length}`);
for (const name of skipped) {
console.log(` - ${name}`);
}
}
if (coverage) {
console.log(
`\nAPI coverage (report-only): ${coverage.percent}% recorded ` +
`(${coverage.covered}/${coverage.total}), ` +
`${coverage.effectivePercent}% effective ` +
`(+${coverage.staticallyCovered} statically covered)`,
);
// Opt-in dump of the uncovered targets per interface, to drive test writing.
if (process.env['PRINT_UNCOVERED']) {
console.log('\nUncovered targets by interface:');
for (const [iface, info] of Object.entries(coverage.byInterface)) {
if (info.uncovered.length > 0) {
console.log(` ${iface}: ${info.uncovered.join(', ')}`);
}
}
}
// Opt-in dump of the statically-covered targets (exercised behaviourally but
// not creditable through the recording proxy).
if (process.env['PRINT_STATIC']) {
console.log('\nStatically covered targets by interface:');
for (const [iface, info] of Object.entries(coverage.byInterface)) {
if (info.staticallyCovered.length > 0) {
console.log(` ${iface}: ${info.staticallyCovered.join(', ')}`);
}
}
}
}
}
async function main() {
const bundle = readFileSync(headlessBundlePath, 'utf-8');
let server: ChildProcess | undefined;
let fileUrl: string;
let authToken: string | undefined;
if (MOCKED) {
server = startE2eServer();
await waitForServer(MOCK_BASE_URL);
fileUrl = mockedFileUrl();
} else {
const session = await login();
authToken = session.authToken;
const file = await createFile(authToken, session.defaultProjectId);
fileUrl = getFileUrl(file);
}
const browser = await chromium.launch({
args: ['--ignore-certificate-errors'],
});
const context = await browser.newContext({ ignoreHTTPSErrors: true });
if (authToken) {
await context.addCookies([
{ name: 'auth-token', value: authToken.split('=')[1], url: apiUrl },
]);
}
const page = await context.newPage();
if (MOCKED) {
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 mocked flag straight into
// the evaluated code so the bundle's `runTests` excludes `skipIfMocked` tests.
const injectedCode = MOCKED
? `globalThis.__PLUGIN_SUITE_MOCKED__ = true;\n${bundle}`
: bundle;
const results: TestResult[] = [];
let coverage: CoverageReport | null = null;
let skipped: string[] = [];
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: TestResult = JSON.parse(
text.slice('__TEST_RESULT__ '.length),
);
results.push(result);
// Print each result as it streams in so the run shows live progress
// instead of staying silent until it finishes.
const icon = result.status === 'pass' ? '✓' : '✗';
console.log(` ${icon} ${result.name} (${result.durationMs}ms)`);
if (result.status === 'fail' && result.error) {
console.log(` ${result.error}`);
}
} else if (text.startsWith('__TEST_COVERAGE__ ')) {
coverage = JSON.parse(text.slice('__TEST_COVERAGE__ '.length));
} else if (text.startsWith('__TEST_SKIPPED__ ')) {
skipped = JSON.parse(text.slice('__TEST_SKIPPED__ '.length));
} else if (text.startsWith('__TEST_DONE__ ')) {
resolvePromise();
} else if (text.startsWith('__TEST_FATAL__ ')) {
fatal = JSON.parse(text.slice('__TEST_FATAL__ '.length)).message;
resolvePromise();
}
});
});
await page.goto(fileUrl);
if (MOCKED) {
await openNotificationsWebSocket(page);
}
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 the bundle.
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: 'Plugin API Test Suite (CI)',
code,
icon: '',
description: '',
permissions,
});
},
{ code: injectedCode, permissions: PERMISSIONS },
);
await Promise.race([
done,
new Promise<void>((_, reject) =>
setTimeout(
() => reject(new Error('Timed out waiting for test results')),
120000,
),
),
]);
await browser.close();
server?.kill();
printReport(results, coverage, skipped);
if (fatal) {
console.error(`\nFatal error while running tests: ${fatal}`);
process.exit(1);
}
const failed = results.filter((r) => r.status === 'fail').length;
const passed = results.filter((r) => r.status === 'pass').length;
console.log(
`\n${passed} passed, ${failed} failed${
skipped.length ? `, ${skipped.length} skipped` : ''
}.`,
);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,27 @@
import baseConfig from '../../eslint.config.js';
export default [
...baseConfig,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: './tsconfig.*?.json',
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {},
},
{
ignores: [
'**/assets/*.js',
'vite.config.ts',
'vite.config.headless.ts',
'vite.config.tests.ts',
'vite.config.iife.ts',
],
},
];

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Plugin API Test Suite</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<main id="app" class="wrapper"></main>
<script type="module" src="/src/ui.ts"></script>
</body>
</html>

View File

@ -0,0 +1,22 @@
{
"name": "plugin-api-test-suite",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build --emptyOutDir && pnpm run build:headless && pnpm run build:tests",
"build:headless": "vite build --config vite.config.headless.ts",
"build:tests": "vite build --config vite.config.tests.ts",
"watch": "concurrently --kill-others --names app,tests \"vite build --watch --mode development\" \"vite build --watch --mode development --config vite.config.tests.ts\"",
"serve": "vite preview",
"init": "concurrently --kill-others --names build,serve \"pnpm run watch\" \"pnpm run serve\"",
"lint": "eslint .",
"gen:api": "tsx tools/gen-api-surface.ts",
"test:ci": "pnpm run build:headless && tsx ci/run-ci.ts",
"test:ci:mocked": "pnpm run build:headless && MOCK_BACKEND=1 tsx ci/run-ci.ts"
},
"devDependencies": {
"playwright": "^1.61.0"
}
}

View File

@ -0,0 +1,4 @@
/*
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,18 @@
{
"name": "Plugin API Test Suite",
"description": "Launcher for a battery of Penpot Plugin API tests",
"code": "plugin.js",
"version": 2,
"icon": "assets/icon.png",
"permissions": [
"content:read",
"content:write",
"library:read",
"library:write",
"user:read",
"comment:read",
"comment:write",
"allow:downloads",
"allow:localstorage"
]
}

View File

@ -0,0 +1,39 @@
import { runTests } from '../framework/runner';
// In-sandbox CI entry point. Built as a standalone IIFE bundle (headless.js) and
// evaluated inside a real Penpot plugin sandbox by the out-of-sandbox driver
// `ci/run-ci.ts` (note: distinct from this `src/ci/` directory). It runs every
// test and reports results + coverage through `console.log` markers that the
// Playwright driver parses. It has no UI.
// Auto-discover the same tests used by the UI plugin.
import.meta.glob('../tests/*.test.ts', { eager: true });
async function main() {
// Set by the mocked-backend runner (MOCK_BACKEND=1) before this bundle loads,
// so backend-result-dependent tests tagged `skipIfMocked` are excluded.
const skipMocked = !!(
globalThis as unknown as { __PLUGIN_SUITE_MOCKED__?: boolean }
).__PLUGIN_SUITE_MOCKED__;
// Stream each result as it completes (not just at the end) so the runner sees
// progress and partial output survives if a later test hangs to its timeout.
const { summary, coverage, skipped } = await runTests(
'all',
(result) => {
if (result.status !== 'running') {
console.log('__TEST_RESULT__ ' + JSON.stringify(result));
}
},
{ skipMocked },
);
console.log('__TEST_COVERAGE__ ' + JSON.stringify(coverage));
console.log('__TEST_SKIPPED__ ' + JSON.stringify(skipped));
console.log('__TEST_DONE__ ' + JSON.stringify(summary));
}
main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.log('__TEST_FATAL__ ' + JSON.stringify({ message }));
});

View File

@ -0,0 +1,287 @@
import { STATIC_COVERAGE } from './static-coverage';
import type { ApiSurface, CoverageReport, InterfaceCoverage } from './types';
export interface Recorder<T> {
/** Proxy to hand to tests; mirrors `root` but records member access. */
proxy: T;
/** Every `Interface.member` pair touched through the proxy. */
accessed: Set<string>;
/**
* Wraps an already-obtained value as a given interface so subsequent access
* through it is recorded, without crediting how it was obtained. Used for the
* scratch board, whose creation is harness bookkeeping, not test coverage.
*/
wrap<V>(value: V, typeName: string): V;
}
function isWrappable(value: unknown): value is object {
return (
value !== null && (typeof value === 'object' || typeof value === 'function')
);
}
/**
* True when `prop` is a non-configurable, non-writable data property of `target`.
* The Proxy `get` invariant requires returning that exact value, so wrapping it
* is not allowed.
*/
function nonConfigurableData(target: object, prop: PropertyKey): boolean {
const desc = Reflect.getOwnPropertyDescriptor(target, prop);
return (
!!desc &&
desc.configurable === false &&
desc.writable === false &&
!desc.get &&
!desc.set
);
}
/**
* Wraps `root` (the real `penpot` API) in a recursive Proxy that records member
* access in a *type-aware* way. Each proxy is tagged with the interface (or union
* alias) name the underlying value has, derived from the API type graph. When a
* member is accessed we record `Interface.member` against the interface that
* actually declares it, and we tag the returned value with the member's declared
* type so nested access is attributed correctly too.
*
* This avoids the false positives of name-only matching, where e.g. reading
* `shape.id` would wrongly credit every interface that happens to have an `id`
* member. Unknown/primitive types are returned unwrapped and never recorded.
*/
export function createRecorder<T extends object>(
root: T,
surface: ApiSurface,
): Recorder<T> {
const accessed = new Set<string>();
const toOriginal = new WeakMap<object, object>();
// Cache proxies per (target, typeName) so identity is stable and cycles end.
const cache = new WeakMap<object, Map<string, object>>();
function unwrap(value: unknown): unknown {
if (isWrappable(value) && toOriginal.has(value)) {
return toOriginal.get(value);
}
return value;
}
/** Resolves the concrete interface name for a tagged value (handles unions). */
function concreteType(target: object, typeName: string): string | null {
if (surface.graph[typeName]) return typeName;
const union = surface.unions[typeName];
if (union?.discriminant) {
const disc = Reflect.get(target, union.discriminant.field) as unknown;
if (typeof disc === 'string') {
return union.discriminant.map[disc] ?? null;
}
}
return null;
}
function wrapValue(
value: unknown,
typeName: string | null,
array: boolean,
): unknown {
if (!isWrappable(value) || !typeName) return value;
if (array) {
return Array.isArray(value) ? wrapArray(value, typeName) : value;
}
if (surface.graph[typeName] || surface.unions[typeName]) {
return wrapObject(value, typeName);
}
return value;
}
function wrapArray(arr: unknown[], elementType: string): unknown[] {
const proxy = new Proxy(arr, {
get(tgt, prop, receiver): unknown {
const value = Reflect.get(tgt, prop, receiver);
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
// A frozen array (e.g. the selection array, sealed by SES) has
// non-configurable, non-writable elements. The Proxy invariant then
// forbids returning a wrapped value that differs from the target's,
// so return the raw element (it just isn't credited for coverage).
if (nonConfigurableData(tgt, prop)) return value;
return wrapValue(value, elementType, false);
}
return value;
},
});
toOriginal.set(proxy, arr);
return proxy;
}
function wrapObject(target: object, typeName: string): object {
let byType = cache.get(target);
if (!byType) {
byType = new Map();
cache.set(target, byType);
}
const cached = byType.get(typeName);
if (cached) return cached;
const proxy: object = new Proxy(target, {
get(tgt, prop, receiver): unknown {
const concrete = concreteType(tgt, typeName);
const entry =
concrete && typeof prop === 'string'
? surface.graph[concrete]?.[prop]
: undefined;
const raw = Reflect.get(tgt, prop, receiver === proxy ? tgt : receiver);
// Methods are credited on call (see wrapMethod), not on access. Property
// reads are credited here as `#get`.
if (entry && entry.kind === 'method') {
return wrapMethod(raw as (...a: unknown[]) => unknown, tgt, {
...entry,
member: String(prop),
});
}
if (entry) accessed.add(`${entry.decl}.${String(prop)}#get`);
// Don't wrap a frozen own property (Proxy invariant would be violated).
if (typeof prop === 'string' && nonConfigurableData(tgt, prop)) {
return raw;
}
return entry ? wrapValue(raw, entry.type, entry.array) : raw;
},
set(tgt, prop, value, receiver): boolean {
const concrete = concreteType(tgt, typeName);
const entry =
concrete && typeof prop === 'string'
? surface.graph[concrete]?.[prop]
: undefined;
if (entry) accessed.add(`${entry.decl}.${String(prop)}#set`);
return Reflect.set(
tgt,
prop,
unwrap(value),
receiver === proxy ? tgt : receiver,
);
},
});
toOriginal.set(proxy, target);
byType.set(typeName, proxy);
return proxy;
}
function wrapMethod(
fn: (...a: unknown[]) => unknown,
self: object,
entry: {
decl: string;
member: string;
type: string | null;
array: boolean;
},
): (...a: unknown[]) => unknown {
return (...args: unknown[]) => {
// Credit the call only once it returns without throwing, so coverage
// means "successfully exercised" rather than "merely invoked".
const result = fn.apply(self, args.map(unwrap));
accessed.add(`${entry.decl}.${entry.member}#call`);
// Async API methods (e.g. uploadMediaUrl, createShapeFromSvgWithImages)
// return a Promise. Wrapping the Promise itself as the declared type would
// break `await` (then() called on the proxy is an incompatible receiver),
// so resolve it first and wrap the resolved value instead.
if (
isWrappable(result) &&
typeof (result as { then?: unknown }).then === 'function'
) {
return Promise.resolve(result as Promise<unknown>).then((value) =>
wrapValue(value, entry.type, entry.array),
);
}
return wrapValue(result, entry.type, entry.array);
};
}
return {
proxy: wrapObject(root, 'Penpot') as T,
accessed,
wrap: <V>(value: V, typeName: string) =>
wrapValue(value, typeName, false) as V,
};
}
/**
* Compares the recorded `Interface.member` pairs against the public API surface
* and produces a report grouped by interface. The denominator is each
* interface's own declared members.
*/
export function computeCoverage(
accessed: Set<string>,
surface: ApiSurface,
): CoverageReport {
const byInterface: Record<string, InterfaceCoverage> = {};
let total = 0;
let coveredCount = 0;
let staticCount = 0;
for (const [iface, members] of Object.entries(surface.interfaces)) {
const all: string[] = [];
const covered: string[] = [];
const staticallyCovered: string[] = [];
const uncovered: string[] = [];
for (const member of members) {
// Each writable property contributes separate get/set targets; read-only
// properties only get; methods only call.
const kind = surface.graph[iface]?.[member]?.kind ?? 'getset';
const targets: { mode: string; label: string }[] =
kind === 'method'
? [{ mode: 'call', label: `${member}()` }]
: kind === 'get'
? [{ mode: 'get', label: member }]
: [
{ mode: 'get', label: `${member} (get)` },
{ mode: 'set', label: `${member} (set)` },
];
for (const { mode, label } of targets) {
all.push(label);
total += 1;
const key = `${iface}.${member}#${mode}`;
if (accessed.has(key)) {
covered.push(label);
coveredCount += 1;
} else if (STATIC_COVERAGE.has(key)) {
staticallyCovered.push(label);
staticCount += 1;
} else {
uncovered.push(label);
}
}
}
byInterface[iface] = {
members: all,
covered,
staticallyCovered,
uncovered,
};
}
const percent =
total === 0 ? 100 : Math.round((coveredCount / total) * 1000) / 10;
const effectivePercent =
total === 0
? 100
: Math.round(((coveredCount + staticCount) / total) * 1000) / 10;
return {
total,
covered: coveredCount,
staticallyCovered: staticCount,
percent,
effectivePercent,
byInterface,
};
}

View File

@ -0,0 +1,285 @@
/**
* Minimal, dependency-free jest-like assertion library. It must not rely on any
* Node/browser globals beyond the basics because it runs inside the SES plugin
* sandbox. Every failed matcher throws an {@link AssertionError}; the runner
* turns that into a red test with the message attached.
*
* Two properties matter for coverage correctness:
* - Failure messages are built lazily (only when an assertion fails), so passing
* assertions never touch the value's members.
* - `stringify` never enumerates non-plain objects (e.g. the recording proxies
* used for API coverage). Otherwise `JSON.stringify` would walk every property
* of a shape and inflate coverage with members the test never used.
*/
export class AssertionError extends Error {
constructor(message: string) {
super(message);
this.name = 'AssertionError';
}
}
function isPlainObject(value: object): boolean {
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function stringify(value: unknown): string {
if (typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'bigint') return `${value}n`;
if (typeof value === 'function')
return `[Function ${value.name || 'anonymous'}]`;
if (value === undefined) return 'undefined';
if (value === null) return 'null';
// Only enumerate plain objects/arrays. Host/proxy objects (e.g. Penpot shape
// proxies) are rendered opaquely so stringifying never reads their members.
if (
typeof value === 'object' &&
!Array.isArray(value) &&
!isPlainObject(value)
) {
return '[object]';
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (
typeof a !== 'object' ||
typeof b !== 'object' ||
a === null ||
b === null
) {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) return false;
const aKeys = Object.keys(a as Record<string, unknown>);
const bKeys = Object.keys(b as Record<string, unknown>);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every(
(key) =>
Object.prototype.hasOwnProperty.call(b, key) &&
deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key],
),
);
}
export interface Matchers {
toBe(expected: unknown): void;
toEqual(expected: unknown): void;
toBeTruthy(): void;
toBeFalsy(): void;
toBeNull(): void;
toBeUndefined(): void;
toBeDefined(): void;
toContain(item: unknown): void;
toHaveLength(length: number): void;
toBeGreaterThan(n: number): void;
toBeLessThan(n: number): void;
toBeCloseTo(n: number, numDigits?: number): void;
toThrow(expected?: string | RegExp): void;
}
export interface Expectation extends Matchers {
not: Matchers;
}
type Message = () => string;
function errorMessage(thrown: unknown): string {
return thrown instanceof Error ? thrown.message : String(thrown);
}
function messageMatches(message: string, expected?: string | RegExp): boolean {
if (typeof expected === 'string') return message.includes(expected);
if (expected instanceof RegExp) return expected.test(message);
return true;
}
function makeMatchers(actual: unknown, negate: boolean): Matchers {
// Message factories are only invoked on failure, so passing assertions never
// stringify `actual` (which would enumerate proxies and skew coverage).
const check = (pass: boolean, message: Message, negatedMessage: Message) => {
if (negate ? pass : !pass) {
throw new AssertionError((negate ? negatedMessage : message)());
}
};
return {
toBe(expected) {
check(
Object.is(actual, expected),
() => `Expected ${stringify(actual)} to be ${stringify(expected)}`,
() => `Expected ${stringify(actual)} not to be ${stringify(expected)}`,
);
},
toEqual(expected) {
check(
deepEqual(actual, expected),
() => `Expected ${stringify(actual)} to equal ${stringify(expected)}`,
() =>
`Expected ${stringify(actual)} not to equal ${stringify(expected)}`,
);
},
toBeTruthy() {
check(
!!actual,
() => `Expected ${stringify(actual)} to be truthy`,
() => `Expected ${stringify(actual)} not to be truthy`,
);
},
toBeFalsy() {
check(
!actual,
() => `Expected ${stringify(actual)} to be falsy`,
() => `Expected ${stringify(actual)} not to be falsy`,
);
},
toBeNull() {
check(
actual === null,
() => `Expected ${stringify(actual)} to be null`,
() => `Expected ${stringify(actual)} not to be null`,
);
},
toBeUndefined() {
check(
actual === undefined,
() => `Expected ${stringify(actual)} to be undefined`,
() => `Expected ${stringify(actual)} not to be undefined`,
);
},
toBeDefined() {
check(
actual !== undefined,
() => 'Expected value to be defined',
() => 'Expected value not to be defined',
);
},
toContain(item) {
const pass =
(typeof actual === 'string' && actual.includes(String(item))) ||
(Array.isArray(actual) && actual.includes(item));
check(
pass,
() => `Expected ${stringify(actual)} to contain ${stringify(item)}`,
() => `Expected ${stringify(actual)} not to contain ${stringify(item)}`,
);
},
toHaveLength(length) {
const actualLength = (actual as { length?: number })?.length;
check(
actualLength === length,
() =>
`Expected ${stringify(actual)} to have length ${length} but got ${actualLength}`,
() => `Expected ${stringify(actual)} not to have length ${length}`,
);
},
toBeGreaterThan(n) {
check(
typeof actual === 'number' && actual > n,
() => `Expected ${stringify(actual)} to be greater than ${n}`,
() => `Expected ${stringify(actual)} not to be greater than ${n}`,
);
},
toBeLessThan(n) {
check(
typeof actual === 'number' && actual < n,
() => `Expected ${stringify(actual)} to be less than ${n}`,
() => `Expected ${stringify(actual)} not to be less than ${n}`,
);
},
toBeCloseTo(n, numDigits = 2) {
const pass =
typeof actual === 'number' &&
Math.abs(actual - n) < Math.pow(10, -numDigits) / 2;
check(
pass,
() =>
`Expected ${stringify(actual)} to be close to ${n} (${numDigits} digits)`,
() =>
`Expected ${stringify(actual)} not to be close to ${n} (${numDigits} digits)`,
);
},
toThrow(expected) {
if (typeof actual !== 'function') {
throw new AssertionError(
`Expected a function to call but got ${stringify(actual)}`,
);
}
let thrown: unknown;
let didThrow = false;
try {
(actual as () => unknown)();
} catch (err) {
didThrow = true;
thrown = err;
}
if (!didThrow) {
check(
false,
() => 'Expected function to throw',
() => 'Expected function not to throw',
);
return;
}
const message = errorMessage(thrown);
const matches = messageMatches(message, expected);
check(
matches,
() =>
`Expected function to throw matching ${stringify(expected)} but threw ${stringify(message)}`,
() => `Expected function not to throw matching ${stringify(expected)}`,
);
},
};
}
export function expect(actual: unknown): Expectation {
const matchers = makeMatchers(actual, false) as Expectation;
matchers.not = makeMatchers(actual, true);
return matchers;
}
/**
* Async counterpart to {@link Matchers.toThrow}: awaits a promise (or a 0-arg
* thunk returning one) and asserts that it REJECTS. `toThrow` can't cover this
* because it calls its argument synchronously, but a large share of edge cases
* are async (uploads, exports, version/comment/library ops).
*
* A thunk that throws synchronously also counts as a rejection, so callers can
* pass `() => ctx.penpot.someAsyncCall(badArgs)` regardless of whether the
* failure surfaces before or after the first await. The optional `expected`
* matches the error message exactly like `toThrow` (string includes / RegExp).
*/
export async function expectReject(
actual: Promise<unknown> | (() => Promise<unknown> | unknown),
expected?: string | RegExp,
): Promise<void> {
let thrown: unknown;
let didReject = false;
try {
await (typeof actual === 'function' ? actual() : actual);
} catch (err) {
didReject = true;
thrown = err;
}
if (!didReject) {
throw new AssertionError('Expected promise to reject but it resolved');
}
const message = errorMessage(thrown);
if (!messageMatches(message, expected)) {
throw new AssertionError(
`Expected promise to reject matching ${stringify(expected)} but rejected with ${stringify(message)}`,
);
}
}

View File

@ -0,0 +1,115 @@
import type { TestCase, TestFn, TestMeta } from './types';
export const DEFAULT_GROUP = 'General';
let registry: TestCase[] = [];
let seenIds = new Set<string>();
const groupStack: string[] = [];
// >0 while inside a `describe.skipIfMocked` block; every test registered while
// it is positive is tagged `mockedSkip`.
let skipMockedDepth = 0;
/** Separator used to join nested `describe` names into a single group path. */
export const GROUP_SEPARATOR = ' / ';
function slugify(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Groups the tests registered inside `fn` under `name`. Groups are collapsible in
* the UI and show their own pass/fail counts. Calls may be nested in a file; the
* nested names are joined into a single hierarchical path (e.g. `Layout / Flex`)
* so a group always reveals the file/area it belongs to. Tests registered outside
* any `describe` fall into the {@link DEFAULT_GROUP}.
*/
function describeImpl(name: string, fn: () => void): void {
groupStack.push(name);
try {
fn();
} finally {
groupStack.pop();
}
}
/**
* Groups the tests registered inside `fn` under `name`.
*
* `describe.skipIfMocked(name, fn)` additionally tags every test registered in
* the block as {@link TestCase.mockedSkip} use it for a whole group of
* backend-dependent tests.
*/
export const describe: {
(name: string, fn: () => void): void;
skipIfMocked(name: string, fn: () => void): void;
} = Object.assign(describeImpl, {
skipIfMocked(name: string, fn: () => void): void {
skipMockedDepth++;
try {
describeImpl(name, fn);
} finally {
skipMockedDepth--;
}
},
});
function registerTest(name: string, fn: TestFn, mockedSkip: boolean): void {
const base = slugify(name) || 'test';
let id = base;
let n = 2;
while (seenIds.has(id)) {
id = `${base}-${n++}`;
}
seenIds.add(id);
const group = groupStack.length
? groupStack.join(GROUP_SEPARATOR)
: DEFAULT_GROUP;
registry.push({ id, name, group, fn, mockedSkip });
}
/**
* Registers a test. Called at module load time from the auto-discovered
* `tests/*.test.ts` files. Ids are derived from the name and de-duplicated so
* the UI and runner can address each test unambiguously.
*
* `test.skipIfMocked(name, fn)` registers a single test that is excluded when
* running against a mocked backend (see {@link TestCase.mockedSkip}).
*/
export const test: {
(name: string, fn: TestFn): void;
skipIfMocked(name: string, fn: TestFn): void;
} = Object.assign(
(name: string, fn: TestFn): void =>
registerTest(name, fn, skipMockedDepth > 0),
{
skipIfMocked(name: string, fn: TestFn): void {
registerTest(name, fn, true);
},
},
);
export function getTests(): TestCase[] {
return registry.slice();
}
export function getTestMetas(): TestMeta[] {
return registry.map(({ id, name, group }) => ({ id, name, group }));
}
/**
* Replaces the whole registry. Used by the reload mechanism, which evaluates a
* freshly built test bundle and hands back the discovered {@link TestCase}s.
*/
export function setTests(tests: TestCase[]): void {
registry = tests.slice();
seenIds = new Set(registry.map((t) => t.id));
}
export function clearTests(): void {
registry = [];
seenIds = new Set();
}

View File

@ -0,0 +1,179 @@
import apiSurface from '../generated/api-surface.json';
import { computeCoverage, createRecorder } from './coverage';
import { getTests } from './registry';
import type {
ApiSurface,
CoverageReport,
RunSummary,
TestResult,
} from './types';
const SCRATCH_NAME = '__api_test_scratch__';
// A single test must never freeze the whole run. Some plugin API calls can hang
// indefinitely (e.g. an async op whose completion event never fires), so each
// test is raced against this timeout and turned into a failure if it exceeds it.
const TEST_TIMEOUT_MS = 15000;
export interface RunOutput {
results: TestResult[];
summary: RunSummary;
coverage: CoverageReport;
/** Names of tests excluded because of {@link RunOptions.skipMocked}. */
skipped: string[];
}
export interface RunOptions {
/**
* When true, tests tagged {@link TestCase.mockedSkip} are excluded from the
* run (used by the mocked-backend CI mode). Their names are returned in
* {@link RunOutput.skipped}.
*/
skipMocked?: boolean;
}
export type ResultReporter = (result: TestResult) => void;
function withTimeout(promise: void | Promise<void>, ms: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
reject(new Error(`Test timed out after ${ms}ms`));
}
}, ms);
Promise.resolve(promise).then(
() => {
if (!settled) {
settled = true;
clearTimeout(timer);
resolve();
}
},
(err) => {
if (!settled) {
settled = true;
clearTimeout(timer);
reject(err);
}
},
);
});
}
/**
* Runs the selected tests (or all of them) in the plugin sandbox. Each test gets
* a fresh scratch board through the recording proxy and that board is removed
* afterwards, so the user's file is left clean. API usage across the whole run is
* accumulated and turned into a coverage report.
*/
export async function runTests(
ids: string[] | 'all',
onResult?: ResultReporter,
options?: RunOptions,
): Promise<RunOutput> {
const all = getTests();
const requested = ids === 'all' ? all : all.filter((t) => ids.includes(t.id));
const skipped = options?.skipMocked
? requested.filter((t) => t.mockedSkip).map((t) => t.name)
: [];
const selected = options?.skipMocked
? requested.filter((t) => !t.mockedSkip)
: requested;
const recorder = createRecorder(penpot, apiSurface as ApiSurface);
// Run every test with strict, deterministic API behavior. Set through the
// recording proxy so the flags also count towards coverage:
// - throwValidationErrors: invalid API usage throws instead of only logging,
// so it surfaces as a red test rather than passing silently.
// - naturalChildOrdering: `children` is always in z-index order and
// appendChild/insertChild respect it, making ordering assertions stable.
recorder.proxy.flags.throwValidationErrors = true;
recorder.proxy.flags.naturalChildOrdering = true;
// Remember the page that was active when the run started. Tests share global
// state (selection, the active page) with no per-test reset, so a test that
// changes the active page — or fails before restoring it — would silently make
// every later test run on the wrong page. After each test we clear the
// selection and restore this page, all through the *raw* penpot so the cleanup
// isn't credited toward coverage.
const homePage = penpot.currentPage;
const results: TestResult[] = [];
for (const testCase of selected) {
onResult?.({
id: testCase.id,
name: testCase.name,
status: 'running',
durationMs: 0,
});
const start = Date.now();
// Create/name/remove the scratch board through the *raw* penpot so this
// harness bookkeeping isn't credited toward coverage. The test still gets a
// recording-wrapped board, so its own access to it is counted.
let rawBoard: ReturnType<typeof penpot.createBoard> | undefined;
let result: TestResult;
try {
rawBoard = penpot.createBoard();
rawBoard.name = SCRATCH_NAME;
const board = recorder.wrap(rawBoard, 'Board');
await withTimeout(
testCase.fn({ penpot: recorder.proxy, board }),
TEST_TIMEOUT_MS,
);
result = {
id: testCase.id,
name: testCase.name,
status: 'pass',
durationMs: Date.now() - start,
};
} catch (err) {
result = {
id: testCase.id,
name: testCase.name,
status: 'fail',
error: err instanceof Error ? err.message : String(err),
durationMs: Date.now() - start,
};
} finally {
try {
rawBoard?.remove();
} catch {
// best-effort cleanup; never fail a test because teardown failed
}
// Reset shared state so the next test starts clean. All best-effort: a
// teardown failure must never turn into a test failure.
try {
penpot.selection = [];
} catch {
/* ignore */
}
try {
const active = penpot.currentPage;
if (homePage && active && active.id !== homePage.id) {
await penpot.openPage(homePage);
}
} catch {
/* ignore */
}
}
results.push(result);
onResult?.(result);
}
const summary: RunSummary = {
total: results.length,
passed: results.filter((r) => r.status === 'pass').length,
failed: results.filter((r) => r.status === 'fail').length,
};
const coverage = computeCoverage(recorder.accessed, apiSurface as ApiSurface);
return { results, summary, coverage, skipped };
}

View File

@ -0,0 +1,70 @@
/**
* "Statically covered" coverage targets.
*
* These members ARE exercised behaviourally by the test suite, but the recording
* proxy structurally cannot credit them, so they would otherwise show as
* uncovered. The reasons are all recorder limitations: frozen SES values (the
* proxy must return them raw), base-interface attribution (members redeclared on
* concrete types are credited there), type maps (events are credited on
* `Penpot.on/off`), type-guard narrowing the recorder can't perform, and methods
* whose return type the surface generator couldn't resolve (the result is handed
* back raw). See README.md "Coverage notes".
*
* Keys are `Interface.member#mode` (mode get/set/call), exactly matching the
* recorder's accessed-set keys and the targets in `computeCoverage`. Only add a
* target here when a named test genuinely exercises it this set feeds the
* "effective" coverage number, so over-claiming makes that number dishonest.
*
* Recorder-credited (recorded) coverage always wins over this set: a target that
* turns out to be recorded simply never shows as static.
*/
export const STATIC_COVERAGE: ReadonlySet<string> = new Set<string>([
// ShapeBase.fills — every concrete shape redeclares `fills`, so accesses are
// attributed to the concrete type (Rectangle.fills, …); exercised pervasively
// (fills-strokes.test.ts, misc.test.ts).
'ShapeBase.fills#get',
'ShapeBase.fills#set',
// utils.types predicates — `penpot.utils.types` is a frozen data property, so
// its members can't be wrapped. Exercised in platform.test.ts.
'ContextTypesUtils.isBoard#call',
'ContextTypesUtils.isBool#call',
'ContextTypesUtils.isEllipse#call',
'ContextTypesUtils.isGroup#call',
'ContextTypesUtils.isMask#call',
'ContextTypesUtils.isPath#call',
'ContextTypesUtils.isRectangle#call',
'ContextTypesUtils.isSVG#call',
'ContextTypesUtils.isText#call',
'ContextTypesUtils.isVariantComponent#call',
'ContextTypesUtils.isVariantContainer#call',
// utils.geometry.center — `penpot.utils.geometry` is likewise a frozen data
// property, so the call can't be wrapped. Exercised (and verified) in
// platform.test.ts.
'ContextGeometryUtils.center#call',
// shapesColors() returns objects whose declared type the surface generator
// couldn't resolve (it records as `type: null`), so the recorder hands the
// result back raw and cannot credit nested access. The members are exercised
// in colors.test.ts (entry.shapesInfo[0].property / .shapeId).
'ColorShapeInfo.shapesInfo#get',
'ColorShapeInfoEntry.index#get',
'ColorShapeInfoEntry.property#get',
'ColorShapeInfoEntry.shapeId#get',
// Deterministic events — `on`/`off` are credited on `Penpot`, never as
// `EventsMap` members. Exercised in events.test.ts. The remaining events
// (pagechange/filechange/themechange/contentsave/finish) are not triggered
// deterministically headless and stay genuinely uncovered.
'EventsMap.selectionchange#get',
'EventsMap.shapechange#get',
// LibraryVariantComponent — the recorder types a component as LibraryComponent
// and can't narrow via the isVariant() type-guard; the behaviour is exercised
// via VariantContainer.variants in variants.test.ts.
'LibraryVariantComponent.variants#get',
'LibraryVariantComponent.variantProps#get',
'LibraryVariantComponent.addVariant#call',
'LibraryVariantComponent.setVariantProperty#call',
]);

View File

@ -0,0 +1,118 @@
import type { Board, Penpot } from '@penpot/plugin-types';
export type TestStatus = 'pending' | 'running' | 'pass' | 'fail';
/**
* The context handed to every test. Tests MUST use `ctx.penpot` (the recording
* proxy) rather than the global `penpot` so their API usage is counted towards
* coverage. A fresh scratch `board` is provided per test and removed afterwards.
*/
export interface TestContext {
penpot: Penpot;
board: Board;
}
export type TestFn = (ctx: TestContext) => void | Promise<void>;
export interface TestCase {
id: string;
name: string;
/** Group the test belongs to (set via `describe`, defaults to "General"). */
group: string;
fn: TestFn;
/**
* When true, the test is excluded from runs against a mocked backend
* (`MOCK_BACKEND=1`): it depends on real backend results/validation that a
* `page.route` mock cannot faithfully reproduce. Set via `test.skipIfMocked`
* or `describe.skipIfMocked`.
*/
mockedSkip?: boolean;
}
/** Lightweight test description sent to the UI (no function). */
export interface TestMeta {
id: string;
name: string;
group: string;
}
export interface TestResult {
id: string;
name: string;
status: TestStatus;
error?: string;
durationMs: number;
}
export interface RunSummary {
total: number;
passed: number;
failed: number;
}
/** Per-interface coverage detail derived from the public Plugin API types. */
export interface InterfaceCoverage {
members: string[];
covered: string[];
/**
* Targets exercised behaviourally by the tests but not creditable through the
* recording proxy (see {@link ../framework/static-coverage}). Listed
* separately from `covered` and `uncovered`.
*/
staticallyCovered: string[];
uncovered: string[];
}
export interface CoverageReport {
total: number;
/** Targets credited by the recording proxy. */
covered: number;
/** Targets covered only via the static allowlist (not recorder-credited). */
staticallyCovered: number;
/** Recorder-credited coverage: `covered / total`. */
percent: number;
/** Effective coverage including static targets: `(covered + static) / total`. */
effectivePercent: number;
byInterface: Record<string, InterfaceCoverage>;
}
/**
* How a member is exercised, which determines the coverage targets it has:
* - `method`: callable -> a single `call` target.
* - `get`: read-only property -> a single `get` target.
* - `getset`: writable property -> separate `get` and `set` targets.
*/
export type MemberKind = 'method' | 'get' | 'getset';
/** A single member in the API type graph. */
export interface ApiMemberInfo {
/** Interface that actually declares this member (may be a base interface). */
decl: string;
/** Whether the member is a method, a read-only, or a writable property. */
kind: MemberKind;
/**
* The interface/union name the member yields (return type for methods,
* property type otherwise), or `null` when it is a primitive/untracked type.
*/
type: string | null;
/** True when the member yields an array of `type`. */
array: boolean;
}
/** A union alias (e.g. `Shape`) and how to resolve it at runtime. */
export interface UnionInfo {
variants: string[];
/** Discriminant used to pick the concrete variant from a runtime value. */
discriminant: { field: string; map: Record<string, string> } | null;
}
/**
* Shape of the generated `api-surface.json`. Coverage is type-aware: `interfaces`
* is the denominator (own members per interface) and `graph`/`unions` let the
* recorder attribute each access to the interface the value actually is.
*/
export interface ApiSurface {
interfaces: Record<string, string[]>;
graph: Record<string, Record<string, ApiMemberInfo>>;
unions: Record<string, UnionInfo>;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,60 @@
import type {
CoverageReport,
RunSummary,
TestMeta,
TestResult,
} from './framework/types';
// Messages sent from the UI iframe to the plugin sandbox.
export interface ReadyMessage {
type: 'ready';
}
export interface RunMessage {
type: 'run';
ids: string[] | 'all';
}
/** Carries the freshly built tests bundle source to be evaluated in the sandbox. */
export interface ReloadTestsMessage {
type: 'reloadTests';
code: string;
}
export type UIToPluginMessage = ReadyMessage | RunMessage | ReloadTestsMessage;
// Messages sent from the plugin sandbox to the UI iframe.
export interface TestsMessage {
type: 'tests';
tests: TestMeta[];
}
export interface ResultMessage {
type: 'result';
result: TestResult;
}
export interface RunCompleteMessage {
type: 'runComplete';
summary: RunSummary;
coverage: CoverageReport;
}
export interface ThemeMessage {
type: 'theme';
theme: string;
}
/** Sent after a reload attempt so the UI can surface success/failure. */
export interface ReloadedMessage {
type: 'reloaded';
ok: boolean;
error?: string;
}
export type PluginToUIMessage =
| TestsMessage
| ResultMessage
| RunCompleteMessage
| ThemeMessage
| ReloadedMessage;

View File

@ -0,0 +1,63 @@
import { getTestMetas, setTests } from './framework/registry';
import type { TestCase } from './framework/types';
import { runTests } from './framework/runner';
import type { PluginToUIMessage, UIToPluginMessage } from './model';
// Auto-discover every test. Importing the modules eagerly runs their top-level
// `test(...)` calls, which register them into the shared registry.
import.meta.glob('./tests/*.test.ts', { eager: true });
penpot.ui.open('Plugin API Test Suite', `?theme=${penpot.theme}`, {
width: 400,
height: 600,
});
function send(message: PluginToUIMessage) {
penpot.ui.sendMessage(message);
}
penpot.ui.onMessage<UIToPluginMessage>(async (message) => {
if (message.type === 'ready') {
send({ type: 'tests', tests: getTestMetas() });
return;
}
if (message.type === 'run') {
const { summary, coverage } = await runTests(message.ids, (result) =>
send({ type: 'result', result }),
);
send({ type: 'runComplete', summary, coverage });
return;
}
if (message.type === 'reloadTests') {
try {
// The runtime is configured with `evalTaming: 'unsafeEval'`, so evaluating
// the freshly built IIFE bundle is allowed. It publishes the discovered
// tests on `globalThis.__penpotReloadedTests`, which we swap into the
// registry so the next run uses the edited code.
const globals = globalThis as unknown as {
__penpotReloadedTests?: TestCase[];
};
globals.__penpotReloadedTests = undefined;
(0, eval)(message.code);
const reloaded = globals.__penpotReloadedTests;
if (!reloaded) {
throw new Error('Reloaded bundle did not expose any tests');
}
setTests(reloaded);
send({ type: 'tests', tests: getTestMetas() });
send({ type: 'reloaded', ok: true });
} catch (err) {
send({
type: 'reloaded',
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
}
});
penpot.on('themechange', () => {
send({ type: 'theme', theme: penpot.theme });
});

View File

@ -0,0 +1,15 @@
import { getTests } from './framework/registry';
// Standalone bundle of all test cases, built as a single self-executing (IIFE)
// chunk by `vite.config.tests.ts` and rebuilt on every save by `watch`.
//
// The reload flow (see `src/plugin.ts`) fetches the freshly built bundle and
// `eval`s it inside the plugin sandbox. Importing the test modules registers them
// into this bundle's own registry; we then publish the discovered tests on
// `globalThis` so the sandbox can pick them up and swap them in without the user
// having to close and reopen the plugin.
import.meta.glob('./tests/*.test.ts', { eager: true });
(
globalThis as unknown as { __penpotReloadedTests?: unknown }
).__penpotReloadedTests = getTests();

View File

@ -0,0 +1,50 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { TestContext } from '../framework/types';
// Colors.
// Exercises the context-level color helpers shapesColors() and replaceColor(),
// plus the ColorShapeInfo metadata they expose.
function rect(ctx: TestContext) {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
return r;
}
describe('Colors', () => {
test('shapesColors lists the colors used by shapes', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#abcdef', fillOpacity: 1 }];
const colors = ctx.penpot.shapesColors([r]);
expect(colors.length).toBeGreaterThan(0);
const entry = colors.find((c) => c.color === '#abcdef');
expect(entry).toBeDefined();
if (entry) {
expect(entry.shapesInfo).toBeDefined();
expect(entry.shapesInfo.length).toBeGreaterThan(0);
expect(entry.shapesInfo[0].property).toBe('fill');
expect(entry.shapesInfo[0].shapeId).toBe(r.id);
}
});
test('replaceColor swaps a solid fill color', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#111111', fillOpacity: 1 }];
// replaceColor matches by exact color-attrs equality, so the old color must
// include the same opacity the fill has.
ctx.penpot.replaceColor(
[r],
{ color: '#111111', opacity: 1 },
{ color: '#222222', opacity: 1 },
);
const fills = r.fills;
if (Array.isArray(fills)) {
expect(fills[0].fillColor).toBe('#222222');
}
});
});

View File

@ -0,0 +1,158 @@
import { expect, expectReject } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { CommentThread, Page } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Comments.
// Comment threads are created on the current page. Both thread removal APIs are
// currently broken (see the dedicated red tests), so cleanup is best-effort to
// keep the other assertions meaningful.
function page(ctx: TestContext): Page {
const p = ctx.penpot.currentPage;
if (!p) throw new Error('no current page');
return p;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function cleanup(thread: CommentThread): void {
try {
thread.remove();
} catch (err) {
void err; // thread.remove is currently broken; ignore for cleanup
}
}
// Skipped under MOCK_BACKEND: comments assert backend-shaped responses
// (seqNumber, etc.) and pin real backend behaviour that a mock won't reproduce.
describe.skipIfMocked('Comments', () => {
test('addCommentThread creates a thread', async (ctx) => {
const p = page(ctx);
const thread = await p.addCommentThread('Hello comment', {
x: 100,
y: 120,
});
try {
expect(typeof thread.seqNumber).toBe('number');
expect(thread.position.x).toBeCloseTo(100, 0);
expect(thread.position.y).toBeCloseTo(120, 0);
expect(thread.resolved).toBe(false);
expect(thread.owner).toBeDefined();
// A page-level thread has no board; reading it still exercises the getter.
void thread.board;
} finally {
cleanup(thread);
}
});
test('findCommentThreads lists threads', async (ctx) => {
const p = page(ctx);
const thread = await p.addCommentThread('Find me', { x: 50, y: 50 });
try {
const threads = await p.findCommentThreads();
expect(threads.length).toBeGreaterThan(0);
} finally {
cleanup(thread);
}
});
test('reply adds a comment and findComments lists them', async (ctx) => {
const p = page(ctx);
const thread = await p.addCommentThread('First comment', { x: 10, y: 10 });
try {
const reply = await thread.reply('A reply');
expect(reply.content).toBe('A reply');
expect(reply.user).toBeDefined();
expect(reply.date).toBeDefined();
const comments = await thread.findComments();
expect(comments.length).toBeGreaterThan(1);
} finally {
cleanup(thread);
}
});
test('thread resolved and position round-trip', async (ctx) => {
const p = page(ctx);
const thread = await p.addCommentThread('Toggle me', { x: 30, y: 30 });
try {
thread.resolved = true;
expect(thread.resolved).toBe(true);
thread.position = { x: 200, y: 220 };
expect(thread.position.x).toBeCloseTo(200, 0);
expect(thread.position.y).toBeCloseTo(220, 0);
} finally {
cleanup(thread);
}
});
test('comment content round-trips', async (ctx) => {
const p = page(ctx);
const thread = await p.addCommentThread('Editable', { x: 0, y: 0 });
try {
const comments = await thread.findComments();
const comment = comments[0];
comment.content = 'edited content';
// The content setter persists via an async RPC before updating locally.
await sleep(300);
expect(comment.content).toBe('edited content');
expect(comment.user).toBeDefined();
} finally {
cleanup(thread);
}
});
test('a comment can be removed', async (ctx) => {
const p = page(ctx);
const thread = await p.addCommentThread('Keep', { x: 5, y: 5 });
try {
const reply = await thread.reply('to be removed');
await reply.remove();
const comments = await thread.findComments();
expect(comments.length).toBeGreaterThan(0);
} finally {
cleanup(thread);
}
});
test('a comment thread can be removed', async (ctx) => {
const p = page(ctx);
const thread = await p.addCommentThread('Remove via thread', {
x: 8,
y: 8,
});
thread.remove();
const threads = await p.findCommentThreads();
expect(threads.every((t) => t.seqNumber !== thread.seqNumber)).toBe(true);
});
test('removeCommentThread removes a thread', async (ctx) => {
const p = page(ctx);
const thread: CommentThread = await p.addCommentThread('Remove me', {
x: 70,
y: 70,
});
await p.removeCommentThread(thread);
});
// ---------------------------------------------------------------------------
// Edge cases: empty comment content must be rejected.
// ---------------------------------------------------------------------------
test('addCommentThread with empty content rejects', async (ctx) => {
const p = page(ctx);
await expectReject(() => p.addCommentThread('', { x: 0, y: 0 }));
});
test('reply with empty content rejects', async (ctx) => {
const p = page(ctx);
const thread = await p.addCommentThread('parent', { x: 12, y: 12 });
try {
await expectReject(() => thread.reply(''));
} finally {
cleanup(thread);
}
});
});

View File

@ -0,0 +1,135 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board, Shape } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Component instances and the ShapeBase component methods.
// A component is built from a rectangle and instantiated; the instance exposes
// the component predicates and navigation methods.
function makeComponent(ctx: TestContext) {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
return ctx.penpot.library.local.createComponent([rect]);
}
function instanceOf(ctx: TestContext): Shape {
const comp = makeComponent(ctx);
const inst = comp.instance();
ctx.board.appendChild(inst);
return inst;
}
describe('Component instances', () => {
test('component predicates identify an instance', (ctx) => {
const inst = instanceOf(ctx);
expect(inst.isComponentInstance()).toBeTruthy();
expect(inst.isComponentRoot()).toBeTruthy();
expect(inst.isComponentHead()).toBeTruthy();
// A fresh instance is a copy, not the main instance.
expect(inst.isComponentMainInstance()).toBeFalsy();
expect(inst.isComponentCopyInstance()).toBeTruthy();
expect(inst.isVariantHead()).toBeFalsy();
});
test('component navigation methods return shapes', (ctx) => {
const inst = instanceOf(ctx);
expect(inst.componentRoot()).toBeDefined();
expect(inst.componentHead()).toBeDefined();
expect(inst.componentRefShape()).toBeDefined();
});
test('component() returns the library component', (ctx) => {
const inst = instanceOf(ctx);
const comp = inst.component();
expect(comp).not.toBeNull();
if (comp) {
expect(typeof comp.id).toBe('string');
}
});
test('detach turns an instance into a basic shape', (ctx) => {
const inst = instanceOf(ctx);
inst.detach();
expect(inst.isComponentInstance()).toBeFalsy();
});
test('swapComponent replaces the instance component', (ctx) => {
const inst = instanceOf(ctx);
const other = makeComponent(ctx);
inst.swapComponent(other);
const comp = inst.component();
expect(comp).not.toBeNull();
if (comp) {
expect(comp.id).toBe(other.id);
}
});
// ---------------------------------------------------------------------------
// Edge cases. "fail" tests exercise the component methods on shapes
// that are not component instances (documented null/self returns, invalid
// swap target); the "success" test checks instance independence.
// ---------------------------------------------------------------------------
test('component() on a plain shape returns null', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(rect.component()).toBeNull();
});
test('componentRoot() on a plain shape returns null', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
// componentRoot (like component(), componentHead(), componentRefShape())
// is null for a shape that is not part of any component. The d.ts
// "returns itself" note applies to a shape that IS the root of a component.
expect(rect.componentRoot()).toBeNull();
});
test('swapComponent with a non-component target throws', (ctx) => {
const inst = instanceOf(ctx);
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(() =>
inst.swapComponent(rect as unknown as ReturnType<typeof makeComponent>),
).toThrow();
});
test('two instances of one component are independent but share the source', (ctx) => {
const comp = makeComponent(ctx);
const first = comp.instance();
const second = comp.instance();
ctx.board.appendChild(first);
ctx.board.appendChild(second);
first.name = 'first';
second.name = 'second';
expect(first.id).not.toBe(second.id);
expect(first.name).toBe('first');
expect(second.name).toBe('second');
const c1 = first.component();
const c2 = second.component();
expect(c1).not.toBeNull();
expect(c2).not.toBeNull();
if (c1 && c2) {
expect(c1.id).toBe(c2.id);
}
});
});
describe('Shape interactions cleanup', () => {
test('removeInteraction removes an interaction from a shape', (ctx) => {
const dest = ctx.penpot.createBoard();
ctx.board.appendChild(dest as Board);
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const interaction = rect.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
const before = rect.interactions.length;
rect.removeInteraction(interaction);
expect(rect.interactions.length).toBe(before - 1);
});
});

View File

@ -0,0 +1,67 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
// Events.
// Listeners are registered with `on`, triggered by mutating state, and removed
// with `off`. Callbacks are debounced (~10ms), so the tests wait before asserting.
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('Events', () => {
test('selectionchange fires with the selected ids', async (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
let received: string[] | null = null;
const listenerId = ctx.penpot.on('selectionchange', (ids) => {
received = ids;
});
ctx.penpot.selection = [rect];
await sleep(150);
ctx.penpot.off(listenerId);
expect(received).not.toBeNull();
if (received) {
expect((received as string[]).includes(rect.id)).toBe(true);
}
});
test('shapechange fires when the observed shape changes', async (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
let fired = false;
const listenerId = ctx.penpot.on(
'shapechange',
() => {
fired = true;
},
{ shapeId: rect.id },
);
rect.name = 'changed-name';
await sleep(150);
ctx.penpot.off(listenerId);
expect(fired).toBe(true);
});
test('off stops further notifications', async (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
let count = 0;
const listenerId = ctx.penpot.on('selectionchange', () => {
count += 1;
});
ctx.penpot.off(listenerId);
ctx.penpot.selection = [rect];
await sleep(150);
expect(count).toBe(0);
});
});

View File

@ -0,0 +1,103 @@
import { expect, expectReject } from '../framework/expect';
import { describe, test } from '../framework/registry';
// File & versions.
// Read-only assertions on currentFile plus the version history API. The file
// name is only read (renaming would mutate the user's file).
describe('File', () => {
test('currentFile exposes id and name', (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
expect(typeof file.id).toBe('string');
expect(typeof file.name).toBe('string');
}
});
test('currentFile exposes revn', (ctx) => {
const file = ctx.penpot.currentFile;
if (file) {
expect(typeof file.revn).toBe('number');
}
});
test('file lists its pages', (ctx) => {
const file = ctx.penpot.currentFile;
if (file) {
expect(file.pages.length).toBeGreaterThan(0);
expect(typeof file.pages[0].id).toBe('string');
}
});
test('export returns binary data', async (ctx) => {
const file = ctx.penpot.currentFile;
if (file) {
// The exporter service may be unavailable in the headless runner, so a
// rejection here is treated as an environment limitation; when it does
// run, the result must be a non-empty byte array.
const data = await file.export('penpot', 'detach').catch(() => null);
if (data) {
expect(data.length).toBeGreaterThan(0);
}
}
});
// Skipped under MOCK_BACKEND: version history is persisted/returned by the
// backend; a no-op persist mock can't reproduce saved versions.
describe.skipIfMocked('Versions', () => {
test('saveVersion and findVersions manage version history', async (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
const version = await file.saveVersion('plugin-test-version');
expect(version).toBeDefined();
expect(version.label).toBe('plugin-test-version');
expect(version.isAutosave).toBe(false);
// Relabel the saved version (covers FileVersion.label set).
version.label = 'plugin-test-version-renamed';
expect(version.label).toBe('plugin-test-version-renamed');
const versions = await file.findVersions();
expect(versions.length).toBeGreaterThan(0);
// Clean up the version we just created.
await version.remove();
}
});
test('version exposes its creation date', async (ctx) => {
const file = ctx.penpot.currentFile;
if (file) {
const version = await file.saveVersion('plugin-test-version-date');
try {
expect(version.createdAt).toBeDefined();
} finally {
await version.remove();
}
}
});
test('version createdBy is exercised', async (ctx) => {
const file = ctx.penpot.currentFile;
if (file) {
const version = await file.saveVersion('plugin-test-version-pin');
void version.createdBy;
// `pin` is intentionally not exercised: it only converts a *system*
// autosave to a permanent version, and a plugin cannot create an
// autosave, so calling it would always reject. See README.md.
await version.remove().catch(() => undefined);
}
});
// Edge case: an empty version label must be rejected.
test('saveVersion with an empty label rejects', async (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
await expectReject(() => file.saveVersion(''));
}
});
});
});

View File

@ -0,0 +1,280 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { TestContext } from '../framework/types';
// Fills & strokes.
// Fills/strokes are assigned as whole arrays of plain objects and read back
// through the shape proxy, so the Fill/Stroke getters are what coverage records
// (the per-property setters are not individually settable at runtime).
//
// Each group bundles its happy-path round-trips together with the related edge
// cases: "throws" tests assert invalid input is rejected, the "(currently
// unvalidated)" tests pin lenient behaviour, and the remaining ones cover
// non-trivial valid behaviour (ordering, type switching, multiple strokes,
// clearing).
function rect(ctx: TestContext) {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
return r;
}
describe('Fills & strokes', () => {
describe('Fills', () => {
test('solid fill color and opacity round-trip', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#ff0000', fillOpacity: 0.5 }];
const fills = r.fills;
expect(fills).toHaveLength(1);
if (Array.isArray(fills)) {
expect(fills[0].fillColor).toBe('#ff0000');
expect(fills[0].fillOpacity).toBeCloseTo(0.5, 2);
}
});
test('gradient fill is preserved', (ctx) => {
const r = rect(ctx);
r.fills = [
{
fillColorGradient: {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
},
},
];
const fills = r.fills;
if (Array.isArray(fills)) {
const gradient = fills[0].fillColorGradient;
expect(gradient).toBeDefined();
expect(gradient && gradient.type).toBe('linear');
}
});
test('multiple fills can be stacked', (ctx) => {
const r = rect(ctx);
r.fills = [
{ fillColor: '#ff0000', fillOpacity: 0.5 },
{ fillColor: '#0000ff', fillOpacity: 0.5 },
];
expect(r.fills).toHaveLength(2);
});
test('multiple fills preserve their order', (ctx) => {
const r = rect(ctx);
r.fills = [
{ fillColor: '#ff0000', fillOpacity: 1 },
{ fillColor: '#00ff00', fillOpacity: 1 },
{ fillColor: '#0000ff', fillOpacity: 1 },
];
const fills = r.fills;
expect(fills).toHaveLength(3);
if (Array.isArray(fills)) {
expect(fills.map((f) => f.fillColor)).toEqual([
'#ff0000',
'#00ff00',
'#0000ff',
]);
}
});
test('a fill can switch solid -> gradient -> solid', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
r.fills = [
{
fillColorGradient: {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
},
},
];
let fills = r.fills;
if (Array.isArray(fills)) {
expect(fills[0].fillColorGradient).toBeDefined();
}
r.fills = [{ fillColor: '#00ff00', fillOpacity: 1 }];
fills = r.fills;
if (Array.isArray(fills)) {
expect(fills[0].fillColor).toBe('#00ff00');
// Switching back to a solid fill clears the gradient (read back as null).
expect(fills[0].fillColorGradient).toBeFalsy();
}
});
test('fillOpacity above 1 throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1.5 }];
}).toThrow();
});
test('fillOpacity below 0 throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.fills = [{ fillColor: '#ff0000', fillOpacity: -0.5 }];
}).toThrow();
});
test('setting fills on a group is accepted (currently unvalidated)', (ctx) => {
// The plugin API does not block fills on groups, so the assignment is
// accepted rather than rejected. This pins the current (lenient) behaviour.
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(group).not.toBeNull();
if (group) {
expect(() => {
group.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
}).not.toThrow();
}
});
test('assigning empty arrays clears fills and strokes', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
r.fills = [];
r.strokes = [];
expect(r.fills).toHaveLength(0);
expect(r.strokes).toHaveLength(0);
});
});
describe('Strokes', () => {
test('stroke properties round-trip', (ctx) => {
const r = rect(ctx);
r.strokes = [
{
strokeColor: '#0000ff',
strokeOpacity: 1,
strokeStyle: 'solid',
strokeWidth: 3,
strokeAlignment: 'center',
},
];
expect(r.strokes).toHaveLength(1);
const stroke = r.strokes[0];
expect(stroke.strokeColor).toBe('#0000ff');
expect(stroke.strokeOpacity).toBeCloseTo(1, 2);
expect(stroke.strokeStyle).toBe('solid');
expect(stroke.strokeWidth).toBeCloseTo(3, 0);
expect(stroke.strokeAlignment).toBe('center');
});
test('stroke caps round-trip on an open path', (ctx) => {
const path = ctx.penpot.createPath();
ctx.board.appendChild(path);
path.d = 'M0 0 L40 0';
path.strokes = [
{
strokeColor: '#000000',
strokeWidth: 4,
strokeCapStart: 'round',
strokeCapEnd: 'triangle-arrow',
},
];
const stroke = path.strokes[0];
expect(stroke.strokeCapStart).toBe('round');
expect(stroke.strokeCapEnd).toBe('triangle-arrow');
});
test('dashed stroke style is preserved', (ctx) => {
const r = rect(ctx);
r.strokes = [
{ strokeColor: '#00ff00', strokeWidth: 2, strokeStyle: 'dashed' },
];
expect(r.strokes[0].strokeStyle).toBe('dashed');
});
test('dotted stroke style is preserved', (ctx) => {
const r = rect(ctx);
r.strokes = [
{ strokeColor: '#0000ff', strokeWidth: 2, strokeStyle: 'dotted' },
];
expect(r.strokes[0].strokeStyle).toBe('dotted');
});
test("stroke style 'none' is rejected at runtime (d.ts lists it)", (ctx) => {
// The d.ts allows strokeStyle 'none', but the runtime rejects it as an
// invalid value ("Value not valid"), so with throwValidationErrors it
// throws. Pins the current d.ts/runtime mismatch.
const r = rect(ctx);
expect(() => {
r.strokes = [
{ strokeColor: '#0000ff', strokeWidth: 2, strokeStyle: 'none' },
];
}).toThrow();
});
test('two strokes with different alignment coexist', (ctx) => {
const r = rect(ctx);
r.strokes = [
{ strokeColor: '#000000', strokeWidth: 2, strokeAlignment: 'inner' },
{ strokeColor: '#ffffff', strokeWidth: 1, strokeAlignment: 'outer' },
];
expect(r.strokes).toHaveLength(2);
expect(r.strokes.map((s) => s.strokeAlignment).sort()).toEqual([
'inner',
'outer',
]);
});
test('negative strokeWidth is accepted (currently unvalidated)', (ctx) => {
// The plugin API does not constrain strokeWidth to be non-negative, so a
// negative value is stored as-is rather than rejected. This pins the current
// (lenient) behaviour.
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: -3 }];
expect(r.strokes).toHaveLength(1);
expect(typeof r.strokes[0].strokeWidth).toBe('number');
});
test('invalid strokeStyle throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.strokes = [
{
strokeColor: '#000000',
strokeWidth: 1,
strokeStyle: 'wavy' as unknown as 'solid',
},
];
}).toThrow();
});
test('invalid strokeAlignment throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.strokes = [
{
strokeColor: '#000000',
strokeWidth: 1,
strokeAlignment: 'middle' as unknown as 'center',
},
];
}).toThrow();
});
});
});

View File

@ -0,0 +1,12 @@
// Shared test fixtures. Not a `*.test.ts`, so the runner's glob doesn't pick it
// up as a test file; it's only imported by the tests that need it.
// A valid 1x1 PNG (opaque red, RGBA), so uploadMediaData needs no network. The
// bytes must form a well-formed PNG — the backend processes the image with
// ImageMagick, which rejects a malformed IDAT chunk (bad CRC / extra data).
export const PNG_1X1 = new Uint8Array([
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0,
0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120,
156, 99, 248, 207, 192, 240, 31, 0, 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0,
0, 73, 69, 78, 68, 174, 66, 96, 130,
]);

View File

@ -0,0 +1,122 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Text } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Fonts.
// Exercises the FontsContext lookups, the Font/FontVariant metadata, applying a
// font to a text shape / range, and generateFontFaces. Fonts are self-provided
// from `fonts.all` so the tests don't depend on a specific font being present.
function text(ctx: TestContext, value = 'Hello Penpot'): Text {
const t = ctx.penpot.createText(value);
if (!t) throw new Error('createText returned null');
ctx.board.appendChild(t);
return t;
}
describe('Fonts', () => {
test('fonts.all lists available fonts', (ctx) => {
const all = ctx.penpot.fonts.all;
expect(all.length).toBeGreaterThan(0);
});
test('a font exposes metadata and variants', (ctx) => {
const font = ctx.penpot.fonts.all[0];
expect(typeof font.name).toBe('string');
expect(typeof font.fontId).toBe('string');
expect(typeof font.fontFamily).toBe('string');
expect(typeof font.fontVariantId).toBe('string');
expect(typeof font.fontWeight).toBe('string');
// fontStyle is optional (string or null).
expect(font.fontStyle == null || typeof font.fontStyle === 'string').toBe(
true,
);
expect(font.variants.length).toBeGreaterThan(0);
const variant = font.variants[0];
expect(typeof variant.name).toBe('string');
expect(typeof variant.fontVariantId).toBe('string');
expect(typeof variant.fontWeight).toBe('string');
expect(
variant.fontStyle === 'normal' || variant.fontStyle === 'italic',
).toBe(true);
});
test('findById returns the matching font', (ctx) => {
const font = ctx.penpot.fonts.all[0];
const found = ctx.penpot.fonts.findById(font.fontId);
expect(found).not.toBeNull();
expect(found && found.fontId).toBe(font.fontId);
});
test('findByName returns the matching font', (ctx) => {
const font = ctx.penpot.fonts.all[0];
const found = ctx.penpot.fonts.findByName(font.name);
expect(found).not.toBeNull();
expect(found && found.name).toBe(font.name);
});
test('findAllById and findAllByName return arrays', (ctx) => {
const font = ctx.penpot.fonts.all[0];
expect(ctx.penpot.fonts.findAllById(font.fontId).length).toBeGreaterThan(0);
expect(ctx.penpot.fonts.findAllByName(font.name).length).toBeGreaterThan(0);
});
test('applyToText sets the font on a text shape', (ctx) => {
const t = text(ctx);
const font = ctx.penpot.fonts.all[0];
font.applyToText(t);
expect(t.fontId).toBe(font.fontId);
});
test('applyToRange sets the font on a text range', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const font = ctx.penpot.fonts.all[0];
const range = t.getRange(0, 5);
font.applyToRange(range);
expect(range.fontId).toBe(font.fontId);
});
test('generateFontFaces returns a css string', async (ctx) => {
const t = text(ctx);
const faces = await ctx.penpot.generateFontFaces([t]);
expect(typeof faces).toBe('string');
});
// ---------------------------------------------------------------------------
// Edge cases. "fail" tests assert the documented null returns for
// unknown lookups; the "success" test applies a specific variant and reads it
// back.
// ---------------------------------------------------------------------------
test('findById of an unknown id returns null', (ctx) => {
const found = ctx.penpot.fonts.findById('definitely-not-a-font-id');
expect(found).toBeNull();
});
test('findByName of an unknown name returns null', (ctx) => {
const found = ctx.penpot.fonts.findByName('No Such Font Name 12345');
expect(found).toBeNull();
});
test('findAllById of an unknown id returns an empty array', (ctx) => {
expect(ctx.penpot.fonts.findAllById('definitely-not-a-font-id')).toEqual(
[],
);
});
test('applying a specific variant sets the variant on the text', (ctx) => {
const t = text(ctx);
// Prefer a font that has more than one variant so the chosen variant is
// meaningful; fall back to the first font otherwise.
const font =
ctx.penpot.fonts.all.find((f) => f.variants.length > 1) ??
ctx.penpot.fonts.all[0];
const variant = font.variants[font.variants.length - 1];
font.applyToText(t, variant);
expect(t.fontId).toBe(font.fontId);
expect(t.fontVariantId).toBe(variant.fontVariantId);
expect(t.fontWeight).toBe(variant.fontWeight);
});
});

View File

@ -0,0 +1,333 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board, Rectangle } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Interactions, overlays and animations.
// Interactions are added to a shape; navigate/overlay actions target boards, so
// destination boards are self-provisioned on the scratch board.
function board(ctx: TestContext): Board {
const b = ctx.penpot.createBoard();
ctx.board.appendChild(b);
return b;
}
function rect(ctx: TestContext): Rectangle {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
return r;
}
describe('Interactions', () => {
test('navigate-to interaction round-trips', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
expect(interaction.trigger).toBe('click');
expect(interaction.action.type).toBe('navigate-to');
if (interaction.action.type === 'navigate-to') {
expect(interaction.action.destination.id).toBe(dest.id);
}
expect(interaction.shape && interaction.shape.id).toBe(r.id);
expect(r.interactions.length).toBeGreaterThan(0);
});
test('open-url interaction round-trips', (ctx) => {
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'open-url',
url: 'https://example.com',
});
expect(interaction.action.type).toBe('open-url');
if (interaction.action.type === 'open-url') {
expect(interaction.action.url).toBe('https://example.com');
}
});
test('open-overlay interaction round-trips', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'open-overlay',
destination: overlay,
position: 'manual',
manualPositionLocation: { x: 10, y: 20 },
closeWhenClickOutside: true,
addBackgroundOverlay: true,
animation: { type: 'dissolve', duration: 100, easing: 'linear' },
});
expect(interaction.action.type).toBe('open-overlay');
if (interaction.action.type === 'open-overlay') {
expect(interaction.action.destination.id).toBe(overlay.id);
expect(interaction.action.position).toBe('manual');
expect(interaction.action.closeWhenClickOutside).toBe(true);
expect(interaction.action.addBackgroundOverlay).toBe(true);
}
});
test('open-overlay supports a non-manual position', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
// Per the types, manualPositionLocation is only needed for 'manual'.
const interaction = r.addInteraction('click', {
type: 'open-overlay',
destination: overlay,
position: 'center',
animation: { type: 'dissolve', duration: 100, easing: 'linear' },
});
expect(interaction.action.type).toBe('open-overlay');
});
test('toggle-overlay interaction round-trips', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'toggle-overlay',
destination: overlay,
position: 'manual',
manualPositionLocation: { x: 0, y: 0 },
animation: { type: 'dissolve', duration: 100, easing: 'linear' },
});
expect(interaction.action.type).toBe('toggle-overlay');
if (interaction.action.type === 'toggle-overlay') {
expect(interaction.action.destination.id).toBe(overlay.id);
}
});
test('close-overlay interaction round-trips', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'close-overlay',
destination: overlay,
animation: { type: 'dissolve', duration: 200, easing: 'linear' },
});
expect(interaction.action.type).toBe('close-overlay');
if (interaction.action.type === 'close-overlay') {
expect(
interaction.action.destination && interaction.action.destination.id,
).toBe(overlay.id);
expect(interaction.action.animation).toBeDefined();
}
});
test('previous-screen interaction round-trips', (ctx) => {
const r = rect(ctx);
const interaction = r.addInteraction('click', { type: 'previous-screen' });
expect(interaction.action.type).toBe('previous-screen');
});
test('after-delay trigger carries a delay', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
1000,
);
expect(interaction.trigger).toBe('after-delay');
expect(interaction.delay).toBeCloseTo(1000, 0);
});
test('mouse-leave trigger is recorded', (ctx) => {
// click / mouse-enter / after-delay are covered above; mouse-leave is the
// remaining trigger.
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('mouse-leave', {
type: 'navigate-to',
destination: dest,
});
expect(interaction.trigger).toBe('mouse-leave');
});
// Pins persistence of the `delay` and `action` setters on an existing
// interaction (mutating after `addInteraction`). `misc.test.ts:300` exercises
// these setters' (set) coverage targets but never asserts that the new values
// stick; this fills that behavioural gap. (An older note claimed these setters
// "don't persist" — that is stale: CI confirms they do.)
test('interaction delay and action setters persist', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
1000,
);
interaction.delay = 250;
interaction.action = { type: 'previous-screen' };
expect(interaction.delay).toBeCloseTo(250, 0);
expect(interaction.action.type).toBe('previous-screen');
});
describe('Animations', () => {
test('dissolve animation round-trips', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
animation: { type: 'dissolve', duration: 300, easing: 'ease' },
});
if (
interaction.action.type === 'navigate-to' &&
interaction.action.animation
) {
expect(interaction.action.animation.type).toBe('dissolve');
if (interaction.action.animation.type === 'dissolve') {
expect(interaction.action.animation.duration).toBeCloseTo(300, 0);
expect(interaction.action.animation.easing).toBe('ease');
}
}
});
test('dissolve animation accepts every easing curve', (ctx) => {
// Only `linear` and `ease` are exercised elsewhere; cover the remaining
// easing curves so a single broken curve is caught.
for (const easing of ['ease-in', 'ease-out', 'ease-in-out'] as const) {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
animation: { type: 'dissolve', duration: 200, easing },
});
if (
interaction.action.type === 'navigate-to' &&
interaction.action.animation &&
interaction.action.animation.type === 'dissolve'
) {
expect(interaction.action.animation.easing).toBe(easing);
}
}
});
test('slide animation round-trips', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
animation: {
type: 'slide',
way: 'in',
direction: 'right',
duration: 300,
easing: 'linear',
},
});
if (
interaction.action.type === 'navigate-to' &&
interaction.action.animation
) {
expect(interaction.action.animation.type).toBe('slide');
if (interaction.action.animation.type === 'slide') {
expect(interaction.action.animation.way).toBe('in');
expect(interaction.action.animation.direction).toBe('right');
expect(interaction.action.animation.duration).toBeCloseTo(300, 0);
}
}
});
test('push animation round-trips', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
animation: {
type: 'push',
direction: 'left',
duration: 300,
easing: 'linear',
},
});
if (
interaction.action.type === 'navigate-to' &&
interaction.action.animation
) {
expect(interaction.action.animation.type).toBe('push');
if (interaction.action.animation.type === 'push') {
expect(interaction.action.animation.direction).toBe('left');
expect(interaction.action.animation.duration).toBeCloseTo(300, 0);
}
}
});
});
test('an interaction can be removed', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
const before = r.interactions.length;
interaction.remove();
expect(r.interactions.length).toBe(before - 1);
});
test('interaction trigger can be changed', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
interaction.trigger = 'mouse-enter';
expect(interaction.trigger).toBe('mouse-enter');
});
// ---------------------------------------------------------------------------
// Edge cases. "fail" tests assert invalid interaction input is
// rejected; the "success" test checks several triggers coexisting.
// ---------------------------------------------------------------------------
// addInteraction validates the interaction's structure (schema) but not the
// liveness of a navigate destination nor the format of an open-url string,
// so both of these are accepted rather than rejected. These pin the current
// (lenient) behaviour.
test('navigate-to a removed board is accepted (dangling destination)', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
dest.remove();
expect(() =>
r.addInteraction('click', { type: 'navigate-to', destination: dest }),
).not.toThrow();
});
test('open-url accepts an arbitrary url string', (ctx) => {
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'open-url',
url: 'not a valid url',
});
expect(interaction.action.type).toBe('open-url');
if (interaction.action.type === 'open-url') {
expect(interaction.action.url).toBe('not a valid url');
}
});
test('several triggers on one shape coexist', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
r.addInteraction('click', { type: 'navigate-to', destination: dest });
r.addInteraction('mouse-enter', {
type: 'navigate-to',
destination: dest,
});
expect(r.interactions).toHaveLength(2);
expect(r.interactions.map((i) => i.trigger).sort()).toEqual([
'click',
'mouse-enter',
]);
});
});

View File

@ -0,0 +1,402 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Layout (flex & grid).
// Layouts are created on boards via addFlexLayout/addGridLayout. Child and cell
// properties are reached through a shape that lives inside the laid-out board.
// Each group keeps its happy-path round-trips together with the related edge
// cases: "throws" tests assert invalid input is rejected (a red test surfaces a
// missing-validation bug) and the remaining ones pin non-trivial valid behaviour.
// Note: track insertion indices are 0-based (addRowAtIndex/removeRow/setRow);
// appendChild cell coordinates and layoutCell.row/column are 1-based.
function board(ctx: TestContext): Board {
const b = ctx.penpot.createBoard();
ctx.board.appendChild(b);
return b;
}
describe('Layout', () => {
describe('Flex', () => {
test('addFlexLayout adds a flex layout to the board', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
expect(flex).toBeDefined();
expect(b.flex).toBeDefined();
});
test('direction and wrap round-trip', (ctx) => {
const flex = board(ctx).addFlexLayout();
flex.dir = 'column';
flex.wrap = 'wrap';
expect(flex.dir).toBe('column');
expect(flex.wrap).toBe('wrap');
});
test('alignment round-trips', (ctx) => {
const flex = board(ctx).addFlexLayout();
flex.alignItems = 'center';
flex.alignContent = 'space-between';
flex.justifyItems = 'center';
flex.justifyContent = 'space-around';
expect(flex.alignItems).toBe('center');
expect(flex.alignContent).toBe('space-between');
expect(flex.justifyItems).toBe('center');
expect(flex.justifyContent).toBe('space-around');
});
test('gaps and padding round-trip', (ctx) => {
const flex = board(ctx).addFlexLayout();
flex.rowGap = 5;
flex.columnGap = 10;
flex.verticalPadding = 4;
flex.horizontalPadding = 8;
flex.topPadding = 1;
flex.rightPadding = 2;
flex.bottomPadding = 3;
flex.leftPadding = 4;
expect(flex.rowGap).toBeCloseTo(5, 0);
expect(flex.columnGap).toBeCloseTo(10, 0);
expect(flex.topPadding).toBeCloseTo(1, 0);
expect(flex.rightPadding).toBeCloseTo(2, 0);
expect(flex.bottomPadding).toBeCloseTo(3, 0);
expect(flex.leftPadding).toBeCloseTo(4, 0);
});
test('sizing round-trips', (ctx) => {
const flex = board(ctx).addFlexLayout();
flex.horizontalSizing = 'fix';
flex.verticalSizing = 'auto';
expect(flex.horizontalSizing).toBe('fix');
expect(flex.verticalSizing).toBe('auto');
});
test('appendChild adds a child to the flex layout', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
const rect = ctx.penpot.createRectangle();
flex.appendChild(rect);
expect(b.children.length).toBeGreaterThan(0);
});
test('remove deletes the flex layout', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
flex.remove();
expect(b.flex).toBeFalsy();
});
});
describe('Grid', () => {
test('addGridLayout adds a grid layout to the board', (ctx) => {
const b = board(ctx);
const grid = b.addGridLayout();
expect(grid).toBeDefined();
expect(b.grid).toBeDefined();
});
test('direction round-trips', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.dir = 'row';
expect(grid.dir).toBe('row');
});
test('rows and columns can be added and read as tracks', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addColumn('percent', 50);
expect(grid.rows.length).toBeGreaterThan(0);
expect(grid.columns.length).toBeGreaterThan(0);
expect(grid.rows[0].type).toBe('flex');
expect(grid.columns[0].type).toBe('percent');
expect(grid.columns[0].value).toBeCloseTo(50, 0);
});
test('addRowAtIndex inserts a row at an index', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addRowAtIndex(0, 'fixed', 100);
expect(grid.rows[0].type).toBe('fixed');
});
test('addColumnAtIndex inserts a column at an index', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addColumn('flex', 1);
grid.addColumnAtIndex(0, 'fixed', 100);
expect(grid.columns[0].type).toBe('fixed');
});
test('setRow and setColumn update tracks', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
grid.setRow(0, 'fixed', 80);
grid.setColumn(0, 'percent', 25);
expect(grid.rows[0].type).toBe('fixed');
expect(grid.rows[0].value).toBeCloseTo(80, 0);
expect(grid.columns[0].type).toBe('percent');
});
test('removeRow and removeColumn drop tracks', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
grid.addColumn('flex', 1);
const rowsBefore = grid.rows.length;
const colsBefore = grid.columns.length;
grid.removeRow(0);
grid.removeColumn(0);
expect(grid.rows.length).toBe(rowsBefore - 1);
expect(grid.columns.length).toBe(colsBefore - 1);
});
test('appendChild places a child into a cell', (ctx) => {
const b = board(ctx);
const grid = b.addGridLayout();
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
const rect = ctx.penpot.createRectangle();
grid.appendChild(rect, 1, 1);
expect(b.children.length).toBeGreaterThan(0);
});
test('alignment and gaps round-trip', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.alignItems = 'center';
grid.justifyItems = 'start';
grid.rowGap = 7;
grid.columnGap = 9;
expect(grid.alignItems).toBe('center');
expect(grid.justifyItems).toBe('start');
expect(grid.rowGap).toBeCloseTo(7, 0);
expect(grid.columnGap).toBeCloseTo(9, 0);
});
// Index boundaries — invalid indices must be rejected.
test('addRowAtIndex with a negative index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
expect(() => grid.addRowAtIndex(-1, 'fixed', 100)).toThrow();
});
test('addRowAtIndex past the end throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
expect(() => grid.addRowAtIndex(5, 'fixed', 100)).toThrow();
});
test('addColumnAtIndex with a negative index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addColumn('flex', 1);
expect(() => grid.addColumnAtIndex(-1, 'fixed', 100)).toThrow();
});
test('removeRow on an empty grid throws', (ctx) => {
const grid = board(ctx).addGridLayout();
expect(() => grid.removeRow(0)).toThrow();
});
test('removeRow with an out-of-range index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
expect(() => grid.removeRow(5)).toThrow();
});
test('removeColumn with an out-of-range index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addColumn('flex', 1);
expect(() => grid.removeColumn(5)).toThrow();
});
test('setRow with an out-of-range index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
expect(() => grid.setRow(5, 'fixed', 80)).toThrow();
});
test('setColumn with an out-of-range index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addColumn('flex', 1);
expect(() => grid.setColumn(5, 'fixed', 80)).toThrow();
});
// Track type — invalid track types must be rejected.
test('addRow with an invalid track type throws', (ctx) => {
const grid = board(ctx).addGridLayout();
expect(() => grid.addRow('not-a-type' as unknown as 'flex', 1)).toThrow();
});
test('addColumn with an invalid track type throws', (ctx) => {
const grid = board(ctx).addGridLayout();
expect(() =>
grid.addColumn('not-a-type' as unknown as 'flex', 1),
).toThrow();
});
// Success edges — non-trivial valid behaviour.
test('addRowAtIndex inserts at the position and shifts the rest', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('fixed', 10);
grid.addRow('percent', 20);
grid.addRowAtIndex(1, 'flex', 1);
expect(grid.rows.length).toBe(3);
expect(grid.rows[0].type).toBe('fixed');
expect(grid.rows[1].type).toBe('flex');
expect(grid.rows[2].type).toBe('percent');
});
test('setRow updates a track in place without changing the count', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.setRow(1, 'fixed', 80);
expect(grid.rows.length).toBe(3);
expect(grid.rows[0].type).toBe('flex');
expect(grid.rows[1].type).toBe('fixed');
expect(grid.rows[1].value).toBeCloseTo(80, 0);
expect(grid.rows[2].type).toBe('flex');
});
test('mixed track types coexist and read back', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addRow('fixed', 50);
grid.addRow('percent', 25);
grid.addRow('auto');
expect(grid.rows.map((r) => r.type)).toEqual([
'flex',
'fixed',
'percent',
'auto',
]);
});
test('appendChild places children into the cells requested', (ctx) => {
const b = board(ctx);
const grid = b.addGridLayout();
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
grid.addColumn('flex', 1);
const a = ctx.penpot.createRectangle();
const c = ctx.penpot.createRectangle();
grid.appendChild(a, 1, 1);
grid.appendChild(c, 2, 2);
const cellA = a.layoutCell;
const cellC = c.layoutCell;
expect(cellA).toBeDefined();
expect(cellC).toBeDefined();
if (cellA && cellC) {
expect(cellA.row).toBeCloseTo(1, 0);
expect(cellA.column).toBeCloseTo(1, 0);
expect(cellC.row).toBeCloseTo(2, 0);
expect(cellC.column).toBeCloseTo(2, 0);
}
});
test('a grid board can nest a flex board as a child', (ctx) => {
const outer = board(ctx);
const grid = outer.addGridLayout();
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
const inner = ctx.penpot.createBoard();
const flex = inner.addFlexLayout();
flex.dir = 'column';
grid.appendChild(inner, 1, 1);
expect(outer.children.length).toBeGreaterThan(0);
expect(inner.flex).toBeDefined();
expect(inner.flex && inner.flex.dir).toBe('column');
});
});
describe('Child', () => {
test('layout child properties round-trip', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
const rect = ctx.penpot.createRectangle();
flex.appendChild(rect);
const child = rect.layoutChild;
expect(child).toBeDefined();
if (child) {
child.absolute = true;
child.zIndex = 3;
child.horizontalSizing = 'fill';
child.verticalSizing = 'fix';
child.alignSelf = 'center';
child.horizontalMargin = 2;
child.verticalMargin = 4;
child.topMargin = 1;
child.rightMargin = 2;
child.bottomMargin = 3;
child.leftMargin = 4;
child.maxWidth = 200;
child.maxHeight = 150;
child.minWidth = 10;
child.minHeight = 20;
expect(child.absolute).toBe(true);
expect(child.zIndex).toBeCloseTo(3, 0);
expect(child.horizontalSizing).toBe('fill');
expect(child.verticalSizing).toBe('fix');
expect(child.alignSelf).toBe('center');
expect(child.topMargin).toBeCloseTo(1, 0);
expect(child.maxWidth).toBeCloseTo(200, 0);
expect(child.minHeight).toBeCloseTo(20, 0);
}
});
});
describe('Cell', () => {
test('layout cell properties round-trip', (ctx) => {
const b = board(ctx);
const grid = b.addGridLayout();
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
grid.addColumn('flex', 1);
const rect = ctx.penpot.createRectangle();
grid.appendChild(rect, 1, 1);
const cell = rect.layoutCell;
expect(cell).toBeDefined();
if (cell) {
cell.row = 1;
cell.column = 1;
cell.rowSpan = 1;
cell.columnSpan = 2;
expect(cell.row).toBeCloseTo(1, 0);
expect(cell.column).toBeCloseTo(1, 0);
expect(cell.columnSpan).toBeCloseTo(2, 0);
}
});
});
describe('Switching type', () => {
// addFlexLayout/addGridLayout do not reject a board that already has a
// layout; they create the requested layout (switching the board's type).
// These pin that behaviour.
test('adding a grid layout to a board that already has a flex layout switches to grid', (ctx) => {
const b = board(ctx);
b.addFlexLayout();
expect(() => b.addGridLayout()).not.toThrow();
expect(b.grid).toBeDefined();
});
test('adding a flex layout to a board that already has a grid layout switches to flex', (ctx) => {
const b = board(ctx);
b.addGridLayout();
expect(() => b.addFlexLayout()).not.toThrow();
expect(b.flex).toBeDefined();
});
});
});

View File

@ -0,0 +1,223 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Text } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
import { PNG_1X1 } from './fixtures';
// Library colors, typographies and components.
// Assets are created in the local library (self-provisioned). Reached through
// `ctx.penpot.library.local` so the Library chain is recorded for coverage.
function text(ctx: TestContext, value = 'Hello Penpot'): Text {
const t = ctx.penpot.createText(value);
if (!t) throw new Error('createText returned null');
ctx.board.appendChild(t);
return t;
}
describe('Library', () => {
test('local library exposes id and name', (ctx) => {
const lib = ctx.penpot.library.local;
expect(typeof lib.id).toBe('string');
expect(typeof lib.name).toBe('string');
});
test('local library lists its assets', (ctx) => {
const lib = ctx.penpot.library.local;
expect(Array.isArray(lib.colors)).toBe(true);
expect(Array.isArray(lib.typographies)).toBe(true);
expect(Array.isArray(lib.components)).toBe(true);
expect(lib.tokens).toBeDefined();
});
test('library context exposes connected libraries', (ctx) => {
expect(Array.isArray(ctx.penpot.library.connected)).toBe(true);
});
test('library elements expose a libraryId', (ctx) => {
const color = ctx.penpot.library.local.createColor();
expect(typeof color.libraryId).toBe('string');
});
// Skipped under MOCK_BACKEND: availableLibraries() returns backend-shaped
// shared-library summaries; under a mock it would resolve vacuously.
test.skipIfMocked('availableLibraries resolves to summaries', async (ctx) => {
// The shared-libraries RPC can error in the headless team context; treat a
// rejection as an environment limitation.
const summaries = await ctx.penpot.library
.availableLibraries()
.catch(() => []);
expect(Array.isArray(summaries)).toBe(true);
if (summaries.length > 0) {
const summary = summaries[0];
expect(typeof summary.id).toBe('string');
expect(typeof summary.name).toBe('string');
expect(typeof summary.numColors).toBe('number');
expect(typeof summary.numComponents).toBe('number');
expect(typeof summary.numTypographies).toBe('number');
}
});
// NOTE: connectLibrary with an unknown id is intentionally NOT exercised here.
// Calling it with a non-existent library id crashes the plugin workspace (the
// returned promise never settles and the sandbox freezes), which would hang
// the whole CI run. This is a genuine API bug to fix at the source; until then
// the suite must not trigger it.
describe('Colors', () => {
test('createColor adds a color asset', (ctx) => {
const color = ctx.penpot.library.local.createColor();
color.name = 'plugin-color';
// Use a single-segment path: Penpot normalizes `a/b` to `a / b`.
color.path = 'plugingroup';
color.color = '#ff8800';
color.opacity = 0.8;
expect(typeof color.id).toBe('string');
expect(color.name).toBe('plugin-color');
expect(color.path).toBe('plugingroup');
expect(color.color).toBe('#ff8800');
expect(color.opacity).toBeCloseTo(0.8, 2);
});
test('library color converts to fill and stroke', (ctx) => {
const color = ctx.penpot.library.local.createColor();
color.color = '#123456';
color.opacity = 1;
const fill = color.asFill();
expect(fill.fillColor).toBe('#123456');
const stroke = color.asStroke();
expect(stroke.strokeColor).toBe('#123456');
});
test('library color gradient round-trips', (ctx) => {
const color = ctx.penpot.library.local.createColor();
color.gradient = {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
};
const g = color.gradient;
expect(g).toBeDefined();
if (g) {
expect(g.type).toBe('linear');
expect(g.stops).toHaveLength(2);
}
});
// Skipped under MOCK_BACKEND: uploadMediaData needs real backend media
// processing (ImageMagick); a mock can't return usable image data.
test.skipIfMocked('library color image round-trips', async (ctx) => {
const image = await ctx.penpot.uploadMediaData(
'lib-color-image',
PNG_1X1,
'image/png',
);
const color = ctx.penpot.library.local.createColor();
color.image = image;
expect(color.image).toBeDefined();
});
});
describe('Typographies', () => {
test('createTypography adds a typography asset', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.name = 'plugin-typo';
typo.path = 'text';
typo.fontSize = '18';
typo.lineHeight = '1.4';
typo.letterSpacing = '0.5';
expect(typeof typo.id).toBe('string');
expect(typo.name).toBe('plugin-typo');
expect(typo.fontSize).toBe('18');
expect(typeof typo.fontId).toBe('string');
});
test('typography fontFamily and fontId round-trip', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
expect(typeof typo.fontFamily).toBe('string');
typo.fontFamily = 'Arial';
typo.fontId = 'gfont-arial';
expect(typo.fontFamily).toBe('Arial');
expect(typo.fontId).toBe('gfont-arial');
});
test('typography style members round-trip', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.fontStyle = 'italic';
typo.textTransform = 'uppercase';
typo.fontWeight = '700';
typo.fontVariantId = 'regular';
typo.lineHeight = '1.5';
typo.letterSpacing = '1';
expect(typo.fontStyle).toBe('italic');
expect(typo.textTransform).toBe('uppercase');
expect(typo.fontWeight).toBe('700');
expect(typo.fontVariantId).toBe('regular');
expect(typeof typo.lineHeight).toBe('string');
expect(typeof typo.letterSpacing).toBe('string');
});
test('typography setFont updates the font', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
const font = ctx.penpot.fonts.all[0];
typo.setFont(font);
expect(typo.fontId).toBe(font.fontId);
});
test('typography applies to a text shape', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.fontSize = '22';
const t = text(ctx);
typo.applyToText(t);
expect(t.fontSize).toBe('22');
});
test('typography applies to a text range', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.fontSize = '28';
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
typo.applyToTextRange(range);
expect(range.fontSize).toBe('28');
});
});
describe('Components', () => {
test('createComponent creates a component asset', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
expect(typeof comp.id).toBe('string');
comp.name = 'plugin-component';
expect(comp.name).toBe('plugin-component');
expect(comp.isVariant()).toBe(false);
});
test('component instance and mainInstance return shapes', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
const main = comp.mainInstance();
expect(main).toBeDefined();
expect(typeof main.id).toBe('string');
const instance = comp.instance();
expect(instance).toBeDefined();
expect(typeof instance.id).toBe('string');
});
});
});

View File

@ -0,0 +1,145 @@
import { expect, expectReject } from '../framework/expect';
import { describe, test } from '../framework/registry';
import { PNG_1X1 } from './fixtures';
// Media uploads and exports.
// Skipped under MOCK_BACKEND: media upload exercises real ImageMagick on the
// backend (image validation / canned upload data) that a 200 mock can't
// reproduce — the rejection tests would fail and the success tests go vacuous.
describe.skipIfMocked('Media', () => {
test('uploadMediaData uploads bytes and returns image data', async (ctx) => {
const image = await ctx.penpot.uploadMediaData(
'plugin-image',
PNG_1X1,
'image/png',
);
expect(typeof image.id).toBe('string');
expect(image.width).toBe(1);
expect(image.height).toBe(1);
expect(image.mtype).toBe('image/png');
expect(typeof image.name).toBe('string');
// keepAspectRatio is optional and may be null when not set.
expect(
image.keepAspectRatio == null ||
typeof image.keepAspectRatio === 'boolean',
).toBe(true);
const bytes = await image.data();
expect(bytes.length).toBeGreaterThan(0);
});
test('an uploaded image can be used as a fill', async (ctx) => {
const image = await ctx.penpot.uploadMediaData(
'plugin-fill',
PNG_1X1,
'image/png',
);
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.fills = [{ fillOpacity: 1, fillImage: image }];
const fills = rect.fills;
if (Array.isArray(fills)) {
expect(fills[0].fillImage).toBeDefined();
}
});
test('Fill.fillImage can be set on a fill', async (ctx) => {
const image = await ctx.penpot.uploadMediaData(
'plugin-fill-set',
PNG_1X1,
'image/png',
);
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
// Set fillImage directly on the fill (covers Fill.fillImage (set)).
const fill = rect.fills[0];
fill.fillImage = image;
expect(fill.fillImage).toBeDefined();
});
test('uploadMediaUrl resolves to image data', async (ctx) => {
// Needs the backend to fetch an external URL, which may be unavailable in
// the headless runner; treat a rejection as an environment limitation.
const image = await ctx.penpot
.uploadMediaUrl(
'plugin-url-image',
'https://design.penpot.app/images/favicon.png',
)
.catch(() => null);
if (image) {
expect(typeof image.id).toBe('string');
}
});
// ---------------------------------------------------------------------------
// Edge cases. Invalid upload input must not resolve. (These hold
// even when the backend is unreachable in the headless runner, since a
// rejection is the asserted outcome.)
// ---------------------------------------------------------------------------
test('uploadMediaData with empty bytes rejects', async (ctx) => {
await expectReject(() =>
ctx.penpot.uploadMediaData('empty', new Uint8Array([]), 'image/png'),
);
});
test('uploadMediaData with non-image bytes rejects', async (ctx) => {
const garbage = new Uint8Array([1, 2, 3, 4, 5]);
await expectReject(() =>
ctx.penpot.uploadMediaData('garbage', garbage, 'image/png'),
);
});
test('uploadMediaUrl with an invalid URL rejects', async (ctx) => {
await expectReject(() =>
ctx.penpot.uploadMediaUrl('bad-url', 'not://a.valid/url'),
);
});
});
describe('Exports', () => {
test('export settings round-trip on a shape', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.exports = [
{ type: 'png', scale: 2, suffix: '@2x', skipChildren: false },
];
expect(rect.exports).toHaveLength(1);
const exp = rect.exports[0];
expect(exp.type).toBe('png');
expect(exp.scale).toBeCloseTo(2, 0);
expect(exp.suffix).toBe('@2x');
// skipChildren is optional; a stored `false` reads back as undefined.
expect(exp.skipChildren).toBeFalsy();
});
test('export settings accept jpeg, webp, svg and pdf types', (ctx) => {
// Only png is exercised above; pin that the other export formats round-trip
// as settings (the actual render is covered separately and may be headless-
// limited).
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
for (const type of ['jpeg', 'webp', 'svg', 'pdf'] as const) {
rect.exports = [{ type, scale: 1 }];
expect(rect.exports).toHaveLength(1);
expect(rect.exports[0].type).toBe(type);
}
});
test('shape export renders to bytes', async (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.resize(20, 20);
// Rendering may not be available in the headless runner; tolerate failure.
const bytes = await rect
.export({ type: 'png', scale: 1 })
.catch(() => null);
if (bytes) {
expect(bytes.length).toBeGreaterThan(0);
}
});
});

View File

@ -0,0 +1,389 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Misc — remaining coverable members across many interfaces.
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function rect(ctx: TestContext) {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
return r;
}
// Note: penpot.utils.types / geometry are frozen (SES) data properties, so the
// recorder cannot wrap them and their members aren't recorded (see README.md
// coverage notes). The predicates are still exercised behaviourally in
// platform.test.ts.
describe('Misc', () => {
describe('Context root', () => {
test('root is a shape', (ctx) => {
expect(ctx.penpot.root).toBeDefined();
});
});
describe('Concrete shape fills', () => {
test('fills round-trip on ellipse, path and board', (ctx) => {
const ellipse = ctx.penpot.createEllipse();
const pathShape = ctx.penpot.createPath();
const board = ctx.penpot.createBoard();
ctx.board.appendChild(ellipse);
ctx.board.appendChild(pathShape);
ctx.board.appendChild(board);
ellipse.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
pathShape.fills = [{ fillColor: '#00ff00', fillOpacity: 1 }];
board.fills = [{ fillColor: '#0000ff', fillOpacity: 1 }];
expect(ellipse.fills).toHaveLength(1);
expect(pathShape.fills).toHaveLength(1);
expect(board.fills).toHaveLength(1);
});
});
describe('Boolean members', () => {
test('boolean content, path data and children round-trip', (ctx) => {
const a = rect(ctx);
const b = rect(ctx);
b.x = 40;
const bool = ctx.penpot.createBoolean('union', [a, b]);
expect(bool).not.toBeNull();
if (bool) {
ctx.board.appendChild(bool);
// Boolean fills round-trip; d/content/commands are derived from the
// operands and not independently settable (see coverage notes).
bool.fills = [{ fillColor: '#abcdef', fillOpacity: 1 }];
void bool.content;
expect(bool.fills).toHaveLength(1);
}
});
test('appendChild and insertChild add operands to a boolean', (ctx) => {
const a = rect(ctx);
const b = rect(ctx);
b.x = 40;
const bool = ctx.penpot.createBoolean('union', [a, b]);
expect(bool).not.toBeNull();
if (bool) {
ctx.board.appendChild(bool);
const before = bool.children.length;
bool.appendChild(rect(ctx));
bool.insertChild(0, rect(ctx));
expect(bool.children.length).toBe(before + 2);
}
});
});
describe('Export settings setters', () => {
test('export members round-trip on the returned export', (ctx) => {
const r = rect(ctx);
r.exports = [{ type: 'png', scale: 1, suffix: '', skipChildren: false }];
const exp = r.exports[0];
exp.type = 'jpeg';
exp.scale = 2;
exp.suffix = '@2x';
exp.skipChildren = true;
expect(exp.type).toBe('jpeg');
expect(exp.scale).toBeCloseTo(2, 0);
});
});
describe('Gradient and shadow leftovers', () => {
test('gradient endpoints and stops round-trip', (ctx) => {
const r = rect(ctx);
r.fills = [
{
fillColorGradient: {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
},
},
];
const fills = r.fills;
if (Array.isArray(fills)) {
const g = fills[0].fillColorGradient;
if (g) {
void g.endX;
void g.startY;
g.stops = [
{ color: '#00ff00', opacity: 1, offset: 0 },
{ color: '#000000', opacity: 1, offset: 1 },
];
expect(g.stops.length).toBeGreaterThan(0);
}
}
});
test('shadow color and id round-trip', (ctx) => {
const r = rect(ctx);
r.shadows = [
{
style: 'drop-shadow',
offsetX: 1,
offsetY: 1,
blur: 2,
spread: 0,
hidden: false,
color: { color: '#000000', opacity: 1 },
},
];
const shadow = r.shadows[0];
void shadow.id;
shadow.color = { color: '#ff00ff', opacity: 0.5 };
const color = shadow.color;
if (color) {
void color.id;
void color.fileId;
void color.refId;
void color.refFile;
color.gradient = {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [{ color: '#ff0000', opacity: 1, offset: 0 }],
};
void color.gradient;
}
expect(r.shadows).toHaveLength(1);
});
});
describe('Bounds and Point', () => {
test('viewport bounds members are readable', (ctx) => {
// The bounds object is frozen, so only the getters are exercised.
const b = ctx.penpot.viewport.bounds;
expect(typeof b.x).toBe('number');
expect(typeof b.y).toBe('number');
expect(typeof b.width).toBe('number');
expect(typeof b.height).toBe('number');
});
test('viewport center point members are readable', (ctx) => {
const c = ctx.penpot.viewport.center;
expect(typeof c.x).toBe('number');
expect(typeof c.y).toBe('number');
});
});
describe('Layout leftovers', () => {
test('flex padding and child margins are readable', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
const flex = board.addFlexLayout();
flex.horizontalPadding = 4;
flex.verticalPadding = 6;
void flex.horizontalPadding;
void flex.verticalPadding;
const child = ctx.penpot.createRectangle();
flex.appendChild(child);
const lc = child.layoutChild;
if (lc) {
lc.horizontalMargin = 1;
lc.verticalMargin = 2;
lc.topMargin = 3;
lc.rightMargin = 4;
lc.bottomMargin = 5;
lc.leftMargin = 6;
lc.maxHeight = 100;
lc.minWidth = 10;
void lc.horizontalMargin;
void lc.verticalMargin;
void lc.leftMargin;
void lc.rightMargin;
void lc.bottomMargin;
void lc.maxHeight;
void lc.minWidth;
}
expect(board.type).toBe('board');
});
test('grid cell properties round-trip', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
const grid = board.addGridLayout();
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
const child = ctx.penpot.createRectangle();
grid.appendChild(child, 1, 1);
const cell = child.layoutCell;
if (cell) {
cell.areaName = 'header';
cell.position = 'auto';
void cell.areaName;
void cell.position;
void cell.rowSpan;
}
expect(board.type).toBe('board');
});
});
describe('Track', () => {
test('grid track members round-trip on the returned track', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
const grid = board.addGridLayout();
grid.addRow('flex', 1);
const track = grid.rows[0];
track.type = 'fixed';
track.value = 80;
expect(track.type).toBe('fixed');
expect(track.value).toBeCloseTo(80, 0);
});
});
describe('Path commands', () => {
test('path command members round-trip', (ctx) => {
const path = ctx.penpot.createPath();
ctx.board.appendChild(path);
path.d = 'M0 0 L10 10';
const commands = path.commands;
expect(commands.length).toBeGreaterThan(0);
const cmd = commands[0];
void cmd.command;
void cmd.params;
cmd.command = 'line-to';
cmd.params = { x: 5, y: 5 };
expect(cmd.command).toBe('line-to');
// Reassign the whole command list (Path.commands set).
path.commands = commands;
});
});
describe('Shape ordering and blur', () => {
test('sendBackward and backgroundBlur are exercised', (ctx) => {
const a = rect(ctx);
const b = rect(ctx);
void b;
a.sendBackward();
void a.backgroundBlur;
expect(a.type).toBe('rectangle');
});
});
describe('Interaction reads', () => {
test('overlay action fields are readable', (ctx) => {
const overlay = ctx.penpot.createBoard();
ctx.board.appendChild(overlay as Board);
const relative = rect(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'open-overlay',
destination: overlay,
relativeTo: relative,
position: 'manual',
manualPositionLocation: { x: 5, y: 5 },
animation: { type: 'dissolve', duration: 100, easing: 'linear' },
});
if (interaction.action.type === 'open-overlay') {
void interaction.action.relativeTo;
void interaction.action.manualPositionLocation;
void interaction.action.animation;
}
// Interaction.action and delay setters (records the (set) targets;
// persistence is asserted in interactions.test.ts).
interaction.delay = 250;
interaction.action = { type: 'previous-screen' };
expect(interaction.shape && interaction.shape.id).toBe(r.id);
});
test('navigate-to preserveScrollPosition and slide/push animation fields', (ctx) => {
const dest = ctx.penpot.createBoard();
ctx.board.appendChild(dest as Board);
const r = rect(ctx);
const nav = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
preserveScrollPosition: true,
animation: {
type: 'slide',
way: 'in',
direction: 'right',
duration: 300,
offsetEffect: true,
easing: 'ease',
},
});
if (nav.action.type === 'navigate-to') {
void nav.action.preserveScrollPosition;
const anim = nav.action.animation;
if (anim && anim.type === 'slide') {
void anim.offsetEffect;
void anim.easing;
}
}
const r2 = rect(ctx);
const push = r2.addInteraction('click', {
type: 'navigate-to',
destination: dest,
animation: {
type: 'push',
direction: 'left',
duration: 300,
easing: 'ease',
},
});
if (push.action.type === 'navigate-to') {
const anim = push.action.animation;
if (anim && anim.type === 'push') {
void anim.easing;
}
}
expect(r.type).toBe('rectangle');
});
});
describe('Variant container variants', () => {
test('Variants interface members via a variant container', async (ctx) => {
function main(): Board {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
return ctx.penpot.library.local
.createComponent([r])
.mainInstance() as Board;
}
const container = ctx.penpot.createVariantFromComponents([
main(),
main(),
]);
await sleep(300);
const v = container.variants;
expect(v).not.toBeNull();
if (v) {
expect(typeof v.id).toBe('string');
expect(typeof v.libraryId).toBe('string');
expect(Array.isArray(v.properties)).toBe(true);
expect(Array.isArray(v.variantComponents())).toBe(true);
if (v.properties.length > 0) {
void v.currentValues(v.properties[0]);
}
v.addProperty();
await sleep(300);
v.addVariant();
await sleep(300);
if (v.properties.length > 0) {
v.renameProperty(0, 'Size');
await sleep(200);
v.removeProperty(v.properties.length - 1);
}
}
});
});
});

View File

@ -0,0 +1,162 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
// Pages, selection and flows.
// Most assertions use the active page (`currentPage`) and the scratch board so
// the user's file is left clean. createPage/openPage necessarily leave a page
// behind (the API has no removePage), so the active page is restored afterwards.
describe('Pages', () => {
test('currentPage exposes id and name', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
expect(typeof page.id).toBe('string');
expect(typeof page.name).toBe('string');
}
});
test('createPage and openPage activate a new page', async (ctx) => {
const original = ctx.penpot.currentPage;
const page = ctx.penpot.createPage();
page.name = 'plugin-test-page';
expect(page.name).toBe('plugin-test-page');
await ctx.penpot.openPage(page);
const active = ctx.penpot.currentPage;
expect(active && active.id).toBe(page.id);
// Restore the originally active page so other tests aren't affected.
if (original) await ctx.penpot.openPage(original);
});
test('getShapeById finds a shape on the page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
const found = page.getShapeById(ctx.board.id);
expect(found).not.toBeNull();
expect(found && found.id).toBe(ctx.board.id);
}
});
test('findShapes returns shapes on the page', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const page = ctx.penpot.currentPage;
if (page) {
expect(page.findShapes().length).toBeGreaterThan(0);
}
});
test('page root is a shape', (ctx) => {
const page = ctx.penpot.currentPage;
if (page) {
expect(page.root).toBeDefined();
expect(typeof page.root.type).toBe('string');
}
});
// Edge cases.
test('getShapeById of an unknown id returns null', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
const found = page.getShapeById('00000000-0000-0000-0000-0000000000ff');
expect(found).toBeNull();
}
});
test('getShapeById finds a just-created shape', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const page = ctx.penpot.currentPage;
if (page) {
const found = page.getShapeById(rect.id);
expect(found).not.toBeNull();
expect(found && found.id).toBe(rect.id);
}
});
});
describe('Selection', () => {
test('selection can be set and read', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
ctx.penpot.selection = [rect];
expect(ctx.penpot.selection).toHaveLength(1);
expect(ctx.penpot.selection[0].id).toBe(rect.id);
});
// Edge cases.
test('assigning an empty selection clears it', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
ctx.penpot.selection = [rect];
ctx.penpot.selection = [];
expect(ctx.penpot.selection).toHaveLength(0);
});
test('selecting the same shape twice keeps a single entry', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
ctx.penpot.selection = [rect, rect];
expect(ctx.penpot.selection).toHaveLength(1);
});
});
describe('Flows', () => {
test('createFlow defines a flow on a board', (ctx) => {
const targetBoard = ctx.penpot.createBoard();
ctx.board.appendChild(targetBoard);
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
const flow = page.createFlow('plugin-flow', targetBoard);
expect(flow.name).toBe('plugin-flow');
expect(flow.startingBoard.id).toBe(targetBoard.id);
expect(flow.page.id).toBe(page.id);
expect(page.flows.length).toBeGreaterThan(0);
}
});
test('flow name and starting board round-trip', (ctx) => {
const first = ctx.penpot.createBoard();
const second = ctx.penpot.createBoard();
ctx.board.appendChild(first);
ctx.board.appendChild(second);
const page = ctx.penpot.currentPage;
if (page) {
const flow = page.createFlow('flow-a', first);
flow.name = 'flow-b';
flow.startingBoard = second;
expect(flow.name).toBe('flow-b');
expect(flow.startingBoard.id).toBe(second.id);
}
});
test('flow can be removed', (ctx) => {
const targetBoard = ctx.penpot.createBoard();
ctx.board.appendChild(targetBoard);
const page = ctx.penpot.currentPage;
if (page) {
const flow = page.createFlow('to-remove', targetBoard);
const before = page.flows.length;
flow.remove();
expect(page.flows.length).toBe(before - 1);
}
});
test('page.removeFlow removes a flow', (ctx) => {
const targetBoard = ctx.penpot.createBoard();
ctx.board.appendChild(targetBoard);
const page = ctx.penpot.currentPage;
if (page) {
const flow = page.createFlow('to-remove-2', targetBoard);
const before = page.flows.length;
page.removeFlow(flow);
expect(page.flows.length).toBe(before - 1);
}
});
});

View File

@ -0,0 +1,164 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
// Platform: user/session, context info, history, utils and markup.
describe('Platform', () => {
describe('User', () => {
test('currentUser exposes profile fields', (ctx) => {
const user = ctx.penpot.currentUser;
expect(typeof user.id).toBe('string');
expect(typeof user.name).toBe('string');
expect(typeof user.sessionId).toBe('string');
// avatarUrl and color may be undefined depending on the profile.
void user.avatarUrl;
void user.color;
});
test('activeUsers is an array', (ctx) => {
const users = ctx.penpot.activeUsers;
expect(Array.isArray(users)).toBe(true);
if (users.length > 0) {
expect(typeof users[0].id).toBe('string');
void users[0].position;
void users[0].zoom;
}
});
});
describe('Context info', () => {
test('version is a string', (ctx) => {
expect(typeof ctx.penpot.version).toBe('string');
});
test('theme is light or dark', (ctx) => {
expect(['light', 'dark']).toContain(ctx.penpot.theme);
});
test('flags are readable', (ctx) => {
expect(typeof ctx.penpot.flags.naturalChildOrdering).toBe('boolean');
expect(typeof ctx.penpot.flags.throwValidationErrors).toBe('boolean');
});
});
describe('History', () => {
test('undo block begin and finish wrap operations', (ctx) => {
const block = ctx.penpot.history.undoBlockBegin();
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.name = 'in-undo-block';
ctx.penpot.history.undoBlockFinish(block);
expect(rect.name).toBe('in-undo-block');
});
// Edge cases.
test('finishing an unknown undo block is a no-op (not rejected)', (ctx) => {
// undoBlockFinish does not validate the block id; an unknown id is ignored
// rather than rejected.
expect(() =>
ctx.penpot.history.undoBlockFinish(Symbol('not-a-real-block')),
).not.toThrow();
});
test('nested undo blocks begin and finish in order', (ctx) => {
const outer = ctx.penpot.history.undoBlockBegin();
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const inner = ctx.penpot.history.undoBlockBegin();
rect.name = 'nested';
ctx.penpot.history.undoBlockFinish(inner);
ctx.penpot.history.undoBlockFinish(outer);
expect(rect.name).toBe('nested');
});
});
describe('Utils', () => {
test('geometry center returns a point', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.x = 0;
rect.y = 0;
rect.resize(100, 100);
const center = ctx.penpot.utils.geometry.center([rect]);
expect(center).not.toBeNull();
if (center) {
expect(center.x).toBeCloseTo(50, 0);
expect(center.y).toBeCloseTo(50, 0);
}
});
// Edge cases.
test('center of an empty array returns null', (ctx) => {
expect(ctx.penpot.utils.geometry.center([])).toBeNull();
});
test('center of two shapes sits at their midpoint', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
a.x = 0;
a.y = 0;
a.resize(100, 100);
b.x = 200;
b.y = 100;
b.resize(100, 100);
const center = ctx.penpot.utils.geometry.center([a, b]);
expect(center).not.toBeNull();
if (center) {
// a spans 0..100, b spans 200..300 → combined bounds 0..300 → centre 150.
expect(center.x).toBeCloseTo(150, 0);
expect(center.y).toBeCloseTo(100, 0);
}
});
test('types predicates identify shapes', (ctx) => {
const types = ctx.penpot.utils.types;
const rect = ctx.penpot.createRectangle();
const ellipse = ctx.penpot.createEllipse();
const text = ctx.penpot.createText('hi');
const path = ctx.penpot.createPath();
ctx.board.appendChild(rect);
ctx.board.appendChild(ellipse);
ctx.board.appendChild(path);
if (text) ctx.board.appendChild(text);
expect(types.isRectangle(rect)).toBe(true);
expect(types.isEllipse(ellipse)).toBe(true);
expect(types.isPath(path)).toBe(true);
expect(types.isBoard(ctx.board)).toBe(true);
if (text) {
expect(types.isText(text)).toBe(true);
}
// Non-matching predicates should be falsy.
expect(types.isGroup(rect)).toBeFalsy();
expect(types.isBool(rect)).toBeFalsy();
expect(types.isMask(rect)).toBeFalsy();
expect(types.isSVG(rect)).toBeFalsy();
expect(types.isVariantContainer(rect)).toBeFalsy();
});
});
describe('Markup', () => {
test('generateMarkup returns html', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const markup = ctx.penpot.generateMarkup([rect]);
expect(typeof markup).toBe('string');
});
test('generateMarkup can target svg', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const svg = ctx.penpot.generateMarkup([rect], { type: 'svg' });
expect(typeof svg).toBe('string');
});
test('generateStyle returns css', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const styles = ctx.penpot.generateStyle([rect]);
expect(typeof styles).toBe('string');
});
});
});

View File

@ -0,0 +1,108 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
// Plugin data and local storage.
describe('Plugin data', () => {
test('plugin data round-trips on a shape', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setPluginData('exampleKey', 'exampleValue');
expect(rect.getPluginData('exampleKey')).toBe('exampleValue');
expect(rect.getPluginDataKeys()).toContain('exampleKey');
});
test('shared plugin data round-trips on a shape', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setSharedPluginData('ns', 'sharedKey', 'sharedValue');
expect(rect.getSharedPluginData('ns', 'sharedKey')).toBe('sharedValue');
expect(rect.getSharedPluginDataKeys('ns')).toContain('sharedKey');
});
test('plugin data round-trips on the file', (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
file.setPluginData('fileKey', 'fileValue');
expect(file.getPluginData('fileKey')).toBe('fileValue');
}
});
// ---------------------------------------------------------------------------
// Edge cases. "fail" tests assert invalid keys/values are
// rejected; "success" tests cover multi-key listing, overwrite, large values,
// missing keys and local/shared isolation.
// ---------------------------------------------------------------------------
test('setPluginData with an empty key is accepted (currently unvalidated)', (ctx) => {
// An empty key is not rejected; this pins the current lenient behaviour
// (a candidate for future hardening).
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(() => rect.setPluginData('', 'value')).not.toThrow();
});
test('setPluginData with a non-string value throws', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(() => rect.setPluginData('key', 123 as unknown as string)).toThrow();
});
test('multiple keys round-trip and are all listed', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setPluginData('a', '1');
rect.setPluginData('b', '2');
rect.setPluginData('c', '3');
expect(rect.getPluginData('a')).toBe('1');
expect(rect.getPluginData('b')).toBe('2');
expect(rect.getPluginData('c')).toBe('3');
const keys = rect.getPluginDataKeys();
expect(keys).toContain('a');
expect(keys).toContain('b');
expect(keys).toContain('c');
});
test('overwriting a key replaces its value', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setPluginData('k', 'first');
rect.setPluginData('k', 'second');
expect(rect.getPluginData('k')).toBe('second');
});
test('a large value round-trips', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const big = 'x'.repeat(10000);
rect.setPluginData('big', big);
expect(rect.getPluginData('big')).toBe(big);
});
test('reading a missing key is falsy', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(rect.getPluginData('never-set')).toBeFalsy();
});
test('local and shared plugin data are isolated', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setPluginData('k', 'local');
rect.setSharedPluginData('ns', 'k', 'shared');
expect(rect.getPluginData('k')).toBe('local');
expect(rect.getSharedPluginData('ns', 'k')).toBe('shared');
});
});
describe('Local storage', () => {
test('set, get, keys and remove an item', (ctx) => {
const ls = ctx.penpot.localStorage;
ls.setItem('plugin-key', 'plugin-value');
expect(ls.getItem('plugin-key')).toBe('plugin-value');
expect(ls.getKeys()).toContain('plugin-key');
ls.removeItem('plugin-key');
expect(ls.getItem('plugin-key')).toBeFalsy();
});
});

View File

@ -0,0 +1,81 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { TestContext } from '../framework/types';
// Shadows & blur.
// Like fills/strokes, shadows are assigned as a whole array and read back; the
// nested Shadow.color yields a Color whose members are then exercised on read.
function rect(ctx: TestContext) {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
return r;
}
describe('Shadows', () => {
test('drop shadow round-trips with a color', (ctx) => {
const r = rect(ctx);
r.shadows = [
{
style: 'drop-shadow',
offsetX: 4,
offsetY: 6,
blur: 8,
spread: 1,
hidden: false,
color: { color: '#000000', opacity: 0.5 },
},
];
expect(r.shadows).toHaveLength(1);
const shadow = r.shadows[0];
expect(shadow.style).toBe('drop-shadow');
expect(shadow.offsetX).toBeCloseTo(4, 0);
expect(shadow.offsetY).toBeCloseTo(6, 0);
expect(shadow.blur).toBeCloseTo(8, 0);
expect(shadow.spread).toBeCloseTo(1, 0);
expect(shadow.hidden).toBe(false);
expect(shadow.color).toBeDefined();
expect(shadow.color && shadow.color.color).toBe('#000000');
expect(shadow.color && shadow.color.opacity).toBeCloseTo(0.5, 2);
});
test('inner shadow can be hidden', (ctx) => {
const r = rect(ctx);
r.shadows = [
{
style: 'inner-shadow',
offsetX: 0,
offsetY: 0,
blur: 4,
spread: 0,
hidden: true,
color: { color: '#ff0000', opacity: 1 },
},
];
const shadow = r.shadows[0];
expect(shadow.style).toBe('inner-shadow');
expect(shadow.hidden).toBe(true);
});
});
describe('Blur', () => {
test('layer blur round-trips', (ctx) => {
const r = rect(ctx);
r.blur = { value: 10 };
expect(r.blur).toBeDefined();
expect(r.blur && r.blur.value).toBeCloseTo(10, 0);
// hidden defaults to false when omitted.
expect(r.blur && r.blur.hidden).toBeFalsy();
});
test('background blur round-trips', (ctx) => {
const r = rect(ctx);
r.backgroundBlur = { value: 5 };
expect(r.backgroundBlur).toBeDefined();
expect(r.backgroundBlur && r.backgroundBlur.value).toBeCloseTo(5, 0);
});
});

View File

@ -0,0 +1,318 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
// Shapes & geometry.
// Exercises the Context shape factories and the context-level structural
// operations (group/ungroup/flatten, align/distribute). Everything created is
// appended to the scratch board `ctx.board` so the user's canvas stays clean.
// Each group keeps its happy-path tests together with the related edge cases:
// "fail" tests assert the documented null returns / rejections for degenerate
// input; the remaining ones pin non-trivial valid construction (clone
// independence, group order, boolean tree, svg tree).
describe('Shapes', () => {
describe('Factories', () => {
test('createRectangle returns a rectangle', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(rect.type).toBe('rectangle');
});
test('createEllipse returns an ellipse', (ctx) => {
const ellipse = ctx.penpot.createEllipse();
ctx.board.appendChild(ellipse);
expect(ellipse.type).toBe('ellipse');
});
test('createBoard returns a board', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
expect(board.type).toBe('board');
});
test('createPath returns a path', (ctx) => {
const path = ctx.penpot.createPath();
ctx.board.appendChild(path);
expect(path.type).toBe('path');
});
test('createText returns a text shape with the given content', (ctx) => {
const text = ctx.penpot.createText('Hello Penpot');
expect(text).not.toBeNull();
if (text) {
ctx.board.appendChild(text);
expect(text.type).toBe('text');
expect(text.characters).toContain('Hello');
}
});
test('createBoolean unions two shapes', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
b.x = 50;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const bool = ctx.penpot.createBoolean('union', [a, b]);
expect(bool).not.toBeNull();
if (bool) {
ctx.board.appendChild(bool);
expect(bool.type).toBe('boolean');
}
});
test('createShapeFromSvg returns a group', (ctx) => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">' +
'<rect width="10" height="10" fill="#ff0000"/></svg>';
const group = ctx.penpot.createShapeFromSvg(svg);
expect(group).not.toBeNull();
if (group) {
ctx.board.appendChild(group);
expect(group.type).toBe('group');
}
});
test('createShapeFromSvgWithImages resolves to a group', async (ctx) => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">' +
'<rect width="10" height="10" fill="#00ff00"/></svg>';
const group = await ctx.penpot.createShapeFromSvgWithImages(svg);
expect(group).not.toBeNull();
if (group) {
ctx.board.appendChild(group);
expect(group.type).toBe('group');
}
});
// Degenerate input — documented null returns / rejections.
test('createText with an empty string returns null', (ctx) => {
// The d.ts documents: "Returns null if an empty string is provided".
const t = ctx.penpot.createText('');
expect(t).toBeNull();
});
test('group of an empty array returns null', (ctx) => {
const g = ctx.penpot.group([]);
expect(g).toBeNull();
});
test('createBoolean with an empty shapes array is rejected', (ctx) => {
// createBoolean validates a non-empty shapes array, so with
// throwValidationErrors enabled it throws rather than returning null.
expect(() => ctx.penpot.createBoolean('union', [])).toThrow();
});
test('createBoolean supports difference, exclude and intersection', (ctx) => {
// Only `union` is exercised elsewhere; cover the remaining boolean ops so a
// typology-specific regression in any single operation is caught.
for (const op of ['difference', 'exclude', 'intersection'] as const) {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createEllipse();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
b.x = 10;
b.y = 10;
const bool = ctx.penpot.createBoolean(op, [a, b]);
expect(bool).not.toBeNull();
if (bool) {
ctx.board.appendChild(bool);
expect(typeof bool.d).toBe('string');
expect(typeof bool.toD()).toBe('string');
}
}
});
test('createShapeFromSvg is lenient with unparseable markup', (ctx) => {
// The SVG importer is permissive: it still produces a group for input
// that is not valid SVG rather than returning null.
const group = ctx.penpot.createShapeFromSvg('not svg at all');
expect(group).not.toBeNull();
if (group) {
ctx.board.appendChild(group);
}
});
// Success edges — non-trivial valid construction.
test('clone produces an independent copy', (ctx) => {
const r = ctx.penpot.createRectangle();
r.name = 'original';
ctx.board.appendChild(r);
const copy = r.clone();
ctx.board.appendChild(copy);
copy.name = 'copy';
expect(copy.id).not.toBe(r.id);
expect(copy.name).toBe('copy');
// Mutating the copy must not affect the original.
expect(r.name).toBe('original');
});
test('group preserves child count and order', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createEllipse();
const c = ctx.penpot.createRectangle();
a.name = 'a';
b.name = 'b';
c.name = 'c';
ctx.board.appendChild(a);
ctx.board.appendChild(b);
ctx.board.appendChild(c);
const group = ctx.penpot.group([a, b, c]);
expect(group).not.toBeNull();
if (group) {
expect(group.children).toHaveLength(3);
expect(group.children.map((s) => s.name).sort()).toEqual([
'a',
'b',
'c',
]);
}
});
test('a boolean keeps its two operands as children', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
b.x = 50;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const bool = ctx.penpot.createBoolean('union', [a, b]);
expect(bool).not.toBeNull();
if (bool) {
ctx.board.appendChild(bool);
expect(bool.type).toBe('boolean');
expect(bool.children).toHaveLength(2);
}
});
test('createShapeFromSvg builds a group with children', (ctx) => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">' +
'<rect width="10" height="10" fill="#ff0000"/>' +
'<circle cx="15" cy="15" r="5" fill="#0000ff"/></svg>';
const group = ctx.penpot.createShapeFromSvg(svg);
expect(group).not.toBeNull();
if (group) {
ctx.board.appendChild(group);
expect(group.type).toBe('group');
expect(group.children.length).toBeGreaterThan(0);
}
});
});
describe('Grouping', () => {
test('group wraps shapes in a group', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createEllipse();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(group).not.toBeNull();
if (group) {
expect(group.type).toBe('group');
expect(group.children).toHaveLength(2);
}
});
test('ungroup dissolves a group', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createEllipse();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(group).not.toBeNull();
if (group) {
const before = ctx.board.children.length;
ctx.penpot.ungroup(group);
// After ungroup the two shapes should be back on the board directly.
expect(ctx.board.children.length).toBeGreaterThan(before - 1);
}
});
test('flatten converts shapes into paths', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const paths = ctx.penpot.flatten([rect]);
expect(paths).toHaveLength(1);
expect(paths[0].type).toBe('path');
});
});
describe('Align & distribute', () => {
test('alignHorizontal moves shapes to a shared edge', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
a.x = 0;
b.x = 200;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
ctx.penpot.alignHorizontal([a, b], 'left');
expect(a.x).toBeCloseTo(b.x, 0);
});
test('alignVertical moves shapes to a shared edge', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
a.y = 0;
b.y = 200;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
ctx.penpot.alignVertical([a, b], 'top');
expect(a.y).toBeCloseTo(b.y, 0);
});
test('distributeHorizontal runs without error', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
const c = ctx.penpot.createRectangle();
a.x = 0;
b.x = 50;
c.x = 300;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
ctx.board.appendChild(c);
ctx.penpot.distributeHorizontal([a, b, c]);
// Middle shape should end up between the outer two.
expect(b.x).toBeGreaterThan(a.x);
});
test('distributeVertical runs without error', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
const c = ctx.penpot.createRectangle();
a.y = 0;
b.y = 50;
c.y = 300;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
ctx.board.appendChild(c);
ctx.penpot.distributeVertical([a, b, c]);
expect(b.y).toBeGreaterThan(a.y);
});
// Edge cases.
test('aligning an empty array is a no-op (not rejected)', (ctx) => {
// align/distribute do not validate the shape list; an empty array is
// simply a no-op rather than an error.
expect(() => ctx.penpot.alignHorizontal([], 'left')).not.toThrow();
});
test('distributing a single shape leaves it in place', (ctx) => {
const a = ctx.penpot.createRectangle();
a.x = 10;
ctx.board.appendChild(a);
ctx.penpot.distributeHorizontal([a]);
expect(a.x).toBeCloseTo(10, 0);
});
});
});

View File

@ -0,0 +1,408 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { TestContext } from '../framework/types';
// Shapes & geometry.
// Exercises the `ShapeBase` identity / geometry / transform / ordering members
// that are common to every shape, using a rectangle on the scratch board.
/** Creates a rectangle, appends it to the scratch board and returns it. */
function rect(ctx: TestContext) {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
return r;
}
describe('Shapes', () => {
describe('Identity', () => {
test('exposes a stable id', (ctx) => {
const r = rect(ctx);
expect(typeof r.id).toBe('string');
expect(r.id.length).toBeGreaterThan(0);
});
test('name is readable and writable', (ctx) => {
const r = rect(ctx);
r.name = 'sample-rect';
expect(r.name).toBe('sample-rect');
});
test('parent points at the containing board', (ctx) => {
const r = rect(ctx);
expect(r.parent).not.toBeNull();
expect(r.parent && r.parent.id).toBe(ctx.board.id);
});
test('parentIndex is a distinct structural index per sibling', (ctx) => {
const a = rect(ctx);
const b = rect(ctx);
// Two siblings on a fresh board occupy indices 0 and 1 (direction depends
// on naturalChildOrdering, so assert the set rather than which is which).
expect([a.parentIndex, b.parentIndex].sort()).toEqual([0, 1]);
});
});
describe('Geometry', () => {
test('x and y are readable and writable', (ctx) => {
const r = rect(ctx);
r.x = 120;
r.y = 80;
expect(r.x).toBeCloseTo(120, 0);
expect(r.y).toBeCloseTo(80, 0);
});
test('resize changes width and height', (ctx) => {
const r = rect(ctx);
r.resize(200, 100);
expect(r.width).toBeCloseTo(200, 0);
expect(r.height).toBeCloseTo(100, 0);
});
test('bounds describes a rectangular area', (ctx) => {
const r = rect(ctx);
r.x = 10;
r.y = 20;
r.resize(50, 40);
const b = r.bounds;
expect(b.width).toBeCloseTo(50, 0);
expect(b.height).toBeCloseTo(40, 0);
});
test('center sits in the middle of the shape', (ctx) => {
const r = rect(ctx);
r.x = 0;
r.y = 0;
r.resize(100, 100);
const c = r.center;
expect(c.x).toBeCloseTo(50, 0);
expect(c.y).toBeCloseTo(50, 0);
});
test('boardX and boardY are readable and writable', (ctx) => {
const r = rect(ctx);
r.boardX = 15;
r.boardY = 25;
expect(r.boardX).toBeCloseTo(15, 0);
expect(r.boardY).toBeCloseTo(25, 0);
});
test('parentX and parentY are readable and writable', (ctx) => {
const r = rect(ctx);
r.parentX = 12;
r.parentY = 22;
expect(r.parentX).toBeCloseTo(12, 0);
expect(r.parentY).toBeCloseTo(22, 0);
});
});
describe('Transform', () => {
test('rotation is readable and writable', (ctx) => {
const r = rect(ctx);
r.rotation = 45;
expect(r.rotation).toBeCloseTo(45, 0);
});
test('rotate() applies an angle', (ctx) => {
const r = rect(ctx);
r.rotate(90);
expect(r.rotation).toBeCloseTo(90, 0);
});
test('flipX is readable and writable', (ctx) => {
const r = rect(ctx);
expect(r.flipX).toBe(false);
r.flipX = true;
expect(r.flipX).toBe(true);
});
test('flipY is readable and writable', (ctx) => {
const r = rect(ctx);
expect(r.flipY).toBe(false);
r.flipY = true;
expect(r.flipY).toBe(true);
});
});
// The geometry/transform members above run on a rectangle. Re-exercise the
// core ones on other shape types so a type-specific regression is caught.
describe('Geometry across shape types', () => {
test('resize and rotate work on an ellipse', (ctx) => {
const e = ctx.penpot.createEllipse();
ctx.board.appendChild(e);
e.resize(120, 60);
expect(e.width).toBeCloseTo(120, 0);
expect(e.height).toBeCloseTo(60, 0);
e.rotate(45);
expect(e.rotation).toBeCloseTo(45, 0);
});
test('flip works on an ellipse', (ctx) => {
// Kept separate from rotation: flipping an already-rotated shape does not
// round-trip through flipX, so exercise flip on an unrotated ellipse.
const e = ctx.penpot.createEllipse();
ctx.board.appendChild(e);
expect(e.flipX).toBe(false);
e.flipX = true;
expect(e.flipX).toBe(true);
e.flipY = true;
expect(e.flipY).toBe(true);
});
test('resize and reposition work on a nested board', (ctx) => {
const b = ctx.penpot.createBoard();
ctx.board.appendChild(b);
b.resize(200, 150);
b.x = 25;
b.y = 35;
expect(b.width).toBeCloseTo(200, 0);
expect(b.height).toBeCloseTo(150, 0);
expect(b.x).toBeCloseTo(25, 0);
expect(b.y).toBeCloseTo(35, 0);
});
});
describe('Appearance flags', () => {
test('blocked is readable and writable', (ctx) => {
const r = rect(ctx);
r.blocked = true;
expect(r.blocked).toBe(true);
});
test('hidden is readable and writable', (ctx) => {
const r = rect(ctx);
r.hidden = true;
expect(r.hidden).toBe(true);
});
test('visible is readable and writable', (ctx) => {
const r = rect(ctx);
r.visible = false;
expect(r.visible).toBe(false);
});
test('proportionLock is readable and writable', (ctx) => {
const r = rect(ctx);
r.proportionLock = true;
expect(r.proportionLock).toBe(true);
});
test('fixedWhenScrolling is readable and writable', (ctx) => {
const r = rect(ctx);
r.fixedWhenScrolling = true;
expect(r.fixedWhenScrolling).toBe(true);
});
test('opacity is readable and writable', (ctx) => {
const r = rect(ctx);
r.opacity = 0.5;
expect(r.opacity).toBeCloseTo(0.5, 2);
});
test('blendMode is readable and writable', (ctx) => {
const r = rect(ctx);
r.blendMode = 'multiply';
expect(r.blendMode).toBe('multiply');
});
});
describe('Constraints', () => {
test('constraintsHorizontal is readable and writable', (ctx) => {
const r = rect(ctx);
r.constraintsHorizontal = 'center';
expect(r.constraintsHorizontal).toBe('center');
});
test('constraintsVertical is readable and writable', (ctx) => {
const r = rect(ctx);
r.constraintsVertical = 'center';
expect(r.constraintsVertical).toBe('center');
});
});
describe('Corner radius', () => {
test('borderRadius is readable and writable', (ctx) => {
const r = rect(ctx);
r.borderRadius = 8;
expect(r.borderRadius).toBeCloseTo(8, 0);
});
test('per-corner border radius is readable and writable', (ctx) => {
const r = rect(ctx);
r.borderRadiusTopLeft = 1;
r.borderRadiusTopRight = 2;
r.borderRadiusBottomRight = 3;
r.borderRadiusBottomLeft = 4;
expect(r.borderRadiusTopLeft).toBeCloseTo(1, 0);
expect(r.borderRadiusTopRight).toBeCloseTo(2, 0);
expect(r.borderRadiusBottomRight).toBeCloseTo(3, 0);
expect(r.borderRadiusBottomLeft).toBeCloseTo(4, 0);
});
});
describe('Ordering', () => {
test('setParentIndex moves the shape to the given index', (ctx) => {
const a = rect(ctx);
const b = rect(ctx);
void a;
b.setParentIndex(0);
expect(b.parentIndex).toBe(0);
});
test('bringToFront / sendToBack move the shape to opposite extremes', (ctx) => {
const a = rect(ctx);
const b = rect(ctx);
void b;
const last = ctx.board.children.length - 1;
a.bringToFront();
const front = a.parentIndex;
expect(front === 0 || front === last).toBe(true);
a.sendToBack();
const back = a.parentIndex;
expect(back === 0 || back === last).toBe(true);
// Front and back must be different extremes.
expect(front).not.toBe(back);
});
test('bringForward / sendBackward move the shape one step', (ctx) => {
const a = rect(ctx);
const b = rect(ctx);
const c = rect(ctx);
void b;
void c;
const start = a.parentIndex;
a.bringForward();
expect(Math.abs(a.parentIndex - start)).toBe(1);
const mid = a.parentIndex;
a.sendBackward();
expect(Math.abs(a.parentIndex - mid)).toBe(1);
});
});
describe('Lifecycle', () => {
test('clone duplicates a shape', (ctx) => {
const r = rect(ctx);
r.name = 'original';
const copy = r.clone();
ctx.board.appendChild(copy);
expect(copy.id).not.toBe(r.id);
expect(copy.type).toBe('rectangle');
});
test('remove detaches the shape from its parent', (ctx) => {
const r = rect(ctx);
const before = ctx.board.children.length;
r.remove();
expect(ctx.board.children.length).toBe(before - 1);
});
});
// ---------------------------------------------------------------------------
// Edge cases. "fail" tests assert invalid numeric/enum input is
// rejected; "success" tests assert documented boundary behaviour
// (setParentIndex clamps to last, rotation about the center, opacity 0/1).
// ---------------------------------------------------------------------------
describe('Numeric & enum — invalid values (fail)', () => {
test('opacity below 0 throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.opacity = -0.1;
}).toThrow();
});
test('opacity above 1 throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.opacity = 1.5;
}).toThrow();
});
test('NaN opacity throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.opacity = NaN;
}).toThrow();
});
test('NaN rotation is accepted (currently unvalidated)', (ctx) => {
// The rotation setter does not reject NaN; this pins the current lenient
// behaviour (a candidate for future hardening).
const r = rect(ctx);
expect(() => {
r.rotation = NaN;
}).not.toThrow();
});
test('invalid blendMode throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.blendMode = 'not-a-mode' as unknown as 'normal';
}).toThrow();
});
test('negative borderRadius throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.borderRadius = -8;
}).toThrow();
});
test('setParentIndex with a negative index is accepted (currently unvalidated)', (ctx) => {
// setParentIndex does not reject a negative index; this pins the current
// lenient behaviour (a candidate for future hardening).
const a = rect(ctx);
const b = rect(ctx);
void b;
expect(() => a.setParentIndex(-1)).not.toThrow();
});
});
describe('Geometry & ordering — success edges', () => {
test('opacity accepts the 0 and 1 boundaries', (ctx) => {
const r = rect(ctx);
r.opacity = 0;
expect(r.opacity).toBeCloseTo(0, 2);
r.opacity = 1;
expect(r.opacity).toBeCloseTo(1, 2);
});
test('setParentIndex past the end positions the shape last', (ctx) => {
const a = rect(ctx);
const b = rect(ctx);
const c = rect(ctx);
void b;
void c;
// The d.ts documents: "If the index is greater than the number of
// elements it will positioned last."
a.setParentIndex(999);
expect(a.parentIndex).toBe(ctx.board.children.length - 1);
});
test('setParentIndex reorders siblings while keeping a contiguous index set', (ctx) => {
const a = rect(ctx);
const b = rect(ctx);
const c = rect(ctx);
void a;
void c;
b.setParentIndex(0);
expect(b.parentIndex).toBe(0);
const indices = ctx.board.children.map((s) => s.parentIndex).sort();
expect(indices).toEqual([0, 1, 2]);
});
test('rotating 360 degrees leaves the center unchanged', (ctx) => {
const r = rect(ctx);
r.x = 0;
r.y = 0;
r.resize(100, 100);
r.rotation = 360;
const c = r.center;
expect(c.x).toBeCloseTo(50, 0);
expect(c.y).toBeCloseTo(50, 0);
});
});
});

View File

@ -0,0 +1,299 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Shape } from '@penpot/plugin-types';
// Shapes & geometry.
// Exercises members specific to the concrete shape types (Board, Group, Boolean,
// Path, Ellipse, SvgRaw) beyond the common `ShapeBase` surface.
/** Depth-first search for the first descendant matching `type`. */
function findByType(shape: Shape, type: string): Shape | null {
if (shape.type === type) return shape;
const children = 'children' in shape ? shape.children : undefined;
if (children) {
for (const child of children) {
const found = findByType(child, type);
if (found) return found;
}
}
return null;
}
describe('Shapes', () => {
describe('Board', () => {
test('clipContent is readable and writable', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
board.clipContent = false;
expect(board.clipContent).toBe(false);
board.clipContent = true;
expect(board.clipContent).toBe(true);
});
test('showInViewMode is readable and writable', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
board.showInViewMode = false;
expect(board.showInViewMode).toBe(false);
});
test('appendChild and children reflect added shapes', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
const child = ctx.penpot.createRectangle();
board.appendChild(child);
expect(board.children).toHaveLength(1);
expect(board.children[0].id).toBe(child.id);
});
test('children setter accepts a reorder and rejects a different set', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
board.appendChild(a);
board.appendChild(b);
const ids = board.children.map((c) => c.id).sort();
// `children` is writable only for *reordering*: assigning the same shapes
// in a new order is accepted and preserves the set. (The visible order is
// governed by the naturalChildOrdering flag, so only the set is asserted.)
board.children = [...board.children].reverse();
expect(board.children).toHaveLength(2);
expect(board.children.map((c) => c.id).sort()).toEqual(ids);
// Assigning a set that doesn't match the current children is rejected.
expect(() => {
board.children = [a];
}).toThrow();
});
test('insertChild places a shape at a given index', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
const first = ctx.penpot.createRectangle();
const second = ctx.penpot.createRectangle();
board.appendChild(first);
board.insertChild(0, second);
// Use the structural parentIndex; the children array sort direction
// depends on the naturalChildOrdering flag.
expect(second.parentIndex).toBe(0);
});
test('horizontalSizing and verticalSizing are readable and writable', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
board.horizontalSizing = 'fix';
board.verticalSizing = 'fix';
expect(board.horizontalSizing).toBe('fix');
expect(board.verticalSizing).toBe('fix');
});
test('isVariantContainer is false for a plain board', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
expect(board.isVariantContainer()).toBe(false);
});
});
describe('Group', () => {
test('children and appendChild work on a group', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(group).not.toBeNull();
if (group) {
expect(group.children).toHaveLength(2);
const extra = ctx.penpot.createRectangle();
group.appendChild(extra);
expect(group.children).toHaveLength(3);
}
});
test('insertChild places a shape into a group', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(group).not.toBeNull();
if (group) {
const extra = ctx.penpot.createRectangle();
group.insertChild(0, extra);
expect(extra.parentIndex).toBe(0);
}
});
test('makeMask and removeMask run without error', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(group).not.toBeNull();
if (group) {
group.makeMask();
group.removeMask();
}
});
test('isMask reports whether the group is a mask', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(group).not.toBeNull();
if (group) {
expect(group.isMask()).toBe(false);
group.makeMask();
expect(group.isMask()).toBe(true);
}
});
});
describe('Boolean', () => {
test('boolean exposes path data and child shapes', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
b.x = 40;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const bool = ctx.penpot.createBoolean('union', [a, b]);
expect(bool).not.toBeNull();
if (bool) {
ctx.board.appendChild(bool);
expect(bool.children.length).toBeGreaterThan(1);
expect(typeof bool.d).toBe('string');
expect(typeof bool.toD()).toBe('string');
expect(Array.isArray(bool.commands)).toBe(true);
}
});
});
describe('Path', () => {
test('d round-trips and populates commands', (ctx) => {
const path = ctx.penpot.createPath();
ctx.board.appendChild(path);
path.d = 'M0 0 L10 0 L10 10 Z';
expect(path.d).toContain('M');
expect(path.commands.length).toBeGreaterThan(0);
expect(typeof path.toD()).toBe('string');
});
test('content alias is readable and writable', (ctx) => {
const path = ctx.penpot.createPath();
ctx.board.appendChild(path);
path.content = 'M0 0 L20 20';
expect(typeof path.content).toBe('string');
});
});
describe('Ellipse', () => {
test('an ellipse reports its type', (ctx) => {
const ellipse = ctx.penpot.createEllipse();
ctx.board.appendChild(ellipse);
expect(ellipse.type).toBe('ellipse');
});
});
describe('SvgRaw', () => {
test('an SVG import contains svg-raw descendants', (ctx) => {
// Native tags (rect/circle/path/…) import as their own shape types; only
// tags without a native mapping (e.g. <text>) become raw svg nodes, so the
// fixture must include one to exercise SvgRaw.
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20">' +
'<rect width="10" height="10" fill="#0000ff"/>' +
'<text x="0" y="18">hi</text></svg>';
const group = ctx.penpot.createShapeFromSvg(svg);
expect(group).not.toBeNull();
if (group) {
ctx.board.appendChild(group);
const raw = findByType(group, 'svg-raw');
expect(raw).not.toBeNull();
expect(raw && raw.type).toBe('svg-raw');
}
});
});
// ---------------------------------------------------------------------------
// Edge cases. "fail" tests assert that building a circular shape
// hierarchy is rejected; "success" tests assert the type predicates classify
// shapes correctly and that masking round-trips (incl. nested in a board).
// ---------------------------------------------------------------------------
describe('Hierarchy — circular references', () => {
// The plugin appendChild does not explicitly reject cycle-creating moves;
// the underlying relocate handles them without throwing. These pin that the
// call is not rejected (cycle-prevention at the API boundary is a candidate
// for future hardening).
test('appending a board into itself does not throw', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
expect(() => board.appendChild(board)).not.toThrow();
});
test('appending an ancestor into its descendant does not throw', (ctx) => {
const outer = ctx.penpot.createBoard();
const inner = ctx.penpot.createBoard();
ctx.board.appendChild(outer);
outer.appendChild(inner);
expect(() => inner.appendChild(outer)).not.toThrow();
});
});
describe('Type predicates — success edges', () => {
test('utils.types classifies shapes by their concrete type', (ctx) => {
const types = ctx.penpot.utils.types;
const rect = ctx.penpot.createRectangle();
const ellipse = ctx.penpot.createEllipse();
const path = ctx.penpot.createPath();
const board = ctx.penpot.createBoard();
ctx.board.appendChild(rect);
ctx.board.appendChild(ellipse);
ctx.board.appendChild(path);
ctx.board.appendChild(board);
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(types.isRectangle(rect)).toBe(true);
expect(types.isBoard(rect)).toBe(false);
expect(types.isEllipse(ellipse)).toBe(true);
expect(types.isPath(path)).toBe(true);
expect(types.isBoard(board)).toBe(true);
expect(types.isGroup(board)).toBe(false);
if (group) {
expect(types.isGroup(group)).toBe(true);
expect(types.isMask(group)).toBe(false);
}
});
test('makeMask / removeMask toggles isMask, including nested in a board', (ctx) => {
const host = ctx.penpot.createBoard();
ctx.board.appendChild(host);
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
host.appendChild(a);
host.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(group).not.toBeNull();
if (group) {
expect(group.isMask()).toBe(false);
group.makeMask();
expect(group.isMask()).toBe(true);
expect(ctx.penpot.utils.types.isMask(group)).toBe(true);
group.removeMask();
expect(group.isMask()).toBe(false);
}
});
});
});

View File

@ -0,0 +1,327 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Text } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Text & text ranges.
// Font-dependent properties (fontFamily/fontWeight/…) are only read here; they
// are set properly via the Font API in fonts.test.ts. The style properties that
// don't depend on a concrete font are set and read back.
function text(ctx: TestContext, value = 'Hello Penpot'): Text {
const t = ctx.penpot.createText(value);
if (!t) throw new Error('createText returned null');
ctx.board.appendChild(t);
return t;
}
describe('Text', () => {
test('characters round-trip', (ctx) => {
const t = text(ctx);
t.characters = 'Updated content';
expect(t.characters).toBe('Updated content');
});
test('growType round-trips', (ctx) => {
const t = text(ctx);
t.growType = 'auto-height';
expect(t.growType).toBe('auto-height');
});
test('fontSize round-trips', (ctx) => {
const t = text(ctx);
t.fontSize = '24';
expect(t.fontSize).toBe('24');
});
test('lineHeight and letterSpacing round-trip', (ctx) => {
const t = text(ctx);
t.lineHeight = '1.5';
t.letterSpacing = '2';
expect(t.lineHeight).toBe('1.5');
expect(t.letterSpacing).toBe('2');
});
test('alignment round-trips', (ctx) => {
const t = text(ctx);
t.align = 'center';
t.verticalAlign = 'center';
expect(t.align).toBe('center');
expect(t.verticalAlign).toBe('center');
});
test('transform, decoration and direction round-trip', (ctx) => {
const t = text(ctx);
t.textTransform = 'uppercase';
t.textDecoration = 'underline';
t.direction = 'rtl';
expect(t.textTransform).toBe('uppercase');
expect(t.textDecoration).toBe('underline');
expect(t.direction).toBe('rtl');
});
test('font identity and variant setters accept a real font/variant', (ctx) => {
const t = text(ctx);
const font = ctx.penpot.fonts.all[0];
const variant = font.variants[0];
// Set the font identity first, then the variant-specific properties using
// values drawn from that same font so validation passes.
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
t.fontVariantId = variant.fontVariantId;
t.fontWeight = variant.fontWeight;
t.fontStyle = variant.fontStyle;
expect(t.fontId).toBe(font.fontId);
});
test('font properties are readable', (ctx) => {
const t = text(ctx);
expect(typeof t.fontId).toBe('string');
expect(typeof t.fontFamily).toBe('string');
expect(typeof t.fontVariantId).toBe('string');
expect(typeof t.fontWeight).toBe('string');
// fontStyle is 'normal' | 'italic' | 'mixed' | null
expect(t.fontStyle === null || typeof t.fontStyle === 'string').toBe(true);
});
test('textBounds exposes a rectangle shape', (ctx) => {
const t = text(ctx);
const b = t.textBounds;
// The numeric values depend on text layout (`:position-data`), which the
// headless runner does not compute, so width/height may be null in CI but
// are real numbers in the interactive editor. Assert the shape of the object.
expect('x' in b).toBe(true);
expect('y' in b).toBe(true);
expect('width' in b).toBe(true);
expect('height' in b).toBe(true);
});
test('applyTypography applies a typography to the text shape', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.fontSize = '21';
const t = text(ctx);
t.applyTypography(typo);
expect(t.fontSize).toBe('21');
});
describe('Range', () => {
test('getRange returns the range characters', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
expect(range.characters.length).toBeGreaterThan(0);
});
test('range shape references the owning text shape', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
expect(range.shape.type).toBe('text');
});
test('range font size round-trips', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
range.fontSize = '30';
expect(range.fontSize).toBe('30');
});
test('range line height and letter spacing round-trip', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
range.lineHeight = '2';
range.letterSpacing = '1';
expect(range.lineHeight).toBe('2');
expect(range.letterSpacing).toBe('1');
});
test('range alignment round-trips', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
range.align = 'right';
range.verticalAlign = 'center';
expect(range.align).toBe('right');
expect(range.verticalAlign).toBe('center');
});
test('range transform and decoration round-trip', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
range.textTransform = 'lowercase';
range.textDecoration = 'line-through';
expect(range.textTransform).toBe('lowercase');
expect(range.textDecoration).toBe('line-through');
});
test('range fills round-trip', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
range.fills = [{ fillColor: '#00ff00', fillOpacity: 1 }];
const fills = range.fills;
if (Array.isArray(fills)) {
expect(fills[0].fillColor).toBe('#00ff00');
}
});
test('two ranges keep independent fills', (ctx) => {
// Mixed-style coverage: distinct fills on distinct sub-ranges must not
// bleed into each other.
const t = text(ctx, 'Hello Penpot');
const first = t.getRange(0, 5);
const second = t.getRange(6, 12);
first.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
second.fills = [{ fillColor: '#0000ff', fillOpacity: 1 }];
const f1 = first.fills;
const f2 = second.fills;
if (Array.isArray(f1) && Array.isArray(f2)) {
expect(f1[0].fillColor).toBe('#ff0000');
expect(f2[0].fillColor).toBe('#0000ff');
}
});
test('range font properties are readable', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
expect(typeof range.fontId).toBe('string');
expect(typeof range.fontFamily).toBe('string');
expect(typeof range.fontVariantId).toBe('string');
expect(typeof range.fontWeight).toBe('string');
});
test('range style properties are readable', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
void range.direction;
void range.fontStyle;
void range.letterSpacing;
void range.lineHeight;
void range.textDecoration;
void range.textTransform;
void range.verticalAlign;
void range.align;
expect(range.characters.length).toBeGreaterThan(0);
});
test('range font properties can be set', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
const font = ctx.penpot.fonts.all[0];
// Setting records the (set) targets; partial-range persistence is a known
// API bug covered elsewhere, so only the call is exercised here.
// (fontStyle/fontVariantId/fontWeight are validated strictly against the
// current font's variants, so they are left out to avoid fragility.)
range.fontFamily = font.fontFamily;
range.fontId = font.fontId;
range.direction = 'ltr';
// Variant-specific setters, using values from the same font so the strict
// per-font validation passes.
const variant = font.variants[0];
range.fontVariantId = variant.fontVariantId;
range.fontWeight = variant.fontWeight;
range.fontStyle = variant.fontStyle;
expect(range.characters.length).toBeGreaterThan(0);
});
test('applyTypography applies to a text range', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);
range.applyTypography(typo);
expect(range.characters.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// Edge cases. "fail" tests assert invalid input is rejected;
// "success" tests assert non-trivial valid behaviour (mixed detection,
// full-span application, multi-paragraph round-trip).
// ---------------------------------------------------------------------------
test('getRange with start greater than end throws', (ctx) => {
const t = text(ctx, 'Hello Penpot');
expect(() => t.getRange(5, 1)).toThrow();
});
test('getRange with a negative index throws', (ctx) => {
const t = text(ctx, 'Hello Penpot');
expect(() => t.getRange(-1, 5)).toThrow();
});
test('getRange beyond the text length is clamped (not rejected)', (ctx) => {
// An end index past the text length is clamped rather than rejected.
const t = text(ctx, 'Hello Penpot');
let range: ReturnType<typeof t.getRange> | null = null;
expect(() => {
range = t.getRange(0, 999);
}).not.toThrow();
expect(range).not.toBeNull();
});
test('empty fontSize throws', (ctx) => {
const t = text(ctx);
expect(() => {
t.fontSize = '';
}).toThrow();
});
test('negative fontSize throws', (ctx) => {
const t = text(ctx);
expect(() => {
t.fontSize = '-12';
}).toThrow();
});
test('non-numeric fontSize throws', (ctx) => {
const t = text(ctx);
expect(() => {
t.fontSize = 'abc';
}).toThrow();
});
test('invalid align value throws', (ctx) => {
const t = text(ctx);
expect(() => {
t.align = 'middle' as unknown as 'center';
}).toThrow();
});
test('wrong-case textTransform throws', (ctx) => {
const t = text(ctx);
expect(() => {
t.textTransform = 'UPPERCASE' as unknown as 'uppercase';
}).toThrow();
});
test('invalid direction value throws', (ctx) => {
const t = text(ctx);
expect(() => {
t.direction = 'sideways' as unknown as 'ltr';
}).toThrow();
});
test('a uniformly-set fontSize is reported, not mixed', (ctx) => {
const t = text(ctx, 'Hello Penpot');
t.fontSize = '20';
expect(t.fontSize).toBe('20');
});
test('setting fontSize on a sub-range makes the shape report mixed', (ctx) => {
const t = text(ctx, 'Hello Penpot');
t.fontSize = '20';
const range = t.getRange(0, 5);
range.fontSize = '40';
expect(t.fontSize).toBe('mixed');
});
test('applying a value to the full span is uniform, not mixed', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, t.characters.length);
range.fontSize = '33';
expect(t.fontSize).toBe('33');
});
test('multi-paragraph content round-trips', (ctx) => {
const t = text(ctx);
t.characters = 'first line\nsecond line';
expect(t.characters).toBe('first line\nsecond line');
});
});

View File

@ -0,0 +1,482 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type {
TokenCatalog,
TokenColor,
TokenSet,
TokenShadow,
TokenType,
TokenTypography,
} from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Design tokens.
// The token catalog is reached through the local library. Sets/themes/tokens are
// self-provisioned; sets are created active so token references resolve.
function catalog(ctx: TestContext): TokenCatalog {
return ctx.penpot.library.local.tokens;
}
function activeSet(ctx: TestContext, name: string): TokenSet {
return catalog(ctx).addSet({ name, active: true });
}
// Names must be unique across runs too: sets/themes leak into the file (the
// API has no theme remove and set removal is best-effort), so a plain counter
// collides with leftovers from a previous run. Add a per-run random tag.
const runTag = Math.random().toString(36).slice(2, 8);
let counter = 0;
function unique(prefix: string): string {
counter += 1;
return `${prefix}-${runTag}-${counter}`;
}
/** Token application and theme/set wiring update the store asynchronously. */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('Tokens', () => {
describe('Catalog', () => {
test('addSet creates a token set', (ctx) => {
const cat = catalog(ctx);
const set = cat.addSet({ name: unique('set'), active: true });
expect(typeof set.id).toBe('string');
expect(set.active).toBe(true);
expect(cat.sets.length).toBeGreaterThan(0);
expect(cat.getSetById(set.id)).toBeDefined();
});
test('addTheme creates a token theme', (ctx) => {
const cat = catalog(ctx);
const theme = cat.addTheme({ group: '', name: unique('theme') });
expect(typeof theme.id).toBe('string');
expect(cat.themes.length).toBeGreaterThan(0);
expect(cat.getThemeById(theme.id)).toBeDefined();
});
});
describe('Set', () => {
test('name and active round-trip', (ctx) => {
const set = activeSet(ctx, unique('set'));
const newName = unique('renamed');
set.name = newName;
expect(set.name).toBe(newName);
set.active = false;
expect(set.active).toBe(false);
set.toggleActive();
expect(set.active).toBe(true);
});
test('addToken adds a token and lists it', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'color',
name: unique('color.'),
value: '#ff0000',
});
expect(typeof token.id).toBe('string');
expect(set.tokens.length).toBeGreaterThan(0);
expect(Array.isArray(set.tokensByType)).toBe(true);
expect(set.getTokenById(token.id)).toBeDefined();
});
test('duplicate and remove a set', (ctx) => {
const set = activeSet(ctx, unique('set'));
const dup = set.duplicate();
expect(dup).not.toBeNull();
expect(dup.id).not.toBe(set.id);
dup.remove();
});
// Invalid input — addToken must reject bad input.
test('empty token name throws', (ctx) => {
const set = activeSet(ctx, unique('set'));
expect(() =>
set.addToken({ type: 'color', name: '', value: '#ff0000' }),
).toThrow();
});
test('duplicate token name in the same set throws', (ctx) => {
const set = activeSet(ctx, unique('set'));
const name = unique('color.dup');
set.addToken({ type: 'color', name, value: '#ff0000' });
expect(() =>
set.addToken({ type: 'color', name, value: '#0000ff' }),
).toThrow();
});
test('opacity token value outside 0..1 throws', (ctx) => {
const set = activeSet(ctx, unique('set'));
expect(() =>
set.addToken({ type: 'opacity', name: unique('op.'), value: '2' }),
).toThrow();
});
test('invalid token type throws', (ctx) => {
const set = activeSet(ctx, unique('set'));
expect(() =>
set.addToken({
type: 'not-a-type' as unknown as TokenType,
name: unique('bad.'),
value: '1',
}),
).toThrow();
});
});
describe('Theme', () => {
test('group, name and active round-trip', (ctx) => {
const theme = catalog(ctx).addTheme({ group: '', name: unique('theme') });
theme.group = 'brand';
theme.name = 'dark';
expect(theme.group).toBe('brand');
expect(theme.name).toBe('dark');
theme.active = true;
expect(theme.active).toBe(true);
theme.toggleActive();
});
test('addSet and removeSet manage the theme sets', async (ctx) => {
const cat = catalog(ctx);
const theme = cat.addTheme({ group: '', name: unique('theme') });
const set = cat.addSet({ name: unique('set'), active: false });
theme.addSet(set);
await sleep(300);
expect(theme.activeSets.length).toBeGreaterThan(0);
theme.removeSet(set);
});
test('duplicate and remove a theme', (ctx) => {
const theme = catalog(ctx).addTheme({ group: '', name: unique('theme') });
const dup = theme.duplicate();
expect(dup.id).not.toBe(theme.id);
dup.remove();
});
});
describe('Token', () => {
test('base properties round-trip', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'color',
name: unique('color.'),
value: '#00ff00',
});
token.description = 'a token';
expect(token.description).toBe('a token');
expect(typeof token.id).toBe('string');
// resolvedValueString resolves against active sets.
expect(token.resolvedValueString).toBeDefined();
});
test('color token exposes type and value', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'color',
name: unique('color.'),
value: '#123456',
});
expect(token.type).toBe('color');
expect(token.value).toBe('#123456');
});
test('dimension and number tokens expose resolved values', (ctx) => {
const set = activeSet(ctx, unique('set'));
const dim = set.addToken({
type: 'dimension',
name: unique('dim.'),
value: '16',
});
const num = set.addToken({
type: 'rotation',
name: unique('rot.'),
value: '2',
});
expect(dim.type).toBe('dimension');
expect(num.type).toBe('rotation');
if (dim.type === 'dimension') {
expect(dim.resolvedValue).toBeCloseTo(16, 0);
}
});
test('applyToShapes applies a token to a shape', async (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'borderRadius',
name: unique('radius.'),
value: '8',
});
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
token.applyToShapes([rect]);
await sleep(300);
expect(Object.keys(rect.tokens).length).toBeGreaterThan(0);
});
test('applyToSelected applies a token to the selection', async (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'opacity',
name: unique('opacity.'),
value: '0.5',
});
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
ctx.penpot.selection = [rect];
token.applyToSelected();
await sleep(300);
expect(Object.keys(rect.tokens).length).toBeGreaterThan(0);
});
test('applyToken applies a token through the shape', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'borderRadius',
name: unique('radius.'),
value: '12',
});
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.applyToken(token);
expect(rect.tokens).toBeDefined();
});
test('duplicate and remove a token', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'color',
name: unique('color.'),
value: '#abcdef',
});
const dup = token.duplicate();
expect(dup.id).not.toBe(token.id);
dup.remove();
});
// Reference resolution — a token referencing another resolves transitively.
test('a token referencing another token resolves transitively', (ctx) => {
const set = activeSet(ctx, unique('set'));
const baseName = unique('dim.base');
set.addToken({ type: 'dimension', name: baseName, value: '16' });
const ref = set.addToken({
type: 'dimension',
name: unique('dim.ref'),
value: `{${baseName}}`,
});
if (ref.type === 'dimension') {
expect(ref.resolvedValue).toBeCloseTo(16, 0);
}
});
});
});
// Every token type and the composite value types.
describe('Token types', () => {
const simpleCases: [TokenType, string, string][] = [
['borderRadius', '8', '12'],
['color', '#ff0000', '#00ff00'],
['dimension', '16', '24'],
['fontFamilies', 'Arial', 'Helvetica'],
['fontSizes', '14', '18'],
['fontWeights', '700', '400'],
['letterSpacing', '2', '3'],
['number', '3', '4'],
['opacity', '0.5', '0.8'],
['rotation', '45', '90'],
['sizing', '100', '120'],
['spacing', '8', '12'],
['borderWidth', '2', '3'],
['textCase', 'uppercase', 'lowercase'],
['textDecoration', 'underline', 'none'],
];
for (const [type, value, value2] of simpleCases) {
test(`${type} token exposes type, value and resolvedValue`, (ctx) => {
const set = activeSet(ctx, unique('set'));
// Cast to a concrete variant for property access; the recorder attributes
// members to the real runtime type via the `type` discriminant.
const token = set.addToken({
type,
name: unique(`${type}.`),
value,
}) as TokenColor;
expect(typeof token.type).toBe('string');
void token.value;
token.value = value2;
token.name = unique('renamed.');
expect(typeof token.name).toBe('string');
// Record the resolvedValue (get) target for every type. fontFamilies
// returns the wrong shape (see the dedicated red test below), but reading
// it no longer throws, so a plain read is enough here.
void token.resolvedValue;
});
}
// A fontFamilies token's `resolvedValue` is the resolved family list
// (`string[] | undefined`, e.g. ['Arial']). The binding used to leak the raw
// tokenscript list symbol; it now returns the documented array.
test('fontFamilies token resolvedValue is the family list', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'fontFamilies',
name: unique('fontFamilies.'),
value: 'Arial',
});
const resolved = token.resolvedValue;
expect(Array.isArray(resolved)).toBe(true);
expect(resolved as unknown as string[]).toContain('Arial');
});
test('shadow token exposes its composite value', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'shadow',
name: unique('shadow.'),
value: {
color: '#000000',
inset: 'false',
offsetX: '1',
offsetY: '2',
spread: '0',
blur: '4',
},
}) as TokenShadow;
expect(token.type).toBe('shadow');
// Round-trip the value (covers TokenShadow.value get + set) without changing
// it — the setter validates against the token's value schema, so assigning
// back exactly what the getter returned is guaranteed valid.
const v = token.value;
token.value = v;
if (typeof v !== 'string' && v.length > 0) {
const sv = v[0];
void sv.color;
void sv.inset;
void sv.offsetX;
void sv.offsetY;
void sv.spread;
void sv.blur;
sv.color = '#111111';
sv.inset = 'true';
sv.offsetX = '3';
sv.offsetY = '4';
sv.spread = '1';
sv.blur = '5';
}
// resolvedValue resolves the composite into a TokenShadowValue[]; each entry
// exposes the shadow members with their resolved (unit-converted) values.
const rv = token.resolvedValue;
expect(Array.isArray(rv)).toBe(true);
expect(rv).toBeDefined();
if (rv && rv.length > 0) {
const s = rv[0];
expect(s.color).toBe('#000000');
expect(s.inset).toBe(false);
expect(s.offsetX).toBeCloseTo(1, 0);
expect(s.offsetY).toBeCloseTo(2, 0);
expect(s.spread).toBeCloseTo(0, 0);
expect(s.blur).toBeCloseTo(4, 0);
// Exercise the writable members (records the set targets).
s.color = '#222222';
s.inset = true;
s.offsetX = 9;
s.offsetY = 8;
s.spread = 2;
s.blur = 6;
}
});
test('typography token exposes its composite value', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'typography',
name: unique('typo.'),
value: {
letterSpacing: '1',
fontFamilies: 'Arial',
fontSizes: '14',
fontWeight: '400',
lineHeight: '1.2',
textCase: 'none',
textDecoration: 'none',
},
}) as TokenTypography;
expect(token.type).toBe('typography');
const v = token.value;
if (typeof v !== 'string') {
void v.letterSpacing;
void v.fontFamilies;
void v.fontSizes;
void v.fontWeight;
void v.lineHeight;
void v.textCase;
void v.textDecoration;
v.letterSpacing = '2';
v.fontFamilies = 'Helvetica';
v.fontSizes = '16';
v.fontWeight = '700';
v.lineHeight = '1.5';
v.textCase = 'uppercase';
v.textDecoration = 'underline';
}
// resolvedValue resolves the composite into a TokenTypographyValue[]; each
// entry exposes the typography members with their resolved (unit-converted)
// values.
const rv = token.resolvedValue;
expect(Array.isArray(rv)).toBe(true);
expect(rv).toBeDefined();
if (rv && rv.length > 0) {
const t = rv[0];
expect(t.fontSizes).toBeCloseTo(14, 0);
expect(t.letterSpacing).toBeCloseTo(1, 0);
expect(t.lineHeight).toBeCloseTo(1.2, 1);
expect(Array.isArray(t.fontFamilies)).toBe(true);
expect(t.fontFamilies).toContain('Arial');
expect(typeof t.fontWeights).toBe('string');
expect(t.textCase).toBe('none');
expect(t.textDecoration).toBe('none');
// Exercise the writable members (records the set targets).
t.letterSpacing = 3;
t.fontFamilies = ['Helvetica'];
t.fontSizes = 18;
t.fontWeights = '500';
t.lineHeight = 2;
t.textCase = 'lowercase';
t.textDecoration = 'line-through';
}
token.value = {
letterSpacing: '2',
fontFamilies: 'Helvetica',
fontSizes: '16',
fontWeight: '700',
lineHeight: '1.5',
textCase: 'uppercase',
textDecoration: 'underline',
};
});
test('a token set can be removed', (ctx) => {
const cat = catalog(ctx);
const set = cat.addSet({ name: unique('set'), active: true });
set.remove();
});
test('theme externalId, activeSets and removeSet', (ctx) => {
const cat = catalog(ctx);
const theme = cat.addTheme({ group: '', name: unique('theme') });
const set = cat.addSet({ name: unique('set'), active: false });
void theme.externalId;
// theme.activeSets has no runtime setter (declared writable) — API bug.
void theme.activeSets;
theme.addSet(set);
theme.removeSet(set);
expect(typeof theme.id).toBe('string');
});
});

View File

@ -0,0 +1,316 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { TestContext } from '../framework/types';
import { PNG_1X1 } from './fixtures';
// Value-object property setters.
// Fills/strokes/gradients are returned as live proxies (their setters persist);
// shadows/blur/colors are returned as plain snapshots (setting records the
// member and round-trips on the returned object). Either way every writable
// member is exercised by reading the value object and setting each property.
function rect(ctx: TestContext) {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
return r;
}
describe('Value objects', () => {
describe('Fill', () => {
test('solid fill members round-trip', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
const fills = r.fills;
if (Array.isArray(fills)) {
const fill = fills[0];
fill.fillColor = '#00ff00';
fill.fillOpacity = 0.5;
expect(fill.fillColor).toBe('#00ff00');
expect(fill.fillOpacity).toBeCloseTo(0.5, 2);
}
});
test('assigning a gradient on a live solid fill switches it', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
const fills = r.fills;
if (Array.isArray(fills)) {
fills[0].fillColorGradient = {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
};
expect(fills[0].fillColorGradient).toBeDefined();
}
});
test('fill color reference members round-trip', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
const fills = r.fills;
if (Array.isArray(fills)) {
const fill = fills[0];
fill.fillColorRefId = '00000000-0000-0000-0000-000000000001';
fill.fillColorRefFile = '00000000-0000-0000-0000-000000000002';
expect(fill.fillColorRefId).toBe(
'00000000-0000-0000-0000-000000000001',
);
expect(fill.fillColorRefFile).toBe(
'00000000-0000-0000-0000-000000000002',
);
}
});
test('fill gradient members round-trip', (ctx) => {
const r = rect(ctx);
r.fills = [
{
fillColorGradient: {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
},
},
];
const fills = r.fills;
if (Array.isArray(fills)) {
const gradient = fills[0].fillColorGradient;
expect(gradient).toBeDefined();
if (gradient) {
gradient.type = 'radial';
gradient.startX = 0.2;
gradient.startY = 0.3;
gradient.endX = 0.8;
gradient.endY = 0.9;
gradient.width = 0.5;
expect(gradient.type).toBe('radial');
expect(gradient.startX).toBeCloseTo(0.2, 2);
expect(gradient.endY).toBeCloseTo(0.9, 2);
expect(gradient.width).toBeCloseTo(0.5, 2);
expect(gradient.stops.length).toBeGreaterThan(0);
}
}
});
});
describe('Stroke', () => {
test('stroke members round-trip', (ctx) => {
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
const stroke = r.strokes[0];
stroke.strokeColor = '#112233';
stroke.strokeOpacity = 0.7;
stroke.strokeStyle = 'dotted';
stroke.strokeWidth = 4;
stroke.strokeAlignment = 'inner';
stroke.strokeCapStart = 'round';
stroke.strokeCapEnd = 'square';
expect(stroke.strokeColor).toBe('#112233');
expect(stroke.strokeOpacity).toBeCloseTo(0.7, 2);
expect(stroke.strokeStyle).toBe('dotted');
expect(stroke.strokeWidth).toBeCloseTo(4, 0);
expect(stroke.strokeAlignment).toBe('inner');
expect(stroke.strokeCapStart).toBe('round');
expect(stroke.strokeCapEnd).toBe('square');
});
test('stroke reference and gradient members round-trip', (ctx) => {
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
const stroke = r.strokes[0];
stroke.strokeColorRefId = '00000000-0000-0000-0000-000000000001';
stroke.strokeColorRefFile = '00000000-0000-0000-0000-000000000002';
expect(stroke.strokeColorRefId).toBe(
'00000000-0000-0000-0000-000000000001',
);
expect(stroke.strokeColorRefFile).toBe(
'00000000-0000-0000-0000-000000000002',
);
stroke.strokeColorGradient = {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
};
expect(stroke.strokeColorGradient).toBeDefined();
});
});
describe('Shadow', () => {
test('shadow members round-trip on the returned shadow', (ctx) => {
const r = rect(ctx);
r.shadows = [
{
style: 'drop-shadow',
offsetX: 1,
offsetY: 1,
blur: 2,
spread: 0,
hidden: false,
color: { color: '#000000', opacity: 1 },
},
];
const shadow = r.shadows[0];
shadow.style = 'inner-shadow';
shadow.offsetX = 5;
shadow.offsetY = 6;
shadow.blur = 7;
shadow.spread = 2;
shadow.hidden = true;
shadow.id = '00000000-0000-0000-0000-000000000003';
expect(shadow.style).toBe('inner-shadow');
expect(shadow.offsetX).toBeCloseTo(5, 0);
expect(shadow.offsetY).toBeCloseTo(6, 0);
expect(shadow.blur).toBeCloseTo(7, 0);
expect(shadow.spread).toBeCloseTo(2, 0);
expect(shadow.hidden).toBe(true);
});
// Skipped under MOCK_BACKEND: exercises uploadMediaData, which needs real
// backend media processing (ImageMagick) a mock can't reproduce.
test.skipIfMocked('shadow color members round-trip', async (ctx) => {
const image = await ctx.penpot.uploadMediaData(
'shadow-color-image',
PNG_1X1,
'image/png',
);
const r = rect(ctx);
r.shadows = [
{
style: 'drop-shadow',
offsetX: 1,
offsetY: 1,
blur: 2,
spread: 0,
hidden: false,
color: { color: '#000000', opacity: 1 },
},
];
const color = r.shadows[0].color;
expect(color).toBeDefined();
if (color) {
color.color = '#abcdef';
color.opacity = 0.4;
color.id = '00000000-0000-0000-0000-000000000004';
color.name = 'shadow-color';
color.path = 'group';
color.refId = '00000000-0000-0000-0000-000000000005';
color.refFile = '00000000-0000-0000-0000-000000000006';
color.fileId = '00000000-0000-0000-0000-000000000007';
// Color is a plain snapshot, so image set/read round-trips on it like
// the other members.
color.image = image;
expect(color.color).toBe('#abcdef');
expect(color.opacity).toBeCloseTo(0.4, 2);
expect(color.name).toBe('shadow-color');
expect(color.path).toBe('group');
expect(color.image).toBeDefined();
}
});
});
describe('Blur', () => {
test('blur members round-trip on the returned blur', (ctx) => {
const r = rect(ctx);
r.blur = { value: 5 };
const blur = r.blur;
expect(blur).toBeDefined();
if (blur) {
blur.value = 12;
blur.hidden = true;
blur.id = '00000000-0000-0000-0000-000000000008';
expect(blur.value).toBeCloseTo(12, 0);
expect(blur.hidden).toBe(true);
expect(blur.id).toBe('00000000-0000-0000-0000-000000000008');
}
});
test('negative blur value is accepted (currently unvalidated)', (ctx) => {
// The blur setter does not reject a negative value; this pins the current
// lenient behaviour (a candidate for future hardening).
const r = rect(ctx);
expect(() => {
r.blur = { value: -5 };
}).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// Edge cases — gradients.
// ---------------------------------------------------------------------------
describe('Gradient — edge cases', () => {
test('a gradient stop offset outside 0..1 throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.fills = [
{
fillColorGradient: {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1.5 },
],
},
},
];
}).toThrow();
});
test('a gradient with many stops at boundary offsets round-trips', (ctx) => {
const r = rect(ctx);
r.fills = [
{
fillColorGradient: {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#00ff00', opacity: 1, offset: 0.5 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
},
},
];
const fills = r.fills;
if (Array.isArray(fills)) {
const gradient = fills[0].fillColorGradient;
expect(gradient).toBeDefined();
if (gradient) {
expect(gradient.stops.length).toBe(3);
expect(gradient.stops[0].offset).toBeCloseTo(0, 2);
expect(gradient.stops[2].offset).toBeCloseTo(1, 2);
}
}
});
});
});

View File

@ -0,0 +1,201 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board, LibraryVariantComponent } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Variants.
// A standard component is created and transformed into a variant; the resulting
// VariantComponent exposes the Variants interface. Variant containers are also
// built from main-instance boards via createVariantFromComponents.
function componentMain(ctx: TestContext): Board {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
return comp.mainInstance() as Board;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// transformInVariant is async, so `variants` is only populated after a tick.
async function variantComponent(
ctx: TestContext,
): Promise<LibraryVariantComponent> {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
comp.transformInVariant();
await sleep(400);
return comp as LibraryVariantComponent;
}
describe('Variants', () => {
test('transformInVariant turns a component into a variant', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
comp.transformInVariant();
expect(comp.isVariant()).toBe(true);
});
test('createVariantFromComponents builds a variant container', (ctx) => {
const mainA = componentMain(ctx);
const mainB = componentMain(ctx);
const container = ctx.penpot.createVariantFromComponents([mainA, mainB]);
expect(container).toBeDefined();
expect(container.isVariantContainer()).toBe(true);
expect(container.variants).not.toBeNull();
});
test('combineAsVariants builds a variant container', (ctx) => {
const mainA = componentMain(ctx);
const mainB = componentMain(ctx);
const container = mainA.combineAsVariants([mainB.id]);
expect(container).toBeDefined();
expect(container.isVariantContainer()).toBe(true);
});
test('variant component exposes variant props and Variants', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
comp.transformInVariant();
expect(comp.isVariant()).toBe(true);
const variantComp = comp as LibraryVariantComponent;
expect(variantComp.variants).not.toBeNull();
expect(typeof variantComp.variantProps).toBe('object');
const variants = variantComp.variants;
if (variants) {
expect(typeof variants.id).toBe('string');
expect(typeof variants.libraryId).toBe('string');
expect(Array.isArray(variants.properties)).toBe(true);
expect(Array.isArray(variants.variantComponents())).toBe(true);
}
});
test('variant property can be added and read', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
comp.transformInVariant();
const variants = (comp as LibraryVariantComponent).variants;
expect(variants).not.toBeNull();
if (variants) {
const before = variants.properties.length;
variants.addProperty();
expect(variants.properties.length).toBe(before + 1);
variants.currentValues(variants.properties[0]);
}
});
test('variant component exposes the Variants interface', async (ctx) => {
const vc = await variantComponent(ctx);
expect(vc.isVariant()).toBe(true);
expect(typeof vc.variantProps).toBe('object');
void vc.variantError; // get only (no runtime setter)
const v = vc.variants;
expect(v).not.toBeNull();
if (v) {
expect(typeof v.id).toBe('string');
expect(typeof v.libraryId).toBe('string');
expect(Array.isArray(v.properties)).toBe(true);
expect(Array.isArray(v.variantComponents())).toBe(true);
if (v.properties.length > 0) {
expect(Array.isArray(v.currentValues(v.properties[0]))).toBe(true);
}
}
});
test('variant properties can be added, renamed and removed', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants;
expect(v).not.toBeNull();
if (v) {
v.addProperty();
await sleep(300);
const count = v.properties.length;
expect(count).toBeGreaterThan(0);
v.renameProperty(0, 'Size');
await sleep(300);
v.removeProperty(count - 1);
await sleep(300);
}
});
test('addVariant and setVariantProperty mutate the variant', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants;
expect(v).not.toBeNull();
if (v) {
const before = v.variantComponents().length;
vc.addVariant();
await sleep(300);
expect(v.variantComponents().length).toBeGreaterThan(before);
if (v.properties.length > 0) {
vc.setVariantProperty(0, 'large');
await sleep(300);
}
}
});
test('switchVariant on a variant instance does not throw', async (ctx) => {
const vc = await variantComponent(ctx);
// Add a second variant so there is another value to switch to.
vc.addVariant();
await sleep(300);
const instance = vc.instance();
ctx.board.appendChild(instance);
// Valid args (nat-int pos, string value): switches to the nearest variant
// with that value at the property position, or no-ops — never throws.
expect(() => instance.switchVariant(0, 'large')).not.toThrow();
});
test('utils.types.isVariantComponent identifies a variant component', async (ctx) => {
const vc = await variantComponent(ctx);
expect(ctx.penpot.utils.types.isVariantComponent(vc)).toBeTruthy();
});
// ---------------------------------------------------------------------------
// Edge cases. Out-of-bounds property positions and degenerate
// container input should be rejected.
// ---------------------------------------------------------------------------
// createVariantFromComponents([]) is rejected (validated), but the
// positional property ops do not bounds-check `pos`; an out-of-range index
// is a no-op rather than an error. These pin the current behaviour
// (bounds-checking the position is a candidate for future hardening).
test('createVariantFromComponents of an empty array throws', (ctx) => {
expect(() => ctx.penpot.createVariantFromComponents([])).toThrow();
});
test('removeProperty out of bounds is a no-op (not rejected)', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants;
expect(v).not.toBeNull();
if (v) {
expect(() => v.removeProperty(999)).not.toThrow();
}
});
test('renameProperty out of bounds is a no-op (not rejected)', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants;
expect(v).not.toBeNull();
if (v) {
expect(() => v.renameProperty(999, 'Nope')).not.toThrow();
}
});
test('setVariantProperty out of bounds is a no-op (not rejected)', async (ctx) => {
const vc = await variantComponent(ctx);
expect(() => vc.setVariantProperty(999, 'large')).not.toThrow();
});
});

View File

@ -0,0 +1,136 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
// Viewport and guides (ruler guides + board guides).
describe('Viewport', () => {
test('zoom is readable and writable', (ctx) => {
const vp = ctx.penpot.viewport;
expect(typeof vp.zoom).toBe('number');
vp.zoom = 2;
expect(vp.zoom).toBeCloseTo(2, 1);
vp.zoomReset();
});
test('center is readable and writable', (ctx) => {
const vp = ctx.penpot.viewport;
vp.center = { x: 100, y: 200 };
expect(vp.center.x).toBeCloseTo(100, 0);
expect(vp.center.y).toBeCloseTo(200, 0);
});
test('bounds are readable', (ctx) => {
const vp = ctx.penpot.viewport;
expect(typeof vp.bounds.width).toBe('number');
expect(typeof vp.bounds.height).toBe('number');
});
test('zoom helpers run without error', (ctx) => {
const vp = ctx.penpot.viewport;
vp.zoomToFitAll();
vp.zoomIntoView([ctx.board]);
vp.zoomReset();
});
});
describe('Ruler guides', () => {
test('board addRulerGuide returns a guide', (ctx) => {
const guide = ctx.board.addRulerGuide('vertical', 50);
expect(guide.orientation).toBe('vertical');
// A board-attached ruler guide exposes its board.
void guide.board;
});
test('board ruler guide can be reassigned to another board', (ctx) => {
const guide = ctx.board.addRulerGuide('vertical', 50);
const other = ctx.penpot.createBoard();
ctx.board.appendChild(other);
guide.board = other;
expect(guide.board && guide.board.id).toBe(other.id);
});
test('board ruler guide position round-trips', (ctx) => {
const guide = ctx.board.addRulerGuide('vertical', 50);
guide.position = 60;
expect(guide.position).toBeCloseTo(60, 0);
});
test('board lists its ruler guides', (ctx) => {
ctx.board.addRulerGuide('horizontal', 30);
expect(ctx.board.rulerGuides.length).toBeGreaterThan(0);
});
test('board removeRulerGuide removes a guide', (ctx) => {
const guide = ctx.board.addRulerGuide('vertical', 50);
ctx.board.removeRulerGuide(guide);
expect(ctx.board.rulerGuides.length).toBe(0);
});
test('page ruler guides can be added and removed', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
const guide = page.addRulerGuide('horizontal', 120);
expect(guide.orientation).toBe('horizontal');
expect(page.rulerGuides.length).toBeGreaterThan(0);
page.removeRulerGuide(guide);
}
});
});
describe('Board guides', () => {
test('column, row and square guides round-trip', (ctx) => {
ctx.board.guides = [
{
type: 'column',
display: true,
params: {
color: { color: '#ff0000', opacity: 1 },
type: 'stretch',
size: 12,
gutter: 8,
},
},
{
type: 'row',
display: true,
params: {
color: { color: '#00ff00', opacity: 1 },
type: 'stretch',
size: 12,
gutter: 8,
},
},
{
type: 'square',
display: true,
params: { color: { color: '#0000ff', opacity: 1 }, size: 16 },
},
];
const guides = ctx.board.guides;
expect(guides).toHaveLength(3);
expect(guides[0].type).toBe('column');
expect(guides[1].type).toBe('row');
expect(guides[2].type).toBe('square');
expect(guides[0].display).toBe(true);
expect(guides[0].params.color.color).toBe('#ff0000');
// Read every guide's display + params fields so the per-type guide and
// params getters are all exercised.
for (const g of guides) {
void g.display;
if (g.type === 'column' || g.type === 'row') {
void g.params.color;
void g.params.type;
void g.params.size;
void g.params.gutter;
void g.params.margin;
void g.params.itemLength;
} else if (g.type === 'square') {
void g.params.color;
void g.params.size;
}
}
});
});

View File

@ -0,0 +1,334 @@
:root {
font-family: var(--font-family, sans-serif);
font-size: 12px;
}
body {
margin: 0;
}
.wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-12, 12px);
padding: var(--spacing-12, 12px);
min-height: 100vh;
box-sizing: border-box;
}
.header {
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
}
.title {
font-size: 14px;
margin: 0;
color: var(--foreground-primary);
}
.summary {
margin: 0;
color: var(--foreground-secondary);
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8, 8px);
}
.toolbar .reload {
margin-inline-start: auto;
}
.toolbar-status {
flex-basis: 100%;
color: var(--foreground-secondary);
}
.groups {
display: flex;
flex-direction: column;
gap: var(--spacing-8, 8px);
}
.group {
border-radius: var(--spacing-8, 8px);
background-color: var(--background-secondary);
}
.group-summary {
display: flex;
align-items: center;
gap: var(--spacing-8, 8px);
padding: var(--spacing-8, 8px);
cursor: pointer;
user-select: none;
}
.group-name {
color: var(--foreground-primary);
}
.group-counts {
margin-inline-start: auto;
font-variant-numeric: tabular-nums;
}
.count-pass {
color: #2d9d78;
}
.count-fail {
color: #e65244;
}
.count-sep,
.count-total {
color: var(--foreground-secondary);
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-4, 4px);
}
.icon {
display: block;
}
.reload.is-loading .icon {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Small rotating ring reused wherever something is in progress. */
.spinner {
display: inline-block;
flex: 0 0 auto;
width: 10px;
height: 10px;
border: 1.5px solid var(--background-quaternary);
border-top-color: #6911d4;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.running-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-4, 4px);
color: #6911d4;
}
.group .test-list {
padding: 0 var(--spacing-8, 8px) var(--spacing-8, 8px);
}
.test-list {
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
list-style: none;
margin: 0;
padding: 0;
}
.test-row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--spacing-8, 8px);
padding: var(--spacing-8, 8px);
border-radius: var(--spacing-8, 8px);
background-color: var(--background-tertiary);
}
.test-main {
display: flex;
align-items: center;
gap: var(--spacing-8, 8px);
cursor: pointer;
min-width: 0;
}
.test-name {
color: var(--foreground-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.test-duration {
display: inline-flex;
align-items: center;
gap: var(--spacing-4, 4px);
color: var(--foreground-secondary);
font-variant-numeric: tabular-nums;
}
.test-duration.running {
color: #6911d4;
}
/* Tint the whole row while its test runs so it stands out at a glance. */
.test-row.status-running {
background-color: color-mix(in srgb, #6911d4 12%, var(--background-tertiary));
}
.status-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--foreground-secondary);
}
.dot-pending {
background-color: #8f9da3;
}
.dot-running {
background-color: #6911d4;
animation: pulse 1s ease-in-out infinite;
}
.dot-pass {
background-color: #2d9d78;
}
.dot-fail {
background-color: #e65244;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.test-error {
grid-column: 1 / -1;
margin: 0;
padding: var(--spacing-8, 8px);
border-radius: var(--spacing-4, 4px);
background-color: var(--background-primary);
color: #e65244;
white-space: pre-wrap;
word-break: break-word;
font-family: monospace;
}
.coverage {
display: flex;
flex-direction: column;
gap: var(--spacing-8, 8px);
border-top: 1px solid var(--background-quaternary);
padding-top: var(--spacing-8, 8px);
color: var(--foreground-secondary);
}
.coverage-empty {
margin: 0;
}
.coverage-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.coverage-title {
color: var(--foreground-primary);
font-weight: 600;
}
.coverage-value {
color: var(--foreground-secondary);
font-variant-numeric: tabular-nums;
}
.progress-track {
width: 100%;
height: 8px;
border-radius: 999px;
background-color: var(--background-quaternary);
overflow: hidden;
}
.progress-track {
position: relative;
}
.progress-fill {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
height: 100%;
border-radius: inherit;
background-color: #2d9d78;
transition: width 0.3s ease;
}
/* The static segment sits behind the recorded fill, in a distinct blue. */
.progress-fill.static {
background-color: #4a8fe7;
}
.coverage summary {
cursor: pointer;
color: var(--foreground-primary);
}
.coverage-body {
display: flex;
flex-direction: column;
gap: var(--spacing-8, 8px);
margin-top: var(--spacing-8, 8px);
}
.coverage-iface {
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
}
.coverage-iface strong {
color: var(--foreground-primary);
}
.coverage-members {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4, 4px);
}
.coverage-member {
padding: 1px 6px;
border-radius: var(--spacing-4, 4px);
background-color: var(--background-tertiary);
word-break: break-word;
}
.coverage-member.covered {
color: #2d9d78;
}
.coverage-member.static {
color: #4a8fe7;
}
.coverage-member.uncovered {
color: var(--foreground-secondary);
}

View File

@ -0,0 +1,558 @@
import 'plugins-styles/lib/styles.css';
import './ui.css';
import type { PluginToUIMessage, UIToPluginMessage } from './model';
import type {
CoverageReport,
TestMeta,
TestResult,
TestStatus,
} from './framework/types';
const root = document.getElementById('app') as HTMLElement;
let tests: TestMeta[] = [];
const results = new Map<string, TestResult>();
const selected = new Set<string>();
const expandedGroups = new Set<string>();
let running = false;
let reloading = false;
let statusText = '';
/** Groups tests by their `group`, preserving first-seen order. */
function groupTests(): { name: string; tests: TestMeta[] }[] {
const order: string[] = [];
const byGroup = new Map<string, TestMeta[]>();
for (const test of tests) {
let bucket = byGroup.get(test.group);
if (!bucket) {
bucket = [];
byGroup.set(test.group, bucket);
order.push(test.group);
}
bucket.push(test);
}
return order.map((name) => ({ name, tests: byGroup.get(name)! }));
}
function applyTheme(theme: string | null) {
document.documentElement.setAttribute(
'data-theme',
theme === 'light' ? 'light' : 'dark',
);
}
function sendToPlugin(message: UIToPluginMessage) {
// `'*'` is intentional: the plugin host controls this iframe's parent and the
// exact embedding origin isn't known ahead of time. Standard for Penpot
// plugin iframes; nothing sensitive crosses this channel.
parent.postMessage(message, '*');
}
/**
* Rolls a group's leaf statuses up into a single status for its header dot:
* running if any test is running, otherwise failed if any failed, otherwise
* passed only when every test passed, and pending until then.
*/
function aggregateStatus(statuses: TestStatus[]): TestStatus {
if (statuses.some((s) => s === 'running')) return 'running';
if (statuses.some((s) => s === 'fail')) return 'fail';
if (statuses.length > 0 && statuses.every((s) => s === 'pass')) return 'pass';
return 'pending';
}
function statusLabel(status: TestStatus): string {
switch (status) {
case 'pass':
return 'Passed';
case 'fail':
return 'Failed';
case 'running':
return 'Running…';
default:
return 'Not run';
}
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
props: Partial<HTMLElementTagNameMap[K]> = {},
children: (Node | string)[] = [],
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
Object.assign(node, props);
for (const child of children) {
node.append(child);
}
return node;
}
function svgIcon(paths: string[], fill: boolean): SVGSVGElement {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('width', '12');
svg.setAttribute('height', '12');
svg.setAttribute('aria-hidden', 'true');
svg.classList.add('icon');
for (const d of paths) {
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', d);
if (fill) {
path.setAttribute('fill', 'currentColor');
} else {
path.setAttribute('fill', 'none');
path.setAttribute('stroke', 'currentColor');
path.setAttribute('stroke-width', '1.5');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
}
svg.append(path);
}
return svg;
}
/** A small triangular "play" icon used on run buttons. */
function playIcon(): SVGSVGElement {
return svgIcon(['M4 2.5v11l9-5.5z'], true);
}
/** A spinning ring, shown wherever something is in progress. */
function spinner(): HTMLElement {
return el('span', { className: 'spinner', ariaLabel: 'In progress' });
}
/** A circular-arrow "reload" icon. */
function reloadIcon(): SVGSVGElement {
return svgIcon(['M13 8a5 5 0 1 1-1.46-3.54', 'M13 2.5v3h-3'], false);
}
function render() {
root.replaceChildren(
renderHeader(),
renderToolbar(),
renderList(),
renderCoverage(),
);
}
function renderHeader(): HTMLElement {
const passed = [...results.values()].filter(
(r) => r.status === 'pass',
).length;
const failed = [...results.values()].filter(
(r) => r.status === 'fail',
).length;
const summary = el('p', { className: 'summary' }, [
`${tests.length} tests · ${passed} passed · ${failed} failed`,
]);
if (running) {
summary.append(
' · ',
el('span', { className: 'running-badge' }, [spinner(), 'Running…']),
);
}
return el('header', { className: 'header' }, [
el('h1', { className: 'title', textContent: 'Plugin API Test Suite' }),
summary,
]);
}
function renderToolbar(): HTMLElement {
const runAll = el('button', {
textContent: 'Run all',
disabled: running || tests.length === 0,
});
runAll.dataset.appearance = 'primary';
runAll.addEventListener('click', () => run('all'));
const runSelected = el('button', {
textContent: 'Run selected',
disabled: running || selected.size === 0,
});
runSelected.dataset.appearance = 'secondary';
runSelected.addEventListener('click', () => run([...selected]));
const reload = el('button', {
className: `icon-button reload${reloading ? ' is-loading' : ''}`,
title: reloading
? 'Reloading tests…'
: 'Reload: fetch and apply edited tests without reopening the plugin',
ariaLabel: 'Reload tests',
disabled: running || reloading,
});
reload.dataset.appearance = 'secondary';
reload.append(reloadIcon());
reload.addEventListener('click', () => reloadTests());
const toolbar = el('div', { className: 'toolbar' }, [
runAll,
runSelected,
reload,
]);
if (statusText) {
toolbar.append(
el('span', { className: 'toolbar-status', textContent: statusText }),
);
}
return toolbar;
}
function renderRow(test: TestMeta): HTMLElement {
const result = results.get(test.id);
const status = result?.status ?? 'pending';
const checkbox = el('input', {
type: 'checkbox',
className: 'checkbox-input',
checked: selected.has(test.id),
disabled: running,
});
checkbox.addEventListener('change', () => {
if (checkbox.checked) selected.add(test.id);
else selected.delete(test.id);
render();
});
const runButton = el('button', {
className: 'icon-button run-single',
title: `Run "${test.name}"`,
ariaLabel: `Run "${test.name}"`,
disabled: running,
});
runButton.dataset.appearance = 'secondary';
runButton.append(playIcon());
runButton.addEventListener('click', () => run([test.id]));
const durationCell =
status === 'running'
? el('span', { className: 'test-duration running' }, [
spinner(),
'Running…',
])
: el('span', {
className: 'test-duration',
textContent: result ? `${result.durationMs}ms` : '',
});
const row = el('li', { className: `test-row status-${status}` }, [
el('label', { className: 'test-main' }, [
checkbox,
el('span', {
className: `status-dot dot-${status}`,
title: statusLabel(status),
}),
el('span', { className: 'test-name', textContent: test.name }),
]),
durationCell,
runButton,
]);
if (result?.status === 'fail' && result.error) {
row.append(
el('pre', { className: 'test-error', textContent: result.error }),
);
}
return row;
}
function renderGroupSummary(
name: string,
groupTestList: TestMeta[],
): HTMLElement {
const statuses = groupTestList.map(
(t) => results.get(t.id)?.status ?? 'pending',
);
const passed = statuses.filter((s) => s === 'pass').length;
const failed = statuses.filter((s) => s === 'fail').length;
const total = groupTestList.length;
const aggregate = aggregateStatus(statuses);
// Select-all checkbox for the group (indeterminate when partially selected).
const ids = groupTestList.map((t) => t.id);
const selectedCount = ids.filter((id) => selected.has(id)).length;
const groupCheckbox = el('input', {
type: 'checkbox',
className: 'checkbox-input',
checked: selectedCount === total && total > 0,
disabled: running,
});
groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total;
// Keep the checkbox from toggling the <details> when clicked.
groupCheckbox.addEventListener('click', (e) => e.stopPropagation());
groupCheckbox.addEventListener('change', () => {
if (groupCheckbox.checked) ids.forEach((id) => selected.add(id));
else ids.forEach((id) => selected.delete(id));
render();
});
const runButton = el('button', {
className: 'icon-button run-group',
title: `Run "${name}"`,
ariaLabel: `Run "${name}"`,
disabled: running,
});
runButton.dataset.appearance = 'secondary';
runButton.append(playIcon());
runButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
run(ids);
});
const counts = el('span', { className: 'group-counts' }, [
el('span', { className: 'count-pass', textContent: `${passed}` }),
el('span', { className: 'count-sep', textContent: ' / ' }),
el('span', { className: 'count-fail', textContent: `${failed}` }),
el('span', {
className: 'count-total',
textContent: ` · ${total} test${total === 1 ? '' : 's'}`,
}),
]);
return el('summary', { className: 'group-summary' }, [
groupCheckbox,
el('span', {
className: `status-dot dot-${aggregate}`,
title: statusLabel(aggregate),
}),
el('span', { className: 'group-name', textContent: name }),
counts,
runButton,
]);
}
function renderList(): HTMLElement {
const container = el('div', { className: 'groups' });
for (const group of groupTests()) {
const details = el('details', { className: 'group' });
// Groups are collapsed by default; remember the ones the user expands.
details.open = expandedGroups.has(group.name);
details.addEventListener('toggle', () => {
if (details.open) expandedGroups.add(group.name);
else expandedGroups.delete(group.name);
});
details.append(renderGroupSummary(group.name, group.tests));
const list = el('ul', { className: 'test-list' });
for (const test of group.tests) {
list.append(renderRow(test));
}
details.append(list);
container.append(details);
}
return container;
}
let lastCoverage: CoverageReport | null = null;
function renderProgressBar(
percent: number,
effectivePercent: number,
): HTMLElement {
const track = el('div', {
className: 'progress-track',
role: 'progressbar',
title: `${percent}% recorded, ${effectivePercent}% effective`,
});
track.setAttribute('aria-valuenow', String(percent));
track.setAttribute('aria-valuemin', '0');
track.setAttribute('aria-valuemax', '100');
// Layered: the static segment (lighter) spans the effective coverage, the
// recorded fill (green) sits on top spanning the recorder-credited coverage.
const staticFill = el('div', { className: 'progress-fill static' });
staticFill.style.width = `${effectivePercent}%`;
const fill = el('div', { className: 'progress-fill' });
fill.style.width = `${percent}%`;
track.append(staticFill, fill);
return track;
}
function renderCoverage(): HTMLElement {
const section = el('div', { className: 'coverage' });
if (!lastCoverage) {
section.append(
el('p', {
className: 'coverage-empty',
textContent: 'API coverage — run tests to measure',
}),
);
return section;
}
const {
covered,
staticallyCovered,
total,
percent,
effectivePercent,
byInterface,
} = lastCoverage;
const valueText =
staticallyCovered > 0
? `${percent}% · ${effectivePercent}% eff. (${covered}+${staticallyCovered}/${total})`
: `${percent}% (${covered}/${total})`;
section.append(
el('div', { className: 'coverage-header' }, [
el('span', {
className: 'coverage-title',
textContent: 'API coverage',
}),
el('span', {
className: 'coverage-value',
textContent: valueText,
}),
]),
renderProgressBar(percent, effectivePercent),
);
const details = el('details', { className: 'coverage-details' });
details.append(el('summary', { textContent: 'Coverage by interface' }));
const list = el('div', { className: 'coverage-body' });
const interfaces = Object.entries(byInterface)
.filter(([, info]) => info.members.length > 0)
.sort(([a], [b]) => a.localeCompare(b));
for (const [iface, info] of interfaces) {
const members = el('div', { className: 'coverage-members' });
// Covered (green) first, then statically covered (blue), then uncovered.
for (const m of info.covered) {
members.append(
el('span', { className: 'coverage-member covered', textContent: m }),
);
}
for (const m of info.staticallyCovered) {
members.append(
el('span', {
className: 'coverage-member static',
textContent: m,
title: 'Exercised behaviourally; not creditable via the proxy',
}),
);
}
for (const m of info.uncovered) {
members.append(
el('span', { className: 'coverage-member uncovered', textContent: m }),
);
}
const ifaceLabel =
info.staticallyCovered.length > 0
? `${iface} (${info.covered.length}+${info.staticallyCovered.length}/${info.members.length})`
: `${iface} (${info.covered.length}/${info.members.length})`;
list.append(
el('div', { className: 'coverage-iface' }, [
el('strong', {
textContent: ifaceLabel,
}),
members,
]),
);
}
details.append(list);
section.append(details);
return section;
}
function run(ids: string[] | 'all') {
if (running) return;
running = true;
const targetIds = ids === 'all' ? tests.map((t) => t.id) : ids;
for (const id of targetIds) {
const test = tests.find((t) => t.id === id);
if (test) {
results.set(id, {
id,
name: test.name,
status: 'running',
durationMs: 0,
});
}
}
render();
sendToPlugin({ type: 'run', ids });
}
async function reloadTests() {
if (running || reloading) return;
reloading = true;
statusText = '';
render();
try {
// Fetch the freshly built tests bundle from the dev server (same origin as
// this iframe). `vite build --watch` rebuilds it on every save, so this
// picks up edited tests. The cache-busting query avoids any stale copy.
const response = await fetch(`./tests-bundle.js?t=${Date.now()}`);
if (!response.ok) {
throw new Error(`Failed to fetch tests bundle (${response.status})`);
}
const code = await response.text();
// The sandbox evaluates the bundle and replies with `reloaded` + `tests`.
sendToPlugin({ type: 'reloadTests', code });
} catch (err) {
reloading = false;
statusText = `Reload failed: ${err instanceof Error ? err.message : String(err)}`;
render();
}
}
window.addEventListener('message', (event: MessageEvent<PluginToUIMessage>) => {
const message = event.data;
if (!message || typeof message !== 'object') return;
switch (message.type) {
case 'tests': {
tests = message.tests;
// Drop results/selection for tests that no longer exist after a reload.
const ids = new Set(tests.map((t) => t.id));
for (const id of [...results.keys()]) {
if (!ids.has(id)) results.delete(id);
}
for (const id of [...selected]) {
if (!ids.has(id)) selected.delete(id);
}
render();
break;
}
case 'result':
results.set(message.result.id, message.result);
render();
break;
case 'runComplete':
running = false;
lastCoverage = message.coverage;
render();
break;
case 'reloaded':
reloading = false;
statusText = message.ok
? `Reloaded ${tests.length} tests`
: `Reload failed: ${message.error ?? 'unknown error'}`;
render();
break;
case 'theme':
applyTheme(message.theme);
break;
}
});
applyTheme(new URLSearchParams(window.location.search).get('theme'));
render();
sendToPlugin({ type: 'ready' });

View File

@ -0,0 +1,339 @@
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { mkdirSync, writeFileSync } from 'node:fs';
import ts from 'typescript';
/**
* Generates `src/generated/api-surface.json` from `libs/plugin-types/index.d.ts`
* using the TypeScript compiler API. The output drives type-aware coverage:
*
* - `interfaces`: own (syntactically declared) members per interface the
* coverage denominator.
* - `graph`: for every interface, all reachable members (including inherited),
* each annotated with the interface that declares it and the type it yields.
* This lets the recorder attribute an access to the interface the value really
* is, instead of matching member names across unrelated interfaces.
* - `unions`: union aliases (e.g. `Shape`) with the discriminant needed to pick
* the concrete variant of a runtime value.
*
* Re-run with `pnpm run gen:api` whenever the public Plugin API types change.
*/
const here = dirname(fileURLToPath(import.meta.url));
const typesPath = resolve(here, '../../../libs/plugin-types/index.d.ts');
const outPath = resolve(here, '../src/generated/api-surface.json');
const program = ts.createProgram([typesPath], { skipLibCheck: true });
const checker = program.getTypeChecker();
const source = program.getSourceFile(typesPath);
if (!source) {
throw new Error(`Could not load Plugin API types at ${typesPath}`);
}
const interfaceDecls = new Map<string, ts.InterfaceDeclaration>();
const unionAliases = new Map<string, ts.TypeAliasDeclaration>();
// Object-literal type aliases (e.g. `type LibraryContext = { local: Library; … }`)
// are treated like interfaces so the recorder can wrap them and follow the chain
// into the types they expose (e.g. Context.library -> LibraryContext.local -> Library).
const objectAliases = new Map<
string,
{ decl: ts.TypeAliasDeclaration; literal: ts.TypeLiteralNode }
>();
source.forEachChild((node) => {
if (ts.isInterfaceDeclaration(node)) {
interfaceDecls.set(node.name.text, node);
} else if (ts.isTypeAliasDeclaration(node) && ts.isUnionTypeNode(node.type)) {
unionAliases.set(node.name.text, node);
} else if (
ts.isTypeAliasDeclaration(node) &&
ts.isTypeLiteralNode(node.type)
) {
objectAliases.set(node.name.text, { decl: node, literal: node.type });
}
});
const knownInterfaces = new Set([
...interfaceDecls.keys(),
...objectAliases.keys(),
]);
const knownUnions = new Set(unionAliases.keys());
function memberName(member: ts.TypeElement): string | undefined {
if (
(ts.isPropertySignature(member) || ts.isMethodSignature(member)) &&
member.name &&
(ts.isIdentifier(member.name) || ts.isStringLiteral(member.name))
) {
return member.name.text;
}
return undefined;
}
/** True when a declaration carries an `@deprecated` JSDoc tag. */
function isDeprecated(node: ts.Node): boolean {
return ts.getJSDocTags(node).some((t) => t.tagName.text === 'deprecated');
}
// Own (declared) members per interface — the coverage denominator. Deprecated
// interfaces and members are skipped so deprecated API never counts towards
// coverage (e.g. the legacy `Image` shape, `Color.refId/refFile`).
const interfaces: Record<string, string[]> = {};
for (const [name, decl] of interfaceDecls) {
if (isDeprecated(decl)) continue;
const names = new Set<string>();
for (const member of decl.members) {
if (isDeprecated(member)) continue;
const m = memberName(member);
if (m) names.add(m);
}
if (names.size > 0) interfaces[name] = [...names].sort();
}
for (const [name, { decl, literal }] of objectAliases) {
if (isDeprecated(decl)) continue;
const names = new Set<string>();
for (const member of literal.members) {
if (isDeprecated(member)) continue;
const m = memberName(member);
if (m) names.add(m);
}
if (names.size > 0) interfaces[name] = [...names].sort();
}
// Honor `Omit<Base, Keys>` in heritage clauses: a member the *public* interface
// removes from an internal base is not part of the reachable surface, so it must
// not count towards coverage. `Penpot extends Omit<Context, 'addListener' |
// 'removeListener'>` is the motivating case — `Context` is the internal interface
// and `Penpot` is the public one — but this applies to any such omission.
function stringLiterals(node: ts.TypeNode): string[] {
const collect = (n: ts.TypeNode): string[] => {
if (ts.isLiteralTypeNode(n) && ts.isStringLiteral(n.literal)) {
return [n.literal.text];
}
if (ts.isUnionTypeNode(n)) return n.types.flatMap(collect);
return [];
};
return collect(node);
}
for (const decl of interfaceDecls.values()) {
for (const clause of decl.heritageClauses ?? []) {
for (const t of clause.types) {
if (
ts.isIdentifier(t.expression) &&
t.expression.text === 'Omit' &&
t.typeArguments?.length === 2
) {
const [baseRef, keysArg] = t.typeArguments;
if (
ts.isTypeReferenceNode(baseRef) &&
ts.isIdentifier(baseRef.typeName)
) {
const base = baseRef.typeName.text;
const omitted = new Set(stringLiterals(keysArg));
if (interfaces[base] && omitted.size > 0) {
interfaces[base] = interfaces[base].filter((m) => !omitted.has(m));
}
}
}
}
}
}
/**
* Resolves a type to a tracked interface/union name (+ array flag) by parsing
* its textual form. Using `typeToString` keeps this resilient across compiler
* versions, where the structural type-flag APIs differ.
*/
function resolveType(type: ts.Type): { name: string | null; array: boolean } {
let text = checker.typeToString(type).replace(/^readonly\s+/, '');
// Unwrap Promise<...>
const promiseMatch = text.match(/^Promise<(.+)>$/s);
if (promiseMatch) text = promiseMatch[1].trim();
// Drop nullish, string-literal and bare-primitive union parts before array
// detection, so a single tracked type can still be resolved out of unions like
// `Group | null`, `Fill[] | 'mixed'` or `string | TokenShadowValueString[]`.
// Dropping primitives is safe: the recorder never wraps primitive values, so a
// primitive run-time value is returned as-is regardless of the resolved type.
const primitives = new Set([
'null',
'undefined',
'string',
'number',
'boolean',
'unknown',
'any',
'void',
]);
text = text
.split('|')
.map((p) => p.trim())
.filter((p) => !primitives.has(p) && !/^["'].*["']$/.test(p))
.join(' | ');
let array = false;
const arrayMatch = text.match(/^(.+)\[\]$/s) ?? text.match(/^Array<(.+)>$/s);
if (arrayMatch) {
array = true;
text = arrayMatch[1].trim();
}
if (knownInterfaces.has(text) || knownUnions.has(text)) {
return { name: text, array };
}
return { name: null, array };
}
// Full member graph per interface (including inherited members).
const graph: Record<string, Record<string, ApiMemberInfoOut>> = {};
type MemberKind = 'method' | 'get' | 'getset';
interface ApiMemberInfoOut {
decl: string;
kind: MemberKind;
type: string | null;
array: boolean;
}
/** Classifies a member declaration as a method, read-only, or writable property. */
function memberKind(decl: ts.Declaration): MemberKind {
if (ts.isMethodSignature(decl)) return 'method';
if (ts.isPropertySignature(decl)) {
if (decl.type && ts.isFunctionTypeNode(decl.type)) return 'method';
const readonly = decl.modifiers?.some(
(m) => m.kind === ts.SyntaxKind.ReadonlyKeyword,
);
return readonly ? 'get' : 'getset';
}
return 'getset';
}
for (const [name, decl] of interfaceDecls) {
const type = checker.getTypeAtLocation(decl);
const entries: Record<string, ApiMemberInfoOut> = {};
for (const prop of checker.getPropertiesOfType(type)) {
const declaration = prop.declarations?.[0];
if (!declaration) continue;
const parent = declaration.parent;
if (!parent || !ts.isInterfaceDeclaration(parent)) continue;
const declName = parent.name.text;
if (!knownInterfaces.has(declName)) continue;
const propType = checker.getTypeOfSymbolAtLocation(prop, decl);
const signatures = propType.getCallSignatures();
const resolved = resolveType(
signatures.length > 0 ? signatures[0].getReturnType() : propType,
);
entries[prop.name] = {
decl: declName,
kind: memberKind(declaration),
type: resolved.name,
array: resolved.array,
};
}
graph[name] = entries;
}
// Object-literal aliases: all members are own (no inheritance), so the declaring
// interface is always the alias itself.
for (const [name, { decl, literal }] of objectAliases) {
const entries: Record<string, ApiMemberInfoOut> = {};
for (const member of literal.members) {
const m = memberName(member);
if (!m) continue;
const propType = checker.getTypeAtLocation(member);
const signatures = propType.getCallSignatures();
const resolved = resolveType(
signatures.length > 0 ? signatures[0].getReturnType() : propType,
);
entries[m] = {
decl: name,
kind: memberKind(member),
type: resolved.name,
array: resolved.array,
};
}
graph[name] = entries;
void decl;
}
// Union aliases + discriminants (literal `type` field -> variant interface).
const unions: Record<string, UnionInfoOut> = {};
interface UnionInfoOut {
variants: string[];
discriminant: { field: string; map: Record<string, string> } | null;
}
function literalDiscriminant(
iface: ts.InterfaceDeclaration,
field: string,
): string | null {
for (const member of iface.members) {
if (memberName(member) !== field) continue;
if (ts.isPropertySignature(member) && member.type) {
if (
ts.isLiteralTypeNode(member.type) &&
ts.isStringLiteral(member.type.literal)
) {
return member.type.literal.text;
}
}
}
return null;
}
for (const [name, decl] of unionAliases) {
if (!ts.isUnionTypeNode(decl.type)) continue;
const variants: string[] = [];
for (const member of decl.type.types) {
if (ts.isTypeReferenceNode(member) && ts.isIdentifier(member.typeName)) {
const variantName = member.typeName.text;
if (knownInterfaces.has(variantName)) variants.push(variantName);
}
}
if (variants.length === 0) continue;
// Build a discriminant map using the `type` literal of each variant.
const map: Record<string, string> = {};
for (const variant of variants) {
const lit = literalDiscriminant(interfaceDecls.get(variant)!, 'type');
if (lit) map[lit] = variant;
}
unions[name] = {
variants,
discriminant: Object.keys(map).length > 0 ? { field: 'type', map } : null,
};
}
const surface = {
interfaces: Object.fromEntries(
Object.entries(interfaces).sort(([a], [b]) => a.localeCompare(b)),
),
graph: Object.fromEntries(
Object.entries(graph).sort(([a], [b]) => a.localeCompare(b)),
),
unions: Object.fromEntries(
Object.entries(unions).sort(([a], [b]) => a.localeCompare(b)),
),
};
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(surface, null, 2) + '\n');
const memberCount = Object.values(surface.interfaces).reduce(
(sum, members) => sum + members.length,
0,
);
console.log(
`Wrote ${memberCount} members across ${Object.keys(surface.interfaces).length} ` +
`interfaces and ${Object.keys(surface.unions).length} unions to ${outPath}`,
);

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node", "vite/client"]
},
"include": ["src/**/*.ts", "../../libs/plugin-types/index.d.ts"]
}

View File

@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src"],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"],
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": [
"tools/**/*.ts",
"ci/**/*.ts",
"vite.config.ts",
"vite.config.headless.ts",
"vite.config.tests.ts",
"vite.config.iife.ts"
]
}

View File

@ -0,0 +1,6 @@
import { iifeConfig } from './vite.config.iife';
// Builds the CI test entry as a single self-executing (IIFE) bundle, evaluated
// inside the Penpot plugin sandbox via `globalThis.ɵloadPlugin({ code })` by the
// CI runner. See vite.config.iife.ts for the shared bundle config.
export default iifeConfig('headless', 'src/ci/headless.ts');

View File

@ -0,0 +1,34 @@
import { defineConfig, type UserConfig } from 'vite';
/**
* Shared config for the two single-file IIFE bundles (`headless.js`,
* `tests-bundle.js`). Both are self-executing chunks with no `import`/`export`
* statements so they can be evaluated directly inside the Penpot plugin sandbox
* (via `globalThis.ɵloadPlugin({ code })` for headless, or the UI "Reload"
* button's `eval` for the tests bundle). They differ only by their entry module.
*
* `emptyOutDir` stays false so a `watch` rebuild of one bundle never wipes the
* sibling outputs in the shared `dist` directory.
*/
export function iifeConfig(name: string, entry: string): UserConfig {
return defineConfig({
root: __dirname,
resolve: {
tsconfigPaths: true,
},
build: {
outDir: '../../dist/apps/plugin-api-test-suite',
emptyOutDir: false,
reportCompressedSize: false,
rollupOptions: {
input: {
[name]: entry,
},
output: {
format: 'iife',
entryFileNames: '[name].js',
},
},
},
});
}

View File

@ -0,0 +1,8 @@
import { iifeConfig } from './vite.config.iife';
// Builds the test cases as a single self-executing (IIFE) bundle that publishes
// the discovered tests on `globalThis.__penpotReloadedTests`. The UI "Reload"
// button fetches this file and the plugin sandbox `eval`s it to pick up edited
// tests without reopening the plugin. Rebuilt on save by the `watch` script.
// See vite.config.iife.ts for the shared bundle config.
export default iifeConfig('tests-bundle', 'src/tests-bundle.ts');

View File

@ -0,0 +1,39 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
export default defineConfig({
root: __dirname,
server: {
port: 4202,
host: '0.0.0.0',
cors: true,
},
preview: {
port: 4202,
host: '0.0.0.0',
cors: true,
},
resolve: {
tsconfigPaths: true,
},
build: {
outDir: '../../dist/apps/plugin-api-test-suite',
// Keep false so `watch` rebuilds don't wipe the sibling tests-bundle.js /
// headless.js outputs. The `build` script passes --emptyOutDir for a clean
// one-shot build.
emptyOutDir: false,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
rollupOptions: {
input: {
plugin: 'src/plugin.ts',
index: './index.html',
},
output: {
entryFileNames: '[name].js',
},
},
},
});

View File

@ -17,8 +17,9 @@
"start:plugin:renamelayers": "pnpm --filter rename-layers-plugin run init",
"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",
"build:runtime": "pnpm --filter @penpot/plugins-runtime build",
"build:plugins": "pnpm --parallel --filter './apps/*-plugin' --filter '!poc-state-plugin' build",
"build:plugins": "pnpm --parallel --filter './apps/*-plugin' --filter plugin-api-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}\"",

37
plugins/pnpm-lock.yaml generated
View File

@ -34,7 +34,7 @@ importers:
version: 21.2.15(@angular/common@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.15(@angular/animations@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)
axios:
specifier: ^1.16.1
version: 1.16.1
version: 1.16.1(debug@4.4.3)
feather-icons:
specifier: ^4.29.2
version: 4.29.2
@ -216,6 +216,12 @@ importers:
apps/lorem-ipsum-plugin: {}
apps/plugin-api-test-suite:
devDependencies:
playwright:
specifier: ^1.61.0
version: 1.61.0
apps/poc-state-plugin: {}
apps/poc-tokens-plugin: {}
@ -412,6 +418,7 @@ packages:
'@angular/animations@21.2.15':
resolution: {integrity: sha512-Z8AsLTwc++Fcu0fJnclAF9zMfumAd5KXrwtSdyECqLpqd+lEmmsOpeOl6P7loqdDz99KYh/8UF4eJxdMvnsaKw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
deprecated: '@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.'
peerDependencies:
'@angular/core': 21.2.15
@ -4158,6 +4165,11 @@ packages:
resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
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}
@ -5387,6 +5399,16 @@ packages:
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
engines: {node: '>=16.0.0'}
playwright-core@1.61.0:
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.0:
resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@ -10122,7 +10144,7 @@ snapshots:
axe-core@4.11.4: {}
axios@1.16.1:
axios@1.16.1(debug@4.4.3):
dependencies:
follow-redirects: 1.16.0(debug@4.4.3)
form-data: 4.0.5
@ -11260,6 +11282,9 @@ snapshots:
dependencies:
minipass: 7.1.3
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@ -12578,6 +12603,14 @@ snapshots:
pvutils: 1.1.5
tslib: 2.8.1
playwright-core@1.61.0: {}
playwright@1.61.0:
dependencies:
playwright-core: 1.61.0
optionalDependencies:
fsevents: 2.3.2
possible-typed-array-names@1.1.0: {}
postcss-loader@8.2.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(postcss@8.5.12)(typescript@6.0.3)(webpack@5.105.2(esbuild@0.27.3)(lightningcss@1.32.0)(postcss@8.5.12)):