Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .pnpm-store/v11/index.db
Binary file not shown.
395 changes: 281 additions & 114 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ overrides:
"@headlessui/react": "^2.2.4"
"@tiptap/core": "^3.0.0"
"@tiptap/pm": "^3.0.0"
"vitest": "4.1.2"
"@vitest/runner": "4.1.2"
"vitest": "4.1.7"
"@vitest/runner": "4.1.7"
"@y/prosemirror>lib0": "1.0.0-rc.13"
allowBuilds:
"@parcel/watcher": true
Expand Down
6 changes: 6 additions & 0 deletions tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# vitest-browser auto-saved debug screenshots on test failure (separate
# from `toMatchScreenshot` reference shots, which use `*-chromium-darwin.png`).
src/browser/**/__screenshots__/**/*-1.png

# vitest-browser attachments (debug artifacts saved during test runs).
.vitest-attachments
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"lint": "eslint src --max-warnings 0",
"playwright": "playwright test",
"test": "vitest --run",
"test:browser": "vitest --run --config ./vite.config.browser.ts",
"test:browser:updateSnaps": "vitest --run --config ./vite.config.browser.ts -u",
"test:updateSnaps": "docker run --rm -e RUN_IN_DOCKER=true --network host -v $(pwd)/..:/work/ -w /work/tests -it mcr.microsoft.com/playwright:v1.51.1-noble npx playwright test -u",
"test-ct": "playwright test -c playwright-ct.config.ts --headed",
"test-ct:updateSnaps": "docker run --rm -e RUN_IN_DOCKER=true --network host -v $(pwd)/..:/work/ -w /work/tests -it mcr.microsoft.com/playwright:v1.51.1-noble npx playwright test -c playwright-ct.config.ts -u",
Expand All @@ -24,6 +26,11 @@
"@types/node": "^20.19.22",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/browser": "4.1.7",
"@vitest/browser-playwright": "4.1.7",
"@y/protocols": "^1.0.6-rc.1",
"@y/y": "^14.0.0-rc.16",
"eslint": "^8.57.1",
"htmlfy": "^0.6.7",
"react": "^19.2.5",
Expand All @@ -32,7 +39,8 @@
"rimraf": "^5.0.10",
"vite": "^8.0.8",
"vite-plugin-eslint": "^1.8.1",
"vitest": "^4.1.2"
"vitest": "4.1.7",
"vitest-browser-react": "^2.2.0"
},
"eslintConfig": {
"extends": [
Expand Down
13 changes: 13 additions & 0 deletions tests/src/browser/vitestSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { afterEach, beforeEach } from "vitest";

// Make BlockNote's `UniqueID` extension emit deterministic, incrementing
// numeric IDs instead of UUIDs. Snapshots that pick up auto-generated
// block ids (e.g. a trailing paragraph BlockNote injects after an image
// or heading) stay stable across runs.
beforeEach(() => {
(window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {};
});

afterEach(() => {
delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
250 changes: 250 additions & 0 deletions tests/src/browser/y-prosemirror/basicText.concurrent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/* eslint-disable testing-library/render-result-naming-convention */
/**
* Vitest browser-mode tests for two-user concurrent suggestion edits.
* Each test sets up three side-by-side editors (User A, User B,
* Merged) backed by `baseDoc` + `suggestionDocA`/`B`/`Merged`, applies
* independent suggestion edits from A and B, calls `sync()` to fan
* both updates into the merged doc, and snapshots the converged state.
*
* TODO: BlockNote's `mapAttributionToMark` (YSync.ts) hashes user IDs
* from the attribution data to pick a color from a fixed palette, but
* `Y.Attributions()` ships empty and nothing in the editor pipeline
* populates it from the editor's `user` / awareness. Result: every
* mark in every test renders as `userColorPalette[0]` (#30bced),
* regardless of which user actually made the edit. In the merged
* snapshots below we therefore cannot tell A's marks from B's. Decide
* whether the attribution layer should automatically tag writes with
* the local awareness user, or whether tests should construct an
* `Attributions` instance with pre-registered client-id → user-id
* mappings.
*/
import { expect, test } from "vitest";

import { setupConcurrentSuggestionTest } from "./fixtures/concurrentSuggestionFixture.js";
import {
editorHtml,
waitForSuggestion,
ydocXml,
} from "./fixtures/suggestionFixture.js";

// Concurrent text edits on overlapping range: A fixes a typo while B
// deletes the whole word. After CRDT merge, snapshot what the merged
// editor ends up displaying.
test("concurrent: A fixes typo, B deletes the word", async () => {
const {
userA,
userB,
merged,
baseDoc,
suggestionDocA,
suggestionDocB,
suggestionDocMerged,
screen,
seed,
enableSuggestions,
sync,
} = await setupConcurrentSuggestionTest({
userAAction: "fix typo",
userBAction: "delete word",
});

// Seed: A writes "hello wrold" (typo) directly to baseDoc since
// suggestion mode isn't on yet. Then `seed()` fans baseDoc into
// all three suggestion docs so everyone starts from the same state.
userA.editor.replaceBlocks(userA.editor.document, [
{ id: "block-hello", type: "paragraph", content: "hello wrold" },
]);
seed();

await expect
.element(screen.getByTestId(userA.testId).getByText("hello wrold"))
.toBeVisible();

// Switch all editors into suggestion mode (subsequent edits in A
// and B are recorded as suggestions, merged starts watching its
// suggestion doc for incoming updates).
enableSuggestions();

// A: fix typo "wrold" -> "world".
const [blockA] = userA.editor.document;
userA.editor.updateBlock(blockA, {
type: "paragraph",
content: "hello world",
});

// B: delete the misspelled word entirely.
const [blockB] = userB.editor.document;
userB.editor.updateBlock(blockB, { type: "paragraph", content: "hello " });

await waitForSuggestion(userA.editor);
await waitForSuggestion(userB.editor);

// Merge A's and B's suggestions into the merged doc.
sync();
await waitForSuggestion(merged.editor);

await expect(screen.getByTestId("editor-root")).toMatchScreenshot(
"concurrent-typo-fix-vs-delete",
);

// TODO: the merged YDoc ends up at "hello o" – an `o` survives even
// though both A (who replaced "wrold" with "world") and B (who
// deleted "wrold" outright) effectively wanted "wrold" gone. The
// CRDT keeps A's inserted `o` because B's delete-range covered the
// original "wrold" letters but not A's freshly-inserted characters,
// so the union of "delete everything B saw" + "keep what A added"
// leaves a stray `o`. Worth deciding whether this is the desired
// merge semantic for the product or whether the suggestion layer
// should resolve overlapping edits differently.
expect(ydocXml(baseDoc)).toMatchInlineSnapshot(`
"<blockGroup>
<blockContainer id="block-hello">
<paragraph backgroundColor="default" textAlignment="left" textColor="default">hello wrold</paragraph>
</blockContainer>
</blockGroup>"
`);
expect(ydocXml(suggestionDocA)).toMatchInlineSnapshot(`
"<blockGroup>
<blockContainer id="block-hello">
<paragraph backgroundColor="default" textAlignment="left" textColor="default">hello world</paragraph>
</blockContainer>
</blockGroup>"
`);
expect(ydocXml(suggestionDocB)).toMatchInlineSnapshot(`
"<blockGroup>
<blockContainer id="block-hello">
<paragraph backgroundColor="default" textAlignment="left" textColor="default">hello</paragraph>
</blockContainer>
</blockGroup>"
`);
expect(ydocXml(suggestionDocMerged)).toMatchInlineSnapshot(`
"<blockGroup>
<blockContainer id="block-hello">
<paragraph backgroundColor="default" textAlignment="left" textColor="default">hello o</paragraph>
</blockContainer>
</blockGroup>"
`);
expect(editorHtml(merged.editor)).toMatchInlineSnapshot(`
"<doc>
<blockGroup>
<blockContainer id="block-hello">
<paragraph backgroundColor="default" textColor="default" textAlignment="left">
hello
<y-attributed-delete user-color="#30bced">w</y-attributed-delete>
<y-attributed-insert user-color="#30bced">o</y-attributed-insert>
<y-attributed-delete user-color="#30bced">rold</y-attributed-delete>
</paragraph>
</blockContainer>
</blockGroup>
</doc>"
`);
});

// Concurrent format edits on the same word: A adds bold, B adds
// italic. After CRDT merge, both marks should land on "world".
test("concurrent: A bolds the word, B italicises the word", async () => {
const {
userA,
userB,
merged,
baseDoc,
suggestionDocA,
suggestionDocB,
suggestionDocMerged,
screen,
seed,
enableSuggestions,
sync,
} = await setupConcurrentSuggestionTest({
userAAction: "bold 'world'",
userBAction: "italicise 'world'",
});

// Seed: A writes plain "hello world" directly to baseDoc, then
// `seed()` fans it into all three suggestion docs.
userA.editor.replaceBlocks(userA.editor.document, [
{ id: "block-hello", type: "paragraph", content: "hello world" },
]);
seed();

await expect
.element(screen.getByTestId(userA.testId).getByText("hello world"))
.toBeVisible();

enableSuggestions();

// A: bold "world".
const [blockA] = userA.editor.document;
userA.editor.updateBlock(blockA, {
type: "paragraph",
content: [
{ type: "text", text: "hello ", styles: {} },
{ type: "text", text: "world", styles: { bold: true } },
],
});

// B: italic "world".
const [blockB] = userB.editor.document;
userB.editor.updateBlock(blockB, {
type: "paragraph",
content: [
{ type: "text", text: "hello ", styles: {} },
{ type: "text", text: "world", styles: { italic: true } },
],
});

await waitForSuggestion(userA.editor);
await waitForSuggestion(userB.editor);

sync();
await waitForSuggestion(merged.editor);

await expect(screen.getByTestId("editor-root")).toMatchScreenshot(
"concurrent-bold-vs-italic",
);

expect(ydocXml(baseDoc)).toMatchInlineSnapshot(`
"<blockGroup>
<blockContainer id="block-hello">
<paragraph backgroundColor="default" textAlignment="left" textColor="default">hello world</paragraph>
</blockContainer>
</blockGroup>"
`);
expect(ydocXml(suggestionDocA)).toMatchInlineSnapshot(`
"<blockGroup>
<blockContainer id="block-hello">
<paragraph backgroundColor="default" textAlignment="left" textColor="default">hello world</paragraph>
</blockContainer>
</blockGroup>"
`);
expect(ydocXml(suggestionDocB)).toMatchInlineSnapshot(`
"<blockGroup>
<blockContainer id="block-hello">
<paragraph backgroundColor="default" textAlignment="left" textColor="default">hello world</paragraph>
</blockContainer>
</blockGroup>"
`);
expect(ydocXml(suggestionDocMerged)).toMatchInlineSnapshot(`
"<blockGroup>
<blockContainer id="block-hello">
<paragraph backgroundColor="default" textAlignment="left" textColor="default">hello world</paragraph>
</blockContainer>
</blockGroup>"
`);
expect(editorHtml(merged.editor)).toMatchInlineSnapshot(`
"<doc>
<blockGroup>
<blockContainer id="block-hello">
<paragraph backgroundColor="default" textColor="default" textAlignment="left">
hello
<y-attributed-format user-color="#30bced">
<italic>
<bold>world</bold>
</italic>
</y-attributed-format>
</paragraph>
</blockContainer>
</blockGroup>
</doc>"
`);
});
Loading
Loading