import { afterAll, afterEach, describe, test, expect, vi } from "vitest"; import { enableMapSet } from "immer"; import { toast } from "@webstudio-is/design-system"; import type { Asset, Instance, Instances, Prop, Props, DataSource, DataSources, Resource, Resources, } from "@webstudio-is/sdk"; import { encodeDataSourceVariable, collectionComponent, coreMetas, elementComponent, blockTemplateComponent, blockComponent, portalComponent, } from "@webstudio-is/sdk"; import type { Project } from "@webstudio-is/project"; import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas"; import { componentMetas } from "@webstudio-is/sdk-components-registry/metas"; import { registerContainers } from "../sync/sync-stores"; import { $registeredComponentMetas } from "../nano-states"; import { $dataSources, $assets, $instances, $pages, $project, $props, $resources, } from "~/shared/sync/data-stores"; import { instanceText } from "./plugin-instance"; import { createDefaultPages } from "@webstudio-is/project-build"; import { selectInstance, selectInstances } from "~/shared/nano-states"; import { $allSelectedInstanceSelectors } from "~/shared/nano-states"; import { $selectedPageId } from "../nano-states/pages"; import * as instanceMutationUtils from "../instance-utils/mutation"; import { expectSlotTreeIntegrity, expectSlotsShareFragment, } from "../slot-test-utils"; import { pasteHandled } from "./copy-paste"; import { __testing__ as builderApiTesting } from "../builder-api"; import { $externalContentRoots } from "../external-content-mutations"; afterAll(builderApiTesting.useLocalApi()); afterEach(() => vi.restoreAllMocks()); const expectString = expect.any(String) as unknown as string; enableMapSet(); registerContainers(); $registeredComponentMetas.set( new Map(Object.entries({ ...baseComponentMetas, ...coreMetas })) ); $project.set({ id: "my-project" } as Project); $pages.set( createDefaultPages({ homePageId: "home-page", rootInstanceId: "body0", }) ); $selectedPageId.set("home-page"); const setPageRoot = (rootInstanceId: Instance["id"]) => { $pages.set( createDefaultPages({ homePageId: "home-page", rootInstanceId, }) ); $selectedPageId.set("home-page"); }; const createInstance = ( id: Instance["id"], component: string, children: Instance["children"] ): Instance => { return { type: "instance", id, component, children }; }; const getIdValuePair = (item: T) => [item.id, item] as const; const toMap = (list: T[]) => new Map(list.map(getIdValuePair)); test("rejects historical Form clipboard data before importing or inserting", async () => { const importAssets = vi.spyOn(builderApiTesting.api, "importAssets"); const before = structuredClone($instances.get()); const clipboardData = JSON.stringify({ "@webstudio/instance/v0.1": { instanceSelector: ["form", "body0"], children: [{ type: "id", value: "form" }], instances: [createInstance("form", "Form", [])], assets: [], dataSources: [], resources: [], props: [], breakpoints: [], styleSourceSelections: [], styleSources: [], styles: [], }, }); expect(await instanceText.onPaste?.(clipboardData)).toMatchObject({ success: false, error: expect.stringMatching(/Copy it again/), }); expect(importAssets).not.toHaveBeenCalled(); expect($instances.get()).toEqual(before); }); const getMapDifference = >( left: Type, right: Type ): Type => { const leftSet = new Set(left.keys()); const rightSet = new Set(right.keys()); const difference = new Map() as Type; for (const [key, value] of left) { if (rightSet.has(key) === false) { difference.set(key, value); } } for (const [key, value] of right) { if (leftSet.has(key) === false) { difference.set(key, value); } } return difference; }; describe("copy and cut guards", () => { test.each([false, true])( "does not delete an edit queued during clipboard completion (multiple: %s)", async (multiple) => { setPageRoot("body0"); const roots = multiple ? ["box1", "box2"] : ["box1"]; $instances.set( toMap([ createInstance( "body0", "Body", roots.map((value) => ({ type: "id", value })) ), ...roots.map((id) => createInstance(id, "Box", [{ type: "text", value: "Before" }]) ), ]) ); selectInstances(roots.map((id) => [id, "body0"])); const notify = vi .spyOn(builderApiTesting.api.toast, "info") .mockImplementation(() => {}); let edited = false; await instanceText.onCut(async () => { queueMicrotask(() => queueMicrotask(() => { // Like an instance mutation, an edit cannot target a deleted instance. if ($instances.get().has("box1") === false) { return; } const instances = new Map($instances.get()); instances.set( "box1", createInstance("box1", "Box", [ { type: "text", value: "New edit" }, ]) ); $instances.set(instances); edited = true; }) ); return true; }); // Either cut finished first, or the edit must cancel the whole deletion. expect($instances.get().get("body0")?.children).toEqual( edited ? roots.map((value) => ({ type: "id", value })) : [] ); expect(notify).toHaveBeenCalledTimes(edited ? 1 : 0); if (edited) { expect($instances.get().get("box1")?.children).toEqual([ { type: "text", value: "New edit" }, ]); } } ); test.each( [false, true].flatMap((multiple) => ["content", "property", "project"].map((change) => ({ multiple, change })) ) )( "preserves pending cut after $change change (multiple: $multiple)", async ({ multiple, change }) => { setPageRoot("body0"); const project = $project.get(); const roots = multiple ? ["box1", "box2"] : ["box1"]; $instances.set( toMap([ createInstance( "body0", "Body", roots.map((value) => ({ type: "id", value })) ), ...roots.map((id) => createInstance(id, "Box", [{ type: "text", value: "Before" }]) ), ]) ); $props.set(new Map()); selectInstances(roots.map((id) => [id, "body0"])); const notify = vi .spyOn(builderApiTesting.api.toast, "info") .mockImplementation(() => {}); let copied: string | undefined; try { await instanceText.onCut(async (data) => { copied = data; if (change === "content") { const instances = new Map($instances.get()); instances.set( "box1", createInstance("box1", "Box", [ { type: "text", value: "New edit" }, ]) ); $instances.set(instances); } if (change === "property") { $props.set( new Map([ [ "prop", { id: "prop", instanceId: "box1", name: "title", type: "string", value: "New edit", }, ], ]) ); } if (change === "project") { $project.set({ id: "another-project" } as Project); } return true; }); const transfer = JSON.parse(copied ?? ""); const fragment = multiple ? transfer["@webstudio/instances/v0.1"].fragment : transfer["@webstudio/instance/v0.1"]; expect( fragment.instances.find( (instance: Instance) => instance.id === "box1" ).children ).toEqual([{ type: "text", value: "Before" }]); expect($instances.get().get("body0")?.children).toEqual( roots.map((value) => ({ type: "id", value })) ); for (const id of roots) { expect($instances.get().has(id)).toBe(true); } if (change === "content") { expect($instances.get().get("box1")?.children).toEqual([ { type: "text", value: "New edit" }, ]); } if (change === "property") { expect($props.get().get("prop")?.value).toBe("New edit"); } expect(notify).toHaveBeenCalledTimes(1); } finally { $project.set(project); $props.set(new Map()); } } ); test("does not copy without a selected instance", async () => { selectInstance(undefined); expect(instanceText.onCopy?.()).toBeUndefined(); }); test("does not cut without a selected instance", async () => { selectInstance(undefined); expect(instanceText.onCut?.(async () => true)).toBeUndefined(); }); test("does not copy or cut the selected root instance", async () => { const instances: Instances = toMap([ createInstance("body0", "Body", [{ type: "id", value: "box1" }]), createInstance("box1", "Box", []), ] satisfies Instance[]); $instances.set(instances); selectInstance(["body0"]); await expect(instanceText.onCopy?.()).resolves.toBeUndefined(); await expect( instanceText.onCut?.(async () => true) ).resolves.toBeUndefined(); expect($instances.get()).toEqual(instances); }); test("copies multiple selected roots into one combined fragment", async () => { $instances.set( toMap([ createInstance("body0", "Body", [ { type: "id", value: "box1" }, { type: "id", value: "box2" }, ]), createInstance("box1", "Box", []), createInstance("box2", "Box", []), ] satisfies Instance[]) ); selectInstances([ ["box1", "body0"], ["box2", "body0"], ]); const clipboardData = await instanceText.onCopy?.(); expect(JSON.parse(clipboardData ?? "")).toMatchObject({ "@webstudio/instances/v0.1": { sourceOrigin: window.location.origin, rootInstanceIds: ["box1", "box2"], fragment: { children: [ { type: "id", value: "box1" }, { type: "id", value: "box2" }, ], instances: [ { id: "box1", type: "instance", component: "Box", children: [] }, { id: "box2", type: "instance", component: "Box", children: [] }, ], }, }, }); }); test("copies and pastes one rendered Collection Content Block with its resolved direct MDX source", async () => { setPageRoot("body"); $instances.set( toMap([ createInstance("body", "Body", [{ type: "id", value: "collection" }]), createInstance("collection", collectionComponent, [ { type: "id", value: "item" }, ]), createInstance("item", "Box", [{ type: "id", value: "block" }]), createInstance("block", blockComponent, [ { type: "id", value: "templates" }, { type: "id", value: "mdx-heading" }, ]), createInstance("templates", blockTemplateComponent, []), createInstance("mdx-heading", elementComponent, [ { type: "text", value: "Loaded from MDX" }, ]), ]) ); $dataSources.set( toMap([ { id: "item-source", scopeInstanceId: "collection", type: "parameter", name: "item", }, ]) ); $props.set( toMap([ { id: "collection-data", instanceId: "collection", name: "data", type: "json", value: [{ mdx: "post" }], }, { id: "collection-item", instanceId: "collection", name: "item", type: "parameter", value: "item-source", }, { id: "block-source", instanceId: "block", name: "src", type: "expression", value: `${encodeDataSourceVariable("item-source")}.mdx`, }, ] satisfies Prop[]) ); $assets.set( toMap([ { id: "post", projectId: "my-project", type: "file", format: "mdx", name: "post.mdx", size: 0, createdAt: "2026-01-01T00:00:00.000Z", meta: {}, }, ]) ); const renderScope = '["block","item","collection[0]","collection","body"]'; $externalContentRoots.set( new Map([ [ "post-root", { sourceBlockInstanceId: "block", sourceRenderScope: renderScope, blockInstanceId: "runtime-block", renderScope, instanceIds: new Set(), mutationRevision: 0, identity: { blockInstanceId: "block", assetId: "post", revision: "revision", contentRef: "post.mdx", format: "mdx", renderScope, }, }, ], ]) ); selectInstance(["item", "collection[0]", "collection", "body"]); const clipboardData = await instanceText.onCopy?.(); const fragment = JSON.parse(clipboardData ?? "")[ "@webstudio/instance/v0.1" ]; expect(fragment.props).toContainEqual({ id: "block-source", instanceId: "block", name: "src", type: "asset", value: "post", }); expect(fragment.assets).toContainEqual( expect.objectContaining({ id: "post", format: "mdx" }) ); expect(fragment.assetPaths).toEqual({ post: "post.mdx" }); expect(fragment.instances).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "block" }), expect.objectContaining({ id: "item" }), expect.objectContaining({ id: "templates" }), ]) ); expect(fragment.instances).not.toEqual( expect.arrayContaining([expect.objectContaining({ id: "mdx-heading" })]) ); const importAssets = vi .spyOn(builderApiTesting.api, "importAssets") .mockResolvedValue(new Map([["post", $assets.get().get("post")!]])); $instances.set( toMap([createInstance("target-body", "Body", [])] satisfies Instance[]) ); $props.set(new Map()); $dataSources.set(new Map()); setPageRoot("target-body"); selectInstance(["target-body"]); expect(await instanceText.onPaste?.(clipboardData ?? "")).toEqual( pasteHandled ); const pastedBlock = Array.from($instances.get().values()).find( (instance) => instance.component === blockComponent ); expect(pastedBlock).toBeDefined(); expect( Array.from($props.get().values()).find( (prop) => prop.instanceId === pastedBlock?.id && prop.name === "src" ) ).toEqual(expect.objectContaining({ type: "asset", value: "post" })); expect( Array.from($instances.get().values()).some( (instance) => instance.id === "mdx-heading" ) ).toBe(false); importAssets.mockRestore(); $externalContentRoots.set(new Map()); $props.set(new Map()); $assets.set(new Map()); setPageRoot("body0"); }); test("pastes multiple copied roots in order and selects pasted roots", async () => { $instances.set( toMap([ createInstance("body0", "Body", [ { type: "id", value: "box1" }, { type: "id", value: "box2" }, ]), createInstance("box1", "Box", [{ type: "id", value: "child" }]), createInstance("child", "Box", []), createInstance("box2", "Box", []), ] satisfies Instance[]) ); selectInstances([ ["box1", "body0"], ["box2", "body0"], ]); const clipboardData = (await instanceText.onCopy?.()) ?? ""; selectInstance(["body0"]); const result = await instanceText.onPaste?.(clipboardData); expect(result).toEqual(pasteHandled); const bodyChildren = $instances.get().get("body0")?.children; const pastedRootIds = bodyChildren?.slice(2).map((child) => child.value); expect(bodyChildren).toEqual([ { type: "id", value: "box1" }, { type: "id", value: "box2" }, { type: "id", value: expect.any(String) }, { type: "id", value: expect.any(String) }, ]); expect(pastedRootIds?.[0]).not.toBe("box1"); expect(pastedRootIds?.[1]).not.toBe("box2"); expect($allSelectedInstanceSelectors.get()).toEqual([ [pastedRootIds?.[0], "body0"], [pastedRootIds?.[1], "body0"], ]); }); test("transfers assets from another deployment before inserting", async () => { $instances.set( toMap([createInstance("body0", "Body", [])] satisfies Instance[]) ); $assets.set(new Map()); selectInstance(["body0"]); const sourceAsset = { id: "source-asset", projectId: "source-project", name: "hero.png", type: "image", size: 128, format: "png", createdAt: "2026-01-01T00:00:00.000Z", description: null, meta: { width: 1200, height: 800 }, } satisfies Asset; const importedAsset = { ...sourceAsset, id: "imported-asset", projectId: "my-project", } satisfies Asset; const importAssets = vi .spyOn(builderApiTesting.api, "importAssets") .mockRejectedValueOnce(new Error("Source is unavailable")) .mockImplementation( async () => new Map([[sourceAsset.id, importedAsset]]) ); const clipboardData = JSON.stringify({ "@webstudio/instance/v0.1": { sourceOrigin: "https://source.example.com", instanceSelector: ["image", "source-body"], children: [{ type: "id", value: "image" }], instances: [createInstance("image", "Image", [])], assets: [sourceAsset], dataSources: [], resources: [], props: [ { id: "image-src", instanceId: "image", name: "src", type: "asset", value: sourceAsset.id, }, ], breakpoints: [], styleSourceSelections: [], styleSources: [], styles: [], }, }); expect(await instanceText.onPaste?.(clipboardData)).toEqual({ success: false, error: "Could not transfer assets from the source Webstudio deployment. Make sure it is reachable and try again.", }); expect($instances.get().get("body0")?.children).toEqual([]); expect(await instanceText.onPaste?.(clipboardData)).toEqual(pasteHandled); expect($instances.get().get("body0")?.children).toEqual([ { type: "id", value: expect.any(String) }, ]); expect(importAssets).toHaveBeenLastCalledWith("my-project", [ { asset: sourceAsset, url: "https://source.example.com/cgi/image/hero.png?format=raw", }, ]); expect(Array.from($props.get().values())).toEqual([ expect.objectContaining({ type: "asset", value: importedAsset.id }), ]); }); test("pastes a legacy invalid subtree with a warning", async () => { $instances.set( toMap([ createInstance("body0", "Body", [ { type: "id", value: "legacy-button" }, ]), { ...createInstance("legacy-button", elementComponent, [ { type: "id", value: "legacy-heading" }, ]), tag: "button", }, { ...createInstance("legacy-heading", elementComponent, []), tag: "h3", }, ] satisfies Instance[]) ); selectInstance(["legacy-button", "body0"]); const clipboardData = (await instanceText.onCopy?.()) ?? ""; selectInstance(["body0"]); const warn = vi .spyOn(builderApiTesting.api.toast, "warn") .mockImplementation(() => {}); expect(await instanceText.onPaste?.(clipboardData)).toEqual(pasteHandled); expect($instances.get().get("body0")?.children).toHaveLength(2); expect(warn).toHaveBeenCalledWith( "Pasted with warning: Placing

element inside a