mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-30 16:47:54 +09:00
Closes https://github.com/webstudio-is/webstudio/issues/5797 ## Summary - Add page, page template, and folder copy/paste support, including cross-project paste. - Reuse a shared page item actions menu for page headers and context menus with Copy, Duplicate, Delete, and shortcut hints. - Add Paste to the pages context menu. - Preserve template semantics when copying/pasting templates. - Support folder copy with nested folders/pages. - Add Backspace/Delete page deletion shortcuts and focus destructive dialog actions for Enter confirmation. - Rename `init-copy-paste.ts` to `copy-paste.ts`. ## Testing - `pnpm --filter @webstudio-is/builder typecheck` - `pnpm --filter @webstudio-is/builder test app/shared/copy-paste/copy-paste.test.ts app/shared/copy-paste/plugin-page.test.ts app/shared/page-utils.test.tsx app/builder/features/pages/page-utils.test.ts` - `git diff --check` - E2E copy/delete flows were added in `apps/builder/e2e/tests/pages-actions.e2e.ts`
304 lines
8.5 KiB
TypeScript
304 lines
8.5 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
import type { WebSocketEmitterOptions } from "@webstudio-is/sync-client/websocket";
|
|
import { $collaborators, $syncStatus } from "@webstudio-is/sync-client";
|
|
import { __testing__ } from "~/shared/awareness";
|
|
import {
|
|
createMultiplayerSyncEmitter,
|
|
startMultiplayerPresenceSync,
|
|
} from "./multiplayer-sync";
|
|
|
|
const { $pointerPosition } = __testing__;
|
|
|
|
const createTransportFactory = () => {
|
|
let callbacks: WebSocketEmitterOptions | undefined;
|
|
const sendToRelay = vi.fn();
|
|
const close = vi.fn();
|
|
return {
|
|
close,
|
|
get callbacks() {
|
|
if (callbacks === undefined) {
|
|
throw new Error("Transport was not created");
|
|
}
|
|
return callbacks;
|
|
},
|
|
createTransport(options: WebSocketEmitterOptions) {
|
|
callbacks = options;
|
|
return {
|
|
close,
|
|
emit: vi.fn(),
|
|
on: vi.fn(() => () => {}),
|
|
sendToRelay,
|
|
};
|
|
},
|
|
sendToRelay,
|
|
};
|
|
};
|
|
|
|
describe("createMultiplayerSyncEmitter", () => {
|
|
afterEach(() => {
|
|
$syncStatus.set({ status: "idle" });
|
|
$collaborators.set(new Map());
|
|
});
|
|
|
|
test("keeps unsaved state until all local applies are durable", () => {
|
|
const transport = createTransportFactory();
|
|
const emitter = createMultiplayerSyncEmitter({
|
|
getAuthToken: async () => "token",
|
|
clientId: "client-1",
|
|
createTransport: transport.createTransport,
|
|
url: "localhost:1999",
|
|
});
|
|
$syncStatus.set({ status: "idle" });
|
|
|
|
emitter.connect("build-1");
|
|
emitter.emit({
|
|
type: "apply",
|
|
clientId: "client-1",
|
|
transaction: { id: "tx-1", object: "server", payload: [] },
|
|
});
|
|
emitter.emit({
|
|
type: "apply",
|
|
clientId: "client-1",
|
|
transaction: { id: "tx-2", object: "server", payload: [] },
|
|
});
|
|
|
|
expect($syncStatus.get()).toEqual({ status: "syncing" });
|
|
|
|
transport.callbacks.onBroadcast({
|
|
type: "broadcast",
|
|
actorId: "client-1",
|
|
clientSeq: 1,
|
|
originClientId: "client-1",
|
|
relayTs: 10,
|
|
seq: 1,
|
|
transaction: { id: "tx-1", object: "server", payload: [] },
|
|
});
|
|
transport.callbacks.onAck(1, 2);
|
|
|
|
expect($syncStatus.get()).toEqual({ status: "syncing" });
|
|
|
|
transport.callbacks.onBroadcast({
|
|
type: "broadcast",
|
|
actorId: "client-1",
|
|
clientSeq: 2,
|
|
originClientId: "client-1",
|
|
relayTs: 11,
|
|
seq: 2,
|
|
transaction: { id: "tx-2", object: "server", payload: [] },
|
|
});
|
|
transport.callbacks.onAck(2, 3);
|
|
|
|
expect($syncStatus.get()).toEqual({ status: "idle" });
|
|
});
|
|
|
|
test("connects to the build room and sends local transactions as relay applies", () => {
|
|
const transport = createTransportFactory();
|
|
const emitter = createMultiplayerSyncEmitter({
|
|
getAuthToken: async () => "token",
|
|
clientId: "client-1",
|
|
createTransport: transport.createTransport,
|
|
url: "localhost:1999",
|
|
});
|
|
|
|
emitter.connect("build-1");
|
|
emitter.emit({
|
|
type: "apply",
|
|
clientId: "client-1",
|
|
transaction: { id: "tx-1", object: "server", payload: [] },
|
|
});
|
|
|
|
expect(transport.callbacks.buildId).toBe("build-1");
|
|
expect(transport.sendToRelay).toHaveBeenCalledWith({
|
|
type: "apply",
|
|
transactionId: "tx-1",
|
|
transaction: { id: "tx-1", object: "server", payload: [] },
|
|
clientId: "client-1",
|
|
actorId: "client-1",
|
|
clientSeq: 1,
|
|
});
|
|
});
|
|
|
|
test("forwards sync messages locally before relaying server transactions", () => {
|
|
const transport = createTransportFactory();
|
|
const emitter = createMultiplayerSyncEmitter({
|
|
getAuthToken: async () => "token",
|
|
clientId: "client-1",
|
|
createTransport: transport.createTransport,
|
|
url: "localhost:1999",
|
|
});
|
|
const handler = vi.fn();
|
|
emitter.on(handler);
|
|
|
|
emitter.emit({
|
|
type: "state",
|
|
clientId: "client-1",
|
|
state: new Map([["server", new Map()]]),
|
|
});
|
|
|
|
expect(handler).toHaveBeenCalledWith({
|
|
type: "state",
|
|
clientId: "client-1",
|
|
state: new Map([["server", new Map()]]),
|
|
});
|
|
expect(transport.sendToRelay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("does not relay client-only sync transactions to the multiplayer service", () => {
|
|
const transport = createTransportFactory();
|
|
const emitter = createMultiplayerSyncEmitter({
|
|
getAuthToken: async () => "token",
|
|
clientId: "client-1",
|
|
createTransport: transport.createTransport,
|
|
url: "localhost:1999",
|
|
});
|
|
const handler = vi.fn();
|
|
emitter.on(handler);
|
|
|
|
emitter.connect("build-1");
|
|
emitter.emit({
|
|
type: "apply",
|
|
clientId: "client-1",
|
|
transaction: { id: "tx-1", object: "canvasIframeState", payload: "idle" },
|
|
});
|
|
|
|
expect(handler).toHaveBeenCalledWith({
|
|
type: "apply",
|
|
clientId: "client-1",
|
|
transaction: { id: "tx-1", object: "canvasIframeState", payload: "idle" },
|
|
});
|
|
expect(transport.sendToRelay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("applies relay broadcasts to sync handlers", () => {
|
|
const transport = createTransportFactory();
|
|
const emitter = createMultiplayerSyncEmitter({
|
|
clientId: "client-1",
|
|
createTransport: transport.createTransport,
|
|
url: "localhost:1999",
|
|
});
|
|
const handler = vi.fn();
|
|
emitter.on(handler);
|
|
|
|
emitter.connect("build-1");
|
|
transport.callbacks.onBroadcast({
|
|
type: "broadcast",
|
|
actorId: "client-2",
|
|
clientSeq: 1,
|
|
originClientId: "client-2",
|
|
relayTs: 10,
|
|
seq: 1,
|
|
transaction: { id: "tx-2", object: "server", payload: [] },
|
|
});
|
|
|
|
expect(handler).toHaveBeenCalledWith({
|
|
type: "apply",
|
|
clientId: "client-2",
|
|
transaction: { id: "tx-2", object: "server", payload: [] },
|
|
});
|
|
});
|
|
|
|
test("surfaces relay applied errors to user messages", () => {
|
|
const transport = createTransportFactory();
|
|
const onUserMessage = vi.fn();
|
|
const emitter = createMultiplayerSyncEmitter({
|
|
getAuthToken: async () => "token",
|
|
clientId: "client-1",
|
|
createTransport: transport.createTransport,
|
|
onUserMessage,
|
|
url: "localhost:1999",
|
|
});
|
|
|
|
emitter.connect("build-1");
|
|
emitter.emit({
|
|
type: "apply",
|
|
clientId: "client-1",
|
|
transaction: { id: "tx-1", object: "server", payload: [] },
|
|
});
|
|
transport.callbacks.onApplied("tx-1", 1, "rejected", "No permission");
|
|
|
|
expect(onUserMessage).toHaveBeenCalledWith("No permission");
|
|
expect($syncStatus.get()).toEqual({ status: "idle" });
|
|
});
|
|
|
|
test("updates collaborators from relay presence", () => {
|
|
const transport = createTransportFactory();
|
|
const emitter = createMultiplayerSyncEmitter({
|
|
clientId: "client-1",
|
|
createTransport: transport.createTransport,
|
|
url: "localhost:1999",
|
|
});
|
|
$collaborators.set(new Map());
|
|
|
|
emitter.connect("build-1");
|
|
transport.callbacks.onPresence("client-2", {
|
|
pageId: "page-1",
|
|
pointerPosition: { x: 1, y: 2, xRatio: 0.1, yRatio: 0.2 },
|
|
selectedInstanceIds: ["instance-1"],
|
|
});
|
|
|
|
expect($collaborators.get().get("client-2")).toMatchObject({
|
|
pageId: "page-1",
|
|
pointerPosition: { x: 1, y: 2, xRatio: 0.1, yRatio: 0.2 },
|
|
selectedInstanceIds: ["instance-1"],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("startMultiplayerPresenceSync", () => {
|
|
afterEach(() => {
|
|
$pointerPosition.set(undefined);
|
|
});
|
|
|
|
test("sends only the latest awareness update in one microtask", async () => {
|
|
const emitter = { sendPresence: vi.fn() };
|
|
const unsubscribe = startMultiplayerPresenceSync(emitter);
|
|
|
|
await Promise.resolve();
|
|
emitter.sendPresence.mockClear();
|
|
|
|
$pointerPosition.set({
|
|
x: 1,
|
|
y: 2,
|
|
xRatio: 0.1,
|
|
yRatio: 0.2,
|
|
});
|
|
$pointerPosition.set({
|
|
x: 3,
|
|
y: 4,
|
|
xRatio: 0.3,
|
|
yRatio: 0.4,
|
|
});
|
|
|
|
expect(emitter.sendPresence).not.toHaveBeenCalled();
|
|
await Promise.resolve();
|
|
|
|
expect(emitter.sendPresence).toHaveBeenCalledTimes(1);
|
|
expect(emitter.sendPresence).toHaveBeenCalledWith({
|
|
pageId: undefined,
|
|
pointerPosition: { x: 3, y: 4, xRatio: 0.3, yRatio: 0.4 },
|
|
selectedInstanceIds: undefined,
|
|
});
|
|
unsubscribe();
|
|
});
|
|
|
|
test("does not send scheduled presence after unsubscribe", async () => {
|
|
const emitter = { sendPresence: vi.fn() };
|
|
const unsubscribe = startMultiplayerPresenceSync(emitter);
|
|
|
|
await Promise.resolve();
|
|
emitter.sendPresence.mockClear();
|
|
|
|
$pointerPosition.set({
|
|
x: 1,
|
|
y: 2,
|
|
xRatio: 0.1,
|
|
yRatio: 0.2,
|
|
});
|
|
unsubscribe();
|
|
|
|
await Promise.resolve();
|
|
|
|
expect(emitter.sendPresence).not.toHaveBeenCalled();
|
|
});
|
|
});
|