snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 17:21:35 +05:00
commit e0c588ca1c
265 changed files with 55259 additions and 0 deletions

891
tests/CHANGELOG.md Normal file
View File

@@ -0,0 +1,891 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.4.6-rc.1](https://github.com/ueberdosis/hocuspocus/compare/v3.4.6-rc.0...v3.4.6-rc.1) (2026-02-03)
**Note:** Version bump only for package tests
## [3.4.6-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.4.5-rc.0...v3.4.6-rc.0) (2026-02-03)
**Note:** Version bump only for package tests
## [3.4.5-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.4.4...v3.4.5-rc.0) (2026-02-03)
**Note:** Version bump only for package tests
## [3.4.4](https://github.com/ueberdosis/hocuspocus/compare/v3.4.3...v3.4.4) (2026-01-25)
**Note:** Version bump only for package tests
## [3.4.3](https://github.com/ueberdosis/hocuspocus/compare/v3.4.2...v3.4.3) (2025-12-15)
**Note:** Version bump only for package tests
## [3.4.2](https://github.com/ueberdosis/hocuspocus/compare/v3.4.1...v3.4.2) (2025-12-15)
**Note:** Version bump only for package tests
## [3.4.1](https://github.com/ueberdosis/hocuspocus/compare/v3.4.0...v3.4.1) (2025-12-09)
**Note:** Version bump only for package tests
# [3.4.0](https://github.com/ueberdosis/hocuspocus/compare/v3.3.2...v3.4.0) (2025-10-24)
**Note:** Version bump only for package tests
## [3.3.2](https://github.com/ueberdosis/hocuspocus/compare/v3.3.1...v3.3.2) (2025-10-23)
**Note:** Version bump only for package tests
## [3.3.1](https://github.com/ueberdosis/hocuspocus/compare/v3.3.0...v3.3.1) (2025-10-22)
**Note:** Version bump only for package tests
# [3.3.0](https://github.com/ueberdosis/hocuspocus/compare/v3.2.6...v3.3.0) (2025-10-22)
### Features
* add onTokenSync hook for auth token re-sync ([#1001](https://github.com/ueberdosis/hocuspocus/issues/1001)) ([ee252fb](https://github.com/ueberdosis/hocuspocus/commit/ee252fb99ea6faf80cc71d2e1ce51726e698b76b))
## [3.2.6](https://github.com/ueberdosis/hocuspocus/compare/v3.2.5...v3.2.6) (2025-10-17)
**Note:** Version bump only for package tests
## [3.2.5](https://github.com/ueberdosis/hocuspocus/compare/v3.2.4...v3.2.5) (2025-10-07)
### Bug Fixes
* delay adding document to instance until connection is about to be established ([#972](https://github.com/ueberdosis/hocuspocus/issues/972)) ([1e2917a](https://github.com/ueberdosis/hocuspocus/commit/1e2917aacb342c035645bb3c01af4910d699eaad))
## [3.2.4](https://github.com/ueberdosis/hocuspocus/compare/v3.2.3...v3.2.4) (2025-09-30)
**Note:** Version bump only for package tests
## [3.2.3](https://github.com/ueberdosis/hocuspocus/compare/v3.2.2...v3.2.3) (2025-08-06)
**Note:** Version bump only for package tests
## [3.2.2](https://github.com/ueberdosis/hocuspocus/compare/v3.2.1...v3.2.2) (2025-07-28)
**Note:** Version bump only for package tests
## [3.2.1](https://github.com/ueberdosis/hocuspocus/compare/v3.2.0...v3.2.1) (2025-07-20)
**Note:** Version bump only for package tests
# [3.2.0](https://github.com/ueberdosis/hocuspocus/compare/v3.1.12...v3.2.0) (2025-07-12)
**Note:** Version bump only for package tests
## [3.1.12](https://github.com/ueberdosis/hocuspocus/compare/v3.1.11...v3.1.12) (2025-07-09)
**Note:** Version bump only for package tests
## [3.1.10](https://github.com/ueberdosis/hocuspocus/compare/v3.1.9...v3.1.10) (2025-07-08)
**Note:** Version bump only for package tests
## [3.1.8](https://github.com/ueberdosis/hocuspocus/compare/v3.1.7...v3.1.8) (2025-07-01)
### Features
* beforeUnloadDocument: allow passing in Hocuspocus constructor ; allow preventing unload by throwing an error in beforeUnloadDocument ([63b298e](https://github.com/ueberdosis/hocuspocus/commit/63b298eddf23b8f472a066dc8274b22d790176ea))
## [3.1.7](https://github.com/ueberdosis/hocuspocus/compare/v3.1.6...v3.1.7) (2025-06-22)
**Note:** Version bump only for package tests
## [3.1.6](https://github.com/ueberdosis/hocuspocus/compare/v3.1.5...v3.1.6) (2025-06-20)
**Note:** Version bump only for package tests
## [3.1.5](https://github.com/ueberdosis/hocuspocus/compare/v3.1.4...v3.1.5) (2025-06-19)
**Note:** Version bump only for package tests
## [3.1.4](https://github.com/ueberdosis/hocuspocus/compare/v3.1.3...v3.1.4) (2025-06-17)
**Note:** Version bump only for package tests
## [3.1.3](https://github.com/ueberdosis/hocuspocus/compare/v3.1.2...v3.1.3) (2025-06-06)
**Note:** Version bump only for package tests
## [3.1.2](https://github.com/ueberdosis/hocuspocus/compare/v3.1.1...v3.1.2) (2025-06-05)
**Note:** Version bump only for package tests
## [3.1.1](https://github.com/ueberdosis/hocuspocus/compare/v3.1.1-rc.1...v3.1.1) (2025-05-10)
**Note:** Version bump only for package tests
## [3.1.1-rc.1](https://github.com/ueberdosis/hocuspocus/compare/v3.1.1-rc.0...v3.1.1-rc.1) (2025-05-08)
**Note:** Version bump only for package tests
## [3.1.1-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.1.0...v3.1.1-rc.0) (2025-04-30)
**Note:** Version bump only for package tests
# [3.1.0](https://github.com/ueberdosis/hocuspocus/compare/v3.1.0-rc.0...v3.1.0) (2025-04-29)
**Note:** Version bump only for package tests
# [3.1.0-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.8-rc.0...v3.1.0-rc.0) (2025-04-28)
**Note:** Version bump only for package tests
## [3.0.8-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.7-rc.0...v3.0.8-rc.0) (2025-04-09)
**Note:** Version bump only for package tests
## [3.0.7-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.6-rc.0...v3.0.7-rc.0) (2025-04-09)
**Note:** Version bump only for package tests
## [3.0.6-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.5-rc.0...v3.0.6-rc.0) (2025-03-28)
**Note:** Version bump only for package tests
## [3.0.5-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.4-rc.0...v3.0.5-rc.0) (2025-03-28)
### Features
* add beforeSync hook ([#919](https://github.com/ueberdosis/hocuspocus/issues/919)) ([a6a7bcd](https://github.com/ueberdosis/hocuspocus/commit/a6a7bcd0768378908ffb5d32096183280115631b))
## [3.0.4-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.0.3-rc.0...v3.0.4-rc.0) (2025-03-20)
**Note:** Version bump only for package tests
## [3.0.3-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.15.2...v3.0.3-rc.0) (2025-03-20)
**Note:** Version bump only for package tests
## [3.0.2-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.15.2...v3.0.2-rc.0) (2025-03-13)
**Note:** Version bump only for package tests
## [3.0.1-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.15.2...v3.0.1-rc.0) (2025-03-13)
**Note:** Version bump only for package tests
# [3.0.0](https://github.com/ueberdosis/hocuspocus/compare/v2.15.2...v3.0.0) (2025-03-13)
**Note:** Version bump only for package tests
## [2.15.2](https://github.com/ueberdosis/hocuspocus/compare/v2.15.1...v2.15.2) (2025-02-03)
**Note:** Version bump only for package tests
## [2.15.1](https://github.com/ueberdosis/hocuspocus/compare/v2.15.1-rc.0...v2.15.1) (2025-01-29)
**Note:** Version bump only for package tests
## [2.15.1-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.15.0...v2.15.1-rc.0) (2025-01-29)
**Note:** Version bump only for package tests
# [2.15.0](https://github.com/ueberdosis/hocuspocus/compare/v2.14.0...v2.15.0) (2024-12-10)
**Note:** Version bump only for package tests
# [2.14.0](https://github.com/ueberdosis/hocuspocus/compare/v2.13.7...v2.14.0) (2024-11-20)
**Note:** Version bump only for package tests
## [2.13.7](https://github.com/ueberdosis/hocuspocus/compare/v2.13.6...v2.13.7) (2024-10-08)
**Note:** Version bump only for package tests
## [2.13.6](https://github.com/ueberdosis/hocuspocus/compare/v2.13.5...v2.13.6) (2024-09-19)
**Note:** Version bump only for package tests
## [2.13.5](https://github.com/ueberdosis/hocuspocus/compare/v2.13.5-rc.0...v2.13.5) (2024-07-02)
**Note:** Version bump only for package tests
## [2.13.5-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.13.4-rc.0...v2.13.5-rc.0) (2024-07-01)
**Note:** Version bump only for package tests
## [2.13.4-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.13.2...v2.13.4-rc.0) (2024-07-01)
**Note:** Version bump only for package tests
## [2.13.3-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.13.2...v2.13.3-rc.0) (2024-07-01)
**Note:** Version bump only for package tests
## [2.13.2](https://github.com/ueberdosis/hocuspocus/compare/v2.13.1...v2.13.2) (2024-06-14)
**Note:** Version bump only for package tests
## [2.13.1](https://github.com/ueberdosis/hocuspocus/compare/v2.13.0...v2.13.1) (2024-06-06)
**Note:** Version bump only for package tests
# [2.13.0](https://github.com/ueberdosis/hocuspocus/compare/v2.12.3...v2.13.0) (2024-05-18)
**Note:** Version bump only for package tests
## [2.12.3](https://github.com/ueberdosis/hocuspocus/compare/v2.12.2...v2.12.3) (2024-05-16)
**Note:** Version bump only for package tests
## [2.12.2](https://github.com/ueberdosis/hocuspocus/compare/v2.12.2-rc.0...v2.12.2) (2024-04-25)
**Note:** Version bump only for package tests
## [2.12.2-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.12.1-rc.0...v2.12.2-rc.0) (2024-03-29)
**Note:** Version bump only for package tests
## [2.12.1-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.12.0-rc.0...v2.12.1-rc.0) (2024-03-29)
**Note:** Version bump only for package tests
# [2.12.0-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.11.3...v2.12.0-rc.0) (2024-03-24)
**Note:** Version bump only for package tests
## [2.11.3](https://github.com/ueberdosis/hocuspocus/compare/v2.11.2...v2.11.3) (2024-03-02)
**Note:** Version bump only for package tests
## [2.11.2](https://github.com/ueberdosis/hocuspocus/compare/v2.11.1...v2.11.2) (2024-02-16)
**Note:** Version bump only for package tests
## [2.11.1](https://github.com/ueberdosis/hocuspocus/compare/v2.11.0...v2.11.1) (2024-02-11)
**Note:** Version bump only for package tests
# [2.11.0](https://github.com/ueberdosis/hocuspocus/compare/v2.10.0...v2.11.0) (2024-02-05)
**Note:** Version bump only for package tests
# [2.10.0](https://github.com/ueberdosis/hocuspocus/compare/v2.9.2-rc.0...v2.10.0) (2024-01-31)
**Note:** Version bump only for package tests
## [2.9.2-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.9.1-rc.0...v2.9.2-rc.0) (2024-01-22)
**Note:** Version bump only for package tests
## [2.9.1-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.9.0...v2.9.1-rc.0) (2024-01-18)
**Note:** Version bump only for package tests
# [2.9.0](https://github.com/ueberdosis/hocuspocus/compare/v2.9.0-rc.0...v2.9.0) (2024-01-05)
**Note:** Version bump only for package tests
# [2.9.0-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.8.1...v2.9.0-rc.0) (2024-01-04)
**Note:** Version bump only for package tests
## [2.8.1](https://github.com/ueberdosis/hocuspocus/compare/v2.8.0...v2.8.1) (2023-11-21)
**Note:** Version bump only for package tests
# [2.8.0](https://github.com/ueberdosis/hocuspocus/compare/v2.7.1...v2.8.0) (2023-11-15)
**Note:** Version bump only for package tests
## [2.7.1](https://github.com/ueberdosis/hocuspocus/compare/v2.7.0...v2.7.1) (2023-10-19)
**Note:** Version bump only for package tests
# [2.7.0](https://github.com/ueberdosis/hocuspocus/compare/v2.6.1...v2.7.0) (2023-10-16)
**Note:** Version bump only for package tests
## [2.6.1](https://github.com/ueberdosis/hocuspocus/compare/v2.6.0...v2.6.1) (2023-10-05)
**Note:** Version bump only for package tests
# [2.6.0](https://github.com/ueberdosis/hocuspocus/compare/v2.5.0...v2.6.0) (2023-10-04)
**Note:** Version bump only for package tests
# [2.5.0](https://github.com/ueberdosis/hocuspocus/compare/v2.5.0-rc.0...v2.5.0) (2023-09-06)
**Note:** Version bump only for package tests
# [2.5.0-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.4.0...v2.5.0-rc.0) (2023-08-24)
**Note:** Version bump only for package tests
# [2.4.0](https://github.com/ueberdosis/hocuspocus/compare/v2.4.0-rc.1...v2.4.0) (2023-08-17)
**Note:** Version bump only for package tests
# [2.4.0-rc.1](https://github.com/ueberdosis/hocuspocus/compare/v2.4.0-rc.0...v2.4.0-rc.1) (2023-08-12)
**Note:** Version bump only for package tests
# [2.4.0-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v2.3.1...v2.4.0-rc.0) (2023-08-12)
**Note:** Version bump only for package tests
## [2.3.1](https://github.com/ueberdosis/hocuspocus/compare/v2.3.0...v2.3.1) (2023-08-01)
**Note:** Version bump only for package tests
# [2.3.0](https://github.com/ueberdosis/hocuspocus/compare/v2.2.3...v2.3.0) (2023-07-31)
**Note:** Version bump only for package tests
## [2.2.3](https://github.com/ueberdosis/hocuspocus/compare/v2.2.2...v2.2.3) (2023-07-14)
**Note:** Version bump only for package tests
## [2.2.2](https://github.com/ueberdosis/hocuspocus/compare/v2.2.1...v2.2.2) (2023-07-14)
**Note:** Version bump only for package tests
## [2.2.1](https://github.com/ueberdosis/hocuspocus/compare/v2.2.0...v2.2.1) (2023-07-04)
**Note:** Version bump only for package tests
# [2.2.0](https://github.com/ueberdosis/hocuspocus/compare/v2.1.0...v2.2.0) (2023-06-22)
**Note:** Version bump only for package tests
# [2.1.0](https://github.com/ueberdosis/hocuspocus/compare/v2.0.6...v2.1.0) (2023-06-03)
**Note:** Version bump only for package tests
## [2.0.6](https://github.com/ueberdosis/hocuspocus/compare/v2.0.5...v2.0.6) (2023-04-25)
**Note:** Version bump only for package tests
## [2.0.5](https://github.com/ueberdosis/hocuspocus/compare/v2.0.4...v2.0.5) (2023-04-24)
**Note:** Version bump only for package tests
## [2.0.4](https://github.com/ueberdosis/hocuspocus/compare/v2.0.3...v2.0.4) (2023-04-23)
**Note:** Version bump only for package tests
## [2.0.3](https://github.com/ueberdosis/hocuspocus/compare/v2.0.2...v2.0.3) (2023-04-05)
**Note:** Version bump only for package tests
## [2.0.2](https://github.com/ueberdosis/hocuspocus/compare/v2.0.1...v2.0.2) (2023-04-04)
**Note:** Version bump only for package tests
## [2.0.1](https://github.com/ueberdosis/hocuspocus/compare/v2.0.0...v2.0.1) (2023-03-30)
**Note:** Version bump only for package tests
# [2.0.0](https://github.com/ueberdosis/hocuspocus/compare/v1.1.3...v2.0.0) (2023-03-29)
**Note:** Version bump only for package tests
# [2.0.0-beta.0](https://github.com/ueberdosis/hocuspocus/compare/v1.1.1...v2.0.0-beta.0) (2023-03-28)
**Note:** Version bump only for package tests
# [2.0.0-alpha.1](https://github.com/ueberdosis/hocuspocus/compare/v2.0.0-alpha.0...v2.0.0-alpha.1) (2023-03-23)
**Note:** Version bump only for package tests
# [2.0.0-alpha.0](https://github.com/ueberdosis/hocuspocus/compare/v1.1.0...v2.0.0-alpha.0) (2023-02-28)
**Note:** Version bump only for package tests
# [1.1.0](https://github.com/ueberdosis/hocuspocus/compare/v1.0.2...v1.1.0) (2023-02-24)
**Note:** Version bump only for package tests
## [1.0.2](https://github.com/ueberdosis/hocuspocus/compare/v1.0.1...v1.0.2) (2023-02-16)
**Note:** Version bump only for package tests
## [1.0.1](https://github.com/ueberdosis/hocuspocus/compare/v1.0.0...v1.0.1) (2023-01-30)
**Note:** Version bump only for package tests
# [1.0.0](https://github.com/ueberdosis/hocuspocus/compare/v1.0.0-beta.7...v1.0.0) (2023-01-18)
**Note:** Version bump only for package tests
# [1.0.0-beta.7](https://github.com/ueberdosis/hocuspocus/compare/v1.0.0-beta.6...v1.0.0-beta.7) (2023-01-11)
**Note:** Version bump only for package tests
# [1.0.0-beta.6](https://github.com/ueberdosis/hocuspocus/compare/v1.0.0-beta.5...v1.0.0-beta.6) (2022-12-03)
**Note:** Version bump only for package tests
# [1.0.0-beta.5](https://github.com/ueberdosis/hocuspocus/compare/v1.0.0-beta.3...v1.0.0-beta.5) (2022-11-25)
### Bug Fixes
* Crash when websocket receives out of bound characters pre-auth ([#444](https://github.com/ueberdosis/hocuspocus/issues/444)) ([73d8a48](https://github.com/ueberdosis/hocuspocus/commit/73d8a4812a4d9a851c97a52b6d62180a1fa4b56b))
# [1.0.0-beta.4](https://github.com/ueberdosis/hocuspocus/compare/v1.0.0-beta.3...v1.0.0-beta.4) (2022-11-25)
### Bug Fixes
* Crash when websocket receives out of bound characters pre-auth ([#444](https://github.com/ueberdosis/hocuspocus/issues/444)) ([73d8a48](https://github.com/ueberdosis/hocuspocus/commit/73d8a4812a4d9a851c97a52b6d62180a1fa4b56b))
# [1.0.0-beta.3](https://github.com/ueberdosis/hocuspocus/compare/v1.0.0-beta.2...v1.0.0-beta.3) (2022-11-03)
**Note:** Version bump only for package tests
# [1.0.0-beta.2](https://github.com/ueberdosis/hocuspocus/compare/v1.0.0-beta.1...v1.0.0-beta.2) (2022-09-28)
**Note:** Version bump only for package tests
# 1.0.0-beta.111 (2022-09-28)
### Bug Fixes
* Potential onCreateDocument race condition ([#167](https://github.com/ueberdosis/hocuspocus/issues/167)) ([b3e3e4d](https://github.com/ueberdosis/hocuspocus/commit/b3e3e4dea74f9b833ccb0c6a6521f55c001411c1))
### Features
* Add connectionsCount and documentsCount methods to server ([8bdbcd8](https://github.com/ueberdosis/hocuspocus/commit/8bdbcd86b1f18462f6636b75a4cbd97ebefdb227))
* Message Authentication ([#163](https://github.com/ueberdosis/hocuspocus/issues/163)) ([a1e68d5](https://github.com/ueberdosis/hocuspocus/commit/a1e68d5a272742bd17dd92522dfc908277343849))

View File

@@ -0,0 +1,56 @@
import test from 'ava'
// eslint-disable-next-line import/no-extraneous-dependencies
import { Database } from '@hocuspocus/extension-database'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('fetch has the document name', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
extensions: [
new Database({
async fetch({ documentName }) {
t.is(documentName, 'my-unique-document-name')
resolve('done')
return null
},
}),
],
})
newHocuspocusProvider(t, server, {
name: 'my-unique-document-name',
})
})
})
test('passes context from onAuthenticate to fetch', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
extensions: [
new Database({
async fetch({ context }) {
t.deepEqual(context, {
user: 123,
})
resolve('done')
return null
},
}),
],
async onAuthenticate() {
return {
user: 123,
}
},
})
newHocuspocusProvider(t, server, {
token: 'SUPER-SECRET-TOKEN',
name: 'my-unique-document-name',
})
})
})

View File

@@ -0,0 +1,83 @@
import test from 'ava'
import sinon from 'sinon'
import { Logger } from '@hocuspocus/extension-logger'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
const fakeLogger = (message: any) => {
}
test('logs something', async t => {
await new Promise(async resolve => {
const spy = sinon.spy(fakeLogger)
const server = await newHocuspocus(t, {
extensions: [
new Logger({
log: spy,
}),
],
})
newHocuspocusProvider(t, server, {
onConnect() {
t.true(spy.callCount > 1, 'Expected the Logger to log something, but didn\'t receive anything.')
t.true(spy.callCount === 3, `Expected it to log 11 times, but actually logged ${spy.callCount} times`)
resolve('done')
},
})
})
})
test('uses the global instance name', async t => {
await new Promise(async resolve => {
const spy = sinon.spy(fakeLogger)
const hocuspocus = await newHocuspocus(t, {
name: 'FOOBAR123',
extensions: [
new Logger({
log: spy,
}),
],
})
await hocuspocus.server!.destroy()
t.is(spy.args[spy.args.length - 1][0].includes('FOOBAR123'), true, 'Expected the Logger to use the configured instance name.')
resolve('done')
})
})
test('doesn\'t log anything if all messages are disabled', async t => {
await new Promise(async resolve => {
const spy = sinon.spy(fakeLogger)
const hocuspocus = await newHocuspocus(t, {
extensions: [
new Logger({
log: spy,
// TODO: Those hooks aren't triggered anyway.
// onLoadDocument: false,
// onChange: false,
// onConnect: false,
// onDisconnect: false,
// onUpgrade: false,
// onRequest: false,
// @ts-expect-error
onListen: false,
onDestroy: false,
onConfigure: false,
}),
],
})
await hocuspocus.server!.destroy()
t.is(spy.callCount, 0, 'Expected the Logger to not log anything.')
resolve('done')
})
})

View File

@@ -0,0 +1,106 @@
import { Redis } from "@hocuspocus/extension-redis";
import type { onAwarenessChangeParameters } from "@hocuspocus/provider";
import test from "ava";
import {
newHocuspocus,
newHocuspocusProvider,
redisConnectionSettings,
} from "../utils/index.ts";
test("syncs existing awareness state", async (t) => {
const documentName = `test-${crypto.randomUUID()}`;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
}),
],
});
const anotherServer = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `anotherServer${crypto.randomUUID()}`,
}),
],
});
const provider = newHocuspocusProvider(t, server, {
name: documentName,
onSynced() {
// Once we're set up, change the local Awareness state.
// The updated state then needs to go through Redis:
// provider -> server -> Redis -> anotherServer -> anotherProvider
provider.setAwarenessField("name", "first");
// Time to initialize a second provider, and connect to `anotherServer`
// to check whether existing Awareness states are synced through Redis.
newHocuspocusProvider(t, anotherServer, {
name: documentName,
onAwarenessChange({ states }: onAwarenessChangeParameters) {
// Wait until we have exactly 2 states with the expected data
const state = states.find(
(state) => state.clientId === provider.document.clientID,
);
if (states.length === 2 && state?.name === "first") {
t.pass();
resolve("done");
}
},
});
},
});
});
});
test("syncs awareness between servers and clients", async (t) => {
const documentName = `test-${crypto.randomUUID()}`;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
}),
],
});
const anotherServer = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `anotherServer${crypto.randomUUID()}`,
}),
],
});
const provider = newHocuspocusProvider(t, anotherServer, {
name: documentName,
onSynced() {
// once we're setup change awareness on provider, to get to client it will
// need to pass through the pubsub extension:
// provider -> anotherServer -> pubsub -> server -> client
provider.setAwarenessField("name", "second");
},
});
newHocuspocusProvider(t, server, {
name: documentName,
onAwarenessChange: ({ states }: onAwarenessChangeParameters) => {
// Wait until we have exactly 2 states with the expected data
const state = states.find(
(state) => state.clientId === provider.document.clientID,
);
if (states.length === 2 && state?.name === "second") {
t.pass();
resolve("done");
}
},
});
});
});

View File

@@ -0,0 +1,60 @@
import { Redis } from "@hocuspocus/extension-redis";
import test from "ava";
import {
newHocuspocus,
newHocuspocusProvider,
redisConnectionSettings,
} from "../utils/index.ts";
test("syncs updates between servers and clients", async (t) => {
await new Promise(async (resolve) => {
let resolved = false;
const server = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
}),
],
});
const anotherServer = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `anotherServer${crypto.randomUUID()}`,
}),
],
});
// Once we’re setup make an edit on anotherProvider. To get to the provider it will need
// to pass through Redis:
// provider -> server -> Redis -> anotherServer -> anotherProvider
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getArray("foo").insert(0, ["bar"]);
},
});
// Once the initial data is synced, wait for an additional update to check
// if both documents have the same content.
const anotherProvider = newHocuspocusProvider(t, anotherServer, {
onSynced() {
provider.on("message", () => {
if (resolved) return;
resolved = true;
setTimeout(() => {
t.is(
provider.document.getArray("foo").get(0),
anotherProvider.document.getArray("foo").get(0),
);
resolve("done");
}, 200);
});
},
});
});
});

View File

@@ -0,0 +1,232 @@
import { Redis } from "@hocuspocus/extension-redis";
import test from "ava";
import {
newHocuspocus,
newHocuspocusProvider,
redisConnectionSettings,
} from "../utils/index.ts";
test("syncs broadcast stateless message between servers and clients", async (t) => {
const redisPrefix = crypto.randomUUID();
const payloadToSend = "STATELESS-MESSAGE";
let resolvePayload!: (payload: string) => void;
const payloadReceived = new Promise<string>((resolve) => {
resolvePayload = resolve;
});
let resolveRemoteSynced!: () => void;
const remoteSynced = new Promise<void>((resolve) => {
resolveRemoteSynced = resolve;
});
let resolveSenderSynced!: () => void;
const senderSynced = new Promise<void>((resolve) => {
resolveSenderSynced = resolve;
});
const server = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
prefix: redisPrefix,
}),
],
});
const anotherServer = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `anotherServer${crypto.randomUUID()}`,
prefix: redisPrefix,
}),
],
});
newHocuspocusProvider(t, anotherServer, {
onStateless({ payload }) {
resolvePayload(payload);
},
onSynced() {
resolveRemoteSynced();
},
});
newHocuspocusProvider(t, server, {
onSynced() {
resolveSenderSynced();
},
});
await Promise.all([remoteSynced, senderSynced]);
const document = server.documents.get("hocuspocus-test");
if (!document) {
throw new Error("Expected the document to be loaded");
}
document.broadcastStateless(payloadToSend);
t.is(await payloadReceived, payloadToSend);
});
test("client stateless messages shouldnt propagate to other server", async (t) => {
const redisPrefix = crypto.randomUUID();
const payloadToSend = "STATELESS-MESSAGE";
const barrier = "redis-barrier";
const remotePayloads: string[] = [];
let resolveLocalPayload!: (payload: string) => void;
const localPayloadReceived = new Promise<string>((resolve) => {
resolveLocalPayload = resolve;
});
let resolveRemoteSynced!: () => void;
const remoteSynced = new Promise<void>((resolve) => {
resolveRemoteSynced = resolve;
});
let resolveRemoteBarrier!: () => void;
const remoteBarrierReceived = new Promise<void>((resolve) => {
resolveRemoteBarrier = resolve;
});
let resolveSenderSynced!: () => void;
const senderSynced = new Promise<void>((resolve) => {
resolveSenderSynced = resolve;
});
const server = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
prefix: redisPrefix,
}),
],
onStateless({ payload }) {
resolveLocalPayload(payload);
},
});
const anotherServer = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `anotherServer${crypto.randomUUID()}`,
prefix: redisPrefix,
}),
],
});
newHocuspocusProvider(t, anotherServer, {
onSynced() {
resolveRemoteSynced();
},
onStateless({ payload }) {
remotePayloads.push(payload);
if (payload === barrier) {
resolveRemoteBarrier();
}
},
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
resolveSenderSynced();
},
});
await Promise.all([remoteSynced, senderSynced]);
provider.sendStateless(payloadToSend);
t.is(await localPayloadReceived, payloadToSend);
const document = server.documents.get("hocuspocus-test");
if (!document) {
throw new Error("Expected the document to be loaded");
}
document.broadcastStateless(barrier);
await remoteBarrierReceived;
t.deepEqual(remotePayloads, [barrier]);
});
test("server client stateless messages shouldnt propagate to other client", async (t) => {
const redisPrefix = crypto.randomUUID();
const response = "test123";
const barrier = "redis-barrier";
const remotePayloads: string[] = [];
let resolveResponse: () => void;
const responseReceived = new Promise<void>((resolve) => {
resolveResponse = resolve;
});
let resolveRemoteSynced: () => void;
const remoteSynced = new Promise<void>((resolve) => {
resolveRemoteSynced = resolve;
});
let resolveRemoteBarrier: () => void;
const remoteBarrierReceived = new Promise<void>((resolve) => {
resolveRemoteBarrier = resolve;
});
let resolveSenderSynced: () => void;
const senderSynced = new Promise<void>((resolve) => {
resolveSenderSynced = resolve;
});
const server = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
prefix: redisPrefix,
}),
],
async onStateless({ connection }) {
connection.sendStateless(response);
},
});
const anotherServer = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `anotherServer${crypto.randomUUID()}`,
prefix: redisPrefix,
}),
],
async onStateless() {
t.fail();
},
});
newHocuspocusProvider(t, anotherServer, {
onSynced() {
resolveRemoteSynced();
},
onStateless({ payload }) {
if (payload === barrier) {
resolveRemoteBarrier();
return;
}
remotePayloads.push(payload);
},
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
resolveSenderSynced();
},
onStateless({ payload }) {
if (payload === response) {
resolveResponse();
}
},
});
await Promise.all([remoteSynced, senderSynced]);
provider.sendStateless("ok");
await responseReceived;
const document = server.documents.get("hocuspocus-test");
if (!document) {
throw new Error("Expected the document to be loaded");
}
// Redis preserves publish order, so receiving this broadcast proves any
// accidental propagation of the earlier response has already arrived.
document.broadcastStateless(barrier);
await remoteBarrierReceived;
t.deepEqual(remotePayloads, []);
});

View File

@@ -0,0 +1,155 @@
import test from 'ava'
import type { onStoreDocumentPayload } from '@hocuspocus/server'
import { Redis } from '@hocuspocus/extension-redis'
import type { HocuspocusProvider } from '@hocuspocus/provider'
import { uuidv4 } from 'lib0/random'
import { newHocuspocus, newHocuspocusProvider, redisConnectionSettings } from '../utils/index.ts'
test('stores documents without conflicts', async t => {
await new Promise(async resolve => {
// eslint-disable-next-line prefer-const
let anotherProvider: HocuspocusProvider
class CustomStorageExtension {
async onStoreDocument({ document }: onStoreDocumentPayload) {
t.is(document.getArray('foo').get(0), 'bar')
t.is(document.getArray('foo').get(0), anotherProvider.document.getArray('foo').get(0))
resolve('done')
}
}
const server = await newHocuspocus(t, {
name: 'redis-1',
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `server${uuidv4()}`,
prefix: 'extension-redis/onStoreDocument1',
}),
new CustomStorageExtension(),
],
})
const anotherServer = await newHocuspocus(t, {
name: 'redis-2',
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `anotherServer${uuidv4()}`,
prefix: 'extension-redis/onStoreDocument1',
}),
new CustomStorageExtension(),
],
})
newHocuspocusProvider(t, server)
anotherProvider = newHocuspocusProvider(t, anotherServer, {
onSynced() {
// once we're setup make an edit on anotherProvider, if all succeeds the onStoreDocument
// callback will be called after the debounce period and all docs will
// be identical
anotherProvider.document.getArray('foo').insert(0, ['bar'])
anotherProvider.disconnect()
},
})
})
})
test('stores documents when the last client disconnects', async t => {
await new Promise(async resolve => {
// eslint-disable-next-line prefer-const
let provider: HocuspocusProvider
const server = await newHocuspocus(t, {
extensions: [
new Redis({
prefix: 'extension-redis/onStoreDocument2',
...redisConnectionSettings,
}),
],
onStoreDocument: async ({ document }) => {
t.is(
provider.document.getArray('foo').get(0),
document.getArray('foo').get(0),
)
resolve('done')
},
})
provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getArray('foo').insert(0, ['bar'])
provider.disconnect()
},
})
})
})
test('document gets unloaded on both servers after disconnection', async t => {
await new Promise(async resolve => {
class CustomStorageExtension {
priority = 10
onStoreDocument({ document }: onStoreDocumentPayload) {
console.log('storing')
return new Promise(resolve2 => {
setTimeout(() => {
console.log('stored')
resolve2('')
}, 3000)
})
}
}
const server = await newHocuspocus(t, {
name: 'redis-1',
extensions: [
new Redis({
...redisConnectionSettings,
prefix: 'extension-redis/onStoreDocument3',
}),
new CustomStorageExtension(),
],
})
const anotherServer = await newHocuspocus(t, {
name: 'redis-2',
extensions: [
new Redis({
...redisConnectionSettings,
prefix: 'extension-redis/onStoreDocument3',
}),
new CustomStorageExtension(),
],
})
const provider = newHocuspocusProvider(t, server)
const anotherProvider = newHocuspocusProvider(t, anotherServer, {
onSynced() {
// once we're setup make an edit on anotherProvider, if all succeeds the onStoreDocument
// callback will be called after the debounce period and all docs will
// be identical
anotherProvider.document.getArray('foo').insert(0, ['bar'])
provider.document.getArray('foo2').insert(0, ['bar'])
setTimeout(() => {
provider.configuration.websocketProvider.disconnect()
anotherProvider.configuration.websocketProvider.disconnect()
setTimeout(() => {
t.is(anotherServer.documents.size, 0)
t.is(server.documents.size, 0)
resolve('')
}, 10000) // must be higher than CustomStorageExtension delay (3s) + RedisExtension.disconnectDelay (1s)
}, 1500)
},
})
})
})

View File

@@ -0,0 +1,74 @@
import { Redis } from "@hocuspocus/extension-redis";
import test from "ava";
import { newHocuspocus, redisConnectionSettings, sleep } from "../utils/index.ts";
const newRedisServer = (t: any, prefix: string, identifier: string, options = {}) =>
newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `${identifier}${crypto.randomUUID()}`,
prefix,
// Keep teardown quick — these delays are unrelated to load-time sync.
disconnectDelay: 100,
...options,
}),
],
});
test("direct connection on a second instance sees edits that only live in another instance's memory", async (t) => {
const prefix = `extension-redis/openDirectConnection-${crypto.randomUUID()}`;
const serverA = await newRedisServer(t, prefix, "serverA");
const serverB = await newRedisServer(t, prefix, "serverB");
// Open + modify the document on server A and keep the connection open so the
// document stays in memory on A and is never persisted to storage. The only
// way server B can learn about this edit is via the Redis sync handshake.
const directA = await serverA.openDirectConnection("sync-on-open");
await directA.transact((doc) => {
doc.getMap("config").set("foo", "bar");
});
// Open a direct connection on server B and read immediately. Without an
// inbound-sync wait in afterLoadDocument, B only has the (empty) state it
// loaded from storage, so this read sees `undefined`.
const directB = await serverB.openDirectConnection("sync-on-open");
let seenValue: unknown;
await directB.transact((doc) => {
seenValue = doc.getMap("config").get("foo");
});
t.is(seenValue, "bar");
await directB.disconnect();
await directA.disconnect();
});
test("openDirectConnection on a single instance does not stall waiting for absent peers", async (t) => {
const prefix = `extension-redis/openDirectConnection-solo-${crypto.randomUUID()}`;
// A generous sync timeout: if presence detection is broken and we wait for
// peers that don't exist, this load would take ~5s instead of being instant.
const server = await newRedisServer(t, prefix, "solo", {
awaitInitialSyncTimeout: 5000,
});
const start = Date.now();
const direct = await server.openDirectConnection("solo-doc");
const elapsed = Date.now() - start;
t.true(
elapsed < 2000,
`openDirectConnection should not block on absent peers (took ${elapsed}ms)`,
);
await direct.transact((doc) => {
doc.getMap("config").set("foo", "bar");
});
t.is(direct.document?.getMap("config").get("foo"), "bar");
await direct.disconnect();
await sleep(50);
});

View File

@@ -0,0 +1,205 @@
import { Redis } from "@hocuspocus/extension-redis";
import { Document, IncomingMessage, MessageType } from "@hocuspocus/server";
import test from "ava";
import {
newHocuspocus,
newHocuspocusProvider,
redisConnectionSettings,
sleep,
} from "../utils/index.ts";
import { retryableAssertion } from "../utils/retryableAssertion.ts";
/**
* Counts what actually reaches Redis by wrapping the extension's publisher.
*/
const countingRedis = () => {
const extension = new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
});
const publisher = (extension as any).pub;
const original = publisher.publish.bind(publisher);
let published = 0;
publisher.publish = (...args: any[]) => {
published += 1;
return original(...args);
};
return { extension, publishCount: () => published, reset: () => (published = 0) };
};
test("coalesces a burst of changes into a single publish", async (t) => {
const redis = countingRedis();
const server = await newHocuspocus(t, { extensions: [redis.extension] });
const provider = newHocuspocusProvider(t, server, {
name: "redis-coalescing",
awareness: null,
});
await new Promise((resolve) => provider.on("synced", () => resolve("done")));
await sleep(300);
redis.reset();
// Drive the burst on the server's own document. Twenty separate transactions
// applied synchronously are guaranteed to share one event loop turn; sending
// them through the provider would leave frame delivery, and therefore the
// turn boundaries, outside the test's control.
const document = server.documents.get("redis-coalescing");
t.truthy(document);
const map = document!.getMap("test");
for (let i = 0; i < 20; i += 1) {
map.set(`key-${i}`, i);
}
await sleep(300);
// Without coalescing this is one SyncStep1 publish per change, so 20.
t.is(redis.publishCount(), 1);
});
test("a failing sync publish does not swallow the awareness publish", async (t) => {
const extension = new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
});
const server = await newHocuspocus(t, { extensions: [extension] });
const provider = newHocuspocusProvider(t, server, {
name: "redis-publish-failure",
});
await new Promise((resolve) => provider.on("synced", () => resolve("done")));
await sleep(300);
// Reject the sync step specifically, by message type rather than by call
// order, and let everything else publish normally.
const publisher = (extension as any).pub;
const original = publisher.publish.bind(publisher);
const attempted: number[] = [];
const succeeded: number[] = [];
publisher.publish = (key: string, payload: Buffer) => {
const [, messageBuffer] = (extension as any).decodeMessage(payload);
const message = new IncomingMessage(messageBuffer);
message.readVarString(); // document name
const messageType = message.readVarUint();
attempted.push(messageType);
if (messageType === MessageType.Sync) {
return Promise.reject(new Error("redis is down"));
}
succeeded.push(messageType);
return original(key, payload);
};
// Both applied server-side and synchronously, so they share one turn and
// therefore one pending entry — which is the case under test.
const document = server.documents.get("redis-publish-failure");
t.truthy(document);
document!.getMap("test").set("a", 1);
document!.awareness.setLocalState({ user: "jan" });
await sleep(300);
t.true(attempted.includes(MessageType.Sync), "the sync step was attempted");
t.true(
succeeded.includes(MessageType.Awareness),
"the awareness update was published despite the sync step failing",
);
});
test("a reloaded document does not inherit the previous instance's pending publish", async (t) => {
const extension = new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
});
const server = await newHocuspocus(t, { extensions: [extension] });
const provider = newHocuspocusProvider(t, server, {
name: "redis-reload",
awareness: null,
});
await new Promise((resolve) => provider.on("synced", () => resolve("done")));
const first = server.documents.get("redis-reload");
t.truthy(first);
// A fast reconnect can replace the Document under an unchanged name while a
// publish is still buffered, because `afterUnloadDocument` returns early and
// never clears it. Buffering against the old instance and then scheduling
// against a new one must not carry the stale document forward.
const schedule = (extension as any).schedulePublish.bind(extension);
const pending = (extension as any).pendingPublishes;
schedule("redis-reload", first);
t.is(pending.get("redis-reload").document, first);
const reloaded = new Document("redis-reload");
t.is(schedule("redis-reload", reloaded).document, reloaded);
t.is(pending.get("redis-reload").document, reloaded);
reloaded.destroy();
});
test("a coalesced burst still reaches another instance", async (t) => {
const server = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
}),
],
});
const anotherServer = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `anotherServer${crypto.randomUUID()}`,
}),
],
});
const provider = newHocuspocusProvider(t, server, {
name: "redis-coalescing-sync",
awareness: null,
});
const anotherProvider = newHocuspocusProvider(t, anotherServer, {
name: "redis-coalescing-sync",
awareness: null,
});
await new Promise((resolve) =>
anotherProvider.on("synced", () => resolve("done")),
);
const map = provider.document.getMap("test");
for (let i = 0; i < 20; i += 1) {
map.set(`key-${i}`, i);
}
// Every change of the collapsed burst has to arrive, not just the last one.
await retryableAssertion(t, (tt) => {
const received = anotherProvider.document.getMap("test");
for (let i = 0; i < 20; i += 1) {
tt.is(received.get(`key-${i}`), i);
}
});
});

View File

@@ -0,0 +1,173 @@
import { Redis } from "@hocuspocus/extension-redis";
import { IncomingMessage, MessageType } from "@hocuspocus/server";
import test from "ava";
import {
newHocuspocus,
redisConnectionSettings,
sleep,
} from "../utils/index.ts";
const newRedisServer = async (t: any, prefix: string, identifier: string) => {
const extension = new Redis({
...redisConnectionSettings,
identifier: `${identifier}-${crypto.randomUUID()}`,
prefix,
disconnectDelay: 100,
});
const server = await newHocuspocus(t, { extensions: [extension] });
return { server, extension };
};
/** Simulate lost pub/sub delivery: stop feeding inbound messages to the extension. */
const silence = (extension: Redis) => {
const sub = (extension as any).sub;
const handler = (extension as any).handleIncomingMessage;
sub.removeListener("messageBuffer", handler);
return () => sub.on("messageBuffer", handler);
};
test("a SyncStep2 computed for another instance must not truncate a stale bystander", async (t) => {
const prefix = `extension-redis/crossInstanceSyncStep2-${crypto.randomUUID()}`;
const docName = "poison";
const a = await newRedisServer(t, prefix, "serverA");
const b = await newRedisServer(t, prefix, "serverB");
const c = await newRedisServer(t, prefix, "serverC");
const directA = await a.server.openDirectConnection(docName);
const directB = await b.server.openDirectConnection(docName);
const directC = await c.server.openDirectConnection(docName);
// All three instances hold the document and agree on "OLD".
await directA.transact((doc) => {
doc.getText("t").insert(0, "OLD");
});
await sleep(500);
t.is(directA.document!.getText("t").toString(), "OLD", "A starts at OLD");
t.is(directB.document!.getText("t").toString(), "OLD", "B starts at OLD");
t.is(directC.document!.getText("t").toString(), "OLD", "C starts at OLD");
// A misses the rewrite (Redis pub/sub is fire-and-forget, so a dropped
// message, a reconnect, or a busy process all produce this).
const unsilence = silence(a.extension);
await directB.transact((doc) => {
const text = doc.getText("t");
text.delete(0, text.length);
text.insert(0, "NEW");
});
await sleep(500);
t.is(directB.document!.getText("t").toString(), "NEW", "B rewrote to NEW");
t.is(directC.document!.getText("t").toString(), "NEW", "C received NEW");
t.is(directA.document!.getText("t").toString(), "OLD", "A is stale at OLD");
unsilence();
// Record every state A's document passes through from here on.
const seen: string[] = [];
directA.document!.on("update", () => {
seen.push(directA.document!.getText("t").toString());
});
// Any change on C publishes a SyncStep1 with C's (up-to-date) state vector.
// B answers it with SyncStep2 = encodeStateAsUpdate(B, C.sv) — zero structs
// plus the full delete set — on the shared document channel, where A also
// hears it.
await directC.transact((doc) => {
doc.getMap("noise").set("k", 1);
});
await sleep(500);
t.log("states A passed through:", JSON.stringify(seen));
t.log("A final:", JSON.stringify(directA.document!.getText("t").toString()));
t.false(
seen.includes(""),
"A must never observe an empty document: it would broadcast that to its own clients and can persist it",
);
await directA.disconnect();
await directB.disconnect();
await directC.disconnect();
});
/** `messageYjsSyncStep2` from y-protocols/sync, which is not resolvable here. */
const SYNC_STEP_2 = 1;
test("sync replies are published to the requester's reply channel only", async (t) => {
const prefix = `extension-redis/syncStep2Routing-channel-${crypto.randomUUID()}`;
const docName = "routing";
const identifierA = `serverA-${crypto.randomUUID()}`;
const extensionA = new Redis({
...redisConnectionSettings,
identifier: identifierA,
prefix,
disconnectDelay: 100,
});
const serverA = await newHocuspocus(t, { extensions: [extensionA] });
const b = await newRedisServer(t, prefix, "serverB");
const published: Array<{ channel: string; syncType?: number }> = [];
const publisher = (b.extension as any).pub;
const original = publisher.publish.bind(publisher);
publisher.publish = (channel: string, payload: Buffer) => {
const [, messageBuffer] = (b.extension as any).decodeMessage(payload);
const message = new IncomingMessage(messageBuffer);
message.readVarString(); // document name
const type = message.readVarUint();
published.push({
channel,
syncType:
type === MessageType.Sync || type === MessageType.SyncReply
? message.readVarUint()
: undefined,
});
return original(channel, payload);
};
// B holds the document with state that only lives in its memory.
const directB = await b.server.openDirectConnection(docName);
await directB.transact((doc) => {
doc.getText("t").insert(0, "hello");
});
await sleep(300);
published.length = 0;
// A loads the same document, so B has to answer A's SyncStep1.
const directA = await serverA.openDirectConnection(docName);
await sleep(300);
t.is(directA.document!.getText("t").toString(), "hello", "A synced from B");
const replies = published.filter((entry) => entry.syncType === SYNC_STEP_2);
t.true(replies.length > 0, "B answered with its state");
for (const reply of replies) {
t.is(reply.channel, `${prefix}#reply:${identifierA}`);
}
t.false(
published.some(
(entry) =>
entry.channel === `${prefix}:${docName}` &&
entry.syncType === SYNC_STEP_2,
),
"no state is published on the shared document channel",
);
await directA.disconnect();
await directB.disconnect();
});

153
tests/extension-s3/fetch.ts Normal file
View File

@@ -0,0 +1,153 @@
// @ts-nocheck - AWS SDK types resolved through extension package
import test from "ava";
import sinon from "sinon";
import { S3 } from "@hocuspocus/extension-s3";
import {
S3Client,
CreateBucketCommand,
HeadBucketCommand,
GetObjectCommand,
PutObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3";
import { ReadableStream } from "stream/web";
import * as Y from 'yjs'
const testConfig = {
bucket: "hocuspocus-test",
prefix: "test-documents/",
};
const storage = new Map<string, Buffer>();
const sandbox = sinon.createSandbox();
// Create a single dummy client to be used in all tests
const dummyS3Client = new S3Client({});
test.beforeEach(() => {
sandbox.restore();
storage.clear();
sandbox.stub(S3Client.prototype, "send").callsFake(async (command: any) => {
if (command instanceof HeadBucketCommand || command instanceof HeadObjectCommand) {
return Promise.resolve();
}
if (command instanceof CreateBucketCommand) {
return Promise.resolve();
}
if (command instanceof GetObjectCommand) {
const key = command.input.Key as string; // 确保 key 是 string 类型
if (storage.has(key)) {
const buffer = storage.get(key)!;
return {
Body: {
// Mimic AWS SDK v3 GetObjectCommand Body interface used in the extension
transformToWebStream: () =>
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
}),
},
};
}
const err = new Error("NoSuchKey");
err.name = "NoSuchKey";
// @ts-expect-error
err.$metadata = { httpStatusCode: 404 };
throw err;
}
if (command instanceof PutObjectCommand) {
const key = command.input.Key as string; // 确保 key 是 string 类型
const body = command.input.Body;
if (body instanceof Buffer) {
storage.set(key, body);
} else {
throw new Error("Body must be a Buffer");
}
return Promise.resolve();
}
});
});
test.afterEach.always(() => {
sandbox.restore();
storage.clear();
});
test("should throw an error without bucket name", async (t) => {
t.throws(
() => {
new S3({});
},
{ message: "S3 bucket name is required" }
);
});
test("should create extension with valid configuration", async (t) => {
const extension = new S3({ ...testConfig, s3Client: dummyS3Client });
t.truthy(extension);
t.is(extension.configuration.bucket, testConfig.bucket);
});
test("should store and retrieve documents", async (t) => {
const extension = new S3({ ...testConfig, s3Client: dummyS3Client });
await (extension as any).onConfigure();
const documentName = "test-document";
const doc = new Y.Doc();
doc.getMap('test').set('key', 'value');
await extension.configuration.store({
documentName,
state: Buffer.from(Y.encodeStateAsUpdate(doc)),
} as any);
const fetched = await extension.configuration.fetch({ documentName } as any);
t.truthy(fetched);
t.true(fetched instanceof Uint8Array);
t.true((fetched as Uint8Array).length > 0);
const doc2 = new Y.Doc();
Y.applyUpdate(doc2, fetched!);
t.is(doc2.getMap('test').get('key'), 'value');
});
test("should handle non-existent documents", async (t) => {
const extension = new S3({ ...testConfig, s3Client: dummyS3Client });
await (extension as any).onConfigure();
const result = await extension.configuration.fetch({
documentName: "non-existent-document",
} as any);
t.is(result, null);
});
test("should work with custom S3 client", async (t) => {
const extension = new S3({
bucket: testConfig.bucket,
s3Client: dummyS3Client,
});
t.truthy(extension);
t.is(extension.configuration.bucket, testConfig.bucket);
});
test("should use correct object key format", async (t) => {
const documentName = "test-document-key-format";
const extension = new S3({ ...testConfig, s3Client: dummyS3Client });
await (extension as any).onConfigure();
const doc = new Y.Doc();
doc.getMap('test').set('key', 'value');
await extension.configuration.store({
documentName,
state: Buffer.from(Y.encodeStateAsUpdate(doc)),
} as any);
const expectedKey = `${testConfig.prefix}${documentName}.bin`;
t.true(storage.has(expectedKey), `Storage should have key: ${expectedKey}`);
});

View File

@@ -0,0 +1,103 @@
import test from 'ava'
import type { onConnectPayload } from '@hocuspocus/server'
import * as MockDate from 'mockdate'
import { Throttle } from '@hocuspocus/extension-throttle'
const getOnConnectPayload = (ip: string) => {
return {
request: {
headers: new Headers({
'x-real-ip': ip,
}),
},
} as unknown as onConnectPayload
}
const generateRequests = async (instance: Throttle, ip: string, numberOfRequests: number) => {
for (let i = 0; i < numberOfRequests; i += 1) {
// eslint-disable-next-line no-await-in-loop
await instance.onConnect(getOnConnectPayload(ip))
}
}
test('throttle extension bans properly', async t => {
const throttle = new Throttle({ banTime: 5, throttle: 15 })
t.teardown(() => throttle.onDestroy())
const ip = '127.0.0.1'
t.false(throttle.isBanned(ip))
await generateRequests(throttle, ip, 15)
try {
await throttle.onConnect(getOnConnectPayload(ip))
t.fail()
} catch (e) {
t.true(throttle.isBanned(ip))
}
})
test('throttle extension unbans properly', async t => {
const throttle = new Throttle({ banTime: 5, throttle: 15 })
t.teardown(() => throttle.onDestroy())
const ip = '127.0.0.1'
t.false(throttle.isBanned(ip))
await generateRequests(throttle, ip, 15)
try {
await throttle.onConnect(getOnConnectPayload(ip))
t.fail()
} catch (e) {
t.true(throttle.isBanned(ip))
}
MockDate.set(Date.now() + 1000 * (throttle.configuration.banTime * 60))
await throttle.onConnect(getOnConnectPayload(ip))
t.false(throttle.isBanned(ip))
MockDate.reset()
})
test.serial('map cleanup works for connectionsByIp', async t => {
const throttle = new Throttle({ consideredSeconds: 60 })
t.teardown(() => throttle.onDestroy())
const ip = '127.0.0.1'
await generateRequests(throttle, ip, 10)
t.is(throttle.connectionsByIp.get(ip)!.length, 10)
MockDate.set(Date.now() + 1000 * throttle.configuration.consideredSeconds)
await throttle.clearMaps()
t.false(throttle.connectionsByIp.has(ip))
MockDate.reset()
})
test.serial('map cleanup works for bannedIps', async t => {
const throttle = new Throttle({ consideredSeconds: 60, throttle: 15 })
t.teardown(() => throttle.onDestroy())
const ip = '127.0.0.1'
await generateRequests(throttle, ip, 15)
try {
await throttle.onConnect(getOnConnectPayload(ip))
// eslint-disable-next-line no-empty
} catch (e) {}
t.true(throttle.bannedIps.has(ip))
MockDate.set(Date.now() + 1000 * throttle.configuration.banTime * 60)
await throttle.clearMaps()
t.false(throttle.bannedIps.has(ip))
MockDate.reset()
})

View File

@@ -0,0 +1,26 @@
import test from 'ava'
import { Throttle } from '@hocuspocus/extension-throttle'
test('throttle has the default configuration', async t => {
const throttle = new Throttle()
t.teardown(() => throttle.onDestroy())
t.is(throttle.configuration.throttle, 15)
})
test('banTime has the default configuration', async t => {
const throttle = new Throttle()
t.teardown(() => throttle.onDestroy())
t.is(throttle.configuration.banTime, 5)
})
test('throttle has a custom value', async t => {
const throttle = new Throttle({ throttle: 100 })
t.teardown(() => throttle.onDestroy())
t.is(throttle.configuration.throttle, 100)
})
test('banTime has a custom value', async t => {
const throttle = new Throttle({ banTime: 100 })
t.teardown(() => throttle.onDestroy())
t.is(throttle.configuration.banTime, 100)
})

28
tests/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "tests",
"version": "3.4.6-rc.1",
"private": true,
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "^3.990.0",
"@hocuspocus/common": "workspace:^",
"@hocuspocus/extension-database": "workspace:^",
"@hocuspocus/extension-logger": "workspace:^",
"@hocuspocus/extension-redis": "workspace:^",
"@hocuspocus/extension-s3": "workspace:^",
"@hocuspocus/extension-throttle": "workspace:^",
"@hocuspocus/provider": "workspace:^",
"@hocuspocus/server": "workspace:^",
"@hocuspocus/transformer": "workspace:^",
"redis": "^4.7.1",
"sinon": "^12.0.1",
"ws": "^8.21.0",
"yjs": "^13.6.29"
},
"devDependencies": {
"@types/redis": "^4.0.11",
"@types/sinon": "^10.0.20",
"lib0": "^0.2.117",
"mockdate": "^3.0.5"
}
}

View File

@@ -0,0 +1,79 @@
import test from 'ava'
import type { HocuspocusProvider } from '@hocuspocus/provider'
import { newHocuspocus, newHocuspocusProvider, sleep } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
const AWARENESS_MESSAGE = 'Awareness states update'
const hasAwarenessField = (provider: HocuspocusProvider) =>
Array.from(provider.awareness?.getStates().values() ?? []).some(state => state.foo === 'bar')
test('does not echo awareness received from the server back to it', async t => {
const server = await newHocuspocus(t)
const first = newHocuspocusProvider(t, server, { name: 'awareness-echo' })
let outgoingAwarenessMessages = 0
const second = newHocuspocusProvider(t, server, {
name: 'awareness-echo',
onOutgoingMessage({ message }) {
if (message.description === AWARENESS_MESSAGE) {
outgoingAwarenessMessages += 1
}
},
})
await new Promise(resolve => second.on('synced', () => resolve('done')))
// Ignore whatever the handshake produced; only the reaction to a remote
// change is under test here.
await sleep(100)
outgoingAwarenessMessages = 0
first.setAwarenessField('foo', 'bar')
// The remote state does arrive, so the assertion below is not vacuous.
await retryableAssertion(t, tt => {
tt.true(hasAwarenessField(second))
})
await sleep(100)
t.is(outgoingAwarenessMessages, 0)
})
test('still sends awareness changes that originate locally', async t => {
const server = await newHocuspocus(t)
const first = newHocuspocusProvider(t, server, { name: 'awareness-local' })
const second = newHocuspocusProvider(t, server, { name: 'awareness-local' })
await new Promise(resolve => second.on('synced', () => resolve('done')))
second.setAwarenessField('foo', 'bar')
await retryableAssertion(t, tt => {
tt.true(hasAwarenessField(first))
})
})
test('still tells the server when a client goes away', async t => {
const server = await newHocuspocus(t)
const first = newHocuspocusProvider(t, server, { name: 'awareness-destroy' })
const second = newHocuspocusProvider(t, server, { name: 'awareness-destroy' })
await new Promise(resolve => second.on('synced', () => resolve('done')))
second.setAwarenessField('foo', 'bar')
await retryableAssertion(t, tt => {
tt.true(hasAwarenessField(first))
})
second.destroy()
await retryableAssertion(t, tt => {
tt.false(hasAwarenessField(first))
})
})

View File

@@ -0,0 +1,138 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider, sleep } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
test('batches rapid document updates into a single outgoing message', async t => {
const server = await newHocuspocus(t)
let updateMessages = 0
await new Promise(resolve => {
const provider = newHocuspocusProvider(t, server, {
awareness: null,
flushDelay: 100,
onOutgoingMessage({ message }) {
if (message.description === 'A document update') {
updateMessages += 1
}
},
async onSynced() {
provider.document.getMap('test').set('a', 1)
provider.document.getMap('test').set('b', 2)
provider.document.getMap('test').set('c', 3)
// Still inside the flush window: nothing has been sent yet.
t.is(updateMessages, 0)
await sleep(300)
// The three updates were merged into a single message.
t.is(updateMessages, 1)
resolve('done')
},
})
})
})
test('a merged batch still converges on another client', async t => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server, {
awareness: null,
flushDelay: 100,
})
await new Promise(resolve => {
provider.on('synced', () => resolve('done'))
})
provider.document.getMap('test').set('a', 1)
provider.document.getMap('test').set('b', 2)
provider.document.getMap('test').set('c', 3)
const reader = newHocuspocusProvider(t, server, { awareness: null })
await retryableAssertion(t, tt => {
const map = reader.document.getMap('test')
tt.is(map.get('a'), 1)
tt.is(map.get('b'), 2)
tt.is(map.get('c'), 3)
})
})
test('sends one message per update when flushDelay is disabled', async t => {
const server = await newHocuspocus(t)
let updateMessages = 0
await new Promise(resolve => {
const provider = newHocuspocusProvider(t, server, {
awareness: null,
onOutgoingMessage({ message }) {
if (message.description === 'A document update') {
updateMessages += 1
}
},
async onSynced() {
provider.document.getMap('test').set('a', 1)
provider.document.getMap('test').set('b', 2)
provider.document.getMap('test').set('c', 3)
await sleep(100)
t.is(updateMessages, 3)
resolve('done')
},
})
})
})
test('reports unsynced changes during the batch window and clears after sync', async t => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server, {
awareness: null,
flushDelay: 100,
})
await new Promise(resolve => {
provider.on('synced', () => resolve('done'))
})
provider.document.getMap('test').set('a', 1)
provider.document.getMap('test').set('b', 2)
// Buffered but not yet sent — still counts as unsynced.
t.is(provider.hasUnsyncedChanges, true)
await retryableAssertion(t, tt => {
tt.is(provider.hasUnsyncedChanges, false)
})
})
test('collapses rapid awareness changes into a single outgoing message', async t => {
const server = await newHocuspocus(t)
let awarenessMessages = 0
await new Promise(resolve => {
const provider = newHocuspocusProvider(t, server, {
flushDelay: 100,
onOutgoingMessage({ message }) {
if (message.description === 'Awareness states update') {
awarenessMessages += 1
}
},
async onSynced() {
// Ignore any awareness sent as part of the initial sync.
awarenessMessages = 0
provider.setAwarenessField('cursor', 1)
provider.setAwarenessField('cursor', 2)
provider.setAwarenessField('cursor', 3)
await sleep(300)
t.is(awarenessMessages, 1)
resolve('done')
},
})
})
})

View File

@@ -0,0 +1,187 @@
import test from 'ava'
import * as Y from 'yjs'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
import { newHocuspocus, newHocuspocusProvider, sleep } from '../utils/index.ts'
test("initially doesn't have unsynced changes", async t => {
return new Promise(async resolve => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
t.is(provider.hasUnsyncedChanges, false)
t.is(provider.synced, false)
setTimeout(() => {
t.is(provider.hasUnsyncedChanges, false)
t.is(provider.synced, true)
resolve()
}, 200)
})
})
test('has unsynced changes when updating', async t => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server, {
awareness: undefined,
})
provider.document.getMap('test').set('foo', 'bar')
t.is(provider.hasUnsyncedChanges, true)
// changes are synced
await retryableAssertion(t, tt => {
tt.is(provider.hasUnsyncedChanges, false)
})
})
test('has unsynced changes when in readonly mode', async t => {
const server = await newHocuspocus(t, {
async onAuthenticate({ connectionConfig }) {
connectionConfig.readOnly = true
},
})
const provider = newHocuspocusProvider(t, server, { token: 'readonly' })
provider.document.getMap('test').set('foo', 'bar')
await retryableAssertion(t, tt => {
tt.is(provider.hasUnsyncedChanges, true)
})
await sleep(100)
// confirm that the changes are not synced later either
t.is(provider.hasUnsyncedChanges, true)
})
test('has no unsynced changes when in readonly mode and no changes', async t => {
const server = await newHocuspocus(t, {
async onAuthenticate({ connectionConfig }) {
connectionConfig.readOnly = true
},
})
const provider = newHocuspocusProvider(t, server, { token: 'readonly' })
// first, unsyncedChanges is briefly set to true when we're waiting for the ack of the initial sync
await new Promise((resolve, reject) => {
provider.on('unsyncedChanges', () => {
provider.off('unsyncedChanges')
if (provider.hasUnsyncedChanges) {
resolve('done')
} else {
reject()
}
})
})
// then, it should be set to false when the sync message is confirmed
await retryableAssertion(t, tt => {
tt.is(provider.hasUnsyncedChanges, false)
})
})
test('has unsynced changes when in readonly mode and receiving external update', async t => {
const server = await newHocuspocus(t, {
async onAuthenticate({ connectionConfig, token }) {
if (token === 'readonly') {
connectionConfig.readOnly = true
}
},
})
const provider = newHocuspocusProvider(t, server, {
token: 'readonly',
})
provider.document.getMap('test').set('foo', 'bar')
t.is(provider.hasUnsyncedChanges, true)
await sleep(100)
t.is(provider.hasUnsyncedChanges, true)
const provider2 = newHocuspocusProvider(t, server, {
token: 'full-access',
})
provider2.document.getMap('test2').set('foo', 'bar')
t.is(provider2.hasUnsyncedChanges, true)
await retryableAssertion(t, tt => {
tt.is(provider2.hasUnsyncedChanges, false)
})
t.is(provider.hasUnsyncedChanges, true)
})
test('has unsynced changes when in readonly mode and initial document has changed', async t => {
const server = await newHocuspocus(t, {
async onAuthenticate({ connectionConfig }) {
connectionConfig.readOnly = true
},
})
const document = new Y.Doc()
document.getMap('test').set('foo', 'bar')
const provider = newHocuspocusProvider(t, server, { document, token: 'readonly' })
await retryableAssertion(t, tt => {
tt.is(provider.hasUnsyncedChanges, true)
})
await sleep(100)
t.is(provider.hasUnsyncedChanges, true)
})
test('has unsynced changes when in readonly mode and initial document has changed (deletion)', async t => {
const document = new Y.Doc()
document.getMap('test').set('foo', 'bar')
const initialState = Y.encodeStateAsUpdate(document)
const server = await newHocuspocus(t, {
async onLoadDocument() {
return initialState
},
async onAuthenticate({ connectionConfig }) {
connectionConfig.readOnly = true
},
})
document.getMap('test').delete('foo')
const provider = newHocuspocusProvider(t, server, { document, token: 'readonly' })
await retryableAssertion(t, tt => {
tt.is(provider.hasUnsyncedChanges, true)
})
await sleep(100)
t.is(provider.hasUnsyncedChanges, true)
})
test('has no unsynced changes when in readonly mode and initial document has not changed', async t => {
const server = await newHocuspocus(t, {
async onAuthenticate({ connectionConfig }) {
connectionConfig.readOnly = true
},
})
const document = new Y.Doc()
const provider = newHocuspocusProvider(t, server, { document, token: 'readonly' })
await sleep(200)
t.is(provider.hasUnsyncedChanges, false)
})

83
tests/provider/observe.ts Normal file
View File

@@ -0,0 +1,83 @@
import test from 'ava'
import * as Y from 'yjs'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
test('observe is called just once', async t => {
let count = 0
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
const type = provider.document.get(
'xmlText',
Y.XmlText,
) as unknown as Y.XmlText
// Count how often observe is called …
type.observe((events, transaction) => {
count += 1
})
// Insert something …
type.insert(1, 'a')
await retryableAssertion(t, tt => {
tt.is(count, 1)
})
})
test('observe is called for every single change', async t => {
let count = 0
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
const type = provider.document.get(
'xmlText',
Y.XmlText,
) as unknown as Y.XmlText
// Count how often observe is called …
type.observe((events, transaction) => {
count += 1
})
// Insert something …
type.insert(1, 'a')
type.insert(2, 'b')
type.insert(3, 'c')
await retryableAssertion(t, tt => {
tt.is(count, 3)
})
})
test('observe is called once for a single transaction', async t => {
let count = 0
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
const type = provider.document.get(
'xmlText',
Y.XmlText,
) as unknown as Y.XmlText
// Count how often observe is called …
type.observe((events, transaction) => {
count += 1
})
// Insert something …
Y.transact(provider.document, () => {
type.insert(1, 'a')
type.insert(2, 'b')
type.insert(3, 'c')
})
await retryableAssertion(t, tt => {
tt.is(count, 1)
})
})

View File

@@ -0,0 +1,82 @@
import test from 'ava'
import * as Y from 'yjs'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
test('observeDeep is called just once', async t => {
let count = 0
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
const type = provider.document.get(
'xmlText',
Y.XmlText,
) as unknown as Y.XmlText
// Count how often observeDeep is called …
type.observeDeep((events, transaction) => {
count += 1
})
// Insert something …
type.insert(1, 'a')
await retryableAssertion(t, tt => {
tt.is(count, 1)
})
})
test('observeDeep is called for every single change', async t => {
let count = 0
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
const type = provider.document.get(
'xmlText',
Y.XmlText,
) as unknown as Y.XmlText
// Count how often observeDeep is called …
type.observeDeep((events, transaction) => {
count += 1
})
// Insert something …
type.insert(1, 'a')
type.insert(2, 'b')
type.insert(3, 'c')
await retryableAssertion(t, tt => {
tt.is(count, 3)
})
})
test('observeDeep is called once for a single transaction', async t => {
let count = 0
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
const type = provider.document.get(
'xmlText',
Y.XmlText,
) as unknown as Y.XmlText
// Count how often observeDeep is called …
type.observeDeep((events, transaction) => {
count += 1
})
// Insert something …
Y.transact(provider.document, () => {
type.insert(1, 'a')
type.insert(2, 'b')
type.insert(3, 'c')
})
await retryableAssertion(t, tt => {
tt.is(count, 1)
})
})

View File

@@ -0,0 +1,91 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('executes the onAuthenticated callback', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate({ token }) {
if (token !== 'SUPER-SECRET-TOKEN') {
throw new Error()
}
},
})
const provider = newHocuspocusProvider(t, server, {
token: 'SUPER-SECRET-TOKEN',
onAuthenticated() {
t.is(provider.isAuthenticated, true)
t.is(provider.authorizedScope, 'read-write')
t.pass()
resolve('done')
},
})
})
})
test('executes the onAuthenticated callback when token is provided as a function that returns a promise', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate({ token }) {
if (token !== 'SUPER-SECRET-TOKEN') {
throw new Error()
}
},
})
const provider = newHocuspocusProvider(t, server, {
token: async () => Promise.resolve('SUPER-SECRET-TOKEN'),
onAuthenticated() {
t.is(provider.isAuthenticated, true)
t.is(provider.authorizedScope, 'read-write')
t.pass()
resolve('done')
},
})
})
})
test('executes the onAuthenticated callback when token is provided as a function that returns a string', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate({ token }) {
if (token !== 'SUPER-SECRET-TOKEN') {
throw new Error()
}
},
})
const provider = newHocuspocusProvider(t, server, {
token: () => 'SUPER-SECRET-TOKEN',
onAuthenticated() {
t.is(provider.isAuthenticated, true)
t.is(provider.authorizedScope, 'read-write')
t.pass()
resolve('done')
},
})
})
})
test('sets correct scope for readonly', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate({ token, connectionConfig }) {
if (token !== 'SUPER-SECRET-TOKEN') {
throw new Error()
}
connectionConfig.readOnly = true
},
})
const provider = newHocuspocusProvider(t, server, {
token: 'SUPER-SECRET-TOKEN',
onAuthenticated() {
t.is(provider.isAuthenticated, true)
t.is(provider.authorizedScope, 'readonly')
t.pass()
resolve('done')
},
})
})
})

View File

@@ -0,0 +1,20 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('executes the onAuthenticationFailed callback', async t => {
await new Promise(async resolve => {
newHocuspocus(t, {
async onAuthenticate({ token }) {
throw new Error()
},
}).then(server => {
newHocuspocusProvider(t, server, {
token: 'SUPER-SECRET-TOKEN',
onAuthenticationFailed() {
t.pass()
resolve('done')
},
})
})
})
})

View File

@@ -0,0 +1,90 @@
import test from "ava";
import {
newHocuspocus,
newHocuspocusProvider,
newHocuspocusProviderWebsocket,
sleep,
} from "../utils/index.ts";
test("provider retries auth with token function after initial failure", async (t) => {
const docName = "superSecretDoc";
const requiredToken = "SUPER-SECRET-TOKEN";
const server = await newHocuspocus(t, {
async onAuthenticate({ token, documentName }) {
if (documentName !== docName) {
throw new Error();
}
if (token !== requiredToken) {
throw new Error();
}
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
let tokenCallCount = 0;
const provider = newHocuspocusProvider(t, server, {
websocketProvider: socket,
name: docName,
token: () => {
tokenCallCount++;
return tokenCallCount === 1 ? "wrongToken" : requiredToken;
},
onAuthenticationFailed() {
provider.sendToken();
provider.startSync();
},
});
await sleep(2000);
t.is(tokenCallCount, 2);
t.is(provider.isAuthenticated, true);
});
test("second provider with same doc name succeeds after first fails auth", async (t) => {
const docName = "superSecretDoc";
const requiredToken = "SUPER-SECRET-TOKEN";
const server = await newHocuspocus(t, {
async onAuthenticate({ token, documentName }) {
if (documentName !== docName) {
throw new Error();
}
if (token !== requiredToken) {
throw new Error();
}
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const providerFail = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: "wrongToken",
name: docName,
onAuthenticated() {
t.fail("providerFail should not authenticate");
},
});
await sleep(1000);
const providerOK = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: requiredToken,
name: docName,
onAuthenticationFailed() {
t.fail("providerOK should not fail auth");
},
});
await sleep(1000);
t.is(providerFail.isAuthenticated, false);
t.is(providerOK.isAuthenticated, true);
});

View File

@@ -0,0 +1,169 @@
import test from "ava";
import { newHocuspocus, newHocuspocusProvider, sleep } from "../utils/index.ts";
test("onAwarenessChange callback is executed", async (t) => {
await new Promise(async (resolve) => {
let resolved = false;
const server = await newHocuspocus(t);
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField("foo", "bar");
},
onAwarenessChange: ({ states }) => {
if (resolved) return;
resolved = true;
t.is(states.length, 1);
t.is(states[0].foo, "bar");
resolve("done");
},
});
});
});
test("onAwarenessChange callback is executed, even when no awareness fields are set", async (t) => {
await new Promise(async (resolve) => {
let resolved = false;
const server = await newHocuspocus(t);
const provider = newHocuspocusProvider(t, server, {
onAwarenessChange: ({ states }) => {
if (resolved) return;
resolved = true;
t.is(states.length, 2);
resolve("done");
},
});
const anotherProvider = newHocuspocusProvider(t, server, {
async onConnect() {
anotherProvider.setAwarenessField("foo", "bar");
provider.configuration.websocketProvider.connect();
},
});
});
t.pass();
});
test("onAwarenessChange callback is executed on provider destroy", async (t) => {
await new Promise(async (resolve) => {
let resolved = false;
const server = await newHocuspocus(t);
const provider = newHocuspocusProvider(t,
server,
{
onConnect() {
provider.destroy();
},
onAwarenessChange: ({ states }) => {
if (resolved) return;
resolved = true;
t.is(states.length, 0);
resolve("done");
},
},
{
maxAttempts: 1,
},
);
});
});
test("gets the current awareness states from the server", async (t) => {
await new Promise(async (resolve) => {
let resolved = false;
const server = await newHocuspocus(t);
const provider = newHocuspocusProvider(t, server);
const provider2 = newHocuspocusProvider(t, server, {
onAwarenessChange: ({ states }) => {
if (resolved) return;
const state = states.find((state) => state.foo === "bar");
if (state && state.foo === "bar") {
resolved = true;
t.pass();
resolve("done");
}
},
});
provider.setAwarenessField("foo", "bar");
});
});
test("shares awareness state with other users", async (t) => {
await new Promise(async (resolve) => {
let resolved = false;
const server = await newHocuspocus(t);
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField("name", "player1");
},
onAwarenessChange: ({ states }) => {
if (resolved) return;
const player2 = !!states.filter((state) => state.name === "player2")
.length;
if (player2) {
resolved = true;
t.is(player2, true);
resolve("done");
}
},
});
const anotherProvider = newHocuspocusProvider(t, server, {
onConnect() {
anotherProvider.setAwarenessField("name", "player2");
},
onAwarenessChange: ({ states }) => {
if (resolved) return;
const player1 = !!states.filter((state) => state.name === "player1")
.length;
if (player1) {
t.is(player1, true);
}
},
});
});
});
test("does not share awareness state with users in other documents", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
newHocuspocusProvider(t, server, {
async onConnect() {
await sleep(100);
t.pass();
resolve("done");
},
onAwarenessChange: ({ states }) => {
const player2 = !!states.filter((state) => state.name === "player2")
.length;
if (player2) {
t.fail("Awareness state leaked!");
}
},
});
const anotherProvider = newHocuspocusProvider(t, server, {
name: "completely-different-and-unrelated-document",
onConnect() {
anotherProvider.setAwarenessField("name", "player2");
},
});
});
});

View File

@@ -0,0 +1,131 @@
import test from 'ava'
import { AwarenessError } from '@hocuspocus/provider'
import { newHocuspocus, newHocuspocusProvider, sleep } from '../utils/index.ts'
test('onAwarenessUpdate callback is executed', async t => {
await new Promise(async resolve => {
let resolved = false
const server = await newHocuspocus(t, { })
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField('foo', 'bar')
},
onAwarenessUpdate: ({ states }) => {
if (resolved) return
resolved = true
t.is(states.length, 1)
t.is(states[0].foo, 'bar')
resolve('done')
},
})
})
})
test('shares awareness state with other users', async t => {
await new Promise(async resolve => {
let resolved = false
const server = await newHocuspocus(t, { })
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField('name', 'player1')
},
onAwarenessUpdate: ({ states }) => {
if (resolved) return
const player2 = !!states.filter(state => state.name === 'player2').length
if (player2) {
resolved = true
t.is(player2, true)
resolve('done')
}
},
})
const anotherProvider = newHocuspocusProvider(t, server, {
onConnect() {
anotherProvider.setAwarenessField('name', 'player2')
},
onAwarenessUpdate: ({ states }) => {
if (resolved) return
const player1 = !!states.filter(state => state.name === 'player1').length
if (player1) {
t.is(player1, true)
}
},
})
})
})
test('does not share awareness state with users in other documents', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, { })
newHocuspocusProvider(t, server, {
async onConnect() {
await sleep(100)
t.pass()
resolve('done')
},
onAwarenessUpdate: ({ states }) => {
const player2 = !!states.filter(state => state.name === 'player2').length
if (player2) {
throw new Error('Awareness state leaked!')
}
},
})
const anotherProvider = newHocuspocusProvider(t, server, {
name: 'hocuspocus-completely-different-and-unrelated-document',
onConnect() {
anotherProvider.setAwarenessField('name', 'player2')
},
})
})
})
test('allows awareness to be null', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, { })
newHocuspocusProvider(t, server, {
awareness: null,
async onConnect() {
await sleep(100)
t.pass()
resolve('done')
},
})
})
})
test('throws an error in setAwarenessFields if awareness is null', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server, {
awareness: null,
onConnect() {
try {
provider.setAwarenessField('foo', 'bar')
t.fail()
} catch (err: any) {
if (err instanceof AwarenessError) {
t.pass()
} else {
t.fail()
}
} finally {
resolve('done')
}
},
})
})
})

35
tests/provider/onClose.ts Normal file
View File

@@ -0,0 +1,35 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('onClose callback is executed', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.configuration.websocketProvider.disconnect()
},
onClose() {
t.pass()
resolve('done')
},
})
})
})
test("on('close') callback is executed", async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
provider.on('connect', () => {
provider.configuration.websocketProvider.disconnect()
})
provider.on('close', () => {
t.pass()
resolve('done')
})
})
})

View File

@@ -0,0 +1,28 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('executes the onConnect callback', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
newHocuspocusProvider(t, server, {
onConnect() {
t.pass()
resolve('done')
},
})
})
})
test("executes the on('connect') callback", async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
provider.on('connect', () => {
t.pass()
resolve('done')
})
})
})

View File

@@ -0,0 +1,36 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('onDisconnect callback is executed', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.configuration.websocketProvider.disconnect()
provider.disconnect()
},
onDisconnect() {
t.pass()
resolve('done')
},
})
})
})
test("on('disconnect') callback is executed", async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
provider.on('connect', () => {
provider.configuration.websocketProvider.disconnect()
provider.disconnect()
})
provider.on('disconnect', () => {
t.pass()
resolve('done')
})
})
})

View File

@@ -0,0 +1,28 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('executes the onMessage callback', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, { })
newHocuspocusProvider(t, server, {
onMessage() {
t.pass()
resolve('done')
},
})
})
})
test("executes the on('message') callback", async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
provider.on('message', () => {
t.pass()
resolve('done')
})
})
})

28
tests/provider/onOpen.ts Normal file
View File

@@ -0,0 +1,28 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('onOpen callback is executed', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
newHocuspocusProvider(t, server, {
onOpen() {
t.pass()
resolve('done')
},
})
})
})
test("on('open') callback is executed", async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
provider.on('open', () => {
t.pass()
resolve('done')
})
})
})

View File

@@ -0,0 +1,21 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('executes the onStateless callback', async t => {
const payloadToSend = 'STATELESS-MESSAGE'
await new Promise(async resolve => {
newHocuspocus(t, {
async onStateless({ payload }) {
t.is(payload, payloadToSend)
t.pass()
resolve('done')
},
}).then(server => {
const provider = newHocuspocusProvider(t, server, {
onSynced: () => {
provider.sendStateless(payloadToSend)
},
})
})
})
})

109
tests/provider/onSynced.ts Normal file
View File

@@ -0,0 +1,109 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider, sleep } from '../utils/index.ts'
test('onSynced callback is executed', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
newHocuspocusProvider(t, server, {
onSynced() {
t.pass()
resolve('done')
},
})
})
})
test("on('synced') callback is executed", async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
const provider = newHocuspocusProvider(t, server)
provider.on('synced', () => {
t.pass()
resolve('done')
})
})
})
test('onSynced callback is executed, even when the onConnect takes longer', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onConnect(data) {
await sleep(100)
},
})
newHocuspocusProvider(t, server, {
onSynced() {
t.pass()
resolve('done')
},
})
})
})
test('onSynced callback is executed when the document is actually synced', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onLoadDocument({ document }) {
document.getArray('foo').insert(0, ['bar'])
return document
},
})
const provider = newHocuspocusProvider(t, server, {
onSynced() {
const value = provider.document.getArray('foo').get(0)
t.is(value, 'bar')
resolve('done')
},
})
})
})
test('send all messages according to the protocol', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onLoadDocument({ document }) {
document.getArray('foo').insert(0, ['bar'])
return document
},
})
const provider = newHocuspocusProvider(t, server, {
async onSynced() {
t.deepEqual(provider.document.getArray('foo').get(0), 'bar')
resolve('done')
},
})
})
})
test('onSynced callback is executed when the document is actually synced, even if it takes longer', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onLoadDocument({ document }) {
await sleep(100)
document.getArray('foo').insert(0, ['bar'])
return document
},
})
const provider = newHocuspocusProvider(t, server, {
onSynced() {
const value = provider.document.getArray('foo').get(0)
t.is(value, 'bar')
resolve('done')
},
})
})
})

View File

@@ -0,0 +1,18 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProviderWebsocket } from '../utils/index.ts'
test('has default configuration (maxDelay = 30000)', async t => {
const server = await newHocuspocus(t)
const client = newHocuspocusProviderWebsocket(t, server)
t.is(client.configuration.maxDelay, 30000)
})
test('overwrites the default configuration', async t => {
const server = await newHocuspocus(t)
const client = newHocuspocusProviderWebsocket(t, server, {
maxDelay: 10000,
})
t.is(client.configuration.maxDelay, 10000)
})

View File

@@ -0,0 +1,151 @@
import {
HocuspocusProviderWebsocket,
type onMaxAttemptsFailedParameters,
} from "@hocuspocus/provider";
import test from "ava";
import { sleep } from "../utils/index.ts";
const exhaustedRetryOptions = {
url: "ws://127.0.0.1:1",
maxAttempts: 1,
delay: 1,
minDelay: 1,
initialDelay: 0,
jitter: false,
};
test("does not produce an unhandled rejection when maxAttempts are exhausted", async (t) => {
const unhandled: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
t.teardown(() => {
process.off("unhandledRejection", onUnhandledRejection);
});
const ws = new HocuspocusProviderWebsocket(exhaustedRetryOptions);
t.teardown(() => ws.destroy());
await sleep(300);
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
t.is(unhandled.length, 0);
});
test("onMaxAttemptsFailed is executed when maxAttempts are exhausted", async (t) => {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("onMaxAttemptsFailed was not called"));
}, 2000);
const ws = new HocuspocusProviderWebsocket({
...exhaustedRetryOptions,
onMaxAttemptsFailed({ error }) {
clearTimeout(timeout);
t.truthy(error);
ws.destroy();
resolve("done");
},
});
t.teardown(() => ws.destroy());
});
});
test("on('maxAttemptsFailed') is executed when maxAttempts are exhausted", async (t) => {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("maxAttemptsFailed was not emitted"));
}, 2000);
const ws = new HocuspocusProviderWebsocket({
...exhaustedRetryOptions,
autoConnect: false,
});
t.teardown(() => ws.destroy());
ws.on("maxAttemptsFailed", ({ error }: onMaxAttemptsFailedParameters) => {
clearTimeout(timeout);
t.truthy(error);
ws.destroy();
resolve("done");
});
ws.connect();
});
});
// A server that accepts the upgrade and immediately closes the socket never
// makes the client emit an `error` event – only a `close` one.
class ClosingWebSocket {
binaryType = "arraybuffer";
identifier = 0;
listeners: Record<string, ((payload: any) => void)[]> = {};
constructor(_url: string) {
setTimeout(() => {
for (const handler of this.listeners.close ?? []) {
handler({ code: 1006, reason: "" });
}
}, 0);
}
addEventListener(name: string, handler: (payload: any) => void) {
this.listeners[name] = [...(this.listeners[name] ?? []), handler];
}
removeEventListener(name: string, handler: (payload: any) => void) {
this.listeners[name] = (this.listeners[name] ?? []).filter(
(listener) => listener !== handler,
);
}
close() {}
send() {}
}
test("emits maxAttemptsFailed when the socket closes without an error", async (t) => {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("maxAttemptsFailed was not emitted"));
}, 2000);
const ws = new HocuspocusProviderWebsocket({
...exhaustedRetryOptions,
WebSocketPolyfill: ClosingWebSocket,
onMaxAttemptsFailed({ error }) {
clearTimeout(timeout);
t.truthy(error);
ws.destroy();
resolve("done");
},
});
t.teardown(() => ws.destroy());
});
});
test("does not emit maxAttemptsFailed when the connection attempt is aborted", async (t) => {
let failed = false;
const ws = new HocuspocusProviderWebsocket({
url: "ws://127.0.0.1:1",
maxAttempts: 5,
delay: 50,
minDelay: 50,
initialDelay: 0,
jitter: false,
onMaxAttemptsFailed() {
failed = true;
},
});
t.teardown(() => ws.destroy());
// Give the first attempt a chance to fail, then stop retrying.
await sleep(60);
ws.disconnect();
await sleep(400);
t.false(failed);
});

View File

@@ -0,0 +1,160 @@
import test from 'ava'
import * as encoding from 'lib0/encoding'
import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'
import { newHocuspocus } from '../utils/index.ts'
/**
* Helper to create a raw message Uint8Array with a given documentName and messageType,
* matching the wire format: VarString(documentName) + VarUint(messageType) + optional payload.
*/
function createRawMessage(documentName: string, messageType: number, payload?: Uint8Array): Uint8Array {
const encoder = encoding.createEncoder()
encoding.writeVarString(encoder, documentName)
encoding.writeVarUint(encoder, messageType)
if (payload) {
encoding.writeVarUint8Array(encoder, payload)
}
return encoding.toUint8Array(encoder)
}
// MessageType enum values (matching provider/src/types.ts)
const MessageType = {
Sync: 0,
Awareness: 1,
Auth: 2,
QueryAwareness: 3,
Stateless: 5,
CLOSE: 7,
}
test('deduplicates awareness messages for the same document in the queue', async t => {
const server = await newHocuspocus(t)
const ws = new HocuspocusProviderWebsocket({
url: server.server!.webSocketURL,
autoConnect: false,
})
t.teardown(() => ws.destroy())
// Queue multiple awareness messages for the same document
const msg1 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([1]))
const msg2 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([2]))
const msg3 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([3]))
ws.send(msg1)
ws.send(msg2)
ws.send(msg3)
// Access the private messageQueue via cast
const queue = (ws as any).messageQueue as Uint8Array[]
t.is(queue.length, 1, 'should only have the latest awareness message')
t.deepEqual(queue[0], msg3, 'should keep the last queued awareness message')
})
test('deduplicates QueryAwareness messages for the same document in the queue', async t => {
const server = await newHocuspocus(t)
const ws = new HocuspocusProviderWebsocket({
url: server.server!.webSocketURL,
autoConnect: false,
})
t.teardown(() => ws.destroy())
const msg1 = createRawMessage('doc1', MessageType.QueryAwareness)
const msg2 = createRawMessage('doc1', MessageType.QueryAwareness)
ws.send(msg1)
ws.send(msg2)
const queue = (ws as any).messageQueue as Uint8Array[]
t.is(queue.length, 1, 'should only have the latest QueryAwareness message')
t.deepEqual(queue[0], msg2)
})
test('does not deduplicate awareness messages for different documents', async t => {
const server = await newHocuspocus(t)
const ws = new HocuspocusProviderWebsocket({
url: server.server!.webSocketURL,
autoConnect: false,
})
t.teardown(() => ws.destroy())
const msg1 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([1]))
const msg2 = createRawMessage('doc2', MessageType.Awareness, new Uint8Array([2]))
ws.send(msg1)
ws.send(msg2)
const queue = (ws as any).messageQueue as Uint8Array[]
t.is(queue.length, 2, 'should keep awareness messages for different documents')
})
test('does not deduplicate Sync messages', async t => {
const server = await newHocuspocus(t)
const ws = new HocuspocusProviderWebsocket({
url: server.server!.webSocketURL,
autoConnect: false,
})
t.teardown(() => ws.destroy())
const msg1 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([1]))
const msg2 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([2]))
const msg3 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([3]))
ws.send(msg1)
ws.send(msg2)
ws.send(msg3)
const queue = (ws as any).messageQueue as Uint8Array[]
t.is(queue.length, 3, 'should keep all Sync messages')
})
test('does not deduplicate Stateless messages', async t => {
const server = await newHocuspocus(t)
const ws = new HocuspocusProviderWebsocket({
url: server.server!.webSocketURL,
autoConnect: false,
})
t.teardown(() => ws.destroy())
const msg1 = createRawMessage('doc1', MessageType.Stateless, new Uint8Array([1]))
const msg2 = createRawMessage('doc1', MessageType.Stateless, new Uint8Array([2]))
ws.send(msg1)
ws.send(msg2)
const queue = (ws as any).messageQueue as Uint8Array[]
t.is(queue.length, 2, 'should keep all Stateless messages')
})
test('deduplicates awareness but preserves sync messages in mixed queue', async t => {
const server = await newHocuspocus(t)
const ws = new HocuspocusProviderWebsocket({
url: server.server!.webSocketURL,
autoConnect: false,
})
t.teardown(() => ws.destroy())
const sync1 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([1]))
const awareness1 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([10]))
const sync2 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([2]))
const awareness2 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([20]))
const sync3 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([3]))
ws.send(sync1)
ws.send(awareness1)
ws.send(sync2)
ws.send(awareness2)
ws.send(sync3)
const queue = (ws as any).messageQueue as Uint8Array[]
t.is(queue.length, 4, 'should have 3 sync + 1 awareness')
t.deepEqual(queue[0], sync1)
t.deepEqual(queue[1], sync2)
t.deepEqual(queue[2], awareness2, 'should keep the latest awareness message')
t.deepEqual(queue[3], sync3)
})

61
tests/server/address.ts Normal file
View File

@@ -0,0 +1,61 @@
import test from 'ava'
import { Server } from '@hocuspocus/server'
import { newHocuspocus } from '../utils/index.ts'
test('returns a dynamic HTTP/WebSocket address with the correct port', async t => {
const hocuspocus = await newHocuspocus(t, {
port: 4010,
})
t.is(hocuspocus.server!.address.port, 4010)
t.is(hocuspocus.server!.httpURL, 'http://0.0.0.0:4010')
t.is(hocuspocus.server!.webSocketURL, 'ws://0.0.0.0:4010')
t.pass()
})
test('binds to the configured address', async t => {
const server = new Server({
port: 0,
address: '127.0.0.1',
stopOnSignals: false,
})
t.teardown(() => server.httpServer.close())
await server.listen()
const addressInfo = server.httpServer.address()
t.truthy(addressInfo && typeof addressInfo === 'object')
if (addressInfo && typeof addressInfo === 'object') {
t.is(addressInfo.address, '127.0.0.1')
t.is(addressInfo.family, 'IPv4')
}
t.is(server.httpURL, `http://127.0.0.1:${server.address.port}`)
const response = await fetch(server.httpURL)
t.is(await response.text(), 'Welcome to Hocuspocus!')
})
test('binds to all interfaces when no address is configured', async t => {
const server = new Server({
port: 0,
stopOnSignals: false,
})
t.teardown(() => server.httpServer.close())
await server.listen()
const addressInfo = server.httpServer.address()
t.truthy(addressInfo && typeof addressInfo === 'object')
if (addressInfo && typeof addressInfo === 'object') {
t.true(['::', '0.0.0.0'].includes(addressInfo.address))
}
t.is(server.httpURL, `http://0.0.0.0:${server.address.port}`)
const response = await fetch(server.httpURL)
t.is(await response.text(), 'Welcome to Hocuspocus!')
})

View File

@@ -0,0 +1,102 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
test('afterHandleMessage is called after the update has been applied', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async afterHandleMessage({ document }) {
// contrary to beforeHandleMessage, the update is already applied here
if (document.getArray('foo').get(0) === 'bar') {
t.pass()
resolve('done')
}
},
})
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getArray('foo').insert(0, ['bar'])
},
})
})
})
test('afterHandleMessage is called once per handled message', async t => {
let beforeHandleMessageCount = 0
let afterHandleMessageCount = 0
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async beforeHandleMessage() {
beforeHandleMessageCount += 1
},
async afterHandleMessage() {
afterHandleMessageCount += 1
},
})
newHocuspocusProvider(t, server, {
onClose() {
t.fail()
},
})
newHocuspocusProvider(t, server, {
onClose() {
t.fail()
},
})
resolve('done')
})
await retryableAssertion(t, tt => {
tt.true(afterHandleMessageCount > 0)
tt.is(afterHandleMessageCount, beforeHandleMessageCount)
})
})
test('afterHandleMessage is not called when beforeHandleMessage rejects', async t => {
let afterHandleMessageCount = 0
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async beforeHandleMessage() {
throw new Error()
},
async afterHandleMessage() {
afterHandleMessageCount += 1
},
})
newHocuspocusProvider(t, server, {
onClose() {
resolve('done')
},
})
})
await retryableAssertion(t, tt => {
tt.is(afterHandleMessageCount, 0)
})
})
test('an exception thrown in afterHandleMessage does not close the connection', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async afterHandleMessage() {
throw new Error()
},
})
const provider = newHocuspocusProvider(t, server, {
onClose() {
t.fail()
},
onSynced() {
t.pass()
setTimeout(() => resolve('done'), 100)
},
})
})
})

View File

@@ -0,0 +1,65 @@
import test from 'ava'
import type { HocuspocusProvider } from '@hocuspocus/provider'
import { newHocuspocus, newHocuspocusProvider, sleep } from '../utils/index.ts'
test('executes the afterLoadDocument callback', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async afterLoadDocument() {
t.pass()
resolve('done')
},
})
newHocuspocusProvider(t, server, {})
})
})
test('executes the afterLoadDocument callback in an extension', async t => {
await new Promise(async resolve => {
let provider: HocuspocusProvider
class CustomExtension {
async afterLoadDocument() {
t.pass()
resolve('done')
}
}
const server = await newHocuspocus(t, {
extensions: [new CustomExtension()],
})
newHocuspocusProvider(t, server)
})
})
test('does not execute the afterLoadDocument callback when document fails to load', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
class CustomExtension {
async onLoadDocument() {
throw new Error('oops!')
}
async afterLoadDocument() {
t.fail('this should not be executed')
resolve('done')
}
}
server.configure({
extensions: [
new CustomExtension(),
],
})
newHocuspocusProvider(t, server)
await sleep(300)
t.pass()
resolve('')
})
})

View File

@@ -0,0 +1,47 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('calls the afterStoreDocument hook', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async afterStoreDocument() {
t.pass()
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onSynced() {
// Dummy change to trigger onStoreDocument
provider.document.getArray('foo').push(['foo'])
provider.configuration.websocketProvider.destroy()
provider.destroy()
},
})
})
})
test('executes afterStoreDocument callback from a custom extension', async t => {
await new Promise(async resolve => {
class CustomExtension {
async afterStoreDocument() {
t.pass()
resolve('done')
}
}
const server = await newHocuspocus(t, {
extensions: [
new CustomExtension(),
],
})
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getArray('foo').insert(0, ['bar'])
},
})
})
})

View File

@@ -0,0 +1,77 @@
import test from 'ava'
import type { HocuspocusProvider } from '@hocuspocus/provider'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('executes the afterUnloadDocument callback', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async afterUnloadDocument() {
t.pass()
resolve('done')
},
})
const p = newHocuspocusProvider(t, server, {
onSynced(data) {
p.configuration.websocketProvider.disconnect()
p.disconnect()
},
})
})
})
test('executes the afterUnloadDocument callback when all clients disconnect after a document was loaded', async t => {
await new Promise(async resolve => {
// eslint-disable-next-line prefer-const
let provider: HocuspocusProvider
class CustomExtension {
async afterLoadDocument() {
provider.configuration.websocketProvider.disconnect()
provider.disconnect()
}
async afterUnloadDocument() {
t.pass()
resolve('done')
}
}
const server = await newHocuspocus(t, {
extensions: [new CustomExtension()],
})
provider = newHocuspocusProvider(t, server)
})
})
test('does not execute the afterUnloadDocument callback when document fails to load', async t => {
await new Promise(async (resolve, reject) => {
const server = await newHocuspocus(t)
class CustomExtension {
async onLoadDocument() {
throw new Error('oops!')
}
async afterUnloadDocument() {
t.fail()
reject('should not be called')
}
}
server.configure({
extensions: [
new CustomExtension(),
],
})
newHocuspocusProvider(t, server)
setTimeout(() => {
t.pass()
resolve('done')
}, 500)
})
})

View File

@@ -0,0 +1,21 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('calls the beforeBroadcastStateless hook', async t => {
await new Promise(async resolve => {
const payloadToSend = 'STATELESS-MESSAGE'
const server = await newHocuspocus(t, {
async beforeBroadcastStateless({ payload }) {
t.is(payload, payloadToSend)
t.pass()
resolve('done')
},
})
newHocuspocusProvider(t, server, {
onSynced() {
server.documents.get('hocuspocus-test')?.broadcastStateless(payloadToSend)
},
})
})
})

View File

@@ -0,0 +1,239 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('beforeHandleAwareness is called before the awareness state is applied', async t => {
await new Promise(async resolve => {
let resolved = false
const server = await newHocuspocus(t, {
async beforeHandleAwareness({ awareness, states, connection }) {
if (resolved) return
resolved = true
// The decoded states from the inbound update are exposed as a
// mutable Map keyed by clientId. The document's own awareness has
// not received the update yet.
t.true(states instanceof Map)
t.true(states.size > 0)
t.truthy(connection, 'connection is forwarded per call')
t.is(awareness.getStates().size, 0)
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField('foo', 'bar')
},
})
})
})
test('mutating states in beforeHandleAwareness rewrites the update before it is applied', async t => {
await new Promise(async resolve => {
let resolved = false
const server = await newHocuspocus(t, {
async beforeHandleAwareness({ states }) {
// Stamp a server-side value over whatever the client sent.
for (const state of states.values()) {
state.user = 'Verified'
}
},
async onAwarenessUpdate({ states }) {
const v = states.find(s => s.user === 'Verified' && s.foo === 'bar')
if (resolved || !v) return
resolved = true
// Mutated value lands in awareness; original ('Spoofed') never appears.
t.is(v.user, 'Verified')
t.is(v.foo, 'bar')
t.falsy(states.find(s => s.user === 'Spoofed'))
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField('user', 'Spoofed')
provider.setAwarenessField('foo', 'bar')
},
})
})
})
test('chaining: a second extension sees mutations made by the first', async t => {
await new Promise(async resolve => {
let resolved = false
class FirstExtension {
async beforeHandleAwareness({ states }: { states: Map<number, Record<string, any>> }) {
for (const state of states.values()) {
state.user = 'first'
}
}
}
class SecondExtension {
async beforeHandleAwareness({ states }: { states: Map<number, Record<string, any>> }) {
// Sees `first`'s mutation, appends to it.
for (const state of states.values()) {
t.is(state.user, 'first', 'sees first extension mutation')
state.user = `${state.user}-then-second`
}
}
}
const server = await newHocuspocus(t, {
extensions: [new FirstExtension(), new SecondExtension()],
async onAwarenessUpdate({ states }) {
const v = states.find(s => s.user === 'first-then-second')
if (resolved || !v) return
resolved = true
// Both extension mutations made it through, in order.
t.is(v.user, 'first-then-second')
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField('user', 'unchanged-by-client')
},
})
})
})
test('chaining: extensions run before the config-level hook, in registration order', async t => {
await new Promise(async resolve => {
let resolved = false
class ExtensionA {
async beforeHandleAwareness({ states }: { states: Map<number, Record<string, any>> }) {
for (const state of states.values()) {
state.trail = `${state.trail ?? ''}A`
}
}
}
class ExtensionB {
async beforeHandleAwareness({ states }: { states: Map<number, Record<string, any>> }) {
for (const state of states.values()) {
state.trail = `${state.trail ?? ''}B`
}
}
}
const server = await newHocuspocus(t, {
extensions: [new ExtensionA(), new ExtensionB()],
async beforeHandleAwareness({ states }) {
// Config-level hook is pushed onto `extensions` as the final entry by
// `configure()`, so it runs AFTER both class extensions.
for (const state of states.values()) {
state.trail = `${state.trail ?? ''}C`
}
},
async onAwarenessUpdate({ states }) {
const v = states.find(s => s.trail === 'ABC')
if (resolved || !v) return
resolved = true
t.is(v.trail, 'ABC', 'extensions run first then the config-level hook')
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField('init', true)
},
})
})
})
test('throwing discards preceding extension mutations', async t => {
await new Promise(async resolve => {
let mutatorCalls = 0
let throwerCalls = 0
let onUpdateCalls = 0
class MutatingExtension {
async beforeHandleAwareness({ states }: { states: Map<number, Record<string, any>> }) {
mutatorCalls += 1
for (const state of states.values()) {
state.user = 'first'
}
}
}
class ThrowingExtension {
async beforeHandleAwareness() {
throwerCalls += 1
throw new Error('rejected')
}
}
const server = await newHocuspocus(t, {
extensions: [new MutatingExtension(), new ThrowingExtension()],
async onAwarenessUpdate({ states }) {
if (states.length > 0) onUpdateCalls += 1
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField('foo', 'bar')
},
})
setTimeout(() => {
t.true(mutatorCalls >= 1, 'preceding extension fired')
t.true(throwerCalls >= 1, 'throwing extension fired')
t.is(onUpdateCalls, 0, 'preceding mutations are not half-applied')
resolve('done')
}, 400)
})
})
test('throwing aborts subsequent extensions and the config-level hook', async t => {
await new Promise(async resolve => {
let throwerCalls = 0
let afterThrowerCalls = 0
let configCalls = 0
class ThrowingExtension {
async beforeHandleAwareness() {
throwerCalls += 1
throw new Error('rejected')
}
}
class AfterThrowerExtension {
async beforeHandleAwareness() {
afterThrowerCalls += 1
}
}
const server = await newHocuspocus(t, {
extensions: [new ThrowingExtension(), new AfterThrowerExtension()],
async beforeHandleAwareness() {
configCalls += 1
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField('foo', 'bar')
},
})
setTimeout(() => {
t.true(throwerCalls >= 1, 'throwing extension fired')
t.is(afterThrowerCalls, 0, 'extensions after the throw do not run')
t.is(configCalls, 0, 'config-level hook does not run')
resolve('done')
}, 400)
})
})

View File

@@ -0,0 +1,111 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
test('beforeHandleMessage gets called in proper order', async t => {
await new Promise(async resolve => {
const mockContext = {
user: 123,
}
const expectedValuesByCallNumber = [
undefined, // syncstep1
undefined, // syncstep2
'foo', // sync finished, value should be there now
]
let callNumber = 0
const server = await newHocuspocus(t, {
async onConnect() {
return mockContext
},
async beforeHandleMessage({ document, context }) {
t.deepEqual(context, mockContext)
const value = document.getArray('foo').get(0)
t.is(value, expectedValuesByCallNumber[callNumber])
callNumber += 1
if (callNumber === expectedValuesByCallNumber.length - 1) {
resolve('done')
}
},
async onChange({ context, document }) {
t.deepEqual(context, mockContext)
const value = document.getArray('foo').get(0)
t.is(value, expectedValuesByCallNumber[2])
},
})
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getArray('foo').insert(0, ['bar'])
},
})
})
})
test('beforeHandleMessage callback is called for every new client', async t => {
let onConnectCount = 0
let beforeHandleMessageCount = 0
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onConnect() {
onConnectCount += 1
},
async beforeHandleMessage() {
beforeHandleMessageCount += 1
},
})
newHocuspocusProvider(t, server, {
onClose() {
t.fail()
},
})
newHocuspocusProvider(t, server, {
onClose() {
t.fail()
},
})
resolve('done')
})
await retryableAssertion(t, tt => {
tt.is(onConnectCount, 2)
tt.is(beforeHandleMessageCount, 6) // 2x awareness per conn, 2x sync per conn (step 1 + 2)
})
})
test('an exception thrown in beforeHandleMessage closes the connection and discards queued messages', async t => {
let beforeHandleMessageCount = 0
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async beforeHandleMessage({ connection, update }) {
beforeHandleMessageCount += 1
if (beforeHandleMessageCount === 1) {
connection.handleMessage(update)
}
throw new Error()
},
})
newHocuspocusProvider(t, server, {
onClose() {
t.pass()
resolve('done')
},
})
})
t.is(beforeHandleMessageCount, 1)
})

162
tests/server/beforeSync.ts Normal file
View File

@@ -0,0 +1,162 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
test('beforeSync gets called in proper order', async t => {
await new Promise(async resolve => {
let resolved = false
const mockContext = {
user: 123,
}
let callNumber = 0
const server = await newHocuspocus(t, {
async onConnect() {
return mockContext
},
async beforeSync({ document, context, payload }) {
if (resolved) return
t.deepEqual(context, mockContext)
callNumber += 1
if (callNumber === 2) {
resolved = true
resolve('done')
}
},
async onChange({ context, document }) {
if (resolved) return
t.deepEqual(context, mockContext)
const value = document.getArray('foo').get(0)
t.is(value, 'bar')
},
})
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getArray('foo').insert(0, ['bar'])
},
})
})
})
test('beforeSync callback is called for every sync', async t => {
let onConnectCount = 0
let updateCount = 0
let syncstep1Count = 0
let syncstep2Count = 0
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onConnect() {
onConnectCount += 1
},
async beforeSync({ type }) {
if (type === 0){
syncstep1Count += 1
} else if (type === 1) {
syncstep2Count += 1
} else if (type === 2) {
updateCount += 1
}
},
})
await Promise.all([
new Promise(done => {
newHocuspocusProvider(t, server, {
onClose() {
t.fail()
},
onSynced() {
done('done')
},
})
}),
new Promise(done => {
newHocuspocusProvider(t, server, {
onClose() {
t.fail()
},
onSynced() {
done('done')
},
})
}),
])
resolve('done')
})
await retryableAssertion(t, tt => {
tt.is(onConnectCount, 2)
tt.is(syncstep1Count, 2)
tt.is(syncstep2Count, 2)
tt.is(updateCount, 0)
})
})
test('beforeSync callback is called on every update', async t => {
let onConnectCount = 0
let updateCount = 0
let syncstep1Count = 0
let syncstep2Count = 0
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onConnect() {
onConnectCount += 1
},
async beforeSync({ type }) {
if (type === 0){
syncstep1Count += 1
} else if (type === 1) {
syncstep2Count += 1
} else if (type === 2) {
updateCount += 1
}
},
})
await Promise.all([
new Promise(done => {
newHocuspocusProvider(t, server, {
onClose() {
t.fail()
},
onSynced() {
done('done')
},
})
}),
new Promise(done => {
const provider = newHocuspocusProvider(t, server, {
onClose() {
t.fail()
},
onSynced() {
provider.document.getArray('foo').insert(0, ['bar'])
done('done')
},
})
}),
])
resolve('done')
})
await retryableAssertion(t, tt => {
tt.is(onConnectCount, 2)
tt.is(syncstep1Count, 2)
tt.is(syncstep2Count, 2)
tt.is(updateCount, 1)
})
})

View File

@@ -0,0 +1,81 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import test from "ava";
import { newHocuspocus, newHocuspocusProvider } from "../utils/index.ts";
test("executes the beforeUnloadDocument callback", async (t) => {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async beforeUnloadDocument() {
t.pass();
resolve("done");
},
});
const p = newHocuspocusProvider(t, server, {
onSynced(data) {
p.destroy();
},
});
});
});
test("executes the beforeUnloadDocument callback when all clients disconnect after a document was loaded", async (t) => {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
await new Promise(async (resolve) => {
// eslint-disable-next-line prefer-const
let provider: HocuspocusProvider;
class CustomExtension {
async afterLoadDocument() {
provider.destroy();
}
async beforeUnloadDocument() {
t.pass();
resolve("done");
}
}
const server = await newHocuspocus(t, {
extensions: [new CustomExtension()],
});
provider = newHocuspocusProvider(t, server);
});
});
test("throwing an exception in beforeUnloadDocument prevents a document from being unloaded", async (t) => {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
await new Promise(async (resolve) => {
// eslint-disable-next-line prefer-const
let provider: HocuspocusProvider;
class CustomExtension {
async beforeUnloadDocument() {
throw new Error("my custom error");
}
async afterUnloadDocument() {
t.fail("should not be called");
}
}
const server = await newHocuspocus(t, {
extensions: [new CustomExtension()],
});
const p = newHocuspocusProvider(t, server, {
onSynced(data) {
p.destroy();
},
});
setTimeout(() => {
t.is(server.documents.size, 1);
t.pass();
resolve("done");
}, 500);
});
});

View File

@@ -0,0 +1,153 @@
import test from 'ava'
import { makeRoutingKey } from '@hocuspocus/common'
import { Document } from '@hocuspocus/server'
import * as decoding from 'lib0/decoding'
type FakeConnection = {
messageAddress: string
sent: Uint8Array[]
send: (message: Uint8Array) => void
}
/**
* Encoding is identical batched or not, so these tests opt out of batching to
* keep every assertion synchronous.
*/
const newDocument = () => new Document('hocuspocus-test', undefined, { flushDelay: false })
/**
* Registers a minimal stand-in for a Connection. The broadcast paths only read
* `messageAddress` and call `send`, so a duck-typed object is enough to observe
* what they produce.
*/
const addConnection = (document: Document, messageAddress: string): FakeConnection => {
const connection: FakeConnection = {
messageAddress,
sent: [],
send(message: Uint8Array) {
this.sent.push(message)
},
}
document.connections.set(connection as any, { clients: new Set() })
return connection
}
const readAddress = (message: Uint8Array) => decoding.readVarString(decoding.createDecoder(message))
test('encodes a document update once and shares it across connections', t => {
const document = newDocument()
const first = addConnection(document, 'hocuspocus-test')
const second = addConnection(document, 'hocuspocus-test')
const third = addConnection(document, 'hocuspocus-test')
document.getMap('test').set('foo', 'bar')
t.is(first.sent.length, 1)
t.is(second.sent.length, 1)
t.is(third.sent.length, 1)
// The same buffer instance reaches every connection, so it was encoded once.
t.is(first.sent[0], second.sent[0])
t.is(first.sent[0], third.sent[0])
})
test('encodes an awareness update once and shares it across connections', t => {
const document = newDocument()
const first = addConnection(document, 'hocuspocus-test')
const second = addConnection(document, 'hocuspocus-test')
document.awareness.setLocalState({ foo: 'bar' })
t.is(first.sent.length, 1)
t.is(second.sent.length, 1)
t.is(first.sent[0], second.sent[0])
})
test('encodes a stateless broadcast once and shares it across connections', t => {
const document = newDocument()
const first = addConnection(document, 'hocuspocus-test')
const second = addConnection(document, 'hocuspocus-test')
document.broadcastStateless('{"event":"document.saved"}')
t.is(first.sent.length, 1)
t.is(second.sent.length, 1)
t.is(first.sent[0], second.sent[0])
})
test('a filtered stateless broadcast only reaches matching connections', t => {
const document = newDocument()
const first = addConnection(document, 'hocuspocus-test')
const skipped = addConnection(document, 'hocuspocus-test')
const third = addConnection(document, 'hocuspocus-test')
document.broadcastStateless('{"event":"document.saved"}', connection => connection !== (skipped as any))
t.is(first.sent.length, 1)
t.is(skipped.sent.length, 0)
t.is(third.sent.length, 1)
// A skipped connection in the middle must not cost the others their shared buffer.
t.is(first.sent[0], third.sent[0])
})
test('addresses stateless broadcasts per connection', t => {
const document = newDocument()
const legacy = addConnection(document, 'hocuspocus-test')
const session = addConnection(document, makeRoutingKey('hocuspocus-test', 'session-a'))
document.broadcastStateless('{"event":"document.saved"}')
t.is(readAddress(legacy.sent[0]), 'hocuspocus-test')
t.is(readAddress(session.sent[0]), makeRoutingKey('hocuspocus-test', 'session-a'))
t.not(legacy.sent[0], session.sent[0])
})
test('reuses the buffer across consecutive connections sharing an address', t => {
const document = newDocument()
const legacy = addConnection(document, 'hocuspocus-test')
const sessionA = addConnection(document, makeRoutingKey('hocuspocus-test', 'session-a'))
const sessionAToo = addConnection(document, makeRoutingKey('hocuspocus-test', 'session-a'))
const sessionB = addConnection(document, makeRoutingKey('hocuspocus-test', 'session-b'))
document.getMap('test').set('foo', 'bar')
t.is(sessionA.sent[0], sessionAToo.sent[0])
t.not(legacy.sent[0], sessionA.sent[0])
t.not(sessionA.sent[0], sessionB.sent[0])
const buffers = new Set([legacy.sent[0], sessionA.sent[0], sessionAToo.sent[0], sessionB.sent[0]])
t.is(buffers.size, 3)
})
test('addresses every broadcast message to the receiving connection', t => {
const document = newDocument()
// Interleaved addresses: buffer reuse does not apply here, so this also
// covers the fallback path where every connection gets its own encode.
const connections = [
addConnection(document, makeRoutingKey('hocuspocus-test', 'session-a')),
addConnection(document, 'hocuspocus-test'),
addConnection(document, makeRoutingKey('hocuspocus-test', 'session-a')),
addConnection(document, makeRoutingKey('hocuspocus-test', 'session-b')),
]
document.getMap('test').set('foo', 'bar')
document.awareness.setLocalState({ foo: 'bar' })
for (const connection of connections) {
t.is(connection.sent.length, 2)
for (const message of connection.sent) {
t.is(readAddress(message), connection.messageAddress)
}
}
})

View File

@@ -0,0 +1,104 @@
import test from 'ava'
import * as encoding from 'lib0/encoding'
import { writeAuthentication } from '@hocuspocus/common'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
// Wire-level MessageType values (mirrors packages/server/src/types.ts).
const MessageType = {
Auth: 2,
Stateless: 5,
BroadcastStateless: 6,
}
const buildAuthFrame = (documentName: string, token: string): Uint8Array => {
const encoder = encoding.createEncoder()
encoding.writeVarString(encoder, documentName)
encoding.writeVarUint(encoder, MessageType.Auth)
writeAuthentication(encoder, token)
// providerVersion is optional on the server side, omit it.
return encoding.toUint8Array(encoder)
}
const buildBroadcastStatelessFrame = (documentName: string, payload: string): Uint8Array => {
const encoder = encoding.createEncoder()
encoding.writeVarString(encoder, documentName)
encoding.writeVarUint(encoder, MessageType.BroadcastStateless)
encoding.writeVarString(encoder, payload)
return encoding.toUint8Array(encoder)
}
const openRawSocket = (url: string): Promise<WebSocket> => new Promise((resolve, reject) => {
const ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.addEventListener('open', () => resolve(ws), { once: true })
ws.addEventListener('error', (event) => reject(event), { once: true })
})
test('rejects client-sent BroadcastStateless (opcode 6) — payload must not reach peers', async t => {
const documentName = 'hocuspocus-test'
const attackerPayload = '{"spoof":"server-system-banner","marker":"LITMUS"}'
let onStatelessCalls = 0
let beforeBroadcastStatelessCalls = 0
const server = await newHocuspocus(t, {
async onAuthenticate({ token, connectionConfig }) {
if (token === 'readonly') {
connectionConfig.readOnly = true
}
},
async onStateless() {
onStatelessCalls += 1
},
async beforeBroadcastStateless() {
beforeBroadcastStatelessCalls += 1
},
})
const received: string[] = []
const victimReady = new Promise<void>((resolve) => {
newHocuspocusProvider(t, server, {
name: documentName,
token: 'read+write',
onSynced: () => resolve(),
onStateless: ({ payload }) => {
received.push(payload)
},
})
})
await victimReady
// Attacker: raw WebSocket using a readOnly token. Sends a single
// BroadcastStateless (opcode 6) frame after a successful auth. The
// server must reject this server-internal opcode and not fan it out.
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
const authenticated = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('auth timeout')), 5000)
attacker.addEventListener('message', () => {
clearTimeout(timer)
resolve()
}, { once: true })
})
attacker.send(buildAuthFrame(documentName, 'readonly'))
await authenticated
attacker.send(buildBroadcastStatelessFrame(documentName, attackerPayload))
// Give the server time to (incorrectly) fan out, if the bug is present.
await new Promise((resolve) => setTimeout(resolve, 200))
t.false(
received.includes(attackerPayload),
'victim must not receive payloads from client-sent BroadcastStateless frames',
)
t.is(onStatelessCalls, 0, 'onStateless must not fire for opcode 6 (it is not the Stateless opcode)')
t.is(
beforeBroadcastStatelessCalls,
0,
'beforeBroadcastStateless must not fire because the frame is rejected before fan-out',
)
})

View File

@@ -0,0 +1,85 @@
import test from 'ava'
import { WebSocketStatus } from '@hocuspocus/provider'
import {
newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket, sleep,
} from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
// test('closes all connections', async t => {
// const server = await newHocuspocus(t)
// const socket = newHocuspocusProviderWebsocket(t, server)
// const socket2 = newHocuspocusProviderWebsocket(t, server)
// const provider = newHocuspocusProvider(t, server, {
// name: 'hocuspocus-test',
// onClose() {
// // Make sure it doesn’t reconnect.
// socket.disconnect()
// },
// websocketProvider: socket,
// })
// const anotherProvider = newHocuspocusProvider(t, server, {
// name: 'hocuspocus-test-2',
// onClose() {
// // Make sure it doesn’t reconnect.
// socket2.disconnect()
// },
// websocketProvider: socket2,
// })
// await sleep(100)
// server.closeConnections()
// t.is(server.documents.size, 1)
// })
test('closes a specific connection when a documentName is passed', async t => {
const server = await newHocuspocus(t)
const socket = newHocuspocusProviderWebsocket(t, server)
const socket2 = newHocuspocusProviderWebsocket(t, server)
const provider = newHocuspocusProvider(t, server, {
name: 'hocuspocus-test',
onClose() {
// Make sure it doesn’t reconnect.
socket.disconnect()
},
websocketProvider: socket,
})
const anotherProvider = newHocuspocusProvider(t, server, {
name: 'hocuspocus-test-2',
websocketProvider: socket2,
})
await sleep(100)
server.closeConnections('hocuspocus-test')
await retryableAssertion(t, tt => {
tt.is(socket.status, WebSocketStatus.Disconnected)
tt.is(socket2.status, WebSocketStatus.Connected)
})
})
// test('uses a proper close event', async t => {
// await new Promise(async resolve => {
// const server = await newHocuspocus(t)
// newHocuspocusProvider(t, server, {
// name: 'hocuspocus-test',
// onSynced() {
// server.closeConnections()
// },
// onClose({ event }) {
// // Make sure it doesn’t reconnect.
// t.is(event.code, 1000)
// t.is(event.reason, 'Reset Connection')
// resolve('done')
// },
// })
// })
// })

31
tests/server/debounce.ts Normal file
View File

@@ -0,0 +1,31 @@
import { useDebounce } from "@hocuspocus/server";
import test from "ava";
import { sleep } from "../utils/index.ts";
test("maxDebounce does not queue an execution per call while a slow execution is in flight", async (t) => {
const { debounce } = useDebounce();
let executions = 0;
const func = async () => {
executions += 1;
// the first execution is slower than maxDebounce, simulating a
// database/S3 latency spike during a store
await sleep(executions === 1 ? 500 : 10);
};
// Continuous calls, like a busy collaborative document: each call resets
// the debounce timeout, so only the maxDebounce branch triggers executions
// (at most one per 200ms).
const end = Date.now() + 1_000;
while (Date.now() < end) {
debounce("doc", func, 50, 200);
await sleep(20);
}
await sleep(600); // let in-flight and queued executions finish
// ~1s of continuous calls with maxDebounce=200 should execute ~5 times.
// If run() suspends on the in-flight execution before deleting the timer
// entry, the stale entry keeps satisfying the maxDebounce age check and
// every call during the slow execution queues another run (~25 executions).
t.true(executions <= 8, `executed ${executions} times, expected ~5`);
});

27
tests/server/destroy.ts Normal file
View File

@@ -0,0 +1,27 @@
import test from "ava";
import { Server } from "@hocuspocus/server";
test("destroy only runs once when called multiple times", async (t) => {
let destroyed = 0;
const server = new Server({
port: 0,
quiet: true,
stopOnSignals: false,
async onDestroy() {
destroyed += 1;
},
});
t.teardown(() => server.httpServer.close());
await server.listen();
// Concurrent and subsequent calls (e.g. from repeated SIGINT signals)
// must not re-run the shutdown, which would close already-closed
// resources in extensions.
await Promise.all([server.destroy(), server.destroy()]);
await server.destroy();
t.is(destroyed, 1);
});

268
tests/server/flushDelay.ts Normal file
View File

@@ -0,0 +1,268 @@
import test from 'ava'
import { Document } from '@hocuspocus/server'
import * as decoding from 'lib0/decoding'
import * as Y from 'yjs'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
type FakeConnection = {
messageAddress: string
sent: Uint8Array[]
send: (message: Uint8Array) => void
}
const addConnection = (document: Document): FakeConnection => {
const connection: FakeConnection = {
messageAddress: document.name,
sent: [],
send(message: Uint8Array) {
this.sent.push(message)
},
}
document.connections.set(connection as any, { clients: new Set() })
return connection
}
/** Resolves after the flush scheduled with `flushDelay: 0` has run. */
const tick = () => new Promise(resolve => setImmediate(resolve))
const readUpdate = (message: Uint8Array) => {
const decoder = decoding.createDecoder(message)
decoding.readVarString(decoder) // message address
decoding.readVarUint(decoder) // MessageType.Sync
decoding.readVarUint(decoder) // messageYjsUpdate
return decoding.readVarUint8Array(decoder)
}
const readAwarenessStates = (message: Uint8Array) => {
const decoder = decoding.createDecoder(message)
decoding.readVarString(decoder) // message address
decoding.readVarUint(decoder) // MessageType.Awareness
const payload = decoding.createDecoder(decoding.readVarUint8Array(decoder))
const states = new Map<number, any>()
const count = decoding.readVarUint(payload)
for (let i = 0; i < count; i += 1) {
const clientId = decoding.readVarUint(payload)
decoding.readVarUint(payload) // clock
states.set(clientId, JSON.parse(decoding.readVarString(payload)))
}
return states
}
test('sends one message per change when flushDelay is false', t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: false })
const connection = addConnection(document)
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
document.getMap('test').set('c', 3)
t.is(connection.sent.length, 3)
})
test('batches by default', async t => {
const document = new Document('hocuspocus-test')
const connection = addConnection(document)
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
t.is(document.flushDelay, 0)
t.is(connection.sent.length, 0)
await tick()
t.is(connection.sent.length, 1)
})
test('a server batches by default', async t => {
const server = await newHocuspocus(t)
t.is(server.configuration.flushDelay, 0)
})
test('merges updates from the same tick into a single message', async t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const connection = addConnection(document)
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
document.getMap('test').set('c', 3)
// Nothing goes out synchronously; the flush is scheduled for the end of the turn.
t.is(connection.sent.length, 0)
await tick()
t.is(connection.sent.length, 1)
})
test('the merged message carries every buffered change', async t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const connection = addConnection(document)
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
document.getMap('test').set('c', 3)
await tick()
const target = new Y.Doc()
Y.applyUpdate(target, readUpdate(connection.sent[0]))
const map = target.getMap('test')
t.is(map.get('a'), 1)
t.is(map.get('b'), 2)
t.is(map.get('c'), 3)
})
test('collapses awareness changes to the latest state of each client', async t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const connection = addConnection(document)
document.awareness.setLocalState({ cursor: 1 })
document.awareness.setLocalState({ cursor: 2 })
document.awareness.setLocalState({ cursor: 3 })
t.is(connection.sent.length, 0)
await tick()
t.is(connection.sent.length, 1)
const states = readAwarenessStates(connection.sent[0])
t.is(states.size, 1)
t.is(states.get(document.clientID).cursor, 3)
})
test('flushes early once flushMaxBytes is buffered', t => {
const document = new Document('hocuspocus-test', undefined, {
flushDelay: 0,
flushMaxBytes: 1,
})
const connection = addConnection(document)
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
// Every update trips the limit, so each is sent without waiting for the flush.
t.is(connection.sent.length, 2)
})
test('calls onUpdate synchronously for every change even when batching', t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const connection = addConnection(document)
let updates = 0
document.callbacks.onUpdate = () => {
updates += 1
}
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
document.getMap('test').set('c', 3)
// The broadcast is deferred, the hook that drives onChange / onStoreDocument
// and the Redis publish is not.
t.is(updates, 3)
t.is(connection.sent.length, 0)
})
test('flush is a no-op when nothing is pending', t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const connection = addConnection(document)
document.flush()
document.flush()
t.is(connection.sent.length, 0)
})
test('destroy flushes what is still buffered instead of dropping it', async t => {
const batched = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const batchedConnection = addConnection(batched)
batched.getMap('test').set('a', 1)
t.is(batchedConnection.sent.length, 0)
batched.destroy()
// Everything is out by the time destroy returns, and nothing is scheduled
// afterwards.
const afterDestroy = batchedConnection.sent.length
await tick()
t.is(batchedConnection.sent.length, afterDestroy)
// The same document without batching emits exactly as many messages: the
// buffered update plus the awareness teardown that destroy triggers.
const unbatched = new Document('hocuspocus-test', undefined, { flushDelay: false })
const unbatchedConnection = addConnection(unbatched)
unbatched.getMap('test').set('a', 1)
unbatched.destroy()
t.is(batchedConnection.sent.length, unbatchedConnection.sent.length)
})
test('a batching server still converges on connected clients', async t => {
const server = await newHocuspocus(t, { flushDelay: 0 })
const writer = newHocuspocusProvider(t, server, { name: 'flush-converge' })
const reader = newHocuspocusProvider(t, server, { name: 'flush-converge' })
await new Promise(resolve => reader.on('synced', () => resolve('done')))
writer.document.getMap('test').set('a', 1)
writer.document.getMap('test').set('b', 2)
writer.document.getMap('test').set('c', 3)
await retryableAssertion(t, tt => {
const map = reader.document.getMap('test')
tt.is(map.get('a'), 1)
tt.is(map.get('b'), 2)
tt.is(map.get('c'), 3)
})
})
test('onChange still fires per change when batching is enabled', async t => {
let changes = 0
const server = await newHocuspocus(t, {
flushDelay: 0,
async onChange() {
changes += 1
},
})
const provider = newHocuspocusProvider(t, server, {
name: 'flush-onchange',
awareness: null,
})
await new Promise(resolve => provider.on('synced', () => resolve('done')))
provider.document.getMap('test').set('a', 1)
provider.document.getMap('test').set('b', 2)
provider.document.getMap('test').set('c', 3)
await retryableAssertion(t, tt => {
tt.is(changes, 3)
})
})

View File

@@ -0,0 +1,130 @@
import test from 'ava'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
import { newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket } from '../utils/index.ts'
test('returns 0 connections when there’s no one connected', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
t.is(server.getConnectionsCount(), 0)
resolve('done')
})
})
test('close connection open when it fails', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onConnect() {
throw new Error()
},
})
newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
t.is(server.getConnectionsCount(), 0)
resolve('done')
},
})
})
})
test('dont close connection open when it fails but socket is external', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onConnect() {
throw new Error()
},
})
newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
t.is(server.getConnectionsCount(), 0)
resolve('done')
},
})
})
})
test('outputs the total connections', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
newHocuspocusProvider(t, server, {
onSynced() {
t.is(server.getConnectionsCount(), 1)
newHocuspocusProvider(t, server, {
onSynced() {
t.is(server.getConnectionsCount(), 2)
resolve('done')
},
})
},
})
})
})
test('total connections includes direct connections', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, { name: 'hocuspocus-test' })
await server.openDirectConnection('hocuspocus-test')
t.is(server.getConnectionsCount(), 1)
newHocuspocusProvider(t, server, {
onSynced() {
t.is(server.getConnectionsCount(), 2)
resolve('done')
},
})
})
})
test('adds and removes connections properly', async t => {
const server = await newHocuspocus(t)
const providers = [
newHocuspocusProvider(t, server),
newHocuspocusProvider(t, server),
newHocuspocusProvider(t, server),
newHocuspocusProvider(t, server),
newHocuspocusProvider(t, server),
]
await retryableAssertion(t, tt => {
tt.is(server.getConnectionsCount(), 5)
})
providers.forEach(provider => { provider.disconnect(); provider.configuration.websocketProvider.disconnect() })
await retryableAssertion(t, tt => {
tt.is(server.getConnectionsCount(), 0)
})
})
test('multiplexed connections counts properly', async t => {
const server = await newHocuspocus(t)
const socket = newHocuspocusProviderWebsocket(t, server)
const providers = [
newHocuspocusProvider(t, server, { name: 'mux-1' }, {}, socket),
newHocuspocusProvider(t, server, { name: 'mux-2' }, {}, socket),
newHocuspocusProvider(t, server, { name: 'mux-3' }, {}, socket),
newHocuspocusProvider(t, server),
newHocuspocusProvider(t, server),
]
await retryableAssertion(t, tt => {
tt.is(server.getConnectionsCount(), 3)
})
providers.forEach(provider => { provider.disconnect(); provider.configuration.websocketProvider.disconnect() })
await retryableAssertion(t, tt => {
tt.is(server.getConnectionsCount(), 0)
})
})

View File

@@ -0,0 +1,93 @@
import test from 'ava'
import {
newHocuspocus, newHocuspocusProvider, randomInteger,
} from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
test('documents count is zero by default', async t => {
const server = await newHocuspocus(t)
t.is(server.getDocumentsCount(), 0)
})
test('documents count is 1 when one provider is connected', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
newHocuspocusProvider(t, server, {
onSynced() {
t.is(server.getDocumentsCount(), 1)
resolve('done')
},
})
})
})
test('the same document name counts as one document', async t => {
const server = await newHocuspocus(t)
const providers = [
newHocuspocusProvider(t, server, { name: 'foobar' }),
newHocuspocusProvider(t, server, { name: 'foobar' }),
]
await retryableAssertion(t, tt => {
tt.is(server.getDocumentsCount(), 1)
})
providers.forEach(provider => { provider.disconnect(); provider.configuration.websocketProvider.disconnect() })
await retryableAssertion(t, tt => {
tt.is(server.getConnectionsCount(), 0)
})
})
test('adds and removes different documents properly', async t => {
const server = await newHocuspocus(t)
const providers = [
newHocuspocusProvider(t, server, { name: 'foo-1' }),
newHocuspocusProvider(t, server, { name: 'foo-2' }),
newHocuspocusProvider(t, server, { name: 'foo-3' }),
newHocuspocusProvider(t, server, { name: 'foo-4' }),
newHocuspocusProvider(t, server, { name: 'foo-5' }),
]
await retryableAssertion(t, tt => {
tt.is(server.getDocumentsCount(), 5)
})
providers.forEach(provider => { provider.disconnect(); provider.configuration.websocketProvider.disconnect() })
await retryableAssertion(t, tt => {
tt.is(server.getConnectionsCount(), 0)
})
})
test('adds and removes random number of documents properly', async t => {
// random number of providers
const server = await newHocuspocus(t)
const numberOfProviders = randomInteger(10, 100)
const providers = []
for (let index = 0; index < numberOfProviders; index += 1) {
providers.push(
newHocuspocusProvider(t, server, { name: `foobar-${index}` }),
)
}
await retryableAssertion(t, tt => {
tt.is(server.getDocumentsCount(), numberOfProviders)
})
// random number of disconnects
const numberOfDisconnects = randomInteger(1, numberOfProviders)
for (let index = 0; index < numberOfDisconnects; index += 1) {
providers[index].disconnect()
providers[index].configuration.websocketProvider.disconnect()
}
// check the count
await retryableAssertion(t, tt => {
tt.is(server.getConnectionsCount(), numberOfProviders - numberOfDisconnects)
})
})

105
tests/server/listen.ts Normal file
View File

@@ -0,0 +1,105 @@
import test from 'ava'
import { Server } from '@hocuspocus/server'
import { newHocuspocus } from '../utils/index.ts'
test('should respond with OK', async t => {
const hocuspocus = await newHocuspocus(t)
const response = await fetch(hocuspocus.server!.httpURL)
t.is(await response.text(), 'Welcome to Hocuspocus!')
})
test('should respond with status 200', async t => {
const hocuspocus = await newHocuspocus(t)
const response = await fetch(hocuspocus.server!.httpURL)
t.is(await response.status, 200)
})
test('should respond with OK on a custom port', async t => {
const hocuspocus = await newHocuspocus(t, {
port: 4000,
})
const response = await fetch(hocuspocus.server!.httpURL)
t.is(hocuspocus.server!.address.port, 4000)
t.is(await response.text(), 'Welcome to Hocuspocus!')
})
test('should respond with OK on a custom port passed to listen()', async t => {
const server = new Server({
port: 0,
stopOnSignals: false,
})
t.teardown(() => server.httpServer.close())
server.listen(4001)
const response = await fetch(server.httpURL)
t.is(server.address.port, 4001)
t.is(await response.text(), 'Welcome to Hocuspocus!')
})
test('should take a custom port and a callback', async t => {
const server = new Server({
port: 0,
stopOnSignals: false,
})
t.teardown(() => server.httpServer.close())
await new Promise(async resolve => {
server.listen(4002, () => {
resolve('done')
})
})
const response = await fetch(server.httpURL)
t.is(server.address.port, 4002)
t.is(await response.text(), 'Welcome to Hocuspocus!')
})
test('should execute a callback', async t => {
const server = new Server({
port: 0,
stopOnSignals: false,
})
t.teardown(() => server.httpServer.close())
await new Promise(async resolve => {
server.listen(0, async () => {
resolve('done')
})
})
const response = await fetch(server.httpURL)
t.is(await response.text(), 'Welcome to Hocuspocus!')
})
test('should have the custom port as a parameter in the callback', async t => {
const server = new Server({
port: 0,
stopOnSignals: false,
})
t.teardown(() => server.httpServer.close())
await new Promise(async resolve => {
server.listen(0, async ({ port }: any) => {
t.is(port, server.address.port)
resolve('done')
})
})
const response = await fetch(server.httpURL)
t.is(await response.text(), 'Welcome to Hocuspocus!')
})

View File

@@ -0,0 +1,333 @@
import { WebSocketStatus } from "@hocuspocus/provider";
import type {
onAuthenticatePayload,
onLoadDocumentPayload,
} from "@hocuspocus/server";
import test from "ava";
import {
newHocuspocus,
newHocuspocusProvider,
newHocuspocusProviderWebsocket,
sleep,
} from "../utils/index.ts";
import { retryableAssertion } from "../utils/retryableAssertion.ts";
test("executes the onAuthenticate callback", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
t.pass();
resolve("done");
},
});
newHocuspocusProvider(t, server, {
token: "SUPER-SECRET-TOKEN",
});
});
});
test("executes the onAuthenticate callback from a custom extension", async (t) => {
await new Promise(async (resolve) => {
class CustomExtension {
async onAuthenticate() {
t.pass();
resolve("done");
}
}
const server = await newHocuspocus(t, {
extensions: [new CustomExtension()],
});
newHocuspocusProvider(t, server, {
token: "SUPER-SECRET-TOKEN",
});
});
});
test("confirms the `Token` message with an `Authenticated` message", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
// success
return true;
},
});
newHocuspocusProvider(t, server, {
token: "SUPER-SECRET-TOKEN",
onAuthenticated() {
t.pass();
resolve("done");
},
});
});
});
test("replies with a `PermissionDenied` message when authentication fails", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
// fail
throw Error();
},
});
newHocuspocusProvider(t, server, {
token: "SUPER-SECRET-TOKEN",
onAuthenticationFailed() {
t.pass();
resolve("done");
},
});
});
});
test("passes context from onAuthenticate to onLoadDocument", async (t) => {
await new Promise(async (resolve) => {
const mockContext = {
user: 123,
};
const server = await newHocuspocus(t, {
async onAuthenticate() {
return mockContext;
},
async onLoadDocument({ context }: onLoadDocumentPayload) {
t.deepEqual(context, mockContext);
resolve("done");
},
});
newHocuspocusProvider(t, server, {
token: "SUPER-SECRET-TOKEN",
});
});
});
test("ignores the authentication token when having no onAuthenticate hook", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
newHocuspocusProvider(t, server, {
token: "SUPER-SECRET-TOKEN",
onOpen() {
t.pass();
resolve("done");
},
});
});
});
test("has the authentication token", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate({ token }: onAuthenticatePayload) {
t.is(token, "SUPER-SECRET-TOKEN");
resolve("done");
},
});
newHocuspocusProvider(t, server, {
token: "SUPER-SECRET-TOKEN",
});
});
});
test("does not disconnect provider when the onAuthenticate hook throws an Error", async (t) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
throw new Error();
},
// MUST NOT BE CALLED
async onLoadDocument() {
t.fail(
"WARNING: When onAuthenticate fails onLoadDocument must not be called.",
);
},
});
const provider = newHocuspocusProvider(t, server, {
onClose() {
t.fail();
},
token: "SUPER-SECRET-TOKEN",
});
await retryableAssertion(t, (tt) => {
tt.is(
provider.configuration.websocketProvider.status,
WebSocketStatus.Connected,
);
tt.is(server.getDocumentsCount(), 0);
tt.is(server.getConnectionsCount(), 0);
});
});
test("connects with the correct token", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate({ token }: onAuthenticatePayload) {
if (token !== "SUPER-SECRET-TOKEN") {
throw new Error();
}
},
async onLoadDocument() {
t.pass();
resolve("done");
},
});
newHocuspocusProvider(t, server, {
token: "SUPER-SECRET-TOKEN",
});
});
});
test("onAuthenticate has access to document name", async (t) => {
const docName = "superSecretDoc";
const requiredToken = "SUPER-SECRET-TOKEN";
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate({ token, documentName }: onAuthenticatePayload) {
if (documentName !== docName) {
throw new Error();
}
if (token !== requiredToken) {
throw new Error();
}
},
});
newHocuspocusProvider(t, server, {
token: requiredToken,
name: docName,
onAuthenticated() {
t.pass();
resolve("done");
},
});
});
});
test("onAuthenticate wrong auth only disconnects affected doc (when multiplexing)", async (t) => {
const docName = "superSecretDoc";
const requiredToken = "SUPER-SECRET-TOKEN";
const server = await newHocuspocus(t, {
async onAuthenticate({ token, documentName }: onAuthenticatePayload) {
if (documentName !== docName) {
throw new Error();
}
if (token !== requiredToken) {
throw new Error();
}
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const providerFail = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: "wrongToken",
name: "otherDocu",
onAuthenticated() {
t.fail();
},
});
await sleep(100);
const providerOK = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: requiredToken,
name: docName,
onAuthenticationFailed() {
t.fail();
},
});
await retryableAssertion(t, (tt) => {
tt.is(socket.status, WebSocketStatus.Connected);
tt.is(server.getDocumentsCount(), 1);
tt.is(server.getConnectionsCount(), 1);
});
});
test("onAuthenticate readonly auth only affects 1 doc (when multiplexing)", async (t) => {
const server = await newHocuspocus(t, {
async onAuthenticate({
token,
documentName,
connectionConfig,
}: onAuthenticatePayload) {
if (token === "readonly") {
connectionConfig.readOnly = true;
}
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const providerReadOnly = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: "readonly",
name: "doc1",
onAuthenticationFailed() {
t.fail();
},
});
const providerOK = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: "read+write",
name: "doc2",
onAuthenticationFailed() {
t.fail();
},
});
await retryableAssertion(t, (tt) => {
tt.is(socket.status, WebSocketStatus.Connected);
tt.is(socket.status, WebSocketStatus.Connected);
tt.is(server.getDocumentsCount(), 2);
tt.is(server.getConnectionsCount(), 1);
tt.is(socket.status, WebSocketStatus.Connected);
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
t.is(
server.documents.get("doc1")!.connections.keys().next().value!
.readOnly,
true,
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
t.is(
server.documents.get("doc2")!.connections.keys().next().value!
.readOnly,
false,
);
});
test("onAuthenticate is called even if no token is provided", async (t) => {
const docName = "superSecretDoc";
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate({ documentName }: onAuthenticatePayload) {
t.pass();
resolve("done");
},
});
newHocuspocusProvider(t, server, {
name: docName,
});
});
});

View File

@@ -0,0 +1,85 @@
import test from 'ava'
import type { onAwarenessUpdatePayload } from '@hocuspocus/server'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('executes the onAwarenessUpdate callback', async t => {
await new Promise(async resolve => {
let resolved = false
const server = await newHocuspocus(t, {
async onAwarenessUpdate({ states }) {
if (resolved) return
resolved = true
t.is(states.length, 1)
t.is(states[0].foo, 'bar')
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField('foo', 'bar')
},
})
})
})
test('executes the onAwarenessUpdate callback from a custom extension', async t => {
await new Promise(async resolve => {
let resolved = false
class CustomExtension {
async onAwarenessUpdate({ states }: onAwarenessUpdatePayload) {
if (resolved) return
resolved = true
t.is(states.length, 1)
t.is(states[0].foo, 'bar')
resolve('done')
}
}
const server = await newHocuspocus(t, {
extensions: [
new CustomExtension(),
],
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.setAwarenessField('foo', 'bar')
},
})
})
})
test('forwards the originating connection on onAwarenessUpdate', async t => {
await new Promise(async resolve => {
let resolved = false
const server = await newHocuspocus(t, {
async onAuthenticate() {
return { user: { id: 'u-1', displayName: 'Test User' } }
},
async onAwarenessUpdate({ connection, states }) {
if (resolved || states.length === 0) return
resolved = true
t.truthy(connection, 'connection should be defined for client-originated awareness updates')
t.is((connection?.context as { user: { id: string } } | undefined)?.user?.id, 'u-1')
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
token: 'anything',
onConnect() {
provider.setAwarenessField('foo', 'bar')
},
})
})
})

158
tests/server/onChange.ts Normal file
View File

@@ -0,0 +1,158 @@
import test from 'ava'
import type { onChangePayload } from '@hocuspocus/server'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
test('onChange callback receives updates', async t => {
await new Promise(async resolve => {
let resolved = false
const mockContext = {
user: 123,
}
const server = await newHocuspocus(t, {
async onConnect() {
return mockContext
},
async onChange({ document, context }) {
if (resolved) return
resolved = true
t.deepEqual(context, mockContext)
const value = document.getArray('foo').get(0)
t.is(value, 'bar')
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getArray('foo').insert(0, ['bar'])
},
})
})
})
test('executes onChange callback from an extension', async t => {
await new Promise(async resolve => {
let resolved = false
class CustomExtension {
async onChange({ document }: onChangePayload) {
if (resolved) return
resolved = true
const value = document.getArray('foo').get(0)
t.is(value, 'bar')
resolve('done')
}
}
const server = await newHocuspocus(t, {
extensions: [
new CustomExtension(),
],
})
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getArray('foo').insert(0, ['bar'])
},
})
})
})
test('onChange callback is not called after onLoadDocument', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onChange(data) {
t.fail()
},
async onLoadDocument({ document }) {
document.getArray('foo').insert(0, ['bar'])
return document
},
})
newHocuspocusProvider(t, server, {
onSynced() {
t.pass()
resolve('done')
},
})
})
})
test('has the server instance', async t => {
await new Promise(async resolve => {
let resolved = false
const server = await newHocuspocus(t, {
async onChange({ instance }) {
if (resolved) return
resolved = true
t.is(instance, server)
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getArray('foo').insert(0, ['bar'])
},
})
})
})
test('onChange callback isn\'t called for every new client', async t => {
let onConnectCount = 0
let onChangeCount = 0
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onConnect() {
onConnectCount += 1
},
async onChange() {
onChangeCount += 1
},
})
newHocuspocusProvider(t, server)
newHocuspocusProvider(t, server)
resolve('done')
})
await retryableAssertion(t, tt => {
tt.is(onConnectCount, 2)
tt.is(onChangeCount, 0)
})
})
test('onChange works properly for changes from direct connections', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
name: 'hocuspocus-test',
async onChange(data) {
resolve('')
t.pass()
},
})
const conn = await server.openDirectConnection('hocuspocus-test')
t.is(server.getConnectionsCount(), 1)
conn.transact(doc => {
doc.getMap('t').set('g', 'b')
})
})
})

70
tests/server/onClose.ts Normal file
View File

@@ -0,0 +1,70 @@
import test from 'ava'
import { WebSocketStatus } from '@hocuspocus/provider'
import { newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
test('server closes connection when receiving close event from provider', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {})
const socket = newHocuspocusProviderWebsocket(t, server, {})
const provider1 = newHocuspocusProvider(t, server, {
websocketProvider: socket,
name: 'hocuspocus-test',
})
await retryableAssertion(t, t2 => {
t2.is(server.getConnectionsCount(), 1)
})
await retryableAssertion(t, t2 => {
provider1.destroy()
t2.is(server.getConnectionsCount(), 0)
})
resolve('ok')
})
})
test('server doesnt close connection after receiving close event from all connections', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {})
const socket = newHocuspocusProviderWebsocket(t, server, {})
const provider1 = newHocuspocusProvider(t, server, {
websocketProvider: socket,
name: 'hocuspocus-test',
})
const provider2 = newHocuspocusProvider(t, server, {
websocketProvider: socket,
name: 'hocuspocus-test2',
})
await retryableAssertion(t, t2 => {
t2.is(server.getConnectionsCount(), 1)
})
socket.shouldConnect = false
provider1.destroy()
t.is(provider1.configuration.websocketProvider.status, WebSocketStatus.Connected)
t.is(provider2.configuration.websocketProvider.status, WebSocketStatus.Connected)
setTimeout(async () => {
t.is(server.getConnectionsCount(), 1)
provider2.destroy()
t.is(provider1.configuration.websocketProvider.status, WebSocketStatus.Connected)
t.is(provider2.configuration.websocketProvider.status, WebSocketStatus.Connected)
await retryableAssertion(t, t2 => {
t2.is(server.getConnectionsCount(), 1)
t2.is(provider1.configuration.websocketProvider.status, WebSocketStatus.Connected)
t2.is(provider2.configuration.websocketProvider.status, WebSocketStatus.Connected)
})
resolve('ok')
}, 200)
})
})

View File

@@ -0,0 +1,62 @@
import test from 'ava'
import type { Hocuspocus } from '@hocuspocus/server'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('onConfigure callback is executed', async t => {
await new Promise(async resolve => {
let givenInstance = null
const server = await newHocuspocus(t, {
async onConfigure({ instance }) {
givenInstance = instance
},
})
t.is(givenInstance as unknown as Hocuspocus, server)
resolve('done')
})
})
test('executes onConfigure callback from an extension', async t => {
await new Promise(async resolve => {
class CustomExtension {
async onConfigure() {
t.pass()
resolve('done')
}
}
const server = await newHocuspocus(t, {
extensions: [
new CustomExtension(),
],
})
newHocuspocusProvider(t, server)
})
})
test('has the configuration', async t => {
await new Promise(async resolve => {
newHocuspocus(t, {
debounce: 2001,
async onConfigure({ configuration }) {
t.is(configuration.debounce, 2001)
resolve('done')
},
})
})
})
test('has the version', async t => {
await new Promise(async resolve => {
newHocuspocus(t, {
async onConfigure({ version }) {
t.truthy(version)
resolve('done')
},
})
})
})

330
tests/server/onConnect.ts Normal file
View File

@@ -0,0 +1,330 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import test from "ava";
import { newHocuspocus, newHocuspocusProvider, sleep } from "../utils/index.ts";
test("executes the onConnect callback", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect() {
t.pass();
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("refuses connection when an error is thrown", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect() {
throw new Error();
},
});
newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
t.pass();
resolve("done");
},
});
});
});
test("executes the onConnect callback from an extension", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
class CustomExtension {
async onConnect() {
t.pass();
resolve("done");
}
}
server.configure({
extensions: [new CustomExtension()],
});
newHocuspocusProvider(t, server);
});
});
test("has the document name", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ documentName }) {
t.is(documentName, "hocuspocus-test");
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("sets the provider to readOnly", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ connectionConfig }) {
connectionConfig.readOnly = true;
},
});
newHocuspocusProvider(t, server, {
onSynced() {
for (const connection of server.documents.get("hocuspocus-test")?.connections.keys() ?? []) {
t.is(connection.readOnly, true);
}
resolve("done");
},
});
});
});
const weirdDocumentNames = [
"not-weird",
"äöü",
"<>{}|^ß",
"with space",
"with/slash",
"with\backslash",
"a-very-long-document-name-which-should-not-make-any-problems-at-all",
"🌟",
":",
"—",
"漢",
"triple space",
"*",
];
weirdDocumentNames.forEach((weirdDocumentName) => {
test(`encodes weird document names: "${weirdDocumentName}"`, async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ documentName }) {
t.is(documentName, weirdDocumentName);
resolve("done");
},
});
newHocuspocusProvider(t, server, {
name: weirdDocumentName,
});
});
});
});
test("rejects empty document name via WebSocket", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
newHocuspocusProvider(t, server, {
name: "",
onAuthenticationFailed() {
t.is(server.getDocumentsCount(), 0);
t.pass();
resolve("done");
},
});
});
});
test("rejects whitespace-only document name via WebSocket", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
newHocuspocusProvider(t, server, {
name: " ",
onAuthenticationFailed() {
t.is(server.getDocumentsCount(), 0);
t.pass();
resolve("done");
},
});
});
});
test("stops when the onConnect hook throws an Error", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
onConnect() {
throw new Error();
},
// MUST NOT BE CALLED
async onLoadDocument() {
t.fail(
"WARNING: When onConnect fails onLoadDocument must not be called.",
);
},
});
newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
t.pass();
resolve("done");
},
});
});
});
test("stops when the onConnect hook returns a rejecting promise", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
onConnect() {
return Promise.reject();
},
// MUST NOT BE CALLED
async onLoadDocument() {
t.fail(
"WARNING: When onConnect fails onLoadDocument must not be called.",
);
},
});
newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
t.pass();
resolve("done");
},
});
});
});
test("has the request headers", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ requestHeaders }) {
t.is(requestHeaders.get("connection") !== null, true);
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("has the whole request", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ request }) {
t.is(new URL(request.url).pathname, "/");
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("has the socketId", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ socketId }) {
t.is(socketId !== undefined, true);
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("has the server instance", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ instance }) {
t.is(instance, server);
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("defaults to readOnly = false", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ connectionConfig }) {
t.is(connectionConfig.readOnly, false);
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("cleans up correctly when provider disconnects during onLoadDocument", async (t) => {
await new Promise(async (resolve) => {
// eslint-disable-next-line prefer-const
let provider: HocuspocusProvider;
const server = await newHocuspocus(t, {
onLoadDocument: async () => {
provider.configuration.websocketProvider.disconnect();
provider.disconnect();
// pretent we loaded data from async source
await sleep(100);
},
});
provider = newHocuspocusProvider(t, server, {
name: "super-unique-name",
async onDisconnect() {
await sleep(100);
t.is(
server.documents.get("super-unique-name"),
undefined,
"no documents",
);
resolve("done");
},
});
});
});
test("the connections count is correct", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async connected() {
t.is(server.getConnectionsCount(), 1);
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("has connection.readOnly", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ connectionConfig }) {
t.is(connectionConfig.readOnly, false);
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("has the request", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ request }) {
t.truthy(request.url);
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});

275
tests/server/onDestroy.ts Normal file
View File

@@ -0,0 +1,275 @@
import test from "ava";
import { newHocuspocus, newHocuspocusProvider } from "../utils/index.ts";
import { retryableAssertion } from "../utils/retryableAssertion.ts";
test("executes the onDestroy hook and has the instance", async (t) => {
await new Promise(async (resolve) => {
const hocuspocus = await newHocuspocus(t, {
async onDestroy({ instance }) {
t.is(instance, hocuspocus);
resolve("done");
},
});
await hocuspocus.server!.destroy();
});
});
test("destroy works if no document is open", async (t) => {
await new Promise(async (resolve) => {
const hocuspocus = await newHocuspocus(t);
await hocuspocus.server!.destroy();
t.pass();
resolve("");
});
});
test("executes the onDestroy hook from a custom extension", async (t) => {
await new Promise(async (resolve) => {
class CustomExtension {
async onDestroy() {
t.pass();
resolve("done");
}
}
const hocuspocus = await newHocuspocus(t, {
extensions: [new CustomExtension()],
});
await hocuspocus.server!.destroy();
});
});
test("destroy closes all connections", async (t) => {
await new Promise(async (resolve) => {
const hocuspocus = await newHocuspocus(t);
const provider1 = newHocuspocusProvider(t, hocuspocus);
await retryableAssertion(t, (t2) => t2.is(provider1.synced, true));
t.is(hocuspocus.getConnectionsCount(), 1);
t.is(hocuspocus.getDocumentsCount(), 1);
await hocuspocus.server!.destroy();
t.is(hocuspocus.getConnectionsCount(), 0);
t.is(hocuspocus.getDocumentsCount(), 0);
resolve("");
});
});
test("destroy does not call onStoreDocument if nothing debounced", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onStoreDocument() {
t.fail();
},
});
const provider = newHocuspocusProvider(t, server);
await retryableAssertion(t, (t2) => t2.is(provider.synced, true));
await server.server!.destroy();
resolve("");
});
});
test("destroy does not call onStoreDocument after debounced onStoreDocument executes", async (t) => {
await new Promise(async (resolve) => {
let called = 0;
const server = await newHocuspocus(t, {
debounce: 200,
unloadImmediately: true,
async onStoreDocument() {
called += 1;
},
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
// Dummy change to trigger onStoreDocument
provider.document.getArray("foo").push(["foo"]);
},
});
await retryableAssertion(t, (t2) => t2.is(provider.synced, true));
// Wait for the debounced onStoreDocument to execute
await new Promise((r) => setTimeout(r, 400));
await server.server!.destroy();
t.is(called, 1);
resolve("");
});
});
test("destroy calls onStoreDocument before returning if debounced", async (t) => {
await new Promise(async (resolve) => {
let called = false;
const hocuspocus = await newHocuspocus(t, {
async onStoreDocument() {
called = true;
},
});
const provider = newHocuspocusProvider(t, hocuspocus, {
onSynced() {
// Dummy change to trigger onStoreDocument
provider.document.getArray("foo").push(["foo"]);
},
});
const provider1 = newHocuspocusProvider(t, hocuspocus);
await retryableAssertion(t, (t2) => t2.is(provider1.synced, true));
t.is(called, false);
await hocuspocus.server!.destroy();
t.is(called, true);
resolve("");
});
});
test("destroy calls onStoreDocument before returning, even with unloadImmediately=false if debounced", async (t) => {
await new Promise(async (resolve) => {
let called = false;
const hocuspocus = await newHocuspocus(t, {
async onStoreDocument() {
called = true;
},
unloadImmediately: false,
});
const provider = newHocuspocusProvider(t, hocuspocus, {
onSynced() {
// Dummy change to trigger onStoreDocument
provider.document.getArray("foo").push(["foo"]);
},
});
const provider1 = newHocuspocusProvider(t, hocuspocus);
await retryableAssertion(t, (t2) => t2.is(provider1.synced, true));
await retryableAssertion(t, (t2) => t2.is(provider.synced, true));
t.is(called, false);
await hocuspocus.server!.destroy();
t.is(called, true);
resolve("");
});
});
test("destroy calls onStoreDocument before returning, even with unloadImmediately=false, with multiple docs if debounced", async (t) => {
await new Promise(async (resolve) => {
let called = 0;
const hocuspocus = await newHocuspocus(t, {
async onStoreDocument() {
called += 1;
},
unloadImmediately: false,
});
const provider1 = newHocuspocusProvider(t, hocuspocus, {
name: "test1",
onSynced() {
provider1.document.getArray("foo").push(["foo"]);
},
});
const provider2 = newHocuspocusProvider(t, hocuspocus, {
name: "test2",
onSynced() {
provider2.document.getArray("foo").push(["foo"]);
},
});
const provider3 = newHocuspocusProvider(t, hocuspocus, {
name: "test3",
onSynced() {
provider3.document.getArray("foo").push(["foo"]);
},
});
await retryableAssertion(t, (t2) => t2.is(provider1.synced, true));
await retryableAssertion(t, (t2) => t2.is(provider2.synced, true));
await retryableAssertion(t, (t2) => t2.is(provider3.synced, true));
// Wait for all changes to reach the server and trigger debounced stores
await retryableAssertion(t, (t2) => {
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test1"));
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test2"));
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test3"));
});
t.is(called, 0);
await hocuspocus.server!.destroy();
await retryableAssertion(t, (t2) => t2.is(called, 3));
resolve("");
});
});
test("destroy calls onStoreDocument before returning, with multiple docs if debounced", async (t) => {
await new Promise(async (resolve) => {
let called = 0;
const hocuspocus = await newHocuspocus(t, {
async onStoreDocument() {
called += 1;
},
unloadImmediately: true,
});
const provider1 = newHocuspocusProvider(t, hocuspocus, {
name: "test1",
onSynced() {
provider1.document.getArray("foo").push(["foo"]);
},
});
const provider2 = newHocuspocusProvider(t, hocuspocus, {
name: "test2",
onSynced() {
provider2.document.getArray("foo").push(["foo"]);
},
});
const provider3 = newHocuspocusProvider(t, hocuspocus, {
name: "test3",
onSynced() {
provider3.document.getArray("foo").push(["foo"]);
},
});
await retryableAssertion(t, (t2) => t2.is(provider1.synced, true));
await retryableAssertion(t, (t2) => t2.is(provider2.synced, true));
await retryableAssertion(t, (t2) => t2.is(provider3.synced, true));
// Wait for all changes to reach the server and trigger debounced stores
await retryableAssertion(t, (t2) => {
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test1"));
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test2"));
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test3"));
});
t.is(called, 0);
await hocuspocus.server!.destroy();
await retryableAssertion(t, (t2) => t2.is(called, 3));
resolve("");
});
});

View File

@@ -0,0 +1,114 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('executes the onDisconnect callback', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onDisconnect() {
t.pass()
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.configuration.websocketProvider.disconnect()
provider.disconnect()
},
})
})
})
test('executes the onDisconnect callback from an extension', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
class CustomExtension {
async onDisconnect() {
t.pass()
resolve('done')
}
}
server.configure({
extensions: [
new CustomExtension(),
],
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.configuration.websocketProvider.disconnect()
provider.disconnect()
},
})
})
})
test('passes the context to the onLoadDocument callback', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t)
const mockContext = {
user: 123,
}
server.configure({
async onConnect() {
return mockContext
},
async onDisconnect({ context }) {
t.deepEqual(context, mockContext)
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.configuration.websocketProvider.disconnect()
provider.disconnect()
},
})
})
})
test('has the server instance', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onDisconnect({ instance }) {
t.is(instance, server)
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.configuration.websocketProvider.disconnect()
provider.disconnect()
},
})
})
})
test('the connections count is correct', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onDisconnect() {
t.is(server.getConnectionsCount(), 0)
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
onConnect() {
provider.configuration.websocketProvider.disconnect()
provider.disconnect()
},
})
})
})

52
tests/server/onListen.ts Normal file
View File

@@ -0,0 +1,52 @@
import test from 'ava'
import { newHocuspocus } from '../utils/index.ts'
test('executes the onListen callback', async t => {
await new Promise(async resolve => {
newHocuspocus(t, {
async onListen() {
t.pass()
resolve('done')
},
})
})
})
test('executes the onListen callback from an extension', async t => {
await new Promise(async resolve => {
class CustomExtension {
async onListen() {
t.pass()
resolve('done')
}
}
newHocuspocus(t, {
extensions: [
new CustomExtension(),
],
})
})
})
test('has the configuration', async t => {
await new Promise(async resolve => {
newHocuspocus(t, {
async onListen({ configuration }) {
t.is(configuration.quiet, true)
resolve('done')
},
})
})
})
test('has the port', async t => {
await new Promise(async resolve => {
newHocuspocus(t, {
async onListen({ port }) {
t.truthy(port)
resolve('done')
},
})
})
})

View File

@@ -0,0 +1,518 @@
import test from "ava";
import * as Y from "yjs";
import { newHocuspocus, newHocuspocusProvider } from "../utils/index.ts";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
test("executes the onLoadDocument callback", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument() {
t.pass();
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("executes the onLoadDocument callback from an extension", async (t) => {
await new Promise(async (resolve) => {
class CustomExtension {
async onLoadDocument() {
t.pass();
resolve("done");
}
}
const server = await newHocuspocus(t, {
extensions: [new CustomExtension()],
});
newHocuspocusProvider(t, server);
});
});
test("passes the context and connection to the onLoadDocument callback", async (t) => {
await new Promise(async (resolve) => {
const mockContext = {
user: 123,
};
const server = await newHocuspocus(t, {
async onConnect({ connectionConfig }) {
connectionConfig.readOnly = true;
return mockContext;
},
async onLoadDocument({ context, connectionConfig }) {
t.deepEqual(context, mockContext);
t.deepEqual(connectionConfig, {
readOnly: true,
isAuthenticated: true,
});
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("sets the provider to readOnly", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument({ connectionConfig }) {
connectionConfig.readOnly = true;
},
});
newHocuspocusProvider(t, server, {
onSynced() {
for (const connection of server.documents.get("hocuspocus-test")?.connections.keys() ?? []) {
t.is(connection.readOnly, true);
}
resolve("done");
},
});
});
});
test("creates a new document in the onLoadDocument callback", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
onLoadDocument({ document }) {
// delay more accurately simulates a database fetch
return new Promise(async (resolve) => {
setTimeout(() => {
document.getArray("foo").insert(0, ["bar"]);
resolve(document);
}, 200);
});
},
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
const value = provider.document.getArray("foo").get(0);
t.is(value, "bar");
resolve("done");
},
});
});
});
test("does not apply the document to itself when onLoadDocument returns it", async (t) => {
const origins: unknown[] = [];
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument({ document }) {
document.on("afterTransaction", (transaction) => {
origins.push(transaction.origin);
});
// the content this hook "loaded", written with an explicit origin so
// it doubles as a control: without it, a listener that never fires
// would satisfy the assertion below vacuously
document.transact(() => {
document.getArray("foo").insert(0, ["bar"]);
}, "loaded");
return document;
},
});
newHocuspocusProvider(t, server, {
onSynced() {
resolve("done");
},
});
});
t.true(origins.includes("loaded"), "the afterTransaction listener never fired");
// the self-apply calls applyUpdate() without an origin, and the client sync
// carries a ConnectionTransactionOrigin, so a null origin can only be the
// document having been applied to itself
const selfApplies = origins.filter((origin) => origin == null);
t.is(
selfApplies.length,
0,
`expected no null-origin transaction after onLoadDocument returned the same document, got ${selfApplies.length}`,
);
});
test("applies a document returned by the onLoadDocument callback", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument() {
const loaded = new Y.Doc();
loaded.getArray("foo").insert(0, ["bar"]);
return loaded;
},
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
t.is(provider.document.getArray("foo").get(0), "bar");
resolve("done");
},
});
});
});
test("multiple simultaneous connections do not create multiple documents", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
onLoadDocument({ document }) {
// delay more accurately simulates a database fetch
return new Promise(async (resolve) => {
setTimeout(() => {
document.getArray("foo").insert(0, ["bar"]);
resolve(document);
}, 200);
});
},
});
const provider = newHocuspocusProvider(t, server);
newHocuspocusProvider(t, server);
provider.on("synced", () => {
t.is(server.documents.size, 1);
resolve("done");
});
});
});
test("multiple simultaneous connections wait for the document to be loaded", async (t) => {
t.plan(6);
await new Promise(async (resolve) => {
let resolveOnLoadDocument: () => void = () => {};
const server = await newHocuspocus(t, {
onLoadDocument({ document }) {
// delay more accurately simulates a database fetch
return new Promise(async (innerResolve) => {
resolveOnLoadDocument = () => {
document.getArray("foo").insert(0, ["bar"]);
innerResolve(document);
};
});
},
});
const provider1 = newHocuspocusProvider(t, server);
const provider2 = newHocuspocusProvider(t, server);
let provider1Synced = false;
let provider2Synced = false;
provider1.on("synced", () => {
provider1Synced = true;
const value = provider1.document.getArray("foo").get(0);
t.is(value, "bar");
});
provider2.on("synced", () => {
provider2Synced = true;
const value = provider2.document.getArray("foo").get(0);
t.is(value, "bar");
});
await sleep(200);
t.false(provider1Synced, "provider1Synced");
t.false(provider2Synced, "provider2Synced");
resolveOnLoadDocument();
await sleep(200);
t.true(provider1Synced, "provider1Synced");
t.true(provider2Synced, "provider2Synced");
resolve("done");
});
});
test("has the server instance", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument({ instance }) {
t.is(instance, server);
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
/**
* When onLoadDocument fails (for whatever reason), the connection attempt will fail.
*/
test("stops when an error is thrown in onLoadDocument", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument() {
throw new Error();
},
});
newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
t.pass();
resolve("done");
},
});
});
});
test("stops when an error is thrown in onLoadDocument, even when authenticated", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true;
},
async onLoadDocument() {
throw new Error();
},
});
newHocuspocusProvider(t, server, {
token: "super-secret-token",
onAuthenticationFailed() {
t.pass();
resolve("done");
},
onClose() {
t.fail();
},
});
});
});
test("disconnects all clients related to the document when an error is thrown in onLoadDocument", async (t) => {
const resolvesNeeded = 2;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument() {
return new Promise((resolve, fail) => {
setTimeout(() => {
// eslint-disable-next-line prefer-promise-reject-errors
fail("ERROR");
}, 250);
});
},
async onStoreDocument(data) {
t.fail("MUST NOT call onStoreDocument");
},
});
let resolvedNumber = 0;
const resolver = () => {
resolvedNumber += 1;
if (resolvedNumber >= resolvesNeeded) {
t.is(server.documents.size, 0);
t.is(server.getConnectionsCount(), 0);
resolve("done");
}
};
const provider1 = newHocuspocusProvider(t, server, {
onAuthenticationFailed(event) {
provider1.disconnect();
resolver();
},
});
const provider2 = newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
provider2.disconnect();
resolver();
},
});
});
});
test("destroys the document when onLoadDocument throws", async (t) => {
let loadedDocument: Y.Doc | undefined;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument({ document }) {
loadedDocument = document;
throw new Error("Something went wrong");
},
});
newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
resolve("done");
},
});
});
t.truthy(loadedDocument, "onLoadDocument never ran");
// Awareness starts a setInterval in its constructor that is only cleared by
// the document's destroy(), so a document that is never destroyed leaves a
// live timer behind for every failed load
t.true(
loadedDocument?.isDestroyed,
"the document created for the failed load was never destroyed",
);
});
test("if a new connection connects while the previous connection still fetches the document, it will just work properly", async (t) => {
t.plan(11);
let callsToOnLoadDocument = 0;
const resolvesNeeded = 10;
await new Promise(async (resolve) => {
let resolvedNumber = 0;
const resolver = () => {
resolvedNumber += 1;
if (resolvedNumber >= resolvesNeeded) {
t.is(callsToOnLoadDocument, 1);
resolve("done");
}
};
const server = await newHocuspocus(t, {
// This test asserts on the document state at each individual inbound
// message, so it pins the unbatched fan-out. With the default
// `flushDelay: 0` a broadcast lands one event loop turn later, which
// reorders it relative to the sync acks and shifts these indices.
flushDelay: false,
onLoadDocument({ document }) {
return new Promise(async (resolve) => {
setTimeout(() => {
callsToOnLoadDocument += 1;
document
.getArray("foo")
.insert(0, [`bar-${callsToOnLoadDocument}`]);
resolve(document);
}, 5000);
});
},
});
let provider1MessagesReceived = 0;
const provider = newHocuspocusProvider(t, server, {
onSynced({ state }) {
// if (!state) return
t.is(server.documents.size, 1);
const value = provider.document.getArray("foo").get(0);
t.is(value, "bar-1");
setTimeout(() => {
provider.document
.getArray("foo")
.insert(0, ["bar-updatedAfterProvider1Synced"]);
}, 100);
resolver();
},
onMessage() {
if (!provider.isSynced) return;
provider1MessagesReceived += 1;
const value = provider.document.getArray("foo").get(0);
if (provider1MessagesReceived === 1) {
// do nothing, this is just the ACK for the sync
} else if (provider1MessagesReceived === 2) {
// do nothing, this is just the ACK for the received update (set "bar-updatedAfterProvider1Synced")
} else if (provider1MessagesReceived === 3) {
t.is(value, "bar-updatedAfterProvider1Synced");
} else {
t.is(value, "bar-updatedAfterProvider2ReceivedMessageFrom1");
}
resolver();
},
});
let provider2MessagesReceived = 0;
setTimeout(() => {
const provider2 = newHocuspocusProvider(t, server, {
onSynced({ state }) {
// if (!state) return
t.is(server.documents.size, 1);
const value = provider2.document.getArray("foo").get(0);
t.is(value, "bar-1");
resolver();
},
onMessage(data) {
if (!provider2.isSynced) return;
provider2MessagesReceived += 1;
setTimeout(() => {
const value = provider2.document.getArray("foo").get(0);
if (provider2MessagesReceived === 1) {
// initial state is now synced
t.is(value, "bar-1");
} else if (provider2MessagesReceived === 2) {
t.is(value, "bar-updatedAfterProvider1Synced");
setTimeout(() => {
provider.document
.getArray("foo")
.insert(0, ["bar-updatedAfterProvider2ReceivedMessageFrom1"]);
}, 100);
} else {
t.is(value, "bar-updatedAfterProvider2ReceivedMessageFrom1");
}
resolver();
});
},
});
}, 2000);
});
});
test("stops when an error is thrown in onLoadDocument and passed on to the provider", async (t) => {
class CustomError extends Error {
code = 1234;
reason = "mycustomreason";
constructor(documentName: string) {
super(`Failed loading ${documentName}`);
}
}
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument() {
throw new CustomError("testdoc");
},
});
newHocuspocusProvider(t, server, {
onAuthenticationFailed(e) {
t.is(e.reason, "mycustomreason");
t.pass();
resolve("done");
},
});
});
});

81
tests/server/onRequest.ts Normal file
View File

@@ -0,0 +1,81 @@
import test from 'ava'
import type { onRequestPayload } from '@hocuspocus/server'
import { newHocuspocus } from '../utils/index.ts'
test('executes the onRequest callback', async t => {
await new Promise(async resolve => {
const hocuspocus = await newHocuspocus(t, {
async onRequest({ request }: onRequestPayload) {
t.is(request.url, '/foobar')
resolve('done')
},
})
fetch(`${hocuspocus.server!.httpURL}/foobar`).catch(() => {})
})
})
test('executes the onRequest callback of a custom extension', async t => {
await new Promise(async resolve => {
class CustomExtension {
async onRequest({ response }: onRequestPayload) {
return new Promise((resolve, reject) => {
response.writeHead(200, { 'Content-Type': 'text/plain' })
response.end('I like cats.')
return reject()
})
}
}
const hocuspocus = await newHocuspocus(t, {
extensions: [
new CustomExtension(),
],
})
const response = await fetch(hocuspocus.server!.httpURL)
t.is(await response.text(), 'I like cats.')
resolve('done')
})
})
test('can intercept specific URLs', async t => {
await new Promise(async resolve => {
const hocuspocus = await newHocuspocus(t, {
async onRequest({ response, request }: onRequestPayload) {
if (request.url === '/foobar') {
return new Promise((resolve, reject) => {
response.writeHead(200, { 'Content-Type': 'text/plain' })
response.end('I like cats.')
return reject()
})
}
},
})
const interceptedResponse = await fetch(`${hocuspocus.server!.httpURL}/foobar`)
t.is(await interceptedResponse.text(), 'I like cats.')
const regularResponse = await fetch(hocuspocus.server!.httpURL)
t.is(await regularResponse.text(), 'Welcome to Hocuspocus!')
resolve('done')
})
})
test('has the instance', async t => {
await new Promise(async resolve => {
const hocuspocus = await newHocuspocus(t, {
async onRequest({ instance }) {
t.is(instance, hocuspocus)
resolve('done')
},
})
fetch(`${hocuspocus.server!.httpURL}/foobar`).catch(() => {})
})
})

101
tests/server/onStateless.ts Normal file
View File

@@ -0,0 +1,101 @@
import test from 'ava'
import type { onStatelessPayload } from '@hocuspocus/server'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('broadcast stateless message to all connections', async t => {
await new Promise(async resolve => {
const payloadToSend = 'STATELESS-MESSAGE'
const server = await newHocuspocus(t, {
onStateless: async ({ document }) => {
await document.broadcastStateless(payloadToSend)
},
})
let count = 2
const onStatelessCallback = (payload: string) => {
t.is(payload, payloadToSend)
count -= 1
if (count === 0) {
t.pass()
resolve('done')
}
}
newHocuspocusProvider(t, server, { onStateless: ({ payload }) => onStatelessCallback(payload) })
const provider = newHocuspocusProvider(t, server, {
onStateless: ({ payload }) => onStatelessCallback(payload),
onSynced: () => {
provider.sendStateless(payloadToSend)
},
})
})
})
test('send a stateless message to a specific connection', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
onStateless: async ({ connection }: onStatelessPayload) => {
connection.sendStateless('This is a specific message.')
},
})
await newHocuspocusProvider(t, server, {
onStateless: async () => {
throw Error('The provider1 should not receive messages')
},
})
const provider = await newHocuspocusProvider(t, server, {
onSynced: () => {
provider.sendStateless('Send stateless message, and then a stateless message is will be received')
},
onStateless: () => {
t.pass()
resolve('done')
},
})
})
})
test('calls the onStateless hook', async t => {
await new Promise(async resolve => {
const payloadToSend = 'STATELESS-MESSAGE'
class CustomExtension {
async onStateless({ payload }: onStatelessPayload) {
t.is(payload, payloadToSend)
t.pass()
resolve('done')
}
}
const server = await newHocuspocus(t, {
extensions: [
new CustomExtension(),
],
})
const provider = await newHocuspocusProvider(t, server, {
onSynced: async () => {
provider.sendStateless(payloadToSend)
},
})
})
})
test('the server actively sends a stateless message', async t => {
const payloadToSend = 'STATELESS-MESSAGE'
const server = await newHocuspocus(t)
await new Promise(resolve => {
newHocuspocusProvider(t, server, {
onSynced: async () => {
server.documents.get('hocuspocus-test')?.broadcastStateless(payloadToSend)
},
onStateless: async ({ payload }) => {
t.is(payload, payloadToSend)
t.pass()
resolve('done')
},
})
})
})

View File

@@ -0,0 +1,768 @@
import type { Extension, onLoadDocumentPayload, onStoreDocumentPayload } from "@hocuspocus/server";
import test from "ava";
import {
newHocuspocus,
newHocuspocusProvider,
newHocuspocusProviderWebsocket,
sleep,
} from "../utils/index.ts";
import * as Y from "yjs";
test("calls the debouned onStoreDocument hook before the document is removed from memory", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
debounce: 3000,
async onStoreDocument() {
t.pass();
resolve("done");
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const provider = newHocuspocusProvider(t, server, {
websocketProvider: socket,
onSynced() {
// Dummy change to trigger onStoreDocument
provider.document.getArray("foo").push(["foo"]);
socket.destroy();
},
});
});
});
test("doesn’t remove the document from memory when there’s a new connection established during onStoreDocument is called", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onStoreDocument() {
return sleep(1000);
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const anotherSocket = newHocuspocusProviderWebsocket(t, server);
newHocuspocusProvider(t, server, {
websocketProvider: socket,
onSynced() {
setTimeout(() => {
anotherSocket.connect();
}, 100);
setTimeout(() => {
t.is(server.getDocumentsCount(), 1);
resolve("done");
}, 1100);
},
});
newHocuspocusProvider(t, server, {
websocketProvider: anotherSocket,
});
});
});
test("removes the document from memory when there’s no connection after onStoreDocument is called", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onStoreDocument() {
return sleep(1000);
},
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
// Dummy change to trigger onStoreDocument
provider.document.getArray("foo").push(["foo"]);
provider.configuration.websocketProvider.destroy();
provider.destroy();
},
onDestroy() {
// Check if the document is removed from memory …
setTimeout(() => {
t.is(server.getDocumentsCount(), 0);
resolve("done");
}, 1100);
},
});
});
});
test("onStoreDocument callback receives document updates", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onStoreDocument({ document }: onStoreDocumentPayload) {
const value = document.getArray("foo").get(0);
t.is(value, "bar");
resolve("done");
},
});
const provider = newHocuspocusProvider(t, server);
provider.on("synced", () => {
provider.document.getArray("foo").insert(0, ["bar"]);
});
});
});
test("debounces document changes for onStoreDocument hooks", async (t) => {
await new Promise(async (resolve) => {
let executedOnChange = 0;
let executedOnStoreDocument = 0;
const hocuspocus = await newHocuspocus(t, {
debounce: 300,
async onChange() {
executedOnChange += 1;
},
async onStoreDocument() {
executedOnStoreDocument += 1;
},
});
const provider = newHocuspocusProvider(t, hocuspocus, {
onSynced() {
provider.document.getArray("foo").push(["foo"]);
provider.document.getArray("foo").push(["bar"]);
provider.document.getArray("foo").push(["barfoo"]);
provider.document.getArray("foo").push(["foobar"]);
provider.document.getArray("foo").push(["foofoo"]);
setTimeout(async () => {
await hocuspocus.server!.destroy();
t.is(executedOnChange, 5);
t.is(executedOnStoreDocument, 1);
resolve("done");
}, 200);
},
});
});
});
test("executes onStoreDocument callback from an extension", async (t) => {
await new Promise(async (resolve) => {
class CustomExtension {
async onStoreDocument({ document }: onStoreDocumentPayload) {
const value = document.getArray("foo").get(0);
t.is(value, "bar");
resolve("done");
}
}
const server = await newHocuspocus(t, {
extensions: [new CustomExtension()],
});
const provider = newHocuspocusProvider(t, server);
provider.on("synced", () => {
provider.document.getArray("foo").insert(0, ["bar"]);
});
});
});
test("stops when one of the onStoreDocument hooks throws an error", async (t) => {
await new Promise(async (resolve) => {
class BreakingTheChain {
async onStoreDocument() {
setTimeout(() => {
t.pass();
resolve("done");
}, 100);
// Stop it!
throw new Error();
}
}
class NotExecuted {
async onStoreDocument() {
// This MUST NOT be executed.
resolve("done");
}
}
const server = await newHocuspocus(t, {
extensions: [new BreakingTheChain(), new NotExecuted()],
});
const provider = newHocuspocusProvider(t, server);
provider.on("synced", () => {
provider.document.getArray("foo").insert(0, ["bar"]);
});
});
});
test("stops when one of the onStoreDocument hooks throws a non-Error value", async (t) => {
await new Promise(async (resolve) => {
class BreakingTheChain {
async onStoreDocument() {
setTimeout(() => {
t.pass();
resolve("done");
}, 100);
// Throw a string instead of an Error object
throw "storage unavailable";
}
}
class NotExecuted {
async onStoreDocument() {
// This MUST NOT be executed.
t.fail("NotExecuted should not run");
}
}
const server = await newHocuspocus(t, {
extensions: [new BreakingTheChain(), new NotExecuted()],
});
const provider = newHocuspocusProvider(t, server);
provider.on("synced", () => {
provider.document.getArray("foo").insert(0, ["bar"]);
});
});
});
test("keeps document in memory when onStoreDocument fails", async (t) => {
let resolveProviderSynced!: () => void;
const providerSynced = new Promise<void>((resolve) => {
resolveProviderSynced = resolve;
});
let resolveDocumentChanged!: () => void;
const documentChanged = new Promise<void>((resolve) => {
resolveDocumentChanged = resolve;
});
let resolveStoreStarted!: () => void;
const storeStarted = new Promise<void>((resolve) => {
resolveStoreStarted = resolve;
});
const server = await newHocuspocus(t, {
debounce: 100,
onChange() {
resolveDocumentChanged();
},
async onStoreDocument() {
resolveStoreStarted();
throw new Error("storage unavailable");
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const provider = newHocuspocusProvider(t, server, {
websocketProvider: socket,
onSynced() {
resolveProviderSynced();
},
});
await providerSynced;
provider.document.getArray("foo").push(["bar"]);
await documentChanged;
socket.destroy();
await storeStarted;
const document = server.documents.get("hocuspocus-test");
if (!document) {
throw new Error("Expected the document to remain loaded while storing");
}
await document.saveMutex.waitForUnlock();
// Document must remain in memory since the store failed.
t.is(server.getDocumentsCount(), 1);
});
test("has the server instance", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onStoreDocument({ instance }) {
t.is(instance, server);
resolve("done");
},
});
const provider = newHocuspocusProvider(t, server);
provider.on("synced", () => {
provider.document.getArray("foo").insert(0, ["bar"]);
});
});
});
test("runs hooks in the given order", async (t) => {
await new Promise(async (resolve) => {
const triggered: string[] = [];
class Running {
async onStoreDocument() {
triggered.push("one");
}
}
class BreakTheChain {
async onStoreDocument() {
triggered.push("two");
throw Error();
}
}
class NotRunning {
async onStoreDocument() {
triggered.push("three");
}
}
const server = await newHocuspocus(t, {
extensions: [new Running(), new BreakTheChain(), new NotRunning()],
// lowest priority
async onStoreDocument() {
triggered.push("four");
},
async afterStoreDocument() {
t.fail(); // this shouldnt run
},
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
// Dummy change to trigger onStoreDocument
provider.document.getArray("foo").push(["foo"]);
provider.configuration.websocketProvider.destroy();
provider.destroy();
setTimeout(() => {
t.deepEqual(triggered, ["one", "two"]);
resolve(true);
}, 250);
},
});
});
});
test("allows to overwrite the order of extension with a priority", async (t) => {
await new Promise(async (resolve) => {
const triggered: string[] = [];
class Running {
async onStoreDocument() {
triggered.push("one");
}
}
class BreakTheChain {
async onStoreDocument() {
triggered.push("two");
throw Error();
}
}
class NotRunning {
async onStoreDocument() {
triggered.push("three");
}
}
class HighPriority {
priority = 1000;
async onStoreDocument() {
triggered.push("zero");
}
}
const server = await newHocuspocus(t, {
afterStoreDocument: async () => {
t.fail();
},
extensions: [
new Running(),
new BreakTheChain(),
new NotRunning(),
new HighPriority(),
],
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
// Dummy change to trigger onStoreDocument
provider.document.getArray("foo").push(["foo"]);
provider.configuration.websocketProvider.destroy();
provider.destroy();
setTimeout(() => {
t.deepEqual(triggered, ["zero", "one", "two"]);
resolve(true);
}, 250);
},
});
});
});
test("if a connection connects while another disconnects onStoreDocument is still running, onLoadDocument will be called after onStoreDocument finished", async (t) => {
await new Promise(async (testResolve) => {
let isStoredOnDb = false;
let loadCalls = 0;
const server = await newHocuspocus(t, {
async onStoreDocument({ instance }) {
return new Promise((resolve) => {
setTimeout(() => {
isStoredOnDb = true;
resolve(true);
}, 200);
});
},
async onLoadDocument() {
return new Promise((resolve) => {
if (loadCalls > 0) {
t.true(isStoredOnDb);
testResolve(true);
}
loadCalls += 1;
resolve(true);
});
},
});
const provider = newHocuspocusProvider(t, server);
provider.on("synced", () => {
// Dummy change to trigger onStoreDocument
provider.document.getArray("foo").push(["foo"]);
provider.configuration.websocketProvider.disconnect();
setTimeout(() => {
const provider2 = newHocuspocusProvider(t, server);
provider2.on("synced", () => {
provider2.configuration.websocketProvider.disconnect();
testResolve(true);
t.pass();
});
}, 50);
});
});
});
test("waits before calling onStoreDocument after the last user disconnects when configured", async (t) => {
await new Promise(async (resolve) => {
let startTime = 0;
const server = await newHocuspocus(t, {
unloadImmediately: false,
debounce: 500,
async onStoreDocument() {
const endTime = Date.now();
if (startTime === 0) {
t.fail("startTime not set");
} else if (endTime - startTime < 500) {
t.fail("did not wait 500ms to call onStoreDocument when closing");
} else {
t.pass();
}
resolve("done");
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const provider = newHocuspocusProvider(t, server, {
websocketProvider: socket,
onSynced() {
// Dummy change to trigger onStoreDocument
provider.document.getArray("foo").push(["foo"]);
startTime = Date.now();
socket.destroy();
},
});
});
});
test("calls debounced onStoreDocument immediately after the last user disconnects when configured", async (t) => {
await new Promise(async (resolve) => {
let startTime = 0;
const server = await newHocuspocus(t, {
unloadImmediately: true,
debounce: 500,
async onStoreDocument() {
const endTime = Date.now();
if (startTime === 0) {
t.fail("startTime not set");
} else if (endTime - startTime < 500) {
t.pass();
} else {
t.fail("did not call onStoreDocument immediately when closing");
}
resolve("done");
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const provider = newHocuspocusProvider(t, server, {
websocketProvider: socket,
onSynced() {
// Dummy change to trigger onStoreDocument
provider.document.getArray("foo").push(["foo"]);
startTime = Date.now();
socket.destroy();
},
});
});
});
test("does not call the onStoreDocument hook if document is not changed after the last user disconnects", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onStoreDocument() {
t.fail(
"no need to call onStoreDocument if the document is not changed",
);
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const provider = newHocuspocusProvider(t, server, {
websocketProvider: socket,
onSynced() {
socket.destroy();
},
});
setTimeout(() => {
resolve("done");
}, 200);
});
t.pass();
});
test("does not start a new onStoreDocument if there is already one running (should wait for the first one to finish)", async (t) => {
/*
If our storage backend takes more time than the debounce time to store the document,
we might end up in a situation where multiple onStoreDocument calls are running at the same time.
Rough timeline:
1. ~0ms Client 1 connects
2. ~10ms Client 1 makes change 1 (triggers debounced save)
3. ~100ms Server starts saving change 1 (debounced)
4. ~200ms Client 1 makes change 2 (triggers debounced save)
6. ~510ms Server finishes saving change 1.
6. ~511ms Server starts saving change 2 (debounced)
7. ~1111ms Server finishes saving change 2.
*/
await new Promise(async (resolve) => {
let started = 0
let finished = 0
const server = await newHocuspocus(t, {
debounce: 100,
async onStoreDocument() {
if (started === 1) {
// This is the second call
t.is(finished, 1, "the first call must have finished before starting the second");
resolve("done");
} else {
started++
await sleep(500) // Simulate long save
finished++
}
},
});
const socket1 = newHocuspocusProviderWebsocket(t, server);
const provider1 = newHocuspocusProvider(t, server, {
websocketProvider: socket1,
async onSynced() {
// Change 1
provider1.document.getArray("foo").push(["foo"]);
setTimeout(() => {
// Change 2
provider1.document.getArray("foo").push(["bar"]);
socket1.destroy();
}, 200)
},
});
});
})
test("triggers unload only after finishing with a save in progress", async (t) => {
/*
Rough timeline:
1. ~0ms Client 1 connects
2. ~10ms Client 1 makes change 1 (triggers debounced save)
3. ~1000ms Server starts saving change 1 (debounced)
4. ~1100ms Client 1 disconnects. Document is not unloaded because save is in progress.
5. ~1500ms Server finishes saving change 1. Document is unloaded
*/
await new Promise(async (resolve) => {
const start = Date.now()
let saveStarted = false
let saveFinished = false
const server = await newHocuspocus(t, {
debounce: 1000,
extensions: [{
async onStoreDocument() {
saveStarted = true
await sleep(500) // Add pause to simulate long save
saveFinished = true
},
async afterUnloadDocument(data) {
t.deepEqual(saveFinished, true, "Unload should occur only after save was finished");
t.pass()
resolve("done");
},
}],
});
const socket1 = newHocuspocusProviderWebsocket(t, server);
const provider1 = newHocuspocusProvider(t, server, {
websocketProvider: socket1,
async onSynced() {
provider1.document.getArray("foo").push(["foo"]);
setTimeout(() => { // Wait for sending changes
socket1.destroy();
}, 1100)
},
});
})
})
test("does not trigger unload prematurely when a save is in progress (unloadImmediately=true)", async (t) => {
/*
Rough timeline:
1. ~0ms Client 1 connects
2. ~10ms Client 1 makes change 1 (triggers debounced save)
3. ~1000ms Server starts saving change 1 (debounced)
4. ~1100ms Client 1 makes change 2 (triggers debounced save)
5. ~1300ms Client 1 disconnects, triggering immediate save of change 2
6. ~1500ms Server finishes saving change 1. Does not unload the document, because change 2 is still being saved.
7. ~1600ms Client 2 connects, loads document
9. ~1800ms Server finishes saving change 2.
10. ~2100ms We verify that Client 2 sees both changes.
*/
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
debounce: 1000,
extensions: [new SlowInMemoryStorage()],
});
const socket1 = newHocuspocusProviderWebsocket(t, server);
const provider1 = newHocuspocusProvider(t, server, {
websocketProvider: socket1,
async onSynced() {
provider1.document.getArray("foo").push(["foo"]);
setTimeout(() => {
provider1.document.getArray("foo").push(["bar"]);
setTimeout(() => { // Wait for sending changes
socket1.destroy();
}, 200)
}, 1100)
},
});
setTimeout(() => {
const socket2 = newHocuspocusProviderWebsocket(t, server);
const provider2 = newHocuspocusProvider(t, server, {
websocketProvider: socket2,
async onSynced() {
setTimeout(() => {
const value = provider2.document.getArray("foo").toArray()
t.deepEqual(value, ["foo", "bar"], "Client 2 should see both changes");
t.pass()
resolve("done");
}, 500)
},
});
}, 1600)
});
});
test("Does not unload prematurely when a debounced save is pending (unloadImmediately=false)", async (t) => {
/*
Rough timeline:
1. ~0ms Client 1 connects
2. ~10ms Client 1 makes a change 1 (triggers debounced save)
3. ~1000ms Server starts saving change 1 (debounced)
4. ~1100ms Client 1 makes change 2 (triggers debounced save)
5. ~1300ms Client 1 disconnects, while change 2 is still being debounced
6. ~1500ms Server finishes saving change 1. Does not unload the document, because change 2 is still being debounced.
7. ~1600ms Client 2 connects
8. ~2000ms Server starts saving change 2 (debounced)
9. ~2500ms Server finishes saving change 2
10. ~3000ms We verify that Client 2 sees both changes.
*/
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
debounce: 1000,
unloadImmediately: false,
extensions: [new SlowInMemoryStorage()],
});
const socket1 = newHocuspocusProviderWebsocket(t, server);
const provider1 = newHocuspocusProvider(t, server, {
websocketProvider: socket1,
async onSynced() {
provider1.document.getArray("foo").push(["foo"]);
},
});
setTimeout(() => {
provider1.document.getArray("foo").push(["bar"]);
setTimeout(() => { // Wait for sending changes
socket1.destroy();
}, 200)
}, 1100)
setTimeout(() => {
const socket2 = newHocuspocusProviderWebsocket(t, server);
const provider2 = newHocuspocusProvider(t, server, {
websocketProvider: socket2,
async onSynced() {
setTimeout(() => {
t.deepEqual(getCurrentValue(provider2.document), ["foo", "bar"], "Client 2 should see both changes");
t.pass()
resolve("done");
}, 1500)
},
});
}, 1600)
});
});
const getCurrentValue = (doc: Y.Doc) => doc.getArray("foo").toArray()
class SlowInMemoryStorage implements Extension {
state: Uint8Array<ArrayBufferLike> | null = null
async onStoreDocument(data: onStoreDocumentPayload) {
const stateToSave = Y.encodeStateAsUpdate(data.document) // Capture state to save immediately
await sleep(500) // Add pause to simulate long save
this.state = stateToSave
}
async onLoadDocument(data: onLoadDocumentPayload) {
if (this.state) {
Y.applyUpdate(data.document, this.state)
}
}
}

365
tests/server/onTokenSync.ts Normal file
View File

@@ -0,0 +1,365 @@
import test from 'ava'
import type { onAuthenticatePayload, onTokenSyncPayload } from '@hocuspocus/server'
import { Unauthorized } from '@hocuspocus/common'
import {
newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket, sleep,
} from '../utils/index.ts'
// ============================================================================
// PROVIDER SEND TOKEN TESTS
// Provider calls sendToken() after onAuthenticated()
// ============================================================================
test('provider sendToken: onTokenSync receives correct token from provider after authentication', async t => {
await new Promise(async resolve => {
const expectedToken = 'UPDATED-TOKEN'
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true // Allow initial auth
},
async onTokenSync({ token }: onTokenSyncPayload) {
t.is(token, expectedToken)
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
token: 'INITIAL-TOKEN', // Initial token for auth
onAuthenticated() {
// Update the provider's token before sending
provider.configuration.token = expectedToken
// Provider sends updated token after authentication
provider.sendToken() // This will send the current token
},
})
})
})
test('provider sendToken: executes onTokenSync from custom extension when provider sends token', async t => {
await new Promise(async resolve => {
class CustomExtension {
async onAuthenticate() {
return true
}
async onTokenSync({ token }: onTokenSyncPayload) {
t.is(token, 'SUPER-SECRET-TOKEN')
t.pass()
resolve('done')
}
}
const server = await newHocuspocus(t, {
extensions: [
new CustomExtension(),
],
})
const provider = newHocuspocusProvider(t, server, {
token: 'SUPER-SECRET-TOKEN',
onAuthenticated() {
provider.sendToken()
},
})
})
})
test('provider sendToken: onTokenSync has access to full payload when provider sends token', async t => {
const mockContext = { user: 123 }
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return mockContext
},
async onTokenSync({ context, connection, documentName, token }: onTokenSyncPayload) {
t.deepEqual(context, mockContext)
t.truthy(connection)
t.is(documentName, 'hocuspocus-test')
t.is(token, 'SUPER-SECRET-TOKEN')
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
token: 'SUPER-SECRET-TOKEN',
onAuthenticated() {
provider.sendToken()
},
})
})
})
test('provider sendToken: onTokenSync works with multiple providers sending tokens', async t => {
const doc1 = 'document1'
const doc2 = 'document2'
const token1 = 'TOKEN-1'
const token2 = 'TOKEN-2'
let completedCount = 0
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true // Allow initial auth
},
async onTokenSync({ documentName, token }: onTokenSyncPayload) {
// Direct verification based on document name
if (documentName === doc1) {
t.is(token, token1)
} else if (documentName === doc2) {
t.is(token, token2)
}
completedCount++
if (completedCount === 2) {
resolve('done')
}
},
})
const socket = newHocuspocusProviderWebsocket(t, server)
// Create two providers for different documents
const provider1 = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: token1,
name: doc1,
onAuthenticated() {
provider1.sendToken()
},
})
const provider2 = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: token2,
name: doc2,
onAuthenticated() {
provider2.sendToken()
},
})
})
})
// ============================================================================
// SERVER REQUEST TOKEN TESTS
// Server calls connection.requestToken() after onAuthenticate() completes
// ============================================================================
test('server requestToken: executes onTokenSync when server requests token after authentication', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true
},
async onTokenSync({ token }: onTokenSyncPayload) {
t.is(token, 'SUPER-SECRET-TOKEN')
t.pass()
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
token: 'SUPER-SECRET-TOKEN',
})
// Wait for provider to be authenticated to be able to find the connection
await sleep(100)
const document = server.documents.get('hocuspocus-test')
if (document) {
const connection = document.connections.keys().next().value
if (connection) {
connection.requestToken()
}
}
})
})
test('server requestToken: executes onTokenSync from custom extension when server requests token', async t => {
await new Promise(async resolve => {
class CustomExtension {
async onAuthenticate() {
return true
}
async onTokenSync({ token }: onTokenSyncPayload) {
t.is(token, 'SUPER-SECRET-TOKEN')
t.pass()
resolve('done')
}
}
const server = await newHocuspocus(t, {
extensions: [
new CustomExtension(),
],
})
const provider = newHocuspocusProvider(t, server, {
token: 'SUPER-SECRET-TOKEN',
})
await sleep(100)
const document = server.documents.get('hocuspocus-test')
if (document) {
const connection = document.connections.keys().next().value
if (connection) {
connection.requestToken()
}
}
})
})
test('server requestToken: onTokenSync receives correct token when server requests it', async t => {
await new Promise(async resolve => {
const expectedToken = 'UPDATED-TOKEN'
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true
},
async onTokenSync({ token }: onTokenSyncPayload) {
t.is(token, expectedToken)
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
token: 'INITIAL-TOKEN',
onAuthenticated() {
// Update the provider's token after connection
provider.configuration.token = expectedToken
},
})
await sleep(100)
// Now trigger token sync request from server
const document = server.documents.get('hocuspocus-test')
if (document) {
const connection = document.connections.keys().next().value
if (connection) {
connection.requestToken()
}
}
})
})
test('server requestToken: onTokenSync works with multiple documents when server requests tokens', async t => {
const doc1 = 'document1'
const doc2 = 'document2'
const token1 = 'TOKEN-1'
const token2 = 'TOKEN-2'
let completedCount = 0
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true // Allow initial auth
},
async onTokenSync({ documentName, token }: onTokenSyncPayload) {
// Direct verification based on document name
if (documentName === doc1) {
t.is(token, token1)
} else if (documentName === doc2) {
t.is(token, token2)
}
completedCount++
if (completedCount === 2) {
resolve('done')
}
},
})
const socket = newHocuspocusProviderWebsocket(t, server)
// Create two providers for different documents
const provider1 = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: token1,
name: doc1,
})
const provider2 = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: token2,
name: doc2,
})
// Wait for both authentications to complete
await sleep(100)
// Now trigger token sync requests from server for both documents
const doc1Connection = server.documents.get(doc1)?.connections.keys().next().value
const doc2Connection = server.documents.get(doc2)?.connections.keys().next().value
if (doc1Connection) doc1Connection.requestToken()
if (doc2Connection) doc2Connection.requestToken()
})
})
test('server requestToken: onTokenSync works with readonly connections when server requests token', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate({ connectionConfig }: onAuthenticatePayload) {
connectionConfig.readOnly = true
return true
},
async onTokenSync({ connection }: onTokenSyncPayload) {
t.is(connection.readOnly, true)
resolve('done')
},
})
const provider = newHocuspocusProvider(t, server, {
token: 'readonly-token',
})
// Wait for authentication to complete first
await sleep(100)
// Now trigger token sync request from server
const document = server.documents.get('hocuspocus-test')
if (document) {
const connection = document.connections.keys().next().value
if (connection) {
connection.requestToken()
}
}
})
})
test('server requestToken: failure of onTokenSync should close the connection', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true // Allow initial auth
},
async onTokenSync() {
throw new Error()
},
})
const provider = newHocuspocusProvider(t, server, {
token: 'SUPER-SECRET-TOKEN',
onClose({ event }) {
t.is(event.reason, Unauthorized.reason)
t.pass()
resolve('done')
},
})
await sleep(100)
const document = server.documents.get('hocuspocus-test')
if (document) {
const connection = document.connections.keys().next().value
if (connection) {
connection.requestToken()
}
}
})
})

90
tests/server/onUpgrade.ts Normal file
View File

@@ -0,0 +1,90 @@
import test from 'ava'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
test('executes the onUpgrade callback', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onUpgrade() {
t.pass()
resolve('done')
},
})
newHocuspocusProvider(t, server)
})
})
test('executes the onUpgrade callback from an extension', async t => {
await new Promise(async resolve => {
class CustomExtension {
async onUpgrade() {
t.pass()
resolve('done')
}
}
const server = await newHocuspocus(t, {
extensions: [
new CustomExtension(),
],
})
newHocuspocusProvider(t, server)
})
})
test('has the server instance', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onUpgrade({ instance }) {
t.is(instance, server)
resolve('done')
},
})
newHocuspocusProvider(t, server)
})
})
test('has the request', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onUpgrade({ request }) {
t.is(request.url, '/')
resolve('done')
},
})
newHocuspocusProvider(t, server)
})
})
test('has the socket', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onUpgrade({ socket }) {
t.truthy(socket)
resolve('done')
},
})
newHocuspocusProvider(t, server)
})
})
test('has the head', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onUpgrade({ head }) {
t.truthy(head)
resolve('done')
},
})
newHocuspocusProvider(t, server)
})
})

View File

@@ -0,0 +1,466 @@
import { TiptapTransformer } from "@hocuspocus/transformer";
import test from "ava";
import * as Y from "yjs";
import { newHocuspocus, newHocuspocusProvider, sleep } from "../utils/index.ts";
test("rejects empty document name via direct connection", async (t) => {
const server = await newHocuspocus(t);
await t.throwsAsync(() => server.openDirectConnection(""), {
message: "Document name must not be empty",
});
t.is(server.getDocumentsCount(), 0);
});
test("rejects whitespace-only document name via direct connection", async (t) => {
const server = await newHocuspocus(t);
await t.throwsAsync(() => server.openDirectConnection(" "), {
message: "Document name must not be empty",
});
t.is(server.getDocumentsCount(), 0);
});
test("direct connection prevents document from being removed from memory", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
await server.openDirectConnection("hocuspocus-test");
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.configuration.websocketProvider.destroy();
provider.destroy();
sleep(server.configuration.debounce + 50).then(() => {
t.is(server.getDocumentsCount(), 1);
resolve("done");
});
},
});
});
});
test("direct connection works even if provider is connected", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getMap("config").set("a", "valueFromProvider");
},
});
await sleep(150);
const directConnection =
await server.openDirectConnection("hocuspocus-test");
await directConnection.transact((doc) => {
t.is("valueFromProvider", String(doc.getMap("config").get("a")));
doc.getMap("config").set("b", "valueFromServerDirectConnection");
});
await sleep(100);
t.is(
"valueFromServerDirectConnection",
String(provider.document.getMap("config").get("b")),
);
resolve(1);
t.pass();
});
});
test("direct connection can apply yjsUpdate", async (t) => {
const server = await newHocuspocus(t);
const expectedContent = "<paragraph>Example Paragraph</paragraph>";
let resolveProviderSynced!: () => void;
const providerSynced = new Promise<void>((resolve) => {
resolveProviderSynced = resolve;
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
resolveProviderSynced();
},
});
await providerSynced;
const fragment = provider.document.getXmlFragment("default");
t.is(fragment.toJSON(), "");
let resolveUpdateApplied!: () => void;
const updateApplied = new Promise<void>((resolve) => {
resolveUpdateApplied = resolve;
});
const observer = () => {
if (fragment.toJSON() === expectedContent) {
fragment.unobserveDeep(observer);
resolveUpdateApplied();
}
};
fragment.observeDeep(observer);
const directConnection =
await server.openDirectConnection("hocuspocus-test");
await directConnection.transact((doc) => {
Y.applyUpdate(
doc,
Y.encodeStateAsUpdate(
TiptapTransformer.toYdoc({
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Example Paragraph",
},
],
},
],
}),
),
);
});
await updateApplied;
t.is(fragment.toJSON(), expectedContent);
await directConnection.disconnect();
});
test("direct connection can transact", async (t) => {
const server = await newHocuspocus(t);
const direct = await server.openDirectConnection("hocuspocus-test");
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
t.is(direct.document?.getArray("test").toJSON()[0], "value");
});
test("direct connection cannot transact once closed", async (t) => {
const server = await newHocuspocus(t);
const direct = await server.openDirectConnection("hocuspocus-test");
await direct.disconnect();
try {
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
t.fail(
"DirectConnection should throw an error when transacting on closed connection",
);
} catch (err) {
if (err instanceof Error && err.message === "direct connection closed") {
t.pass();
} else {
t.fail("unknown error");
}
}
});
test("if a direct connection closes, the document should be unloaded if there is no other connection left", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
const direct = await server.openDirectConnection("hocuspocus-test1");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
await direct.disconnect();
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
resolve("done");
});
});
test("direct connection transact awaits until onStoreDocument has finished", async (t) => {
let onStoreDocumentFinished = false;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
onStoreDocument: async () => {
onStoreDocumentFinished = false;
await sleep(200);
onStoreDocumentFinished = true;
},
});
const direct = await server.openDirectConnection("hocuspocus-test2");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentFinished, false);
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
await direct.disconnect();
t.is(onStoreDocumentFinished, true);
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
t.is(onStoreDocumentFinished, true);
resolve("done");
});
});
test("direct connection transact awaits until onStoreDocument has finished, even if unloadImmediately=false", async (t) => {
let onStoreDocumentFinished = false;
let directConnDisconnecting = false;
let storedAfterDisconnect = false;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
unloadImmediately: false,
onStoreDocument: async () => {
onStoreDocumentFinished = false;
await sleep(200);
onStoreDocumentFinished = true;
if (directConnDisconnecting) {
storedAfterDisconnect = true;
}
},
afterUnloadDocument: async (data) => {
if (!storedAfterDisconnect) {
t.fail("this shouldnt be called");
}
},
});
const direct = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentFinished, false);
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
const provider = newHocuspocusProvider(t, server);
provider.document.getMap("aaa").set("bb", "b");
provider.disconnect();
provider.configuration.websocketProvider.disconnect();
await sleep(100);
directConnDisconnecting = true;
await direct.disconnect();
t.is(onStoreDocumentFinished, true);
t.is(server.getConnectionsCount(), 0);
t.is(storedAfterDisconnect, true);
resolve("done");
});
});
test("does not unload document if an earlierly started onStoreDocument is still running", async (t) => {
let onStoreDocumentStarted = 0;
let onStoreDocumentFinished = 0;
const server = await newHocuspocus(t, {
unloadImmediately: false,
debounce: 100,
onStoreDocument: async () => {
onStoreDocumentStarted++;
if (onStoreDocumentStarted === 1) {
// Simulate a long running onStoreDocument for the first debounced save
await sleep(500);
}
onStoreDocumentFinished++;
},
afterUnloadDocument: async (data) => {},
});
// Trigger a change, which will start a debounced onStoreDocument after 100ms
const provider = newHocuspocusProvider(t, server);
provider.document.getMap("aaa").set("bb", "b");
await new Promise(async (resolve) => {
provider.on("synced", resolve);
if (!provider.unsyncedChanges) resolve("");
});
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
// Wait for the debounced onStoreDocument to start
await sleep(110);
t.is(onStoreDocumentStarted, 1);
t.is(onStoreDocumentFinished, 0);
// Open direct connection to prevent document from being unloaded
const direct = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 2);
// Close the websocket client
provider.disconnect();
provider.configuration.websocketProvider.disconnect();
await sleep(50);
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentStarted, 1);
t.is(onStoreDocumentFinished, 0);
direct.disconnect();
await sleep(50);
// Another save must not start before the first one has finished
t.is(onStoreDocumentStarted, 1);
t.is(onStoreDocumentFinished, 0);
// Document must not be unloaded yet, because the first onStoreDocument is still running
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 0);
// Wait enough time to be sure the onStoreDocument has finished and ensure that the document was eventually unloaded
await sleep(500);
// The second onStoreDocument triggered by direct.disconnect must have started and finished now
t.is(onStoreDocumentStarted, 2);
t.is(onStoreDocumentFinished, 2);
// The document must have been unloaded now as well
t.is(server.getDocumentsCount(), 0);
});
test("creating a websocket connection after transact but before debounce interval doesnt create different docs", async (t) => {
let onStoreDocumentFinished = false;
let disconnected = false;
let testDone = false;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
onStoreDocument: async () => {
onStoreDocumentFinished = false;
await sleep(200);
onStoreDocumentFinished = true;
},
async afterUnloadDocument(data) {
if (disconnected && !testDone) {
t.fail("must not be called");
}
},
});
const direct = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentFinished, false);
await direct.transact((document) => {
document.transact(() => {
document.getArray("test").insert(0, ["value"]);
}, "testOrigin");
});
await direct.disconnect();
t.is(onStoreDocumentFinished, true);
disconnected = true;
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
t.is(onStoreDocumentFinished, true);
const provider = newHocuspocusProvider(t, server);
await sleep(server.configuration.debounce * 2);
testDone = true;
resolve("done");
});
});
test("direct connection passes context", async (t) => {
return new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onChange(x) {
t.is(x.context.x, 123);
t.pass();
resolve();
},
});
const direct = await server.openDirectConnection("hocuspocus-test", {
x: 123,
});
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
t.is(direct.document?.getArray("test").toJSON()[0], "value");
});
});
test("disconnect({ unloadImmediately: false }) keeps the document warm and coalesces stores", async (t) => {
let storeCount = 0;
const server = await newHocuspocus(t, {
debounce: 100,
maxDebounce: 500,
onStoreDocument: async () => {
storeCount += 1;
},
});
const first = await server.openDirectConnection("hocuspocus-test");
await first.transact((document) => {
document.getArray("test").insert(0, ["a"]);
});
await first.disconnect({ unloadImmediately: false });
// The document stays in memory and nothing is persisted yet.
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 1);
t.is(storeCount, 0);
// A follow-up direct connection reuses the warm document.
const second = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
await second.transact((document) => {
document.getArray("test").insert(1, ["b"]);
});
await second.disconnect({ unloadImmediately: false });
t.is(server.getDocumentsCount(), 1);
t.is(storeCount, 0);
// After the debounce window the store flushes once (coalesced) and the
// document is unloaded.
await sleep(server.configuration.maxDebounce + server.configuration.debounce + 100);
t.is(storeCount, 1);
t.is(server.getDocumentsCount(), 0);
});
test("disconnect() without options still persists and unloads immediately", async (t) => {
let storeCount = 0;
const server = await newHocuspocus(t, {
onStoreDocument: async () => {
storeCount += 1;
},
});
const direct = await server.openDirectConnection("hocuspocus-test");
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
await direct.disconnect();
t.is(storeCount, 1);
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
});

View File

@@ -0,0 +1,101 @@
import type {
connectedPayload,
onAuthenticatePayload,
onConnectPayload,
} from "@hocuspocus/server";
import test from "ava";
import {
newHocuspocus,
newHocuspocusProvider,
newHocuspocusProviderWebsocket,
} from "../utils/index.ts";
test("onAuthenticate receives providerVersion", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate({ providerVersion }: onAuthenticatePayload) {
t.is(typeof providerVersion, "string");
t.not(providerVersion, null);
resolve("done");
},
});
newHocuspocusProvider(t, server, {
token: "test-token",
});
});
});
test("onConnect receives providerVersion", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onConnect({ providerVersion }: onConnectPayload) {
t.is(typeof providerVersion, "string");
t.not(providerVersion, null);
resolve("done");
},
});
newHocuspocusProvider(t, server, {
token: "test-token",
});
});
});
test("connected receives providerVersion and it is set on the connection", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async connected({
providerVersion,
connection,
}: connectedPayload) {
t.is(typeof providerVersion, "string");
t.not(providerVersion, null);
t.is(connection.providerVersion, providerVersion);
resolve("done");
},
});
newHocuspocusProvider(t, server, {
token: "test-token",
});
});
});
test("providerVersion is a non-empty string", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate({ providerVersion }: onAuthenticatePayload) {
t.is(typeof providerVersion, "string");
t.truthy(providerVersion!.length > 0);
resolve("done");
},
});
newHocuspocusProvider(t, server, {
token: "test-token",
});
});
});
test("providerVersion is the same across multiplexed documents", async (t) => {
await new Promise(async (resolve) => {
const versions: string[] = [];
const server = await newHocuspocus(t, {
async onAuthenticate({ providerVersion }: onAuthenticatePayload) {
versions.push(providerVersion!);
if (versions.length === 2) {
t.is(versions[0], versions[1]);
t.not(versions[0], null);
resolve("done");
}
},
});
const ws = newHocuspocusProviderWebsocket(t, server);
newHocuspocusProvider(t, server, { name: "doc1", token: "t1" }, {}, ws);
newHocuspocusProvider(t, server, { name: "doc2", token: "t2" }, {}, ws);
});
});

View File

@@ -0,0 +1,329 @@
import { parseRoutingKey } from "@hocuspocus/common";
import type { onAuthenticatePayload, connectedPayload } from "@hocuspocus/server";
import test from "ava";
import { readVarString, createDecoder } from "lib0/decoding";
import {
newHocuspocus,
newHocuspocusProvider,
newHocuspocusProviderWebsocket,
} from "../utils/index.ts";
import { retryableAssertion } from "../utils/retryableAssertion.ts";
test("sessionAwareness: two providers with same doc name both connect successfully", async (t) => {
await new Promise(async (resolve) => {
let connectedCount = 0;
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true;
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const provider1 = newHocuspocusProvider(
t,
server,
{
websocketProvider: socket,
token: "token-1",
name: "shared-doc",
sessionAwareness: true,
onAuthenticated() {
connectedCount++;
if (connectedCount === 2) {
resolve("done");
}
},
},
);
const provider2 = newHocuspocusProvider(
t,
server,
{
websocketProvider: socket,
token: "token-2",
name: "shared-doc",
sessionAwareness: true,
onAuthenticated() {
connectedCount++;
if (connectedCount === 2) {
resolve("done");
}
},
},
);
t.truthy(provider1);
t.truthy(provider2);
});
});
test("sessionAwareness: auth failure isolation - provider A fails, provider B succeeds", async (t) => {
const server = await newHocuspocus(t, {
async onAuthenticate({ token }: onAuthenticatePayload) {
if (token === "bad-token") {
throw new Error("unauthorized");
}
return true;
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const providerFail = newHocuspocusProvider(
t,
server,
{
websocketProvider: socket,
token: "bad-token",
name: "shared-doc",
sessionAwareness: true,
onAuthenticated() {
t.fail("providerFail should not authenticate");
},
},
);
const providerOK = newHocuspocusProvider(
t,
server,
{
websocketProvider: socket,
token: "good-token",
name: "shared-doc",
sessionAwareness: true,
onAuthenticationFailed() {
t.fail("providerOK should not fail auth");
},
},
);
await retryableAssertion(t, (tt) => {
tt.is(providerFail.isAuthenticated, false);
tt.is(providerOK.isAuthenticated, true);
tt.is(server.getDocumentsCount(), 1);
});
});
test("sessionAwareness: false - two providers with same name on same socket throws when first is authenticated", async (t) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true;
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const provider1 = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: "token",
name: "same-doc",
sessionAwareness: false,
});
await retryableAssertion(t, (tt) => {
tt.is(provider1.isAuthenticated, true);
});
// Now that provider1 is authenticated, attaching a second with the same name should throw
t.throws(() => {
newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: "token",
name: "same-doc",
sessionAwareness: false,
});
});
});
test("sessionAwareness: connection has correct sessionId", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true;
},
async connected({ connection }: connectedPayload) {
t.truthy(connection.sessionId);
t.is(typeof connection.sessionId, "string");
t.truthy(connection.sessionId!.length > 0);
resolve("done");
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: "test-token",
name: "session-doc",
sessionAwareness: true,
});
});
});
test("sessionAwareness: connection has correct providerVersion", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true;
},
async connected({ connection }: connectedPayload) {
t.is(typeof connection.providerVersion, "string");
t.truthy(connection.providerVersion!.length > 0);
resolve("done");
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: "test-token",
name: "session-doc",
sessionAwareness: true,
});
});
});
test("sessionAwareness: all outgoing messages from provider include sessionId in routing key", async (t) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true;
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const sentMessages: Uint8Array[] = [];
const originalSend = socket.webSocket!.send.bind(socket.webSocket!);
const provider = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: "test-token",
name: "session-doc",
sessionAwareness: true,
});
await retryableAssertion(t, (tt) => {
tt.is(provider.isSynced, true);
});
// Monkey-patch send to capture outgoing messages
socket.webSocket!.send = (data: any) => {
if (data instanceof Uint8Array || data instanceof ArrayBuffer) {
sentMessages.push(new Uint8Array(data));
}
originalSend(data);
};
// Trigger a document update which causes the provider to send an UpdateMessage
provider.document.getMap("test").set("key", "value");
await retryableAssertion(t, (tt) => {
tt.true(sentMessages.length > 0, "should have captured outgoing messages");
for (const msg of sentMessages) {
const decoder = createDecoder(msg);
const routingKey = readVarString(decoder);
const { documentName, sessionId } = parseRoutingKey(routingKey);
tt.is(documentName, "session-doc", `message routing key should contain correct document name, got: ${routingKey}`);
tt.truthy(sessionId, `every outgoing message should include a sessionId in the routing key, got: ${routingKey}`);
}
});
});
test("sessionAwareness: SyncStep2 reply includes sessionId in routing key", async (t) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true;
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
const provider = newHocuspocusProvider(t, server, {
websocketProvider: socket,
token: "test-token",
name: "sync-reply-doc",
sessionAwareness: true,
});
await retryableAssertion(t, (tt) => {
tt.is(provider.isSynced, true);
});
// Capture all outgoing messages
const sentMessages: Uint8Array[] = [];
const originalSend = socket.webSocket!.send.bind(socket.webSocket!);
socket.webSocket!.send = (data: any) => {
if (data instanceof Uint8Array || data instanceof ArrayBuffer) {
sentMessages.push(new Uint8Array(data));
}
originalSend(data);
};
// Force a sync which triggers SyncStep1 -> server replies with SyncStep1 -> provider replies with SyncStep2
provider.forceSync();
await retryableAssertion(t, (tt) => {
// We expect at least the SyncStep1 from forceSync plus the SyncStep2 reply
tt.true(sentMessages.length >= 2, `should have at least 2 messages, got ${sentMessages.length}`);
for (const msg of sentMessages) {
const decoder = createDecoder(msg);
const routingKey = readVarString(decoder);
const { sessionId } = parseRoutingKey(routingKey);
tt.truthy(sessionId, `message should include sessionId, got routing key: ${routingKey}`);
}
});
});
test("sessionAwareness: providers with different doc names still work without sessionAwareness", async (t) => {
await new Promise(async (resolve) => {
let connectedCount = 0;
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true;
},
});
const socket = newHocuspocusProviderWebsocket(t, server);
newHocuspocusProvider(
t,
server,
{
websocketProvider: socket,
token: "token-1",
name: "doc-1",
onAuthenticated() {
connectedCount++;
if (connectedCount === 2) {
resolve("done");
}
},
},
);
newHocuspocusProvider(
t,
server,
{
websocketProvider: socket,
token: "token-2",
name: "doc-2",
onAuthenticated() {
connectedCount++;
if (connectedCount === 2) {
resolve("done");
}
},
},
);
t.pass();
});
});

View File

@@ -0,0 +1,227 @@
import test from 'ava'
import * as encoding from 'lib0/encoding'
import { writeAuthentication } from '@hocuspocus/common'
import { newHocuspocus } from '../utils/index.ts'
// Wire-level MessageType values (mirrors packages/server/src/types.ts).
const MessageType = {
Auth: 2,
Stateless: 5,
}
const buildStatelessFrame = (documentName: string, payloadSize: number): Uint8Array => {
const encoder = encoding.createEncoder()
encoding.writeVarString(encoder, documentName)
encoding.writeVarUint(encoder, MessageType.Stateless)
encoding.writeVarString(encoder, 'A'.repeat(payloadSize))
return encoding.toUint8Array(encoder)
}
const buildAuthFrame = (documentName: string, token: string): Uint8Array => {
const encoder = encoding.createEncoder()
encoding.writeVarString(encoder, documentName)
encoding.writeVarUint(encoder, MessageType.Auth)
writeAuthentication(encoder, token)
return encoding.toUint8Array(encoder)
}
const buildCloseFrame = (documentName: string): Uint8Array => {
const encoder = encoding.createEncoder()
encoding.writeVarString(encoder, documentName)
encoding.writeVarUint(encoder, 7) // MessageType.CLOSE
return encoding.toUint8Array(encoder)
}
const openRawSocket = (url: string): Promise<WebSocket> => new Promise((resolve, reject) => {
const ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.addEventListener('open', () => resolve(ws), { once: true })
ws.addEventListener('error', (event) => reject(event), { once: true })
})
// Wait for the server to close the socket, returning the close code.
const waitForClose = (ws: WebSocket, timeoutMs = 5000): Promise<number> => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('connection was not closed by the server')), timeoutMs)
ws.addEventListener('close', (event) => {
clearTimeout(timer)
resolve(event.code)
}, { once: true })
})
test('closes an unauthenticated connection that exceeds the pre-auth message-count limit', async t => {
const server = await newHocuspocus(t, {
maxUnauthenticatedQueueMessages: 5,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
const closed = waitForClose(attacker)
// Flood many small non-Auth frames before authenticating. The server must
// stop queuing and close the connection once the count limit is exceeded.
for (let i = 0; i < 50; i += 1) {
attacker.send(buildStatelessFrame('queue-count-doc', 16))
}
const code = await closed
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
})
test('closes an unauthenticated connection that exceeds the pre-auth byte limit', async t => {
const server = await newHocuspocus(t, {
// 256 KB total budget across the unauthenticated queue.
maxUnauthenticatedQueueSize: 256 * 1024,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
const closed = waitForClose(attacker)
// Each frame carries ~64 KB; the 5th frame crosses the 256 KB budget.
for (let i = 0; i < 20; i += 1) {
attacker.send(buildStatelessFrame('queue-bytes-doc', 64 * 1024))
}
const code = await closed
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
})
test('closes an unauthenticated connection that opens too many pending documents', async t => {
const server = await newHocuspocus(t, {
maxPendingDocuments: 3,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
const closed = waitForClose(attacker)
// Spread a single small frame across many distinct document keys. A per-key
// limit alone would not catch this amplification; a per-connection cap does.
for (let i = 0; i < 25; i += 1) {
attacker.send(buildStatelessFrame(`pending-doc-${i}`, 16))
}
const code = await closed
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
})
test('closes a continuously-sending unauthenticated connection once the pre-auth timeout elapses', async t => {
const server = await newHocuspocus(t, {
timeout: 1000,
// Keep queue limits high so the timeout — not the queue cap — is what fires.
maxUnauthenticatedQueueMessages: 1_000_000,
maxUnauthenticatedQueueSize: 1024 * 1024 * 1024,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
const closed = waitForClose(attacker, 6000)
// Keep refreshing activity with tiny frames. The pre-auth deadline must not
// be reset by inbound traffic, so the connection still closes.
const interval = setInterval(() => {
if (attacker.readyState === WebSocket.OPEN) {
attacker.send(buildStatelessFrame('timeout-doc', 8))
}
}, 200)
t.teardown(() => {
clearInterval(interval)
attacker.close()
})
const code = await closed
clearInterval(interval)
t.is(code, 4408, 'connection should be closed with ConnectionTimeout (4408)')
})
test('counts in-flight authentications toward the pending-document limit', async t => {
const server = await newHocuspocus(t, {
maxPendingDocuments: 3,
// Never resolves, so every document stays in the "authenticating" state.
// Sending Auth first marks a document as establishing but keeps its hook
// payload alive while onAuthenticate runs — this must still count.
onAuthenticate: () => new Promise(() => {}),
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
const closed = waitForClose(attacker)
// Send Auth as the FIRST frame for many distinct documents.
for (let i = 0; i < 25; i += 1) {
attacker.send(buildAuthFrame(`auth-first-doc-${i}`, ''))
}
const code = await closed
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
})
test('does not close an authenticated connection that has closed all of its documents', async t => {
const server = await newHocuspocus(t, {
timeout: 600,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
// Authenticate a document (no onAuthenticate hook => allowed).
const authenticated = new Promise<void>((resolve) => {
attacker.addEventListener('message', () => resolve(), { once: true })
})
attacker.send(buildAuthFrame('soon-empty-doc', ''))
await authenticated
let closedCode: number | undefined
attacker.addEventListener('close', (event) => { closedCode = event.code }, { once: true })
// Keep the socket alive with periodic frames so `lastMessageReceivedAt` stays
// fresh. Halfway through, close the only document, leaving zero established
// document connections. Because the connection already authenticated, the idle
// check must keep using `lastMessageReceivedAt` rather than the original open
// time — so the socket must survive past `timeout` measured from open.
const interval = setInterval(() => {
if (attacker.readyState === WebSocket.OPEN) {
attacker.send(buildStatelessFrame('keepalive-doc', 8))
}
}, 150)
t.teardown(() => clearInterval(interval))
await new Promise((resolve) => setTimeout(resolve, 700))
attacker.send(buildCloseFrame('soon-empty-doc'))
await new Promise((resolve) => setTimeout(resolve, 1100))
clearInterval(interval)
t.is(closedCode, undefined, 'authenticated connection must not be closed by the pre-auth deadline')
})
test('does not close an authenticated connection that later exceeds the pre-auth limits', async t => {
const server = await newHocuspocus(t, {
maxUnauthenticatedQueueMessages: 5,
maxUnauthenticatedQueueSize: 64 * 1024,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
// Authenticate first (no onAuthenticate hook => allowed).
const authenticated = new Promise<void>((resolve) => {
attacker.addEventListener('message', () => resolve(), { once: true })
})
attacker.send(buildAuthFrame('authenticated-doc', ''))
await authenticated
// After auth, large/many frames flow through the established connection and
// are not subject to the pre-auth queue caps.
let closedEarly = false
attacker.addEventListener('close', () => { closedEarly = true }, { once: true })
for (let i = 0; i < 50; i += 1) {
attacker.send(buildStatelessFrame('authenticated-doc', 64 * 1024))
}
await new Promise((resolve) => setTimeout(resolve, 300))
t.false(closedEarly, 'authenticated connection must not be closed by pre-auth queue limits')
})

View File

@@ -0,0 +1,38 @@
import test from 'ava'
import type { onAuthenticatePayload } from '@hocuspocus/server'
import { newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket } from '../utils/index.ts'
test('does not crash when malformed message is sent pre-authentication', async t => {
await new Promise(async resolve => {
const server = await newHocuspocus(t, {
async onAuthenticate(data: onAuthenticatePayload) {
return new Promise(async resolve => {
setTimeout(resolve, 2000)
})
},
})
const socket = newHocuspocusProviderWebsocket(t, server)
let interval: ReturnType<typeof setInterval>
const provider = newHocuspocusProvider(t, server, {
websocketProvider: socket,
onClose({ event }) {
t.is(event.code, 4401)
clearInterval(interval)
provider.destroy()
},
onDestroy() {
t.pass()
resolve(true)
},
})
interval = setInterval(() => {
if (socket.webSocket) {
socket.webSocket.send('ϩ') // eslint-disable-line
}
}, 500)
})
})

View File

@@ -0,0 +1,73 @@
import test from 'ava'
import { TiptapTransformer } from '@hocuspocus/transformer'
test('transforms JSON to Y.Doc', async t => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
},
],
},
],
}
const ydoc = TiptapTransformer.toYdoc(json, 'content')
t.is(
ydoc.getXmlFragment('content').toJSON(),
'<paragraph>Example Text</paragraph>',
)
})
test('writes to the correct Y.Doc field', async t => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
},
],
},
],
}
const ydoc = TiptapTransformer.toYdoc(json, 'mySuperCustomField')
t.is(
ydoc.getXmlFragment('mySuperCustomField').toJSON(),
'<paragraph>Example Text</paragraph>',
)
})
test('throws a helpful error when the document is empty', async t => {
const invalidJson = null
const error = t.throws(() => {
TiptapTransformer.toYdoc(invalidJson, 'content')
}, { instanceOf: Error })
t.truthy(error?.message.includes('ProseMirror-compatible JSON'))
})
test('throws a helpful error when the document is invalid', async t => {
const invalidJson = {
type: 'invalidType',
content: [],
}
const error = t.throws(() => {
TiptapTransformer.toYdoc(invalidJson, 'content')
}, { instanceOf: Error })
t.truthy(error?.message.includes('Unknown node type: invalidType'))
})

View File

@@ -0,0 +1,6 @@
import process from "node:process";
import { registerCompletionHandler } from "ava";
registerCompletionHandler(() => {
process.exit(); // eslint-disable-line
});

View File

@@ -0,0 +1,9 @@
import fs from 'fs'
export const createDirectory = (dir: string) => {
try {
fs.mkdir(dir, () => {})
} catch {
//
}
}

13
tests/utils/flushRedis.ts Normal file
View File

@@ -0,0 +1,13 @@
// @ts-expect-error
import { createClient } from 'redis'
import { redisConnectionSettings } from './redisConnectionSettings.ts'
export const flushRedis = async () => {
const client = createClient({
url: `redis://${redisConnectionSettings.host}:${redisConnectionSettings.port}`,
})
await client.connect()
return client.flushDb()
}

9
tests/utils/index.ts Normal file
View File

@@ -0,0 +1,9 @@
export * from './createDirectory.ts'
export * from './flushRedis.ts'
export * from './newHocuspocus.ts'
export * from './newHocuspocusProvider.ts'
export * from './newHocuspocusProviderWebsocket.ts'
export * from './randomInteger.ts'
export * from './redisConnectionSettings.ts'
export * from './removeDirectory.ts'
export * from './sleep.ts'

View File

@@ -0,0 +1,35 @@
import type { ExecutionContext } from "ava";
import type { ServerConfiguration } from "@hocuspocus/server";
import { Server } from "@hocuspocus/server";
export const newHocuspocus = (
t: ExecutionContext,
options?: Partial<ServerConfiguration>,
) => {
const server = new Server({
// We don't need the logging in testing.
quiet: true,
// Binding something port 0 will end up on a random free port.
// That's helpful to run tests concurrently.
port: 0,
// Ava 6+ considers process.exit() in workers an error.
// Disable signal handlers to prevent that.
stopOnSignals: false,
// Add or overwrite settings, depending on the test case.
...options,
});
t.teardown(() => {
// Close WebSocket connections and shut down the HTTP server.
// We avoid calling server.destroy() because it triggers lifecycle
// hooks (onDestroy, afterUnloadDocument) which could run assertions
// after the test has finished (Ava 6+ treats this as an error).
// The _force-exit completion handler ensures the process exits
// even if some handles remain open.
server.hocuspocus.closeConnections();
server.httpServer.closeAllConnections();
server.httpServer.close();
});
return server.listen();
};

View File

@@ -0,0 +1,32 @@
import type { ExecutionContext } from "ava";
import {
HocuspocusProvider,
type HocuspocusProviderConfiguration,
type HocuspocusProviderWebsocket,
type HocuspocusProviderWebsocketConfiguration,
} from "@hocuspocus/provider";
import type { Hocuspocus } from "@hocuspocus/server";
import { newHocuspocusProviderWebsocket } from "./newHocuspocusProviderWebsocket.ts";
export const newHocuspocusProvider = (
t: ExecutionContext,
server: Hocuspocus,
options: Partial<HocuspocusProviderConfiguration> = {},
websocketOptions: Partial<HocuspocusProviderWebsocketConfiguration> = {},
websocketProvider?: HocuspocusProviderWebsocket,
): HocuspocusProvider => {
const provider = new HocuspocusProvider({
websocketProvider:
websocketProvider ??
newHocuspocusProviderWebsocket(t, server, websocketOptions),
// Just use a generic document name for all tests.
name: "hocuspocus-test",
// Add or overwrite settings, depending on the test case.
...options,
});
provider.attach();
t.teardown(() => provider.destroy());
return provider;
};

View File

@@ -0,0 +1,22 @@
import type { ExecutionContext } from "ava";
import type { HocuspocusProviderWebsocketConfiguration } from "@hocuspocus/provider";
import { HocuspocusProviderWebsocket } from "@hocuspocus/provider";
import type { Hocuspocus } from "@hocuspocus/server";
export const newHocuspocusProviderWebsocket = (
t: ExecutionContext,
hocuspocus: Hocuspocus,
options: Partial<Omit<HocuspocusProviderWebsocketConfiguration, "url">> = {},
) => {
const ws = new HocuspocusProviderWebsocket({
// We don't need which port the server is running on, but
// we can get the URL from the passed server instance.
url: hocuspocus.server!.webSocketURL,
// Node.js 22+ has native WebSocket support
...options,
});
t.teardown(() => ws.destroy());
return ws;
};

View File

@@ -0,0 +1,3 @@
export const randomInteger = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1) + min)
}

View File

@@ -0,0 +1,4 @@
export const redisConnectionSettings = {
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number.parseInt(process.env.REDIS_PORT || '', 10) || 6379,
}

View File

@@ -0,0 +1,27 @@
import fs from 'fs'
import path from 'path'
export const removeDirectory = (dir: string) => {
try {
const list = fs.readdirSync(dir)
for (let i = 0; i < list.length; i += 1) {
const filename = path.join(dir, list[i])
const stat = fs.statSync(filename)
if (filename === '.' || filename === '..') {
// pass these files
} else if (stat.isDirectory()) {
// rmdir recursively
removeDirectory(filename)
} else {
// rm fiilename
fs.unlinkSync(filename)
}
}
fs.rmdirSync(dir)
} catch {
//
}
}

View File

@@ -0,0 +1,18 @@
import type { ExecutionContext } from 'ava'
import { sleep } from './sleep.ts'
/* eslint-disable no-await-in-loop */
export const retryableAssertion = async (t: ExecutionContext, recoverableTry: (tt: ExecutionContext) => void) => {
// eslint-disable-next-line no-constant-condition
while (true) {
const lastTry = await t.try(recoverableTry)
if (lastTry.passed) {
lastTry.commit()
break
}
lastTry.discard()
await sleep(100)
}
}

5
tests/utils/sleep.ts Normal file
View File

@@ -0,0 +1,5 @@
export const sleep = (time: number) => {
return new Promise(async resolve => {
setTimeout(resolve, time)
})
}