Add end-to-end tests for plugins validation

This commit is contained in:
alonso.torres 2026-09-09 15:43:21 +02:00
parent 7d372769c3
commit 8a3aecd75a
16 changed files with 1061 additions and 49 deletions

View File

@ -156,4 +156,23 @@ describe.skipIfMocked('Comments', () => {
cleanup(thread);
}
});
for (const content of [' ', '\n\t', '\u200b']) {
test(`blank comment content ${JSON.stringify(content)} rejects everywhere`, async (ctx) => {
const p = page(ctx);
await expectReject(() => p.addCommentThread(content, { x: 0, y: 0 }));
const thread = await p.addCommentThread('parent', { x: 12, y: 12 });
try {
await expectReject(() => thread.reply(content));
const comments = await thread.findComments();
expect(comments.length).toBeGreaterThan(0);
expect(() => {
comments[0].content = content;
}).toThrow();
} finally {
cleanup(thread);
}
});
}
});

View File

@ -222,13 +222,21 @@ describe('Component instances', () => {
const inst = comp.instance();
ctx.board.appendChild(inst);
const mainColor = main.fills?.[0]?.fillColor;
const mainFill = main.fills?.[0];
const mainColor =
typeof mainFill === 'string' ? mainFill : mainFill?.fillColor;
inst.fills = [{ fillColor: '#FF0000', fillOpacity: 1 }];
// The override applied (fill getter normalizes to lowercase).
expect(inst.fills?.[0]?.fillColor?.toLowerCase()).toBe('#ff0000');
const overrideFill = inst.fills?.[0];
const overrideColor =
typeof overrideFill === 'string' ? overrideFill : overrideFill?.fillColor;
expect(overrideColor?.toLowerCase()).toBe('#ff0000');
inst.resetOverrides();
expect(inst.fills?.[0]?.fillColor).toBe(mainColor);
const resetFill = inst.fills?.[0];
expect(
typeof resetFill === 'string' ? resetFill : resetFill?.fillColor,
).toBe(mainColor);
});
test('resetOverrides on a plain shape throws', (ctx) => {
@ -266,6 +274,23 @@ describe('Component instances', () => {
).toThrow();
});
test('swapComponent rejects a component nesting loop', (ctx) => {
const leaf = ctx.penpot.createRectangle();
ctx.board.appendChild(leaf);
const inner = ctx.penpot.library.local.createComponent([leaf]);
const wrapper = ctx.penpot.createBoard();
ctx.board.appendChild(wrapper);
wrapper.appendChild(inner.instance());
const outer = ctx.penpot.library.local.createComponent([wrapper]);
const nested = (outer.mainInstance() as Board).children.find((child) =>
child.isComponentInstance(),
);
expect(nested).toBeDefined();
if (nested) expect(() => nested.swapComponent(outer)).toThrow();
});
test('two instances of one component are independent but share the source', (ctx) => {
const comp = makeComponent(ctx);
const first = comp.instance();

View File

@ -51,7 +51,9 @@ describe('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);
const data = await file
.export('penpot', 'detach-libraries')
.catch(() => null);
if (data) {
expect(data.length).toBeGreaterThan(0);
}

View File

@ -267,14 +267,16 @@ describe('Fills & strokes', () => {
]);
});
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.
test('negative strokeWidth throws', (ctx) => {
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: -3 }];
expect(r.strokes).toHaveLength(1);
expect(typeof r.strokes[0].strokeWidth).toBe('number');
expect(() => {
r.strokes = [{ strokeColor: '#000000', strokeWidth: -3 }];
}).toThrow();
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
expect(() => {
r.strokes[0].strokeWidth = -1;
}).toThrow();
});
test('invalid strokeStyle throws', (ctx) => {

View File

@ -119,4 +119,17 @@ describe('Fonts', () => {
expect(t.fontVariantId).toBe(variant.fontVariantId);
expect(t.fontWeight).toBe(variant.fontWeight);
});
test('a font rejects a variant owned by another font', (ctx) => {
const fonts = ctx.penpot.fonts.all;
const first = fonts[0];
const second = fonts.find((font) => font.fontId !== first.fontId);
if (second) {
const t = text(ctx);
expect(() => first.applyToText(t, second.variants[0])).toThrow();
expect(() =>
first.applyToRange(t.getRange(0, 5), second.variants[0]),
).toThrow();
}
});
});

View File

@ -156,7 +156,7 @@ describe('Interactions', () => {
test('after-delay trigger carries a delay', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const r = board(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
@ -169,7 +169,7 @@ describe('Interactions', () => {
// A zero delay is a valid value (fires immediately), not an error.
test('after-delay accepts a zero delay', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const r = board(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
@ -198,7 +198,7 @@ describe('Interactions', () => {
// "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 r = board(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
@ -218,7 +218,7 @@ describe('Interactions', () => {
// The delay setter accepts zero (fires immediately) as a valid value.
test('delay setter accepts a zero value', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const r = board(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
@ -425,35 +425,205 @@ describe('Interactions', () => {
expect(interaction.trigger).toBe('mouse-enter');
});
test('unknown interaction triggers are rejected', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
expect(() =>
r.addInteraction('unknown-trigger' as unknown as 'click', {
type: 'navigate-to',
destination: dest,
}),
).toThrow();
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
expect(() => {
interaction.trigger = 'unknown-trigger' as unknown as 'click';
}).toThrow();
});
// ---------------------------------------------------------------------------
// 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) => {
test('navigate-to a removed board throws', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
dest.remove();
expect(() =>
r.addInteraction('click', { type: 'navigate-to', destination: dest }),
).not.toThrow();
).toThrow();
});
test('open-url accepts an arbitrary url string', (ctx) => {
test('open-url rejects invalid input and normalizes a bare hostname', (ctx) => {
const r = rect(ctx);
expect(() =>
r.addInteraction('click', {
type: 'open-url',
url: 'not a valid url',
}),
).toThrow();
expect(() =>
r.addInteraction('click', {
type: 'open-url',
url: 'ftp://example.com/file',
}),
).toThrow();
const interaction = r.addInteraction('click', {
type: 'open-url',
url: 'not a valid url',
url: 'example.com/path',
});
expect(interaction.action.type).toBe('open-url');
if (interaction.action.type === 'open-url') {
expect(interaction.action.url).toBe('not a valid url');
expect(interaction.action.url).toBe('http://example.com/path');
}
});
test('after-delay is board-only and initializes its default delay', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
expect(() =>
r.addInteraction('after-delay', {
type: 'navigate-to',
destination: dest,
}),
).toThrow();
const source = board(ctx);
const interaction = source.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
interaction.trigger = 'after-delay';
expect(interaction.delay).toBeCloseTo(600, 0);
expect(() => {
const invalid = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
invalid.trigger = 'after-delay';
}).toThrow();
});
test('after-delay creation rejects invalid delays', (ctx) => {
const dest = board(ctx);
const source = board(ctx);
for (const delay of ['bad', 1.5, -1]) {
expect(() =>
source.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
delay as unknown as number,
),
).toThrow();
}
const interaction = source.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
10,
);
for (const delay of ['bad', 1.5, -1]) {
expect(() => {
interaction.delay = delay as unknown as number;
}).toThrow();
}
});
test('navigation destinations must be eligible boards', (ctx) => {
const source = board(ctx);
const child = rect(ctx);
const rectangle = rect(ctx);
expect(() =>
source.addInteraction('click', {
type: 'navigate-to',
destination: source,
}),
).toThrow();
expect(() =>
child.addInteraction('click', {
type: 'navigate-to',
destination: ctx.board,
}),
).toThrow();
expect(() =>
source.addInteraction('click', {
type: 'navigate-to',
destination: rectangle as unknown as Board,
}),
).toThrow();
});
test('interaction destinations must belong to the current page', async (ctx) => {
const original = ctx.penpot.currentPage;
expect(original).not.toBeNull();
if (!original) return;
const source = rect(ctx);
const localDestination = board(ctx);
const interaction = source.addInteraction('click', {
type: 'navigate-to',
destination: localDestination,
});
const otherPage = ctx.penpot.createPage();
try {
await ctx.penpot.openPage(otherPage);
const otherBoard = ctx.penpot.createBoard();
(otherPage.root as Board).appendChild(otherBoard);
await ctx.penpot.openPage(original);
expect(() =>
source.addInteraction('click', {
type: 'navigate-to',
destination: otherBoard,
}),
).toThrow();
expect(() => {
interaction.action = {
type: 'navigate-to',
destination: otherBoard,
};
}).toThrow();
} finally {
if (ctx.penpot.currentPage?.id !== original.id) {
await ctx.penpot.openPage(original);
}
otherPage.remove();
}
});
test('push animation is rejected for overlay actions and replacements', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
const push = {
type: 'push' as const,
direction: 'left' as const,
duration: 300,
easing: 'linear' as const,
};
expect(() =>
r.addInteraction('click', {
type: 'open-overlay',
destination: overlay,
animation: push,
}),
).toThrow();
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: overlay,
});
expect(() => {
interaction.action = {
type: 'open-overlay',
destination: overlay,
animation: push,
};
}).toThrow();
});
test('several triggers on one shape coexist', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);

View File

@ -82,6 +82,34 @@ describe('Layout', () => {
expect(flex.leftPadding).toBeCloseTo(4.5, 2);
});
test('every flex gap and padding setter rejects negative values', (ctx) => {
const flex = board(ctx).addFlexLayout();
expect(() => {
flex.rowGap = -1;
}).toThrow();
expect(() => {
flex.columnGap = -1;
}).toThrow();
expect(() => {
flex.verticalPadding = -1;
}).toThrow();
expect(() => {
flex.horizontalPadding = -1;
}).toThrow();
expect(() => {
flex.topPadding = -1;
}).toThrow();
expect(() => {
flex.rightPadding = -1;
}).toThrow();
expect(() => {
flex.bottomPadding = -1;
}).toThrow();
expect(() => {
flex.leftPadding = -1;
}).toThrow();
});
// paddingType is "simple" (sides mirrored) or "multiple" (each side independent).
test('paddingType round-trips', (ctx) => {
const flex = board(ctx).addFlexLayout();
@ -185,6 +213,37 @@ describe('Layout', () => {
expect(grid.columns[0].type).toBe('percent');
});
test('grid track creation and replacement reject negative values', (ctx) => {
const grid = board(ctx).addGridLayout();
for (const type of ['fixed', 'percent', 'flex'] as const) {
expect(() => grid.addRow(type, -1)).toThrow();
expect(() => grid.addColumn(type, -1)).toThrow();
}
expect(() =>
grid.addColumn('fixed', 'bad' as unknown as number),
).toThrow();
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
expect(() => grid.addRowAtIndex(0, 'fixed', -1)).toThrow();
expect(() => grid.addColumnAtIndex(0, 'fixed', -1)).toThrow();
expect(() => grid.setRow(0, 'flex', -1)).toThrow();
expect(() => grid.setColumn(0, 'flex', -1)).toThrow();
});
test('retained grid track proxies reject negative values', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('fixed', 10);
grid.addColumn('fixed', 10);
const row = grid.rows[0];
const column = grid.columns[0];
expect(() => {
row.value = -1;
}).toThrow();
expect(() => {
column.value = -1;
}).toThrow();
});
test('removeRow and removeColumn drop tracks', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
@ -238,6 +297,34 @@ describe('Layout', () => {
expect(grid.leftPadding).toBeCloseTo(4.5, 2);
});
test('every grid gap and padding setter rejects negative values', (ctx) => {
const grid = board(ctx).addGridLayout();
expect(() => {
grid.rowGap = -1;
}).toThrow();
expect(() => {
grid.columnGap = -1;
}).toThrow();
expect(() => {
grid.verticalPadding = -1;
}).toThrow();
expect(() => {
grid.horizontalPadding = -1;
}).toThrow();
expect(() => {
grid.topPadding = -1;
}).toThrow();
expect(() => {
grid.rightPadding = -1;
}).toThrow();
expect(() => {
grid.bottomPadding = -1;
}).toThrow();
expect(() => {
grid.leftPadding = -1;
}).toThrow();
});
// paddingType behaves the same as on flex layouts (see issue #10278).
test('paddingType round-trips', (ctx) => {
const grid = board(ctx).addGridLayout();
@ -433,6 +520,29 @@ describe('Layout', () => {
}
});
test('every layout child min and max bound rejects negatives', (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) {
expect(() => {
child.minWidth = -1;
}).toThrow();
expect(() => {
child.maxWidth = -1;
}).toThrow();
expect(() => {
child.minHeight = -1;
}).toThrow();
expect(() => {
child.maxHeight = -1;
}).toThrow();
}
});
// marginType is the child-margin counterpart of a layout's paddingType.
test('marginType round-trips', (ctx) => {
const b = board(ctx);
@ -491,7 +601,7 @@ describe('Layout', () => {
b.resize(300, 200);
b.addFlexLayout();
const found = ctx.penpot.currentPage.getShapeById(b.id) as Board;
const found = ctx.penpot.currentPage!.getShapeById(b.id) as Board;
expect(found).not.toBeNull();
const child = ctx.penpot.createRectangle();
found.appendChild(child);

View File

@ -206,28 +206,85 @@ describe('Library', () => {
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');
const font = ctx.penpot.fonts.all[0];
typo.fontFamily = font.fontFamily;
typo.fontId = font.fontId;
expect(typo.fontFamily).toBe(font.fontFamily);
expect(typo.fontId).toBe(font.fontId);
});
test('typography style members round-trip', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.fontStyle = 'italic';
const font =
ctx.penpot.fonts.all.find((item) =>
item.variants.some((variant) => variant.fontStyle === 'italic'),
) ?? ctx.penpot.fonts.all[0];
typo.setFont(font);
const italic = font.variants.find(
(variant) => variant.fontStyle === 'italic',
);
if (italic) {
typo.fontStyle = italic.fontStyle;
expect(typo.fontStyle).toBe(italic.fontStyle);
expect(typo.fontVariantId).toBe(italic.fontVariantId);
expect(typo.fontWeight).toBe(italic.fontWeight);
}
typo.textTransform = 'uppercase';
typo.fontWeight = '700';
typo.fontVariantId = 'regular';
const variant = font.variants[font.variants.length - 1];
typo.fontWeight = variant.fontWeight;
typo.fontVariantId = variant.fontVariantId;
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(typo.fontWeight).toBe(variant.fontWeight);
expect(typo.fontStyle).toBe(variant.fontStyle);
expect(typo.fontVariantId).toBe(variant.fontVariantId);
expect(typo.lineHeight).toBe('1.5');
expect(typeof typo.letterSpacing).toBe('string');
});
test('typography text values use text validation', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
expect(() => {
typo.fontSize = '2';
}).toThrow();
expect(() => {
typo.fontSize = '12px';
}).toThrow();
expect(() => {
typo.lineHeight = '201';
}).toThrow();
expect(() => {
typo.lineHeight = '12px';
}).toThrow();
expect(() => {
typo.letterSpacing = '12px';
}).toThrow();
expect(() => {
typo.textTransform = 'not-a-transform' as unknown as 'uppercase';
}).toThrow();
});
test('typography font fields require installed fonts and variants', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
expect(() => {
typo.fontId = 'missing-font';
}).toThrow();
expect(() => {
typo.fontFamily = 'Missing Font Family';
}).toThrow();
expect(() => {
typo.fontVariantId = 'missing-variant';
}).toThrow();
const fonts = ctx.penpot.fonts.all;
const first = fonts[0];
const second = fonts.find((font) => font.fontId !== first.fontId);
if (second) {
expect(() => typo.setFont(first, second.variants[0])).toThrow();
}
});
test('typography setFont updates the font', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
const font = ctx.penpot.fonts.all[0];

View File

@ -1,5 +1,6 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board } from '@penpot/plugin-types';
// Pages, selection and flows.
// Most assertions use the active page (`currentPage`) and the scratch board so
@ -16,6 +17,18 @@ describe('Pages', () => {
}
});
test('page names are trimmed and cannot be blank', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
expect(() => {
page.name = ' ';
}).toThrow();
page.name = ' Trimmed page ';
expect(page.name).toBe('Trimmed page');
}
});
test('createPage and openPage activate a new page', async (ctx) => {
const original = ctx.penpot.currentPage;
const page = ctx.penpot.createPage();
@ -206,4 +219,66 @@ describe('Flows', () => {
expect(page.flows.length).toBe(before - 1);
}
});
test('flows require a live, unused board on their page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(() =>
page.createFlow('rect-flow', rect as unknown as Board),
).toThrow();
const removed = ctx.penpot.createBoard();
ctx.board.appendChild(removed);
removed.remove();
expect(() => page.createFlow('removed-flow', removed)).toThrow();
const target = ctx.penpot.createBoard();
ctx.board.appendChild(target);
const first = page.createFlow('first-flow', target);
expect(() => page.createFlow('duplicate-flow', target)).toThrow();
const secondTarget = ctx.penpot.createBoard();
ctx.board.appendChild(secondTarget);
const second = page.createFlow('second-flow', secondTarget);
expect(() => {
second.startingBoard = target;
}).toThrow();
expect(() => {
second.startingBoard = removed;
}).toThrow();
first.remove();
second.remove();
}
});
test('flows reject a starting board from another page', async (ctx) => {
const original = ctx.penpot.currentPage;
expect(original).not.toBeNull();
if (!original) return;
const localBoard = ctx.penpot.createBoard();
ctx.board.appendChild(localBoard);
const flow = original.createFlow('cross-page-flow', localBoard);
const otherPage = ctx.penpot.createPage();
try {
await ctx.penpot.openPage(otherPage);
const otherBoard = ctx.penpot.createBoard();
(otherPage.root as Board).appendChild(otherBoard);
await ctx.penpot.openPage(original);
expect(() => original.createFlow('foreign-flow', otherBoard)).toThrow();
expect(() => {
flow.startingBoard = otherBoard;
}).toThrow();
} finally {
if (ctx.penpot.currentPage?.id !== original.id) {
await ctx.penpot.openPage(original);
}
flow.remove();
otherPage.remove();
}
});
});

View File

@ -58,6 +58,36 @@ describe('Shadows', () => {
expect(shadow.style).toBe('inner-shadow');
expect(shadow.hidden).toBe(true);
});
test('negative shadow blur throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.shadows = [
{
style: 'drop-shadow',
offsetX: 0,
offsetY: 0,
blur: -1,
spread: 0,
color: { color: '#000000', opacity: 1 },
},
];
}).toThrow();
r.shadows = [
{
style: 'drop-shadow',
offsetX: 0,
offsetY: 0,
blur: 1,
spread: 0,
color: { color: '#000000', opacity: 1 },
},
];
expect(() => {
r.shadows[0].blur = -1;
}).toThrow();
});
});
describe('Blur', () => {
@ -78,4 +108,14 @@ describe('Blur', () => {
expect(r.backgroundBlur).toBeDefined();
expect(r.backgroundBlur && r.backgroundBlur.value).toBeCloseTo(5, 0);
});
test('negative layer and background blur throw', (ctx) => {
const r = rect(ctx);
expect(() => {
r.blur = { value: -1 };
}).toThrow();
expect(() => {
r.backgroundBlur = { value: -1 };
}).toThrow();
});
});

View File

@ -1,5 +1,6 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Group } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Shapes & geometry.
@ -254,6 +255,22 @@ describe('Shapes', () => {
expect(r.borderRadiusBottomRight).toBeCloseTo(3.75, 2);
expect(r.borderRadiusBottomLeft).toBeCloseTo(0.5, 2);
});
test('individual corner radii reject negative values', (ctx) => {
const r = rect(ctx);
expect(() => {
r.borderRadiusTopLeft = -1;
}).toThrow();
expect(() => {
r.borderRadiusTopRight = -1;
}).toThrow();
expect(() => {
r.borderRadiusBottomRight = -1;
}).toThrow();
expect(() => {
r.borderRadiusBottomLeft = -1;
}).toThrow();
});
});
describe('Ordering', () => {
@ -365,7 +382,7 @@ describe('Shapes', () => {
expect(group).not.toBeNull();
if (!group) return;
const copy = group.clone();
const copy = group.clone() as Group;
ctx.board.appendChild(copy);
expect(copy.id).not.toBe(group.id);
expect(copy.children).toHaveLength(group.children.length);
@ -447,6 +464,21 @@ describe('Shapes', () => {
}).toThrow();
});
test('export scale must be positive', (ctx) => {
const r = rect(ctx);
expect(() => {
r.exports = [{ type: 'png', scale: 0, suffix: '' }];
}).toThrow();
expect(() => {
r.exports = [{ type: 'png', scale: -1, suffix: '' }];
}).toThrow();
r.exports = [{ type: 'png', scale: 1, suffix: '' }];
expect(() => {
r.exports[0].scale = 0;
}).toThrow();
});
test('resize to zero dimensions throws', (ctx) => {
const r = rect(ctx);
expect(() => {

View File

@ -302,6 +302,60 @@ describe('Text', () => {
}).toThrow();
});
test('text numeric fields require complete numbers within editor bounds', (ctx) => {
const t = text(ctx);
for (const value of ['.', '-', '12px', '2', '1001']) {
expect(() => {
t.fontSize = value;
}).toThrow();
}
for (const value of ['.', '-', '12px', '-201', '201']) {
expect(() => {
t.lineHeight = value;
}).toThrow();
expect(() => {
t.letterSpacing = value;
}).toThrow();
}
});
test('text range numeric fields use the same validation', (ctx) => {
const range = text(ctx, 'Hello').getRange(0, 5);
expect(() => {
range.fontSize = '12px';
}).toThrow();
expect(() => {
range.lineHeight = '12px';
}).toThrow();
expect(() => {
range.letterSpacing = '12px';
}).toThrow();
});
test('text and range font fields reject missing fonts and variants', (ctx) => {
const t = text(ctx, 'Hello');
expect(() => {
t.fontId = 'missing-font';
}).toThrow();
expect(() => {
t.fontFamily = 'Missing Font Family';
}).toThrow();
expect(() => {
t.fontVariantId = 'missing-variant';
}).toThrow();
const range = t.getRange(0, 5);
expect(() => {
range.fontId = 'missing-font';
}).toThrow();
expect(() => {
range.fontFamily = 'Missing Font Family';
}).toThrow();
expect(() => {
range.fontVariantId = 'missing-variant';
}).toThrow();
});
test('invalid align value throws', (ctx) => {
const t = text(ctx);
expect(() => {
@ -314,6 +368,9 @@ describe('Text', () => {
expect(() => {
t.textTransform = 'UPPERCASE' as unknown as 'uppercase';
}).toThrow();
expect(() => {
t.getRange(0, 1).textTransform = 'UPPERCASE' as unknown as 'uppercase';
}).toThrow();
});
test('invalid direction value throws', (ctx) => {

View File

@ -56,6 +56,20 @@ describe('Tokens', () => {
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', () => {
@ -70,6 +84,17 @@ describe('Tokens', () => {
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.
@ -148,15 +173,42 @@ describe('Tokens', () => {
}),
).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') });
theme.group = 'brand';
theme.name = 'dark';
expect(theme.group).toBe('brand');
expect(theme.name).toBe('dark');
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();
@ -191,6 +243,33 @@ describe('Tokens', () => {
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', () => {
@ -315,9 +394,131 @@ describe('Tokens', () => {
});
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'));

View File

@ -247,13 +247,11 @@ describe('Value objects', () => {
}
});
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).
test('negative blur value throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.blur = { value: -5 };
}).not.toThrow();
}).toThrow();
});
});

View File

@ -232,9 +232,12 @@ describe('Variants', () => {
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();
const property = vc.variants!.properties[0];
const target =
vc.variants!.variantComponents()[1] as LibraryVariantComponent;
expect(() =>
instance.switchVariant(0, target.variantProps[property]),
).not.toThrow();
});
// Community report (forum #10700, issue #3): switchVariant on an instance
@ -256,7 +259,12 @@ describe('Variants', () => {
const clonedInstance = cloned.children.find((s) => s.isComponentInstance());
expect(clonedInstance).toBeDefined();
if (clonedInstance) {
expect(() => clonedInstance.switchVariant(0, 'large')).not.toThrow();
const property = vc.variants!.properties[0];
const target =
vc.variants!.variantComponents()[1] as LibraryVariantComponent;
expect(() =>
clonedInstance.switchVariant(0, target.variantProps[property]),
).not.toThrow();
}
});
@ -332,6 +340,14 @@ describe('Variants', () => {
expect(() => ctx.penpot.createVariantFromComponents([])).toThrow();
});
test('createVariantFromComponents requires two distinct components', (ctx) => {
const main = componentMain(ctx);
expect(() => ctx.penpot.createVariantFromComponents([main])).toThrow();
expect(() =>
ctx.penpot.createVariantFromComponents([main, main]),
).toThrow();
});
test('removeProperty out of bounds throws', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants;
@ -341,6 +357,13 @@ describe('Variants', () => {
}
});
test('the last variant property cannot be removed', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants!;
expect(v.properties).toHaveLength(1);
expect(() => v.removeProperty(0)).toThrow();
});
test('renameProperty out of bounds throws', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants;
@ -350,8 +373,88 @@ describe('Variants', () => {
}
});
test('variant property names are trimmed, nonblank, and at most 60 characters', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants!;
expect(() => v.renameProperty(0, ' ')).toThrow();
expect(() => v.renameProperty(0, 'x'.repeat(61))).toThrow();
v.renameProperty(0, ' Size ');
expect(v.properties[0]).toBe('Size');
});
test('setVariantProperty out of bounds throws', async (ctx) => {
const vc = await variantComponent(ctx);
expect(() => vc.setVariantProperty(999, 'large')).toThrow();
});
test('variant values are trimmed and at most 60 characters', async (ctx) => {
const vc = await variantComponent(ctx);
expect(() => vc.setVariantProperty(0, 'x'.repeat(61))).toThrow();
vc.setVariantProperty(0, ' Large ');
expect(vc.variantProps[vc.variants!.properties[0]]).toBe('Large');
});
test('empty variant values remain allowed after trimming', async (ctx) => {
const vc = await variantComponent(ctx);
expect(() => vc.setVariantProperty(0, ' ')).not.toThrow();
expect(vc.variantProps[vc.variants!.properties[0]]).toBe('');
});
test('switchVariant rejects mains, bad positions, and unavailable values', async (ctx) => {
const vc = await variantComponent(ctx);
vc.addVariant();
await waitFor(() => (vc.variants?.variantComponents().length ?? 0) > 1);
const instance = vc.instance();
ctx.board.appendChild(instance);
expect(() => vc.mainInstance().switchVariant(0, 'Value2')).toThrow();
expect(() => instance.switchVariant(999, 'Value2')).toThrow();
expect(() => instance.switchVariant(0, 'missing-value')).toThrow();
});
test('variant combining rejects copies and existing variants', async (ctx) => {
const standard = componentWithMain(ctx);
const other = componentWithMain(ctx);
const copy = other.comp.instance() as Board;
ctx.board.appendChild(copy);
expect(() =>
ctx.penpot.createVariantFromComponents([standard.main, copy]),
).toThrow();
expect(() => standard.main.combineAsVariants([copy.id])).toThrow();
const variant = await variantComponent(ctx);
expect(() =>
ctx.penpot.createVariantFromComponents([
standard.main,
variant.mainInstance() as Board,
]),
).toThrow();
expect(() =>
standard.main.combineAsVariants([variant.mainInstance().id]),
).toThrow();
});
test('variant combining rejects components from another page', async (ctx) => {
const original = ctx.penpot.currentPage;
expect(original).not.toBeNull();
if (!original) return;
const originalMain = componentMain(ctx);
const otherPage = ctx.penpot.createPage();
try {
await ctx.penpot.openPage(otherPage);
const rect = ctx.penpot.createRectangle();
(otherPage.root as Board).appendChild(rect);
const other = ctx.penpot.library.local.createComponent([rect]);
const otherMain = other.mainInstance() as Board;
expect(() =>
ctx.penpot.createVariantFromComponents([originalMain, otherMain]),
).toThrow();
expect(() => otherMain.combineAsVariants([originalMain.id])).toThrow();
} finally {
await ctx.penpot.openPage(original);
otherPage.remove();
}
});
});

View File

@ -1,5 +1,6 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board } from '@penpot/plugin-types';
// Viewport and guides (ruler guides + board guides).
@ -80,6 +81,51 @@ describe('Ruler guides', () => {
page.removeRulerGuide(guide);
}
});
test('page ruler guide rejects a removed board', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
const removed = ctx.penpot.createBoard();
ctx.board.appendChild(removed);
removed.remove();
expect(() => page.addRulerGuide('vertical', 20, removed)).toThrow();
const guide = page.addRulerGuide('vertical', 20);
expect(() => {
guide.board = removed;
}).toThrow();
guide.remove();
}
});
test('ruler guides reject boards from another page', async (ctx) => {
const original = ctx.penpot.currentPage;
expect(original).not.toBeNull();
if (!original) return;
const guide = original.addRulerGuide('vertical', 20);
const otherPage = ctx.penpot.createPage();
try {
await ctx.penpot.openPage(otherPage);
const otherBoard = ctx.penpot.createBoard();
(otherPage.root as Board).appendChild(otherBoard);
await ctx.penpot.openPage(original);
expect(() =>
original.addRulerGuide('vertical', 20, otherBoard),
).toThrow();
expect(() => {
guide.board = otherBoard;
}).toThrow();
} finally {
if (ctx.penpot.currentPage?.id !== original.id) {
await ctx.penpot.openPage(original);
}
guide.remove();
otherPage.remove();
}
});
});
describe('Board guides', () => {
@ -137,4 +183,66 @@ describe('Board guides', () => {
}
}
});
test('board guides allow automatic sizes', (ctx) => {
expect(() => {
ctx.board.guides = [
{
type: 'column',
display: true,
params: { color: { color: '#ff0000', opacity: 1 } },
},
{
type: 'row',
display: true,
params: { color: { color: '#00ff00', opacity: 1 } },
},
{
type: 'square',
display: true,
params: { color: { color: '#0000ff', opacity: 1 } },
},
];
}).not.toThrow();
});
test('board guide sizes must meet their positive minimums', (ctx) => {
expect(() => {
ctx.board.guides = [
{
type: 'column',
display: true,
params: {
color: { color: '#000000', opacity: 1 },
type: 'stretch',
size: 0.5,
gutter: 0,
},
},
];
}).toThrow();
expect(() => {
ctx.board.guides = [
{
type: 'row',
display: true,
params: {
color: { color: '#000000', opacity: 1 },
type: 'stretch',
size: 0.5,
gutter: 0,
},
},
];
}).toThrow();
expect(() => {
ctx.board.guides = [
{
type: 'square',
display: true,
params: { color: { color: '#000000', opacity: 1 }, size: 0.009 },
},
];
}).toThrow();
});
});