mirror of
https://github.com/penpot/penpot.git
synced 2026-11-10 12:00:37 +00:00
* 🐛 Fix font family space in tokens * 🐛 Resolve token merge conflicts Keep the font-family token fix and the newer background-plugin change together in the unreleased changelog and plugin API test suite. AI-assisted-by: gpt-6-luna --------- Co-authored-by: niwinz <843689+niwinz@users.noreply.github.com>
942 lines
31 KiB
TypeScript
942 lines
31 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}`;
|
|
}
|
|
|
|
async function rectangleWithStrokeTokens(ctx: TestContext) {
|
|
const set = activeSet(ctx, unique('stroke-set'));
|
|
const width = set.addToken({
|
|
type: 'borderWidth',
|
|
name: unique('border.width'),
|
|
value: '4',
|
|
});
|
|
const color = set.addToken({
|
|
type: 'color',
|
|
name: unique('border.color'),
|
|
value: '#112233',
|
|
});
|
|
const rect = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(rect);
|
|
rect.strokes = [
|
|
{ strokeColor: '#000000', strokeWidth: 1 },
|
|
{ strokeColor: '#ffffff', strokeWidth: 2 },
|
|
];
|
|
rect.applyToken(width, ['strokeWidth']);
|
|
await waitFor(() => rect.tokens.strokeWidth === width.name);
|
|
rect.applyToken(color, ['strokeColor']);
|
|
await waitFor(() => rect.tokens.strokeColor === color.name);
|
|
expect(rect.tokens).toEqual({
|
|
strokeColor: color.name,
|
|
strokeWidth: width.name,
|
|
strokeWidthTop: width.name,
|
|
strokeWidthRight: width.name,
|
|
strokeWidthBottom: width.name,
|
|
strokeWidthLeft: width.name,
|
|
});
|
|
return { rect, width, color };
|
|
}
|
|
|
|
/** 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();
|
|
});
|
|
|
|
test('catalog name validation uses current state and rejects blank names', (ctx) => {
|
|
const cat = catalog(ctx);
|
|
const name = unique('live-set');
|
|
cat.addSet({ name });
|
|
expect(() => cat.addSet({ name })).toThrow();
|
|
expect(() => cat.addSet({ name: ' ' })).toThrow();
|
|
expect(() => cat.addTheme({ group: '', name: ' ' })).toThrow();
|
|
|
|
const group = unique('live-theme-group');
|
|
const themeName = unique('live-theme');
|
|
cat.addTheme({ group, name: themeName });
|
|
expect(() => cat.addTheme({ group, name: themeName })).toThrow();
|
|
});
|
|
});
|
|
|
|
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('retained set proxies validate names against current state', (ctx) => {
|
|
const first = activeSet(ctx, unique('set'));
|
|
const second = activeSet(ctx, unique('set'));
|
|
expect(() => {
|
|
first.name = second.name;
|
|
}).toThrow();
|
|
expect(() => {
|
|
first.name = ' ';
|
|
}).toThrow();
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
|
|
test('empty composites and line-height without font-size throw', (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
expect(() =>
|
|
set.addToken({
|
|
type: 'typography',
|
|
name: unique('empty-typography.'),
|
|
value: {} as never,
|
|
}),
|
|
).toThrow();
|
|
expect(() =>
|
|
set.addToken({
|
|
type: 'shadow',
|
|
name: unique('empty-shadow.'),
|
|
value: [] as never,
|
|
}),
|
|
).toThrow();
|
|
expect(() =>
|
|
set.addToken({
|
|
type: 'typography',
|
|
name: unique('line-height-only.'),
|
|
value: { lineHeight: '1.2' } as never,
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Theme', () => {
|
|
test('group, name and active round-trip', (ctx) => {
|
|
const theme = catalog(ctx).addTheme({ group: '', name: unique('theme') });
|
|
const group = unique('brand');
|
|
const name = unique('dark');
|
|
theme.group = group;
|
|
theme.name = name;
|
|
expect(theme.group).toBe(group);
|
|
expect(theme.name).toBe(name);
|
|
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();
|
|
});
|
|
|
|
test('theme names stay unique within their current group', (ctx) => {
|
|
const cat = catalog(ctx);
|
|
const group = unique('group');
|
|
const first = cat.addTheme({ group, name: unique('theme') });
|
|
const second = cat.addTheme({ group, name: unique('theme') });
|
|
expect(() => {
|
|
first.name = second.name;
|
|
}).toThrow();
|
|
expect(() => {
|
|
first.name = ' ';
|
|
}).toThrow();
|
|
|
|
const duplicate = first.duplicate();
|
|
expect(duplicate.name).not.toBe(first.name);
|
|
duplicate.remove();
|
|
});
|
|
|
|
test('moving a retained theme cannot create a group/name collision', (ctx) => {
|
|
const cat = catalog(ctx);
|
|
const name = unique('shared-theme');
|
|
const first = cat.addTheme({ group: unique('group-a'), name });
|
|
const second = cat.addTheme({ group: unique('group-b'), name });
|
|
expect(() => {
|
|
first.group = second.group;
|
|
}).toThrow();
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
// The target is a text shape because the attribute set of a board excludes
|
|
// the font-family property, so the application is filtered out there and
|
|
// the assertions pass vacuously.
|
|
// The family comes from the bundled Google fonts catalog, available in every
|
|
// run. `Red` in its name reads as a color if the resolver parses the words.
|
|
test('applyToken binds a fontFamilies token to a text shape', async (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const font = ctx.penpot.fonts.findByName('Red Hat Display');
|
|
if (!font) throw new Error('Red Hat Display font not found');
|
|
const token = set.addToken({
|
|
type: 'fontFamilies',
|
|
name: unique('fontFamilies.'),
|
|
value: [font.fontFamily],
|
|
});
|
|
|
|
const t = ctx.penpot.createText('Hello Penpot');
|
|
if (!t) throw new Error('createText returned null');
|
|
ctx.board.appendChild(t);
|
|
|
|
t.applyToken(token, ['fontFamilies']);
|
|
await waitFor(() => t.fontFamily === font.fontFamily);
|
|
expect(Object.keys(t.tokens)).toContain('fontFamilies');
|
|
expect(t.fontFamily).toBe(font.fontFamily);
|
|
});
|
|
|
|
test('applyToken accepts the uniform strokeWidth property', async (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const token = set.addToken({
|
|
type: 'borderWidth',
|
|
name: unique('width.'),
|
|
value: '4',
|
|
});
|
|
const rect = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(rect);
|
|
|
|
rect.applyToken(token, ['strokeWidth']);
|
|
|
|
await waitFor(() => Object.keys(rect.tokens).length > 0);
|
|
const keys = Object.keys(rect.tokens);
|
|
expect(keys).toContain('strokeWidthTop');
|
|
expect(keys).toContain('strokeWidthRight');
|
|
expect(keys).toContain('strokeWidthBottom');
|
|
expect(keys).toContain('strokeWidthLeft');
|
|
});
|
|
|
|
test('applyToken binds a single per-side stroke width property', async (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const token = set.addToken({
|
|
type: 'borderWidth',
|
|
name: unique('width.'),
|
|
value: '4',
|
|
});
|
|
const rect = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(rect);
|
|
|
|
rect.applyToken(token, ['strokeWidthTop']);
|
|
|
|
await waitFor(() => Object.keys(rect.tokens).includes('strokeWidthTop'));
|
|
const keys = Object.keys(rect.tokens);
|
|
expect(keys).toContain('strokeWidthTop');
|
|
expect(keys).not.toContain('strokeWidthRight');
|
|
});
|
|
|
|
for (const side of [
|
|
'strokeWidthTop',
|
|
'strokeWidthRight',
|
|
'strokeWidthBottom',
|
|
'strokeWidthLeft',
|
|
] as const) {
|
|
test(`${side} edits preserve unrelated stroke tokens`, async (ctx) => {
|
|
const { rect } = await rectangleWithStrokeTokens(ctx);
|
|
const expected: Partial<typeof rect.tokens> = { ...rect.tokens };
|
|
delete expected[side];
|
|
delete expected.strokeWidth;
|
|
|
|
rect.strokes[0][side] = 8;
|
|
await ctx.penpot.waitForLayoutUpdate();
|
|
|
|
expect(rect.strokes[0][side]).toBe(8);
|
|
expect(rect.tokens).toEqual(expected);
|
|
});
|
|
}
|
|
|
|
test('uniform stroke width edits preserve the color token', async (ctx) => {
|
|
const { rect, color } = await rectangleWithStrokeTokens(ctx);
|
|
rect.strokes[0].strokeWidth = 8;
|
|
await ctx.penpot.waitForLayoutUpdate();
|
|
|
|
expect(rect.tokens).toEqual({ strokeColor: color.name });
|
|
expect(rect.strokes[0].strokeWidthType).toBe('simple');
|
|
expect(rect.strokes[0].strokeWidthTop).toBe(8);
|
|
expect(rect.strokes[0].strokeWidthRight).toBe(8);
|
|
expect(rect.strokes[0].strokeWidthBottom).toBe(8);
|
|
expect(rect.strokes[0].strokeWidthLeft).toBe(8);
|
|
});
|
|
|
|
test('stroke color edits preserve the width tokens', async (ctx) => {
|
|
const { rect } = await rectangleWithStrokeTokens(ctx);
|
|
const expected: Partial<typeof rect.tokens> = { ...rect.tokens };
|
|
delete expected.strokeColor;
|
|
rect.strokes[0].strokeColor = '#ff0000';
|
|
await ctx.penpot.waitForLayoutUpdate();
|
|
|
|
expect(rect.strokes[0].strokeColor).toBe('#ff0000');
|
|
expect(rect.tokens).toEqual(expected);
|
|
});
|
|
|
|
test('later stroke edits preserve the first stroke tokens', async (ctx) => {
|
|
const { rect } = await rectangleWithStrokeTokens(ctx);
|
|
const expected = { ...rect.tokens };
|
|
rect.strokes[1].strokeWidthTop = 8;
|
|
rect.strokes[1].strokeColor = '#ff0000';
|
|
await ctx.penpot.waitForLayoutUpdate();
|
|
|
|
expect(rect.strokes[1].strokeWidthTop).toBe(8);
|
|
expect(rect.strokes[1].strokeColor).toBe('#ff0000');
|
|
expect(rect.strokes[0].strokeWidthTop).toBe(4);
|
|
expect(rect.strokes[0].strokeColor).toBe('#112233');
|
|
expect(rect.tokens).toEqual(expected);
|
|
});
|
|
|
|
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);
|
|
expect(dup.name).not.toBe(token.name);
|
|
dup.remove();
|
|
});
|
|
|
|
test('token edits reject missing, self, cyclic, and dotted-name conflicts', (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const prefix = unique('tree');
|
|
set.addToken({
|
|
type: 'dimension',
|
|
name: `${prefix}.child`,
|
|
value: '8',
|
|
});
|
|
const token = set.addToken({
|
|
type: 'dimension',
|
|
name: unique('editable.'),
|
|
value: '4',
|
|
});
|
|
expect(() => {
|
|
token.name = prefix;
|
|
}).toThrow();
|
|
expect(() => {
|
|
token.value = `{${token.name}}`;
|
|
}).toThrow();
|
|
expect(() => {
|
|
token.value = '{missing-token}';
|
|
}).toThrow();
|
|
|
|
const other = set.addToken({
|
|
type: 'dimension',
|
|
name: unique('other.'),
|
|
value: '2',
|
|
});
|
|
token.value = `{${other.name}}`;
|
|
expect(() => {
|
|
other.value = `{${token.name}}`;
|
|
}).toThrow();
|
|
});
|
|
|
|
test('retained token proxies validate names against current state', (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const retained = set.addToken({
|
|
type: 'dimension',
|
|
name: unique('retained.'),
|
|
value: '1',
|
|
});
|
|
const later = set.addToken({
|
|
type: 'dimension',
|
|
name: unique('later.'),
|
|
value: '2',
|
|
});
|
|
expect(() => {
|
|
retained.name = later.name;
|
|
}).toThrow();
|
|
});
|
|
|
|
test('renaming validates the whole token and rejects a new self-reference', (ctx) => {
|
|
const targetName = unique('rename-target');
|
|
activeSet(ctx, unique('target-set')).addToken({
|
|
type: 'dimension',
|
|
name: targetName,
|
|
value: '8',
|
|
});
|
|
const token = activeSet(ctx, unique('source-set')).addToken({
|
|
type: 'dimension',
|
|
name: unique('rename-source'),
|
|
value: `{${targetName}}`,
|
|
});
|
|
|
|
expect(() => {
|
|
token.name = targetName;
|
|
}).toThrow();
|
|
});
|
|
|
|
test('composite token updates reject empty values and missing font size', (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const typography = set.addToken({
|
|
type: 'typography',
|
|
name: unique('typography.'),
|
|
value: { fontSizes: '14', lineHeight: '1.2' } as never,
|
|
}) as TokenTypography;
|
|
const shadow = set.addToken({
|
|
type: 'shadow',
|
|
name: unique('shadow.'),
|
|
value: {
|
|
color: '#000000',
|
|
inset: 'false',
|
|
offsetX: '0',
|
|
offsetY: '0',
|
|
spread: '0',
|
|
blur: '1',
|
|
},
|
|
}) as TokenShadow;
|
|
|
|
expect(() => {
|
|
typography.value = {} as never;
|
|
}).toThrow();
|
|
expect(() => {
|
|
typography.value = { lineHeight: '1.2' } as never;
|
|
}).toThrow();
|
|
expect(() => {
|
|
shadow.value = [];
|
|
}).toThrow();
|
|
});
|
|
|
|
test('tokens with newly broken references cannot be applied', (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const base = set.addToken({
|
|
type: 'borderRadius',
|
|
name: unique('base.'),
|
|
value: '8',
|
|
});
|
|
const ref = set.addToken({
|
|
type: 'borderRadius',
|
|
name: unique('ref.'),
|
|
value: `{${base.name}}`,
|
|
});
|
|
base.remove();
|
|
const rect = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(rect);
|
|
ctx.penpot.selection = [rect];
|
|
expect(() => ref.applyToShapes([rect])).toThrow();
|
|
expect(() => ref.applyToSelected()).toThrow();
|
|
expect(() => rect.applyToken(ref)).toThrow();
|
|
});
|
|
|
|
// 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');
|
|
});
|
|
|
|
// The resolver parses a family whose name has several words as a list of
|
|
// word symbols, so each family name must survive as a single entry. A
|
|
// single-word family takes the simpler path the test above covers.
|
|
test('fontFamilies token resolvedValue keeps multi-word families whole', (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const token = set.addToken({
|
|
type: 'fontFamilies',
|
|
name: unique('fontFamilies.'),
|
|
value: ['Hanken Grotesk'],
|
|
});
|
|
// The stored value keeps the family whole, isolating resolution below.
|
|
expect(token.value).toEqual(['Hanken Grotesk']);
|
|
|
|
const resolved = token.resolvedValue as unknown as string[];
|
|
expect(Array.isArray(resolved)).toBe(true);
|
|
expect(resolved).toHaveLength(1);
|
|
expect(resolved[0]).toBe('Hanken Grotesk');
|
|
});
|
|
|
|
// Words like `Red` or `Black` read as colors and `2P` as a number and a word
|
|
// when the resolver parses the family name.
|
|
test('fontFamilies token resolvedValue keeps names with expression words', (ctx) => {
|
|
const set = activeSet(ctx, unique('set'));
|
|
const families = ['Red Hat Display', 'Archivo Black', 'Press Start 2P'];
|
|
const token = set.addToken({
|
|
type: 'fontFamilies',
|
|
name: unique('fontFamilies.'),
|
|
value: families,
|
|
});
|
|
|
|
expect(token.resolvedValue as unknown as string[]).toEqual(families);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|