Skip to content

Commit cd46d9a

Browse files
authored
Merge pull request #23755 from opf/test/hocuspocus-provider-server-integration
hocuspocus: Add provider/server wire-protocol integration test with CI version-skew guard
2 parents 4cd6bc4 + 98390aa commit cd46d9a

5 files changed

Lines changed: 301 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: "Hocuspocus / Version skew"
2+
3+
on:
4+
pull_request:
5+
types: [opened, reopened, synchronize]
6+
paths:
7+
- 'frontend/package.json'
8+
- 'extensions/op-blocknote-hocuspocus/package.json'
9+
10+
permissions:
11+
contents: read # to fetch code (actions/checkout)
12+
pull-requests: write # to comment on the PR
13+
14+
jobs:
15+
version-skew:
16+
runs-on: ubuntu-latest
17+
18+
steps:
19+
- name: Checkout code
20+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
21+
with:
22+
persist-credentials: false
23+
24+
- name: Compare Hocuspocus client and server major versions
25+
id: skew
26+
run: ./script/ci/hocuspocus_version_skew.sh
27+
28+
- name: Add comment if versions skew
29+
if: steps.skew.outputs.skew == 'true'
30+
uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3
31+
with:
32+
header: hocuspocus-version-skew
33+
message: |
34+
> [!WARNING]
35+
> The Hocuspocus client and server are on different major versions.
36+
37+
- `@hocuspocus/provider` (client, frontend): **${{ steps.skew.outputs.provider_range }}**
38+
- `@hocuspocus/server` (server, op-blocknote-hocuspocus extension): **${{ steps.skew.outputs.server_range }}**
39+
40+
The client ships with the core app; the server ships as the separately deployed
41+
`openproject/hocuspocus` image, so they can drift independently.
42+
43+
A one-major skew is supported by Hocuspocus in both directions and is fine as a
44+
temporary state while the two halves are realigned. Two or more majors apart is not
45+
supported. Please confirm this skew is intentional, or bump the lagging side.
46+
- name: Skew check passed
47+
if: steps.skew.outputs.skew != 'true'
48+
uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3
49+
with:
50+
header: hocuspocus-version-skew
51+
delete: true

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-test-only": "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-test-only";
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-test-only", 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-test-only 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+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/bin/bash
2+
#-- copyright
3+
# OpenProject is a project management system.
4+
# Copyright (C) the OpenProject GmbH
5+
#
6+
# This program is free software; you can redistribute it and/or
7+
# modify it under the terms of the GNU General Public License version 3.
8+
#
9+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
10+
# Copyright (C) 2006-2013 Jean-Philippe Lang
11+
# Copyright (C) 2010-2013 the ChiliProject Team
12+
#
13+
# This program is free software; you can redistribute it and/or
14+
# modify it under the terms of the GNU General Public License
15+
# as published by the Free Software Foundation; either version 2
16+
# of the License, or (at your option) any later version.
17+
#
18+
# This program is distributed in the hope that it will be useful,
19+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
20+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21+
# GNU General Public License for more details.
22+
#
23+
# You should have received a copy of the GNU General Public License
24+
# along with this program; if not, write to the Free Software
25+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
26+
#
27+
# See doc/COPYRIGHT.rdoc for more details.
28+
#++
29+
30+
# Compares the major version of the Hocuspocus client (@hocuspocus/provider, in
31+
# the frontend) against the server (@hocuspocus/server, in the op-blocknote-hocuspocus
32+
# extension). The client ships with the core app; the server ships as the separately
33+
# built and deployed openproject/hocuspocus image, and the two live in separate
34+
# dependabot ecosystems that can never be grouped — so they bump independently and
35+
# can silently drift. This flags a major mismatch for human review. It never fails
36+
# the build: Hocuspocus supports a one-major skew in both directions, so a mismatch
37+
# is a heads-up, not a blocker.
38+
39+
set -e
40+
41+
PROVIDER_RANGE=$(jq -r '.dependencies["@hocuspocus/provider"] // empty' frontend/package.json)
42+
SERVER_RANGE=$(jq -r '.dependencies["@hocuspocus/server"] // empty' extensions/op-blocknote-hocuspocus/package.json)
43+
44+
if [ -z "$PROVIDER_RANGE" ] || [ -z "$SERVER_RANGE" ]; then
45+
echo "::warning::Could not read @hocuspocus/provider or @hocuspocus/server version; skipping skew check."
46+
exit 0
47+
fi
48+
49+
# Strip a leading range operator (^, ~, >=, etc.) and take the major version.
50+
major() {
51+
echo "$1" | sed -E 's/^[^0-9]*//' | cut -d. -f1
52+
}
53+
54+
PROVIDER_MAJOR=$(major "$PROVIDER_RANGE")
55+
SERVER_MAJOR=$(major "$SERVER_RANGE")
56+
57+
echo "@hocuspocus/provider (client): ${PROVIDER_RANGE} (major ${PROVIDER_MAJOR})"
58+
echo "@hocuspocus/server (server): ${SERVER_RANGE} (major ${SERVER_MAJOR})"
59+
60+
{
61+
echo "provider_range=${PROVIDER_RANGE}"
62+
echo "server_range=${SERVER_RANGE}"
63+
echo "provider_major=${PROVIDER_MAJOR}"
64+
echo "server_major=${SERVER_MAJOR}"
65+
} >> "${GITHUB_OUTPUT:-/dev/stdout}"
66+
67+
if [ "$PROVIDER_MAJOR" != "$SERVER_MAJOR" ]; then
68+
echo "Major version skew detected between Hocuspocus client and server."
69+
echo "skew=true" >> "${GITHUB_OUTPUT:-/dev/stdout}"
70+
else
71+
echo "Hocuspocus client and server are on the same major version."
72+
echo "skew=false" >> "${GITHUB_OUTPUT:-/dev/stdout}"
73+
fi

0 commit comments

Comments
 (0)