Skip to content

Commit 761efb2

Browse files
committed
Add Hocuspocus provider/server wire-protocol integration test
The existing extension specs exercise the server hooks with a mocked API but never a real client, so nothing proves the @hocuspocus/provider actually speaks the wire protocol to the server. This boots the server in-process and connects a real provider, asserting the connect/authenticate/load/sync handshake for both the current and previous provider majors — making the one-major skew the version-skew guard tolerates a standing assertion.
1 parent c40fab7 commit 761efb2

3 files changed

Lines changed: 177 additions & 0 deletions

File tree

extensions/op-blocknote-hocuspocus/package-lock.json

Lines changed: 63 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extensions/op-blocknote-hocuspocus/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
"@blocknote/core": "^0.51.3",
3434
"@eslint/js": "^9.35.0",
3535
"@eslint/json": "^1.2.0",
36+
"@hocuspocus/provider": "^4.2.0",
37+
"@hocuspocus/provider-prev": "npm:@hocuspocus/provider@^3",
3638
"@stylistic/eslint-plugin": "^5.3.1",
3739
"@types/node": "^25.0.2",
3840
"eslint": "^9.35.0",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
2+
import { Server } from "@hocuspocus/server";
3+
import { HocuspocusProvider } from "@hocuspocus/provider";
4+
import { HocuspocusProvider as HocuspocusProviderPrev } from "@hocuspocus/provider-prev";
5+
import * as Y from "yjs";
6+
import { ws } from "msw";
7+
import { readFileSync } from "node:fs";
8+
import { dirname, join } from "node:path";
9+
import { fileURLToPath } from "node:url";
10+
import { OpenProjectApi } from "../../src/extensions/openProjectApi";
11+
import { createTestToken } from "../helpers/tokenHelper";
12+
import { server as apiMock } from "../mocks/node";
13+
14+
// Proves a real @hocuspocus/provider completes the connect -> authenticate -> load -> sync
15+
// handshake against our server over the actual wire protocol. The server boots in-process so
16+
// the existing msw mocks intercept its outbound Rails calls.
17+
const PORT = 9678;
18+
// Must equal the token's resource_url: onAuthenticate sets resourceUrl = documentName
19+
// and validates they match. createTestToken() defaults to this URL.
20+
const DOC_NAME = "https://test.api/api/v3/documents/1";
21+
22+
// setup.ts runs msw with onUnhandledRequest:'error', which also patches the global
23+
// WebSocket. Passthrough the connection to the in-process server so the real client
24+
// transport is exercised; Rails calls to test.api stay mocked by the default handlers.
25+
const socketLink = ws.link(`ws://127.0.0.1:${PORT}`);
26+
27+
// Expected provider majors, declared statically so a version bump forces a conscious edit
28+
// here as well as in package.json (the previous major is a manual `npm:` alias that won't
29+
// move on its own). The guard test below cross-checks these against the installed versions
30+
// and enforces the one-major gap Hocuspocus supports.
31+
const CURRENT_MAJOR = 4;
32+
const PREVIOUS_MAJOR = 3;
33+
34+
// The installed manifest is the source of truth — package.json's `^3` range only states
35+
// intent, not what npm resolved. Read the file directly rather than require()-ing it: these
36+
// packages' `exports` maps don't expose ./package.json, so require('<pkg>/package.json')
37+
// throws ERR_PACKAGE_PATH_NOT_EXPORTED. Path is anchored to this file so cwd doesn't matter.
38+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
39+
const installedMajor = (pkg: string): number => {
40+
const { version } = JSON.parse(
41+
readFileSync(join(packageRoot, "node_modules", pkg, "package.json"), "utf8"),
42+
) as { version: string };
43+
return Number(version.split(".")[0]);
44+
};
45+
46+
// The two provider majors have incompatible constructor/config types, so the matrix drives
47+
// both through this minimal duck-typed shape (hence the `as unknown as` casts below). If a
48+
// future major renames `synced`/`destroy` or changes the config, update this shape — TS can't
49+
// see through the cast, so the only symptom would be the sync poll silently timing out.
50+
interface ProviderConfig {
51+
url: string;
52+
name: string;
53+
token: string;
54+
document: Y.Doc;
55+
}
56+
interface ProviderInstance {
57+
synced: boolean;
58+
destroy(): void;
59+
}
60+
type ProviderConstructor = new (config: ProviderConfig) => ProviderInstance;
61+
62+
const providers = [
63+
{ label: `v${CURRENT_MAJOR} (current)`, pkg: "@hocuspocus/provider", major: CURRENT_MAJOR, Provider: HocuspocusProvider as unknown as ProviderConstructor },
64+
{ label: `v${PREVIOUS_MAJOR} (previous)`, pkg: "@hocuspocus/provider-prev", major: PREVIOUS_MAJOR, Provider: HocuspocusProviderPrev as unknown as ProviderConstructor },
65+
] as const;
66+
67+
let hocuspocus: Server;
68+
69+
beforeAll(async () => {
70+
hocuspocus = new Server({ port: PORT, quiet: true, extensions: [new OpenProjectApi()] });
71+
await hocuspocus.listen();
72+
});
73+
74+
afterAll(async () => {
75+
await hocuspocus?.destroy();
76+
});
77+
78+
beforeEach(() => {
79+
apiMock.use(socketLink.addEventListener("connection", ({ server }) => server.connect()));
80+
});
81+
82+
it("provider matrix stays within Hocuspocus's one-major skew window", () => {
83+
for (const { pkg, major } of providers) {
84+
expect(
85+
installedMajor(pkg),
86+
`${pkg} resolved to a different major than declared — update CURRENT_MAJOR/PREVIOUS_MAJOR and package.json together`,
87+
).toBe(major);
88+
}
89+
expect(
90+
CURRENT_MAJOR - PREVIOUS_MAJOR,
91+
"the matrix must stay exactly one major apart; bump the @hocuspocus/provider-prev alias in package.json",
92+
).toBe(1);
93+
});
94+
95+
describe.each(providers)("@hocuspocus/provider ($label) <-> server", ({ Provider }) => {
96+
it("connects, authenticates, and syncs", async () => {
97+
const provider = new Provider({
98+
url: `ws://127.0.0.1:${PORT}`,
99+
name: DOC_NAME,
100+
token: createTestToken(),
101+
document: new Y.Doc(),
102+
});
103+
104+
// finally so a failed/timed-out poll still tears down the socket and reconnect timers,
105+
// which would otherwise leak handles and hang the run.
106+
try {
107+
await expect.poll(() => provider.synced, { timeout: 10000 }).toBe(true);
108+
} finally {
109+
provider.destroy();
110+
}
111+
});
112+
});

0 commit comments

Comments
 (0)