mirror of
https://github.com/penpot/penpot.git
synced 2026-08-11 23:38:45 +00:00
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import { expect } from '../framework/expect';
|
|
import { describe, test } from '../framework/registry';
|
|
import type { Board, 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);
|
|
});
|
|
|
|
// The Library object itself carries plugin data (it extends PluginData),
|
|
// stored on the underlying file. Exercised on the local library; the
|
|
// connected-library case (writing to a shared library's file/assets) shares
|
|
// the same code path but can't be fixtured here (connecting a library hangs).
|
|
test('local library stores plugin data', (ctx) => {
|
|
const lib = ctx.penpot.library.local;
|
|
lib.setPluginData('k', 'v');
|
|
expect(lib.getPluginData('k')).toBe('v');
|
|
expect(lib.getPluginDataKeys()).toContain('k');
|
|
});
|
|
|
|
test('local library stores shared plugin data', (ctx) => {
|
|
const lib = ctx.penpot.library.local;
|
|
lib.setSharedPluginData('ns', 'k', 'v');
|
|
expect(lib.getSharedPluginData('ns', 'k')).toBe('v');
|
|
expect(lib.getSharedPluginDataKeys('ns')).toContain('k');
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
// `name`/`path` and the PluginData methods are inherited (LibraryElement /
|
|
// PluginData) but implemented per concrete type in the cljs binding
|
|
// (lib-color-proxy), so they must be exercised on each type separately.
|
|
test('color plugin data round-trips', (ctx) => {
|
|
const color = ctx.penpot.library.local.createColor();
|
|
color.setPluginData('k', 'v');
|
|
expect(color.getPluginData('k')).toBe('v');
|
|
expect(color.getPluginDataKeys()).toContain('k');
|
|
});
|
|
|
|
test('color shared plugin data round-trips', (ctx) => {
|
|
const color = ctx.penpot.library.local.createColor();
|
|
color.setSharedPluginData('ns', 'k', 'v');
|
|
expect(color.getSharedPluginData('ns', 'k')).toBe('v');
|
|
expect(color.getSharedPluginDataKeys('ns')).toContain('k');
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
// `name`/`path` and PluginData are implemented per concrete type
|
|
// (lib-typography-proxy); exercise them on a typography specifically.
|
|
test('typography name and path round-trip', (ctx) => {
|
|
const typo = ctx.penpot.library.local.createTypography();
|
|
typo.name = 'Heading';
|
|
typo.path = 'Text';
|
|
expect(typo.name).toBe('Heading');
|
|
expect(typo.path).toBe('Text');
|
|
});
|
|
|
|
test('typography plugin data round-trips', (ctx) => {
|
|
const typo = ctx.penpot.library.local.createTypography();
|
|
typo.setPluginData('k', 'v');
|
|
expect(typo.getPluginData('k')).toBe('v');
|
|
expect(typo.getPluginDataKeys()).toContain('k');
|
|
});
|
|
|
|
test('typography shared plugin data round-trips', (ctx) => {
|
|
const typo = ctx.penpot.library.local.createTypography();
|
|
typo.setSharedPluginData('ns', 'k', 'v');
|
|
expect(typo.getSharedPluginData('ns', 'k')).toBe('v');
|
|
expect(typo.getSharedPluginDataKeys('ns')).toContain('k');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// `name`/`path` and PluginData are implemented per concrete type
|
|
// (lib-component-proxy). `component.path` in particular had no test before,
|
|
// which is how the variant `.path` corruption slipped through — exercise it
|
|
// on a plain (non-variant) component here.
|
|
test('component name and path round-trip', (ctx) => {
|
|
const rect = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(rect);
|
|
const comp = ctx.penpot.library.local.createComponent([rect]);
|
|
comp.name = 'Button';
|
|
comp.path = 'Controls';
|
|
expect(comp.name).toBe('Button');
|
|
expect(comp.path).toBe('Controls');
|
|
});
|
|
|
|
test('component plugin data round-trips', (ctx) => {
|
|
const rect = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(rect);
|
|
const comp = ctx.penpot.library.local.createComponent([rect]);
|
|
comp.setPluginData('k', 'v');
|
|
expect(comp.getPluginData('k')).toBe('v');
|
|
expect(comp.getPluginDataKeys()).toContain('k');
|
|
});
|
|
|
|
test('component shared plugin data round-trips', (ctx) => {
|
|
const rect = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(rect);
|
|
const comp = ctx.penpot.library.local.createComponent([rect]);
|
|
comp.setSharedPluginData('ns', 'k', 'v');
|
|
expect(comp.getSharedPluginData('ns', 'k')).toBe('v');
|
|
expect(comp.getSharedPluginDataKeys('ns')).toContain('k');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
// Reported (via MCP): create a page-root shape (rectangle / ellipse / text),
|
|
// save it as a component, then use it from Assets — the component root was
|
|
// said to be a plain shape instead of a board/component. This does not
|
|
// reproduce: a lone page-root shape is wrapped in a board and that board
|
|
// becomes the component (main-instance) root. Pin the invariant for the
|
|
// exact repro.
|
|
test('createComponent wraps a page-root shape in a board component root', (ctx) => {
|
|
// A genuine page-root shape: created but NOT placed inside any board.
|
|
const rect = ctx.penpot.createRectangle();
|
|
const comp = ctx.penpot.library.local.createComponent([rect]);
|
|
|
|
const main = comp.mainInstance() as Board;
|
|
expect(main.type).toBe('board');
|
|
expect(main.isComponentRoot()).toBeTruthy();
|
|
expect(main.isComponentMainInstance()).toBeTruthy();
|
|
expect(main.children.some((c) => c.id === rect.id)).toBe(true);
|
|
|
|
const errors = ctx.penpot.currentFile?.validate() ?? [];
|
|
expect(errors.map((e) => e.code)).toEqual([]);
|
|
|
|
// The generated main instance lands at the page root (outside ctx.board);
|
|
// tuck it under the scratch board so teardown removes it.
|
|
ctx.board.appendChild(main);
|
|
});
|
|
|
|
// createComponent must reject inputs that cannot form a component rather
|
|
// than succeeding silently or crashing. Both cases below currently surface
|
|
// an INTERNAL assertion ("Assert failed: (uuid? id)"): the operation is a
|
|
// no-op, so the returned LibraryComponent proxy is built from a nil id. It
|
|
// should be a clean rejection (or a null return) instead — and in a release
|
|
// build, where the assertion is elided, the caller gets a broken component
|
|
// proxy pointing at nothing. The file must stay valid regardless.
|
|
test('createComponent with no shapes is rejected', (ctx) => {
|
|
expect(() => ctx.penpot.library.local.createComponent([])).toThrow();
|
|
const errors = ctx.penpot.currentFile?.validate() ?? [];
|
|
expect(errors.map((e) => e.code)).toEqual([]);
|
|
});
|
|
|
|
test('createComponent from a shape inside a component copy is rejected', (ctx) => {
|
|
const rect = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(rect);
|
|
const source = ctx.penpot.library.local.createComponent([rect]);
|
|
const copy = source.instance() as Board;
|
|
ctx.board.appendChild(copy);
|
|
const copyChild = copy.children[0];
|
|
expect(copyChild).toBeDefined();
|
|
|
|
expect(() =>
|
|
ctx.penpot.library.local.createComponent([copyChild]),
|
|
).toThrow();
|
|
const errors = ctx.penpot.currentFile?.validate() ?? [];
|
|
expect(errors.map((e) => e.code)).toEqual([]);
|
|
});
|
|
});
|
|
});
|