From e0be9f7adecdac04ec2ad5ad95924fbd8dfc7628 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 30 Jun 2026 14:01:38 +0200 Subject: [PATCH] :sparkles: Plugin for api testing (#10410) --- .github/workflows/tests-plugin-api-suite.yml | 133 + plugins/apps/plugin-api-test-suite/README.md | 391 + .../ci/fixtures/get-file.json | 60 + .../apps/plugin-api-test-suite/ci/run-ci.ts | 475 + .../plugin-api-test-suite/eslint.config.js | 27 + plugins/apps/plugin-api-test-suite/index.html | 12 + .../apps/plugin-api-test-suite/package.json | 22 + .../plugin-api-test-suite/public/_headers | 4 + .../public/assets/icon.png | Bin 0 -> 2171 bytes .../public/manifest.json | 18 + .../plugin-api-test-suite/src/ci/headless.ts | 39 + .../src/framework/coverage.ts | 287 + .../src/framework/expect.ts | 285 + .../src/framework/registry.ts | 115 + .../src/framework/runner.ts | 179 + .../src/framework/static-coverage.ts | 70 + .../src/framework/types.ts | 118 + .../src/generated/api-surface.json | 11192 ++++++++++++++++ .../apps/plugin-api-test-suite/src/model.ts | 60 + .../apps/plugin-api-test-suite/src/plugin.ts | 63 + .../plugin-api-test-suite/src/tests-bundle.ts | 15 + .../src/tests/colors.test.ts | 50 + .../src/tests/comments.test.ts | 158 + .../src/tests/components.test.ts | 135 + .../src/tests/events.test.ts | 67 + .../src/tests/file.test.ts | 103 + .../src/tests/fills-strokes.test.ts | 280 + .../src/tests/fixtures.ts | 12 + .../src/tests/fonts.test.ts | 122 + .../src/tests/interactions.test.ts | 333 + .../src/tests/layout.test.ts | 402 + .../src/tests/library.test.ts | 223 + .../src/tests/media.test.ts | 145 + .../src/tests/misc.test.ts | 389 + .../src/tests/pages.test.ts | 162 + .../src/tests/platform.test.ts | 164 + .../src/tests/plugin-data.test.ts | 108 + .../src/tests/shadows-blur.test.ts | 81 + .../src/tests/shapes-factories.test.ts | 318 + .../src/tests/shapes-geometry.test.ts | 408 + .../src/tests/shapes-types.test.ts | 299 + .../src/tests/text.test.ts | 327 + .../src/tests/tokens.test.ts | 482 + .../src/tests/value-objects.test.ts | 316 + .../src/tests/variants.test.ts | 201 + .../src/tests/viewport-guides.test.ts | 136 + plugins/apps/plugin-api-test-suite/src/ui.css | 334 + plugins/apps/plugin-api-test-suite/src/ui.ts | 558 + .../tools/gen-api-surface.ts | 339 + .../plugin-api-test-suite/tsconfig.app.json | 8 + .../apps/plugin-api-test-suite/tsconfig.json | 26 + .../plugin-api-test-suite/tsconfig.node.json | 19 + .../vite.config.headless.ts | 6 + .../plugin-api-test-suite/vite.config.iife.ts | 34 + .../vite.config.tests.ts | 8 + .../apps/plugin-api-test-suite/vite.config.ts | 39 + plugins/package.json | 3 +- plugins/pnpm-lock.yaml | 37 +- 58 files changed, 20394 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/tests-plugin-api-suite.yml create mode 100644 plugins/apps/plugin-api-test-suite/README.md create mode 100644 plugins/apps/plugin-api-test-suite/ci/fixtures/get-file.json create mode 100644 plugins/apps/plugin-api-test-suite/ci/run-ci.ts create mode 100644 plugins/apps/plugin-api-test-suite/eslint.config.js create mode 100644 plugins/apps/plugin-api-test-suite/index.html create mode 100644 plugins/apps/plugin-api-test-suite/package.json create mode 100644 plugins/apps/plugin-api-test-suite/public/_headers create mode 100644 plugins/apps/plugin-api-test-suite/public/assets/icon.png create mode 100644 plugins/apps/plugin-api-test-suite/public/manifest.json create mode 100644 plugins/apps/plugin-api-test-suite/src/ci/headless.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/coverage.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/expect.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/registry.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/runner.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/static-coverage.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/types.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/generated/api-surface.json create mode 100644 plugins/apps/plugin-api-test-suite/src/model.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/plugin.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests-bundle.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/colors.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/components.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/events.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/file.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/fixtures.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/library.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/media.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/misc.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/platform.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/shapes-types.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/text.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/ui.css create mode 100644 plugins/apps/plugin-api-test-suite/src/ui.ts create mode 100644 plugins/apps/plugin-api-test-suite/tools/gen-api-surface.ts create mode 100644 plugins/apps/plugin-api-test-suite/tsconfig.app.json create mode 100644 plugins/apps/plugin-api-test-suite/tsconfig.json create mode 100644 plugins/apps/plugin-api-test-suite/tsconfig.node.json create mode 100644 plugins/apps/plugin-api-test-suite/vite.config.headless.ts create mode 100644 plugins/apps/plugin-api-test-suite/vite.config.iife.ts create mode 100644 plugins/apps/plugin-api-test-suite/vite.config.tests.ts create mode 100644 plugins/apps/plugin-api-test-suite/vite.config.ts diff --git a/.github/workflows/tests-plugin-api-suite.yml b/.github/workflows/tests-plugin-api-suite.yml new file mode 100644 index 0000000000..e589c5414c --- /dev/null +++ b/.github/workflows/tests-plugin-api-suite.yml @@ -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 diff --git a/plugins/apps/plugin-api-test-suite/README.md b/plugins/apps/plugin-api-test-suite/README.md new file mode 100644 index 0000000000..82313a1060 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/README.md @@ -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`; 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` (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`), 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/.test.ts` and uses `test(...)` + `expect`, + ideally wrapped in a `describe('', …)`. +- [ ] 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/`. diff --git a/plugins/apps/plugin-api-test-suite/ci/fixtures/get-file.json b/plugins/apps/plugin-api-test-suite/ci/fixtures/get-file.json new file mode 100644 index 0000000000..dcc331badd --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/ci/fixtures/get-file.json @@ -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 + } + ] + } +} diff --git a/plugins/apps/plugin-api-test-suite/ci/run-ci.ts b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts new file mode 100644 index 0000000000..5b03d30386 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts @@ -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 = /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 { + 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 = { + '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 { + 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 { + const created = new Set(); + 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 }).__wsCreated = created; +} + +async function openNotificationsWebSocket(page: Page): Promise { + const created = (page as unknown as { __wsCreated: Set }).__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 { + // 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((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((_, 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); +}); diff --git a/plugins/apps/plugin-api-test-suite/eslint.config.js b/plugins/apps/plugin-api-test-suite/eslint.config.js new file mode 100644 index 0000000000..961ac200ec --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/eslint.config.js @@ -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', + ], + }, +]; diff --git a/plugins/apps/plugin-api-test-suite/index.html b/plugins/apps/plugin-api-test-suite/index.html new file mode 100644 index 0000000000..22b70e5f1f --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/index.html @@ -0,0 +1,12 @@ + + + + + Plugin API Test Suite + + + +
+ + + diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json new file mode 100644 index 0000000000..43bf67412c --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -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" + } +} diff --git a/plugins/apps/plugin-api-test-suite/public/_headers b/plugins/apps/plugin-api-test-suite/public/_headers new file mode 100644 index 0000000000..cdb4e7ed20 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/public/_headers @@ -0,0 +1,4 @@ +/* +Access-Control-Allow-Origin: * +Access-Control-Allow-Methods: GET, POST, OPTIONS +Access-Control-Allow-Headers: Content-Type diff --git a/plugins/apps/plugin-api-test-suite/public/assets/icon.png b/plugins/apps/plugin-api-test-suite/public/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4f010b502f09ca1c2d663af93c67ce45d5182633 GIT binary patch literal 2171 zcmV->2!!{EP)Px-HAzH4RCt{2Tg`7&*A@SrbMKq+%y?om7~8Q^3?}4*#!3m1P+JKRsf!>rU9#z- zQj=91seeXQRqCpnC}EkZMCzuyO0CqCBAT>FsDU6L6zm3Y{Q>@(8P9w7-qXbxdp-oN*;mMZV)8tZt)G#-58mBLQ5aVI=GWmL|e*U9zkjP>>U!@fR5$Bw!D*|UZUg_M;A`s{|Sov>8ZMs#hBEE9Pd#(7&6-KqX`CC8ugGx1M5U$;adUV-)EpJ6LD?vxg{BKL& zEi4cupgxJfE-Y-?Q}w?=Yt#CSxBfRW2T&9}nGI*2u0svt4M22|gQ{!v0&Q+NkBkUq z{DYt<0*{Pr+PlJJP zrW!Pe%Y94iz;SOsy#b&I7&x0TKDwEgSaK&-Q^2`*-Z98#cQ(#u>$(l%hJWDfz#Cpc z*~33KAt@771G2BNWRG#WBLocoxE&}e7SVU+%#J(RcjkK%&RP=pJoys=v;*D-Mh4Q?p)hIM1zAUy!T$BCnh=uK=A^i zuYRrh(dW%ba2Cm-pBel7kAmtHx&;6mgHdu96i*vggDjcD?9>6z?qeg`G& zJSb{>T%i2gJy-7mt6wrD4@vr8cCP;=fy#FiM)v>!04!h^g}6vUxo~q?UM2vbfq`%m z5F7vqs36E3Wt(|}ETz4*T!y)PS-aU;MzWDZ zQ3M_xb^62!gETfM38JM-O09jV*sj%|k%G;PiYc8kHaot%Y*4De`e&k(o6@lwQ9+PD ztr9if!Wx`|y?$NUt5+3Fhi0NR)YOz!ue@TxEool4#hJyw=*_^l5!3&gOG>AVuzxxP zbpdk+oEAI)NSUzy52w*u@5^PR1`hznz<%+CLMs~-i_FqRl@>ez=(Ve?D>s8;@@Io9 zs0ZdDb`ezjr)(vrb4V^;j8cPtEXE+YbSY}&4$;!z6~_x+6*gM@RI_>HqY8uo5Z@Lh zUSrUP{;e$lWObEfanYNg$oxFX%F3p`Uc2UB0! zNOZLM#OT*wH(sYeYv|+0o7S;~W6t(<<_L|FlujF@PGZdx)HBsVhNZ&gQ~Co^-_plIz0NOQO*drW%Lj-{OQ)1~FM! zpya}Z2$hyEx49hjsZ$9L4RvONb`9CZKWVPqY2G-}h)bu9DSqdhV}o#jlmS-;Q3P;5 z#Jf|7K&n+prBWwD`MgjrhsL~5b=p-yW|c@1i0&81{pgkubw!2%00O`f!GYl*^(X$P zkBEqw2>_%XxVJG8Kve&yz7+urw@6n2xLIO1tu!_coNRc(`^51PXAq_MQ>_wDPe;@; z7K_%9iHSsVxy~sd2{P+9BW;%(kBR~qwZ6l~=#gDc0Snl#l$M9oxp+nbb<`y^(0&m> z^Ye=5=2XW!0wTDP5i3JOZN*aAWy-GKj40(^&gfwm_e~lJsO>>rK<1fKg9m_w;HFu( z9!g3Q@cew|;8Cv)JU6GPZFCl7YIk&6@BpCA9BVVTJ8Td&f}LTnl#w!#o8hghBm$b5 ziQqg=sB@sNzizro$;8u&UB#70aUoI-w?0Q&bLZ~4TbP<02JV?KLDZqLO0|(cX0*TU?Tye zE$Wx#{>%6(QXXO3nBS^)lFdpG2+Vbx4Y8aLyBk!6r_Tmy&aAJ-D;v2obvEX^zN&<2_72 xvIjt~9q6^}0nlp)dM$eZ^xA=5%QFwa{{gL)lgoyKnVkRt002ovPDHLkV1g829PI!A literal 0 HcmV?d00001 diff --git a/plugins/apps/plugin-api-test-suite/public/manifest.json b/plugins/apps/plugin-api-test-suite/public/manifest.json new file mode 100644 index 0000000000..a56ee76498 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/public/manifest.json @@ -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" + ] +} diff --git a/plugins/apps/plugin-api-test-suite/src/ci/headless.ts b/plugins/apps/plugin-api-test-suite/src/ci/headless.ts new file mode 100644 index 0000000000..9374ab4e48 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/ci/headless.ts @@ -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 })); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/framework/coverage.ts b/plugins/apps/plugin-api-test-suite/src/framework/coverage.ts new file mode 100644 index 0000000000..cbf36aa048 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/coverage.ts @@ -0,0 +1,287 @@ +import { STATIC_COVERAGE } from './static-coverage'; +import type { ApiSurface, CoverageReport, InterfaceCoverage } from './types'; + +export interface Recorder { + /** Proxy to hand to tests; mirrors `root` but records member access. */ + proxy: T; + /** Every `Interface.member` pair touched through the proxy. */ + accessed: Set; + /** + * 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(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( + root: T, + surface: ApiSurface, +): Recorder { + const accessed = new Set(); + const toOriginal = new WeakMap(); + // Cache proxies per (target, typeName) so identity is stable and cycles end. + const cache = new WeakMap>(); + + 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).then((value) => + wrapValue(value, entry.type, entry.array), + ); + } + + return wrapValue(result, entry.type, entry.array); + }; + } + + return { + proxy: wrapObject(root, 'Penpot') as T, + accessed, + wrap: (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, + surface: ApiSurface, +): CoverageReport { + const byInterface: Record = {}; + 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, + }; +} diff --git a/plugins/apps/plugin-api-test-suite/src/framework/expect.ts b/plugins/apps/plugin-api-test-suite/src/framework/expect.ts new file mode 100644 index 0000000000..e194317e6b --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/expect.ts @@ -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); + const bKeys = Object.keys(b as Record); + if (aKeys.length !== bKeys.length) return false; + + return aKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(b, key) && + deepEqual( + (a as Record)[key], + (b as Record)[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 | (() => Promise | unknown), + expected?: string | RegExp, +): Promise { + 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)}`, + ); + } +} diff --git a/plugins/apps/plugin-api-test-suite/src/framework/registry.ts b/plugins/apps/plugin-api-test-suite/src/framework/registry.ts new file mode 100644 index 0000000000..da01c57430 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/registry.ts @@ -0,0 +1,115 @@ +import type { TestCase, TestFn, TestMeta } from './types'; + +export const DEFAULT_GROUP = 'General'; + +let registry: TestCase[] = []; +let seenIds = new Set(); +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(); +} diff --git a/plugins/apps/plugin-api-test-suite/src/framework/runner.ts b/plugins/apps/plugin-api-test-suite/src/framework/runner.ts new file mode 100644 index 0000000000..218a95e2f8 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/runner.ts @@ -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, ms: number): Promise { + return new Promise((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 { + 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 | 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 }; +} diff --git a/plugins/apps/plugin-api-test-suite/src/framework/static-coverage.ts b/plugins/apps/plugin-api-test-suite/src/framework/static-coverage.ts new file mode 100644 index 0000000000..ecc1e86736 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/static-coverage.ts @@ -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 = new Set([ + // 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', +]); diff --git a/plugins/apps/plugin-api-test-suite/src/framework/types.ts b/plugins/apps/plugin-api-test-suite/src/framework/types.ts new file mode 100644 index 0000000000..94b19cbe8b --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/types.ts @@ -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; + +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; +} + +/** + * 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 } | 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; + graph: Record>; + unions: Record; +} diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json new file mode 100644 index 0000000000..783f45d044 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -0,0 +1,11192 @@ +{ + "interfaces": { + "ActiveUser": ["position", "zoom"], + "Blur": ["hidden", "id", "value"], + "Board": [ + "addFlexLayout", + "addGridLayout", + "addRulerGuide", + "appendChild", + "children", + "clipContent", + "fills", + "flex", + "grid", + "guides", + "horizontalSizing", + "insertChild", + "isVariantContainer", + "removeRulerGuide", + "rulerGuides", + "showInViewMode", + "type", + "verticalSizing" + ], + "Boolean": [ + "appendChild", + "children", + "commands", + "d", + "fills", + "insertChild", + "type" + ], + "Bounds": ["height", "width", "x", "y"], + "CloseOverlay": ["animation", "destination", "type"], + "Color": [ + "color", + "fileId", + "gradient", + "id", + "image", + "name", + "opacity", + "path" + ], + "ColorShapeInfo": ["shapesInfo"], + "ColorShapeInfoEntry": ["index", "property", "shapeId"], + "Comment": ["content", "date", "remove", "user"], + "CommentThread": [ + "board", + "findComments", + "owner", + "position", + "remove", + "reply", + "resolved", + "seqNumber" + ], + "CommonLayout": [ + "alignContent", + "alignItems", + "bottomPadding", + "columnGap", + "horizontalPadding", + "horizontalSizing", + "justifyContent", + "justifyItems", + "leftPadding", + "remove", + "rightPadding", + "rowGap", + "topPadding", + "verticalPadding", + "verticalSizing" + ], + "Context": [ + "activeUsers", + "alignHorizontal", + "alignVertical", + "createBoard", + "createBoolean", + "createEllipse", + "createPage", + "createPath", + "createRectangle", + "createShapeFromSvg", + "createShapeFromSvgWithImages", + "createText", + "createVariantFromComponents", + "currentFile", + "currentPage", + "currentUser", + "distributeHorizontal", + "distributeVertical", + "flags", + "flatten", + "fonts", + "generateFontFaces", + "generateMarkup", + "generateStyle", + "group", + "history", + "library", + "localStorage", + "openPage", + "openViewer", + "replaceColor", + "root", + "selection", + "shapesColors", + "theme", + "ungroup", + "uploadMediaData", + "uploadMediaUrl", + "version", + "viewport" + ], + "ContextGeometryUtils": ["center"], + "ContextTypesUtils": [ + "isBoard", + "isBool", + "isEllipse", + "isGroup", + "isMask", + "isPath", + "isRectangle", + "isSVG", + "isText", + "isVariantComponent", + "isVariantContainer" + ], + "ContextUtils": ["geometry", "types"], + "Dissolve": ["duration", "easing", "type"], + "Ellipse": ["fills", "type"], + "EventsMap": [ + "contentsave", + "filechange", + "finish", + "pagechange", + "selectionchange", + "shapechange", + "themechange" + ], + "Export": ["scale", "skipChildren", "suffix", "type"], + "File": [ + "export", + "findVersions", + "id", + "name", + "pages", + "revn", + "saveVersion" + ], + "FileVersion": [ + "createdAt", + "createdBy", + "isAutosave", + "label", + "pin", + "remove", + "restore" + ], + "Fill": [ + "fillColor", + "fillColorGradient", + "fillColorRefFile", + "fillColorRefId", + "fillImage", + "fillOpacity" + ], + "Flags": ["naturalChildOrdering", "throwValidationErrors"], + "FlexLayout": ["appendChild", "dir", "wrap"], + "Flow": ["name", "page", "remove", "startingBoard"], + "Font": [ + "applyToRange", + "applyToText", + "fontFamily", + "fontId", + "fontStyle", + "fontVariantId", + "fontWeight", + "name", + "variants" + ], + "FontsContext": [ + "all", + "findAllById", + "findAllByName", + "findById", + "findByName" + ], + "FontVariant": ["fontStyle", "fontVariantId", "fontWeight", "name"], + "Gradient": ["endX", "endY", "startX", "startY", "stops", "type", "width"], + "GridLayout": [ + "addColumn", + "addColumnAtIndex", + "addRow", + "addRowAtIndex", + "appendChild", + "columns", + "dir", + "removeColumn", + "removeRow", + "rows", + "setColumn", + "setRow" + ], + "Group": [ + "appendChild", + "children", + "insertChild", + "isMask", + "makeMask", + "removeMask", + "type" + ], + "GuideColumn": ["display", "params", "type"], + "GuideColumnParams": [ + "color", + "gutter", + "itemLength", + "margin", + "size", + "type" + ], + "GuideRow": ["display", "params", "type"], + "GuideSquare": ["display", "params", "type"], + "GuideSquareParams": ["color", "size"], + "HistoryContext": ["undoBlockBegin", "undoBlockFinish"], + "ImageData": [ + "data", + "height", + "id", + "keepAspectRatio", + "mtype", + "name", + "width" + ], + "Interaction": ["action", "delay", "remove", "shape", "trigger"], + "LayoutCellProperties": [ + "areaName", + "column", + "columnSpan", + "position", + "row", + "rowSpan" + ], + "LayoutChildProperties": [ + "absolute", + "alignSelf", + "bottomMargin", + "horizontalMargin", + "horizontalSizing", + "leftMargin", + "maxHeight", + "maxWidth", + "minHeight", + "minWidth", + "rightMargin", + "topMargin", + "verticalMargin", + "verticalSizing", + "zIndex" + ], + "Library": [ + "colors", + "components", + "createColor", + "createComponent", + "createTypography", + "id", + "name", + "tokens", + "typographies" + ], + "LibraryColor": [ + "asFill", + "asStroke", + "color", + "gradient", + "image", + "opacity" + ], + "LibraryComponent": [ + "instance", + "isVariant", + "mainInstance", + "transformInVariant" + ], + "LibraryContext": [ + "availableLibraries", + "connectLibrary", + "connected", + "local" + ], + "LibraryElement": ["id", "libraryId", "name", "path"], + "LibrarySummary": [ + "id", + "name", + "numColors", + "numComponents", + "numTypographies" + ], + "LibraryTypography": [ + "applyToText", + "applyToTextRange", + "fontFamily", + "fontId", + "fontSize", + "fontStyle", + "fontVariantId", + "fontWeight", + "letterSpacing", + "lineHeight", + "setFont", + "textTransform" + ], + "LibraryVariantComponent": [ + "addVariant", + "setVariantProperty", + "variantError", + "variantProps", + "variants" + ], + "LocalStorage": ["getItem", "getKeys", "removeItem", "setItem"], + "NavigateTo": [ + "animation", + "destination", + "preserveScrollPosition", + "type" + ], + "OpenOverlay": ["type"], + "OpenUrl": ["type", "url"], + "OverlayAction": [ + "addBackgroundOverlay", + "animation", + "closeWhenClickOutside", + "destination", + "manualPositionLocation", + "position", + "relativeTo" + ], + "Page": [ + "addCommentThread", + "addRulerGuide", + "createFlow", + "findCommentThreads", + "findShapes", + "flows", + "getShapeById", + "id", + "name", + "removeCommentThread", + "removeFlow", + "removeRulerGuide", + "root", + "rulerGuides" + ], + "Path": ["commands", "d", "fills", "type"], + "PathCommand": ["command", "params"], + "Penpot": ["closePlugin", "off", "on", "ui", "utils"], + "PluginData": [ + "getPluginData", + "getPluginDataKeys", + "getSharedPluginData", + "getSharedPluginDataKeys", + "setPluginData", + "setSharedPluginData" + ], + "Point": ["x", "y"], + "PreviousScreen": ["type"], + "Push": ["direction", "duration", "easing", "type"], + "Rectangle": ["fills", "type"], + "RulerGuide": ["board", "orientation", "position"], + "Shadow": [ + "blur", + "color", + "hidden", + "id", + "offsetX", + "offsetY", + "spread", + "style" + ], + "ShapeBase": [ + "addInteraction", + "applyToken", + "backgroundBlur", + "blendMode", + "blocked", + "blur", + "boardX", + "boardY", + "borderRadius", + "borderRadiusBottomLeft", + "borderRadiusBottomRight", + "borderRadiusTopLeft", + "borderRadiusTopRight", + "bounds", + "bringForward", + "bringToFront", + "center", + "clone", + "combineAsVariants", + "component", + "componentHead", + "componentRefShape", + "componentRoot", + "constraintsHorizontal", + "constraintsVertical", + "detach", + "export", + "exports", + "fills", + "fixedWhenScrolling", + "flipX", + "flipY", + "height", + "hidden", + "id", + "interactions", + "isComponentCopyInstance", + "isComponentHead", + "isComponentInstance", + "isComponentMainInstance", + "isComponentRoot", + "isVariantHead", + "layoutCell", + "layoutChild", + "name", + "opacity", + "parent", + "parentIndex", + "parentX", + "parentY", + "proportionLock", + "remove", + "removeInteraction", + "resize", + "rotate", + "rotation", + "sendBackward", + "sendToBack", + "setParentIndex", + "shadows", + "strokes", + "swapComponent", + "switchVariant", + "tokens", + "visible", + "width", + "x", + "y" + ], + "Slide": ["direction", "duration", "easing", "offsetEffect", "type", "way"], + "Stroke": [ + "strokeAlignment", + "strokeCapEnd", + "strokeCapStart", + "strokeColor", + "strokeColorGradient", + "strokeColorRefFile", + "strokeColorRefId", + "strokeOpacity", + "strokeStyle", + "strokeWidth" + ], + "SvgRaw": ["type"], + "Text": [ + "align", + "applyTypography", + "characters", + "direction", + "fontFamily", + "fontId", + "fontSize", + "fontStyle", + "fontVariantId", + "fontWeight", + "getRange", + "growType", + "letterSpacing", + "lineHeight", + "textBounds", + "textDecoration", + "textTransform", + "type", + "verticalAlign" + ], + "TextRange": [ + "align", + "applyTypography", + "characters", + "direction", + "fills", + "fontFamily", + "fontId", + "fontSize", + "fontStyle", + "fontVariantId", + "fontWeight", + "letterSpacing", + "lineHeight", + "shape", + "textDecoration", + "textTransform", + "verticalAlign" + ], + "ToggleOverlay": ["type"], + "TokenBase": [ + "applyToSelected", + "applyToShapes", + "description", + "duplicate", + "id", + "name", + "remove", + "resolvedValueString" + ], + "TokenBorderRadius": ["resolvedValue", "type", "value"], + "TokenBorderWidth": ["resolvedValue", "type", "value"], + "TokenCatalog": [ + "addSet", + "addTheme", + "getSetById", + "getThemeById", + "sets", + "themes" + ], + "TokenColor": ["resolvedValue", "type", "value"], + "TokenDimension": ["resolvedValue", "type", "value"], + "TokenFontFamilies": ["resolvedValue", "type", "value"], + "TokenFontSizes": ["resolvedValue", "type", "value"], + "TokenFontWeights": ["resolvedValue", "type", "value"], + "TokenLetterSpacing": ["resolvedValue", "type", "value"], + "TokenNumber": ["resolvedValue", "type", "value"], + "TokenOpacity": ["resolvedValue", "type", "value"], + "TokenRotation": ["resolvedValue", "type", "value"], + "TokenSet": [ + "active", + "addToken", + "duplicate", + "getTokenById", + "id", + "name", + "remove", + "toggleActive", + "tokens", + "tokensByType" + ], + "TokenShadow": ["resolvedValue", "type", "value"], + "TokenShadowValue": [ + "blur", + "color", + "inset", + "offsetX", + "offsetY", + "spread" + ], + "TokenShadowValueString": [ + "blur", + "color", + "inset", + "offsetX", + "offsetY", + "spread" + ], + "TokenSizing": ["resolvedValue", "type", "value"], + "TokenSpacing": ["resolvedValue", "type", "value"], + "TokenTextCase": ["resolvedValue", "type", "value"], + "TokenTextDecoration": ["resolvedValue", "type", "value"], + "TokenTheme": [ + "active", + "activeSets", + "addSet", + "duplicate", + "externalId", + "group", + "id", + "name", + "remove", + "removeSet", + "toggleActive" + ], + "TokenTypography": ["resolvedValue", "type", "value"], + "TokenTypographyValue": [ + "fontFamilies", + "fontSizes", + "fontWeights", + "letterSpacing", + "lineHeight", + "textCase", + "textDecoration" + ], + "TokenTypographyValueString": [ + "fontFamilies", + "fontSizes", + "fontWeight", + "letterSpacing", + "lineHeight", + "textCase", + "textDecoration" + ], + "Track": ["type", "value"], + "User": ["avatarUrl", "color", "id", "name", "sessionId"], + "VariantContainer": ["variants"], + "Variants": [ + "addProperty", + "addVariant", + "currentValues", + "id", + "libraryId", + "properties", + "removeProperty", + "renameProperty", + "variantComponents" + ], + "Viewport": [ + "bounds", + "center", + "zoom", + "zoomIntoView", + "zoomReset", + "zoomToFitAll" + ] + }, + "graph": { + "ActiveUser": { + "position": { + "decl": "ActiveUser", + "kind": "getset", + "type": null, + "array": false + }, + "zoom": { + "decl": "ActiveUser", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "avatarUrl": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "color": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "sessionId": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + } + }, + "Blur": { + "id": { + "decl": "Blur", + "kind": "getset", + "type": null, + "array": false + }, + "value": { + "decl": "Blur", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "Blur", + "kind": "getset", + "type": null, + "array": false + } + }, + "Board": { + "type": { + "decl": "Board", + "kind": "get", + "type": null, + "array": false + }, + "clipContent": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "showInViewMode": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "grid": { + "decl": "Board", + "kind": "get", + "type": "GridLayout", + "array": false + }, + "flex": { + "decl": "Board", + "kind": "get", + "type": "FlexLayout", + "array": false + }, + "guides": { + "decl": "Board", + "kind": "getset", + "type": "Guide", + "array": true + }, + "rulerGuides": { + "decl": "Board", + "kind": "get", + "type": "RulerGuide", + "array": true + }, + "horizontalSizing": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "Board", + "kind": "getset", + "type": "Fill", + "array": true + }, + "children": { + "decl": "Board", + "kind": "getset", + "type": "Shape", + "array": true + }, + "appendChild": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "insertChild": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "addFlexLayout": { + "decl": "Board", + "kind": "method", + "type": "FlexLayout", + "array": false + }, + "addGridLayout": { + "decl": "Board", + "kind": "method", + "type": "GridLayout", + "array": false + }, + "addRulerGuide": { + "decl": "Board", + "kind": "method", + "type": "RulerGuide", + "array": false + }, + "removeRulerGuide": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "isVariantContainer": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Boolean": { + "type": { + "decl": "Boolean", + "kind": "get", + "type": null, + "array": false + }, + "toD": { + "decl": "Boolean", + "kind": "method", + "type": null, + "array": false + }, + "content": { + "decl": "Boolean", + "kind": "get", + "type": null, + "array": false + }, + "d": { + "decl": "Boolean", + "kind": "get", + "type": null, + "array": false + }, + "commands": { + "decl": "Boolean", + "kind": "get", + "type": "PathCommand", + "array": true + }, + "fills": { + "decl": "Boolean", + "kind": "getset", + "type": "Fill", + "array": true + }, + "children": { + "decl": "Boolean", + "kind": "get", + "type": "Shape", + "array": true + }, + "appendChild": { + "decl": "Boolean", + "kind": "method", + "type": null, + "array": false + }, + "insertChild": { + "decl": "Boolean", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Bounds": { + "x": { + "decl": "Bounds", + "kind": "get", + "type": null, + "array": false + }, + "y": { + "decl": "Bounds", + "kind": "get", + "type": null, + "array": false + }, + "width": { + "decl": "Bounds", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "Bounds", + "kind": "get", + "type": null, + "array": false + } + }, + "CloseOverlay": { + "type": { + "decl": "CloseOverlay", + "kind": "get", + "type": null, + "array": false + }, + "destination": { + "decl": "CloseOverlay", + "kind": "get", + "type": "Board", + "array": false + }, + "animation": { + "decl": "CloseOverlay", + "kind": "get", + "type": "Animation", + "array": false + } + }, + "Color": { + "id": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "fileId": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "name": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "color": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "refId": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "refFile": { + "decl": "Color", + "kind": "getset", + "type": null, + "array": false + }, + "gradient": { + "decl": "Color", + "kind": "getset", + "type": "Gradient", + "array": false + }, + "image": { + "decl": "Color", + "kind": "getset", + "type": "ImageData", + "array": false + } + }, + "ColorShapeInfo": { + "shapesInfo": { + "decl": "ColorShapeInfo", + "kind": "get", + "type": "ColorShapeInfoEntry", + "array": true + } + }, + "ColorShapeInfoEntry": { + "property": { + "decl": "ColorShapeInfoEntry", + "kind": "get", + "type": null, + "array": false + }, + "index": { + "decl": "ColorShapeInfoEntry", + "kind": "get", + "type": null, + "array": false + }, + "shapeId": { + "decl": "ColorShapeInfoEntry", + "kind": "get", + "type": null, + "array": false + } + }, + "Comment": { + "user": { + "decl": "Comment", + "kind": "get", + "type": "User", + "array": false + }, + "date": { + "decl": "Comment", + "kind": "get", + "type": null, + "array": false + }, + "content": { + "decl": "Comment", + "kind": "getset", + "type": null, + "array": false + }, + "remove": { + "decl": "Comment", + "kind": "method", + "type": null, + "array": false + } + }, + "CommentThread": { + "seqNumber": { + "decl": "CommentThread", + "kind": "get", + "type": null, + "array": false + }, + "board": { + "decl": "CommentThread", + "kind": "get", + "type": "Board", + "array": false + }, + "owner": { + "decl": "CommentThread", + "kind": "get", + "type": "User", + "array": false + }, + "position": { + "decl": "CommentThread", + "kind": "getset", + "type": "Point", + "array": false + }, + "resolved": { + "decl": "CommentThread", + "kind": "getset", + "type": null, + "array": false + }, + "findComments": { + "decl": "CommentThread", + "kind": "method", + "type": "Comment", + "array": true + }, + "reply": { + "decl": "CommentThread", + "kind": "method", + "type": "Comment", + "array": false + }, + "remove": { + "decl": "CommentThread", + "kind": "method", + "type": null, + "array": false + } + }, + "CommonLayout": { + "alignItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "alignContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rowGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "columnGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "topPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rightPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "bottomPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "leftPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "remove": { + "decl": "CommonLayout", + "kind": "method", + "type": null, + "array": false + } + }, + "Context": { + "version": { + "decl": "Context", + "kind": "get", + "type": null, + "array": false + }, + "root": { + "decl": "Context", + "kind": "get", + "type": "Shape", + "array": false + }, + "currentFile": { + "decl": "Context", + "kind": "get", + "type": "File", + "array": false + }, + "currentPage": { + "decl": "Context", + "kind": "get", + "type": "Page", + "array": false + }, + "viewport": { + "decl": "Context", + "kind": "get", + "type": "Viewport", + "array": false + }, + "flags": { + "decl": "Context", + "kind": "get", + "type": "Flags", + "array": false + }, + "history": { + "decl": "Context", + "kind": "get", + "type": "HistoryContext", + "array": false + }, + "library": { + "decl": "Context", + "kind": "get", + "type": "LibraryContext", + "array": false + }, + "fonts": { + "decl": "Context", + "kind": "get", + "type": "FontsContext", + "array": false + }, + "currentUser": { + "decl": "Context", + "kind": "get", + "type": "User", + "array": false + }, + "activeUsers": { + "decl": "Context", + "kind": "get", + "type": "ActiveUser", + "array": true + }, + "theme": { + "decl": "Context", + "kind": "get", + "type": "Theme", + "array": false + }, + "localStorage": { + "decl": "Context", + "kind": "get", + "type": "LocalStorage", + "array": false + }, + "selection": { + "decl": "Context", + "kind": "getset", + "type": "Shape", + "array": true + }, + "shapesColors": { + "decl": "Context", + "kind": "method", + "type": null, + "array": true + }, + "replaceColor": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "uploadMediaUrl": { + "decl": "Context", + "kind": "method", + "type": "ImageData", + "array": false + }, + "uploadMediaData": { + "decl": "Context", + "kind": "method", + "type": "ImageData", + "array": false + }, + "group": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "ungroup": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "createRectangle": { + "decl": "Context", + "kind": "method", + "type": "Rectangle", + "array": false + }, + "createBoard": { + "decl": "Context", + "kind": "method", + "type": "Board", + "array": false + }, + "createEllipse": { + "decl": "Context", + "kind": "method", + "type": "Ellipse", + "array": false + }, + "createPath": { + "decl": "Context", + "kind": "method", + "type": "Path", + "array": false + }, + "createBoolean": { + "decl": "Context", + "kind": "method", + "type": "Boolean", + "array": false + }, + "createShapeFromSvg": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "createShapeFromSvgWithImages": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "createText": { + "decl": "Context", + "kind": "method", + "type": "Text", + "array": false + }, + "generateMarkup": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "generateStyle": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "generateFontFaces": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "addListener": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "removeListener": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "openViewer": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "createPage": { + "decl": "Context", + "kind": "method", + "type": "Page", + "array": false + }, + "openPage": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "alignHorizontal": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "alignVertical": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "distributeHorizontal": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "distributeVertical": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "flatten": { + "decl": "Context", + "kind": "method", + "type": "Path", + "array": true + }, + "createVariantFromComponents": { + "decl": "Context", + "kind": "method", + "type": "VariantContainer", + "array": false + } + }, + "ContextGeometryUtils": { + "center": { + "decl": "ContextGeometryUtils", + "kind": "method", + "type": null, + "array": false + } + }, + "ContextTypesUtils": { + "isBoard": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isGroup": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isMask": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isBool": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isRectangle": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isPath": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isText": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isEllipse": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isSVG": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isVariantContainer": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + }, + "isVariantComponent": { + "decl": "ContextTypesUtils", + "kind": "method", + "type": null, + "array": false + } + }, + "ContextUtils": { + "geometry": { + "decl": "ContextUtils", + "kind": "get", + "type": "ContextGeometryUtils", + "array": false + }, + "types": { + "decl": "ContextUtils", + "kind": "get", + "type": "ContextTypesUtils", + "array": false + } + }, + "Dissolve": { + "type": { + "decl": "Dissolve", + "kind": "get", + "type": null, + "array": false + }, + "duration": { + "decl": "Dissolve", + "kind": "get", + "type": null, + "array": false + }, + "easing": { + "decl": "Dissolve", + "kind": "get", + "type": null, + "array": false + } + }, + "Ellipse": { + "type": { + "decl": "Ellipse", + "kind": "get", + "type": null, + "array": false + }, + "fills": { + "decl": "Ellipse", + "kind": "getset", + "type": "Fill", + "array": true + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "EventsMap": { + "pagechange": { + "decl": "EventsMap", + "kind": "get", + "type": "Page", + "array": false + }, + "filechange": { + "decl": "EventsMap", + "kind": "get", + "type": "File", + "array": false + }, + "selectionchange": { + "decl": "EventsMap", + "kind": "get", + "type": null, + "array": true + }, + "themechange": { + "decl": "EventsMap", + "kind": "get", + "type": "Theme", + "array": false + }, + "finish": { + "decl": "EventsMap", + "kind": "get", + "type": null, + "array": false + }, + "shapechange": { + "decl": "EventsMap", + "kind": "get", + "type": "Shape", + "array": false + }, + "contentsave": { + "decl": "EventsMap", + "kind": "get", + "type": null, + "array": false + } + }, + "Export": { + "type": { + "decl": "Export", + "kind": "getset", + "type": null, + "array": false + }, + "scale": { + "decl": "Export", + "kind": "getset", + "type": null, + "array": false + }, + "suffix": { + "decl": "Export", + "kind": "getset", + "type": null, + "array": false + }, + "skipChildren": { + "decl": "Export", + "kind": "getset", + "type": null, + "array": false + } + }, + "File": { + "id": { + "decl": "File", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "File", + "kind": "get", + "type": null, + "array": false + }, + "revn": { + "decl": "File", + "kind": "get", + "type": null, + "array": false + }, + "pages": { + "decl": "File", + "kind": "get", + "type": "Page", + "array": true + }, + "export": { + "decl": "File", + "kind": "method", + "type": null, + "array": false + }, + "findVersions": { + "decl": "File", + "kind": "method", + "type": "FileVersion", + "array": true + }, + "saveVersion": { + "decl": "File", + "kind": "method", + "type": "FileVersion", + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "FileVersion": { + "label": { + "decl": "FileVersion", + "kind": "getset", + "type": null, + "array": false + }, + "createdBy": { + "decl": "FileVersion", + "kind": "get", + "type": "User", + "array": false + }, + "createdAt": { + "decl": "FileVersion", + "kind": "get", + "type": null, + "array": false + }, + "isAutosave": { + "decl": "FileVersion", + "kind": "get", + "type": null, + "array": false + }, + "restore": { + "decl": "FileVersion", + "kind": "method", + "type": null, + "array": false + }, + "remove": { + "decl": "FileVersion", + "kind": "method", + "type": null, + "array": false + }, + "pin": { + "decl": "FileVersion", + "kind": "method", + "type": "FileVersion", + "array": false + } + }, + "Fill": { + "fillColor": { + "decl": "Fill", + "kind": "getset", + "type": null, + "array": false + }, + "fillOpacity": { + "decl": "Fill", + "kind": "getset", + "type": null, + "array": false + }, + "fillColorGradient": { + "decl": "Fill", + "kind": "getset", + "type": "Gradient", + "array": false + }, + "fillColorRefFile": { + "decl": "Fill", + "kind": "getset", + "type": null, + "array": false + }, + "fillColorRefId": { + "decl": "Fill", + "kind": "getset", + "type": null, + "array": false + }, + "fillImage": { + "decl": "Fill", + "kind": "getset", + "type": "ImageData", + "array": false + } + }, + "Flags": { + "naturalChildOrdering": { + "decl": "Flags", + "kind": "getset", + "type": null, + "array": false + }, + "throwValidationErrors": { + "decl": "Flags", + "kind": "getset", + "type": null, + "array": false + } + }, + "FlexLayout": { + "dir": { + "decl": "FlexLayout", + "kind": "getset", + "type": null, + "array": false + }, + "wrap": { + "decl": "FlexLayout", + "kind": "getset", + "type": null, + "array": false + }, + "appendChild": { + "decl": "FlexLayout", + "kind": "method", + "type": null, + "array": false + }, + "alignItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "alignContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rowGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "columnGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "topPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rightPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "bottomPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "leftPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "remove": { + "decl": "CommonLayout", + "kind": "method", + "type": null, + "array": false + } + }, + "Flow": { + "page": { + "decl": "Flow", + "kind": "get", + "type": "Page", + "array": false + }, + "name": { + "decl": "Flow", + "kind": "getset", + "type": null, + "array": false + }, + "startingBoard": { + "decl": "Flow", + "kind": "getset", + "type": "Board", + "array": false + }, + "remove": { + "decl": "Flow", + "kind": "method", + "type": null, + "array": false + } + }, + "Font": { + "name": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "fontId": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "fontFamily": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "fontStyle": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "fontVariantId": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "Font", + "kind": "get", + "type": null, + "array": false + }, + "variants": { + "decl": "Font", + "kind": "get", + "type": "FontVariant", + "array": true + }, + "applyToText": { + "decl": "Font", + "kind": "method", + "type": null, + "array": false + }, + "applyToRange": { + "decl": "Font", + "kind": "method", + "type": null, + "array": false + } + }, + "FontsContext": { + "all": { + "decl": "FontsContext", + "kind": "get", + "type": "Font", + "array": true + }, + "findById": { + "decl": "FontsContext", + "kind": "method", + "type": "Font", + "array": false + }, + "findByName": { + "decl": "FontsContext", + "kind": "method", + "type": "Font", + "array": false + }, + "findAllById": { + "decl": "FontsContext", + "kind": "method", + "type": "Font", + "array": true + }, + "findAllByName": { + "decl": "FontsContext", + "kind": "method", + "type": "Font", + "array": true + } + }, + "FontVariant": { + "name": { + "decl": "FontVariant", + "kind": "get", + "type": null, + "array": false + }, + "fontVariantId": { + "decl": "FontVariant", + "kind": "get", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "FontVariant", + "kind": "get", + "type": null, + "array": false + }, + "fontStyle": { + "decl": "FontVariant", + "kind": "get", + "type": null, + "array": false + } + }, + "Gradient": { + "type": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "startX": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "startY": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "endX": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "endY": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": false + }, + "stops": { + "decl": "Gradient", + "kind": "getset", + "type": null, + "array": true + } + }, + "GridLayout": { + "dir": { + "decl": "GridLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rows": { + "decl": "GridLayout", + "kind": "get", + "type": "Track", + "array": true + }, + "columns": { + "decl": "GridLayout", + "kind": "get", + "type": "Track", + "array": true + }, + "addRow": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "addRowAtIndex": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "addColumn": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "addColumnAtIndex": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "removeRow": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "removeColumn": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "setColumn": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "setRow": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "appendChild": { + "decl": "GridLayout", + "kind": "method", + "type": null, + "array": false + }, + "alignItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "alignContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyItems": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "justifyContent": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rowGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "columnGap": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "topPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "rightPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "bottomPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "leftPadding": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, + "remove": { + "decl": "CommonLayout", + "kind": "method", + "type": null, + "array": false + } + }, + "Group": { + "type": { + "decl": "Group", + "kind": "get", + "type": null, + "array": false + }, + "children": { + "decl": "Group", + "kind": "get", + "type": "Shape", + "array": true + }, + "appendChild": { + "decl": "Group", + "kind": "method", + "type": null, + "array": false + }, + "insertChild": { + "decl": "Group", + "kind": "method", + "type": null, + "array": false + }, + "isMask": { + "decl": "Group", + "kind": "method", + "type": null, + "array": false + }, + "makeMask": { + "decl": "Group", + "kind": "method", + "type": null, + "array": false + }, + "removeMask": { + "decl": "Group", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Fill", + "array": true + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "GuideColumn": { + "type": { + "decl": "GuideColumn", + "kind": "get", + "type": null, + "array": false + }, + "display": { + "decl": "GuideColumn", + "kind": "get", + "type": null, + "array": false + }, + "params": { + "decl": "GuideColumn", + "kind": "get", + "type": "GuideColumnParams", + "array": false + } + }, + "GuideColumnParams": { + "color": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + }, + "type": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + }, + "size": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + }, + "margin": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + }, + "itemLength": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + }, + "gutter": { + "decl": "GuideColumnParams", + "kind": "get", + "type": null, + "array": false + } + }, + "GuideRow": { + "type": { + "decl": "GuideRow", + "kind": "get", + "type": null, + "array": false + }, + "display": { + "decl": "GuideRow", + "kind": "get", + "type": null, + "array": false + }, + "params": { + "decl": "GuideRow", + "kind": "get", + "type": "GuideColumnParams", + "array": false + } + }, + "GuideSquare": { + "type": { + "decl": "GuideSquare", + "kind": "get", + "type": null, + "array": false + }, + "display": { + "decl": "GuideSquare", + "kind": "get", + "type": null, + "array": false + }, + "params": { + "decl": "GuideSquare", + "kind": "get", + "type": "GuideSquareParams", + "array": false + } + }, + "GuideSquareParams": { + "color": { + "decl": "GuideSquareParams", + "kind": "get", + "type": null, + "array": false + }, + "size": { + "decl": "GuideSquareParams", + "kind": "get", + "type": null, + "array": false + } + }, + "HistoryContext": { + "undoBlockBegin": { + "decl": "HistoryContext", + "kind": "method", + "type": null, + "array": false + }, + "undoBlockFinish": { + "decl": "HistoryContext", + "kind": "method", + "type": null, + "array": false + } + }, + "Image": { + "type": { + "decl": "Image", + "kind": "get", + "type": null, + "array": false + }, + "fills": { + "decl": "Image", + "kind": "getset", + "type": "Fill", + "array": true + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "ImageData": { + "name": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "width": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "mtype": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "keepAspectRatio": { + "decl": "ImageData", + "kind": "get", + "type": null, + "array": false + }, + "data": { + "decl": "ImageData", + "kind": "method", + "type": null, + "array": false + } + }, + "Interaction": { + "shape": { + "decl": "Interaction", + "kind": "get", + "type": "Shape", + "array": false + }, + "trigger": { + "decl": "Interaction", + "kind": "getset", + "type": "Trigger", + "array": false + }, + "delay": { + "decl": "Interaction", + "kind": "getset", + "type": null, + "array": false + }, + "action": { + "decl": "Interaction", + "kind": "getset", + "type": "Action", + "array": false + }, + "remove": { + "decl": "Interaction", + "kind": "method", + "type": null, + "array": false + } + }, + "LayoutCellProperties": { + "row": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + }, + "rowSpan": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + }, + "column": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + }, + "columnSpan": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + }, + "areaName": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + }, + "position": { + "decl": "LayoutCellProperties", + "kind": "getset", + "type": null, + "array": false + } + }, + "LayoutChildProperties": { + "absolute": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "zIndex": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalSizing": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "alignSelf": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "horizontalMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "verticalMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "topMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "rightMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "bottomMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "leftMargin": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "maxWidth": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "maxHeight": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "minWidth": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, + "minHeight": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + } + }, + "Library": { + "id": { + "decl": "Library", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "Library", + "kind": "get", + "type": null, + "array": false + }, + "colors": { + "decl": "Library", + "kind": "get", + "type": "LibraryColor", + "array": true + }, + "typographies": { + "decl": "Library", + "kind": "get", + "type": "LibraryTypography", + "array": true + }, + "components": { + "decl": "Library", + "kind": "get", + "type": "LibraryComponent", + "array": true + }, + "tokens": { + "decl": "Library", + "kind": "get", + "type": "TokenCatalog", + "array": false + }, + "createColor": { + "decl": "Library", + "kind": "method", + "type": "LibraryColor", + "array": false + }, + "createTypography": { + "decl": "Library", + "kind": "method", + "type": "LibraryTypography", + "array": false + }, + "createComponent": { + "decl": "Library", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LibraryColor": { + "color": { + "decl": "LibraryColor", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "LibraryColor", + "kind": "getset", + "type": null, + "array": false + }, + "gradient": { + "decl": "LibraryColor", + "kind": "getset", + "type": "Gradient", + "array": false + }, + "image": { + "decl": "LibraryColor", + "kind": "getset", + "type": "ImageData", + "array": false + }, + "asFill": { + "decl": "LibraryColor", + "kind": "method", + "type": "Fill", + "array": false + }, + "asStroke": { + "decl": "LibraryColor", + "kind": "method", + "type": "Stroke", + "array": false + }, + "id": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LibraryComponent": { + "instance": { + "decl": "LibraryComponent", + "kind": "method", + "type": "Shape", + "array": false + }, + "mainInstance": { + "decl": "LibraryComponent", + "kind": "method", + "type": "Shape", + "array": false + }, + "isVariant": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "transformInVariant": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LibraryContext": { + "local": { + "decl": "LibraryContext", + "kind": "get", + "type": "Library", + "array": false + }, + "connected": { + "decl": "LibraryContext", + "kind": "get", + "type": "Library", + "array": true + }, + "availableLibraries": { + "decl": "LibraryContext", + "kind": "method", + "type": "LibrarySummary", + "array": true + }, + "connectLibrary": { + "decl": "LibraryContext", + "kind": "method", + "type": "Library", + "array": false + } + }, + "LibraryElement": { + "id": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LibrarySummary": { + "id": { + "decl": "LibrarySummary", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibrarySummary", + "kind": "get", + "type": null, + "array": false + }, + "numColors": { + "decl": "LibrarySummary", + "kind": "get", + "type": null, + "array": false + }, + "numComponents": { + "decl": "LibrarySummary", + "kind": "get", + "type": null, + "array": false + }, + "numTypographies": { + "decl": "LibrarySummary", + "kind": "get", + "type": null, + "array": false + } + }, + "LibraryTypography": { + "fontId": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "fontFamily": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "fontVariantId": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "fontSize": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "fontStyle": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "lineHeight": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "letterSpacing": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "textTransform": { + "decl": "LibraryTypography", + "kind": "getset", + "type": null, + "array": false + }, + "applyToText": { + "decl": "LibraryTypography", + "kind": "method", + "type": null, + "array": false + }, + "applyToTextRange": { + "decl": "LibraryTypography", + "kind": "method", + "type": null, + "array": false + }, + "setFont": { + "decl": "LibraryTypography", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LibraryVariantComponent": { + "variants": { + "decl": "LibraryVariantComponent", + "kind": "get", + "type": "Variants", + "array": false + }, + "variantProps": { + "decl": "LibraryVariantComponent", + "kind": "get", + "type": null, + "array": false + }, + "variantError": { + "decl": "LibraryVariantComponent", + "kind": "getset", + "type": null, + "array": false + }, + "addVariant": { + "decl": "LibraryVariantComponent", + "kind": "method", + "type": null, + "array": false + }, + "setVariantProperty": { + "decl": "LibraryVariantComponent", + "kind": "method", + "type": null, + "array": false + }, + "instance": { + "decl": "LibraryComponent", + "kind": "method", + "type": "Shape", + "array": false + }, + "mainInstance": { + "decl": "LibraryComponent", + "kind": "method", + "type": "Shape", + "array": false + }, + "isVariant": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "transformInVariant": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "LibraryElement", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "path": { + "decl": "LibraryElement", + "kind": "getset", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "LocalStorage": { + "getItem": { + "decl": "LocalStorage", + "kind": "method", + "type": null, + "array": false + }, + "setItem": { + "decl": "LocalStorage", + "kind": "method", + "type": null, + "array": false + }, + "removeItem": { + "decl": "LocalStorage", + "kind": "method", + "type": null, + "array": false + }, + "getKeys": { + "decl": "LocalStorage", + "kind": "method", + "type": null, + "array": true + } + }, + "NavigateTo": { + "type": { + "decl": "NavigateTo", + "kind": "get", + "type": null, + "array": false + }, + "destination": { + "decl": "NavigateTo", + "kind": "get", + "type": "Board", + "array": false + }, + "preserveScrollPosition": { + "decl": "NavigateTo", + "kind": "get", + "type": null, + "array": false + }, + "animation": { + "decl": "NavigateTo", + "kind": "get", + "type": "Animation", + "array": false + } + }, + "OpenOverlay": { + "type": { + "decl": "OpenOverlay", + "kind": "get", + "type": null, + "array": false + }, + "destination": { + "decl": "OverlayAction", + "kind": "get", + "type": "Board", + "array": false + }, + "relativeTo": { + "decl": "OverlayAction", + "kind": "get", + "type": "Shape", + "array": false + }, + "position": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "manualPositionLocation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Point", + "array": false + }, + "closeWhenClickOutside": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "addBackgroundOverlay": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "animation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Animation", + "array": false + } + }, + "OpenUrl": { + "type": { + "decl": "OpenUrl", + "kind": "get", + "type": null, + "array": false + }, + "url": { + "decl": "OpenUrl", + "kind": "get", + "type": null, + "array": false + } + }, + "OverlayAction": { + "destination": { + "decl": "OverlayAction", + "kind": "get", + "type": "Board", + "array": false + }, + "relativeTo": { + "decl": "OverlayAction", + "kind": "get", + "type": "Shape", + "array": false + }, + "position": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "manualPositionLocation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Point", + "array": false + }, + "closeWhenClickOutside": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "addBackgroundOverlay": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "animation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Animation", + "array": false + } + }, + "Page": { + "id": { + "decl": "Page", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "Page", + "kind": "getset", + "type": null, + "array": false + }, + "rulerGuides": { + "decl": "Page", + "kind": "get", + "type": "RulerGuide", + "array": true + }, + "root": { + "decl": "Page", + "kind": "get", + "type": "Shape", + "array": false + }, + "getShapeById": { + "decl": "Page", + "kind": "method", + "type": "Shape", + "array": false + }, + "findShapes": { + "decl": "Page", + "kind": "method", + "type": "Shape", + "array": true + }, + "flows": { + "decl": "Page", + "kind": "get", + "type": "Flow", + "array": true + }, + "createFlow": { + "decl": "Page", + "kind": "method", + "type": "Flow", + "array": false + }, + "removeFlow": { + "decl": "Page", + "kind": "method", + "type": null, + "array": false + }, + "addRulerGuide": { + "decl": "Page", + "kind": "method", + "type": "RulerGuide", + "array": false + }, + "removeRulerGuide": { + "decl": "Page", + "kind": "method", + "type": null, + "array": false + }, + "addCommentThread": { + "decl": "Page", + "kind": "method", + "type": "CommentThread", + "array": false + }, + "removeCommentThread": { + "decl": "Page", + "kind": "method", + "type": null, + "array": false + }, + "findCommentThreads": { + "decl": "Page", + "kind": "method", + "type": "CommentThread", + "array": true + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Path": { + "type": { + "decl": "Path", + "kind": "get", + "type": null, + "array": false + }, + "toD": { + "decl": "Path", + "kind": "method", + "type": null, + "array": false + }, + "content": { + "decl": "Path", + "kind": "getset", + "type": null, + "array": false + }, + "d": { + "decl": "Path", + "kind": "getset", + "type": null, + "array": false + }, + "commands": { + "decl": "Path", + "kind": "getset", + "type": "PathCommand", + "array": true + }, + "fills": { + "decl": "Path", + "kind": "getset", + "type": "Fill", + "array": true + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "PathCommand": { + "command": { + "decl": "PathCommand", + "kind": "getset", + "type": null, + "array": false + }, + "params": { + "decl": "PathCommand", + "kind": "getset", + "type": null, + "array": false + } + }, + "Penpot": { + "ui": { + "decl": "Penpot", + "kind": "get", + "type": null, + "array": false + }, + "utils": { + "decl": "Penpot", + "kind": "get", + "type": "ContextUtils", + "array": false + }, + "closePlugin": { + "decl": "Penpot", + "kind": "method", + "type": null, + "array": false + }, + "on": { + "decl": "Penpot", + "kind": "method", + "type": null, + "array": false + }, + "off": { + "decl": "Penpot", + "kind": "method", + "type": null, + "array": false + }, + "version": { + "decl": "Context", + "kind": "get", + "type": null, + "array": false + }, + "root": { + "decl": "Context", + "kind": "get", + "type": "Shape", + "array": false + }, + "currentFile": { + "decl": "Context", + "kind": "get", + "type": "File", + "array": false + }, + "currentPage": { + "decl": "Context", + "kind": "get", + "type": "Page", + "array": false + }, + "viewport": { + "decl": "Context", + "kind": "get", + "type": "Viewport", + "array": false + }, + "flags": { + "decl": "Context", + "kind": "get", + "type": "Flags", + "array": false + }, + "history": { + "decl": "Context", + "kind": "get", + "type": "HistoryContext", + "array": false + }, + "library": { + "decl": "Context", + "kind": "get", + "type": "LibraryContext", + "array": false + }, + "fonts": { + "decl": "Context", + "kind": "get", + "type": "FontsContext", + "array": false + }, + "currentUser": { + "decl": "Context", + "kind": "get", + "type": "User", + "array": false + }, + "activeUsers": { + "decl": "Context", + "kind": "get", + "type": "ActiveUser", + "array": true + }, + "theme": { + "decl": "Context", + "kind": "get", + "type": "Theme", + "array": false + }, + "localStorage": { + "decl": "Context", + "kind": "get", + "type": "LocalStorage", + "array": false + }, + "selection": { + "decl": "Context", + "kind": "getset", + "type": "Shape", + "array": true + }, + "shapesColors": { + "decl": "Context", + "kind": "method", + "type": null, + "array": true + }, + "replaceColor": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "uploadMediaUrl": { + "decl": "Context", + "kind": "method", + "type": "ImageData", + "array": false + }, + "uploadMediaData": { + "decl": "Context", + "kind": "method", + "type": "ImageData", + "array": false + }, + "group": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "ungroup": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "createRectangle": { + "decl": "Context", + "kind": "method", + "type": "Rectangle", + "array": false + }, + "createBoard": { + "decl": "Context", + "kind": "method", + "type": "Board", + "array": false + }, + "createEllipse": { + "decl": "Context", + "kind": "method", + "type": "Ellipse", + "array": false + }, + "createPath": { + "decl": "Context", + "kind": "method", + "type": "Path", + "array": false + }, + "createBoolean": { + "decl": "Context", + "kind": "method", + "type": "Boolean", + "array": false + }, + "createShapeFromSvg": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "createShapeFromSvgWithImages": { + "decl": "Context", + "kind": "method", + "type": "Group", + "array": false + }, + "createText": { + "decl": "Context", + "kind": "method", + "type": "Text", + "array": false + }, + "generateMarkup": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "generateStyle": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "generateFontFaces": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "openViewer": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "createPage": { + "decl": "Context", + "kind": "method", + "type": "Page", + "array": false + }, + "openPage": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "alignHorizontal": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "alignVertical": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "distributeHorizontal": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "distributeVertical": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false + }, + "flatten": { + "decl": "Context", + "kind": "method", + "type": "Path", + "array": true + }, + "createVariantFromComponents": { + "decl": "Context", + "kind": "method", + "type": "VariantContainer", + "array": false + } + }, + "PluginData": { + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Point": { + "x": { + "decl": "Point", + "kind": "get", + "type": null, + "array": false + }, + "y": { + "decl": "Point", + "kind": "get", + "type": null, + "array": false + } + }, + "PreviousScreen": { + "type": { + "decl": "PreviousScreen", + "kind": "get", + "type": null, + "array": false + } + }, + "Push": { + "type": { + "decl": "Push", + "kind": "get", + "type": null, + "array": false + }, + "direction": { + "decl": "Push", + "kind": "get", + "type": null, + "array": false + }, + "duration": { + "decl": "Push", + "kind": "get", + "type": null, + "array": false + }, + "easing": { + "decl": "Push", + "kind": "get", + "type": null, + "array": false + } + }, + "Rectangle": { + "type": { + "decl": "Rectangle", + "kind": "get", + "type": null, + "array": false + }, + "fills": { + "decl": "Rectangle", + "kind": "getset", + "type": "Fill", + "array": true + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "RulerGuide": { + "orientation": { + "decl": "RulerGuide", + "kind": "get", + "type": "RulerGuideOrientation", + "array": false + }, + "position": { + "decl": "RulerGuide", + "kind": "getset", + "type": null, + "array": false + }, + "board": { + "decl": "RulerGuide", + "kind": "getset", + "type": "Board", + "array": false + } + }, + "Shadow": { + "id": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "style": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "offsetX": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "offsetY": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "blur": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "spread": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "Shadow", + "kind": "getset", + "type": null, + "array": false + }, + "color": { + "decl": "Shadow", + "kind": "getset", + "type": "Color", + "array": false + } + }, + "ShapeBase": { + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Fill", + "array": true + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Slide": { + "type": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + }, + "way": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + }, + "direction": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + }, + "duration": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + }, + "offsetEffect": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + }, + "easing": { + "decl": "Slide", + "kind": "get", + "type": null, + "array": false + } + }, + "Stroke": { + "strokeColor": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeColorRefFile": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeColorRefId": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeOpacity": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeStyle": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeWidth": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeAlignment": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeCapStart": { + "decl": "Stroke", + "kind": "getset", + "type": "StrokeCap", + "array": false + }, + "strokeCapEnd": { + "decl": "Stroke", + "kind": "getset", + "type": "StrokeCap", + "array": false + }, + "strokeColorGradient": { + "decl": "Stroke", + "kind": "getset", + "type": "Gradient", + "array": false + } + }, + "SvgRaw": { + "type": { + "decl": "SvgRaw", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Fill", + "array": true + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Text": { + "type": { + "decl": "Text", + "kind": "get", + "type": null, + "array": false + }, + "characters": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "growType": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontId": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontFamily": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontVariantId": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontSize": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "fontStyle": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "lineHeight": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "letterSpacing": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "textTransform": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "textDecoration": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "direction": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "align": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "verticalAlign": { + "decl": "Text", + "kind": "getset", + "type": null, + "array": false + }, + "textBounds": { + "decl": "Text", + "kind": "get", + "type": null, + "array": false + }, + "getRange": { + "decl": "Text", + "kind": "method", + "type": "TextRange", + "array": false + }, + "applyTypography": { + "decl": "Text", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Fill", + "array": true + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "TextRange": { + "shape": { + "decl": "TextRange", + "kind": "get", + "type": "Text", + "array": false + }, + "characters": { + "decl": "TextRange", + "kind": "get", + "type": null, + "array": false + }, + "fontId": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fontFamily": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fontVariantId": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fontSize": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fontStyle": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "lineHeight": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "letterSpacing": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "textTransform": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "textDecoration": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "direction": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "TextRange", + "kind": "getset", + "type": "Fill", + "array": true + }, + "align": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "verticalAlign": { + "decl": "TextRange", + "kind": "getset", + "type": null, + "array": false + }, + "applyTypography": { + "decl": "TextRange", + "kind": "method", + "type": null, + "array": false + } + }, + "ToggleOverlay": { + "type": { + "decl": "ToggleOverlay", + "kind": "get", + "type": null, + "array": false + }, + "destination": { + "decl": "OverlayAction", + "kind": "get", + "type": "Board", + "array": false + }, + "relativeTo": { + "decl": "OverlayAction", + "kind": "get", + "type": "Shape", + "array": false + }, + "position": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "manualPositionLocation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Point", + "array": false + }, + "closeWhenClickOutside": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "addBackgroundOverlay": { + "decl": "OverlayAction", + "kind": "get", + "type": null, + "array": false + }, + "animation": { + "decl": "OverlayAction", + "kind": "get", + "type": "Animation", + "array": false + } + }, + "TokenBase": { + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenBorderRadius": { + "type": { + "decl": "TokenBorderRadius", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenBorderRadius", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenBorderRadius", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenBorderWidth": { + "type": { + "decl": "TokenBorderWidth", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenBorderWidth", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenBorderWidth", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenCatalog": { + "themes": { + "decl": "TokenCatalog", + "kind": "get", + "type": "TokenTheme", + "array": true + }, + "sets": { + "decl": "TokenCatalog", + "kind": "get", + "type": "TokenSet", + "array": true + }, + "addTheme": { + "decl": "TokenCatalog", + "kind": "method", + "type": "TokenTheme", + "array": false + }, + "addSet": { + "decl": "TokenCatalog", + "kind": "method", + "type": "TokenSet", + "array": false + }, + "getThemeById": { + "decl": "TokenCatalog", + "kind": "method", + "type": "TokenTheme", + "array": false + }, + "getSetById": { + "decl": "TokenCatalog", + "kind": "method", + "type": "TokenSet", + "array": false + } + }, + "TokenColor": { + "type": { + "decl": "TokenColor", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenColor", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenColor", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenDimension": { + "type": { + "decl": "TokenDimension", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenDimension", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenDimension", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenFontFamilies": { + "type": { + "decl": "TokenFontFamilies", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenFontFamilies", + "kind": "getset", + "type": null, + "array": true + }, + "resolvedValue": { + "decl": "TokenFontFamilies", + "kind": "get", + "type": null, + "array": true + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenFontSizes": { + "type": { + "decl": "TokenFontSizes", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenFontSizes", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenFontSizes", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenFontWeights": { + "type": { + "decl": "TokenFontWeights", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenFontWeights", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenFontWeights", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenLetterSpacing": { + "type": { + "decl": "TokenLetterSpacing", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenLetterSpacing", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenLetterSpacing", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenNumber": { + "type": { + "decl": "TokenNumber", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenNumber", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenNumber", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenOpacity": { + "type": { + "decl": "TokenOpacity", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenOpacity", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenOpacity", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenRotation": { + "type": { + "decl": "TokenRotation", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenRotation", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenRotation", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenSet": { + "id": { + "decl": "TokenSet", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenSet", + "kind": "getset", + "type": null, + "array": false + }, + "active": { + "decl": "TokenSet", + "kind": "getset", + "type": null, + "array": false + }, + "tokens": { + "decl": "TokenSet", + "kind": "get", + "type": "Token", + "array": true + }, + "tokensByType": { + "decl": "TokenSet", + "kind": "get", + "type": null, + "array": true + }, + "toggleActive": { + "decl": "TokenSet", + "kind": "method", + "type": null, + "array": false + }, + "getTokenById": { + "decl": "TokenSet", + "kind": "method", + "type": "Token", + "array": false + }, + "addToken": { + "decl": "TokenSet", + "kind": "method", + "type": "Token", + "array": false + }, + "duplicate": { + "decl": "TokenSet", + "kind": "method", + "type": "TokenSet", + "array": false + }, + "remove": { + "decl": "TokenSet", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenShadow": { + "type": { + "decl": "TokenShadow", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenShadow", + "kind": "getset", + "type": "TokenShadowValueString", + "array": true + }, + "resolvedValue": { + "decl": "TokenShadow", + "kind": "get", + "type": "TokenShadowValue", + "array": true + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenShadowValue": { + "color": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + }, + "inset": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + }, + "offsetX": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + }, + "offsetY": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + }, + "spread": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + }, + "blur": { + "decl": "TokenShadowValue", + "kind": "getset", + "type": null, + "array": false + } + }, + "TokenShadowValueString": { + "color": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + }, + "inset": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + }, + "offsetX": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + }, + "offsetY": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + }, + "spread": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + }, + "blur": { + "decl": "TokenShadowValueString", + "kind": "getset", + "type": null, + "array": false + } + }, + "TokenSizing": { + "type": { + "decl": "TokenSizing", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenSizing", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenSizing", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenSpacing": { + "type": { + "decl": "TokenSpacing", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenSpacing", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenSpacing", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenTextCase": { + "type": { + "decl": "TokenTextCase", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenTextCase", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenTextCase", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenTextDecoration": { + "type": { + "decl": "TokenTextDecoration", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenTextDecoration", + "kind": "getset", + "type": null, + "array": false + }, + "resolvedValue": { + "decl": "TokenTextDecoration", + "kind": "get", + "type": null, + "array": false + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenTheme": { + "id": { + "decl": "TokenTheme", + "kind": "get", + "type": null, + "array": false + }, + "externalId": { + "decl": "TokenTheme", + "kind": "get", + "type": null, + "array": false + }, + "group": { + "decl": "TokenTheme", + "kind": "getset", + "type": null, + "array": false + }, + "name": { + "decl": "TokenTheme", + "kind": "getset", + "type": null, + "array": false + }, + "active": { + "decl": "TokenTheme", + "kind": "getset", + "type": null, + "array": false + }, + "toggleActive": { + "decl": "TokenTheme", + "kind": "method", + "type": null, + "array": false + }, + "activeSets": { + "decl": "TokenTheme", + "kind": "get", + "type": "TokenSet", + "array": true + }, + "addSet": { + "decl": "TokenTheme", + "kind": "method", + "type": null, + "array": false + }, + "removeSet": { + "decl": "TokenTheme", + "kind": "method", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenTheme", + "kind": "method", + "type": "TokenTheme", + "array": false + }, + "remove": { + "decl": "TokenTheme", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenTypography": { + "type": { + "decl": "TokenTypography", + "kind": "get", + "type": null, + "array": false + }, + "value": { + "decl": "TokenTypography", + "kind": "getset", + "type": "TokenTypographyValueString", + "array": false + }, + "resolvedValue": { + "decl": "TokenTypography", + "kind": "get", + "type": "TokenTypographyValue", + "array": true + }, + "id": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "description": { + "decl": "TokenBase", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "TokenBase", + "kind": "method", + "type": "Token", + "array": false + }, + "remove": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "resolvedValueString": { + "decl": "TokenBase", + "kind": "get", + "type": null, + "array": false + }, + "applyToShapes": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToSelected": { + "decl": "TokenBase", + "kind": "method", + "type": null, + "array": false + } + }, + "TokenTypographyValue": { + "letterSpacing": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + }, + "fontFamilies": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": true + }, + "fontSizes": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + }, + "fontWeights": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + }, + "lineHeight": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + }, + "textCase": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + }, + "textDecoration": { + "decl": "TokenTypographyValue", + "kind": "getset", + "type": null, + "array": false + } + }, + "TokenTypographyValueString": { + "letterSpacing": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + }, + "fontFamilies": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": true + }, + "fontSizes": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + }, + "fontWeight": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + }, + "lineHeight": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + }, + "textCase": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + }, + "textDecoration": { + "decl": "TokenTypographyValueString", + "kind": "getset", + "type": null, + "array": false + } + }, + "Track": { + "type": { + "decl": "Track", + "kind": "getset", + "type": "TrackType", + "array": false + }, + "value": { + "decl": "Track", + "kind": "getset", + "type": null, + "array": false + } + }, + "User": { + "id": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "avatarUrl": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "color": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + }, + "sessionId": { + "decl": "User", + "kind": "get", + "type": null, + "array": false + } + }, + "VariantContainer": { + "variants": { + "decl": "VariantContainer", + "kind": "get", + "type": "Variants", + "array": false + }, + "type": { + "decl": "Board", + "kind": "get", + "type": null, + "array": false + }, + "clipContent": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "showInViewMode": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "grid": { + "decl": "Board", + "kind": "get", + "type": "GridLayout", + "array": false + }, + "flex": { + "decl": "Board", + "kind": "get", + "type": "FlexLayout", + "array": false + }, + "guides": { + "decl": "Board", + "kind": "getset", + "type": "Guide", + "array": true + }, + "rulerGuides": { + "decl": "Board", + "kind": "get", + "type": "RulerGuide", + "array": true + }, + "horizontalSizing": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "verticalSizing": { + "decl": "Board", + "kind": "getset", + "type": null, + "array": false + }, + "fills": { + "decl": "Board", + "kind": "getset", + "type": "Fill", + "array": true + }, + "children": { + "decl": "Board", + "kind": "getset", + "type": "Shape", + "array": true + }, + "appendChild": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "insertChild": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "addFlexLayout": { + "decl": "Board", + "kind": "method", + "type": "FlexLayout", + "array": false + }, + "addGridLayout": { + "decl": "Board", + "kind": "method", + "type": "GridLayout", + "array": false + }, + "addRulerGuide": { + "decl": "Board", + "kind": "method", + "type": "RulerGuide", + "array": false + }, + "removeRulerGuide": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "isVariantContainer": { + "decl": "Board", + "kind": "method", + "type": null, + "array": false + }, + "id": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parent": { + "decl": "ShapeBase", + "kind": "get", + "type": "Shape", + "array": false + }, + "parentIndex": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "x": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "y": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "width": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "height": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "bounds": { + "decl": "ShapeBase", + "kind": "get", + "type": "Bounds", + "array": false + }, + "center": { + "decl": "ShapeBase", + "kind": "get", + "type": "Point", + "array": false + }, + "blocked": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "hidden": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "visible": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "proportionLock": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsHorizontal": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "constraintsVertical": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "fixedWhenScrolling": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadius": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusTopRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomRight": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "borderRadiusBottomLeft": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "opacity": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "blendMode": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "shadows": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Shadow", + "array": true + }, + "blur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "backgroundBlur": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Blur", + "array": false + }, + "exports": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Export", + "array": true + }, + "boardX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "boardY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "parentY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipX": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "flipY": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "rotation": { + "decl": "ShapeBase", + "kind": "getset", + "type": null, + "array": false + }, + "strokes": { + "decl": "ShapeBase", + "kind": "getset", + "type": "Stroke", + "array": true + }, + "layoutChild": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutChildProperties", + "array": false + }, + "layoutCell": { + "decl": "ShapeBase", + "kind": "get", + "type": "LayoutCellProperties", + "array": false + }, + "setParentIndex": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "tokens": { + "decl": "ShapeBase", + "kind": "get", + "type": null, + "array": false + }, + "isComponentInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentMainInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentCopyInstance": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "isComponentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "componentRefShape": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentRoot": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "componentHead": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "component": { + "decl": "ShapeBase", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "detach": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "swapComponent": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "switchVariant": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "combineAsVariants": { + "decl": "ShapeBase", + "kind": "method", + "type": "VariantContainer", + "array": false + }, + "isVariantHead": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "resize": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "rotate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringToFront": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "bringForward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendToBack": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "sendBackward": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "export": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "interactions": { + "decl": "ShapeBase", + "kind": "get", + "type": "Interaction", + "array": true + }, + "addInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": "Interaction", + "array": false + }, + "removeInteraction": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "applyToken": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "clone": { + "decl": "ShapeBase", + "kind": "method", + "type": "Shape", + "array": false + }, + "remove": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, + "getPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + }, + "getSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "setSharedPluginData": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": false + }, + "getSharedPluginDataKeys": { + "decl": "PluginData", + "kind": "method", + "type": null, + "array": true + } + }, + "Variants": { + "id": { + "decl": "Variants", + "kind": "get", + "type": null, + "array": false + }, + "libraryId": { + "decl": "Variants", + "kind": "get", + "type": null, + "array": false + }, + "properties": { + "decl": "Variants", + "kind": "get", + "type": null, + "array": true + }, + "currentValues": { + "decl": "Variants", + "kind": "method", + "type": null, + "array": true + }, + "removeProperty": { + "decl": "Variants", + "kind": "method", + "type": null, + "array": false + }, + "renameProperty": { + "decl": "Variants", + "kind": "method", + "type": null, + "array": false + }, + "variantComponents": { + "decl": "Variants", + "kind": "method", + "type": "LibraryComponent", + "array": true + }, + "addVariant": { + "decl": "Variants", + "kind": "method", + "type": null, + "array": false + }, + "addProperty": { + "decl": "Variants", + "kind": "method", + "type": null, + "array": false + } + }, + "Viewport": { + "center": { + "decl": "Viewport", + "kind": "getset", + "type": "Point", + "array": false + }, + "zoom": { + "decl": "Viewport", + "kind": "getset", + "type": null, + "array": false + }, + "bounds": { + "decl": "Viewport", + "kind": "get", + "type": "Bounds", + "array": false + }, + "zoomReset": { + "decl": "Viewport", + "kind": "method", + "type": null, + "array": false + }, + "zoomToFitAll": { + "decl": "Viewport", + "kind": "method", + "type": null, + "array": false + }, + "zoomIntoView": { + "decl": "Viewport", + "kind": "method", + "type": null, + "array": false + } + } + }, + "unions": { + "Action": { + "variants": [ + "NavigateTo", + "OpenOverlay", + "ToggleOverlay", + "CloseOverlay", + "PreviousScreen", + "OpenUrl" + ], + "discriminant": { + "field": "type", + "map": { + "navigate-to": "NavigateTo", + "open-overlay": "OpenOverlay", + "toggle-overlay": "ToggleOverlay", + "close-overlay": "CloseOverlay", + "previous-screen": "PreviousScreen", + "open-url": "OpenUrl" + } + } + }, + "Animation": { + "variants": ["Dissolve", "Slide", "Push"], + "discriminant": { + "field": "type", + "map": { + "dissolve": "Dissolve", + "slide": "Slide", + "push": "Push" + } + } + }, + "Guide": { + "variants": ["GuideColumn", "GuideRow", "GuideSquare"], + "discriminant": { + "field": "type", + "map": { + "column": "GuideColumn", + "row": "GuideRow", + "square": "GuideSquare" + } + } + }, + "Shape": { + "variants": [ + "Board", + "Group", + "Boolean", + "Rectangle", + "Path", + "Text", + "Ellipse", + "SvgRaw", + "Image" + ], + "discriminant": { + "field": "type", + "map": { + "board": "Board", + "group": "Group", + "boolean": "Boolean", + "rectangle": "Rectangle", + "path": "Path", + "text": "Text", + "ellipse": "Ellipse", + "svg-raw": "SvgRaw", + "image": "Image" + } + } + }, + "Token": { + "variants": [ + "TokenBorderRadius", + "TokenShadow", + "TokenColor", + "TokenDimension", + "TokenFontFamilies", + "TokenFontSizes", + "TokenFontWeights", + "TokenLetterSpacing", + "TokenNumber", + "TokenOpacity", + "TokenRotation", + "TokenSizing", + "TokenSpacing", + "TokenBorderWidth", + "TokenTextCase", + "TokenTextDecoration", + "TokenTypography" + ], + "discriminant": { + "field": "type", + "map": { + "borderRadius": "TokenBorderRadius", + "shadow": "TokenShadow", + "color": "TokenColor", + "dimension": "TokenDimension", + "fontFamilies": "TokenFontFamilies", + "fontSizes": "TokenFontSizes", + "fontWeights": "TokenFontWeights", + "letterSpacing": "TokenLetterSpacing", + "number": "TokenNumber", + "opacity": "TokenOpacity", + "rotation": "TokenRotation", + "sizing": "TokenSizing", + "spacing": "TokenSpacing", + "borderWidth": "TokenBorderWidth", + "textCase": "TokenTextCase", + "textDecoration": "TokenTextDecoration", + "typography": "TokenTypography" + } + } + }, + "TokenValueString": { + "variants": ["TokenShadowValueString", "TokenTypographyValueString"], + "discriminant": null + } + } +} diff --git a/plugins/apps/plugin-api-test-suite/src/model.ts b/plugins/apps/plugin-api-test-suite/src/model.ts new file mode 100644 index 0000000000..a86cb3eb19 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/model.ts @@ -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; diff --git a/plugins/apps/plugin-api-test-suite/src/plugin.ts b/plugins/apps/plugin-api-test-suite/src/plugin.ts new file mode 100644 index 0000000000..db4f548bbb --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/plugin.ts @@ -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(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 }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests-bundle.ts b/plugins/apps/plugin-api-test-suite/src/tests-bundle.ts new file mode 100644 index 0000000000..cdba097903 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests-bundle.ts @@ -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(); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/colors.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/colors.test.ts new file mode 100644 index 0000000000..d61a34234d --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/colors.test.ts @@ -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'); + } + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts new file mode 100644 index 0000000000..b6ba1745ad --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts @@ -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 { + 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); + } + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts new file mode 100644 index 0000000000..4982bc574c --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts @@ -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), + ).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); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/events.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/events.test.ts new file mode 100644 index 0000000000..8d1fddf66a --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/events.test.ts @@ -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 { + 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); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/file.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/file.test.ts new file mode 100644 index 0000000000..5f8f4a8b11 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/file.test.ts @@ -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('')); + } + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts new file mode 100644 index 0000000000..bf4ef96cf0 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts @@ -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(); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/fixtures.ts b/plugins/apps/plugin-api-test-suite/src/tests/fixtures.ts new file mode 100644 index 0000000000..45c3302b4d --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/fixtures.ts @@ -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, +]); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts new file mode 100644 index 0000000000..7340ddfa94 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts @@ -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); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts new file mode 100644 index 0000000000..91f26d3dce --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts @@ -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', + ]); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts new file mode 100644 index 0000000000..f4f8aacc6e --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts @@ -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(); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts new file mode 100644 index 0000000000..f28e3263b2 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts @@ -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'); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/media.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/media.test.ts new file mode 100644 index 0000000000..c05ba336e2 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/media.test.ts @@ -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); + } + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/misc.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/misc.test.ts new file mode 100644 index 0000000000..cabb0869b4 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/misc.test.ts @@ -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 { + 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); + } + } + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts new file mode 100644 index 0000000000..1cd3463d7f --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts @@ -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); + } + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/platform.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/platform.test.ts new file mode 100644 index 0000000000..ba318d0599 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/platform.test.ts @@ -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'); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts new file mode 100644 index 0000000000..e5c02be79b --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts @@ -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(); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts new file mode 100644 index 0000000000..7ede39799a --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts @@ -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); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts new file mode 100644 index 0000000000..89209a0906 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts @@ -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 = + '' + + ''; + 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 = + '' + + ''; + 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 = + '' + + '' + + ''; + 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); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts new file mode 100644 index 0000000000..2402f29035 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts @@ -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); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shapes-types.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shapes-types.test.ts new file mode 100644 index 0000000000..dcd7c82dd5 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/shapes-types.test.ts @@ -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. ) become raw svg nodes, so the + // fixture must include one to exercise SvgRaw. + const svg = + '' + + '' + + 'hi'; + 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); + } + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts new file mode 100644 index 0000000000..21a4efbe38 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts @@ -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 | 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'); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts new file mode 100644 index 0000000000..1bfc15003e --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts @@ -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 { + 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'); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts new file mode 100644 index 0000000000..adcbabd52c --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts @@ -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); + } + } + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts new file mode 100644 index 0000000000..6afb75f495 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts @@ -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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// transformInVariant is async, so `variants` is only populated after a tick. +async function variantComponent( + ctx: TestContext, +): Promise { + 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(); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts new file mode 100644 index 0000000000..0745629625 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts @@ -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; + } + } + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/ui.css b/plugins/apps/plugin-api-test-suite/src/ui.css new file mode 100644 index 0000000000..104ac64c90 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/ui.css @@ -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); +} diff --git a/plugins/apps/plugin-api-test-suite/src/ui.ts b/plugins/apps/plugin-api-test-suite/src/ui.ts new file mode 100644 index 0000000000..64dc79b486 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/ui.ts @@ -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(); +const selected = new Set(); +const expandedGroups = new Set(); +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(); + 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( + tag: K, + props: Partial = {}, + 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
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) => { + 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' }); diff --git a/plugins/apps/plugin-api-test-suite/tools/gen-api-surface.ts b/plugins/apps/plugin-api-test-suite/tools/gen-api-surface.ts new file mode 100644 index 0000000000..bafb5f7f34 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/tools/gen-api-surface.ts @@ -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(); +const unionAliases = new Map(); +// 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 = {}; +for (const [name, decl] of interfaceDecls) { + if (isDeprecated(decl)) continue; + const names = new Set(); + 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(); + 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` 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` 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> = {}; + +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 = {}; + + 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 = {}; + 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 = {}; + +interface UnionInfoOut { + variants: string[]; + discriminant: { field: string; map: Record } | 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 = {}; + 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}`, +); diff --git a/plugins/apps/plugin-api-test-suite/tsconfig.app.json b/plugins/apps/plugin-api-test-suite/tsconfig.app.json new file mode 100644 index 0000000000..951462f9d2 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/tsconfig.app.json @@ -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"] +} diff --git a/plugins/apps/plugin-api-test-suite/tsconfig.json b/plugins/apps/plugin-api-test-suite/tsconfig.json new file mode 100644 index 0000000000..8262ab74ac --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/tsconfig.json @@ -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" } + ] +} diff --git a/plugins/apps/plugin-api-test-suite/tsconfig.node.json b/plugins/apps/plugin-api-test-suite/tsconfig.node.json new file mode 100644 index 0000000000..15543271a6 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/tsconfig.node.json @@ -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" + ] +} diff --git a/plugins/apps/plugin-api-test-suite/vite.config.headless.ts b/plugins/apps/plugin-api-test-suite/vite.config.headless.ts new file mode 100644 index 0000000000..c2fad314f5 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/vite.config.headless.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'); diff --git a/plugins/apps/plugin-api-test-suite/vite.config.iife.ts b/plugins/apps/plugin-api-test-suite/vite.config.iife.ts new file mode 100644 index 0000000000..b2ee8a2985 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/vite.config.iife.ts @@ -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', + }, + }, + }, + }); +} diff --git a/plugins/apps/plugin-api-test-suite/vite.config.tests.ts b/plugins/apps/plugin-api-test-suite/vite.config.tests.ts new file mode 100644 index 0000000000..39468d5209 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/vite.config.tests.ts @@ -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'); diff --git a/plugins/apps/plugin-api-test-suite/vite.config.ts b/plugins/apps/plugin-api-test-suite/vite.config.ts new file mode 100644 index 0000000000..cc031bca49 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/vite.config.ts @@ -0,0 +1,39 @@ +/// +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', + }, + }, + }, +}); diff --git a/plugins/package.json b/plugins/package.json index 7a97ee50da..8615dc1b95 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -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}\"", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index f8760b69e3..8d6e63b12a 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -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)):