mirror of
https://github.com/penpot/penpot.git
synced 2026-11-04 00:50:44 +00:00
* 🐛 Fix plugins api issues * 🐛 Fix problem with text weight * 🐛 Fix api problems * ✨ Updated plugins test suite * 🐛 Referential integrity tests * ✨ Add resetOverrides method * 🐛 Fix plugins components structure poluting
554 lines
18 KiB
TypeScript
554 lines
18 KiB
TypeScript
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';
|
|
import { waitFor } from './wait';
|
|
|
|
// Design tokens.
|
|
// The token catalog is reached through the local library. Sets/themes/tokens are
|
|
// self-provisioned; sets are created active so token references resolve.
|
|
|
|
function catalog(ctx: TestContext): TokenCatalog {
|
|
return ctx.penpot.library.local.tokens;
|
|
}
|
|
|
|
function activeSet(ctx: TestContext, name: string): TokenSet {
|
|
return catalog(ctx).addSet({ name, active: true });
|
|
}
|
|
|
|
// Names must be unique across runs too: sets/themes leak into the file (the
|
|
// API has no theme remove and set removal is best-effort), so a plain counter
|
|
// collides with leftovers from a previous run. Add a per-run random tag.
|
|
const runTag = Math.random().toString(36).slice(2, 8);
|
|
let counter = 0;
|
|
function unique(prefix: string): string {
|
|
counter += 1;
|
|
return `${prefix}-${runTag}-${counter}`;
|
|
}
|
|
|
|
/** Token application and theme/set wiring update the store asynchronously. */
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
describe('Tokens', () => {
|
|
describe('Catalog', () => {
|
|
test('addSet creates a token set', (ctx) => {
|
|
const cat = catalog(ctx);
|
|
const set = cat.addSet({ name: unique('set'), active: true });
|
|
expect(typeof set.id).toBe('string');
|
|
expect(set.active).toBe(true);
|
|
expect(cat.sets.length).toBeGreaterThan(0);
|
|
expect(cat.getSetById(set.id)).toBeDefined();
|
|
});
|
|
|
|
test('addTheme creates a token theme', (ctx) => {
|
|
const cat = catalog(ctx);
|
|
const theme = cat.addTheme({ group: '', name: unique('theme') });
|
|
expect(typeof theme.id).toBe('string');
|
|
expect(cat.themes.length).toBeGreaterThan(0);
|
|
expect(cat.getThemeById(theme.id)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('Set', () => {
|
|
test('name and active round-trip', (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const newName = unique('renamed');
|
|
set.name = newName;
|
|
expect(set.name).toBe(newName);
|
|
set.active = false;
|
|
expect(set.active).toBe(false);
|
|
set.toggleActive();
|
|
expect(set.active).toBe(true);
|
|
});
|
|
|
|
// Community report (forum #10700, issue #14): toggling a token set's
|
|
// active state was said to freeze and roll back when tokens from the set
|
|
// are bound to shapes. Did not reproduce; kept as a regression pin.
|
|
test('toggleActive persists when the set has bound tokens', async (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);
|
|
await sleep(300);
|
|
|
|
set.toggleActive();
|
|
await waitFor(() => set.active === false);
|
|
expect(set.active).toBe(false);
|
|
set.toggleActive();
|
|
await waitFor(() => set.active === true);
|
|
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 waitFor(() => theme.activeSets.length > 0);
|
|
expect(theme.activeSets.length).toBeGreaterThan(0);
|
|
theme.removeSet(set);
|
|
});
|
|
|
|
// addSet/removeSet also accept a token set id (string), not just a TokenSet.
|
|
test('addSet and removeSet accept a set id', 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.id);
|
|
await waitFor(() => theme.activeSets.length > 0);
|
|
expect(theme.activeSets.length).toBeGreaterThan(0);
|
|
theme.removeSet(set.id);
|
|
await waitFor(() => theme.activeSets.length === 0);
|
|
expect(theme.activeSets.length).toBe(0);
|
|
});
|
|
|
|
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 waitFor(() => Object.keys(rect.tokens).length > 0);
|
|
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 waitFor(() => Object.keys(rect.tokens).length > 0);
|
|
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();
|
|
});
|
|
|
|
// Community report (forum #10700, issue #9): applyToken with a spacing token
|
|
// on 'paddingLeft' once recorded the binding under a bogus
|
|
// 'paddingBottomLeft' key (the gap properties bound correctly). Fixed; kept
|
|
// as a regression pin.
|
|
test('applyToken binds a spacing token to flex padding', async (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const token = set.addToken({
|
|
type: 'spacing',
|
|
name: unique('spacing.'),
|
|
value: '8',
|
|
});
|
|
|
|
const b = ctx.penpot.createBoard();
|
|
ctx.board.appendChild(b);
|
|
b.resize(300, 200);
|
|
b.addFlexLayout();
|
|
|
|
b.applyToken(token, ['columnGap']);
|
|
b.applyToken(token, ['paddingLeft']);
|
|
// Both bindings settle together; wait on columnGap, then assert paddingLeft
|
|
// lands under its own key (the previously-misfiled case).
|
|
await waitFor(() => Object.keys(b.tokens).includes('columnGap'));
|
|
expect(Object.keys(b.tokens)).toContain('columnGap');
|
|
expect(Object.keys(b.tokens)).toContain('paddingLeft');
|
|
});
|
|
|
|
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;
|
|
const renamed = unique('renamed.');
|
|
token.name = renamed;
|
|
// The value and name setters round-trip on the live backend. A
|
|
// `fontFamilies` value reads back as a family array, so join it before
|
|
// comparing to the assigned single family.
|
|
const readValue = token.value;
|
|
const normalized = Array.isArray(readValue)
|
|
? readValue.join(', ')
|
|
: readValue;
|
|
expect(normalized).toBe(value2);
|
|
expect(token.name).toBe(renamed);
|
|
// Record the resolvedValue (get) target for every type; the fontFamilies
|
|
// shape is asserted in the dedicated test below.
|
|
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');
|
|
});
|
|
});
|