import { act, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Doc } from "yjs";
import { createEditorProvider, useEditorContext } from "./editor-provider";
import type * as EditorPeers from "./editor-peers";
import type { EditorProviderPeers } from "./editor-peers";
import { useEditorConnection } from "./use-editor";
interface ProviderOptions {
url: string;
name: string;
document: Doc;
onConnect: () => void;
onDisconnect: () => void;
}
class StubHocuspocusProvider {
static instances: StubHocuspocusProvider[] = [];
awareness = {
clientID: 1,
getStates: () => new Map(),
getLocalState: () => ({}),
setLocalStateField: vi.fn(),
on: vi.fn(),
off: vi.fn(),
};
connect = vi.fn();
disconnect = vi.fn();
destroy = vi.fn();
constructor(readonly options: ProviderOptions) {
StubHocuspocusProvider.instances.push(this);
}
}
/** Only the members the provider reads; yjs is the real package. */
function stubPeers() {
return {
yjs: { Doc },
hocuspocus: { HocuspocusProvider: StubHocuspocusProvider },
} as unknown as EditorProviderPeers;
}
function ConnectionProbe() {
// Reads the context through the public hook, not through the provider
// module, so a context created per factory call would leave it unresolved.
const { state, isConnected } = useEditorConnection();
const { doc } = useEditorContext();
return (
{state}
{isConnected ? " (live)" : ""}
);
}
afterEach(() => {
StubHocuspocusProvider.instances = [];
vi.doUnmock("./editor-peers");
vi.resetModules();
});
describe("createEditorProvider", () => {
it("builds the document and the transport from the loaded peers", () => {
const EditorProvider = createEditorProvider(stubPeers());
render(
,
);
expect(StubHocuspocusProvider.instances).toHaveLength(1);
const [provider] = StubHocuspocusProvider.instances;
expect(provider.options.url).toBe("wss://collab.example/ws");
expect(provider.options.name).toBe("doc:readme");
// The Y.Doc came from `peers.yjs.Doc`, and the editor asks it for this
// fragment, so the transport and the editor must share the one instance.
expect(provider.options.document).toBeInstanceOf(Doc);
expect(provider.awareness.setLocalStateField).toHaveBeenCalledWith(
"user",
expect.objectContaining({ name: "Ada" }),
);
});
it("serves the connection state to hooks that read the shared context", async () => {
const EditorProvider = createEditorProvider(stubPeers());
const onConnectionChange = vi.fn();
render(
,
);
expect(screen.getByTestId("probe")).toHaveTextContent("connecting");
const [provider] = StubHocuspocusProvider.instances;
act(() => {
provider.options.onConnect();
});
await waitFor(() => {
expect(screen.getByTestId("probe")).toHaveTextContent("connected (live)");
});
expect(onConnectionChange).toHaveBeenCalledWith("connected");
});
it("destroys the transport when it unmounts", () => {
const EditorProvider = createEditorProvider(stubPeers());
const { unmount } = render(
,
);
const [provider] = StubHocuspocusProvider.instances;
unmount();
expect(provider.destroy).toHaveBeenCalledTimes(1);
});
});
describe("EditorProvider", () => {
it("renders with no tiptap package installed", async () => {
// The provider builds the document and the socket from yjs and Hocuspocus
// alone. A consumer that drives its own editor from this context installs
// those two and nothing else, so reaching for a tiptap namespace here
// would break that consumer's build-clean install.
const tiptapSpecifiers = [
"@tiptap/react",
"@tiptap/starter-kit",
"@tiptap/extension-collaboration",
"@tiptap/extension-collaboration-caret",
];
vi.resetModules();
for (const specifier of tiptapSpecifiers) {
vi.doMock(specifier, () => {
throw new Error(`Could not resolve "${specifier}"`);
});
}
vi.doMock("yjs", () => ({ Doc }));
vi.doMock("@hocuspocus/provider", () => ({
HocuspocusProvider: StubHocuspocusProvider,
}));
const { EditorProvider } = await import("./editor-provider");
render(
custom editor surface
,
);
await waitFor(() => {
expect(screen.getByTestId("child")).toBeInTheDocument();
});
expect(StubHocuspocusProvider.instances).toHaveLength(1);
for (const specifier of [...tiptapSpecifiers, "yjs", "@hocuspocus/provider"]) {
vi.doUnmock(specifier);
}
});
it("holds children back until the peers load, then renders them", async () => {
let releasePeers: (() => void) | null = null;
const gate = new Promise((resolve) => {
releasePeers = resolve;
});
vi.resetModules();
vi.doMock("./editor-peers", async () => ({
...(await vi.importActual("./editor-peers")),
loadEditorProviderPeers: async () => {
await gate;
return stubPeers();
},
}));
const { EditorProvider } = await import("./editor-provider");
render(
connected surface
,
);
// A child that rendered before the provider would throw out of
// `useEditorContext`, so the wrapper must render nothing while the gate
// holds the loader.
expect(screen.queryByTestId("child")).toBeNull();
expect(StubHocuspocusProvider.instances).toHaveLength(0);
releasePeers?.();
await waitFor(() => {
expect(screen.getByTestId("child")).toBeInTheDocument();
});
expect(StubHocuspocusProvider.instances).toHaveLength(1);
});
});