From 8a3aecd75ac2767d2da24992c50051e992ea20d3 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 9 Sep 2026 15:43:21 +0200 Subject: [PATCH] :sparkles: Add end-to-end tests for plugins validation --- .../src/tests/comments.test.ts | 19 ++ .../src/tests/components.test.ts | 31 ++- .../src/tests/file.test.ts | 4 +- .../src/tests/fills-strokes.test.ts | 16 +- .../src/tests/fonts.test.ts | 13 ++ .../src/tests/interactions.test.ts | 196 ++++++++++++++-- .../src/tests/layout.test.ts | 112 +++++++++- .../src/tests/library.test.ts | 79 ++++++- .../src/tests/pages.test.ts | 75 +++++++ .../src/tests/shadows-blur.test.ts | 40 ++++ .../src/tests/shapes-geometry.test.ts | 34 ++- .../src/tests/text.test.ts | 57 +++++ .../src/tests/tokens.test.ts | 209 +++++++++++++++++- .../src/tests/value-objects.test.ts | 6 +- .../src/tests/variants.test.ts | 111 +++++++++- .../src/tests/viewport-guides.test.ts | 108 +++++++++ 16 files changed, 1061 insertions(+), 49 deletions(-) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts index ecf00c5255..cc3f471295 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/comments.test.ts @@ -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); + } + }); + } }); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts index 367eaf8267..ecdd6c5118 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts @@ -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(); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/file.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/file.test.ts index 9bff861b82..ccb2b68657 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/file.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/file.test.ts @@ -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); } diff --git a/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts index 8a01314f53..685d253665 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts @@ -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) => { diff --git a/plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts index 7340ddfa94..af1ef08b33 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/fonts.test.ts @@ -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(); + } + }); }); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts index 6f01f8ef0f..570bdaa35a 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/interactions.test.ts @@ -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); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts index 867132714e..37f9a735c2 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts @@ -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); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts index 32da4f5a59..1aacbe7780 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts @@ -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]; diff --git a/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts index 94f2c3842c..7c546a9e6a 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts @@ -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(); + } + }); }); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts index 7ede39799a..713862b561 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/shadows-blur.test.ts @@ -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(); + }); }); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts index c31ef3912a..f337796938 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts @@ -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(() => { diff --git a/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts index 3dbaaf61c9..6b8ed2e448 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts @@ -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) => { diff --git a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts index 0b3ae9be6c..9a3f65d0d0 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts @@ -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')); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts index 88f3eb94c8..f552b2e0fc 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts @@ -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(); }); }); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts index d1e2be5a5a..451e75f91b 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts @@ -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(); + } + }); }); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts index 2680c3991e..4feec6e14a 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/viewport-guides.test.ts @@ -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(); + }); });