Files
webstudio/apps/builder/app/shared/copy-paste/plugin-instance.test.ts
Oleg Isonen 8718feb465 test: simplify browser testing infrastructure (#6271)
## Summary

Unifies frontend testing around three intentional boundaries:

- Vitest Node projects for pure and server tests
- Vitest Browser Mode with Playwright/Chromium for DOM and component
tests
- Playwright Test for Builder end-to-end workflows

The Builder no longer uses jsdom. Ordinary E2E workflows use an
automatically managed isolated `page` fixture; explicit contexts remain
only for simultaneous identities and suite-scoped setup.

## Technical choices

- Split Builder tests into one Node project and independent browser
projects so each Chromium/Vite process has a bounded workload.
- Added shared Browser Mode configuration for the affected SDK React
packages.
- Preserve the Builder's production top-window bridge ownership; Browser
Mode uses explicit test-only installation seams.
- Centralized Playwright context options and kept action/navigation
timeouts bounded.
- Validate Playwright filters before Docker, migrations, browser
installation, or Builder builds start.
- Preserve the first failed E2E attempt's report, traces, screenshots,
and videos when CI retries a shard.
- Split static checks, typechecks, CLI tests, core package tests,
browser package tests, and Builder projects into parallel CI jobs with
five-minute hard ceilings.
- Distribute Builder E2E tests across ten isolated shards with two
workers; parallel suites create unique per-worker setup data.
- Reuse color contrast DOM/canvas samples so the exhaustive theme sweep
takes about four seconds instead of timing out at 30 seconds.
- The removed cost is jsdom behavior drift, oversized serial check jobs,
and unnecessary E2E startup for empty selections. Real browser startup
and E2E environment setup remain.

## Todo

- [x] Split Builder pure/server tests from browser/component tests.
- [x] Migrate Builder DOM tests from jsdom to Vitest Browser Mode.
- [x] Migrate affected SDK React DOM tests to the shared Browser Mode
setup.
- [x] Remove jsdom annotations, dependencies, and compatibility setup.
- [x] Replace ordinary manual E2E contexts with the isolated `page`
fixture.
- [x] Keep explicit contexts only for concurrent identities and
`beforeAll` setup.
- [x] Preserve setup authentication isolation from workflow pages.
- [x] Add bounded Playwright action and navigation timeouts.
- [x] Preserve first-attempt E2E failure evidence across CI retry.
- [x] Reject empty E2E selections before expensive environment setup.
- [x] Parallelize repository checks into bounded CI shards.
- [x] Isolate parallel E2E setup data across workers.
- [x] Remove the color contrast sweep bottleneck.
- [x] Update E2E contributor documentation.
- [x] Review product documentation impact; no user-facing product docs
are affected.
- [x] Run the full repository tests and typechecks.
- [x] Run formatting, lint, package-boundary, generated-file, and
fixture checks.
- [x] Complete final correctness and simplification review.

## Verification

- `pnpm --filter @webstudio-is/builder test`
- `pnpm -r typecheck`
- `pnpm lint`
- `pnpm check:package-boundaries`
- `pnpm check:generated-api`
- `pnpm check:generated-docs`
- `pnpm fixtures`
- Design-system: 222 tests pass locally.
- Builder: all seven Vitest projects pass locally.
- The formerly slow E2E shard passes locally in 1m55s.
- Full database-backed Builder E2E execution passes in CI.
- All CI jobs pass; the slowest completed in 4m58s.
- No fixture inputs/outputs or generated files changed.

Ref #6270
2026-08-31 17:17:28 +02:00

1948 lines
61 KiB
TypeScript

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 = <T extends { id: string }>(item: T) =>
[item.id, item] as const;
const toMap = <T extends { id: string }>(list: T[]) =>
new Map(list.map(getIdValuePair));
const getMapDifference = <Type extends Map<unknown, unknown>>(
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("does not copy without a selected instance", () => {
selectInstance(undefined);
expect(instanceText.onCopy?.()).toBeUndefined();
});
test("does not cut without a selected instance", () => {
selectInstance(undefined);
expect(instanceText.onCut?.()).toBeUndefined();
});
test("does not copy or cut the selected root instance", () => {
const instances: Instances = toMap([
createInstance("body0", "Body", [{ type: "id", value: "box1" }]),
createInstance("box1", "Box", []),
] satisfies Instance[]);
$instances.set(instances);
selectInstance(["body0"]);
expect(instanceText.onCopy?.()).toBeUndefined();
expect(instanceText.onCut?.()).toBeUndefined();
expect($instances.get()).toEqual(instances);
});
test("copies multiple selected roots into one combined fragment", () => {
$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 = 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<string>(),
mutationRevision: 0,
identity: {
blockInstanceId: "block",
assetId: "post",
revision: "revision",
contentRef: "post.mdx",
format: "mdx",
renderScope,
},
},
],
])
);
selectInstance(["item", "collection[0]", "collection", "body"]);
const clipboardData = 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 = 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 = 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 <h3> element inside a <button> violates HTML spec."
);
});
test("sanitizes multi-root clipboard root ids before paste", 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 = JSON.parse(instanceText.onCopy?.() ?? "");
clipboardData["@webstudio/instances/v0.1"].rootInstanceIds = [
"box1",
"child",
"missing",
"box1",
];
selectInstance(["body0"]);
expect(await instanceText.onPaste?.(JSON.stringify(clipboardData))).toEqual(
pasteHandled
);
const bodyChildren = $instances.get().get("body0")?.children;
expect(bodyChildren).toEqual([
{ type: "id", value: "box1" },
{ type: "id", value: "box2" },
{ type: "id", value: expect.any(String) },
]);
clipboardData["@webstudio/instances/v0.1"].rootInstanceIds = ["missing"];
expect(await instanceText.onPaste?.(JSON.stringify(clipboardData))).toEqual(
pasteHandled
);
expect($instances.get().get("body0")?.children).toEqual(bodyChildren);
});
test("writes only copyable roots when multi-copy skips selected roots", () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "box1" },
{ type: "id", value: "template" },
]),
createInstance("box1", "Box", []),
createInstance("template", blockTemplateComponent, []),
] satisfies Instance[])
);
selectInstances([
["template", "body0"],
["box1", "body0"],
]);
const clipboardData = instanceText.onCopy?.();
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
"@webstudio/instances/v0.1": {
rootInstanceIds: ["box1"],
},
});
});
test("does not copy when no multi-selected root is copyable", () => {
const instances: Instances = toMap([
createInstance("body0", "Body", [
{ type: "id", value: "template1" },
{ type: "id", value: "template2" },
]),
createInstance("template1", blockTemplateComponent, []),
createInstance("template2", blockTemplateComponent, []),
] satisfies Instance[]);
$instances.set(instances);
selectInstances([
["template1", "body0"],
["template2", "body0"],
]);
expect(instanceText.onCopy?.()).toBeUndefined();
expect($instances.get()).toEqual(instances);
});
test("cuts multiple selected roots into one combined fragment and removes them", () => {
$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 = instanceText.onCut?.();
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
"@webstudio/instances/v0.1": {
rootInstanceIds: ["box1", "box2"],
},
});
expect($instances.get().get("body0")?.children).toEqual([]);
expect($instances.get().has("box1")).toBe(false);
expect($instances.get().has("box2")).toBe(false);
expect($allSelectedInstanceSelectors.get()).toEqual([]);
});
test("cuts only copyable roots when multi-cut skips selected roots", () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "box1" },
{ type: "id", value: "template" },
]),
createInstance("box1", "Box", []),
createInstance("template", blockTemplateComponent, []),
] satisfies Instance[])
);
selectInstances([
["template", "body0"],
["box1", "body0"],
]);
const clipboardData = instanceText.onCut?.();
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
"@webstudio/instances/v0.1": {
rootInstanceIds: ["box1"],
},
});
expect($instances.get().get("body0")?.children).toEqual([
{ type: "id", value: "template" },
]);
expect($instances.get().has("box1")).toBe(false);
expect($instances.get().has("template")).toBe(true);
expect($allSelectedInstanceSelectors.get()).toEqual([]);
});
test("does not cut when no multi-selected root is copyable", () => {
const instances: Instances = toMap([
createInstance("body0", "Body", [
{ type: "id", value: "template1" },
{ type: "id", value: "template2" },
]),
createInstance("template1", blockTemplateComponent, []),
createInstance("template2", blockTemplateComponent, []),
] satisfies Instance[]);
$instances.set(instances);
selectInstances([
["template1", "body0"],
["template2", "body0"],
]);
expect(instanceText.onCut?.()).toBeUndefined();
expect($instances.get()).toEqual(instances);
expect($allSelectedInstanceSelectors.get()).toEqual([
["template1", "body0"],
["template2", "body0"],
]);
});
});
describe("paste target", () => {
test("pastes radix navigation menu fragment with viewport wrapper", async () => {
const previousMetas = $registeredComponentMetas.get();
$registeredComponentMetas.set(componentMetas);
$instances.set(toMap([createInstance("body0", "Body", [])]));
selectInstance(["body0"]);
const clipboardData = JSON.stringify({
"@webstudio/instance/v0.1": {
instanceSelector: ["nav", "source-parent"],
children: [{ type: "id", value: "nav" }],
instances: [
createInstance(
"nav",
"@webstudio-is/sdk-components-react-radix:NavigationMenu",
[
{ type: "id", value: "list" },
{ type: "id", value: "viewport-container" },
]
),
createInstance(
"list",
"@webstudio-is/sdk-components-react-radix:NavigationMenuList",
[{ type: "id", value: "item" }]
),
createInstance(
"item",
"@webstudio-is/sdk-components-react-radix:NavigationMenuItem",
[
{ type: "id", value: "trigger" },
{ type: "id", value: "content" },
]
),
createInstance(
"trigger",
"@webstudio-is/sdk-components-react-radix:NavigationMenuTrigger",
[{ type: "id", value: "button" }]
),
createInstance("button", "Button", [{ type: "id", value: "text" }]),
createInstance("text", "Text", [{ type: "text", value: "About" }]),
createInstance(
"content",
"@webstudio-is/sdk-components-react-radix:NavigationMenuContent",
[{ type: "id", value: "content-box" }]
),
createInstance("content-box", "Box", [
{ type: "id", value: "content-link" },
]),
createInstance("content-link", "Link", [
{ type: "text", value: "Documentation" },
]),
{
...createInstance("viewport-container", "Box", [
{ type: "id", value: "viewport" },
]),
label: "Viewport Container",
},
createInstance(
"viewport",
"@webstudio-is/sdk-components-react-radix:NavigationMenuViewport",
[]
),
],
assets: [],
dataSources: [],
resources: [],
props: [],
breakpoints: [],
styleSourceSelections: [],
styleSources: [],
styles: [],
},
});
const warn = vi.spyOn(toast, "warn").mockImplementation(() => "");
const result = await instanceText.onPaste?.(clipboardData);
expect(result).toEqual(pasteHandled);
expect(warn).not.toHaveBeenCalled();
const [pastedNavId] =
$instances
.get()
.get("body0")
?.children.map((child) => child.value) ?? [];
expect($instances.get().get(pastedNavId ?? "")?.component).toBe(
"@webstudio-is/sdk-components-react-radix:NavigationMenu"
);
warn.mockRestore();
$registeredComponentMetas.set(previousMetas);
});
// body0
// box1
// box2
const instances: Instances = toMap([
createInstance("body0", "Body", [
{ type: "id", value: "box1" },
{ type: "id", value: "box2" },
]),
createInstance("box1", "Box", []),
createInstance("box2", "Box", []),
] satisfies Instance[]);
test("is inside selected instance", async () => {
$instances.set(instances);
selectInstance(["box1", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["box2", "body0"]);
await instanceText.onPaste?.(clipboardData);
const instancesDifference = getMapDifference(instances, $instances.get());
const [newBox1] = instancesDifference.keys();
expect($instances.get().get("box2")).toEqual(
createInstance("box2", "Box", [{ type: "id", value: newBox1 }])
);
expect(instancesDifference).toEqual(
toMap([createInstance(newBox1, "Box", [])])
);
});
test("is after selected instance when same as copied", async () => {
$instances.set(instances);
selectInstance(["box1", "body0"]);
await instanceText.onPaste?.(instanceText.onCopy?.() ?? "");
const instancesDifference = getMapDifference(instances, $instances.get());
const [newBox1] = instancesDifference.keys();
expect($instances.get().get("body0")).toEqual(
createInstance("body0", "Body", [
{ type: "id", value: "box1" },
{ type: "id", value: newBox1 },
{ type: "id", value: "box2" },
])
);
expect(instancesDifference).toEqual(
toMap([createInstance(newBox1, "Box", [])])
);
});
test("uses multi-selection paste target after the last selected sibling for old single-root clipboard data", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "source" },
{ type: "id", value: "box1" },
{ type: "id", value: "box2" },
{ type: "id", value: "tail" },
]),
createInstance("source", "Box", []),
createInstance("box1", "Box", []),
createInstance("box2", "Box", []),
createInstance("tail", "Box", []),
] satisfies Instance[])
);
selectInstance(["source", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstances([
["box1", "body0"],
["box2", "body0"],
]);
await instanceText.onPaste?.(clipboardData);
const bodyChildren = $instances.get().get("body0")?.children;
expect(bodyChildren).toEqual([
{ type: "id", value: "source" },
{ type: "id", value: "box1" },
{ type: "id", value: "box2" },
{ type: "id", value: expect.any(String) },
{ type: "id", value: "tail" },
]);
const pastedId = bodyChildren?.[3]?.value;
expect(pastedId).not.toBe("source");
expect($allSelectedInstanceSelectors.get()).toEqual([[pastedId, "body0"]]);
});
test("uses multi-selection paste target after the last selected sibling for multi-root clipboard data", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "source1" },
{ type: "id", value: "source2" },
{ type: "id", value: "box1" },
{ type: "id", value: "box2" },
{ type: "id", value: "tail" },
]),
createInstance("source1", "Box", []),
createInstance("source2", "Box", []),
createInstance("box1", "Box", []),
createInstance("box2", "Box", []),
createInstance("tail", "Box", []),
] satisfies Instance[])
);
selectInstances([
["source1", "body0"],
["source2", "body0"],
]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstances([
["box1", "body0"],
["box2", "body0"],
]);
await instanceText.onPaste?.(clipboardData);
const bodyChildren = $instances.get().get("body0")?.children;
expect(bodyChildren).toEqual([
{ type: "id", value: "source1" },
{ type: "id", value: "source2" },
{ type: "id", value: "box1" },
{ type: "id", value: "box2" },
{ type: "id", value: expect.any(String) },
{ type: "id", value: expect.any(String) },
{ type: "id", value: "tail" },
]);
});
test("uses lowest common ancestor paste target for mixed-parent multi-selection", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "source" },
{ type: "id", value: "section" },
{ type: "id", value: "footer" },
]),
createInstance("source", "Box", []),
createInstance("section", "Box", [
{ type: "id", value: "group" },
{ type: "id", value: "aside" },
{ type: "id", value: "tail" },
]),
createInstance("group", "Box", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
createInstance("aside", "Box", []),
createInstance("tail", "Box", []),
createInstance("footer", "Box", []),
] satisfies Instance[])
);
selectInstance(["source", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstances([
["box", "group", "section", "body0"],
["aside", "section", "body0"],
]);
await instanceText.onPaste?.(clipboardData);
expect($instances.get().get("section")?.children).toEqual([
{ type: "id", value: "group" },
{ type: "id", value: "aside" },
{ type: "id", value: "tail" },
{ type: "id", value: expect.any(String) },
]);
expect($instances.get().get("body0")?.children).toEqual([
{ type: "id", value: "source" },
{ type: "id", value: "section" },
{ type: "id", value: "footer" },
]);
});
test("pastes into shared slot content", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "source" },
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
]),
createInstance("source", "Box", []),
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
] satisfies Instance[])
);
selectInstance(["source", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["slot1", "body0"]);
await instanceText.onPaste?.(clipboardData);
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "box" },
{ type: "id", value: expect.any(String) },
]);
});
test("pastes into legacy shared slot content", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "source" },
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
]),
createInstance("source", "Box", []),
createInstance("slot1", "Slot", [{ type: "id", value: "box" }]),
createInstance("slot2", "Slot", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
] satisfies Instance[])
);
selectInstance(["source", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["slot1", "body0"]);
await instanceText.onPaste?.(clipboardData);
const fragmentId = expectSlotsShareFragment($instances.get(), [
"slot1",
"slot2",
]);
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: fragmentId },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: fragmentId },
]);
expect($instances.get().get(fragmentId ?? "")?.children).toEqual([
{ type: "id", value: "box" },
{ type: "id", value: expect.any(String) },
]);
});
test("pastes after selected shared slot child in shared slot content", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
]),
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
] satisfies Instance[])
);
selectInstance(["box", "fragment", "slot1", "body0"]);
await instanceText.onPaste?.(instanceText.onCopy?.() ?? "");
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "box" },
{ type: "id", value: expect.any(String) },
]);
});
test("pastes after selected legacy shared slot child in shared slot content", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
]),
createInstance("slot1", "Slot", [
{ type: "id", value: "box" },
{ type: "id", value: "heading" },
]),
createInstance("slot2", "Slot", [
{ type: "id", value: "box" },
{ type: "id", value: "heading" },
]),
createInstance("box", "Box", []),
createInstance("heading", "Heading", []),
] satisfies Instance[])
);
selectInstance(["box", "slot1", "body0"]);
await instanceText.onPaste?.(instanceText.onCopy?.() ?? "");
const fragmentId = expectSlotsShareFragment($instances.get(), [
"slot1",
"slot2",
]);
expect($instances.get().get(fragmentId ?? "")?.children).toEqual([
{ type: "id", value: "box" },
{ type: "id", value: expect.any(String) },
{ type: "id", value: "heading" },
]);
expectSlotTreeIntegrity($instances.get());
});
test("pastes after selected nested shared slot child in shared slot content", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
]),
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
createInstance("div", "Box", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
] satisfies Instance[])
);
selectInstance(["box", "div", "fragment", "slot1", "body0"]);
await instanceText.onPaste?.(instanceText.onCopy?.() ?? "");
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "div" },
]);
expect($instances.get().get("div")?.children).toEqual([
{ type: "id", value: "box" },
{ type: "id", value: expect.any(String) },
]);
});
test("pastes into nested shared slot container", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "source" },
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
]),
createInstance("source", "Box", []),
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
createInstance("div", "Box", []),
] satisfies Instance[])
);
selectInstance(["source", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["div", "fragment", "slot1", "body0"]);
await instanceText.onPaste?.(clipboardData);
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "div" },
]);
expect($instances.get().get("div")?.children).toEqual([
{ type: "id", value: expect.any(String) },
]);
});
test("copies shared slot child and pastes independent copy outside", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
{ type: "id", value: "target" },
]),
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
createInstance("target", "Box", []),
] satisfies Instance[])
);
selectInstance(["box", "fragment", "slot1", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["target", "body0"]);
await instanceText.onPaste?.(clipboardData);
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
const pastedBoxId = $instances.get().get("target")?.children[0]?.value;
expect(pastedBoxId).toEqual(expect.any(String));
expect(pastedBoxId).not.toBe("box");
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "box" },
]);
expect($instances.get().get(pastedBoxId ?? "")?.children).toEqual([]);
});
test("copies legacy shared slot child and pastes independent copy outside", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
{ type: "id", value: "target" },
]),
createInstance("slot1", "Slot", [
{ type: "id", value: "box" },
{ type: "id", value: "heading" },
]),
createInstance("slot2", "Slot", [
{ type: "id", value: "box" },
{ type: "id", value: "heading" },
]),
createInstance("box", "Box", []),
createInstance("heading", "Heading", []),
createInstance("target", "Box", []),
] satisfies Instance[])
);
selectInstance(["box", "slot1", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["target", "body0"]);
await instanceText.onPaste?.(clipboardData);
const pastedBoxId = $instances.get().get("target")?.children[0]?.value;
expect(pastedBoxId).toEqual(expect.any(String));
expect(pastedBoxId).not.toBe("box");
expect($instances.get().get(pastedBoxId ?? "")?.component).toBe("Box");
selectInstance(["box", "slot1", "body0"]);
instanceMutationUtils.convertInstance(elementComponent, "section");
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
expect($instances.get().get("box")?.component).toBe(elementComponent);
expect($instances.get().get("box")?.tag).toBe("section");
expect($instances.get().get(pastedBoxId ?? "")?.component).toBe("Box");
});
test("copies nested shared slot child with scoped data and pastes independent copy outside", async () => {
const dataSources: DataSources = toMap([
{
id: "boxVariable",
scopeInstanceId: "box",
type: "variable",
name: "Box Variable",
value: { type: "string", value: "value" },
},
] satisfies DataSource[]);
const props: Props = toMap([
{
id: "boxExpressionProp",
instanceId: "box",
name: "children",
type: "expression",
value: "$ws$dataSource$boxVariable",
},
{
id: "boxResourceProp",
instanceId: "box",
name: "resource",
type: "resource",
value: "boxResource",
},
] satisfies Prop[]);
const resources: Resources = toMap([
{
id: "boxResource",
name: "Box Resource",
method: "get",
url: "$ws$dataSource$boxVariable",
headers: [
{
name: "authorization",
value: "$ws$dataSource$boxVariable",
},
],
searchParams: [
{
name: "query",
value: "$ws$dataSource$boxVariable",
},
],
},
] satisfies Resource[]);
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
{ type: "id", value: "target" },
]),
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
createInstance("div", "Box", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
createInstance("target", "Box", []),
] satisfies Instance[])
);
$dataSources.set(dataSources);
$props.set(props);
$resources.set(resources);
selectInstance(["box", "div", "fragment", "slot1", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["target", "body0"]);
await instanceText.onPaste?.(clipboardData);
const pastedBoxId = $instances.get().get("target")?.children[0]?.value;
const dataSourcesDifference = getMapDifference(
dataSources,
$dataSources.get()
);
const [newDataSourceId] = dataSourcesDifference.keys();
const propsDifference = getMapDifference(props, $props.get());
const resourcesDifference = getMapDifference(resources, $resources.get());
const [newResourceId] = resourcesDifference.keys();
expect(pastedBoxId).toEqual(expect.any(String));
expect(pastedBoxId).not.toBe("box");
expect(dataSourcesDifference).toEqual(
toMap([
{
...dataSources.get("boxVariable"),
id: newDataSourceId,
scopeInstanceId: pastedBoxId,
},
])
);
expect(Array.from(propsDifference.values())).toEqual(
expect.arrayContaining([
{
id: expectString,
instanceId: pastedBoxId,
name: "children",
type: "expression",
value: encodeDataSourceVariable(newDataSourceId),
},
{
id: expectString,
instanceId: pastedBoxId,
name: "resource",
type: "resource",
value: newResourceId,
},
])
);
expect(resourcesDifference).toEqual(
toMap([
{
id: newResourceId,
name: "Box Resource",
method: "get",
url: encodeDataSourceVariable(newDataSourceId),
headers: [
{
name: "authorization",
value: encodeDataSourceVariable(newDataSourceId),
},
],
searchParams: [
{
name: "query",
value: encodeDataSourceVariable(newDataSourceId),
},
],
},
])
);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "div" },
]);
expect($instances.get().get("div")?.children).toEqual([
{ type: "id", value: "box" },
]);
});
test("cuts shared slot child from all slot occurrences", () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
]),
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
] satisfies Instance[])
);
selectInstance(["box", "fragment", "slot1", "body0"]);
const clipboardData = instanceText.onCut?.();
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
"@webstudio/instance/v0.1": {
instanceSelector: ["box", "fragment", "slot1", "body0"],
children: [{ type: "id", value: "box" }],
},
});
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([]);
expect($instances.get().has("box")).toBe(false);
});
test("cuts legacy shared slot child from all slot occurrences", () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
]),
createInstance("slot1", "Slot", [
{ type: "id", value: "box" },
{ type: "id", value: "heading" },
]),
createInstance("slot2", "Slot", [
{ type: "id", value: "box" },
{ type: "id", value: "heading" },
]),
createInstance("box", "Box", []),
createInstance("heading", "Heading", []),
] satisfies Instance[])
);
selectInstance(["box", "slot1", "body0"]);
const clipboardData = instanceText.onCut?.();
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
"@webstudio/instance/v0.1": {
instanceSelector: ["box", "slot1", "body0"],
children: [{ type: "id", value: "box" }],
},
});
const fragmentId = expectSlotsShareFragment($instances.get(), [
"slot1",
"slot2",
]);
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: fragmentId },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: fragmentId },
]);
expect($instances.get().get(fragmentId ?? "")?.children).toEqual([
{ type: "id", value: "heading" },
]);
expect($instances.get().has("box")).toBe(false);
});
test("cuts nested shared slot child from all slot occurrences", () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
]),
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
createInstance("div", "Box", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
] satisfies Instance[])
);
selectInstance(["box", "div", "fragment", "slot1", "body0"]);
const clipboardData = instanceText.onCut?.();
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
"@webstudio/instance/v0.1": {
instanceSelector: ["box", "div", "fragment", "slot1", "body0"],
children: [{ type: "id", value: "box" }],
},
});
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "div" },
]);
expect($instances.get().get("div")?.children).toEqual([]);
expect($instances.get().has("box")).toBe(false);
});
test("cuts shared slot child and preserves shared siblings", () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "slot1" },
{ type: "id", value: "slot2" },
]),
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
createInstance("fragment", "Fragment", [
{ type: "id", value: "box" },
{ type: "id", value: "heading" },
]),
createInstance("box", "Box", []),
createInstance("heading", "Heading", []),
] satisfies Instance[])
);
selectInstance(["box", "fragment", "slot1", "body0"]);
const clipboardData = instanceText.onCut?.();
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
"@webstudio/instance/v0.1": {
instanceSelector: ["box", "fragment", "slot1", "body0"],
children: [{ type: "id", value: "box" }],
},
});
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "heading" },
]);
expect($instances.get().has("box")).toBe(false);
});
});
describe("data sources", () => {
// body0
// box1
// box2
const instances: Instances = toMap([
createInstance("body0", "Body", [{ type: "id", value: "box1" }]),
createInstance("box1", "Box", [{ type: "id", value: "box2" }]),
createInstance("box2", "Box", []),
] satisfies Instance[]);
const dataSources: DataSources = toMap([
{
id: "box1$state",
scopeInstanceId: "box1",
type: "variable",
name: "state",
value: { type: "string", value: "initial" },
},
] satisfies DataSource[]);
const props: Props = toMap([
{
id: "box1$stateProp",
instanceId: "box1",
name: "state",
type: "expression",
value: "$ws$dataSource$box1$state",
},
{
id: "box2$stateProp",
instanceId: "box2",
name: "state",
type: "expression",
value: "$ws$dataSource$box1$state",
},
{
id: "box2$showProp",
instanceId: "box2",
name: "show",
type: "expression",
value: `$ws$dataSource$box1$state === 'initial'`,
},
{
id: "box2$onChangeProp",
instanceId: "box2",
type: "action",
name: "onChange",
value: [
{
type: "execute",
args: ["value"],
code: `$ws$dataSource$box1$state = value`,
},
],
},
] satisfies Prop[]);
test("are copy pasted when scoped to copied instances", async () => {
$instances.set(instances);
$dataSources.set(dataSources);
$props.set(props);
selectInstance(["box1", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["body0"]);
await instanceText.onPaste?.(clipboardData);
const instancesDifference = getMapDifference(instances, $instances.get());
const [newBox1, newBox2] = instancesDifference.keys();
const dataSourcesDifference = getMapDifference(
dataSources,
$dataSources.get()
);
const [newDataSource1] = dataSourcesDifference.keys();
expect(dataSourcesDifference).toEqual(
toMap([
{
...dataSources.get("box1$state"),
id: newDataSource1,
scopeInstanceId: newBox1,
},
])
);
const propsDifference = getMapDifference(props, $props.get());
const [newProp1, newProp2, newProp3, newProp4] = propsDifference.keys();
expect(propsDifference).toEqual(
toMap([
{
id: newProp1,
instanceId: newBox1,
name: "state",
type: "expression",
value: encodeDataSourceVariable(newDataSource1),
},
{
id: newProp2,
instanceId: newBox2,
name: "state",
type: "expression",
value: encodeDataSourceVariable(newDataSource1),
},
{
id: newProp3,
instanceId: newBox2,
name: "show",
type: "expression",
value: `${encodeDataSourceVariable(newDataSource1)} === 'initial'`,
},
{
id: newProp4,
instanceId: newBox2,
name: "onChange",
type: "action",
value: [
{
type: "execute",
args: ["value"],
code: `${encodeDataSourceVariable(newDataSource1)} = value`,
},
],
},
])
);
});
test("preserve data sources outside of scope when pasted within their scope", async () => {
$instances.set(instances);
$props.set(props);
$dataSources.set(dataSources);
selectInstance(["box2", "box1", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["box1", "body0"]);
await instanceText.onPaste?.(clipboardData);
const instancesDifference = getMapDifference(instances, $instances.get());
const [newBox2] = instancesDifference.keys();
const dataSourcesDifference = getMapDifference(
dataSources,
$dataSources.get()
);
expect(dataSourcesDifference).toEqual(new Map());
const propsDifference = getMapDifference(props, $props.get());
const [newProp1, newProp2, newProp3] = propsDifference.keys();
expect(propsDifference).toEqual(
toMap([
{
id: newProp1,
instanceId: newBox2,
name: "state",
type: "expression",
value: `$ws$dataSource$box1$state`,
},
{
id: newProp2,
instanceId: newBox2,
name: "show",
type: "expression",
value: `$ws$dataSource$box1$state === 'initial'`,
},
{
id: newProp3,
instanceId: newBox2,
type: "action",
name: "onChange",
value: [
{
args: ["value"],
code: "$ws$dataSource$box1$state = value",
type: "execute",
},
],
},
])
);
});
test("copy parameter prop with new data source", async () => {
setPageRoot("body");
const instances: Instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "list" }]),
createInstance("list", collectionComponent, []),
] satisfies Instance[]);
const dataSources: DataSources = toMap([
{
id: "itemDataSource",
scopeInstanceId: "list",
type: "parameter",
name: "item",
},
] satisfies DataSource[]);
const props: Props = toMap([
{
id: "dataProp",
instanceId: "list",
name: "data",
type: "json",
value: [],
},
{
id: "itemProp",
instanceId: "list",
name: "item",
type: "parameter",
value: "itemDataSource",
},
] satisfies Prop[]);
$instances.set(instances);
$props.set(props);
$dataSources.set(dataSources);
selectInstance(["list", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["body"]);
await instanceText.onPaste?.(clipboardData);
const instancesDifference = getMapDifference(instances, $instances.get());
const [collectionId] = instancesDifference.keys();
const dataSourcesDifference = getMapDifference(
dataSources,
$dataSources.get()
);
const [itemDataSourceId] = dataSourcesDifference.keys();
expect(dataSourcesDifference).toEqual(
new Map([
[
itemDataSourceId,
{
id: itemDataSourceId,
scopeInstanceId: collectionId,
type: "parameter",
name: "item",
},
],
])
);
const propsDifference = getMapDifference(props, $props.get());
const [dataPropId, itemPropId] = propsDifference.keys();
expect(propsDifference).toEqual(
toMap([
{
id: dataPropId,
instanceId: collectionId,
name: "data",
type: "json",
value: [],
},
{
id: itemPropId,
instanceId: collectionId,
name: "item",
type: "parameter",
value: itemDataSourceId,
},
])
);
});
});
test("when paste into copied instance insert after it", async () => {
setPageRoot("body");
$instances.set(
toMap([
createInstance("body", "Body", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
])
);
selectInstance(["box", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
await instanceText.onPaste?.(clipboardData);
expect($instances.get()).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "box" },
{ type: "id", value: expectString },
]),
createInstance("box", "Box", []),
createInstance(expectString, "Box", []),
])
);
});
test("prevent pasting portal into own descendants", async () => {
const instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "portal" }]),
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
]);
$instances.set(instances);
selectInstance(["portal", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["fragment", "portal", "body"]);
await instanceText.onPaste?.(clipboardData);
expect($instances.get()).toEqual(instances);
});
test("prevent pasting portal into copy of it", async () => {
const instances = toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal1" },
{ type: "id", value: "portal2" },
]),
createInstance("portal1", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("portal2", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
]);
$instances.set(instances);
selectInstance(["portal1", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["portal2", "body"]);
await instanceText.onPaste?.(clipboardData);
expect($instances.get()).toEqual(instances);
});
test("insert portal into its sibling", async () => {
setPageRoot("body");
$instances.set(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "sibling" },
]),
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
createInstance("sibling", "Box", []),
])
);
selectInstance(["portal", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["sibling", "body"]);
await instanceText.onPaste?.(clipboardData);
expect($instances.get()).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "sibling" },
]),
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
createInstance("sibling", "Box", [{ type: "id", value: expectString }]),
createInstance(expectString, portalComponent, [
{ type: "id", value: "fragment" },
]),
])
);
});
test("inserts multi-root clipboard into descendant of copied non-portal root", async () => {
setPageRoot("body");
$instances.set(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "box" },
]),
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
createInstance("box", "Box", [{ type: "id", value: "target" }]),
createInstance("target", "Box", []),
])
);
selectInstances([
["portal", "body"],
["box", "body"],
]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["target", "box", "body"]);
await instanceText.onPaste?.(clipboardData);
const targetChildren = $instances.get().get("target")?.children;
const [pastedPortalId, pastedBoxId] =
targetChildren?.map((child) => (child.type === "id" ? child.value : "")) ??
[];
expect(targetChildren).toEqual([
{ type: "id", value: expectString },
{ type: "id", value: expectString },
]);
expect($instances.get().get(pastedPortalId ?? "")?.component).toBe(
portalComponent
);
expect($instances.get().get(pastedBoxId ?? "")?.component).toBe("Box");
});
test("insert into portal fragment when portal is a target", async () => {
setPageRoot("body");
$instances.set(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "box" },
]),
createInstance("portal", portalComponent, []),
createInstance("box", "Box", []),
])
);
selectInstance(["box", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["portal", "body"]);
// fragment not exists
const prevInstances = $instances.get();
await instanceText.onPaste?.(clipboardData);
const newInstances = getMapDifference(prevInstances, $instances.get());
const boxId = Array.from(newInstances.values()).find(
(instance) => instance.component === "Box"
)?.id;
const fragmentId = Array.from(newInstances.values()).find(
(instance) => instance.component === "Fragment"
)?.id;
if (boxId === undefined || fragmentId === undefined) {
throw Error("Expected pasted Box and Fragment instances");
}
expect($instances.get()).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "box" },
]),
createInstance("portal", portalComponent, [
{ type: "id", value: fragmentId },
]),
createInstance(fragmentId, "Fragment", [{ type: "id", value: boxId }]),
createInstance("box", "Box", []),
createInstance(boxId, "Box", []),
])
);
// fragment already exists
selectInstance(["portal", "body"]);
await instanceText.onPaste?.(clipboardData);
expect($instances.get()).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "box" },
]),
createInstance("portal", portalComponent, [
{ type: "id", value: fragmentId },
]),
createInstance(fragmentId, "Fragment", [
{ type: "id", value: boxId },
{ type: "id", value: expectString },
]),
createInstance("box", "Box", []),
createInstance(boxId, "Box", []),
createInstance(expectString, "Box", []),
])
);
});