mirror of
https://github.com/penpot/penpot.git
synced 2026-07-02 12:25:42 +00:00
✨ Plugin for api testing (#10410)
This commit is contained in:
parent
ca81776d04
commit
e0be9f7ade
133
.github/workflows/tests-plugin-api-suite.yml
vendored
Normal file
133
.github/workflows/tests-plugin-api-suite.yml
vendored
Normal 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
|
||||
391
plugins/apps/plugin-api-test-suite/README.md
Normal file
391
plugins/apps/plugin-api-test-suite/README.md
Normal 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/`.
|
||||
60
plugins/apps/plugin-api-test-suite/ci/fixtures/get-file.json
Normal file
60
plugins/apps/plugin-api-test-suite/ci/fixtures/get-file.json
Normal file
@ -0,0 +1,60 @@
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/pointer-map",
|
||||
"fdata/objects-map",
|
||||
"fdata/shape-data-type",
|
||||
"fdata/path-data",
|
||||
"components/v2",
|
||||
"design-tokens/v1",
|
||||
"variants/v1",
|
||||
"plugins/runtime"
|
||||
]
|
||||
},
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "New File 1",
|
||||
"~:revn": 11,
|
||||
"~:modified-at": "~m1713873823633",
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:is-shared": false,
|
||||
"~:version": 46,
|
||||
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
|
||||
"~:created-at": "~m1713536343369",
|
||||
"~:data": {
|
||||
"~:pages": ["~u66697432-c33d-8055-8006-2c62cc084cad"],
|
||||
"~:pages-index": {
|
||||
"~u66697432-c33d-8055-8006-2c62cc084cad": {
|
||||
"~#penpot/pointer": [
|
||||
"~ude58c8f6-c5c2-8196-8004-3df9e2e52d88",
|
||||
{
|
||||
"~:created-at": "~m1713873823636"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:options": {
|
||||
"~:components-v2": true
|
||||
},
|
||||
"~:recent-colors": [
|
||||
{
|
||||
"~:color": "#0000ff",
|
||||
"~:opacity": 1,
|
||||
"~:id": null,
|
||||
"~:file-id": null,
|
||||
"~:image": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
475
plugins/apps/plugin-api-test-suite/ci/run-ci.ts
Normal file
475
plugins/apps/plugin-api-test-suite/ci/run-ci.ts
Normal 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);
|
||||
});
|
||||
27
plugins/apps/plugin-api-test-suite/eslint.config.js
Normal file
27
plugins/apps/plugin-api-test-suite/eslint.config.js
Normal 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',
|
||||
],
|
||||
},
|
||||
];
|
||||
12
plugins/apps/plugin-api-test-suite/index.html
Normal file
12
plugins/apps/plugin-api-test-suite/index.html
Normal 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>
|
||||
22
plugins/apps/plugin-api-test-suite/package.json
Normal file
22
plugins/apps/plugin-api-test-suite/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
4
plugins/apps/plugin-api-test-suite/public/_headers
Normal file
4
plugins/apps/plugin-api-test-suite/public/_headers
Normal file
@ -0,0 +1,4 @@
|
||||
/*
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type
|
||||
BIN
plugins/apps/plugin-api-test-suite/public/assets/icon.png
Normal file
BIN
plugins/apps/plugin-api-test-suite/public/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
18
plugins/apps/plugin-api-test-suite/public/manifest.json
Normal file
18
plugins/apps/plugin-api-test-suite/public/manifest.json
Normal 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"
|
||||
]
|
||||
}
|
||||
39
plugins/apps/plugin-api-test-suite/src/ci/headless.ts
Normal file
39
plugins/apps/plugin-api-test-suite/src/ci/headless.ts
Normal 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 }));
|
||||
});
|
||||
287
plugins/apps/plugin-api-test-suite/src/framework/coverage.ts
Normal file
287
plugins/apps/plugin-api-test-suite/src/framework/coverage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
285
plugins/apps/plugin-api-test-suite/src/framework/expect.ts
Normal file
285
plugins/apps/plugin-api-test-suite/src/framework/expect.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
115
plugins/apps/plugin-api-test-suite/src/framework/registry.ts
Normal file
115
plugins/apps/plugin-api-test-suite/src/framework/registry.ts
Normal 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();
|
||||
}
|
||||
179
plugins/apps/plugin-api-test-suite/src/framework/runner.ts
Normal file
179
plugins/apps/plugin-api-test-suite/src/framework/runner.ts
Normal 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 };
|
||||
}
|
||||
@ -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',
|
||||
]);
|
||||
118
plugins/apps/plugin-api-test-suite/src/framework/types.ts
Normal file
118
plugins/apps/plugin-api-test-suite/src/framework/types.ts
Normal 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>;
|
||||
}
|
||||
11192
plugins/apps/plugin-api-test-suite/src/generated/api-surface.json
Normal file
11192
plugins/apps/plugin-api-test-suite/src/generated/api-surface.json
Normal file
File diff suppressed because it is too large
Load Diff
60
plugins/apps/plugin-api-test-suite/src/model.ts
Normal file
60
plugins/apps/plugin-api-test-suite/src/model.ts
Normal 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;
|
||||
63
plugins/apps/plugin-api-test-suite/src/plugin.ts
Normal file
63
plugins/apps/plugin-api-test-suite/src/plugin.ts
Normal 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 });
|
||||
});
|
||||
15
plugins/apps/plugin-api-test-suite/src/tests-bundle.ts
Normal file
15
plugins/apps/plugin-api-test-suite/src/tests-bundle.ts
Normal 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();
|
||||
50
plugins/apps/plugin-api-test-suite/src/tests/colors.test.ts
Normal file
50
plugins/apps/plugin-api-test-suite/src/tests/colors.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
158
plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts
Normal file
158
plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
135
plugins/apps/plugin-api-test-suite/src/tests/components.test.ts
Normal file
135
plugins/apps/plugin-api-test-suite/src/tests/components.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
67
plugins/apps/plugin-api-test-suite/src/tests/events.test.ts
Normal file
67
plugins/apps/plugin-api-test-suite/src/tests/events.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
103
plugins/apps/plugin-api-test-suite/src/tests/file.test.ts
Normal file
103
plugins/apps/plugin-api-test-suite/src/tests/file.test.ts
Normal 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(''));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
12
plugins/apps/plugin-api-test-suite/src/tests/fixtures.ts
Normal file
12
plugins/apps/plugin-api-test-suite/src/tests/fixtures.ts
Normal 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,
|
||||
]);
|
||||
122
plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts
Normal file
122
plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
402
plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts
Normal file
402
plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
223
plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
Normal file
223
plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
145
plugins/apps/plugin-api-test-suite/src/tests/media.test.ts
Normal file
145
plugins/apps/plugin-api-test-suite/src/tests/media.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
389
plugins/apps/plugin-api-test-suite/src/tests/misc.test.ts
Normal file
389
plugins/apps/plugin-api-test-suite/src/tests/misc.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
162
plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts
Normal file
162
plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
164
plugins/apps/plugin-api-test-suite/src/tests/platform.test.ts
Normal file
164
plugins/apps/plugin-api-test-suite/src/tests/platform.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
108
plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts
Normal file
108
plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
327
plugins/apps/plugin-api-test-suite/src/tests/text.test.ts
Normal file
327
plugins/apps/plugin-api-test-suite/src/tests/text.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
482
plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts
Normal file
482
plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
201
plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts
Normal file
201
plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
334
plugins/apps/plugin-api-test-suite/src/ui.css
Normal file
334
plugins/apps/plugin-api-test-suite/src/ui.css
Normal 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);
|
||||
}
|
||||
558
plugins/apps/plugin-api-test-suite/src/ui.ts
Normal file
558
plugins/apps/plugin-api-test-suite/src/ui.ts
Normal 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' });
|
||||
339
plugins/apps/plugin-api-test-suite/tools/gen-api-surface.ts
Normal file
339
plugins/apps/plugin-api-test-suite/tools/gen-api-surface.ts
Normal 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}`,
|
||||
);
|
||||
8
plugins/apps/plugin-api-test-suite/tsconfig.app.json
Normal file
8
plugins/apps/plugin-api-test-suite/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
26
plugins/apps/plugin-api-test-suite/tsconfig.json
Normal file
26
plugins/apps/plugin-api-test-suite/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
19
plugins/apps/plugin-api-test-suite/tsconfig.node.json
Normal file
19
plugins/apps/plugin-api-test-suite/tsconfig.node.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -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');
|
||||
34
plugins/apps/plugin-api-test-suite/vite.config.iife.ts
Normal file
34
plugins/apps/plugin-api-test-suite/vite.config.iife.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
8
plugins/apps/plugin-api-test-suite/vite.config.tests.ts
Normal file
8
plugins/apps/plugin-api-test-suite/vite.config.tests.ts
Normal 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');
|
||||
39
plugins/apps/plugin-api-test-suite/vite.config.ts
Normal file
39
plugins/apps/plugin-api-test-suite/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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
37
plugins/pnpm-lock.yaml
generated
@ -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)):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user