Files
webstudio/apps/builder/app/shared/copy-paste/plugin-page.test.ts
Oleg Isonen ca08919b13 fix: Confirm conflicting root styles on page paste (#5971)
## Summary

Prevent page, template, folder, and marketplace page imports from
silently replacing local declarations on the shared global root. When
incoming root styles differ at the same property, breakpoint, and state,
Builder now asks whether to preserve the project values or use the
incoming values. Cancellation happens before the runtime mutation.

## Technical choices

- Compare CSS values by their serialized meaning so equivalent keyword
and unparsed values do not create false conflicts.
- Plan breakpoint remapping with the same insertion path used by
fragment paste, including ID collisions and breakpoint limits.
- Keep named-token conflict resolution separate because token conflicts
support suffixing and merging, while a shared root-local declaration has
only an existing or incoming value.
- Default the root-style dialog to **Keep existing** because global root
styles affect every page, and show the breakpoint plus current and
incoming values for each conflict.
- Re-read current project data after the asynchronous token dialog
before detecting root conflicts.
- Leave no-conflict resolution undefined so the runtime performs a final
conflict check instead of treating it as an explicit **Use incoming**
choice.
- Resolve root conflicts at the page-copy boundary before using the
generic fragment/style insertion path.
- Reuse one root-conflict UI resolver for clipboard paste and
marketplace imports, one page-transfer traversal helper in the UI and
runtime, and one presentational shell for token and root-style conflict
dialogs.
- Require an explicit `rootStyleConflictResolution` for conflicting
`pageTransfer.insert` and `pages.copy` runtime operations so non-UI
callers cannot overwrite global values accidentally.
- Document that `ours` keeps target-project values and `theirs` uses
incoming values in both generated runtime contracts.

## Todo

- [x] Detect conflicting root-local declarations by property,
breakpoint, and state
- [x] Add keep-existing, use-incoming, and cancel behavior
- [x] Cover page, template, nested-folder, marketplace, and direct
runtime transfer paths
- [x] Preserve non-conflicting incoming root declarations with either
resolution
- [x] Avoid false conflicts for equivalent CSS values and colliding
breakpoint IDs
- [x] Revalidate root styles after asynchronous token resolution
- [x] Preserve existing named-token conflict behavior
- [x] Remove duplicated transfer traversal, conflict resolution mapping,
and dialog layout
- [x] Regenerate the runtime operation contract
- [x] Verify regression tests fail when their corresponding behavior is
disabled
- [x] Run targeted tests, typechecks, formatting, and lint

## Verification

- `pnpm --filter @webstudio-is/project-build test --
src/runtime/style-copy.test.ts src/runtime/fragment.test.tsx
src/runtime/page-copy.test.tsx` — 80 passed
- `pnpm --filter @webstudio-is/builder test --
app/shared/copy-paste/plugin-page.test.ts
app/shared/root-style-conflict-dialog.test.tsx
app/shared/token-conflict-dialog.test.tsx` — 17 passed
- Retrospective fail/pass audit covered semantic CSS equality,
breakpoint ID collisions, both runtime guards, both resolution
directions, folder/template paths, conflict detail rendering, and stale
target data after token resolution
- `pnpm --filter @webstudio-is/project-build typecheck` — passed
- `pnpm --filter @webstudio-is/builder typecheck` — passed
- `pnpm lint` — passed
- Runtime contract regeneration was repeated and produced identical
output
- Fixture pipeline not run locally: no fixture inputs or outputs are
affected; GitHub fixture checks passed on the previous revision
- Agent evaluations not run: repository guidance requires separate
explicit approval before running them

## Compatibility and limitations

The new runtime inputs are optional for non-conflicting imports.
Existing imports continue unchanged; only imports that would overwrite a
conflicting shared root-local declaration require an explicit
resolution. No migrations or persistence changes are required.

Closes #5970
2026-08-02 12:33:02 +02:00

816 lines
21 KiB
TypeScript

import { afterEach, expect, test, vi } from "vitest";
import { enableMapSet } from "immer";
import { createDefaultPages } from "@webstudio-is/project-build";
import {
encodeDataSourceVariable,
ROOT_FOLDER_ID,
ROOT_INSTANCE_ID,
type DataSource,
type Instance,
} from "@webstudio-is/sdk";
import type { Project } from "@webstudio-is/project";
import { registerContainers } from "../sync/sync-stores";
import {
$assets,
$breakpoints,
$dataSources,
$instances,
$pages,
$project,
$props,
$resources,
$styleSourceSelections,
$styleSources,
$styles,
} from "../sync/data-stores";
import {
$editingPageId,
$editingTemplateId,
selectInstance,
$selectedPageId,
} from "../nano-states";
import {
copyFolderData,
copyPageData,
copyTemplateData,
handlePastePage,
pageText,
} from "./plugin-page";
import { pasteHandled, pasteIgnored } from "./copy-paste";
import { initBuilderApi } from "../builder-api";
enableMapSet();
registerContainers();
initBuilderApi();
afterEach(() => {
vi.restoreAllMocks();
});
const resetBuildStores = () => {
$instances.set(new Map());
$props.set(new Map());
$dataSources.set(new Map());
$resources.set(new Map());
$breakpoints.set(new Map());
$styleSourceSelections.set(new Map());
$styleSources.set(new Map());
$styles.set(new Map());
$assets.set(new Map());
$editingPageId.set(undefined);
$editingTemplateId.set(undefined);
selectInstance(undefined);
$selectedPageId.set(undefined);
};
const setRootLocalStyle = ({
styleSourceId,
value,
}: {
styleSourceId: string;
value: string;
}) => {
$instances.set(
new Map([
...$instances.get(),
[
ROOT_INSTANCE_ID,
{
type: "instance",
id: ROOT_INSTANCE_ID,
component: "Box",
children: [],
} satisfies Instance,
],
])
);
$breakpoints.set(new Map([["base", { id: "base", label: "Base" }]]));
$styleSources.set(
new Map([[styleSourceId, { id: styleSourceId, type: "local" }]])
);
$styleSourceSelections.set(
new Map([
[
ROOT_INSTANCE_ID,
{ instanceId: ROOT_INSTANCE_ID, values: [styleSourceId] },
],
])
);
$styles.set(
new Map([
[
`${styleSourceId}:base:color:`,
{
styleSourceId,
breakpointId: "base",
property: "color",
value: { type: "keyword", value },
},
],
])
);
};
const addRootTokenStyle = ({
styleSourceId,
value,
}: {
styleSourceId: string;
value: string;
}) => {
const styleSources = new Map($styleSources.get());
styleSources.set(styleSourceId, {
id: styleSourceId,
type: "token",
name: "primary",
});
$styleSources.set(styleSources);
const rootSelection = $styleSourceSelections.get().get(ROOT_INSTANCE_ID);
const selections = new Map($styleSourceSelections.get());
selections.set(ROOT_INSTANCE_ID, {
instanceId: ROOT_INSTANCE_ID,
values: [...(rootSelection?.values ?? []), styleSourceId],
});
$styleSourceSelections.set(selections);
const styles = new Map($styles.get());
styles.set(`${styleSourceId}:base:color:`, {
styleSourceId,
breakpointId: "base",
property: "color",
value: { type: "keyword", value },
});
$styles.set(styles);
};
const updateRootLocalStyle = (value: string) => {
const rootLocalStyle = Array.from($styles.get().values()).find(
(style) =>
$styleSources.get().get(style.styleSourceId)?.type === "local" &&
style.property === "color"
);
if (rootLocalStyle === undefined) {
throw new Error("Expected root local color style");
}
const styles = new Map($styles.get());
styles.set(`${rootLocalStyle.styleSourceId}:base:color:`, {
...rootLocalStyle,
value: { type: "keyword", value },
});
$styles.set(styles);
};
const setupProjectWithRootStyle = ({
projectId,
pageId,
rootInstanceId,
styleSourceId,
color,
}: {
projectId: string;
pageId: string;
rootInstanceId: string;
styleSourceId: string;
color: string;
}) => {
$project.set({ id: projectId } as Project);
resetBuildStores();
const pages = createDefaultPages({ homePageId: pageId, rootInstanceId });
$pages.set(pages);
$instances.set(
new Map([
[
rootInstanceId,
{
type: "instance",
id: rootInstanceId,
component: "Body",
children: [],
} satisfies Instance,
],
])
);
setRootLocalStyle({ styleSourceId, value: color });
return pages;
};
test("copies the selected page root from the copy plugin", () => {
$project.set({ id: "source-project" } as Project);
resetBuildStores();
const pages = createDefaultPages({
homePageId: "source-page",
rootInstanceId: "source-root",
});
const page = pages.pages.get("source-page");
if (page === undefined) {
throw new Error("Expected source page");
}
page.name = "Selected Page";
$pages.set(pages);
$selectedPageId.set("source-page");
selectInstance(["source-root"]);
$instances.set(
new Map<Instance["id"], Instance>([
[
"source-root",
{
type: "instance",
id: "source-root",
component: "Body",
children: [],
},
],
])
);
expect(pageText.onCopy?.()).toContain('"type":"page"');
expect(pageText.onCopy?.()).toContain('"name":"Selected Page"');
});
test("copies the edited folder from the copy plugin", () => {
$project.set({ id: "source-project" } as Project);
resetBuildStores();
const pages = createDefaultPages({
homePageId: "source-page",
rootInstanceId: "source-root",
});
pages.folders.set("source-folder", {
id: "source-folder",
name: "Edited Folder",
slug: "edited-folder",
children: [],
});
$pages.set(pages);
$editingPageId.set("source-folder");
$instances.set(
new Map<Instance["id"], Instance>([
[
"source-root",
{
type: "instance",
id: "source-root",
component: "Body",
children: [],
},
],
])
);
expect(pageText.onCopy?.()).toContain('"type":"folder"');
expect(pageText.onCopy?.()).toContain('"name":"Edited Folder"');
});
test("copies page data across projects", async () => {
$project.set({ id: "source-project" } as Project);
resetBuildStores();
const sourcePages = createDefaultPages({
homePageId: "source-page",
rootInstanceId: "source-root",
});
const variable: DataSource = {
id: "source-variable",
scopeInstanceId: "source-root",
name: "title",
type: "variable",
value: { type: "string", value: "" },
};
const variableName = encodeDataSourceVariable(variable.id);
const sourcePage = sourcePages.pages.get("source-page");
if (sourcePage === undefined) {
throw new Error("Expected source page");
}
sourcePage.name = "Landing";
sourcePage.path = "/landing";
sourcePage.title = `"Landing " + ${variableName}`;
sourcePage.meta.description = `"Description " + ${variableName}`;
$pages.set(sourcePages);
$selectedPageId.set(sourcePages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>([
[
"source-root",
{
type: "instance",
id: "source-root",
component: "Body",
children: [],
},
],
])
);
$dataSources.set(new Map([[variable.id, variable]]));
const clipboardData = copyPageData("source-page");
expect(clipboardData).toBeDefined();
$project.set({ id: "target-project" } as Project);
resetBuildStores();
const targetPages = createDefaultPages({
homePageId: "target-page",
rootInstanceId: "target-root",
});
$pages.set(targetPages);
$selectedPageId.set(targetPages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>([
[
"target-root",
{
type: "instance",
id: "target-root",
component: "Body",
children: [],
},
],
])
);
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
const pastedPage = Array.from($pages.get()?.pages.values() ?? []).find(
(page) => page.name === "Landing"
);
expect(pastedPage).toEqual({
id: expect.any(String),
name: "Landing",
path: "/landing",
title: expect.stringContaining("$ws$dataSource$"),
rootInstanceId: expect.not.stringMatching("source-root"),
meta: {
description: expect.stringContaining("$ws$dataSource$"),
},
});
expect($instances.get().has(pastedPage?.rootInstanceId ?? "")).toBe(true);
expect($dataSources.get().has("source-variable")).toBe(false);
});
test.each([
["ours", "blue"],
["theirs", "red"],
] as const)(
"resolves conflicting global root styles with %s",
async (resolution, expectedColor) => {
setupProjectWithRootStyle({
projectId: "source-project",
pageId: "source-page",
rootInstanceId: "source-body",
styleSourceId: "source-local",
color: "red",
});
$styles.get().set("source-local:base:fontSize:", {
styleSourceId: "source-local",
breakpointId: "base",
property: "fontSize",
value: { type: "keyword", value: "medium" },
});
const clipboardData = copyPageData("source-page");
setupProjectWithRootStyle({
projectId: "target-project",
pageId: "target-page",
rootInstanceId: "target-body",
styleSourceId: "target-local",
color: "blue",
});
const conflictDialog = vi
.spyOn(window.__webstudio__$__builderApi, "showRootStyleConflictDialog")
.mockResolvedValue(resolution);
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
expect(conflictDialog).toHaveBeenCalledWith([
expect.objectContaining({
incomingStyle: expect.objectContaining({
property: "color",
value: { type: "keyword", value: "red" },
}),
}),
]);
expect($styles.get().get("target-local:base:color:")?.value).toEqual({
type: "keyword",
value: expectedColor,
});
expect($styles.get().get("target-local:base:fontSize:")?.value).toEqual({
type: "keyword",
value: "medium",
});
}
);
test("cancels page paste when global root style resolution is cancelled", async () => {
setupProjectWithRootStyle({
projectId: "source-project",
pageId: "source-page",
rootInstanceId: "source-body",
styleSourceId: "source-local",
color: "red",
});
const clipboardData = copyPageData("source-page");
const targetPages = setupProjectWithRootStyle({
projectId: "target-project",
pageId: "target-page",
rootInstanceId: "target-body",
styleSourceId: "target-local",
color: "blue",
});
const initialPageCount = targetPages.pages.size;
vi.spyOn(
window.__webstudio__$__builderApi,
"showRootStyleConflictDialog"
).mockResolvedValue("cancel");
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
expect($pages.get()?.pages.size).toBe(initialPageCount);
expect($styles.get().get("target-local:base:color:")?.value).toEqual({
type: "keyword",
value: "blue",
});
});
test("checks current root styles after resolving token conflicts", async () => {
setupProjectWithRootStyle({
projectId: "source-project",
pageId: "source-page",
rootInstanceId: "source-body",
styleSourceId: "source-local",
color: "red",
});
addRootTokenStyle({ styleSourceId: "source-token", value: "red" });
const clipboardData = copyPageData("source-page");
setupProjectWithRootStyle({
projectId: "target-project",
pageId: "target-page",
rootInstanceId: "target-body",
styleSourceId: "target-local",
color: "red",
});
addRootTokenStyle({ styleSourceId: "target-token", value: "blue" });
vi.spyOn(
window.__webstudio__$__builderApi,
"showTokenConflictDialog"
).mockImplementation(async () => {
updateRootLocalStyle("blue");
return "ours";
});
const rootConflictDialog = vi
.spyOn(window.__webstudio__$__builderApi, "showRootStyleConflictDialog")
.mockResolvedValue("ours");
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
expect(rootConflictDialog).toHaveBeenCalledOnce();
expect($styles.get().get("target-local:base:color:")?.value).toEqual({
type: "keyword",
value: "blue",
});
});
test("handles page paste data without falling through when insertion cannot complete", async () => {
$project.set({ id: "source-project" } as Project);
resetBuildStores();
const sourcePages = createDefaultPages({
homePageId: "source-page",
rootInstanceId: "source-root",
});
$pages.set(sourcePages);
$selectedPageId.set(sourcePages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>([
[
"source-root",
{
type: "instance",
id: "source-root",
component: "Body",
children: [],
},
],
])
);
const clipboardData = copyPageData("source-page");
expect(clipboardData).toBeDefined();
resetBuildStores();
$project.set(undefined);
const targetPages = createDefaultPages({
homePageId: "target-page",
rootInstanceId: "target-root",
});
const initialPageCount = targetPages.pages.size;
$pages.set(targetPages);
$selectedPageId.set(targetPages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>([
[
"target-root",
{
type: "instance",
id: "target-root",
component: "Body",
children: [],
},
],
])
);
await expect(
handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID)
).resolves.toEqual(pasteHandled);
expect($pages.get()?.pages.size).toBe(initialPageCount);
});
test("ignores non-page paste data", async () => {
await expect(handlePastePage("plain text", ROOT_FOLDER_ID)).resolves.toEqual(
pasteIgnored
);
});
test("copies folder data with nested pages across projects", async () => {
$project.set({ id: "source-project" } as Project);
resetBuildStores();
const sourcePages = createDefaultPages({
homePageId: "source-home",
rootInstanceId: "source-home-root",
});
sourcePages.folders.set("source-folder", {
id: "source-folder",
name: "Docs",
slug: "docs",
children: ["source-page-a", "source-subfolder"],
});
sourcePages.folders.set("source-subfolder", {
id: "source-subfolder",
name: "Nested",
slug: "nested",
children: ["source-page-b"],
});
sourcePages.folders
.get(sourcePages.rootFolderId)
?.children.push("source-folder");
sourcePages.pages.set("source-page-a", {
id: "source-page-a",
name: "Overview",
path: "/overview",
title: `"Overview"`,
rootInstanceId: "source-page-a-root",
meta: {},
});
sourcePages.pages.set("source-page-b", {
id: "source-page-b",
name: "Details",
path: "/details",
title: `"Details"`,
rootInstanceId: "source-page-b-root",
meta: {},
});
$pages.set(sourcePages);
$selectedPageId.set(sourcePages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>(
["source-home-root", "source-page-a-root", "source-page-b-root"].map(
(id) => [
id,
{
type: "instance",
id,
component: "Body",
children: [],
},
]
)
)
);
setRootLocalStyle({ styleSourceId: "source-local", value: "red" });
const clipboardData = copyFolderData("source-folder");
expect(clipboardData).toBeDefined();
$project.set({ id: "target-project" } as Project);
resetBuildStores();
const targetPages = createDefaultPages({
homePageId: "target-home",
rootInstanceId: "target-home-root",
});
$pages.set(targetPages);
$selectedPageId.set(targetPages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>([
[
"target-home-root",
{
type: "instance",
id: "target-home-root",
component: "Body",
children: [],
},
],
])
);
setRootLocalStyle({ styleSourceId: "target-local", value: "blue" });
const conflictDialog = vi
.spyOn(window.__webstudio__$__builderApi, "showRootStyleConflictDialog")
.mockResolvedValue("ours");
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
expect(conflictDialog).toHaveBeenCalledOnce();
expect($styles.get().get("target-local:base:color:")?.value).toEqual({
type: "keyword",
value: "blue",
});
const pastedFolder = Array.from($pages.get()?.folders.values() ?? []).find(
(folder) => folder.name === "Docs"
);
expect(pastedFolder).toBeDefined();
const pastedSubfolder = Array.from($pages.get()?.folders.values() ?? []).find(
(folder) => folder.name === "Nested"
);
expect(pastedSubfolder).toBeDefined();
const pastedPages = Array.from($pages.get()?.pages.values() ?? []).filter(
(page) => page.name === "Overview" || page.name === "Details"
);
expect(pastedPages).toHaveLength(2);
for (const page of pastedPages) {
expect(page.rootInstanceId).not.toMatch(/^source-/);
expect($instances.get().has(page.rootInstanceId)).toBe(true);
}
});
test("preserves empty folder slugs when pasting folder data", async () => {
$project.set({ id: "source-project" } as Project);
resetBuildStores();
const sourcePages = createDefaultPages({
homePageId: "source-home",
rootInstanceId: "source-home-root",
});
sourcePages.folders.set("source-folder", {
id: "source-folder",
name: "Empty Slug Folder",
slug: "",
children: [],
});
sourcePages.folders
.get(sourcePages.rootFolderId)
?.children.push("source-folder");
$pages.set(sourcePages);
$selectedPageId.set(sourcePages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>([
[
"source-home-root",
{
type: "instance",
id: "source-home-root",
component: "Body",
children: [],
},
],
])
);
const clipboardData = copyFolderData("source-folder");
expect(clipboardData).toBeDefined();
$project.set({ id: "target-project" } as Project);
resetBuildStores();
const targetPages = createDefaultPages({
homePageId: "target-home",
rootInstanceId: "target-home-root",
});
targetPages.folders.set("existing-empty-slug-folder", {
id: "existing-empty-slug-folder",
name: "Existing Empty Slug Folder",
slug: "",
children: [],
});
targetPages.folders
.get(targetPages.rootFolderId)
?.children.push("existing-empty-slug-folder");
$pages.set(targetPages);
$selectedPageId.set(targetPages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>([
[
"target-home-root",
{
type: "instance",
id: "target-home-root",
component: "Body",
children: [],
},
],
])
);
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
const pastedFolder = Array.from($pages.get()?.folders.values() ?? []).find(
(folder) => folder.name === "Empty Slug Folder"
);
expect(pastedFolder?.slug).toBe("");
});
test("copies template data as a template", async () => {
$project.set({ id: "source-project" } as Project);
resetBuildStores();
const sourcePages = createDefaultPages({
homePageId: "source-home",
rootInstanceId: "source-home-root",
});
sourcePages.pageTemplates = new Map([
[
"template-id",
{
id: "template-id",
name: "Landing Template",
title: `"Landing title"`,
rootInstanceId: "template-root",
meta: { description: `"Template description"` },
},
],
]);
$pages.set(sourcePages);
$selectedPageId.set(sourcePages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>(
["source-home-root", "template-root"].map((id) => [
id,
{
type: "instance",
id,
component: "Body",
children: [],
},
])
)
);
setRootLocalStyle({ styleSourceId: "source-local", value: "red" });
const clipboardData = copyTemplateData("template-id");
expect(clipboardData).toBeDefined();
$project.set({ id: "target-project" } as Project);
resetBuildStores();
const targetPages = createDefaultPages({
homePageId: "target-home",
rootInstanceId: "target-home-root",
});
$pages.set(targetPages);
$selectedPageId.set(targetPages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>([
[
"target-home-root",
{
type: "instance",
id: "target-home-root",
component: "Body",
children: [],
},
],
])
);
setRootLocalStyle({ styleSourceId: "target-local", value: "blue" });
const conflictDialog = vi
.spyOn(window.__webstudio__$__builderApi, "showRootStyleConflictDialog")
.mockResolvedValue("theirs");
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
expect(conflictDialog).toHaveBeenCalledOnce();
expect($styles.get().get("target-local:base:color:")?.value).toEqual({
type: "keyword",
value: "red",
});
const pastedTemplate = Array.from(
$pages.get()?.pageTemplates?.values() ?? []
).find((template) => template.name === "Landing Template");
expect(pastedTemplate).toEqual({
id: expect.any(String),
name: "Landing Template",
title: `"Landing title"`,
rootInstanceId: expect.any(String),
meta: { description: `"Template description"` },
});
expect(pastedTemplate?.rootInstanceId).not.toBe("template-root");
const pastedPage = Array.from($pages.get()?.pages.values() ?? []).find(
(page) => page.name === "Landing Template"
);
expect(pastedPage).toBeUndefined();
});