Alonso Torres cbb9e5d971
Add end-to-end tests for plugins validation (#11587)
*  Add missing plugin data validations

*  Add migration to fix the new schema validations

*  Add end-to-end tests for plugins validation

* 🐛 Fix unit tests after merge

* 🐛 Change normalize behavior
2026-09-23 19:59:22 +02:00

322 lines
11 KiB
TypeScript

import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import { waitFor } from './wait';
import type { Board, Shape } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
const COPY_STRUCTURE_ERROR = 'Cannot change the structure of a component copy';
// Component instances and the ShapeBase component methods.
// A component is built from a rectangle and instantiated; the instance exposes
// the component predicates and navigation methods.
function makeComponent(ctx: TestContext) {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
return ctx.penpot.library.local.createComponent([rect]);
}
function instanceOf(ctx: TestContext): Shape {
const comp = makeComponent(ctx);
const inst = comp.instance();
ctx.board.appendChild(inst);
return inst;
}
function nestedComponentMain(ctx: TestContext): Board {
const nested = makeComponent(ctx).instance();
const host = ctx.penpot.createBoard();
ctx.board.appendChild(host);
host.appendChild(nested);
return ctx.penpot.library.local
.createComponent([host])
.mainInstance() as Board;
}
function boardComponentMainWithChildren(ctx: TestContext): Board {
const first = ctx.penpot.createRectangle();
const second = ctx.penpot.createRectangle();
const host = ctx.penpot.createBoard();
ctx.board.appendChild(host);
host.appendChild(first);
host.appendChild(second);
return ctx.penpot.library.local
.createComponent([host])
.mainInstance() as Board;
}
function componentMainFromClonedNestedMain(ctx: TestContext): Board {
const cloned = nestedComponentMain(ctx).clone();
return ctx.penpot.library.local
.createComponent([cloned])
.mainInstance() as Board;
}
describe('Component instances', () => {
test('component predicates identify an instance', (ctx) => {
const inst = instanceOf(ctx);
expect(inst.isComponentInstance()).toBeTruthy();
expect(inst.isComponentRoot()).toBeTruthy();
expect(inst.isComponentHead()).toBeTruthy();
// A fresh instance is a copy, not the main instance.
expect(inst.isComponentMainInstance()).toBeFalsy();
expect(inst.isComponentCopyInstance()).toBeTruthy();
expect(inst.isVariantHead()).toBeFalsy();
});
test('component navigation methods return shapes', (ctx) => {
const inst = instanceOf(ctx);
expect(inst.componentRoot()).toBeDefined();
expect(inst.componentHead()).toBeDefined();
expect(inst.componentRefShape()).toBeDefined();
});
// Community report (forum #10700, issue #8): cloning a component's main
// instance was said to yield a shape with null type/name that appendChild
// silently drops. Did not reproduce (clone attaches to the same parent and
// can be re-parented); kept as a regression pin.
test('cloning a component main instance yields a valid shape', (ctx) => {
const comp = makeComponent(ctx);
const main = comp.mainInstance();
const copy = main.clone();
expect(copy.type).not.toBeNull();
expect(copy.type).toBe(main.type);
expect(copy.name).not.toBeNull();
expect(copy.id).not.toBe(main.id);
const target = ctx.penpot.createBoard();
ctx.board.appendChild(target);
target.resize(200, 200);
const before = target.children.length;
target.appendChild(copy);
expect(target.children.length).toBe(before + 1);
expect(target.children.some((s) => s.id === copy.id)).toBe(true);
});
test('appendChild rejects moving children out of a cloned main instance', (ctx) => {
const main = nestedComponentMain(ctx);
const cloned = main.clone() as Board;
const child = cloned.children[0];
expect(child).toBeDefined();
expect(() => ctx.board.appendChild(child)).toThrow(COPY_STRUCTURE_ERROR);
const errors = ctx.penpot.currentFile?.validate() ?? [];
expect(errors.map((e) => e.code)).toEqual([]);
});
test('insertChild rejects moving children out of a cloned main instance', (ctx) => {
const main = nestedComponentMain(ctx);
const cloned = main.clone() as Board;
const child = cloned.children[0];
const target = ctx.penpot.createBoard();
ctx.board.appendChild(target);
expect(child).toBeDefined();
expect(() => target.insertChild(0, child)).toThrow(COPY_STRUCTURE_ERROR);
const errors = ctx.penpot.currentFile?.validate() ?? [];
expect(errors.map((e) => e.code)).toEqual([]);
});
test('children assignment rejects reordering a cloned main instance', (ctx) => {
const main = nestedComponentMain(ctx);
const cloned = main.clone() as Board;
expect(() => {
cloned.children = [...cloned.children];
}).toThrow(COPY_STRUCTURE_ERROR);
const errors = ctx.penpot.currentFile?.validate() ?? [];
expect(errors.map((e) => e.code)).toEqual([]);
});
test('setParentIndex rejects reordering children inside a component copy', (ctx) => {
const main = boardComponentMainWithChildren(ctx);
const copy = main.component()?.instance() as Board | undefined;
expect(copy).toBeDefined();
if (!copy) return;
ctx.board.appendChild(copy);
const child = copy.children[1];
expect(child).toBeDefined();
expect(() => child.setParentIndex(0)).toThrow(COPY_STRUCTURE_ERROR);
const errors = ctx.penpot.currentFile?.validate() ?? [];
expect(errors.map((e) => e.code)).toEqual([]);
});
test('group rejects children inside a cloned main instance', (ctx) => {
const main = nestedComponentMain(ctx);
const cloned = main.clone() as Board;
const child = cloned.children[0];
expect(child).toBeDefined();
expect(() => ctx.penpot.group([child])).toThrow(COPY_STRUCTURE_ERROR);
const errors = ctx.penpot.currentFile?.validate() ?? [];
expect(errors.map((e) => e.code)).toEqual([]);
});
test('remove hides children inside a cloned main instance', (ctx) => {
const main = nestedComponentMain(ctx);
const cloned = main.clone() as Board;
const child = cloned.children[0];
expect(child).toBeDefined();
child.remove();
expect(child.hidden).toBe(true);
const errors = ctx.penpot.currentFile?.validate() ?? [];
expect(errors.map((e) => e.code)).toEqual([]);
});
test('detaching a cloned main instance makes child reparenting safe', (ctx) => {
const main = nestedComponentMain(ctx);
const cloned = main.clone() as Board;
cloned.detach();
const child = cloned.children[0];
expect(child).toBeDefined();
ctx.board.appendChild(child);
const errors = ctx.penpot.currentFile?.validate() ?? [];
expect(errors.map((e) => e.code)).toEqual([]);
});
test('cloned nested main instances can be used as variant component sources', async (ctx) => {
const mainA = componentMainFromClonedNestedMain(ctx);
const mainB = componentMainFromClonedNestedMain(ctx);
const container = ctx.penpot.createVariantFromComponents([mainA, mainB]);
await waitFor(
() => (container.variants?.variantComponents().length ?? 0) >= 2,
);
const errors = ctx.penpot.currentFile?.validate() ?? [];
expect(errors.map((e) => e.code)).toEqual([]);
});
test('component() returns the library component', (ctx) => {
const inst = instanceOf(ctx);
const comp = inst.component();
expect(comp).not.toBeNull();
if (comp) {
expect(typeof comp.id).toBe('string');
}
});
test('detach turns an instance into a basic shape', (ctx) => {
const inst = instanceOf(ctx);
inst.detach();
expect(inst.isComponentInstance()).toBeFalsy();
});
test('swapComponent replaces the instance component', (ctx) => {
const inst = instanceOf(ctx);
const other = makeComponent(ctx);
inst.swapComponent(other);
const comp = inst.component();
expect(comp).not.toBeNull();
if (comp) {
expect(comp.id).toBe(other.id);
}
});
test('resetOverrides restores a copy to its main component', (ctx) => {
const comp = makeComponent(ctx);
const main = comp.mainInstance();
const inst = comp.instance();
ctx.board.appendChild(inst);
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).
const overrideFill = inst.fills?.[0];
const overrideColor =
typeof overrideFill === 'string' ? overrideFill : overrideFill?.fillColor;
expect(overrideColor?.toLowerCase()).toBe('#ff0000');
inst.resetOverrides();
const resetFill = inst.fills?.[0];
expect(
typeof resetFill === 'string' ? resetFill : resetFill?.fillColor,
).toBe(mainColor);
});
test('resetOverrides on a plain shape throws', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(() => rect.resetOverrides()).toThrow();
});
// ---------------------------------------------------------------------------
// Edge cases. "fail" tests exercise the component methods on shapes
// that are not component instances (documented null/self returns, invalid
// swap target); the "success" test checks instance independence.
// ---------------------------------------------------------------------------
test('component() on a plain shape returns null', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(rect.component()).toBeNull();
});
test('componentRoot() on a plain shape returns null', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
// componentRoot (like component(), componentHead(), componentRefShape())
// is null for a shape that is not part of any component. The d.ts
// "returns itself" note applies to a shape that IS the root of a component.
expect(rect.componentRoot()).toBeNull();
});
test('swapComponent with a non-component target throws', (ctx) => {
const inst = instanceOf(ctx);
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(() =>
inst.swapComponent(rect as unknown as ReturnType<typeof makeComponent>),
).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();
const second = comp.instance();
ctx.board.appendChild(first);
ctx.board.appendChild(second);
first.name = 'first';
second.name = 'second';
expect(first.id).not.toBe(second.id);
expect(first.name).toBe('first');
expect(second.name).toBe('second');
const c1 = first.component();
const c2 = second.component();
expect(c1).not.toBeNull();
expect(c2).not.toBeNull();
if (c1 && c2) {
expect(c1.id).toBe(c2.id);
}
});
test('detaching a copy breaks the component link', (ctx) => {
const inst = instanceOf(ctx);
inst.detach();
expect(inst.isComponentInstance()).toBeFalsy();
});
});