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), ).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(); }); });