Skip to content
File

Blob: tests/client/components/editor/editor-body.dom.test.tsx

typescript175 lines
1import { act } from "react";
2import { createRoot, type Root } from "react-dom/client";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4import * as Y from "yjs";
5import type { Awareness } from "y-protocols/awareness";
6import type { ResolveIdentity } from "@/client/lib/presence-identity";
7import type { AwarenessState } from "@/client/hooks/use-sync";
8import type { EditorAffordance } from "@/client/lib/affordance/editor";
9 
10const affordance: EditorAffordance = {
11 documentEditable: true,
12 canInsertPageMentions: true,
13 canInsertImages: true,
14 canUseAiRewrite: true,
15 canUseAiGenerate: true,
16 canSummarizePage: true,
17 canAskPage: true,
18};
19 
20type AwarenessListener = () => void;
21 
22class FakeAwareness {
23 readonly clientID = 1;
24 readonly states = new Map<number, AwarenessState>([[this.clientID, {}]]);
25 private readonly listeners = new Map<string, Set<AwarenessListener>>();
26 
27 getLocalState(): AwarenessState | null {
28 return this.states.get(this.clientID) ?? null;
29 }
30 
31 getStates(): Map<number, AwarenessState> {
32 return this.states;
33 }
34 
35 setLocalState(state: AwarenessState | null): void {
36 if (state === null) {
37 this.states.delete(this.clientID);
38 } else {
39 this.states.set(this.clientID, state);
40 }
41 this.emit("change");
42 this.emit("update");
43 }
44 
45 setLocalStateField(field: string, value: unknown): void {
46 const state = this.getLocalState();
47 if (state === null) return;
48 this.setLocalState({ ...state, [field]: value });
49 }
50 
51 on(event: string, listener: AwarenessListener): void {
52 const listeners = this.listeners.get(event) ?? new Set<AwarenessListener>();
53 listeners.add(listener);
54 this.listeners.set(event, listeners);
55 }
56 
57 off(event: string, listener: AwarenessListener): void {
58 this.listeners.get(event)?.delete(listener);
59 }
60 
61 private emit(event: string): void {
62 for (const listener of this.listeners.get(event) ?? []) listener();
63 }
64}
65 
66beforeEach(() => {
67 vi.resetModules();
68 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
69});
70 
71afterEach(() => {
72 vi.restoreAllMocks();
73 vi.unstubAllGlobals();
74 document.body.innerHTML = "";
75});
76 
77describe("EditorBody", () => {
78 it("does not notify awareness subscribers while rendering", async () => {
79 vi.doMock("@/client/lib/collab-identity", () => ({
80 useCollabIdentity: () => ({
81 userId: "user-local",
82 resolveIdentity: () => ({ name: "Local user", avatar_url: null }),
83 }),
84 }));
85 vi.doMock("@/client/components/editor/controllers/drag-handle", () => ({ DragHandle: () => null }));
86 vi.doMock("@/client/components/editor/controllers/formatting-toolbar", () => ({ FormattingToolbar: () => null }));
87 vi.doMock("@/client/components/editor/controllers/link-toolbar", () => ({ LinkToolbar: () => null }));
88 vi.doMock("@/client/components/editor/controllers/image/toolbar", () => ({ ImageToolbar: () => null }));
89 vi.doMock("@/client/components/editor/controllers/table-menu", () => ({ TableMenu: () => null }));
90 vi.doMock("@/client/components/editor/editor-metrics", () => ({ EditorMetrics: () => null }));
91 vi.doMock("@/client/components/editor/editor-outline", () => ({ EditorOutline: () => null }));
92 vi.doMock("@/client/components/editor/extensions/create-editor-extensions", async () => {
93 const [{ StarterKit }, { Collaboration }, { createCollaborationCaret }] = await Promise.all([
94 vi.importActual<typeof import("@tiptap/starter-kit")>("@tiptap/starter-kit"),
95 vi.importActual<typeof import("@tiptap/extension-collaboration")>("@tiptap/extension-collaboration"),
96 vi.importActual<typeof import("@/client/components/editor/extensions/collaboration-caret")>(
97 "@/client/components/editor/extensions/collaboration-caret",
98 ),
99 ]);
100 
101 return {
102 createEditorExtensions: (opts: {
103 fragment: Y.XmlFragment;
104 provider: { awareness: Awareness };
105 user: { userId: string | null };
106 resolveIdentity: ResolveIdentity;
107 }) => [
108 StarterKit.configure({ undoRedo: false }),
109 Collaboration.configure({ fragment: opts.fragment }),
110 createCollaborationCaret({
111 provider: opts.provider,
112 user: opts.user,
113 resolveIdentity: opts.resolveIdentity,
114 }),
115 ],
116 };
117 });
118 
119 const [{ EditorBody }, { useAwareness }] = await Promise.all([
120 import("@/client/components/editor/editor-body"),
121 import("@/client/hooks/use-sync"),
122 ]);
123 
124 const ydoc = new Y.Doc();
125 const awareness = new FakeAwareness() as unknown as Awareness;
126 const provider = { awareness };
127 const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
128 
129 function AwarenessSubscriber() {
130 const states = useAwareness(awareness);
131 return <output>{states.size}</output>;
132 }
133 
134 function Harness() {
135 return (
136 <>
137 <AwarenessSubscriber />
138 <EditorBody
139 fragment={ydoc.getXmlFragment("default")}
140 provider={provider}
141 pageId="page-1"
142 workspaceId="workspace-1"
143 affordance={affordance}
144 />
145 </>
146 );
147 }
148 
149 const host = document.createElement("div");
150 document.body.appendChild(host);
151 let root: Root | null = createRoot(host);
152 
153 try {
154 await act(async () => {
155 root!.render(<Harness />);
156 });
157 await act(async () => {
158 await Promise.resolve();
159 });
160 
161 expect(host.querySelector(".tiptap")).not.toBeNull();
162 expect(consoleError.mock.calls.map((call) => call.join(" ")).join("\n")).not.toContain(
163 "Cannot update a component",
164 );
165 } finally {
166 await act(async () => {
167 root?.unmount();
168 });
169 root = null;
170 host.remove();
171 ydoc.destroy();
172 }
173 });
174});