From cd877b068782c31a0834d01822593290a944c12d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:08:00 +0000 Subject: [PATCH 1/5] Update final step link in Next.js presence get started guide (#3162) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Chris Nicholas --- docs/pages/get-started/nextjs-presence.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/get-started/nextjs-presence.mdx b/docs/pages/get-started/nextjs-presence.mdx index e3cc5719d5..297aec0550 100644 --- a/docs/pages/get-started/nextjs-presence.mdx +++ b/docs/pages/get-started/nextjs-presence.mdx @@ -162,7 +162,7 @@ the [`@liveblocks/react`](/docs/api-reference/liveblocks-react) package. add each user's name and avatar. From 3e71bc262e3d5c054b3abed7b22e60b4e3bd7c6f Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Fri, 6 Mar 2026 14:07:13 +0100 Subject: [PATCH 2/5] Native ansi colors (PR 1629) Original commit: 07a52b46503971710389607186bd32ccb9e875b8 --- tools/liveblocks-cli/src/dev-server/index.ts | 6 ++-- tools/liveblocks-cli/src/lib/term-colors.ts | 35 +++++++++++--------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/tools/liveblocks-cli/src/dev-server/index.ts b/tools/liveblocks-cli/src/dev-server/index.ts index 1db2f63ae8..52f60bec29 100644 --- a/tools/liveblocks-cli/src/dev-server/index.ts +++ b/tools/liveblocks-cli/src/dev-server/index.ts @@ -236,10 +236,10 @@ const dev: SubCommand = { const status = resp.status; const colorStatus = status >= 500 - ? red(String(status)) + ? red(status) : status >= 400 - ? yellow(String(status)) - : green(String(status)); + ? yellow(status) + : green(status); console.log(`${colorStatus} ${route}`); const warnMsg = resp.headers.get("X-LB-Warn") ?? undefined; warn(warnMsg, !resp.ok); diff --git a/tools/liveblocks-cli/src/lib/term-colors.ts b/tools/liveblocks-cli/src/lib/term-colors.ts index 138487bf10..ace040cf2d 100644 --- a/tools/liveblocks-cli/src/lib/term-colors.ts +++ b/tools/liveblocks-cli/src/lib/term-colors.ts @@ -15,33 +15,38 @@ * along with this program. If not, see . */ +import { styleText } from "node:util"; + +type Stringable = { toString(): string }; + +// Node's styleText auto-strips ANSI when not a TTY, but Bun's doesn't, +// even with { stream, validateStream: true } (broken as of Bun 1.3.8) const enabled = process.stdout.isTTY; -const RESET = "\x1b[0m"; -export function yellow(text: string): string { - return enabled ? `\x1b[33m${text}${RESET}` : text; +export function yellow(text: Stringable): string { + return enabled ? styleText("yellow", String(text)) : String(text); } -export function blue(text: string): string { - return enabled ? `\x1b[34m${text}${RESET}` : text; +export function blue(text: Stringable): string { + return enabled ? styleText("blue", String(text)) : String(text); } -export function magenta(text: string): string { - return enabled ? `\x1b[35m${text}${RESET}` : text; +export function magenta(text: Stringable): string { + return enabled ? styleText("magenta", String(text)) : String(text); } -export function green(text: string): string { - return enabled ? `\x1b[32m${text}${RESET}` : text; +export function green(text: Stringable): string { + return enabled ? styleText("green", String(text)) : String(text); } -export function red(text: string): string { - return enabled ? `\x1b[31m${text}${RESET}` : text; +export function red(text: Stringable): string { + return enabled ? styleText("red", String(text)) : String(text); } -export function bold(text: string): string { - return enabled ? `\x1b[1m${text}${RESET}` : text; +export function bold(text: Stringable): string { + return enabled ? styleText("bold", String(text)) : String(text); } -export function dim(text: string): string { - return enabled ? `\x1b[2m${text}${RESET}` : text; +export function dim(text: Stringable): string { + return enabled ? styleText("dim", String(text)) : String(text); } From 947bd488545d4001ea721cdaec5edea781e76766 Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Fri, 6 Mar 2026 14:24:46 +0000 Subject: [PATCH 3/5] Improve AG Grid example (#3159) Co-authored-by: Marc Bouchenoire --- .../app/CommentCell.tsx | 59 +++++++++++-------- .../nextjs-comments-ag-grid/app/globals.css | 20 +------ .../nextjs-comments-ag-grid/package-lock.json | 52 ++++++++-------- examples/nextjs-comments-ag-grid/package.json | 8 +-- 4 files changed, 67 insertions(+), 72 deletions(-) diff --git a/examples/nextjs-comments-ag-grid/app/CommentCell.tsx b/examples/nextjs-comments-ag-grid/app/CommentCell.tsx index 9e4aa2e547..a8c2529d7c 100644 --- a/examples/nextjs-comments-ag-grid/app/CommentCell.tsx +++ b/examples/nextjs-comments-ag-grid/app/CommentCell.tsx @@ -1,15 +1,30 @@ "use client"; import { - Comment, + CommentPin, FloatingComposer, FloatingThread, + Icon, } from "@liveblocks/react-ui"; +import { useSelf } from "@liveblocks/react"; import { CustomCellRendererProps } from "ag-grid-react"; import { useCellThread } from "./CellThreadContext"; +import { CSSProperties, useState } from "react"; + +const COMMENT_PIN_SIZE = 24; + +const commentPinStyle = { + "--lb-comment-pin-padding": "3px", + width: COMMENT_PIN_SIZE, + height: COMMENT_PIN_SIZE, + cursor: "pointer", + marginTop: 3, +} as CSSProperties; export function CommentCell(params: CustomCellRendererProps) { const { threads, openCell, setOpenCell } = useCellThread(); + const currentUserId = useSelf((self) => self.id) ?? undefined; + const [isComposerOpen, setIsComposerOpen] = useState(false); const rowId = params.data?.id; const columnId = params.colDef?.field; @@ -37,8 +52,7 @@ export function CommentCell(params: CustomCellRendererProps) { style={{ display: "flex", alignItems: "center", - justifyContent: "space-between", - gap: 16, + gap: 12, }} > {/* Cell contents */} @@ -46,26 +60,25 @@ export function CommentCell(params: CustomCellRendererProps) { {/* Show thread if it exists, otherwise show thread composer (plus on hover) */} {!thread ? ( -
+
setOpenCell(metadata)} + onOpenChange={setIsComposerOpen} style={{ zIndex: 10 }} > - + + {!isComposerOpen ? ( + + ) : null} +
) : ( @@ -81,13 +94,9 @@ export function CommentCell(params: CustomCellRendererProps) { style={{ zIndex: 10 }} autoFocus > - diff --git a/examples/nextjs-comments-ag-grid/app/globals.css b/examples/nextjs-comments-ag-grid/app/globals.css index 177ce92c58..f97db037d3 100644 --- a/examples/nextjs-comments-ag-grid/app/globals.css +++ b/examples/nextjs-comments-ag-grid/app/globals.css @@ -34,30 +34,16 @@ button { --lb-accent: var(--ag-accent-color, #2196f3); } -/* Comment cell: plus trigger is hidden until row/cell is hovered */ +/* Hover to show "+" CommentPin icon */ .comment-cell-trigger { opacity: 0; transition: opacity 0.15s ease; } -.comment-cell:hover .comment-cell-trigger { +.comment-cell:hover .comment-cell-trigger, +.comment-cell-trigger[data-open] { opacity: 1; } -/* Style the plus (add comment) trigger here */ -.comment-cell-trigger button { - border-radius: 100%; - background: #ddf0ff; - padding-top: 0px; - appearance: none; - height: 28px; - width: 28px; - border: 0; - display: flex; - align-items: center; - justify-content: center; - color: #111c26; -} - .lb-portal { z-index: 10; } diff --git a/examples/nextjs-comments-ag-grid/package-lock.json b/examples/nextjs-comments-ag-grid/package-lock.json index 5525f3cf47..b41a9adc43 100644 --- a/examples/nextjs-comments-ag-grid/package-lock.json +++ b/examples/nextjs-comments-ag-grid/package-lock.json @@ -7,10 +7,10 @@ "name": "@liveblocks-examples/nextjs-comments-ag-grid", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "^3.15.0-rc1", - "@liveblocks/node": "^3.15.0-rc1", - "@liveblocks/react": "^3.15.0-rc1", - "@liveblocks/react-ui": "^3.15.0-rc1", + "@liveblocks/client": "^3.15.1-rc1", + "@liveblocks/node": "^3.15.1-rc1", + "@liveblocks/react": "^3.15.1-rc1", + "@liveblocks/react-ui": "^3.15.1-rc1", "ag-grid-community": "^35.1.0", "ag-grid-react": "^35.1.0", "next": "^16.1.6", @@ -545,43 +545,43 @@ "license": "Apache-2.0" }, "node_modules/@liveblocks/client": { - "version": "3.15.0-rc1", - "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.15.0-rc1.tgz", - "integrity": "sha512-SmMpegEmd4XWbPs9CRDniv3GNVkGjXO1FlpVhCP4xZMLCtyWLTMdAIfxgwIRzRHhWQKzSn0W4XryXWNN/Hknzg==", + "version": "3.15.1-rc1", + "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.15.1-rc1.tgz", + "integrity": "sha512-HJJ9QzEsc/KEZPF2MpVWhJ4/mmJzDyy2AC+9eqB9kL79krQII7RNYBfxQLobDnl2zwmNQsENnP8dlmzRGDhZxg==", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.15.0-rc1" + "@liveblocks/core": "3.15.1-rc1" } }, "node_modules/@liveblocks/core": { - "version": "3.15.0-rc1", - "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.15.0-rc1.tgz", - "integrity": "sha512-B88HJ974l0V1oteSH+fePTGCx3jUHvU2CAEB+pKGQjV4SOr6XcHJkLd5kRULVGLDhl6xBveqzxHhH7UXRK+MHg==", + "version": "3.15.1-rc1", + "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.15.1-rc1.tgz", + "integrity": "sha512-ha4atG61rGxlhRUJMnIrdam4PwZ3C0imkHRp+/Lbnb6TkLUJzcmCoHZcp2igVbOfQDrVG+P7wJn8b6JUVRNaVw==", "license": "Apache-2.0", "peerDependencies": { "@types/json-schema": "^7" } }, "node_modules/@liveblocks/node": { - "version": "3.15.0-rc1", - "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.15.0-rc1.tgz", - "integrity": "sha512-hc52BJVkX9nXvtTnqYhSD0H/y4a5p5MSyC0LFpDT061SDQixxZZp5vfHbOT61knhQGE9Q3FoTVliGqUrYPczMA==", + "version": "3.15.1-rc1", + "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.15.1-rc1.tgz", + "integrity": "sha512-ELKE7qyMT4boYJ0wAWwQxOKX+qzV56cBFaMo7MimL0fimbzyxkQAcxNYw0TksZ4UvspwuO+5s04Qap9py+s9tA==", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.15.0-rc1", + "@liveblocks/core": "3.15.1-rc1", "@stablelib/base64": "^1.0.1", "fast-sha256": "^1.3.0", "node-fetch": "^2.6.1" } }, "node_modules/@liveblocks/react": { - "version": "3.15.0-rc1", - "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.15.0-rc1.tgz", - "integrity": "sha512-F3X3QTH896Kj+ohRjWjbETb82dIYDfApJEsoU5ruwF9Omx/hn9A/PYdb8PQ/vNhVt2R7FtesN/mnA3pJugyq7w==", + "version": "3.15.1-rc1", + "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.15.1-rc1.tgz", + "integrity": "sha512-vVRka7bhT5VMAGhTcdWJR03w3+mUO7OhIxkDQ9MQ9ClQO0weUQq9AVR2Zgt5DxOQ56LGAZ01bGPh+96/hU50aw==", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.15.0-rc1", - "@liveblocks/core": "3.15.0-rc1" + "@liveblocks/client": "3.15.1-rc1", + "@liveblocks/core": "3.15.1-rc1" }, "peerDependencies": { "@types/react": "*", @@ -598,15 +598,15 @@ } }, "node_modules/@liveblocks/react-ui": { - "version": "3.15.0-rc1", - "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.15.0-rc1.tgz", - "integrity": "sha512-qX7FMROa/vMbjNriWvOFKo3uUAGfCeHssiMeCegkR7CpJNFAL0pUiAZMkrzs5Vf3/AyU9JaKkhu9FujavJlhRA==", + "version": "3.15.1-rc1", + "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.15.1-rc1.tgz", + "integrity": "sha512-+jQdsHcwsypFc77amDPjy2Q0xzV4qpZfJmf5wsqWrMYAFKoKS3UouJh/VE1Vnyjh+in0DvVha0yP/MrqA7iBLA==", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.0", - "@liveblocks/client": "3.15.0-rc1", - "@liveblocks/core": "3.15.0-rc1", - "@liveblocks/react": "3.15.0-rc1", + "@liveblocks/client": "3.15.1-rc1", + "@liveblocks/core": "3.15.1-rc1", + "@liveblocks/react": "3.15.1-rc1", "frimousse": "^0.2.0", "marked": "^15.0.11", "radix-ui": "^1.4.0", diff --git a/examples/nextjs-comments-ag-grid/package.json b/examples/nextjs-comments-ag-grid/package.json index 75e0c70978..c60f8b132c 100644 --- a/examples/nextjs-comments-ag-grid/package.json +++ b/examples/nextjs-comments-ag-grid/package.json @@ -9,10 +9,10 @@ "start": "next start" }, "dependencies": { - "@liveblocks/client": "^3.15.0-rc1", - "@liveblocks/node": "^3.15.0-rc1", - "@liveblocks/react": "^3.15.0-rc1", - "@liveblocks/react-ui": "^3.15.0-rc1", + "@liveblocks/client": "^3.15.1-rc1", + "@liveblocks/node": "^3.15.1-rc1", + "@liveblocks/react": "^3.15.1-rc1", + "@liveblocks/react-ui": "^3.15.1-rc1", "ag-grid-community": "^35.1.0", "ag-grid-react": "^35.1.0", "next": "^16.1.6", From 9cb71a781e0cf97cf30de2374377bc9d4a8bd15e Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Fri, 6 Mar 2026 14:29:05 +0000 Subject: [PATCH 4/5] =?UTF-8?q?CHANGELOG=20=E2=80=94=20WEEK=2010=20(#3133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Marc Bouchenoire --- CHANGELOG_PUBLIC.md | 50 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/CHANGELOG_PUBLIC.md b/CHANGELOG_PUBLIC.md index 0b37d3d187..c2eafec0f5 100644 --- a/CHANGELOG_PUBLIC.md +++ b/CHANGELOG_PUBLIC.md @@ -18,6 +18,56 @@ list and feel free to give them credit at the end of a line, e.g.: --> +# Week 10 (2026-03-06) + +## v3.15.0 + +### `@liveblocks/react-ui` + +- Add various new ways to customize `Thread` and `Comment`: + - Comments in `Thread` can now be overridden or customized via the + `components` prop. + - New parts of `Comment` (content, avatar, author, and date) can now be + overridden or customized via the `children`, `additionalContent`, `avatar`, + `author`, and `date` props. +- Fix `commentDropdownItems` prop on `Thread` not working as expected in some + cases. + +### `@liveblocks/react` + +- Each `createRoomContext()` invocation now creates its own isolated context to + allow nesting independent room contexts and their `RoomProvider` components. + +### `@liveblocks/react-blocknote` + +- Support newer BlockNote versions and bump the minimum required version to + v0.43.0. (Thanks @nperez0111 for the contribution!) + +### `@liveblocks/react-ui`, `@liveblocks/react-tiptap`, and `@liveblocks/react-lexical` + +- Improve how inline components passed to `components={{ ... }}` props are + handled by keeping them stable instead of re-mounting them on every render. +- Move `@radix-ui/*` dependencies to the `radix-ui` mono package. + +## Examples + +- New example: [AG Grid Comments](https://liveblocks.io/examples/ag-grid-comments/nextjs-comments-ag-grid). +- Update old examples to use new presence and commenting components. + +## Documentation + +- New quickstart: [Draggable comments with Next.js](https://liveblocks.io/docs/get-started/nextjs-comments-canvas). +- New quickstart: [Commenting inside AG Grid with Next.js](https://liveblocks.io/docs/get-started/nextjs-comments-ag-grid). +- New quickstart: [Commenting inside a table with Next.js](https://liveblocks.io/docs/get-started/nextjs-comments-table). +- New quickstart: [Realtime avatar and cursor presence with Next.js](https://liveblocks.io/docs/get-started/nextjs-presence). +- New guide: [How to add users to Liveblocks presence components](https://liveblocks.io/docs/guides/how-to-add-users-to-liveblocks-presence-components). +- Mention `sk_localdev` and `pk_localdev` keys more explicitly in dev server docs. +- Mention `["comments:write"]` permission under authentication. + +## Contributors + +nperez0111, marcbouchenoire, ctnicholas + # Week 9 (2026-02-27) ## v3.14.1 From b0087647d0fc28a2cdf5d48f05ad32acdb82b65c Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Fri, 6 Mar 2026 16:09:37 +0100 Subject: [PATCH 5/5] Rewrite core unit tests to use the real dev server (#3157) --- packages/liveblocks-core/package.json | 4 +- ...s.ts => _MockWebSocketServer.behaviors.ts} | 6 +- ...utils.ts => _MockWebSocketServer.setup.ts} | 51 +- .../src/__tests__/_MockWebSocketServer.ts | 7 + .../src/__tests__/_devserver.ts | 340 ++++ .../src/__tests__/_waitUtils.ts | 20 +- .../src/__tests__/connection.test.ts | 2 +- .../src/__tests__/immutable.test.ts | 494 +++-- .../src/__tests__/room.devserver.test.ts | 275 +++ .../{room.test.ts => room.mockserver.test.ts} | 229 +-- .../__tests__/LiveList.devserver.test.ts | 820 ++++++++ .../__tests__/LiveList.mockserver.test.ts | 930 +++++++++ .../src/crdts/__tests__/LiveList.test.ts | 1679 ----------------- ...eMap.test.ts => LiveMap.devserver.test.ts} | 577 +++--- .../__tests__/LiveMap.mockserver.test.ts | 58 + ...t.test.ts => LiveObject.devserver.test.ts} | 1008 +++++----- .../__tests__/LiveObject.mockserver.test.ts | 161 ++ .../src/crdts/__tests__/cloning.test.ts | 45 +- .../src/crdts/__tests__/doc.test.ts | 256 +-- .../__tests__/liveblocks-helpers.test.ts | 2 +- packages/liveblocks-core/src/room.ts | 6 +- packages/liveblocks-core/vitest.config.ts | 1 + 22 files changed, 3768 insertions(+), 3203 deletions(-) rename packages/liveblocks-core/src/__tests__/{_behaviors.ts => _MockWebSocketServer.behaviors.ts} (98%) rename packages/liveblocks-core/src/__tests__/{_utils.ts => _MockWebSocketServer.setup.ts} (95%) create mode 100644 packages/liveblocks-core/src/__tests__/_devserver.ts create mode 100644 packages/liveblocks-core/src/__tests__/room.devserver.test.ts rename packages/liveblocks-core/src/__tests__/{room.test.ts => room.mockserver.test.ts} (91%) create mode 100644 packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts create mode 100644 packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts delete mode 100644 packages/liveblocks-core/src/crdts/__tests__/LiveList.test.ts rename packages/liveblocks-core/src/crdts/__tests__/{LiveMap.test.ts => LiveMap.devserver.test.ts} (52%) create mode 100644 packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts rename packages/liveblocks-core/src/crdts/__tests__/{LiveObject.test.ts => LiveObject.devserver.test.ts} (55%) create mode 100644 packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json index 45719b73fe..dd361f1c4f 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -37,8 +37,8 @@ "format": "(eslint --fix src/ e2e/ || true) && prettier --write src/ e2e/", "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", - "test": "NODE_OPTIONS=\"--no-deprecation\" vitest run", - "test:ci": "NODE_OPTIONS=\"--no-deprecation\" vitest run", + "test": "npx liveblocks dev -c 'vitest run --coverage'", + "test:ci": "vitest run", "test:types": "ls test-d/* | xargs -n1 tsd --files", "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest", "test:e2e": "npx liveblocks dev -p 1154 -c 'vitest run --config=./vitest.config.e2e.ts'", diff --git a/packages/liveblocks-core/src/__tests__/_behaviors.ts b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.behaviors.ts similarity index 98% rename from packages/liveblocks-core/src/__tests__/_behaviors.ts rename to packages/liveblocks-core/src/__tests__/_MockWebSocketServer.behaviors.ts index fbfa51f0dd..17d87d7206 100644 --- a/packages/liveblocks-core/src/__tests__/_behaviors.ts +++ b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.behaviors.ts @@ -18,7 +18,11 @@ import { ServerMsgCode } from "../protocol/ServerMsg"; import type { WebsocketCloseCodes } from "../types/IWebSocket"; import type { MockWebSocket } from "./_MockWebSocketServer"; import { MockWebSocketServer } from "./_MockWebSocketServer"; -import { makeAccessToken, makeIDToken, serverMessage } from "./_utils"; +import { + makeAccessToken, + makeIDToken, + serverMessage, +} from "./_MockWebSocketServer.setup"; type AuthBehavior = () => AuthValue; type SocketBehavior = (wss: MockWebSocketServer) => MockWebSocket; diff --git a/packages/liveblocks-core/src/__tests__/_utils.ts b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts similarity index 95% rename from packages/liveblocks-core/src/__tests__/_utils.ts rename to packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts index 8eb6caf9d9..fc3e64c539 100644 --- a/packages/liveblocks-core/src/__tests__/_utils.ts +++ b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts @@ -1,3 +1,11 @@ +/** + * MockWebSocket-based test utilities for testing edge cases that cannot be + * tested against the real dev server: connection state machine (auth, + * reconnection, backoff, close codes), wire protocol message inspection, + * and CRDT conflict resolution with precise op injection. + * + * For normal storage/presence/history tests, use `_devserver.ts`. + */ import { expect, onTestFinished } from "vitest"; import { createApiClient } from "../api-client"; @@ -34,13 +42,13 @@ import type { Room, RoomConfig, RoomDelegates, SyncSource } from "../room"; import { createRoom } from "../room"; import { WebsocketCloseCodes } from "../types/IWebSocket"; import type { LiveblocksError } from "../types/LiveblocksError"; +import type { MockWebSocketServer } from "./_MockWebSocketServer"; +import { MockWebSocket } from "./_MockWebSocketServer"; import { ALWAYS_AUTH_WITH_ACCESS_TOKEN, defineBehavior, SOCKET_AUTOCONNECT_AND_ROOM_STATE, -} from "./_behaviors"; -import type { MockWebSocketServer } from "./_MockWebSocketServer"; -import { MockWebSocket } from "./_MockWebSocketServer"; +} from "./_MockWebSocketServer.behaviors"; import type { JsonStorageUpdate } from "./_updatesUtils"; import { serializeUpdateToJson } from "./_updatesUtils"; @@ -527,43 +535,6 @@ export async function prepareStorageUpdateTest< }; } -/** - * Create a room, join with the client but sync local storage changes with the server - */ -export async function prepareDisconnectedStorageUpdateTest< - S extends LsonObject, - P extends JsonObject = never, - U extends BaseUserMeta = never, - E extends Json = never, - TM extends BaseMetadata = never, - CM extends BaseMetadata = never, ->(items: StorageNode[]) { - const { storage, room } = await prepareRoomWithStorage( - items, - -1 - ); - - const receivedUpdates: JsonStorageUpdate[][] = []; - - onTestFinished( - room.subscribe( - storage.root, - (updates) => receivedUpdates.push(updates.map(serializeUpdateToJson)), - { isDeep: true } - ) - ); - - function expectUpdates(updates: JsonStorageUpdate[][]) { - expect(receivedUpdates).toEqual(updates); - } - - return { - room, - root: storage.root, - expectUpdates, - }; -} - export function replaceRemoteStorageAndReconnect( wss: MockWebSocketServer, nextStorageItems: StorageNode[] diff --git a/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.ts b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.ts index b863febc54..3bed439a36 100644 --- a/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.ts +++ b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.ts @@ -1,3 +1,10 @@ +/** + * Mock WebSocket server for testing connection edge cases. + * + * Used by room.mockserver.test.ts and *.mockserver.test.ts for scenarios + * requiring low-level socket control. Normal tests use the real dev server + * instead. See `_devserver.ts` for dev-server-based test helpers. + */ import type { EventSource, Observable } from "../lib/EventSource"; import { makeEventSource } from "../lib/EventSource"; import type { Json } from "../lib/Json"; diff --git a/packages/liveblocks-core/src/__tests__/_devserver.ts b/packages/liveblocks-core/src/__tests__/_devserver.ts new file mode 100644 index 0000000000..0409662d48 --- /dev/null +++ b/packages/liveblocks-core/src/__tests__/_devserver.ts @@ -0,0 +1,340 @@ +/** + * Test utilities for running @liveblocks/core unit tests against the real + * local dev server at localhost:1154. + */ +import { expect, onTestFinished, vi } from "vitest"; + +import { createClient } from "../client"; +import type { LsonObject } from "../crdts/Lson"; +import type { ToImmutable } from "../crdts/utils"; +import { kInternal } from "../internal"; +import type { JsonObject } from "../lib/Json"; +import { nanoid } from "../lib/nanoid"; +import type { PlainLsonObject } from "../types/PlainLson"; +import type { JsonStorageUpdate } from "./_updatesUtils"; +import { serializeUpdateToJson } from "./_updatesUtils"; + +const DEV_SERVER = "http://localhost:1154"; + +export function randomRoomId(): string { + return `room-${nanoid()}`; +} + +/** + * Creates a room on the dev server and initializes its storage. + * Returns the room ID, which can be passed to enterAndConnect(). + */ +export async function initRoom(storage?: PlainLsonObject): Promise { + const roomId = randomRoomId(); + + // Create the room + await fetch(`${DEV_SERVER}/v2/rooms`, { + method: "POST", + headers: { + Authorization: "Bearer sk_localdev", + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: roomId }), + }); + + // Initialize its storage + if (storage) { + await fetch( + `${DEV_SERVER}/v2/rooms/${encodeURIComponent(roomId)}/storage`, + { + method: "POST", + headers: { + Authorization: "Bearer sk_localdev", + "Content-Type": "application/json", + }, + body: JSON.stringify(storage), + } + ); + } + return roomId; +} + +async function UNSAFE_generateAccessToken( + roomId?: string, + permissions?: string[] +) { + const res = await fetch(`${DEV_SERVER}/v2/authorize-user`, { + method: "POST", + headers: { + Authorization: "Bearer sk_localdev", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId: `user-${nanoid()}`, + userInfo: { name: "Testy McTester" }, + permissions: { [roomId!]: permissions ?? ["room:write"] }, + }), + }); + return (await res.json()) as { token: string }; +} + +export function createTestClient(permissions?: string[]) { + return createClient({ + baseUrl: DEV_SERVER, + authEndpoint: (roomId) => UNSAFE_generateAccessToken(roomId, permissions), + polyfills: { WebSocket: globalThis.WebSocket }, + __DANGEROUSLY_disableThrottling: true, + }); +} + +/** + * Enters a room, waits for "connected" status, and registers cleanup + * via onTestFinished. + */ +export async function enterAndConnect( + roomId: string, + opts?: { initialStorage?: S; permissions?: string[] } +) { + const client = createTestClient(opts?.permissions); + const { room, leave } = client.enterRoom( + roomId, + // @ts-expect-error Test helper passes options directly + { + initialPresence: {}, + initialStorage: (opts?.initialStorage ?? {}) as S, + } + ); + + onTestFinished(leave); + + await vi.waitUntil(() => room.getStatus() === "connected"); + return { room, leave }; +} + +/** + * Like enterAndConnect, but also calls getStorage() before returning. + * Useful for parallelizing connection + storage fetch across multiple clients. + */ +export async function enterConnectAndGetStorage( + roomId: string, + opts?: { permissions?: string[] } +) { + const { room, leave } = await enterAndConnect(roomId, opts); + const storage = await room.getStorage(); + return { room, leave, storage }; +} + +/** + * Atomically replaces a room's storage on the dev server and disconnects all + * clients, forcing them to reconnect and reconcile with the new storage. + * + * TODO: This does not exist yet in the dev server. + * See https://linear.app/liveblocks/issue/LB-3529/dev-server-needs-support-for-a-crash-replace-storage-atomic-feature + * + * The dev server needs a new endpoint (e.g. POST /v2/rooms/{roomId}/storage + * with force-replace semantics) that: + * 1. Replaces the room's storage with the provided PlainLsonObject + * 2. Disconnects all connected clients in that room + * 3. Clients reconnect automatically and receive the new storage + * 4. The client reconciles the diff and fires subscription callbacks + */ +export async function replaceStorageAndReconnectDevServer( + roomId: string, + newStorage: PlainLsonObject +): Promise { + await fetch(`${DEV_SERVER}/v2/rooms/${encodeURIComponent(roomId)}/storage`, { + method: "POST", + headers: { + Authorization: "Bearer sk_localdev", + "Content-Type": "application/json", + }, + body: JSON.stringify(newStorage), + }); +} + +// --------------------------------------------------------------------------- +// High-level test helpers +// --------------------------------------------------------------------------- + +function deepCloneWithoutOpId(item: T) { + return JSON.parse( + JSON.stringify(item), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + (key, value) => (key === "opId" ? undefined : value) + ) as T; +} + +/** + * Two clients (A and B) connected to the same room via the real dev server. + * + * Returns { roomA, roomB, storageA, storageB, expectStorage, assertUndoRedo }. + * + * - expectStorage(data) is async: asserts client A's storage equals `data`, + * then waits for client B to sync to the same state. + * - assertUndoRedo() is async: walks the full undo/redo stack on client A, + * verifying client B stays in sync at each step. + */ +export async function prepareStorageTest( + initialStorage: PlainLsonObject +) { + const roomId = await initRoom(initialStorage); + + const [clientA, clientB] = await Promise.all([ + enterConnectAndGetStorage(roomId), + enterConnectAndGetStorage(roomId), + ]); + + const storageA = clientA.storage; + const storageB = clientB.storage; + + // Wait for both clients to have synced initial storage + await vi.waitFor(() => { + expect(storageA.root.toImmutable()).toEqual(storageB.root.toImmutable()); + }); + + const states: ToImmutable[] = []; + + async function expectBothClientStoragesToEqual(data: ToImmutable) { + expect(storageA.root.toImmutable()).toEqual(data); + + await vi.waitFor(() => { + expect(storageB.root.toImmutable()).toEqual(data); + }); + + expect(clientA.room[kInternal].nodeCount).toBe( + clientB.room[kInternal].nodeCount + ); + } + + async function expectStorage(data: ToImmutable) { + states.push(data); + await expectBothClientStoragesToEqual(data); + } + + async function assertUndoRedo() { + const before = deepCloneWithoutOpId( + clientA.room[kInternal].undoStack[ + clientA.room[kInternal].undoStack.length - 1 + ] + ); + + // Undo the whole stack + for (let i = 0; i < states.length - 1; i++) { + clientA.room.history.undo(); + await expectBothClientStoragesToEqual(states[states.length - 2 - i]); + } + + // Redo the whole stack + for (let i = 0; i < states.length - 1; i++) { + clientA.room.history.redo(); + await expectBothClientStoragesToEqual(states[i + 1]); + } + + const after = deepCloneWithoutOpId( + clientA.room[kInternal].undoStack[ + clientA.room[kInternal].undoStack.length - 1 + ] + ); + + // It should be identical before/after + expect(before).toEqual(after); + + // Undo everything again + for (let i = 0; i < states.length - 1; i++) { + clientA.room.history.undo(); + await expectBothClientStoragesToEqual(states[states.length - 2 - i]); + } + } + + return { + roomA: clientA.room, + roomB: clientB.room, + storageA, + storageB, + expectStorage, + assertUndoRedo, + }; +} + +/** + * Single client connected to a room via the real dev server. + * + * Returns { root, room, expectStorage }. + * + * - expectStorage(data) is synchronous: just asserts the single client's + * storage equals `data` (no second client to wait for). + */ +export async function prepareIsolatedStorageTest( + initialStorage?: PlainLsonObject, + opts?: { permissions?: string[] } +) { + const roomId = initialStorage + ? await initRoom(initialStorage) + : randomRoomId(); + + const { room, storage } = await enterConnectAndGetStorage(roomId, { + permissions: opts?.permissions, + }); + + function expectStorage(data: ToImmutable) { + expect(storage.root.toImmutable()).toEqual(data); + } + + return { + root: storage.root, + room, + expectStorage, + }; +} + +/** + * Two clients (A and B) connected to the same room via the real dev server, + * both subscribed to storageBatch events. + * + * Returns { roomA, roomB, rootA, expectUpdates }. + * + * - expectUpdates(updates) is async: asserts client A received the expected + * update batches, then waits for client B to receive the same. + */ +export async function prepareStorageUpdateTest( + initialStorage: PlainLsonObject +) { + const roomId = await initRoom(initialStorage); + + const [clientA, clientB] = await Promise.all([ + enterConnectAndGetStorage(roomId), + enterConnectAndGetStorage(roomId), + ]); + + const storageA = clientA.storage; + const storageB = clientB.storage; + + // Wait for both clients to have synced initial storage + await vi.waitFor(() => { + expect(storageA.root.toImmutable()).toEqual(storageB.root.toImmutable()); + }); + + const updatesA: JsonStorageUpdate[][] = []; + const updatesB: JsonStorageUpdate[][] = []; + + onTestFinished( + clientA.room.events.storageBatch.subscribe((updates) => + updatesA.push(updates.map(serializeUpdateToJson)) + ) + ); + onTestFinished( + clientB.room.events.storageBatch.subscribe((updates) => + updatesB.push(updates.map(serializeUpdateToJson)) + ) + ); + + async function expectUpdates(updates: JsonStorageUpdate[][]) { + expect(updatesA).toEqual(updates); + + await vi.waitFor(() => { + expect(updatesB).toEqual(updates); + }); + } + + return { + roomA: clientA.room, + roomB: clientB.room, + rootA: storageA.root, + expectUpdates, + }; +} diff --git a/packages/liveblocks-core/src/__tests__/_waitUtils.ts b/packages/liveblocks-core/src/__tests__/_waitUtils.ts index fd70be44be..63151a91ba 100644 --- a/packages/liveblocks-core/src/__tests__/_waitUtils.ts +++ b/packages/liveblocks-core/src/__tests__/_waitUtils.ts @@ -1,25 +1,7 @@ import type { Status } from "../connection"; -import { wait, withTimeout } from "../lib/utils"; +import { withTimeout } from "../lib/utils"; import type { OpaqueRoom } from "../room"; -export async function waitFor(predicate: () => boolean): Promise { - const result = predicate(); - if (result) { - return; - } - - const time = new Date().getTime(); - - while (new Date().getTime() - time < 2000) { - await wait(100); - if (predicate()) { - return; - } - } - - throw new Error("TIMEOUT"); -} - /** * Handy helper that allows to pause test execution until the room has * asynchronously reached a particular status. Status must be reached within diff --git a/packages/liveblocks-core/src/__tests__/connection.test.ts b/packages/liveblocks-core/src/__tests__/connection.test.ts index b5fb101fb7..62be6d1d6f 100644 --- a/packages/liveblocks-core/src/__tests__/connection.test.ts +++ b/packages/liveblocks-core/src/__tests__/connection.test.ts @@ -8,7 +8,7 @@ import { SOCKET_AUTOCONNECT_AND_ROOM_STATE, SOCKET_AUTOCONNECT_BUT_NO_ROOM_STATE, SOCKET_NO_BEHAVIOR, -} from "./_behaviors"; +} from "./_MockWebSocketServer.behaviors"; describe("ManagedSocket", () => { test("failure to authenticate", async () => { diff --git a/packages/liveblocks-core/src/__tests__/immutable.test.ts b/packages/liveblocks-core/src/__tests__/immutable.test.ts index 51ae278f28..2eef4a42b2 100644 --- a/packages/liveblocks-core/src/__tests__/immutable.test.ts +++ b/packages/liveblocks-core/src/__tests__/immutable.test.ts @@ -22,107 +22,83 @@ import { } from "../immutable"; import { kInternal } from "../internal"; import * as console from "../lib/fancy-console"; -import type { Json, JsonObject } from "../lib/Json"; -import type { BaseUserMeta } from "../protocol/BaseUserMeta"; -import { ClientMsgCode } from "../protocol/ClientMsg"; -import type { BaseMetadata } from "../protocol/Comments"; -import { ServerMsgCode } from "../protocol/ServerMsg"; -import type { StorageNode } from "../protocol/StorageNode"; -import { - createSerializedList, - createSerializedObject, - createSerializedRegister, - createSerializedRoot, - FIRST_POSITION, - FOURTH_POSITION, - parseAsClientMsgs, - prepareRoomWithStorage, - SECOND_POSITION, - serverMessage, - THIRD_POSITION, -} from "./_utils"; - -export async function prepareStorageImmutableTest< - S extends LsonObject, - P extends JsonObject = never, - U extends BaseUserMeta = never, - E extends Json = never, - TM extends BaseMetadata = never, - CM extends BaseMetadata = never, ->(items: StorageNode[], actor: number = 0) { - let state = {} as ToJson; - let refState = {} as ToJson; - - let totalStorageOps = 0; - - const ref = await prepareRoomWithStorage(items, -1); - - const subject = await prepareRoomWithStorage( - items, - actor - ); - - onTestFinished( - subject.wss.onReceive.subscribe((data) => { - const messages = parseAsClientMsgs(data); - for (const message of messages) { - if (message.type === ClientMsgCode.UPDATE_STORAGE) { - totalStorageOps += message.ops.length; - ref.wss.last.send( - serverMessage({ - type: ServerMsgCode.UPDATE_STORAGE, - ops: message.ops, - }) - ); - subject.wss.last.send( - serverMessage({ - type: ServerMsgCode.UPDATE_STORAGE, - ops: message.ops, - }) - ); - } - } - }) - ); +import type { JsonObject } from "../lib/Json"; +import type { PlainLsonObject } from "../types/PlainLson"; +import { enterConnectAndGetStorage, initRoom } from "./_devserver"; + +/** + * Sets up two real clients (A and B) connected to the same room via the dev + * server, with storage initialized from `initialStorage`. + * + * Returns: + * - `storage` — client A's storage root (the "active" client that tests + * mutate via `patchLiveObjectKey` / `patchLiveObject`) + * - `refStorage` — client B's storage root (the "reference" client that + * passively receives changes via server-mediated sync) + * - `state` — a plain-JSON mirror of client A's storage, shared by reference + * with the caller. Tests mutate this object directly (e.g. + * `state.foo = "bar"`) and then call `patchLiveObjectKey` to propagate + * the diff into the live CRDT tree. + * - `expectStorageAndState(data, itemsCount?)` — asserts that both clients' + * storage equals `data`, that the CRDT node count matches `itemsCount` + * (if provided), and that the JSON `state` mirror was kept in sync by + * the `storageBatch` subscription. + * - `expectStorage(data)` — lighter variant that only checks storage equality + * across both clients (no node count or state mirror checks). + */ +export async function prepareStorageImmutableTest( + initialStorage: PlainLsonObject +) { + const roomId = await initRoom(initialStorage); + + const [clientA, clientB] = await Promise.all([ + enterConnectAndGetStorage(roomId), + enterConnectAndGetStorage(roomId), + ]); + + const storageA = clientA.storage; + const storageB = clientB.storage; + + // Wait for both clients to sync initial storage + await vi.waitFor(() => { + expect(lsonToJson(storageA.root)).toEqual(lsonToJson(storageB.root)); + }); - state = lsonToJson(subject.storage.root) as ToJson; - refState = lsonToJson(ref.storage.root) as ToJson; + const state = lsonToJson(storageA.root) as ToJson; + let refState = lsonToJson(storageB.root) as ToJson; onTestFinished( - ref.room.events.storageBatch.subscribe(() => { - refState = lsonToJson(ref.storage.root) as ToJson; + clientB.room.events.storageBatch.subscribe(() => { + refState = lsonToJson(storageB.root) as ToJson; }) ); - function expectStorageAndStateInBothClients( - data: ToJson, - itemsCount?: number, - storageOpsCount?: number - ) { - expectStorageInBothClients(data); + async function expectStorageAndState(data: ToJson, itemsCount?: number) { + expect(lsonToJson(storageA.root)).toEqual(data); + + await vi.waitFor(() => { + expect(lsonToJson(storageB.root)).toEqual(data); + }); if (itemsCount !== undefined) { - expect(subject.room[kInternal].nodeCount).toBe(itemsCount); + expect(clientA.room[kInternal].nodeCount).toBe(itemsCount); } expect(state).toEqual(refState); expect(state).toEqual(data); - - if (storageOpsCount !== undefined) { - expect(totalStorageOps).toEqual(storageOpsCount); - } } - function expectStorageInBothClients(data: ToJson) { - const json = lsonToJson(subject.storage.root); - expect(json).toEqual(data); - expect(lsonToJson(ref.storage.root)).toEqual(data); + async function expectStorage(data: ToJson) { + expect(lsonToJson(storageA.root)).toEqual(data); + await vi.waitFor(() => { + expect(lsonToJson(storageB.root)).toEqual(data); + }); } return { - storage: subject.storage, - refStorage: ref.storage, - expectStorageAndState: expectStorageAndStateInBothClients, - expectStorage: expectStorageInBothClients, + storage: storageA, + refStorage: storageB, + expectStorageAndState, + expectStorage, state, }; } @@ -171,7 +147,10 @@ describe("2 ways tests with two clients", () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncObj: { a: number }; - }>([createSerializedRoot()], 1); + }>({ + liveblocksType: "LiveObject", + data: {}, + }); expect(state).toEqual({}); @@ -186,20 +165,19 @@ describe("2 ways tests with two clients", () => { newState["syncObj"] ); - expectStorageAndState({ syncObj: { a: 1 } }, 2, 1); + await expectStorageAndState({ syncObj: { a: 1 } }, 2); }); test("update object", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncObj: { a: number }; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 0 }, "root", "syncObj"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncObj: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }); expect(state).toEqual({ syncObj: { a: 0 } }); @@ -214,20 +192,19 @@ describe("2 ways tests with two clients", () => { newState["syncObj"] ); - expectStorageAndState({ syncObj: { a: 1 } }, 2, 1); + await expectStorageAndState({ syncObj: { a: 1 } }, 2); }); test("add nested object", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncObj: { a: any }; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 0 }, "root", "syncObj"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncObj: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }); expect(state).toEqual({ syncObj: { a: 0 } }); @@ -242,20 +219,19 @@ describe("2 ways tests with two clients", () => { newState["syncObj"] ); - expectStorageAndState({ syncObj: { a: { subA: "ok" } } }, 3, 1); + await expectStorageAndState({ syncObj: { a: { subA: "ok" } } }, 3); }); test("create LiveList with one LiveRegister item in same batch", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ doc: any; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", {}, "root", "doc"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + doc: { liveblocksType: "LiveObject", data: {} }, + }, + }); expect(state).toEqual({ doc: {} }); @@ -265,20 +241,19 @@ describe("2 ways tests with two clients", () => { patchLiveObjectKey(storage.root, "doc", oldState["doc"], newState["doc"]); - expectStorageAndState({ doc: { sub: [0] } }, 4, 2); + await expectStorageAndState({ doc: { sub: [0] } }, 4); }); test("create nested LiveList with one LiveObject item in same batch", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ doc: any; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", {}, "root", "doc"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + doc: { liveblocksType: "LiveObject", data: {} }, + }, + }); expect(state).toEqual({ doc: {} }); @@ -288,20 +263,19 @@ describe("2 ways tests with two clients", () => { patchLiveObjectKey(storage.root, "doc", oldState["doc"], newState["doc"]); - expectStorageAndState({ doc: { sub: { subSub: [{ a: 1 }] } } }, 5, 3); + await expectStorageAndState({ doc: { sub: { subSub: [{ a: 1 }] } } }, 5); }); test("Add nested objects in same batch", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ doc: any; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", {}, "root", "doc"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + doc: { liveblocksType: "LiveObject", data: {} }, + }, + }); expect(state).toEqual({ doc: {} }); @@ -311,20 +285,19 @@ describe("2 ways tests with two clients", () => { patchLiveObjectKey(storage.root, "doc", oldState["doc"], newState["doc"]); - expectStorageAndState({ doc: { pos: { a: { b: 1 } } } }, 4, 2); + await expectStorageAndState({ doc: { pos: { a: { b: 1 } } } }, 4); }); test("delete object key", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncObj: { a?: number }; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 0 }, "root", "syncObj"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncObj: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }); expect(state).toEqual({ syncObj: { a: 0 } }); @@ -339,7 +312,7 @@ describe("2 ways tests with two clients", () => { newState["syncObj"] ); - expectStorageAndState({ syncObj: {} }, 2, 1); + await expectStorageAndState({ syncObj: {} }, 2); }); }); @@ -348,16 +321,12 @@ describe("2 ways tests with two clients", () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncList: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "syncList"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, 1), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, 1), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, 1), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncList: { liveblocksType: "LiveList", data: [1, 1, 1] }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.syncList = [2]; @@ -370,20 +339,19 @@ describe("2 ways tests with two clients", () => { newState["syncList"] ); - expectStorageAndState({ syncList: [2] }); + await expectStorageAndState({ syncList: [2] }); }); test("add item to array", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncList: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "syncList"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncList: { liveblocksType: "LiveList", data: [] }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.syncList.push("a"); @@ -396,23 +364,19 @@ describe("2 ways tests with two clients", () => { newState["syncList"] ); - expectStorageAndState({ syncList: ["a"] }, 3, 1); + await expectStorageAndState({ syncList: ["a"] }, 3); }); test("replace first item in array", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ list: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "list"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + list: { liveblocksType: "LiveList", data: ["A", "B", "C"] }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.list[0] = "D"; @@ -420,23 +384,19 @@ describe("2 ways tests with two clients", () => { patchLiveObject(storage.root, oldState, newState); - expectStorageAndState({ list: ["D", "B", "C"] }, 5, 1); + await expectStorageAndState({ list: ["D", "B", "C"] }, 5); }); test("replace last item in array", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ list: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "list"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + list: { liveblocksType: "LiveList", data: ["A", "B", "C"] }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.list[2] = "D"; @@ -444,21 +404,19 @@ describe("2 ways tests with two clients", () => { patchLiveObject(storage.root, oldState, newState); - expectStorageAndState({ list: ["A", "B", "D"] }, 5, 1); + await expectStorageAndState({ list: ["A", "B", "D"] }, 5); }); test("insert item at beginning of array", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncList: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "syncList"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncList: { liveblocksType: "LiveList", data: ["a"] }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.syncList.unshift("b"); @@ -471,24 +429,22 @@ describe("2 ways tests with two clients", () => { newState["syncList"] ); - expectStorageAndState({ syncList: ["b", "a"] }, 4, 1); + await expectStorageAndState({ syncList: ["b", "a"] }, 4); }); test("swap items in array", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncList: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "syncList"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "c"), - createSerializedRegister("0:5", "0:1", FOURTH_POSITION, "d"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncList: { + liveblocksType: "LiveList", + data: ["a", "b", "c", "d"], + }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.syncList = ["d", "b", "c", "a"]; @@ -501,21 +457,22 @@ describe("2 ways tests with two clients", () => { newState["syncList"] ); - expectStorageAndState({ syncList: ["d", "b", "c", "a"] }, 6, 4); + await expectStorageAndState({ syncList: ["d", "b", "c", "a"] }, 6); }); test("array of objects", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncList: LiveList>; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "syncList"), - createSerializedObject("0:2", { a: 1 }, "0:1", FIRST_POSITION), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncList: { + liveblocksType: "LiveList", + data: [{ liveblocksType: "LiveObject", data: { a: 1 } }], + }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.syncList[0].a = 2; @@ -528,22 +485,19 @@ describe("2 ways tests with two clients", () => { newState["syncList"] ); - expectStorageAndState({ syncList: [{ a: 2 }] }, 3, 1); + await expectStorageAndState({ syncList: [{ a: 2 }] }, 3); }); test("remove first item from array", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncList: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "syncList"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncList: { liveblocksType: "LiveList", data: ["a", "b"] }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.syncList.shift(); @@ -556,22 +510,19 @@ describe("2 ways tests with two clients", () => { newState["syncList"] ); - expectStorageAndState({ syncList: ["b"] }, 3, 1); + await expectStorageAndState({ syncList: ["b"] }, 3); }); test("remove last item from array", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncList: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "syncList"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncList: { liveblocksType: "LiveList", data: ["a", "b"] }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.syncList.pop(); @@ -584,23 +535,22 @@ describe("2 ways tests with two clients", () => { newState["syncList"] ); - expectStorageAndState({ syncList: ["a"] }, 3, 1); + await expectStorageAndState({ syncList: ["a"] }, 3); }); test("remove all elements of array except first", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncList: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "syncList"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "c"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncList: { + liveblocksType: "LiveList", + data: ["a", "b", "c"], + }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.syncList = ["a"]; @@ -613,23 +563,22 @@ describe("2 ways tests with two clients", () => { newState["syncList"] ); - expectStorageAndState({ syncList: ["a"] }, 3, 2); + await expectStorageAndState({ syncList: ["a"] }, 3); }); test("remove all elements of array except last", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncList: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "syncList"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "c"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncList: { + liveblocksType: "LiveList", + data: ["a", "b", "c"], + }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.syncList = ["c"]; @@ -642,23 +591,22 @@ describe("2 ways tests with two clients", () => { newState["syncList"] ); - expectStorageAndState({ syncList: ["c"] }, 3, 2); + await expectStorageAndState({ syncList: ["c"] }, 3); }); test("remove all elements of array", async () => { const { storage, state, expectStorageAndState } = await prepareStorageImmutableTest<{ syncList: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "syncList"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "c"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncList: { + liveblocksType: "LiveList", + data: ["a", "b", "c"], + }, + }, + }); const { oldState, newState } = applyStateChanges(state, () => { state.syncList = []; @@ -671,7 +619,7 @@ describe("2 ways tests with two clients", () => { newState["syncList"] ); - expectStorageAndState({ syncList: [] }, 2, 3); + await expectStorageAndState({ syncList: [] }, 2); }); }); @@ -691,13 +639,12 @@ describe("2 ways tests with two clients", () => { test("new state contains a function", async () => { const { storage, state, expectStorage } = - await prepareStorageImmutableTest<{ syncObj: { a: any } }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 0 }, "root", "syncObj"), - ], - 1 - ); + await prepareStorageImmutableTest<{ syncObj: { a: any } }>({ + liveblocksType: "LiveObject", + data: { + syncObj: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }); expect(state).toEqual({ syncObj: { a: 0 } }); @@ -714,19 +661,18 @@ describe("2 ways tests with two clients", () => { expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - expectStorage({ syncObj: { a: 0 } }); + await expectStorage({ syncObj: { a: 0 } }); }); test("Production env - new state contains a function", async () => { const { storage, state } = await prepareStorageImmutableTest<{ syncObj: { a: any }; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 0 }, "root", "syncObj"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + syncObj: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }); expect(state).toEqual({ syncObj: { a: 0 } }); diff --git a/packages/liveblocks-core/src/__tests__/room.devserver.test.ts b/packages/liveblocks-core/src/__tests__/room.devserver.test.ts new file mode 100644 index 0000000000..590906b8af --- /dev/null +++ b/packages/liveblocks-core/src/__tests__/room.devserver.test.ts @@ -0,0 +1,275 @@ +/** + * Room tests that run against the real dev server. Covers history (undo/redo, + * pause/resume, batch, clear) and storage subscription behavior. + * + * For connection state machine, auth, reconnection, and wire protocol tests, + * see room.mockserver.test.ts. + */ +import { describe, expect, onTestFinished, test } from "vitest"; + +import { LiveList } from "../crdts/LiveList"; +import { LiveObject } from "../crdts/LiveObject"; +import type { StorageUpdate } from "../crdts/StorageUpdates"; +import { legacy_patchImmutableObject } from "../immutable"; +import { nn } from "../lib/assert"; +import { prepareIsolatedStorageTest } from "./_devserver"; +import type { JsonStorageUpdate } from "./_updatesUtils"; +import { + listUpdate, + listUpdateInsert, + listUpdateSet, + serializeUpdateToJson, +} from "./_updatesUtils"; + +describe("room (dev server)", () => { + test("pausing history twice is a no-op", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + items: LiveList>>; + }>({ + liveblocksType: "LiveObject", + data: { + items: { + liveblocksType: "LiveList", + data: [{ liveblocksType: "LiveObject", data: {} }], + }, + }, + }); + + const items = root.get("items"); + + room.history.pause(); + nn(items.get(0)).set("a", 1); + room.history.pause(); // Pausing again should be a no-op! + nn(items.get(0)).set("b", 2); + room.history.pause(); // Pausing again should be a no-op! + room.history.resume(); + room.history.resume(); // Resuming again should also be a no-op! + + expect(items.toImmutable()).toEqual([{ a: 1, b: 2 }]); + expect(room.history.canUndo()).toBe(true); + expect(room.history.canRedo()).toBe(false); + room.history.undo(); + + expect(items.toImmutable()).toEqual([{}]); + expect(room.history.canUndo()).toBe(false); + expect(room.history.canRedo()).toBe(true); + room.history.redo(); + + expect(items.toImmutable()).toEqual([{ a: 1, b: 2 }]); + expect(room.history.canUndo()).toBe(true); + expect(room.history.canRedo()).toBe(false); + }); + + test("undo redo batch", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + items: LiveList>>; + }>({ + liveblocksType: "LiveObject", + data: { + items: { + liveblocksType: "LiveList", + data: [{ liveblocksType: "LiveObject", data: {} }], + }, + }, + }); + + const receivedUpdates: JsonStorageUpdate[][] = []; + onTestFinished( + room.subscribe( + root, + (updates) => receivedUpdates.push(updates.map(serializeUpdateToJson)), + { isDeep: true } + ) + ); + + const items = root.get("items"); + room.batch(() => { + nn(items.get(0)).set("a", 1); + items.set(0, new LiveObject({ a: 2 })); + }); + + expect(items.toImmutable()).toEqual([{ a: 2 }]); + room.history.undo(); + + expect(items.toImmutable()).toEqual([{}]); + room.history.redo(); + + expect(items.toImmutable()).toEqual([{ a: 2 }]); + expect(receivedUpdates).toEqual([ + [listUpdate([{ a: 2 }], [listUpdateSet(0, { a: 2 })])], + [listUpdate([{}], [listUpdateSet(0, {})])], + [listUpdate([{ a: 2 }], [listUpdateSet(0, { a: 2 })])], + ]); + }); + + test("canUndo / canRedo", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + a: number; + }>({ + liveblocksType: "LiveObject", + data: { a: 1 }, + }); + + expect(room.history.canUndo()).toBe(false); + expect(room.history.canRedo()).toBe(false); + + root.set("a", 2); + + expect(room.history.canUndo()).toBe(true); + + room.history.undo(); + + expect(room.history.canRedo()).toBe(true); + }); + + test("clearing undo/redo stack", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + a: number; + }>({ + liveblocksType: "LiveObject", + data: { a: 1 }, + }); + + expect(room.history.canUndo()).toBe(false); + expect(room.history.canRedo()).toBe(false); + + root.set("a", 2); + root.set("a", 3); + root.set("a", 4); + room.history.undo(); + + expect(room.history.canUndo()).toBe(true); + expect(room.history.canRedo()).toBe(true); + + room.history.clear(); + expect(room.history.canUndo()).toBe(false); + expect(room.history.canRedo()).toBe(false); + + room.history.undo(); // won't do anything now + + expect(root.toObject()).toEqual({ a: 3 }); + }); + + describe("subscription", () => { + test("batch without operations should not add an item to the undo stack", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + a: number; + }>({ + liveblocksType: "LiveObject", + data: { a: 1 }, + }); + + root.set("a", 2); + + // Batch without operations on storage or presence + room.batch(() => {}); + + expectStorage({ a: 2 }); + + room.history.undo(); + + expectStorage({ a: 1 }); + }); + + test("batch storage with changes from server", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: [] }, + }, + }); + + const items = root.get("items"); + + room.batch(() => { + items.push("A"); + items.push("B"); + items.push("C"); + }); + + expectStorage({ + items: ["A", "B", "C"], + }); + + room.history.undo(); + + expectStorage({ + items: [], + }); + + room.history.redo(); + + expectStorage({ + items: ["A", "B", "C"], + }); + }); + + test("nested storage updates", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + items: LiveList }>>; + }>({ + liveblocksType: "LiveObject", + data: { + items: { + liveblocksType: "LiveList", + data: [ + { + liveblocksType: "LiveObject", + data: { + names: { liveblocksType: "LiveList", data: [] }, + }, + }, + ], + }, + }, + }); + + const jsonUpdates: JsonStorageUpdate[][] = []; + let receivedUpdates: StorageUpdate[] = []; + + onTestFinished( + room.events.storageBatch.subscribe((updates) => { + jsonUpdates.push(updates.map(serializeUpdateToJson)); + receivedUpdates = updates; + }) + ); + + const immutableState = root.toImmutable() as { + items: Array<{ names: Array }>; + }; + + room.batch(() => { + const items = root.get("items"); + items.insert(new LiveObject({ names: new LiveList(["John Doe"]) }), 0); + items.get(1)?.get("names").push("Jane Doe"); + items.push(new LiveObject({ names: new LiveList(["James Doe"]) })); + }); + + expect(jsonUpdates).toEqual([ + [ + listUpdate( + [ + { names: ["John Doe"] }, + { names: ["Jane Doe"] }, + { names: ["James Doe"] }, + ], + [ + listUpdateInsert(0, { names: ["John Doe"] }), + listUpdateInsert(2, { names: ["James Doe"] }), + ] + ), + listUpdate(["Jane Doe"], [listUpdateInsert(0, "Jane Doe")]), + ], + ]); + + // Additional check to prove that generated updates could patch an immutable state + const newImmutableState = legacy_patchImmutableObject( + immutableState, + receivedUpdates + ); + expect(newImmutableState).toEqual(root.toImmutable()); + }); + }); +}); diff --git a/packages/liveblocks-core/src/__tests__/room.test.ts b/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts similarity index 91% rename from packages/liveblocks-core/src/__tests__/room.test.ts rename to packages/liveblocks-core/src/__tests__/room.mockserver.test.ts index bec69436b2..f6d2e1e0db 100644 --- a/packages/liveblocks-core/src/__tests__/room.test.ts +++ b/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts @@ -1,3 +1,10 @@ +/** + * Room tests that use a MockWebSocket server. Covers connection state machine + * (auth flows, reconnection, backoff, close codes), wire protocol message + * inspection, presence, subscriptions, and offline behavior. + * + * For normal history/batch/storage tests, see room.devserver.test.ts. + */ import { afterEach, beforeEach, @@ -17,10 +24,8 @@ import { LiveList } from "../crdts/LiveList"; import { LiveMap } from "../crdts/LiveMap"; import { LiveObject } from "../crdts/LiveObject"; import type { LsonObject } from "../crdts/Lson"; -import type { StorageUpdate } from "../crdts/StorageUpdates"; -import { legacy_patchImmutableObject, lsonToJson } from "../immutable"; +import { lsonToJson } from "../immutable"; import { kInternal } from "../internal"; -import { nn } from "../lib/assert"; import { makeEventSource } from "../lib/EventSource"; import * as console from "../lib/fancy-console"; import type { Json, JsonObject } from "../lib/Json"; @@ -46,22 +51,18 @@ import { SOCKET_REFUSES, SOCKET_SEQUENCE, SOCKET_THROWS, -} from "./_behaviors"; -import { listUpdate, listUpdateInsert, listUpdateSet } from "./_updatesUtils"; +} from "./_MockWebSocketServer.behaviors"; import { createSerializedList, - createSerializedObject, createSerializedRegister, createSerializedRoot, FIRST_POSITION, makeSyncSource, - prepareDisconnectedStorageUpdateTest, prepareIsolatedStorageTest, prepareRoomWithStorage_loadWithDelay, prepareStorageTest, - prepareStorageUpdateTest, serverMessage, -} from "./_utils"; +} from "./_MockWebSocketServer.setup"; import { waitUntilCustomEvent, waitUntilOthersEvent, @@ -1097,70 +1098,6 @@ describe("room", () => { expect(room.getPresence()).toEqual({ x: 1 }); }); - test("pausing history twice is a no-op", async () => { - const { room, root } = await prepareDisconnectedStorageUpdateTest<{ - items: LiveList>>; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedObject("0:2", {}, "0:1", FIRST_POSITION), - ]); - - const items = root.get("items"); - - room.history.pause(); - nn(items.get(0)).set("a", 1); - room.history.pause(); // Pausing again should be a no-op! - nn(items.get(0)).set("b", 2); - room.history.pause(); // Pausing again should be a no-op! - room.history.resume(); - room.history.resume(); // Resuming again should also be a no-op! - - expect(items.toImmutable()).toEqual([{ a: 1, b: 2 }]); - expect(room.history.canUndo()).toBe(true); - expect(room.history.canRedo()).toBe(false); - room.history.undo(); - - expect(items.toImmutable()).toEqual([{}]); - expect(room.history.canUndo()).toBe(false); - expect(room.history.canRedo()).toBe(true); - room.history.redo(); - - expect(items.toImmutable()).toEqual([{ a: 1, b: 2 }]); - expect(room.history.canUndo()).toBe(true); - expect(room.history.canRedo()).toBe(false); - }); - - test("undo redo batch", async () => { - const { room, root, expectUpdates } = - await prepareDisconnectedStorageUpdateTest<{ - items: LiveList>>; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedObject("0:2", {}, "0:1", FIRST_POSITION), - ]); - - const items = root.get("items"); - room.batch(() => { - nn(items.get(0)).set("a", 1); - items.set(0, new LiveObject({ a: 2 })); - }); - - expect(items.toImmutable()).toEqual([{ a: 2 }]); - room.history.undo(); - - expect(items.toImmutable()).toEqual([{}]); - room.history.redo(); - - expect(items.toImmutable()).toEqual([{ a: 2 }]); - expectUpdates([ - [listUpdate([{ a: 2 }], [listUpdateSet(0, { a: 2 })])], - [listUpdate([{}], [listUpdateSet(0, {})])], - [listUpdate([{ a: 2 }], [listUpdateSet(0, { a: 2 })])], - ]); - }); - test("if presence is not added to history during a batch, it should not impact the undo/stack", async () => { const { room, wss } = createTestableRoom({}); @@ -1341,48 +1278,6 @@ describe("room", () => { expect(storage.root.toObject()).toEqual({ x: 1 }); }); - test("canUndo / canRedo", async () => { - const { room, storage } = await prepareStorageTest<{ - a: number; - }>([createSerializedRoot({ a: 1 })], 1); - - expect(room.history.canUndo()).toBe(false); - expect(room.history.canRedo()).toBe(false); - - storage.root.set("a", 2); - - expect(room.history.canUndo()).toBe(true); - - room.history.undo(); - - expect(room.history.canRedo()).toBe(true); - }); - - test("clearing undo/redo stack", async () => { - const { room, storage } = await prepareStorageTest<{ - a: number; - }>([createSerializedRoot({ a: 1 })], 1); - - expect(room.history.canUndo()).toBe(false); - expect(room.history.canRedo()).toBe(false); - - storage.root.set("a", 2); - storage.root.set("a", 3); - storage.root.set("a", 4); - room.history.undo(); - - expect(room.history.canUndo()).toBe(true); - expect(room.history.canRedo()).toBe(true); - - room.history.clear(); - expect(room.history.canUndo()).toBe(false); - expect(room.history.canRedo()).toBe(false); - - room.history.undo(); // won't do anything now - - expect(storage.root.toObject()).toEqual({ a: 3 }); - }); - describe("subscription", () => { test("batch my-presence", () => { const { room } = createTestableRoom({}); @@ -1439,56 +1334,6 @@ describe("room", () => { expect(storageRootSubscriber).toHaveBeenCalledWith(storage.root); }); - test("batch without operations should not add an item to the undo stack", async () => { - const { room, storage, expectStorage } = await prepareStorageTest<{ - a: number; - }>([createSerializedRoot({ a: 1 })], 1); - - storage.root.set("a", 2); - - // Batch without operations on storage or presence - room.batch(() => {}); - - expectStorage({ a: 2 }); - - room.history.undo(); - - expectStorage({ a: 1 }); - }); - - test("batch storage with changes from server", async () => { - const { room, storage, expectStorage } = await prepareStorageTest<{ - items: LiveList; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1 - ); - - const items = storage.root.get("items"); - - room.batch(() => { - items.push("A"); - items.push("B"); - items.push("C"); - }); - - expectStorage({ - items: ["A", "B", "C"], - }); - - room.history.undo(); - - expectStorage({ - items: [], - }); - - room.history.redo(); - - expectStorage({ - items: ["A", "B", "C"], - }); - }); - test("batch storage and presence with changes from server", async () => { type P = { x?: number }; type S = { items: LiveList }; @@ -1547,60 +1392,6 @@ describe("room", () => { }); }); - test("nested storage updates", async () => { - const { room, root, expectUpdates } = await prepareStorageUpdateTest<{ - items: LiveList }>>; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedObject("0:2", {}, "0:1", FIRST_POSITION), - createSerializedList("0:3", "0:2", "names"), - ]); - - let receivedUpdates: StorageUpdate[] = []; - - onTestFinished( - room.events.storageBatch.subscribe( - (updates) => (receivedUpdates = updates) - ) - ); - - const immutableState = root.toImmutable() as { - items: Array<{ names: Array }>; - }; - - room.batch(() => { - const items = root.get("items"); - items.insert(new LiveObject({ names: new LiveList(["John Doe"]) }), 0); - items.get(1)?.get("names").push("Jane Doe"); - items.push(new LiveObject({ names: new LiveList(["James Doe"]) })); - }); - - expectUpdates([ - [ - listUpdate( - [ - { names: ["John Doe"] }, - { names: ["Jane Doe"] }, - { names: ["James Doe"] }, - ], - [ - listUpdateInsert(0, { names: ["John Doe"] }), - listUpdateInsert(2, { names: ["James Doe"] }), - ] - ), - listUpdate(["Jane Doe"], [listUpdateInsert(0, "Jane Doe")]), - ], - ]); - - // Additional check to prove that generated updates could patch an immutable state - const newImmutableState = legacy_patchImmutableObject( - immutableState, - receivedUpdates - ); - expect(newImmutableState).toEqual(root.toImmutable()); - }); - test("batch history", () => { const { room } = createTestableRoom({}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts new file mode 100644 index 0000000000..a691bf7136 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts @@ -0,0 +1,820 @@ +/** + * LiveList tests that run against the real dev server. + * + * For edge cases that require precise control over wire-level ops (CRDT + * conflict resolution, reconnection behavior), see LiveList.mockserver.test.ts. + */ +import { describe, expect, onTestFinished, test, vi } from "vitest"; + +import { + prepareIsolatedStorageTest, + prepareStorageTest, + prepareStorageUpdateTest, +} from "../../__tests__/_devserver"; +import { + listUpdate, + listUpdateDelete, + listUpdateInsert, + listUpdateMove, +} from "../../__tests__/_updatesUtils"; +import { kInternal } from "../../internal"; +import { LiveList } from "../LiveList"; +import { LiveMap } from "../LiveMap"; +import { LiveObject } from "../LiveObject"; + +describe("LiveList", () => { + describe("not attached", () => { + test("basic operations with native objects", () => { + const list = new LiveList(["first", "second", "third"]); + expect(list.get(0)).toEqual("first"); + expect(list.length).toBe(3); + + expect(list.toArray()).toEqual(["first", "second", "third"]); + + expect(Array.from(list)).toEqual(["first", "second", "third"]); + + expect(list.map((item) => item.toUpperCase())).toEqual([ + "FIRST", + "SECOND", + "THIRD", + ]); + + expect(list.filter((item) => item.endsWith("d"))).toEqual([ + "second", + "third", + ]); + + expect(list.findIndex((item) => item.startsWith("s"))).toEqual(1); + + expect(list.some((item) => item.startsWith("x"))).toEqual(false); + + expect(list.indexOf("quatre")).toEqual(-1); + expect(list.indexOf("third")).toEqual(2); + + list.delete(0); + + expect(list.toArray()).toEqual(["second", "third"]); + expect(list.get(2)).toBe(undefined); + expect(list.length).toBe(2); + + list.clear(); + expect(list.toArray()).toEqual([]); + }); + }); + + describe("deserialization", () => { + test("create document with list in root", async () => { + const { expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); + + expectStorage({ + items: [], + }); + }); + + test("init list with items", async () => { + const { expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList>; + }>({ + liveblocksType: "LiveObject", + data: { + items: { + liveblocksType: "LiveList", + data: [ + { liveblocksType: "LiveObject", data: { a: 0 } }, + { liveblocksType: "LiveObject", data: { a: 1 } }, + { liveblocksType: "LiveObject", data: { a: 2 } }, + ], + }, + }, + }); + + expectStorage({ + items: [{ a: 0 }, { a: 1 }, { a: 2 }], + }); + }); + }); + + describe("push", () => { + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + { + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }, + { permissions: ["room:read", "room:presence:write"] } + ); + + const items = root.get("items"); + + expect(() => items.push("first")).toThrow( + "Cannot write to storage with a read only user, please ensure the user has write permissions" + ); + }); + + describe("updates", () => { + test("push on empty list update", async () => { + const { + rootA: root, + expectUpdates, + roomA: room, + } = await prepareStorageUpdateTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); + + root.get("items").push("a"); + room.history.undo(); + room.history.redo(); + + await expectUpdates([ + [listUpdate(["a"], [listUpdateInsert(0, "a")])], + [listUpdate([], [listUpdateDelete(0, "a")])], + [listUpdate(["a"], [listUpdateInsert(0, "a")])], + ]); + }); + }); + + test("push LiveObject on empty list", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList>; + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); + + const root = storageA.root; + const items = root.get("items"); + + await expectStorage({ + items: [], + }); + + items.push(new LiveObject({ a: 0 })); + + await expectStorage({ + items: [{ a: 0 }], + }); + + await assertUndoRedo(); + }); + + test("push number on empty list", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); + + const root = storageA.root; + const items = root.toObject().items; + + await expectStorage({ items: [] }); + + items.push(0); + await expectStorage({ items: [0] }); + + await assertUndoRedo(); + }); + + test("push LiveMap on empty list", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList>; + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); + + const root = storageA.root; + const items = root.get("items"); + + await expectStorage({ items: [] }); + + items.push(new LiveMap([["first", 0]])); + + await expectStorage({ items: [new Map([["first", 0]])] }); + + await assertUndoRedo(); + }); + + test("push already attached LiveObject should throw", async () => { + const { root } = await prepareIsolatedStorageTest<{ + items: LiveList>; + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); + + const items = root.toObject().items; + + const object = new LiveObject({ a: 0 }); + + items.push(object); + expect(() => items.push(object)).toThrow(); + }); + }); + + describe("insert", () => { + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + { + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }, + { permissions: ["room:read", "room:presence:write"] } + ); + + const items = root.get("items"); + + expect(() => items.insert("first", 0)).toThrow( + "Cannot write to storage with a read only user, please ensure the user has write permissions" + ); + }); + + describe("updates", () => { + test("insert at the middle update", async () => { + const { + rootA: root, + expectUpdates, + roomA: room, + } = await prepareStorageUpdateTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["A", "C"] }, + }, + }); + + root.get("items").insert("B", 1); + room.history.undo(); + room.history.redo(); + + await expectUpdates([ + [listUpdate(["A", "B", "C"], [listUpdateInsert(1, "B")])], + [listUpdate(["A", "C"], [listUpdateDelete(1, "B")])], + [listUpdate(["A", "B", "C"], [listUpdateInsert(1, "B")])], + ]); + }); + }); + + test("insert LiveObject at position 0", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList>; + }>({ + liveblocksType: "LiveObject", + data: { + items: { + liveblocksType: "LiveList", + data: [{ liveblocksType: "LiveObject", data: { a: 1 } }], + }, + }, + }); + + await expectStorage({ + items: [{ a: 1 }], + }); + + const root = storageA.root; + const items = root.toObject().items; + + items.insert(new LiveObject({ a: 0 }), 0); + + await expectStorage({ items: [{ a: 0 }, { a: 1 }] }); + + await assertUndoRedo(); + }); + }); + + describe("delete", () => { + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + { + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }, + { permissions: ["room:read", "room:presence:write"] } + ); + + const items = root.get("items"); + + expect(() => items.delete(0)).toThrow( + "Cannot write to storage with a read only user, please ensure the user has write permissions" + ); + }); + + describe("updates", () => { + test("delete first update", async () => { + const { + rootA: root, + expectUpdates, + roomA: room, + } = await prepareStorageUpdateTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["A"] }, + }, + }); + + root.get("items").delete(0); + room.history.undo(); + room.history.redo(); + + await expectUpdates([ + [listUpdate([], [listUpdateDelete(0, "A")])], + [listUpdate(["A"], [listUpdateInsert(0, "A")])], + [listUpdate([], [listUpdateDelete(0, "A")])], + ]); + }); + }); + + test("delete first item", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["A", "B"] }, + }, + }); + + const root = storageA.root; + const items = root.toObject().items; + + await expectStorage({ + items: ["A", "B"], + }); + + items.delete(0); + + await expectStorage({ + items: ["B"], + }); + + await assertUndoRedo(); + }); + + test("delete should remove descendants", async () => { + const { roomA, storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList }>>; + }>({ + liveblocksType: "LiveObject", + data: { + items: { + liveblocksType: "LiveList", + data: [ + { + liveblocksType: "LiveObject", + data: { + child: { + liveblocksType: "LiveObject", + data: { a: 0 }, + }, + }, + }, + ], + }, + }, + }); + + await expectStorage({ + items: [{ child: { a: 0 } }], + }); + + storageA.root.toObject().items.delete(0); + + await expectStorage({ + items: [], + }); + + // Ensure that LiveStructure are deleted properly + expect(roomA[kInternal].nodeCount).toBe(2); + + await assertUndoRedo(); + }); + }); + + describe("move", () => { + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + { + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }, + { permissions: ["room:read", "room:presence:write"] } + ); + + const items = root.get("items"); + + expect(() => items.move(0, 1)).toThrow( + "Cannot write to storage with a read only user, please ensure the user has write permissions" + ); + }); + + describe("updates", () => { + test("move at the end update", async () => { + const { + rootA: root, + expectUpdates, + roomA: room, + } = await prepareStorageUpdateTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["A", "B"] }, + }, + }); + + root.get("items").move(0, 1); + room.history.undo(); + room.history.redo(); + + await expectUpdates([ + [listUpdate(["B", "A"], [listUpdateMove(0, 1, "A")])], + [listUpdate(["A", "B"], [listUpdateMove(1, 0, "A")])], + [listUpdate(["B", "A"], [listUpdateMove(0, 1, "A")])], + ]); + }); + }); + + test("move after current position", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["A", "B", "C"] }, + }, + }); + + await expectStorage({ + items: ["A", "B", "C"], + }); + + const root = storageA.root; + const items = root.toObject().items; + items.move(0, 1); + + await expectStorage({ items: ["B", "A", "C"] }); + + await assertUndoRedo(); + }); + + test("move before current position", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["A", "B", "C"] }, + }, + }); + + await expectStorage({ + items: ["A", "B", "C"], + }); + + const items = storageA.root.get("items"); + + items.move(0, 1); + await expectStorage({ + items: ["B", "A", "C"], + }); + + await assertUndoRedo(); + }); + + test("move at the end of the list", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["A", "B", "C"] }, + }, + }); + + await expectStorage({ + items: ["A", "B", "C"], + }); + + const root = storageA.root; + const items = root.toObject().items; + items.move(0, 2); + + await expectStorage({ + items: ["B", "C", "A"], + }); + + await assertUndoRedo(); + }); + }); + + describe("clear", () => { + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + { + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }, + { permissions: ["room:read", "room:presence:write"] } + ); + + const items = root.get("items"); + + expect(() => items.clear()).toThrow( + "Cannot write to storage with a read only user, please ensure the user has write permissions" + ); + }); + + describe("updates", () => { + test("clear updates", async () => { + const { + rootA: root, + expectUpdates, + roomA: room, + } = await prepareStorageUpdateTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["A", "B"] }, + }, + }); + + root.get("items").clear(); + room.history.undo(); + room.history.redo(); + + await expectUpdates([ + [ + listUpdate( + [], + [listUpdateDelete(0, "A"), listUpdateDelete(0, "B")] + ), + ], + [ + listUpdate( + ["A", "B"], + [listUpdateInsert(0, "A"), listUpdateInsert(1, "B")] + ), + ], + // Because redo reverse the operations, we delete items from the end + [ + listUpdate( + [], + [listUpdateDelete(1, "B"), listUpdateDelete(0, "A")] + ), + ], + ]); + }); + }); + + test("clear should delete all items", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["A", "B", "C"] }, + }, + }); + + const root = storageA.root; + const items = root.get("items"); + + await expectStorage({ + items: ["A", "B", "C"], + }); + + items.clear(); + await expectStorage({ + items: [], + }); + + await assertUndoRedo(); + }); + }); + + describe("batch", () => { + test("batch multiple inserts", async () => { + const { + roomA: room, + storageA, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); + + const items = storageA.root.get("items"); + + await expectStorage({ items: [] }); + + room.batch(() => { + items.push("A"); + items.push("B"); + }); + + await expectStorage( + { items: ["A", "B"] } + // Updates are not tested here because undo/redo is not symetric + ); + + await assertUndoRedo(); + }); + }); + + describe("set", () => { + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + { + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }, + { permissions: ["room:read", "room:presence:write"] } + ); + + const items = root.get("items"); + + expect(() => items.set(0, "A")).toThrow( + "Cannot write to storage with a read only user, please ensure the user has write permissions" + ); + }); + + test("set register on detached list", () => { + const list = new LiveList(["A", "B", "C"]); + list.set(0, "D"); + expect(list.toArray()).toEqual(["D", "B", "C"]); + }); + + test("set at invalid position should throw", () => { + const list = new LiveList(["A", "B", "C"]); + expect(() => list.set(-1, "D")).toThrow( + 'Cannot set list item at index "-1". index should be between 0 and 2' + ); + expect(() => list.set(3, "D")).toThrow( + 'Cannot set list item at index "3". index should be between 0 and 2' + ); + }); + + test("set register", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["A", "B", "C"] }, + }, + }); + + const root = storageA.root; + const items = root.toObject().items; + + await expectStorage({ items: ["A", "B", "C"] }); + + items.set(0, "D"); + await expectStorage({ items: ["D", "B", "C"] }); + + items.set(1, "E"); + await expectStorage({ items: ["D", "E", "C"] }); + + await assertUndoRedo(); + }); + + test("set nested object", async () => { + const { storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + items: LiveList>; + }>({ + liveblocksType: "LiveObject", + data: { + items: { + liveblocksType: "LiveList", + data: [{ liveblocksType: "LiveObject", data: { a: 1 } }], + }, + }, + }); + + const root = storageA.root; + const items = root.toObject().items; + + await expectStorage({ items: [{ a: 1 }] }); + + items.set(0, new LiveObject({ a: 2 })); + await expectStorage({ items: [{ a: 2 }] }); + + await assertUndoRedo(); + }); + }); + + describe("subscriptions", () => { + test("batch multiple actions", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["a"] }, + }, + }); + + const callback = vi.fn(); + onTestFinished(room.events.storageBatch.subscribe(callback)); + + const liveList = root.get("items"); + + room.batch(() => { + liveList.push("b"); + liveList.push("c"); + }); + + expectStorage({ items: ["a", "b", "c"] }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith([ + { + node: liveList, + type: "LiveList", + updates: [ + { index: 1, item: "b", type: "insert" }, + { index: 2, item: "c", type: "insert" }, + ], + }, + ]); + }); + + test("batch multiple inserts", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["a"] }, + }, + }); + + const callback = vi.fn(); + onTestFinished(room.events.storageBatch.subscribe(callback)); + + const liveList = root.get("items"); + + room.batch(() => { + liveList.insert("b", 1); + liveList.insert("c", 2); + }); + + expectStorage({ items: ["a", "b", "c"] }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts new file mode 100644 index 0000000000..7f5ba892e9 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts @@ -0,0 +1,930 @@ +/** + * LiveList tests that use a MockWebSocket server for precise control over + * wire-level operations. Covers CRDT conflict resolution, reconnection + * behavior, and internal methods that need deterministic node IDs. + * + * For normal storage/presence/history tests, see LiveList.devserver.test.ts. + */ +import { describe, expect, onTestFinished, test, vi } from "vitest"; + +import { + createSerializedList, + createSerializedObject, + createSerializedRegister, + createSerializedRoot, + FIFTH_POSITION, + FIRST_POSITION, + FOURTH_POSITION, + prepareIsolatedStorageTest, + replaceRemoteStorageAndReconnect, + SECOND_POSITION, + THIRD_POSITION, +} from "../../__tests__/_MockWebSocketServer.setup"; +import { + waitUntilStatus, + waitUntilStorageUpdate, +} from "../../__tests__/_waitUtils"; +import { kInternal } from "../../internal"; +import type { ServerWireOp } from "../../protocol/Op"; +import { OpCode } from "../../protocol/Op"; +import { ServerMsgCode } from "../../protocol/ServerMsg"; +import type { StorageNode } from "../../protocol/StorageNode"; +import { CrdtType } from "../../protocol/StorageNode"; +import { WebsocketCloseCodes } from "../../types/IWebSocket"; +import type { LiveList } from "../LiveList"; +import type { LiveObject } from "../LiveObject"; + +/** + * Injects server operations directly into the room's message handler, + * bypassing the MockWebSocket transport layer. + */ +function simulateRemoteOps( + room: { [kInternal]: { simulate: { incomingMessage(data: string): void } } }, + ops: ServerWireOp[] +) { + room[kInternal].simulate.incomingMessage( + JSON.stringify({ type: ServerMsgCode.UPDATE_STORAGE, ops }) + ); +} + +describe("LiveList edge cases", () => { + describe("conflict", () => { + test("list conflicts", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [createSerializedRoot(), createSerializedList("0:1", "root", "items")], + 1 + ); + + const items = root.get("items"); + + // Register id = 1:0 + items.push("0"); + + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "2:1", + parentId: "0:1", + parentKey: FIRST_POSITION, + data: "1", + }, + ]); + + expectStorage({ + items: ["1", "0"], + }); + + // Fix from backend + simulateRemoteOps(room, [ + { + type: OpCode.SET_PARENT_KEY, + id: "1:0", + parentKey: SECOND_POSITION, + }, + ]); + + expectStorage({ + items: ["1", "0"], + }); + }); + + test("list conflicts 2", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [createSerializedRoot(), createSerializedList("0:1", "root", "items")], + 1 + ); + + const items = root.get("items"); + + items.push("x0"); // Register id = 1:0 + items.push("x1"); // Register id = 1:1 + + // Should go to pending + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "2:0", + parentId: "0:1", + parentKey: FIRST_POSITION, + data: "y0", + }, + ]); + + expectStorage({ + items: ["y0", "x0", "x1"], + }); + + // Should go to pending + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "2:1", + parentId: "0:1", + parentKey: SECOND_POSITION, + data: "y1", + }, + ]); + + expectStorage({ + items: ["y0", "x0", "y1", "x1"], + }); + + simulateRemoteOps(room, [ + { + type: OpCode.SET_PARENT_KEY, + id: "1:0", + parentKey: THIRD_POSITION, + }, + ]); + + expectStorage({ + items: ["y0", "y1", "x0", "x1"], + }); + + simulateRemoteOps(room, [ + { + type: OpCode.SET_PARENT_KEY, + id: "1:1", + parentKey: FOURTH_POSITION, + }, + ]); + + expectStorage({ + items: ["y0", "y1", "x0", "x1"], + }); + }); + + test("list conflicts with offline", async () => { + const { room, root, expectStorage, wss } = + await prepareIsolatedStorageTest<{ items: LiveList }>( + [ + createSerializedRoot(), + createSerializedList("0:1", "root", "items"), + ], + 1 + ); + + const items = root.get("items"); + + // Register id = 1:0 + items.push("0"); + + expectStorage({ + items: ["0"], + }); + + replaceRemoteStorageAndReconnect(wss, [ + createSerializedRoot(), + createSerializedList("0:1", "root", "items"), + createSerializedRegister("2:0", "0:1", FIRST_POSITION, "1"), + ]); + + await waitUntilStorageUpdate(room); + expectStorage({ + items: ["1", "0"], + }); + + // Fix from backend + simulateRemoteOps(room, [ + { + type: OpCode.SET_PARENT_KEY, + id: "1:0", + parentKey: SECOND_POSITION, + }, + ]); + + expectStorage({ + items: ["1", "0"], + }); + }); + + // This test uses the wss-based applyRemoteOperations (not injectRemoteOps) + // because the reconnection cycle affects how the room buffers ops. + test("list conflicts with undo redo and remote change", async () => { + const { root, expectStorage, applyRemoteOperations, room, wss } = + await prepareIsolatedStorageTest<{ items: LiveList }>( + [ + createSerializedRoot(), + createSerializedList("0:1", "root", "items"), + ], + 1 + ); + + wss.last.close( + new CloseEvent("close", { + code: WebsocketCloseCodes.CLOSE_ABNORMAL, + wasClean: false, + }) + ); + await waitUntilStatus(room, "connected"); + + const items = root.get("items"); + + items.push("0"); + expectStorage({ items: ["0"] }); + + room.history.undo(); + expectStorage({ items: [] }); + + applyRemoteOperations([ + { + type: OpCode.CREATE_REGISTER, + id: "1:1", + parentId: "0:1", + parentKey: FIRST_POSITION, + data: "1", + }, + ]); + expectStorage({ items: [] }); + + room.history.redo(); + expectStorage({ items: ["0"] }); + + await waitUntilStorageUpdate(room); + expectStorage({ items: ["1", "0"] }); + }); + + test("list conflicts - move", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [createSerializedRoot(), createSerializedList("0:1", "root", "items")], + 1 + ); + + const items = root.get("items"); + + // Register id = 1:0 + items.push("A"); + // Register id = 1:1 + items.push("B"); + // Register id = 1:2 + items.push("C"); + + expectStorage({ + items: ["A", "B", "C"], + }); + + items.move(0, 2); + + expectStorage({ + items: ["B", "C", "A"], + }); + + simulateRemoteOps(room, [ + { + type: OpCode.SET_PARENT_KEY, + id: "1:1", + parentKey: FOURTH_POSITION, + }, + ]); + + expectStorage({ + items: ["C", "B", "A"], + }); + + simulateRemoteOps(room, [ + { + type: OpCode.SET_PARENT_KEY, + id: "1:0", + parentKey: FIFTH_POSITION, + }, + ]); + + expectStorage({ + items: ["C", "B", "A"], + }); + }); + + test("list conflicts - ack has different position that local item", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [createSerializedRoot(), createSerializedList("0:0", "root", "items")], + 1 + ); + + const items = root.get("items"); + + items.push("B"); + + expectStorage({ + items: ["B"], + }); + + // Other client created "A" at the same time but was processed first by the server. + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "2:0", + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "A", + }, + ]); + // B is shifted to SECOND_POSITION + + // Other client deleted "A" right after creation. + simulateRemoteOps(room, [ + { + type: OpCode.DELETE_CRDT, + id: "2:0", + }, + ]); + + expectStorage({ + items: ["B"], // "B" is at SECOND_POSITION + }); + + // Server sends ackownledgment for "B" creation with different position/ + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "1:0", + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "B", + opId: "1:0", // Ack + }, + ]); + + expectStorage({ + items: ["B"], // "B" should at FIRST_POSITION + }); + + // Other client creates an item at the SECOND_POSITION + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "2:0", + parentId: "0:0", + parentKey: SECOND_POSITION, + data: "C", + }, + ]); + + expectStorage({ + items: ["B", "C"], + }); + }); + + test("list conflicts - ack has different position that local and ack position is used", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [createSerializedRoot(), createSerializedList("0:0", "root", "items")], + 1 + ); + + const items = root.get("items"); + + items.push("B"); + + expectStorage({ + items: ["B"], + }); + + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "2:0", + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "A", + }, + ]); + + simulateRemoteOps(room, [ + { + type: OpCode.DELETE_CRDT, + id: "2:0", + }, + ]); + + items.insert("C", 0); // Insert at FIRST_POSITION + + // Ack + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "1:0", + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "B", + opId: "1:0", // Ack + }, + ]); + + expectStorage({ + items: ["B", "C"], // C position is shifted + }); + }); + + // Regression test: #applySetChildKeyAck must return modified when restoring + // items from implicitlyDeletedItems, otherwise subscriptions won't fire. + test("restoring item from implicitlyDeletedItems triggers subscription", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [ + createSerializedRoot(), + createSerializedList("0:0", "root", "items"), + createSerializedRegister("0:1", "0:0", FIRST_POSITION, "a"), + createSerializedRegister("0:2", "0:0", SECOND_POSITION, "b"), + createSerializedRegister("0:3", "0:0", THIRD_POSITION, "c"), + ], + 1 + ); + + const items = root.get("items"); + items.delete(0); + items.move(1, 0); + expectStorage({ items: ["c", "b"] }); + + // Remote set at "a"'s position moves "c" to implicitlyDeletedItems + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "2:0", + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "X", + intent: "set", + deletedId: "0:1", + }, + ]); + expectStorage({ items: ["X", "b"] }); + + // Start listening for subscription updates + const onStorage = vi.fn(); + onTestFinished(room.events.storageBatch.subscribe(onStorage)); + + // Ack restores "c" from implicitlyDeletedItems - this MUST trigger subscription + simulateRemoteOps(room, [ + { + type: OpCode.SET_PARENT_KEY, + id: "0:3", + parentKey: SECOND_POSITION, + opId: "1:1", + }, + ]); + + expectStorage({ items: ["X", "c", "b"] }); + expect(onStorage).toHaveBeenCalled(); + }); + }); + + describe("reconnect with remote changes and subscribe", () => { + test("register added to list", async () => { + const { expectStorage, room, root, wss } = + await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [ + createSerializedRoot(), + createSerializedList("0:1", "root", "items"), + createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), + ], + 1 + ); + + const rootCallback = vi.fn(); + const rootDeepCallback = vi.fn(); + const listCallback = vi.fn(); + + const listItems = root.get("items"); + + onTestFinished(room.subscribe(root, rootCallback)); + onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true })); + onTestFinished(room.subscribe(listItems, listCallback)); + + expectStorage({ items: ["a"] }); + + const newInitStorage: StorageNode[] = [ + ["root", { type: CrdtType.OBJECT, data: {} }], + ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }], + [ + "0:2", + { + type: CrdtType.REGISTER, + parentId: "0:1", + parentKey: FIRST_POSITION, + data: "a", + }, + ], + [ + "2:0", + { + type: CrdtType.REGISTER, + parentId: "0:1", + parentKey: SECOND_POSITION, + data: "b", + }, + ], + ]; + + replaceRemoteStorageAndReconnect(wss, newInitStorage); + + await waitUntilStorageUpdate(room); + expectStorage({ + items: ["a", "b"], + }); + + listItems.push("c"); + + expectStorage({ + items: ["a", "b", "c"], + }); + + expect(rootCallback).toHaveBeenCalledTimes(0); + + expect(rootDeepCallback).toHaveBeenCalledTimes(2); + + expect(rootDeepCallback).toHaveBeenCalledWith([ + { + type: "LiveList", + node: listItems, + updates: [{ index: 1, item: "b", type: "insert" }], + }, + ]); + expect(rootDeepCallback).toHaveBeenCalledWith([ + { + type: "LiveList", + node: listItems, + updates: [{ index: 2, item: "c", type: "insert" }], + }, + ]); + expect(listCallback).toHaveBeenCalledTimes(2); + }); + + test("register moved in list", async () => { + const { expectStorage, room, root, wss } = + await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [ + createSerializedRoot(), + createSerializedList("0:1", "root", "items"), + createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), + createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"), + ], + 1 + ); + + const rootCallback = vi.fn(); + const rootDeepCallback = vi.fn(); + const listCallback = vi.fn(); + + const listItems = root.get("items"); + + onTestFinished(room.subscribe(root, rootCallback)); + onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true })); + onTestFinished(room.subscribe(listItems, listCallback)); + + expectStorage({ items: ["a", "b"] }); + + const newInitStorage: StorageNode[] = [ + ["root", { type: CrdtType.OBJECT, data: {} }], + ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }], + [ + "0:2", + { + type: CrdtType.REGISTER, + parentId: "0:1", + parentKey: SECOND_POSITION, + data: "a", + }, + ], + [ + "0:3", + { + type: CrdtType.REGISTER, + parentId: "0:1", + parentKey: FIRST_POSITION, + data: "b", + }, + ], + ]; + + replaceRemoteStorageAndReconnect(wss, newInitStorage); + + await waitUntilStorageUpdate(room); + expectStorage({ + items: ["b", "a"], + }); + + expect(rootCallback).toHaveBeenCalledTimes(0); + + expect(rootDeepCallback).toHaveBeenCalledTimes(1); + + expect(rootDeepCallback).toHaveBeenCalledWith([ + { + type: "LiveList", + node: listItems, + updates: [{ index: 0, previousIndex: 1, item: "b", type: "move" }], + }, + ]); + + expect(listCallback).toHaveBeenCalledTimes(1); + }); + + test("register deleted from list", async () => { + const { expectStorage, room, root, wss } = + await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [ + createSerializedRoot(), + createSerializedList("0:1", "root", "items"), + createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), + createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"), + ], + 1 + ); + + const rootCallback = vi.fn(); + const rootDeepCallback = vi.fn(); + const listCallback = vi.fn(); + + const listItems = root.get("items"); + + onTestFinished(room.subscribe(root, rootCallback)); + onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true })); + onTestFinished(room.subscribe(listItems, listCallback)); + + expectStorage({ items: ["a", "b"] }); + + const newInitStorage: StorageNode[] = [ + ["root", { type: CrdtType.OBJECT, data: {} }], + ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }], + [ + "0:2", + { + type: CrdtType.REGISTER, + parentId: "0:1", + parentKey: FIRST_POSITION, + data: "a", + }, + ], + ]; + + replaceRemoteStorageAndReconnect(wss, newInitStorage); + + await waitUntilStorageUpdate(room); + expectStorage({ + items: ["a"], + }); + + expect(rootCallback).toHaveBeenCalledTimes(0); + + expect(rootDeepCallback).toHaveBeenCalledTimes(1); + + expect(rootDeepCallback).toHaveBeenCalledWith([ + { + type: "LiveList", + node: listItems, + updates: [{ index: 1, type: "delete", deletedItem: "b" }], + }, + ]); + + expect(listCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe("internal methods", () => { + test("_detachChild", async () => { + const { root } = await prepareIsolatedStorageTest<{ + items: LiveList>; + }>( + [ + createSerializedRoot(), + createSerializedList("0:1", "root", "items"), + createSerializedObject("0:2", { a: 1 }, "0:1", FIRST_POSITION), + createSerializedObject("0:3", { a: 2 }, "0:1", SECOND_POSITION), + ], + 1 + ); + + const items = root.get("items"); + const secondItem = items.get(1); + + const applyResult = items._detachChild(secondItem!); + + expect(applyResult).toEqual({ + modified: { + type: "LiveList", + node: items, + updates: [{ index: 1, type: "delete", deletedItem: secondItem }], + }, + reverse: [ + { + data: { a: 2 }, + id: "0:3", + parentId: "0:1", + parentKey: SECOND_POSITION, + type: OpCode.CREATE_OBJECT, + }, + ], + }); + }); + + describe("apply CreateRegister", () => { + test('with intent "set" should replace existing item', async () => { + const { room, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [ + createSerializedRoot(), + createSerializedList("0:0", "root", "items"), + createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"), + ], + 1 + ); + + expectStorage({ + items: ["A"], + }); + + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "0:2", + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "B", + intent: "set", + }, + ]); + + expectStorage({ + items: ["B"], + }); + }); + + test('with intent "set" should notify with a "set" update', async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [ + createSerializedRoot(), + createSerializedList("0:0", "root", "items"), + createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"), + ], + 1 + ); + + const items = root.get("items"); + + const callback = vi.fn(); + onTestFinished(room.events.storageBatch.subscribe(callback)); + + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "0:2", + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "B", + intent: "set", + }, + ]); + + expect(callback).toHaveBeenCalledWith([ + { + node: items, + type: "LiveList", + updates: [{ type: "set", index: 0, item: "B" }], + }, + ]); + }); + + test('with intent "set" should insert item if conflict with a delete operation', async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [ + createSerializedRoot(), + createSerializedList("0:0", "root", "items"), + createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"), + ], + 1 + ); + + const items = root.get("items"); + + expectStorage({ + items: ["A"], + }); + + items.delete(0); + + expectStorage({ + items: [], + }); + + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "0:2", + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "B", + intent: "set", + }, + ]); + + expectStorage({ + items: ["B"], + }); + }); + + test('with intent "set" should notify with a "insert" update if no item exists at this position', async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [ + createSerializedRoot(), + createSerializedList("0:0", "root", "items"), + createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"), + ], + 1 + ); + + const items = root.get("items"); + items.delete(0); + + const callback = vi.fn(); + onTestFinished(room.subscribe(items, callback, { isDeep: true })); + + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "0:2", + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "B", + intent: "set", + }, + ]); + + expect(callback).toHaveBeenCalledWith([ + { + node: items, + type: "LiveList", + updates: [{ type: "insert", index: 0, item: "B" }], + }, + ]); + }); + + test("on existing position should give the right update", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [ + createSerializedRoot(), + createSerializedList("0:1", "root", "items"), + ], + 1 + ); + + const items = root.get("items"); + + // Register id = 1:0 + items.push("0"); + + expectStorage({ + items: ["0"], + }); + + const callback = vi.fn(); + onTestFinished(room.subscribe(items, callback, { isDeep: true })); + + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "2:1", + parentId: "0:1", + parentKey: FIRST_POSITION, + data: "1", + }, + ]); + + expectStorage({ + items: ["1", "0"], + }); + + expect(callback).toHaveBeenCalledWith([ + { + node: items, + type: "LiveList", + updates: [{ type: "insert", index: 0, item: "1" }], + }, + ]); + }); + }); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveList.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveList.test.ts deleted file mode 100644 index a5c09af925..0000000000 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveList.test.ts +++ /dev/null @@ -1,1679 +0,0 @@ -import { describe, expect, onTestFinished, test, vi } from "vitest"; - -import { - listUpdate, - listUpdateDelete, - listUpdateInsert, - listUpdateMove, -} from "../../__tests__/_updatesUtils"; -import { - createSerializedList, - createSerializedObject, - createSerializedRegister, - createSerializedRoot, - FIFTH_POSITION, - FIRST_POSITION, - FOURTH_POSITION, - prepareIsolatedStorageTest, - prepareStorageTest, - prepareStorageUpdateTest, - replaceRemoteStorageAndReconnect, - SECOND_POSITION, - THIRD_POSITION, -} from "../../__tests__/_utils"; -import { - waitUntilStatus, - waitUntilStorageUpdate, -} from "../../__tests__/_waitUtils"; -import { kInternal } from "../../internal"; -import { Permission } from "../../protocol/AuthToken"; -import { OpCode } from "../../protocol/Op"; -import type { StorageNode } from "../../protocol/StorageNode"; -import { CrdtType } from "../../protocol/StorageNode"; -import { WebsocketCloseCodes } from "../../types/IWebSocket"; -import { LiveList } from "../LiveList"; -import { LiveMap } from "../LiveMap"; -import { LiveObject } from "../LiveObject"; - -describe("LiveList", () => { - describe("not attached", () => { - test("basic operations with native objects", () => { - const list = new LiveList(["first", "second", "third"]); - expect(list.get(0)).toEqual("first"); - expect(list.length).toBe(3); - - expect(list.toArray()).toEqual(["first", "second", "third"]); - - expect(Array.from(list)).toEqual(["first", "second", "third"]); - - expect(list.map((item) => item.toUpperCase())).toEqual([ - "FIRST", - "SECOND", - "THIRD", - ]); - - expect(list.filter((item) => item.endsWith("d"))).toEqual([ - "second", - "third", - ]); - - expect(list.findIndex((item) => item.startsWith("s"))).toEqual(1); - - expect(list.some((item) => item.startsWith("x"))).toEqual(false); - - expect(list.indexOf("quatre")).toEqual(-1); - expect(list.indexOf("third")).toEqual(2); - - list.delete(0); - - expect(list.toArray()).toEqual(["second", "third"]); - expect(list.get(2)).toBe(undefined); - expect(list.length).toBe(2); - - list.clear(); - expect(list.toArray()).toEqual([]); - }); - }); - - describe("deserialization", () => { - test("create document with list in root", async () => { - const { expectStorage } = await prepareIsolatedStorageTest<{ - items: LiveList; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ]); - - expectStorage({ - items: [], - }); - }); - - test("init list with items", async () => { - const { expectStorage } = await prepareIsolatedStorageTest<{ - items: LiveList>; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedObject("0:2", { a: 0 }, "0:1", FIRST_POSITION), - createSerializedObject("0:3", { a: 1 }, "0:1", SECOND_POSITION), - createSerializedObject("0:4", { a: 2 }, "0:1", THIRD_POSITION), - ]); - - expectStorage({ - items: [{ a: 0 }, { a: 1 }, { a: 2 }], - }); - }); - }); - - describe("push", () => { - test("throws on read-only", async () => { - const { storage } = await prepareStorageTest<{ - items: LiveList; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1, - [Permission.Read, Permission.PresenceWrite] - ); - - const root = storage.root; - const items = root.get("items"); - - expect(() => items.push("first")).toThrow( - "Cannot write to storage with a read only user, please ensure the user has write permissions" - ); - }); - - describe("updates", () => { - test("push on empty list update", async () => { - const { root, expectUpdates, room } = await prepareStorageUpdateTest<{ - items: LiveList; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ]); - - root.get("items").push("a"); - room.history.undo(); - room.history.redo(); - - expectUpdates([ - [listUpdate(["a"], [listUpdateInsert(0, "a")])], - [listUpdate([], [listUpdateDelete(0, "a")])], - [listUpdate(["a"], [listUpdateInsert(0, "a")])], - ]); - }); - }); - - test("push LiveObject on empty list", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList>; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); - - const root = storage.root; - const items = root.get("items"); - - expectStorage({ - items: [], - }); - - items.push(new LiveObject({ a: 0 })); - - expectStorage({ - items: [{ a: 0 }], - }); - - assertUndoRedo(); - }); - - test("push number on empty list", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); - - const root = storage.root; - const items = root.toObject().items; - - expectStorage({ items: [] }); - - items.push(0); - expectStorage({ items: [0] }); - - assertUndoRedo(); - }); - - test("push LiveMap on empty list", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList>; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); - - const root = storage.root; - const items = root.get("items"); - - expectStorage({ items: [] }); - - items.push(new LiveMap([["first", 0]])); - - expectStorage({ items: [new Map([["first", 0]])] }); - - assertUndoRedo(); - }); - - test("push already attached LiveObject should throw", async () => { - const { root } = await prepareIsolatedStorageTest<{ - items: LiveList>; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1 - ); - - const items = root.toObject().items; - - const object = new LiveObject({ a: 0 }); - - items.push(object); - expect(() => items.push(object)).toThrow(); - }); - }); - - describe("insert", () => { - test("throws on read-only", async () => { - const { storage } = await prepareStorageTest<{ - items: LiveList; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1, - [Permission.Read, Permission.PresenceWrite] - ); - - const root = storage.root; - const items = root.get("items"); - - expect(() => items.insert("first", 0)).toThrow( - "Cannot write to storage with a read only user, please ensure the user has write permissions" - ); - }); - - describe("updates", () => { - test("insert at the middle update", async () => { - const { root, expectUpdates, room } = await prepareStorageUpdateTest<{ - items: LiveList; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "C"), - ]); - - root.get("items").insert("B", 1); - room.history.undo(); - room.history.redo(); - - expectUpdates([ - [listUpdate(["A", "B", "C"], [listUpdateInsert(1, "B")])], - [listUpdate(["A", "C"], [listUpdateDelete(1, "B")])], - [listUpdate(["A", "B", "C"], [listUpdateInsert(1, "B")])], - ]); - }); - }); - - test("insert LiveObject at position 0", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList>; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedObject("0:2", { a: 1 }, "0:1", FIRST_POSITION), - ], - 1 - ); - - expectStorage({ - items: [{ a: 1 }], - }); - - const root = storage.root; - const items = root.toObject().items; - - items.insert(new LiveObject({ a: 0 }), 0); - - expectStorage({ items: [{ a: 0 }, { a: 1 }] }); - - assertUndoRedo(); - }); - }); - - describe("delete", () => { - test("throws on read-only", async () => { - const { storage } = await prepareStorageTest<{ - items: LiveList; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1, - [Permission.Read, Permission.PresenceWrite] - ); - - const root = storage.root; - const items = root.get("items"); - - expect(() => items.delete(0)).toThrow( - "Cannot write to storage with a read only user, please ensure the user has write permissions" - ); - }); - - describe("updates", () => { - test("delete first update", async () => { - const { root, expectUpdates, room } = await prepareStorageUpdateTest<{ - items: LiveList; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - ]); - - root.get("items").delete(0); - room.history.undo(); - room.history.redo(); - - expectUpdates([ - [listUpdate([], [listUpdateDelete(0, "A")])], - [listUpdate(["A"], [listUpdateInsert(0, "A")])], - [listUpdate([], [listUpdateDelete(0, "A")])], - ]); - }); - }); - - test("delete first item", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"), - ]); - - const root = storage.root; - const items = root.toObject().items; - - expectStorage({ - items: ["A", "B"], - }); - - items.delete(0); - - expectStorage({ - items: ["B"], - }); - - assertUndoRedo(); - }); - - test("delete should remove descendants", async () => { - const { room, storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList }>>; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedObject("0:2", {}, "0:1", "!"), - createSerializedObject("0:3", { a: 0 }, "0:2", "child"), - ]); - - expectStorage({ - items: [{ child: { a: 0 } }], - }); - - storage.root.toObject().items.delete(0); - - expectStorage({ - items: [], - }); - - // Ensure that LiveStructure are deleted properly - expect(room[kInternal].nodeCount).toBe(2); - - assertUndoRedo(); - }); - }); - - describe("move", () => { - test("throws on read-only", async () => { - const { storage } = await prepareStorageTest<{ - items: LiveList; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1, - [Permission.Read, Permission.PresenceWrite] - ); - - const root = storage.root; - const items = root.get("items"); - - expect(() => items.move(0, 1)).toThrow( - "Cannot write to storage with a read only user, please ensure the user has write permissions" - ); - }); - - describe("updates", () => { - test("move at the end update", async () => { - const { root, expectUpdates, room } = await prepareStorageUpdateTest<{ - items: LiveList; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"), - ]); - - root.get("items").move(0, 1); - room.history.undo(); - room.history.redo(); - - expectUpdates([ - [listUpdate(["B", "A"], [listUpdateMove(0, 1, "A")])], - [listUpdate(["A", "B"], [listUpdateMove(1, 0, "A")])], - [listUpdate(["B", "A"], [listUpdateMove(0, 1, "A")])], - ]); - }); - }); - - test("move after current position", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"), - ]); - - expectStorage({ - items: ["A", "B", "C"], - }); - - const root = storage.root; - const items = root.toObject().items; - items.move(0, 1); - - expectStorage({ items: ["B", "A", "C"] }); - - assertUndoRedo(); - }); - - test("move before current position", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"), - ], - 1 - ); - - expectStorage({ - items: ["A", "B", "C"], - }); - - const items = storage.root.get("items"); - - items.move(0, 1); - expectStorage({ - items: ["B", "A", "C"], - }); - - assertUndoRedo(); - }); - - test("move at the end of the list", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"), - ]); - - expectStorage({ - items: ["A", "B", "C"], - }); - - const root = storage.root; - const items = root.toObject().items; - items.move(0, 2); - - expectStorage({ - items: ["B", "C", "A"], - }); - - assertUndoRedo(); - }); - }); - - describe("clear", () => { - test("throws on read-only", async () => { - const { storage } = await prepareStorageTest<{ items: LiveList }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1, - [Permission.Read, Permission.PresenceWrite] - ); - - const root = storage.root; - const items = root.get("items"); - - expect(() => items.clear()).toThrow( - "Cannot write to storage with a read only user, please ensure the user has write permissions" - ); - }); - - describe("updates", () => { - test("clear updates", async () => { - const { root, expectUpdates, room } = await prepareStorageUpdateTest<{ - items: LiveList; - }>([ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"), - ]); - - root.get("items").clear(); - room.history.undo(); - room.history.redo(); - - expectUpdates([ - [ - listUpdate( - [], - [listUpdateDelete(0, "A"), listUpdateDelete(0, "B")] - ), - ], - [ - listUpdate( - ["A", "B"], - [listUpdateInsert(0, "A"), listUpdateInsert(1, "B")] - ), - ], - // Because redo reverse the operations, we delete items from the end - [ - listUpdate( - [], - [listUpdateDelete(1, "B"), listUpdateDelete(0, "A")] - ), - ], - ]); - }); - }); - - test("clear should delete all items", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"), - ], - 1 - ); - - const root = storage.root; - const items = root.get("items"); - - expectStorage({ - items: ["A", "B", "C"], - }); - - items.clear(); - expectStorage({ - items: [], - }); - - assertUndoRedo(); - }); - }); - - describe("batch", () => { - test("batch multiple inserts", async () => { - const { room, storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); - - const items = storage.root.get("items"); - - expectStorage({ items: [] }); - - room.batch(() => { - items.push("A"); - items.push("B"); - }); - - expectStorage( - { items: ["A", "B"] } - // Updates are not tested here because undo/redo is not symetric - ); - - assertUndoRedo(); - }); - }); - - describe("set", () => { - test("throws on read-only", async () => { - const { storage } = await prepareStorageTest<{ items: LiveList }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1, - [Permission.Read, Permission.PresenceWrite] - ); - - const root = storage.root; - const items = root.get("items"); - - expect(() => items.set(0, "A")).toThrow( - "Cannot write to storage with a read only user, please ensure the user has write permissions" - ); - }); - - test("set register on detached list", () => { - const list = new LiveList(["A", "B", "C"]); - list.set(0, "D"); - expect(list.toArray()).toEqual(["D", "B", "C"]); - }); - - test("set at invalid position should throw", () => { - const list = new LiveList(["A", "B", "C"]); - expect(() => list.set(-1, "D")).toThrow( - 'Cannot set list item at index "-1". index should be between 0 and 2' - ); - expect(() => list.set(3, "D")).toThrow( - 'Cannot set list item at index "3". index should be between 0 and 2' - ); - }); - - test("set register", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"), - createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"), - ], - 1 - ); - - const root = storage.root; - const items = root.toObject().items; - - expectStorage({ items: ["A", "B", "C"] }); - - items.set(0, "D"); - expectStorage({ items: ["D", "B", "C"] }); - - items.set(1, "E"); - expectStorage({ items: ["D", "E", "C"] }); - - assertUndoRedo(); - }); - - test("set nested object", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList>; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedObject("0:2", { a: 1 }, "0:1", FIRST_POSITION), - ], - 1 - ); - - const root = storage.root; - const items = root.toObject().items; - - expectStorage({ items: [{ a: 1 }] }); - - items.set(0, new LiveObject({ a: 2 })); - expectStorage({ items: [{ a: 2 }] }); - - assertUndoRedo(); - }); - }); - - describe("conflict", () => { - test("list conflicts", async () => { - const { root, expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); - - const items = root.get("items"); - - // Register id = 1:0 - items.push("0"); - - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "2:1", - parentId: "0:1", - parentKey: FIRST_POSITION, - data: "1", - }, - ]); - - expectStorage({ - items: ["1", "0"], - }); - - // Fix from backend - applyRemoteOperations([ - { - type: OpCode.SET_PARENT_KEY, - id: "1:0", - parentKey: SECOND_POSITION, - }, - ]); - - expectStorage({ - items: ["1", "0"], - }); - }); - - test("list conflicts 2", async () => { - const { root, applyRemoteOperations, expectStorage } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); - - const items = root.get("items"); - - items.push("x0"); // Register id = 1:0 - items.push("x1"); // Register id = 1:1 - - // Should go to pending - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "2:0", - parentId: "0:1", - parentKey: FIRST_POSITION, - data: "y0", - }, - ]); - - expectStorage({ - items: ["y0", "x0", "x1"], - }); - - // Should go to pending - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "2:1", - parentId: "0:1", - parentKey: SECOND_POSITION, - data: "y1", - }, - ]); - - expectStorage({ - items: ["y0", "x0", "y1", "x1"], - }); - - applyRemoteOperations([ - { - type: OpCode.SET_PARENT_KEY, - id: "1:0", - parentKey: THIRD_POSITION, - }, - ]); - - expectStorage({ - items: ["y0", "y1", "x0", "x1"], - }); - - applyRemoteOperations([ - { - type: OpCode.SET_PARENT_KEY, - id: "1:1", - parentKey: FOURTH_POSITION, - }, - ]); - - expectStorage({ - items: ["y0", "y1", "x0", "x1"], - }); - }); - - test("list conflicts with offline", async () => { - const { room, root, expectStorage, applyRemoteOperations, wss } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); - - const items = root.get("items"); - - // Register id = 1:0 - items.push("0"); - - expectStorage({ - items: ["0"], - }); - - replaceRemoteStorageAndReconnect(wss, [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("2:0", "0:1", FIRST_POSITION, "1"), - ]); - - await waitUntilStorageUpdate(room); - expectStorage({ - items: ["1", "0"], - }); - - // Fix from backend - applyRemoteOperations([ - { - type: OpCode.SET_PARENT_KEY, - id: "1:0", - parentKey: SECOND_POSITION, - }, - ]); - - expectStorage({ - items: ["1", "0"], - }); - }); - - test("list conflicts with undo redo and remote change", async () => { - const { root, expectStorage, applyRemoteOperations, room, wss } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); - - wss.last.close( - new CloseEvent("close", { - code: WebsocketCloseCodes.CLOSE_ABNORMAL, - wasClean: false, - }) - ); - await waitUntilStatus(room, "connected"); - - const items = root.get("items"); - - items.push("0"); - expectStorage({ items: ["0"] }); - - room.history.undo(); - expectStorage({ items: [] }); - - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "1:1", - parentId: "0:1", - parentKey: FIRST_POSITION, - data: "1", - }, - ]); - expectStorage({ items: [] }); - - room.history.redo(); - expectStorage({ items: ["0"] }); - - await waitUntilStorageUpdate(room); - expectStorage({ items: ["1", "0"] }); - }); - - test("list conflicts - move", async () => { - const { root, expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); - - const items = root.get("items"); - - // Register id = 1:0 - items.push("A"); - // Register id = 1:1 - items.push("B"); - // Register id = 1:2 - items.push("C"); - - expectStorage({ - items: ["A", "B", "C"], - }); - - items.move(0, 2); - - expectStorage({ - items: ["B", "C", "A"], - }); - - applyRemoteOperations([ - { - type: OpCode.SET_PARENT_KEY, - id: "1:1", - parentKey: FOURTH_POSITION, - }, - ]); - - expectStorage({ - items: ["C", "B", "A"], - }); - - applyRemoteOperations([ - { - type: OpCode.SET_PARENT_KEY, - id: "1:0", - parentKey: FIFTH_POSITION, - }, - ]); - - expectStorage({ - items: ["C", "B", "A"], - }); - }); - - test("list conflicts - ack has different position that local item", async () => { - const { root, expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:0", "root", "items"), - ], - 1 - ); - - const items = root.get("items"); - - items.push("B"); - - expectStorage({ - items: ["B"], - }); - - // Other client created "A" at the same time but was processed first by the server. - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "2:0", - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "A", - }, - ]); - // B is shifted to SECOND_POSITION - - // Other client deleted "A" right after creation. - applyRemoteOperations([ - { - type: OpCode.DELETE_CRDT, - id: "2:0", - }, - ]); - - expectStorage({ - items: ["B"], // "B" is at SECOND_POSITION - }); - - // Server sends ackownledgment for "B" creation with different position/ - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "1:0", - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "B", - opId: "1:0", // Ack - }, - ]); - - expectStorage({ - items: ["B"], // "B" should at FIRST_POSITION - }); - - // Other client creates an item at the SECOND_POSITION - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "2:0", - parentId: "0:0", - parentKey: SECOND_POSITION, - data: "C", - }, - ]); - - expectStorage({ - items: ["B", "C"], - }); - }); - - test("list conflicts - ack has different position that local and ack position is used", async () => { - const { root, expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:0", "root", "items"), - ], - 1 - ); - - const items = root.get("items"); - - items.push("B"); - - expectStorage({ - items: ["B"], - }); - - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "2:0", - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "A", - }, - ]); - - applyRemoteOperations([ - { - type: OpCode.DELETE_CRDT, - id: "2:0", - }, - ]); - - items.insert("C", 0); // Insert at FIRST_POSITION - - // Ack - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "1:0", - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "B", - opId: "1:0", // Ack - }, - ]); - - expectStorage({ - items: ["B", "C"], // C position is shifted - }); - }); - - // Regression test: #applySetChildKeyAck must return modified when restoring - // items from implicitlyDeletedItems, otherwise subscriptions won't fire. - test("restoring item from implicitlyDeletedItems triggers subscription", async () => { - const { room, root, expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:0", "root", "items"), - createSerializedRegister("0:1", "0:0", FIRST_POSITION, "a"), - createSerializedRegister("0:2", "0:0", SECOND_POSITION, "b"), - createSerializedRegister("0:3", "0:0", THIRD_POSITION, "c"), - ], - 1 - ); - - const items = root.get("items"); - items.delete(0); - items.move(1, 0); - expectStorage({ items: ["c", "b"] }); - - // Remote set at "a"'s position moves "c" to implicitlyDeletedItems - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "2:0", - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "X", - intent: "set", - deletedId: "0:1", - }, - ]); - expectStorage({ items: ["X", "b"] }); - - // Start listening for subscription updates - const onStorage = vi.fn(); - onTestFinished(room.events.storageBatch.subscribe(onStorage)); - - // Ack restores "c" from implicitlyDeletedItems - this MUST trigger subscription - applyRemoteOperations([ - { - type: OpCode.SET_PARENT_KEY, - id: "0:3", - parentKey: SECOND_POSITION, - opId: "1:1", - }, - ]); - - expectStorage({ items: ["X", "c", "b"] }); - expect(onStorage).toHaveBeenCalled(); - }); - }); - - describe("subscriptions", () => { - test("batch multiple actions", async () => { - const { room, storage, expectStorage } = await prepareStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - ], - 1 - ); - - const callback = vi.fn(); - onTestFinished(room.events.storageBatch.subscribe(callback)); - - const root = storage.root; - const liveList = root.get("items"); - - room.batch(() => { - liveList.push("b"); - liveList.push("c"); - }); - - expectStorage({ items: ["a", "b", "c"] }); - - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith([ - { - node: liveList, - type: "LiveList", - updates: [ - { index: 1, item: "b", type: "insert" }, - { index: 2, item: "c", type: "insert" }, - ], - }, - ]); - }); - - test("batch multiple inserts", async () => { - const { room, storage, expectStorage } = await prepareStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - ], - 1 - ); - - const callback = vi.fn(); - onTestFinished(room.events.storageBatch.subscribe(callback)); - - const root = storage.root; - const liveList = root.get("items"); - - room.batch(() => { - liveList.insert("b", 1); - liveList.insert("c", 2); - }); - - expectStorage({ items: ["a", "b", "c"] }); - - expect(callback).toHaveBeenCalledTimes(1); - }); - }); - - describe("reconnect with remote changes and subscribe", () => { - test("register added to list", async () => { - const { expectStorage, room, root, wss } = - await prepareIsolatedStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - ], - 1 - ); - - const rootCallback = vi.fn(); - const rootDeepCallback = vi.fn(); - const listCallback = vi.fn(); - - const listItems = root.get("items"); - - onTestFinished(room.subscribe(root, rootCallback)); - onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true })); - onTestFinished(room.subscribe(listItems, listCallback)); - - expectStorage({ items: ["a"] }); - - const newInitStorage: StorageNode[] = [ - ["root", { type: CrdtType.OBJECT, data: {} }], - ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }], - [ - "0:2", - { - type: CrdtType.REGISTER, - parentId: "0:1", - parentKey: FIRST_POSITION, - data: "a", - }, - ], - [ - "2:0", - { - type: CrdtType.REGISTER, - parentId: "0:1", - parentKey: SECOND_POSITION, - data: "b", - }, - ], - ]; - - replaceRemoteStorageAndReconnect(wss, newInitStorage); - - await waitUntilStorageUpdate(room); - expectStorage({ - items: ["a", "b"], - }); - - listItems.push("c"); - - expectStorage({ - items: ["a", "b", "c"], - }); - - expect(rootCallback).toHaveBeenCalledTimes(0); - - expect(rootDeepCallback).toHaveBeenCalledTimes(2); - - expect(rootDeepCallback).toHaveBeenCalledWith([ - { - type: "LiveList", - node: listItems, - updates: [{ index: 1, item: "b", type: "insert" }], - }, - ]); - expect(rootDeepCallback).toHaveBeenCalledWith([ - { - type: "LiveList", - node: listItems, - updates: [{ index: 2, item: "c", type: "insert" }], - }, - ]); - expect(listCallback).toHaveBeenCalledTimes(2); - }); - - test("register moved in list", async () => { - const { expectStorage, room, root, wss } = - await prepareIsolatedStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"), - ], - 1 - ); - - const rootCallback = vi.fn(); - const rootDeepCallback = vi.fn(); - const listCallback = vi.fn(); - - const listItems = root.get("items"); - - onTestFinished(room.subscribe(root, rootCallback)); - onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true })); - onTestFinished(room.subscribe(listItems, listCallback)); - - expectStorage({ items: ["a", "b"] }); - - const newInitStorage: StorageNode[] = [ - ["root", { type: CrdtType.OBJECT, data: {} }], - ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }], - [ - "0:2", - { - type: CrdtType.REGISTER, - parentId: "0:1", - parentKey: SECOND_POSITION, - data: "a", - }, - ], - [ - "0:3", - { - type: CrdtType.REGISTER, - parentId: "0:1", - parentKey: FIRST_POSITION, - data: "b", - }, - ], - ]; - - replaceRemoteStorageAndReconnect(wss, newInitStorage); - - await waitUntilStorageUpdate(room); - expectStorage({ - items: ["b", "a"], - }); - - expect(rootCallback).toHaveBeenCalledTimes(0); - - expect(rootDeepCallback).toHaveBeenCalledTimes(1); - - expect(rootDeepCallback).toHaveBeenCalledWith([ - { - type: "LiveList", - node: listItems, - updates: [{ index: 0, previousIndex: 1, item: "b", type: "move" }], - }, - ]); - - expect(listCallback).toHaveBeenCalledTimes(1); - }); - - test("register deleted from list", async () => { - const { expectStorage, room, root, wss } = - await prepareIsolatedStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"), - createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"), - ], - 1 - ); - - const rootCallback = vi.fn(); - const rootDeepCallback = vi.fn(); - const listCallback = vi.fn(); - - const listItems = root.get("items"); - - onTestFinished(room.subscribe(root, rootCallback)); - onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true })); - onTestFinished(room.subscribe(listItems, listCallback)); - - expectStorage({ items: ["a", "b"] }); - - const newInitStorage: StorageNode[] = [ - ["root", { type: CrdtType.OBJECT, data: {} }], - ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }], - [ - "0:2", - { - type: CrdtType.REGISTER, - parentId: "0:1", - parentKey: FIRST_POSITION, - data: "a", - }, - ], - ]; - - replaceRemoteStorageAndReconnect(wss, newInitStorage); - - await waitUntilStorageUpdate(room); - expectStorage({ - items: ["a"], - }); - - expect(rootCallback).toHaveBeenCalledTimes(0); - - expect(rootDeepCallback).toHaveBeenCalledTimes(1); - - expect(rootDeepCallback).toHaveBeenCalledWith([ - { - type: "LiveList", - node: listItems, - updates: [{ index: 1, type: "delete", deletedItem: "b" }], - }, - ]); - - expect(listCallback).toHaveBeenCalledTimes(1); - }); - }); - - describe("internal methods", () => { - test("_detachChild", async () => { - const { root } = await prepareIsolatedStorageTest<{ - items: LiveList>; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - createSerializedObject("0:2", { a: 1 }, "0:1", FIRST_POSITION), - createSerializedObject("0:3", { a: 2 }, "0:1", SECOND_POSITION), - ], - 1 - ); - - const items = root.get("items"); - const secondItem = items.get(1); - - const applyResult = items._detachChild(secondItem!); - - expect(applyResult).toEqual({ - modified: { - type: "LiveList", - node: items, - updates: [{ index: 1, type: "delete", deletedItem: secondItem }], - }, - reverse: [ - { - data: { a: 2 }, - id: "0:3", - parentId: "0:1", - parentKey: SECOND_POSITION, - type: OpCode.CREATE_OBJECT, - }, - ], - }); - }); - - describe("apply CreateRegister", () => { - test('with intent "set" should replace existing item', async () => { - const { expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:0", "root", "items"), - createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"), - ], - 1 - ); - - expectStorage({ - items: ["A"], - }); - - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "0:2", - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "B", - intent: "set", - }, - ]); - - expectStorage({ - items: ["B"], - }); - }); - - test('with intent "set" should notify with a "set" update', async () => { - const { room, root, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:0", "root", "items"), - createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"), - ], - 1 - ); - - const items = root.get("items"); - - const callback = vi.fn(); - onTestFinished(room.events.storageBatch.subscribe(callback)); - - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "0:2", - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "B", - intent: "set", - }, - ]); - - expect(callback).toHaveBeenCalledWith([ - { - node: items, - type: "LiveList", - updates: [{ type: "set", index: 0, item: "B" }], - }, - ]); - }); - - test('with intent "set" should insert item if conflict with a delete operation', async () => { - const { root, expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:0", "root", "items"), - createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"), - ], - 1 - ); - - const items = root.get("items"); - - expectStorage({ - items: ["A"], - }); - - items.delete(0); - - expectStorage({ - items: [], - }); - - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "0:2", - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "B", - intent: "set", - }, - ]); - - expectStorage({ - items: ["B"], - }); - }); - - test('with intent "set" should notify with a "insert" update if no item exists at this position', async () => { - const { room, root, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:0", "root", "items"), - createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"), - ], - 1 - ); - - const items = root.get("items"); - items.delete(0); - - const callback = vi.fn(); - onTestFinished(room.subscribe(items, callback, { isDeep: true })); - - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "0:2", - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "B", - intent: "set", - }, - ]); - - expect(callback).toHaveBeenCalledWith([ - { - node: items, - type: "LiveList", - updates: [{ type: "insert", index: 0, item: "B" }], - }, - ]); - }); - - test("on existing position should give the right update", async () => { - const { room, root, expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ items: LiveList }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); - - const items = root.get("items"); - - // Register id = 1:0 - items.push("0"); - - expectStorage({ - items: ["0"], - }); - - const callback = vi.fn(); - onTestFinished(room.subscribe(items, callback, { isDeep: true })); - - applyRemoteOperations([ - { - type: OpCode.CREATE_REGISTER, - id: "2:1", - parentId: "0:1", - parentKey: FIRST_POSITION, - data: "1", - }, - ]); - - expectStorage({ - items: ["1", "0"], - }); - - expect(callback).toHaveBeenCalledWith([ - { - node: items, - type: "LiveList", - updates: [{ type: "insert", index: 0, item: "1" }], - }, - ]); - }); - }); - }); -}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveMap.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.devserver.test.ts similarity index 52% rename from packages/liveblocks-core/src/crdts/__tests__/LiveMap.test.ts rename to packages/liveblocks-core/src/crdts/__tests__/LiveMap.devserver.test.ts index 8dc631daa8..0ef44b83f6 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveMap.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.devserver.test.ts @@ -1,21 +1,17 @@ +/** + * LiveMap tests that run against the real dev server. + * + * For edge cases that require precise control over wire-level ops (internal + * methods), see LiveMap.mockserver.test.ts. + */ import { describe, expect, test, vi } from "vitest"; import { - createSerializedList, - createSerializedMap, - createSerializedObject, - createSerializedRegister, - createSerializedRoot, prepareIsolatedStorageTest, prepareStorageTest, - replaceRemoteStorageAndReconnect, -} from "../../__tests__/_utils"; -import { waitUntilStorageUpdate } from "../../__tests__/_waitUtils"; + replaceStorageAndReconnectDevServer, +} from "../../__tests__/_devserver"; import { kInternal } from "../../internal"; -import { Permission } from "../../protocol/AuthToken"; -import { OpCode } from "../../protocol/Op"; -import type { StorageNode } from "../../protocol/StorageNode"; -import { CrdtType } from "../../protocol/StorageNode"; import { LiveList } from "../LiveList"; import { LiveMap } from "../LiveMap"; import { LiveObject } from "../LiveObject"; @@ -98,51 +94,63 @@ describe("LiveMap", () => { }); test("create document with map in root", async () => { - const { storage, expectStorage } = await prepareStorageTest<{ + const { root, expectStorage } = await prepareIsolatedStorageTest<{ map: LiveMap>; - }>([createSerializedRoot(), createSerializedMap("0:1", "root", "map")]); + }>({ + liveblocksType: "LiveObject", + data: { map: { liveblocksType: "LiveMap", data: {} } }, + }); - const root = storage.root; - const map = root.toObject().map; + const map = root.get("map"); expect(Array.from(map.entries())).toEqual([]); expectStorage({ map: new Map() }); }); - test("set throws on read-only", async () => { - const { storage } = await prepareStorageTest<{ + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("set throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest<{ map: LiveMap>; - }>([createSerializedRoot(), createSerializedMap("0:1", "root", "map")], 1, [ - Permission.Read, - Permission.PresenceWrite, - ]); + }>( + { + liveblocksType: "LiveObject", + data: { map: { liveblocksType: "LiveMap", data: {} } }, + }, + { permissions: ["room:read", "room:presence:write"] } + ); - const map = storage.root.get("map"); + const map = root.get("map"); expect(() => map.set("key", new LiveObject({ a: 0 }))).toThrow( "Cannot write to storage with a read only user, please ensure the user has write permissions" ); }); test("init map with items", async () => { - const { storage, expectStorage } = await prepareStorageTest<{ + const { root, expectStorage } = await prepareIsolatedStorageTest<{ map: LiveMap>; - }>([ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedObject("0:2", { a: 0 }, "0:1", "first"), - createSerializedObject("0:3", { a: 1 }, "0:1", "second"), - createSerializedObject("0:4", { a: 2 }, "0:1", "third"), - ]); - - const root = storage.root; + }>({ + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { + first: { liveblocksType: "LiveObject", data: { a: 0 } }, + second: { liveblocksType: "LiveObject", data: { a: 1 } }, + third: { liveblocksType: "LiveObject", data: { a: 2 } }, + }, + }, + }, + }); + const map = root.get("map"); - expect( - Array.from(map.entries()).map((entry) => [entry[0], entry[1].toObject()]) - ).toMatchObject([ - ["first", { a: 0 }], - ["second", { a: 1 }], - ["third", { a: 2 }], - ]); + expect(map.toImmutable()).toEqual( + new Map([ + ["first", { a: 0 }], + ["second", { a: 1 }], + ["third", { a: 2 }], + ]) + ); expectStorage({ map: new Map([ @@ -154,26 +162,26 @@ describe("LiveMap", () => { }); test("map.set object", async () => { - const { storage, expectStorage, assertUndoRedo } = + const { storageA, expectStorage, assertUndoRedo } = await prepareStorageTest<{ map: LiveMap; - }>( - [createSerializedRoot(), createSerializedMap("0:1", "root", "map")], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { map: { liveblocksType: "LiveMap", data: {} } }, + }); - const root = storage.root; - const map = root.toObject().map; + const root = storageA.root; + const map = root.get("map"); - expectStorage({ map: new Map() }); + await expectStorage({ map: new Map() }); map.set("first", 0); - expectStorage({ + await expectStorage({ map: new Map([["first", 0]]), }); map.set("second", 1); - expectStorage({ + await expectStorage({ map: new Map([ ["first", 0], ["second", 1], @@ -181,7 +189,7 @@ describe("LiveMap", () => { }); map.set("third", 2); - expectStorage({ + await expectStorage({ map: new Map([ ["first", 0], ["second", 1], @@ -189,41 +197,47 @@ describe("LiveMap", () => { ]), }); - assertUndoRedo(); + await assertUndoRedo(); }); describe("delete", () => { - test("throws on read-only", async () => { - const { storage } = await prepareStorageTest<{ + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest<{ map: LiveMap; }>( - [createSerializedRoot(), createSerializedMap("0:1", "root", "map")], - 1, - [Permission.Read, Permission.PresenceWrite] + { + liveblocksType: "LiveObject", + data: { map: { liveblocksType: "LiveMap", data: {} } }, + }, + { permissions: ["room:read", "room:presence:write"] } ); - const map = storage.root.get("map"); + const map = root.get("map"); expect(() => map.delete("key")).toThrow( "Cannot write to storage with a read only user, please ensure the user has write permissions" ); }); test("should delete LiveObject", async () => { - const { storage, expectStorage, assertUndoRedo } = + const { storageA, expectStorage, assertUndoRedo } = await prepareStorageTest<{ map: LiveMap; - }>([ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedRegister("0:2", "0:1", "first", 0), - createSerializedRegister("0:3", "0:1", "second", 1), - createSerializedRegister("0:4", "0:1", "third", 2), - ]); + }>({ + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { first: 0, second: 1, third: 2 }, + }, + }, + }); - const root = storage.root; - const map = root.toObject().map; + const root = storageA.root; + const map = root.get("map"); - expectStorage({ + await expectStorage({ map: new Map([ ["first", 0], ["second", 1], @@ -232,7 +246,7 @@ describe("LiveMap", () => { }); map.delete("first"); - expectStorage({ + await expectStorage({ map: new Map([ ["second", 1], ["third", 2], @@ -240,92 +254,99 @@ describe("LiveMap", () => { }); map.delete("second"); - expectStorage({ + await expectStorage({ map: new Map([["third", 2]]), }); map.delete("third"); - expectStorage({ + await expectStorage({ map: new Map(), }); - assertUndoRedo(); + await assertUndoRedo(); }); test("should remove nested data structure from cache", async () => { - const { room, storage, expectStorage, assertUndoRedo } = + const { roomA, storageA, expectStorage, assertUndoRedo } = await prepareStorageTest<{ map: LiveMap>; - }>( - [ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedObject("0:2", { a: 0 }, "0:1", "first"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { + first: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }, + }, + }); - expectStorage({ + await expectStorage({ map: new Map([["first", { a: 0 }]]), }); - const root = storage.root; - const map = root.toObject().map; + const root = storageA.root; + const map = root.get("map"); - expect(room[kInternal].nodeCount).toBe(3); + expect(roomA[kInternal].nodeCount).toBe(3); expect(map.delete("first")).toBe(true); - expect(room[kInternal].nodeCount).toBe(2); + expect(roomA[kInternal].nodeCount).toBe(2); - expectStorage({ + await expectStorage({ map: new Map(), }); - assertUndoRedo(); + await assertUndoRedo(); }); test("should delete live list", async () => { - const { room, storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ map: LiveMap> }>( - [ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedList("0:2", "0:1", "first"), - createSerializedRegister("0:3", "0:2", "!", 0), - ], - 1 - ); + const { roomA, storageA, expectStorage, assertUndoRedo } = + await prepareStorageTest<{ + map: LiveMap>; + }>({ + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { + first: { liveblocksType: "LiveList", data: [0] }, + }, + }, + }, + }); - expectStorage({ + await expectStorage({ map: new Map([["first", [0]]]), }); - const root = storage.root; - const map = root.toObject().map; + const root = storageA.root; + const map = root.get("map"); - expect(room[kInternal].nodeCount).toBe(4); + expect(roomA[kInternal].nodeCount).toBe(4); expect(map.delete("first")).toBe(true); - expect(room[kInternal].nodeCount).toBe(2); + expect(roomA[kInternal].nodeCount).toBe(2); - expectStorage({ + await expectStorage({ map: new Map(), }); - assertUndoRedo(); + await assertUndoRedo(); }); // https://github.com/liveblocks/liveblocks/issues/95 test("should have deleted key when subscriber is called", async () => { const { room, root } = await prepareIsolatedStorageTest<{ map: LiveMap; - }>( - [ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedRegister("0:2", "0:1", "first", "a"), - createSerializedRegister("0:3", "0:1", "second", "b"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { first: "a", second: "b" }, + }, + }, + }); const map = root.get("map"); @@ -341,15 +362,15 @@ describe("LiveMap", () => { test("should call subscribe when key is deleted", async () => { const { room, root } = await prepareIsolatedStorageTest<{ map: LiveMap; - }>( - [ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedRegister("0:2", "0:1", "first", "a"), - createSerializedRegister("0:3", "0:1", "second", "b"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { first: "a", second: "b" }, + }, + }, + }); const map = root.get("map"); @@ -366,15 +387,15 @@ describe("LiveMap", () => { test("should not call subscribe when key is not deleted", async () => { const { room, root } = await prepareIsolatedStorageTest<{ map: LiveMap; - }>( - [ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedRegister("0:2", "0:1", "first", "a"), - createSerializedRegister("0:3", "0:1", "second", "b"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { first: "a", second: "b" }, + }, + }, + }); const map = root.get("map"); @@ -389,36 +410,38 @@ describe("LiveMap", () => { }); test("map.set live object", async () => { - const { storage, expectStorage, assertUndoRedo } = + const { storageA, expectStorage, assertUndoRedo } = await prepareStorageTest<{ map: LiveMap>; - }>( - [createSerializedRoot(), createSerializedMap("0:1", "root", "map")], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { map: { liveblocksType: "LiveMap", data: {} } }, + }); - const root = storage.root; - const map = root.toObject().map; - expectStorage({ + const root = storageA.root; + const map = root.get("map"); + await expectStorage({ map: new Map(), }); map.set("first", new LiveObject({ a: 0 })); - expectStorage({ + await expectStorage({ map: new Map([["first", { a: 0 }]]), }); - assertUndoRedo(); + await assertUndoRedo(); }); test("map.set already attached live object should throw", async () => { - const { storage } = await prepareStorageTest<{ + const { root } = await prepareIsolatedStorageTest<{ map: LiveMap>; - }>([createSerializedRoot(), createSerializedMap("0:1", "root", "map")]); + }>({ + liveblocksType: "LiveObject", + data: { map: { liveblocksType: "LiveMap", data: {} } }, + }); - const root = storage.root; - const map = root.toObject().map; + const map = root.get("map"); const object = new LiveObject({ a: 0 }); @@ -427,12 +450,14 @@ describe("LiveMap", () => { }); test("new Map with already attached live object should throw", async () => { - const { storage } = await prepareStorageTest<{ + const { root } = await prepareIsolatedStorageTest<{ child: LiveObject<{ a: number }> | null; map: LiveMap> | null; - }>([createSerializedRoot({ child: null, map: null })], 1); + }>({ + liveblocksType: "LiveObject", + data: { child: null, map: null }, + }); - const root = storage.root; const child = new LiveObject({ a: 0 }); root.update({ child }); @@ -440,140 +465,153 @@ describe("LiveMap", () => { }); test("map.set live object on existing key", async () => { - const { storage, expectStorage, assertUndoRedo } = + const { storageA, expectStorage, assertUndoRedo } = await prepareStorageTest<{ map: LiveMap>; - }>( - [ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedObject("0:2", { a: 0 }, "0:1", "first"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { + first: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }, + }, + }); - expectStorage({ + await expectStorage({ map: new Map([["first", { a: 0 }]]), }); - const root = storage.root; - const map = root.toObject().map; + const root = storageA.root; + const map = root.get("map"); map.set("first", new LiveObject({ a: 1 })); - expectStorage({ + await expectStorage({ map: new Map([["first", { a: 1 }]]), }); - assertUndoRedo(); + await assertUndoRedo(); }); test("attach map with items to root", async () => { - const { storage, expectStorage, assertUndoRedo } = + const { storageA, expectStorage, assertUndoRedo } = await prepareStorageTest<{ map?: LiveMap; - }>([createSerializedRoot()], 1); + }>({ + liveblocksType: "LiveObject", + data: {}, + }); - expectStorage({}); + await expectStorage({}); - storage.root.set("map", new LiveMap([["first", { a: 0 }]])); + storageA.root.set("map", new LiveMap([["first", { a: 0 }]])); - expectStorage({ + await expectStorage({ map: new Map([["first", { a: 0 }]]), }); - assertUndoRedo(); + await assertUndoRedo(); }); test("attach map with live objects to root", async () => { - const { storage, expectStorage, assertUndoRedo } = + const { storageA, expectStorage, assertUndoRedo } = await prepareStorageTest<{ map?: LiveMap>; - }>([createSerializedRoot()], 1); + }>({ + liveblocksType: "LiveObject", + data: {}, + }); - expectStorage({}); + await expectStorage({}); - storage.root.set("map", new LiveMap([["first", new LiveObject({ a: 0 })]])); + storageA.root.set( + "map", + new LiveMap([["first", new LiveObject({ a: 0 })]]) + ); - expectStorage({ + await expectStorage({ map: new Map([["first", { a: 0 }]]), }); - assertUndoRedo(); + await assertUndoRedo(); }); test("attach map with objects to root", async () => { - const { storage, expectStorage, assertUndoRedo } = + const { storageA, expectStorage, assertUndoRedo } = await prepareStorageTest<{ map?: LiveMap; - }>([createSerializedRoot()], 1); + }>({ + liveblocksType: "LiveObject", + data: {}, + }); - expectStorage({}); + await expectStorage({}); - storage.root.set("map", new LiveMap([["first", { a: 0 }]])); + storageA.root.set("map", new LiveMap([["first", { a: 0 }]])); - expectStorage({ + await expectStorage({ map: new Map([["first", { a: 0 }]]), }); - assertUndoRedo(); + await assertUndoRedo(); }); test("add list in map", async () => { - const { storage, expectStorage, assertUndoRedo } = + const { storageA, expectStorage, assertUndoRedo } = await prepareStorageTest<{ map: LiveMap>; - }>( - [createSerializedRoot(), createSerializedMap("0:1", "root", "map")], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { map: { liveblocksType: "LiveMap", data: {} } }, + }); - expectStorage({ map: new Map() }); + await expectStorage({ map: new Map() }); - const map = storage.root.get("map"); + const map = storageA.root.get("map"); map.set("list", new LiveList(["itemA", "itemB", "itemC"])); - expectStorage({ + await expectStorage({ map: new Map([["list", ["itemA", "itemB", "itemC"]]]), }); - assertUndoRedo(); + await assertUndoRedo(); }); test("add map in map", async () => { - const { storage, expectStorage, assertUndoRedo } = + const { storageA, expectStorage, assertUndoRedo } = await prepareStorageTest<{ map: LiveMap>; - }>( - [createSerializedRoot(), createSerializedMap("0:1", "root", "map")], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { map: { liveblocksType: "LiveMap", data: {} } }, + }); - expectStorage({ map: new Map() }); + await expectStorage({ map: new Map() }); - const map = storage.root.get("map"); + const map = storageA.root.get("map"); map.set("map", new LiveMap([["first", "itemA"]])); - expectStorage({ + await expectStorage({ map: new Map([["map", new Map([["first", "itemA"]])]]), }); - assertUndoRedo(); + await assertUndoRedo(); }); describe("subscriptions", () => { test("simple action", async () => { - const { room, storage } = await prepareStorageTest<{ + const { room, root } = await prepareIsolatedStorageTest<{ map: LiveMap; - }>( - [createSerializedRoot(), createSerializedMap("0:1", "root", "map")], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { map: { liveblocksType: "LiveMap", data: {} } }, + }); const callback = vi.fn(); - const root = storage.root; - const liveMap = root.get("map"); room.subscribe(liveMap, callback); @@ -585,20 +623,22 @@ describe("LiveMap", () => { }); test("deep subscribe", async () => { - const { room, storage } = await prepareStorageTest<{ + const { room, root } = await prepareIsolatedStorageTest<{ map: LiveMap>; - }>( - [ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedObject("0:2", { a: 0 }, "0:1", "mapElement"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { + mapElement: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }, + }, + }); const callback = vi.fn(); - const root = storage.root; const mapElement = root.get("map").get("mapElement"); const unsubscribe = room.subscribe(root.get("map"), callback, { @@ -623,55 +663,42 @@ describe("LiveMap", () => { }); describe("reconnect with remote changes and subscribe", () => { - test("register added to map", async () => { - const { expectStorage, room, root, wss } = - await prepareIsolatedStorageTest<{ - map: LiveMap; - }>( - [ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedRegister("0:2", "0:1", "first", "a"), - ], - 1 - ); + // TODO: Needs atomic storage replacement in dev server + // See https://linear.app/liveblocks/issue/LB-3529/dev-server-needs-support-for-a-crash-replace-storage-atomic-feature + test.skip("register added to map", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + map: LiveMap; + }>({ + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { first: "a" }, + }, + }, + }); const rootDeepCallback = vi.fn(); const mapCallback = vi.fn(); - const listItems = root.get("map"); + const map = root.get("map"); room.subscribe(root, rootDeepCallback, { isDeep: true }); - room.subscribe(listItems, mapCallback); + room.subscribe(map, mapCallback); expectStorage({ map: new Map([["first", "a"]]) }); - const newInitStorage: StorageNode[] = [ - ["root", { type: CrdtType.OBJECT, data: {} }], - ["0:1", { type: CrdtType.MAP, parentId: "root", parentKey: "map" }], - [ - "0:2", - { - type: CrdtType.REGISTER, - parentId: "0:1", - parentKey: "first", - data: "a", + await replaceStorageAndReconnectDevServer(room.id, { + liveblocksType: "LiveObject", + data: { + map: { + liveblocksType: "LiveMap", + data: { first: "a", second: "b" }, }, - ], - [ - "2:0", - { - type: CrdtType.REGISTER, - parentId: "0:1", - parentKey: "second", - data: "b", - }, - ], - ]; - - replaceRemoteStorageAndReconnect(wss, newInitStorage); + }, + }); - await waitUntilStorageUpdate(room); + await vi.waitUntil(() => root.toImmutable().map.has("second")); expectStorage({ map: new Map([ ["first", "a"], @@ -684,7 +711,7 @@ describe("LiveMap", () => { expect(rootDeepCallback).toHaveBeenCalledWith([ { type: "LiveMap", - node: listItems, + node: map, updates: { second: { type: "update" } }, }, ]); @@ -692,42 +719,4 @@ describe("LiveMap", () => { expect(mapCallback).toHaveBeenCalledTimes(1); }); }); - - describe("internal methods", () => { - test("_detachChild", async () => { - const { root } = await prepareIsolatedStorageTest<{ - map: LiveMap>; - }>( - [ - createSerializedRoot(), - createSerializedMap("0:1", "root", "map"), - createSerializedObject("0:2", { a: 1 }, "0:1", "el1"), - createSerializedObject("0:3", { a: 2 }, "0:1", "el2"), - ], - 1 - ); - - const map = root.get("map"); - const secondItem = map.get("el2"); - - const applyResult = map._detachChild(secondItem!); - - expect(applyResult).toEqual({ - modified: { - node: map, - type: "LiveMap", - updates: { el2: { type: "delete", deletedItem: secondItem } }, - }, - reverse: [ - { - data: { a: 2 }, - id: "0:3", - parentId: "0:1", - parentKey: "el2", - type: OpCode.CREATE_OBJECT, - }, - ], - }); - }); - }); }); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts new file mode 100644 index 0000000000..a61189a94b --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts @@ -0,0 +1,58 @@ +/** + * LiveMap tests that use a MockWebSocket server for precise control over + * wire-level operations. Covers internal methods that need deterministic + * node IDs. + * + * For normal storage/presence/history tests, see LiveMap.devserver.test.ts. + */ +import { describe, expect, test } from "vitest"; + +import { + createSerializedMap, + createSerializedObject, + createSerializedRoot, + prepareIsolatedStorageTest, +} from "../../__tests__/_MockWebSocketServer.setup"; +import { OpCode } from "../../protocol/Op"; +import type { LiveMap } from "../LiveMap"; +import type { LiveObject } from "../LiveObject"; + +describe("LiveMap edge cases", () => { + describe("internal methods", () => { + test("_detachChild", async () => { + const { root } = await prepareIsolatedStorageTest<{ + map: LiveMap>; + }>( + [ + createSerializedRoot(), + createSerializedMap("0:1", "root", "map"), + createSerializedObject("0:2", { a: 1 }, "0:1", "el1"), + createSerializedObject("0:3", { a: 2 }, "0:1", "el2"), + ], + 1 + ); + + const map = root.get("map"); + const secondItem = map.get("el2"); + + const applyResult = map._detachChild(secondItem!); + + expect(applyResult).toEqual({ + modified: { + node: map, + type: "LiveMap", + updates: { el2: { type: "delete", deletedItem: secondItem } }, + }, + reverse: [ + { + data: { a: 2 }, + id: "0:3", + parentId: "0:1", + parentKey: "el2", + type: OpCode.CREATE_OBJECT, + }, + ], + }); + }); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.devserver.test.ts similarity index 55% rename from packages/liveblocks-core/src/crdts/__tests__/LiveObject.test.ts rename to packages/liveblocks-core/src/crdts/__tests__/LiveObject.devserver.test.ts index eec2037718..ee8c570565 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.devserver.test.ts @@ -1,21 +1,30 @@ -import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +/** + * LiveObject tests that run against the real dev server. + * + * For edge cases that require precise control over wire-level ops (ack + * mechanism, internal methods), see LiveObject.mockserver.test.ts. + */ +import { + afterEach, + beforeAll, + describe, + expect, + onTestFinished, + test, + vi, +} from "vitest"; -import { objectUpdate } from "../../__tests__/_updatesUtils"; import { - createSerializedList, - createSerializedObject, - createSerializedRoot, - prepareDisconnectedStorageUpdateTest, prepareIsolatedStorageTest, prepareStorageTest, - replaceRemoteStorageAndReconnect, -} from "../../__tests__/_utils"; -import { waitUntilStorageUpdate } from "../../__tests__/_waitUtils"; + replaceStorageAndReconnectDevServer, +} from "../../__tests__/_devserver"; +import { + type JsonStorageUpdate, + objectUpdate, + serializeUpdateToJson, +} from "../../__tests__/_updatesUtils"; import { kInternal } from "../../internal"; -import { Permission } from "../../protocol/AuthToken"; -import { OpCode } from "../../protocol/Op"; -import type { StorageNode } from "../../protocol/StorageNode"; -import { CrdtType } from "../../protocol/StorageNode"; import { LiveList } from "../LiveList"; import { LiveObject } from "../LiveObject"; @@ -26,28 +35,24 @@ describe("LiveObject", () => { }); test("should be the associated room id if attached", async () => { - const { root } = await prepareIsolatedStorageTest( - [createSerializedRoot()], - 1 - ); + const { root, room } = await prepareIsolatedStorageTest(); - expect(root.roomId).toBe("room-id"); + expect(root.roomId).toBe(room.id); }); test("should be null after being detached", async () => { - const { root } = await prepareIsolatedStorageTest<{ + const { root, room } = await prepareIsolatedStorageTest<{ child: LiveObject<{ a: number }>; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:0", { a: 0 }, "root", "child"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + child: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }); const child = root.get("child"); - expect(child.roomId).toBe("room-id"); + expect(child.roomId).toBe(room.id); root.set("child", new LiveObject({ a: 1 })); @@ -56,186 +61,200 @@ describe("LiveObject", () => { }); test("update non existing property", async () => { - const { storage, expectStorage, assertUndoRedo } = await prepareStorageTest( - [createSerializedRoot()] - ); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest({ + liveblocksType: "LiveObject", + data: {}, + }); - expectStorage({}); + await expectStorage({}); storage.root.update({ a: 1 }); - expectStorage({ + await expectStorage({ a: 1, }); - assertUndoRedo(); + await assertUndoRedo(); }); test("update non existing property with null", async () => { - const { storage, expectStorage, assertUndoRedo } = await prepareStorageTest( - [createSerializedRoot()] - ); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest({ + liveblocksType: "LiveObject", + data: {}, + }); - expectStorage({}); + await expectStorage({}); storage.root.update({ a: null }); - expectStorage({ + await expectStorage({ a: null, }); - assertUndoRedo(); + await assertUndoRedo(); }); - test("update throws on read-only", async () => { - const { storage } = await prepareStorageTest( - [createSerializedRoot({ a: 0 })], - 1, - [Permission.Read, Permission.PresenceWrite] + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("update throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest<{ a: number }>( + { liveblocksType: "LiveObject", data: { a: 0 } }, + { permissions: ["room:read", "room:presence:write"] } ); - expect(() => storage.root.update({ a: 1 })).toThrow( + expect(() => root.update({ a: 1 })).toThrow( "Cannot write to storage with a read only user, please ensure the user has write permissions" ); }); test("update existing property", async () => { - const { storage, expectStorage, assertUndoRedo } = await prepareStorageTest( - [createSerializedRoot({ a: 0 })] - ); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ a: number }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); - expectStorage({ a: 0 }); + await expectStorage({ a: 0 }); storage.root.update({ a: 1 }); - expectStorage({ + await expectStorage({ a: 1, }); - assertUndoRedo(); + await assertUndoRedo(); }); test("update existing property with null", async () => { - const { storage, expectStorage, assertUndoRedo } = await prepareStorageTest( - [createSerializedRoot({ a: 0 })] - ); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ a: number | null }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); - expectStorage({ a: 0 }); + await expectStorage({ a: 0 }); storage.root.update({ a: null }); - expectStorage({ + await expectStorage({ a: null, }); - assertUndoRedo(); + await assertUndoRedo(); }); test("update root", async () => { - const { storage, expectStorage, assertUndoRedo } = await prepareStorageTest( - [createSerializedRoot({ a: 0 })] - ); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ a: number; b?: number }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); - expectStorage({ + await expectStorage({ a: 0, }); storage.root.update({ a: 1 }); - expectStorage({ + await expectStorage({ a: 1, }); storage.root.update({ b: 1 }); - expectStorage({ + await expectStorage({ a: 1, b: 1, }); - assertUndoRedo(); + await assertUndoRedo(); }); - test("set throws on read-only", async () => { - const { storage } = await prepareStorageTest([createSerializedRoot()], 1, [ - Permission.Read, - Permission.PresenceWrite, - ]); + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("set throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest(undefined, { + permissions: ["room:read", "room:presence:write"], + }); - expect(() => storage.root.set("a", 1)).toThrow( + expect(() => root.set("a", 1)).toThrow( "Cannot write to storage with a read only user, please ensure the user has write permissions" ); }); test("update with LiveObject", async () => { - const { room, storage, expectStorage, operations, assertUndoRedo } = - await prepareStorageTest<{ child: LiveObject<{ a: number }> | null }>( - [createSerializedRoot({ child: null })], - 1 - ); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ + child: LiveObject<{ a: number }> | null; + }>({ + liveblocksType: "LiveObject", + data: { child: null }, + }); const root = storage.root; - expectStorage({ + await expectStorage({ child: null, }); root.set("child", new LiveObject({ a: 0 })); - expectStorage({ + await expectStorage({ child: { a: 0, }, }); - expect(room[kInternal].undoStack[0]).toEqual([ - { - type: OpCode.UPDATE_OBJECT, - id: "root", - data: { - child: null, - }, - }, - ]); - - expect(operations.length).toEqual(1); - expect(operations).toEqual([ - { - type: OpCode.CREATE_OBJECT, - id: "1:0", - opId: "1:1", - data: { a: 0 }, - parentId: "root", - parentKey: "child", - }, - ]); root.set("child", null); - expectStorage({ + await expectStorage({ child: null, }); - expect(room[kInternal].undoStack[1]).toEqual([ - { - type: OpCode.CREATE_OBJECT, - id: "1:0", - data: { a: 0 }, - parentId: "root", - parentKey: "child", - }, - ]); - assertUndoRedo(); + await assertUndoRedo(); }); test("remove nested grand child record with update", async () => { - const { room, storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - a: number; - child: LiveObject<{ - b: number; - grandChild: LiveObject<{ c: number }>; - }> | null; - }>([ - createSerializedRoot({ a: 0 }), - createSerializedObject("0:1", { b: 0 }, "root", "child"), - createSerializedObject("0:2", { c: 0 }, "0:1", "grandChild"), - ]); + const { + roomA: room, + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ + a: number; + child: LiveObject<{ + b: number; + grandChild: LiveObject<{ c: number }>; + }> | null; + }>({ + liveblocksType: "LiveObject", + data: { + a: 0, + child: { + liveblocksType: "LiveObject", + data: { + b: 0, + grandChild: { liveblocksType: "LiveObject", data: { c: 0 } }, + }, + }, + }, + }); - expectStorage({ + await expectStorage({ a: 0, child: { b: 0, @@ -247,26 +266,33 @@ describe("LiveObject", () => { storage.root.update({ child: null }); - expectStorage({ + await expectStorage({ a: 0, child: null, }); expect(room[kInternal].nodeCount).toBe(1); - assertUndoRedo(); + await assertUndoRedo(); }); test("remove nested child record with update", async () => { - const { room, storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - a: number; - child: LiveObject<{ b: number }> | null; - }>([ - createSerializedRoot({ a: 0 }), - createSerializedObject("0:1", { b: 0 }, "root", "child"), - ]); + const { + roomA: room, + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ + a: number; + child: LiveObject<{ b: number }> | null; + }>({ + liveblocksType: "LiveObject", + data: { + a: 0, + child: { liveblocksType: "LiveObject", data: { b: 0 } }, + }, + }); - expectStorage({ + await expectStorage({ a: 0, child: { b: 0, @@ -275,26 +301,33 @@ describe("LiveObject", () => { storage.root.update({ child: null }); - expectStorage({ + await expectStorage({ a: 0, child: null, }); expect(room[kInternal].nodeCount).toBe(1); - assertUndoRedo(); + await assertUndoRedo(); }); test("add nested record with update", async () => { - const { room, storage, expectStorage, assertUndoRedo } = - await prepareStorageTest([createSerializedRoot()], 1); + const { + roomA: room, + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest({ + liveblocksType: "LiveObject", + data: {}, + }); - expectStorage({}); + await expectStorage({}); storage.root.update({ child: new LiveObject({ a: 0 }), }); - expectStorage({ + await expectStorage({ child: { a: 0, }, @@ -302,20 +335,27 @@ describe("LiveObject", () => { expect(room[kInternal].nodeCount).toBe(2); - assertUndoRedo(); + await assertUndoRedo(); }); test("replace nested record with update", async () => { - const { room, storage, expectStorage, assertUndoRedo } = - await prepareStorageTest([createSerializedRoot()], 1); + const { + roomA: room, + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest({ + liveblocksType: "LiveObject", + data: {}, + }); - expectStorage({}); + await expectStorage({}); storage.root.update({ child: new LiveObject({ a: 0 }), }); - expectStorage({ + await expectStorage({ child: { a: 0, }, @@ -325,7 +365,7 @@ describe("LiveObject", () => { child: new LiveObject({ a: 1 }), }); - expectStorage({ + await expectStorage({ child: { a: 1, }, @@ -333,23 +373,29 @@ describe("LiveObject", () => { expect(room[kInternal].nodeCount).toBe(2); - assertUndoRedo(); + await assertUndoRedo(); }); test("update nested record", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - a: number; - child: LiveObject<{ b: number }>; - }>([ - createSerializedRoot({ a: 0 }), - createSerializedObject("0:1", { b: 0 }, "root", "child"), - ]); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ + a: number; + child: LiveObject<{ b: number }>; + }>({ + liveblocksType: "LiveObject", + data: { + a: 0, + child: { liveblocksType: "LiveObject", data: { b: 0 } }, + }, + }); const root = storage.root; const child = root.toObject().child; - expectStorage({ + await expectStorage({ a: 0, child: { b: 0, @@ -357,28 +403,39 @@ describe("LiveObject", () => { }); child.update({ b: 1 }); - expectStorage({ + await expectStorage({ a: 0, child: { b: 1, }, }); - assertUndoRedo(); + await assertUndoRedo(); }); test("update deeply nested record", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - a: number; - child: LiveObject<{ b: number; grandChild: LiveObject<{ c: number }> }>; - }>([ - createSerializedRoot({ a: 0 }), - createSerializedObject("0:1", { b: 0 }, "root", "child"), - createSerializedObject("0:2", { c: 0 }, "0:1", "grandChild"), - ]); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ + a: number; + child: LiveObject<{ b: number; grandChild: LiveObject<{ c: number }> }>; + }>({ + liveblocksType: "LiveObject", + data: { + a: 0, + child: { + liveblocksType: "LiveObject", + data: { + b: 0, + grandChild: { liveblocksType: "LiveObject", data: { c: 0 } }, + }, + }, + }, + }); - expectStorage({ + await expectStorage({ a: 0, child: { b: 0, @@ -398,7 +455,7 @@ describe("LiveObject", () => { grandChild.update({ c: 1 }); expect(grandChild.toObject()).toMatchObject({ c: 1 }); - expectStorage({ + await expectStorage({ a: 0, child: { b: 0, @@ -408,18 +465,31 @@ describe("LiveObject", () => { }, }); - assertUndoRedo(); + await assertUndoRedo(); }); describe("acknowledge mechanism", () => { test("should not ignore history updates if the current op has not been acknowledged", async () => { - const { room, root, expectUpdates } = - await prepareDisconnectedStorageUpdateTest<{ - items: LiveObject<{ b?: string; a?: string }>; - }>([ - createSerializedRoot(), - createSerializedObject("0:1", { a: "initial" }, "root", "items"), - ]); + const { room, root } = await prepareIsolatedStorageTest<{ + items: LiveObject<{ b?: string; a?: string }>; + }>({ + liveblocksType: "LiveObject", + data: { + items: { + liveblocksType: "LiveObject", + data: { a: "initial" }, + }, + }, + }); + + const receivedUpdates: JsonStorageUpdate[][] = []; + onTestFinished( + room.subscribe( + root, + (updates) => receivedUpdates.push(updates.map(serializeUpdateToJson)), + { isDeep: true } + ) + ); const items = root.get("items"); room.batch(() => { @@ -428,7 +498,7 @@ describe("LiveObject", () => { }); expect(items.toObject()).toEqual({ a: "A", b: "B" }); - expectUpdates([ + expect(receivedUpdates).toEqual([ [ objectUpdate( { a: "A", b: "B" }, @@ -440,7 +510,7 @@ describe("LiveObject", () => { room.history.undo(); expect(items.toObject()).toEqual({ a: "initial" }); - expectUpdates([ + expect(receivedUpdates).toEqual([ [ objectUpdate( { a: "A", b: "B" }, @@ -455,108 +525,26 @@ describe("LiveObject", () => { ], ]); }); - - describe("should ignore incoming updates if the current op has not been acknowledged", () => { - test("when value is not a crdt", async () => { - const { root, expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ a: number }>( - [createSerializedRoot({ a: 0 })], - 1 - ); - - expectStorage({ a: 0 }); - - root.set("a", 1); - - expectStorage({ a: 1 }); - - applyRemoteOperations([ - { - type: OpCode.UPDATE_OBJECT, - data: { a: 2 }, - id: "root", - }, - ]); - - expectStorage({ a: 1 }); - }); - - test("when value is a LiveObject", async () => { - const { root, expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ - a: LiveObject<{ subA: number }>; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { subA: 0 }, "root", "a"), - ], - 1 - ); - - expectStorage({ a: { subA: 0 } }); - - root.set("a", new LiveObject({ subA: 1 })); - - expectStorage({ a: { subA: 1 } }); - - applyRemoteOperations([ - { - type: OpCode.CREATE_OBJECT, - data: { subA: 2 }, - id: "2:0", - parentKey: "a", - parentId: "root", - }, - ]); - - expectStorage({ a: { subA: 1 } }); - }); - - test("when value is a LiveList with LiveObjects", async () => { - const { root, expectStorage, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ - a: LiveList>; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "a")], - 1 - ); - - expectStorage({ a: [] }); - - const newList = new LiveList>([]); - newList.push(new LiveObject({ b: 1 })); - root.set("a", newList); - - expectStorage({ a: [{ b: 1 }] }); - - applyRemoteOperations([ - { - type: OpCode.CREATE_LIST, - id: "2:0", - parentKey: "a", - parentId: "root", - }, - ]); - - expectStorage({ a: [{ b: 1 }] }); - }); - }); }); describe("delete", () => { - test("throws on read-only", async () => { - const { storage } = await prepareStorageTest<{ + // TODO: Needs read-only permission support in dev server + // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms + test.skip("throws on read-only", async () => { + const { root } = await prepareIsolatedStorageTest<{ child: LiveObject<{ a: number }>; }>( - [ - createSerializedRoot({ a: 1 }), - createSerializedObject("0:1", { b: 2 }, "root", "child"), - ], - 1, - [Permission.Read, Permission.PresenceWrite] + { + liveblocksType: "LiveObject", + data: { + a: 1, + child: { liveblocksType: "LiveObject", data: { b: 2 } }, + }, + }, + { permissions: ["room:read", "room:presence:write"] } ); - expect(() => storage.root.get("child").delete("a")).toThrow( + expect(() => root.get("child").delete("a")).toThrow( "Cannot write to storage with a read only user, please ensure the user has write permissions" ); }); @@ -568,39 +556,50 @@ describe("LiveObject", () => { }); test("should delete property from the object", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - a?: number; - }>([createSerializedRoot({ a: 0 })]); - expectStorage({ a: 0 }); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ + a?: number; + }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); + await expectStorage({ a: 0 }); storage.root.delete("a"); - expectStorage({}); + await expectStorage({}); - assertUndoRedo(); + await assertUndoRedo(); }); test("should delete nested crdt", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - child?: LiveObject<{ a: number }>; - }>([ - createSerializedRoot(), - createSerializedObject("0:1", { a: 0 }, "root", "child"), - ]); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ + child?: LiveObject<{ a: number }>; + }>({ + liveblocksType: "LiveObject", + data: { + child: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }); - expectStorage({ child: { a: 0 } }); + await expectStorage({ child: { a: 0 } }); storage.root.delete("child"); - expectStorage({}); + await expectStorage({}); - assertUndoRedo(); + await assertUndoRedo(); }); test("should not notify if property does not exist", async () => { const { room, root } = await prepareIsolatedStorageTest<{ a?: number; - }>([createSerializedRoot()]); + }>(); const callback = vi.fn(); room.subscribe(root, callback); @@ -613,7 +612,10 @@ describe("LiveObject", () => { test("should notify if property has been deleted", async () => { const { room, root } = await prepareIsolatedStorageTest<{ a?: number; - }>([createSerializedRoot({ a: 1 })]); + }>({ + liveblocksType: "LiveObject", + data: { a: 1 }, + }); const callback = vi.fn(); room.subscribe(root, callback); @@ -625,75 +627,84 @@ describe("LiveObject", () => { }); describe("applyDeleteObjectKey", () => { - test("should not notify if property does not exist", async () => { - const { room, root, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ a?: number }>([ - createSerializedRoot(), - ]); - - const callback = vi.fn(); - room.subscribe(root, callback); + // When a remote DELETE_OBJECT_KEY arrives for a key that doesn't exist + // locally (because this client already deleted it), the subscription + // callback should NOT fire. We test this by having both clients delete + // an overlapping key "c" simultaneously: + // + // Start: { a, b, c } on both clients + // Client A: deletes a + c locally + // Client B: deletes b + c locally + // + // After sync, each client gets 3 notifications (not 4): + // - 2 from their own local deletes + // - 1 from the other client's non-overlapping delete + // - 0 from the other client's overlapping "delete c" (already gone) + test("should not notify for redundant remote delete", async () => { + const { roomA, roomB, storageA, storageB } = await prepareStorageTest<{ + a?: number; + b?: number; + c?: number; + }>({ + liveblocksType: "LiveObject", + data: { a: 1, b: 2, c: 3 }, + }); - applyRemoteOperations([ - { type: OpCode.DELETE_OBJECT_KEY, id: "root", key: "a" }, - ]); + const callbackA = vi.fn(); + const callbackB = vi.fn(); - expect(callback).toHaveBeenCalledTimes(0); - }); + roomA.subscribe(storageA.root, callbackA); + roomB.subscribe(storageB.root, callbackB); - test("should notify if property has been deleted", async () => { - const { room, root, applyRemoteOperations } = - await prepareIsolatedStorageTest<{ a?: number }>([ - createSerializedRoot({ a: 1 }), - ]); + // Both clients delete an overlapping key "c" simultaneously + storageA.root.delete("a"); + storageA.root.delete("c"); - const callback = vi.fn(); - room.subscribe(root, callback); + storageB.root.delete("b"); + storageB.root.delete("c"); - applyRemoteOperations([ - { type: OpCode.DELETE_OBJECT_KEY, id: "root", key: "a" }, - ]); + // Wait for both clients to fully sync + await vi.waitUntil(() => storageA.root.get("b") === undefined); + await vi.waitUntil(() => storageB.root.get("a") === undefined); - expect(callback).toHaveBeenCalledTimes(1); + // Each client: 2 local deletes + 1 remote delete = 3 + // The redundant remote "delete c" must NOT fire a 4th notification + expect(callbackA).toHaveBeenCalledTimes(3); + expect(callbackB).toHaveBeenCalledTimes(3); }); }); describe("subscriptions", () => { test("simple action", async () => { - const { room, storage } = await prepareStorageTest<{ a: number }>( - [createSerializedRoot({ a: 0 })], - 1 - ); + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); const callback = vi.fn(); - const root = storage.root; - room.subscribe(root, callback); root.set("a", 1); expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(storage.root); + expect(callback).toHaveBeenCalledWith(root); }); test("subscribe multiple actions", async () => { - const { room, storage } = await prepareStorageTest<{ + const { room, root } = await prepareIsolatedStorageTest<{ child: LiveObject<{ a: number }>; child2: LiveObject<{ a: number }>; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 0 }, "root", "child"), - createSerializedObject("0:2", { a: 0 }, "root", "child2"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + child: { liveblocksType: "LiveObject", data: { a: 0 } }, + child2: { liveblocksType: "LiveObject", data: { a: 0 } }, + }, + }); const callback = vi.fn(); - const root = storage.root; - const unsubscribe = room.subscribe(root.get("child"), callback); root.get("child").set("a", 1); @@ -709,21 +720,23 @@ describe("LiveObject", () => { }); test("deep subscribe", async () => { - const { room, storage } = await prepareStorageTest<{ + const { room, root } = await prepareIsolatedStorageTest<{ child: LiveObject<{ a: number; subchild: LiveObject<{ b: number }> }>; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 0 }, "root", "child"), - createSerializedObject("0:2", { b: 0 }, "0:1", "subchild"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + child: { + liveblocksType: "LiveObject", + data: { + a: 0, + subchild: { liveblocksType: "LiveObject", data: { b: 0 } }, + }, + }, + }, + }); const callback = vi.fn(); - const root = storage.root; - const unsubscribe = room.subscribe(root, callback, { isDeep: true }); root.get("child").set("a", 1); @@ -751,132 +764,126 @@ describe("LiveObject", () => { }); test("deep subscribe remote operation", async () => { - const { room, storage, applyRemoteOperations } = - await prepareStorageTest<{ - child: LiveObject<{ - a: number; - subchild: LiveObject<{ b: number }>; - }>; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 0 }, "root", "child"), - createSerializedObject("0:2", { b: 0 }, "0:1", "subchild"), - ], - 1 - ); + const { roomA, storageA, storageB } = await prepareStorageTest<{ + child: LiveObject<{ + a: number; + subchild: LiveObject<{ b: number }>; + }>; + }>({ + liveblocksType: "LiveObject", + data: { + child: { + liveblocksType: "LiveObject", + data: { + a: 0, + subchild: { liveblocksType: "LiveObject", data: { b: 0 } }, + }, + }, + }, + }); const callback = vi.fn(); - const root = storage.root; + const rootA = storageA.root; - const unsubscribe = room.subscribe(root, callback, { isDeep: true }); + const unsubscribe = roomA.subscribe(rootA, callback, { isDeep: true }); - root.get("child").set("a", 1); + rootA.get("child").set("a", 1); - applyRemoteOperations([ - { - type: OpCode.UPDATE_OBJECT, - data: { b: 1 }, - id: "0:2", - }, - ]); + // Remote change via client B + storageB.root.get("child").get("subchild").set("b", 1); + await vi.waitUntil( + () => rootA.get("child").get("subchild").get("b") === 1 + ); unsubscribe(); - root.get("child").set("a", 2); + rootA.get("child").set("a", 2); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith([ { type: "LiveObject", - node: root.get("child"), + node: rootA.get("child"), updates: { a: { type: "update" } }, }, ]); expect(callback).toHaveBeenCalledWith([ { type: "LiveObject", - node: root.get("child").get("subchild"), + node: rootA.get("child").get("subchild"), updates: { b: { type: "update" } }, }, ]); }); test("subscribe subchild remote operation", async () => { - const { room, storage, applyRemoteOperations } = - await prepareStorageTest<{ - child: LiveObject<{ - a: number; - subchild: LiveObject<{ b: number }>; - }>; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 0 }, "root", "child"), - createSerializedObject("0:2", { b: 0 }, "0:1", "subchild"), - ], - 1 - ); + const { roomA, storageA, storageB } = await prepareStorageTest<{ + child: LiveObject<{ + a: number; + subchild: LiveObject<{ b: number }>; + }>; + }>({ + liveblocksType: "LiveObject", + data: { + child: { + liveblocksType: "LiveObject", + data: { + a: 0, + subchild: { liveblocksType: "LiveObject", data: { b: 0 } }, + }, + }, + }, + }); const callback = vi.fn(); - const root = storage.root; + const rootA = storageA.root; - const subchild = root.get("child").get("subchild"); + const subchild = rootA.get("child").get("subchild"); - const unsubscribe = room.subscribe(subchild, callback); + const unsubscribe = roomA.subscribe(subchild, callback); - applyRemoteOperations([ - { - type: OpCode.UPDATE_OBJECT, - data: { a: 1 }, - id: "0:1", - }, - { - type: OpCode.UPDATE_OBJECT, - data: { b: 1 }, - id: "0:2", - }, - ]); + // Remote changes via client B + storageB.root.get("child").set("a", 1); + storageB.root.get("child").get("subchild").set("b", 1); + await vi.waitUntil( + () => rootA.get("child").get("subchild").get("b") === 1 + ); unsubscribe(); - root.get("child").get("subchild").set("b", 2); + rootA.get("child").get("subchild").set("b", 2); expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(root.get("child").get("subchild")); + expect(callback).toHaveBeenCalledWith(rootA.get("child").get("subchild")); }); test("deep subscribe remote and local operation - delete object key", async () => { - const { room, storage, applyRemoteOperations } = - await prepareStorageTest<{ - child: LiveObject<{ a?: number; b?: number }>; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: -1, b: -2 }, "root", "child"), - ], - 1 - ); + const { roomA, storageA, storageB } = await prepareStorageTest<{ + child: LiveObject<{ a?: number; b?: number }>; + }>({ + liveblocksType: "LiveObject", + data: { + child: { + liveblocksType: "LiveObject", + data: { a: -1, b: -2 }, + }, + }, + }); const callback = vi.fn(); - const root = storage.root; + const rootA = storageA.root; - const unsubscribe = room.subscribe(root, callback, { isDeep: true }); + const unsubscribe = roomA.subscribe(rootA, callback, { isDeep: true }); - // Remote deletion - applyRemoteOperations([ - { - type: OpCode.DELETE_OBJECT_KEY, - key: "a", - id: "0:1", - }, - ]); + // Remote deletion via client B + storageB.root.get("child").delete("a"); + await vi.waitUntil(() => rootA.get("child").get("a") === undefined); // Local deletion - root.get("child").delete("b"); + rootA.get("child").delete("b"); unsubscribe(); @@ -884,32 +891,36 @@ describe("LiveObject", () => { expect(callback).toHaveBeenNthCalledWith(1, [ { type: "LiveObject", - node: root.get("child"), + node: rootA.get("child"), updates: { a: { type: "delete", deletedItem: -1 } }, }, ]); expect(callback).toHaveBeenNthCalledWith(2, [ { type: "LiveObject", - node: root.get("child"), + node: rootA.get("child"), updates: { b: { type: "delete", deletedItem: -2 } }, }, ]); }); }); + // TODO: Needs atomic storage replacement + reconnect support in dev server + // See https://linear.app/liveblocks/issue/LB-3529/dev-server-needs-support-for-a-crash-replace-storage-atomic-feature + // + // The dev server needs a new endpoint that atomically replaces a room's + // storage and disconnects all clients, forcing them to reconnect and + // reconcile the diff. Until then, these tests are skipped. describe("reconnect with remote changes and subscribe", () => { - test("LiveObject updated", async () => { - const { expectStorage, room, root, wss } = - await prepareIsolatedStorageTest<{ - obj: LiveObject<{ a: number }>; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 1 }, "root", "obj"), - ], - 1 - ); + test.skip("LiveObject updated", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + obj: LiveObject<{ a: number }>; + }>({ + liveblocksType: "LiveObject", + data: { + obj: { liveblocksType: "LiveObject", data: { a: 1 } }, + }, + }); const rootDeepCallback = vi.fn(); const liveObjectCallback = vi.fn(); @@ -919,22 +930,14 @@ describe("LiveObject", () => { expectStorage({ obj: { a: 1 } }); - const newInitStorage: StorageNode[] = [ - ["root", { type: CrdtType.OBJECT, data: {} }], - [ - "0:1", - { - type: CrdtType.OBJECT, - data: { a: 2 }, - parentId: "root", - parentKey: "obj", - }, - ], - ]; - - replaceRemoteStorageAndReconnect(wss, newInitStorage); + await replaceStorageAndReconnectDevServer(room.id, { + liveblocksType: "LiveObject", + data: { + obj: { liveblocksType: "LiveObject", data: { a: 2 } }, + }, + }); - await waitUntilStorageUpdate(room); + await vi.waitUntil(() => root.get("obj").get("a") === 2); expectStorage({ obj: { a: 2 }, }); @@ -952,17 +955,15 @@ describe("LiveObject", () => { expect(liveObjectCallback).toHaveBeenCalledTimes(1); }); - test("LiveObject updated nested", async () => { - const { expectStorage, room, root, wss } = - await prepareIsolatedStorageTest<{ - obj: LiveObject<{ a: number; subObj?: LiveObject<{ b: number }> }>; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", { a: 1 }, "root", "obj"), - ], - 1 - ); + test.skip("LiveObject updated nested", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + obj: LiveObject<{ a: number; subObj?: LiveObject<{ b: number }> }>; + }>({ + liveblocksType: "LiveObject", + data: { + obj: { liveblocksType: "LiveObject", data: { a: 1 } }, + }, + }); const rootDeepCallback = vi.fn(); const liveObjectCallback = vi.fn(); @@ -972,31 +973,20 @@ describe("LiveObject", () => { expectStorage({ obj: { a: 1 } }); - const newInitStorage: StorageNode[] = [ - ["root", { type: CrdtType.OBJECT, data: {} }], - [ - "0:1", - { - type: CrdtType.OBJECT, - data: { a: 1 }, - parentId: "root", - parentKey: "obj", - }, - ], - [ - "0:2", - { - type: CrdtType.OBJECT, - data: { b: 1 }, - parentId: "0:1", - parentKey: "subObj", + await replaceStorageAndReconnectDevServer(room.id, { + liveblocksType: "LiveObject", + data: { + obj: { + liveblocksType: "LiveObject", + data: { + a: 1, + subObj: { liveblocksType: "LiveObject", data: { b: 1 } }, + }, }, - ], - ]; - - replaceRemoteStorageAndReconnect(wss, newInitStorage); + }, + }); - await waitUntilStorageUpdate(room); + await vi.waitUntil(() => root.get("obj").get("subObj") !== undefined); expectStorage({ obj: { a: 1, subObj: { b: 1 } }, }); @@ -1021,7 +1011,10 @@ describe("LiveObject", () => { test("subscription should gives the right update", async () => { const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ a: number; - }>([createSerializedRoot({ a: 0 })], 1); + }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); expectStorage({ a: 0 }); root.set("a", 1); @@ -1039,47 +1032,6 @@ describe("LiveObject", () => { }); }); - describe("internal methods", () => { - test("_detachChild", async () => { - const { root } = await prepareIsolatedStorageTest<{ - obj: LiveObject<{ - a: LiveObject<{ subA: number }>; - b: LiveObject<{ subA: number }>; - }>; - }>( - [ - createSerializedRoot(), - createSerializedObject("0:1", {}, "root", "obj"), - createSerializedObject("0:2", { subA: 1 }, "0:1", "a"), - createSerializedObject("0:3", { subA: 2 }, "0:1", "b"), - ], - 1 - ); - - const obj = root.get("obj"); - const secondItem = obj.get("b"); - - const applyResult = obj._detachChild(secondItem); - - expect(applyResult).toEqual({ - modified: { - node: obj, - type: "LiveObject", - updates: { b: { type: "delete" } }, - }, - reverse: [ - { - data: { subA: 2 }, - id: "0:3", - parentId: "0:1", - parentKey: "b", - type: OpCode.CREATE_OBJECT, - }, - ], - }); - }); - }); - describe("LiveObject.detectLargeObjects static property validation", () => { // Create a large string of specified size in bytes const createLargeData = (sizeInBytes: number) => { diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts new file mode 100644 index 0000000000..a64403dc35 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts @@ -0,0 +1,161 @@ +/** + * LiveObject tests that use a MockWebSocket server for precise control over + * wire-level operations. Covers ack mechanism edge cases and internal methods + * that need deterministic node IDs. + * + * For normal storage/presence/history tests, see LiveObject.devserver.test.ts. + */ +import { describe, expect, test } from "vitest"; + +import { + createSerializedList, + createSerializedObject, + createSerializedRoot, + prepareIsolatedStorageTest, +} from "../../__tests__/_MockWebSocketServer.setup"; +import { kInternal } from "../../internal"; +import type { ServerWireOp } from "../../protocol/Op"; +import { OpCode } from "../../protocol/Op"; +import { ServerMsgCode } from "../../protocol/ServerMsg"; +import { LiveList } from "../LiveList"; +import { LiveObject } from "../LiveObject"; + +/** + * Injects server operations directly into the room's message handler, + * bypassing the MockWebSocket transport layer. + */ +function injectRemoteOps( + room: { [kInternal]: { simulate: { incomingMessage(data: string): void } } }, + ops: ServerWireOp[] +) { + room[kInternal].simulate.incomingMessage( + JSON.stringify({ type: ServerMsgCode.UPDATE_STORAGE, ops }) + ); +} + +describe("LiveObject edge cases", () => { + describe("acknowledge mechanism", () => { + describe("should ignore incoming updates if the current op has not been acknowledged", () => { + test("when value is not a crdt", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + a: number; + }>([createSerializedRoot({ a: 0 })], 1); + + expectStorage({ a: 0 }); + + root.set("a", 1); + + expectStorage({ a: 1 }); + + injectRemoteOps(room, [ + { + type: OpCode.UPDATE_OBJECT, + data: { a: 2 }, + id: "root", + }, + ]); + + expectStorage({ a: 1 }); + }); + + test("when value is a LiveObject", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + a: LiveObject<{ subA: number }>; + }>( + [ + createSerializedRoot(), + createSerializedObject("0:1", { subA: 0 }, "root", "a"), + ], + 1 + ); + + expectStorage({ a: { subA: 0 } }); + + root.set("a", new LiveObject({ subA: 1 })); + + expectStorage({ a: { subA: 1 } }); + + injectRemoteOps(room, [ + { + type: OpCode.CREATE_OBJECT, + data: { subA: 2 }, + id: "2:0", + parentKey: "a", + parentId: "root", + }, + ]); + + expectStorage({ a: { subA: 1 } }); + }); + + test("when value is a LiveList with LiveObjects", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + a: LiveList>; + }>( + [createSerializedRoot(), createSerializedList("0:1", "root", "a")], + 1 + ); + + expectStorage({ a: [] }); + + const newList = new LiveList>([]); + newList.push(new LiveObject({ b: 1 })); + root.set("a", newList); + + expectStorage({ a: [{ b: 1 }] }); + + injectRemoteOps(room, [ + { + type: OpCode.CREATE_LIST, + id: "2:0", + parentKey: "a", + parentId: "root", + }, + ]); + + expectStorage({ a: [{ b: 1 }] }); + }); + }); + }); + + describe("internal methods", () => { + test("_detachChild", async () => { + const { root } = await prepareIsolatedStorageTest<{ + obj: LiveObject<{ + a: LiveObject<{ subA: number }>; + b: LiveObject<{ subA: number }>; + }>; + }>( + [ + createSerializedRoot(), + createSerializedObject("0:1", {}, "root", "obj"), + createSerializedObject("0:2", { subA: 1 }, "0:1", "a"), + createSerializedObject("0:3", { subA: 2 }, "0:1", "b"), + ], + 1 + ); + + const obj = root.get("obj"); + const secondItem = obj.get("b"); + + const applyResult = obj._detachChild(secondItem); + + expect(applyResult).toEqual({ + modified: { + node: obj, + type: "LiveObject", + updates: { b: { type: "delete" } }, + }, + reverse: [ + { + data: { subA: 2 }, + id: "0:3", + parentId: "0:1", + parentKey: "b", + type: OpCode.CREATE_OBJECT, + }, + ], + }); + }); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/cloning.test.ts b/packages/liveblocks-core/src/crdts/__tests__/cloning.test.ts index ddba103c44..605d39d4e5 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/cloning.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/cloning.test.ts @@ -1,26 +1,31 @@ import * as fc from "fast-check"; import { describe, expect, test } from "vitest"; +import { prepareStorageUpdateTest } from "../../__tests__/_devserver"; import { listUpdate, listUpdateInsert, objectUpdate, } from "../../__tests__/_updatesUtils"; -import { - createSerializedList, - createSerializedRoot, - prepareStorageUpdateTest, -} from "../../__tests__/_utils"; import { cloneLson } from "../../crdts/liveblocks-helpers"; +import type { LsonObject } from "../../crdts/Lson"; import type { LiveList } from "../LiveList"; +import { LiveObject } from "../LiveObject"; import { liveStructure, lson } from "./_arbitraries"; describe("cloning LiveStructures", () => { test("basic cloning logic", async () => { - const { root, expectUpdates, room } = await prepareStorageUpdateTest<{ + const { + roomA: room, + rootA: root, + expectUpdates, + } = await prepareStorageUpdateTest<{ list1: LiveList; list2: LiveList; - }>([createSerializedRoot(), createSerializedList("0:1", "root", "list1")]); + }>({ + liveblocksType: "LiveObject", + data: { list1: { liveblocksType: "LiveList", data: [] } }, + }); const list1 = root.get("list1"); list1.push("a"); @@ -32,7 +37,7 @@ describe("cloning LiveStructures", () => { room.history.undo(); room.history.redo(); - expectUpdates([ + await expectUpdates([ // List creation [listUpdate(["a"], [listUpdateInsert(0, "a")])], [listUpdate(["a", "b"], [listUpdateInsert(1, "b")])], @@ -65,13 +70,11 @@ describe("cloning LiveStructures", () => { test("[property] deep cloning of LiveStructures", () => fc.assert( - fc.asyncProperty( + fc.property( liveStructure, - async (data) => { - const { root } = await prepareStorageUpdateTest([ - createSerializedRoot(), - ]); + (data) => { + const root = new LiveObject({}); // Clone "a" to "b" root.set("a", data); @@ -85,13 +88,11 @@ describe("cloning LiveStructures", () => { test("[property] deep cloning of LiveStructures (twice)", () => fc.assert( - fc.asyncProperty( + fc.property( liveStructure, - async (data) => { - const { root } = await prepareStorageUpdateTest([ - createSerializedRoot(), - ]); + (data) => { + const root = new LiveObject({}); // Clone "a" to "b" root.set("a", data); @@ -106,13 +107,11 @@ describe("cloning LiveStructures", () => { test("[property] deep cloning of LSON data (= LiveStructures or JSON)", () => fc.assert( - fc.asyncProperty( + fc.property( lson, - async (data) => { - const { root } = await prepareStorageUpdateTest([ - createSerializedRoot(), - ]); + (data) => { + const root = new LiveObject({}); // Clone "a" to "b" root.set("a", data); diff --git a/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts b/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts index c000e7c473..c94ae549aa 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts @@ -1,13 +1,6 @@ import { describe, expect, test, vi } from "vitest"; -import { - createSerializedList, - createSerializedMap, - createSerializedObject, - createSerializedRoot, - prepareStorageTest, -} from "../../__tests__/_utils"; -import { OpCode } from "../../protocol/Op"; +import { prepareStorageTest } from "../../__tests__/_devserver"; import type { LiveList } from "../LiveList"; import type { LiveMap } from "../LiveMap"; import type { LiveObject } from "../LiveObject"; @@ -15,10 +8,12 @@ import type { LiveObject } from "../LiveObject"; describe("Storage", () => { describe("subscribe generic", () => { test("simple action", async () => { - const { room, storage } = await prepareStorageTest<{ a: number }>( - [createSerializedRoot({ a: 0 })], - 1 - ); + const { roomA: room, storageA: storage } = await prepareStorageTest<{ + a: number; + }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); const callback = vi.fn(); @@ -41,73 +36,81 @@ describe("Storage", () => { }); test("remote action", async () => { - const { room, storage, applyRemoteOperations } = - await prepareStorageTest<{ a: number }>( - [createSerializedRoot({ a: 0 })], - 1 - ); + const { roomA, storageA, storageB } = await prepareStorageTest<{ + a: number; + }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); const callback = vi.fn(); - const unsubscribe = room.subscribe(callback); + const unsubscribe = roomA.subscribe(callback); - applyRemoteOperations([ - { type: OpCode.UPDATE_OBJECT, data: { a: 1 }, id: "root" }, - ]); + storageB.root.set("a", 1); + await vi.waitUntil(() => storageA.root.get("a") === 1); unsubscribe(); - applyRemoteOperations([ - { type: OpCode.UPDATE_OBJECT, data: { a: 2 }, id: "root" }, - ]); + storageB.root.set("a", 2); + await vi.waitUntil(() => storageA.root.get("a") === 2); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([ { type: "LiveObject", - node: storage.root, + node: storageA.root, updates: { a: { type: "update" } }, }, ]); }); test("remote action with multipe updates on same object", async () => { - const { room, storage, applyRemoteOperations } = - await prepareStorageTest<{ a: number }>( - [createSerializedRoot({ a: 0 })], - 1 - ); + const { roomA, roomB, storageA, storageB } = await prepareStorageTest<{ + a: number; + b?: number; + }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); const callback = vi.fn(); - const unsubscribe = room.subscribe(callback); + const unsubscribe = roomA.subscribe(callback); - applyRemoteOperations([ - { type: OpCode.UPDATE_OBJECT, data: { a: 1 }, id: "root" }, - { type: OpCode.UPDATE_OBJECT, data: { b: 1 }, id: "root" }, - ]); + roomB.batch(() => { + storageB.root.set("a", 1); + storageB.root.set("b", 1); + }); + await vi.waitUntil(() => storageA.root.get("a") === 1); unsubscribe(); - applyRemoteOperations([ - { type: OpCode.UPDATE_OBJECT, data: { a: 2 }, id: "root" }, - ]); + storageB.root.set("a", 2); + await vi.waitUntil(() => storageA.root.get("a") === 2); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([ { type: "LiveObject", - node: storage.root, + node: storageA.root, updates: { a: { type: "update" }, b: { type: "update" } }, }, ]); }); test("batch actions on a single LiveObject", async () => { - const { room, storage, assertUndoRedo } = await prepareStorageTest<{ + const { + roomA: room, + storageA: storage, + assertUndoRedo, + } = await prepareStorageTest<{ a: number; b: number; - }>([createSerializedRoot({ a: 0, b: 0 })], 1); + }>({ + liveblocksType: "LiveObject", + data: { a: 0, b: 0 }, + }); const callback = vi.fn(); @@ -136,20 +139,20 @@ describe("Storage", () => { }, ]); - assertUndoRedo(); + await assertUndoRedo(); }); test("batch actions on multiple LiveObjects", async () => { - const { room, storage } = await prepareStorageTest<{ + const { roomA: room, storageA: storage } = await prepareStorageTest<{ a: number; child: LiveObject<{ b: number }>; - }>( - [ - createSerializedRoot({ a: 0 }), - createSerializedObject("0:1", { b: 0 }, "root", "child"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + a: 0, + child: { liveblocksType: "LiveObject", data: { b: 0 } }, + }, + }); const callback = vi.fn(); @@ -178,20 +181,20 @@ describe("Storage", () => { }); test("batch actions on multiple Live types", async () => { - const { room, storage } = await prepareStorageTest<{ + const { roomA: room, storageA: storage } = await prepareStorageTest<{ a: number; childObj: LiveObject<{ b: number }>; childList: LiveList; childMap: LiveMap; - }>( - [ - createSerializedRoot({ a: 0 }), - createSerializedObject("0:1", { b: 0 }, "root", "childObj"), - createSerializedList("0:2", "root", "childList"), - createSerializedMap("0:3", "root", "childMap"), - ], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { + a: 0, + childObj: { liveblocksType: "LiveObject", data: { b: 0 } }, + childList: { liveblocksType: "LiveList", data: [] }, + childMap: { liveblocksType: "LiveMap", data: {} }, + }, + }); const callback = vi.fn(); @@ -234,12 +237,16 @@ describe("Storage", () => { describe("batching", () => { test("batching and undo", async () => { - const { room, storage, expectStorage } = await prepareStorageTest<{ + const { + roomA: room, + storageA: storage, + expectStorage, + } = await prepareStorageTest<{ items: LiveList; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); const items = storage.root.get("items"); @@ -249,30 +256,34 @@ describe("Storage", () => { items.push("C"); }); - expectStorage({ + await expectStorage({ items: ["A", "B", "C"], }); room.history.undo(); - expectStorage({ + await expectStorage({ items: [], }); room.history.redo(); - expectStorage({ + await expectStorage({ items: ["A", "B", "C"], }); }); test("nesting batches makes inner batches a no-op", async () => { - const { room, storage, expectStorage } = await prepareStorageTest<{ + const { + roomA: room, + storageA: storage, + expectStorage, + } = await prepareStorageTest<{ items: LiveList; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); const items = storage.root.get("items"); @@ -296,30 +307,30 @@ describe("Storage", () => { }); }); - expectStorage({ + await expectStorage({ items: ["A", "B", "C"], }); room.history.undo(); - expectStorage({ + await expectStorage({ items: [], }); room.history.redo(); - expectStorage({ + await expectStorage({ items: ["A", "B", "C"], }); }); test("batch callbacks can return a value", async () => { - const { room, storage } = await prepareStorageTest<{ + const { roomA: room, storageA: storage } = await prepareStorageTest<{ items: LiveList; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); const items = storage.root.get("items"); @@ -335,10 +346,10 @@ describe("Storage", () => { }); test("calling undo during a batch should throw", async () => { - const { room } = await prepareStorageTest<{ a: number }>( - [createSerializedRoot({ a: 0 })], - 1 - ); + const { roomA: room } = await prepareStorageTest<{ a: number }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); room.batch(() => { expect(() => room.history.undo()).toThrow(); @@ -346,10 +357,10 @@ describe("Storage", () => { }); test("calling redo during a batch should throw", async () => { - const { room } = await prepareStorageTest<{ a: number }>( - [createSerializedRoot({ a: 0 })], - 1 - ); + const { roomA: room } = await prepareStorageTest<{ a: number }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); room.batch(() => { expect(() => room.history.redo()).toThrow(); @@ -359,82 +370,87 @@ describe("Storage", () => { describe("undo / redo", () => { test("list.push", async () => { - const { storage, expectStorage, assertUndoRedo } = - await prepareStorageTest<{ - items: LiveList; - }>( - [ - createSerializedRoot(), - createSerializedList("0:1", "root", "items"), - ], - 1 - ); + const { + storageA: storage, + expectStorage, + assertUndoRedo, + } = await prepareStorageTest<{ + items: LiveList; + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); const items = storage.root.get("items"); - expectStorage({ items: [] }); + await expectStorage({ items: [] }); items.push("A"); - expectStorage({ + await expectStorage({ items: ["A"], }); items.push("B"); - expectStorage({ + await expectStorage({ items: ["A", "B"], }); - assertUndoRedo(); + await assertUndoRedo(); }); test("max undo-redo stack", async () => { - const { room, storage, expectStorage } = await prepareStorageTest<{ + const { roomA: room, storageA: storage } = await prepareStorageTest<{ a: number; - }>([createSerializedRoot({ a: 0 })], 1); + }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); for (let i = 0; i < 100; i++) { storage.root.set("a", i + 1); - expectStorage({ - a: i + 1, - }); } + expect(storage.root.toImmutable()).toEqual({ a: 100 }); + for (let i = 0; i < 100; i++) { room.history.undo(); } - expectStorage({ - a: 50, - }); + // Max undo stack is 50, so undoing 100 times only goes back to 50 + expect(storage.root.toImmutable()).toEqual({ a: 50 }); }); test("storage operation should clear redo stack", async () => { - const { room, storage, expectStorage } = await prepareStorageTest<{ + const { + roomA: room, + storageA: storage, + expectStorage, + } = await prepareStorageTest<{ items: LiveList; - }>( - [createSerializedRoot(), createSerializedList("0:1", "root", "items")], - 1 - ); + }>({ + liveblocksType: "LiveObject", + data: { items: { liveblocksType: "LiveList", data: [] } }, + }); const items = storage.root.get("items"); - expectStorage({ items: [] }); + await expectStorage({ items: [] }); items.insert("A", 0); - expectStorage({ + await expectStorage({ items: ["A"], }); room.history.undo(); items.insert("B", 0); - expectStorage({ + await expectStorage({ items: ["B"], }); room.history.redo(); - expectStorage({ + await expectStorage({ items: ["B"], }); }); diff --git a/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts b/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts index efc5b210b9..39d0c11011 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts @@ -6,7 +6,7 @@ import { FOURTH_POSITION, SECOND_POSITION, THIRD_POSITION, -} from "../../__tests__/_utils"; +} from "../../__tests__/_MockWebSocketServer.setup"; import { OpCode } from "../../protocol/Op"; import type { NodeMap } from "../../protocol/StorageNode"; import { CrdtType } from "../../protocol/StorageNode"; diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts index 37c655caf4..53d71fc9af 100644 --- a/packages/liveblocks-core/src/room.ts +++ b/packages/liveblocks-core/src/room.ts @@ -1112,10 +1112,11 @@ export type PrivateRoomApi = { signal: AbortSignal; }): Promise; - // NOTE: These are only used in our e2e test app! + // NOTE: These are only used for some edge case unit tests or our e2e test app! simulate: { explicitClose(event: IWebSocketCloseEvent): void; rawSend(data: string): void; + incomingMessage(data: string): void; }; attachmentUrlsStore: BatchStore; @@ -3068,9 +3069,10 @@ export function createRoom< // prettier-ignore simulate: { - // These exist only for our E2E testing app + // These exist only for our E2E testing app and unit tests explicitClose: (event) => managedSocket._privateSendMachineEvent({ type: "EXPLICIT_SOCKET_CLOSE", event }), rawSend: (data) => managedSocket.send(data), + incomingMessage: (data) => handleServerMessage(new MessageEvent("message", { data })), }, attachmentUrlsStore: httpClient.getOrCreateAttachmentUrlsStore(roomId), diff --git a/packages/liveblocks-core/vitest.config.ts b/packages/liveblocks-core/vitest.config.ts index 4f7fc4ef2d..826872546c 100644 --- a/packages/liveblocks-core/vitest.config.ts +++ b/packages/liveblocks-core/vitest.config.ts @@ -16,6 +16,7 @@ export default defaultLiveblocksVitestConfig({ coverage: { provider: "istanbul", exclude: ["**/__tests__/**"], + reporter: [["text", { maxCols: 100 }]], }, }, });