mirror of
https://github.com/penpot/penpot.git
synced 2026-09-23 20:36:15 +00:00
* ✨ 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
322 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|