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
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:
891
tests/CHANGELOG.md
Normal file
891
tests/CHANGELOG.md
Normal 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))
|
||||
56
tests/extension-database/fetch.ts
Normal file
56
tests/extension-database/fetch.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
83
tests/extension-logger/onListen.ts
Normal file
83
tests/extension-logger/onListen.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
106
tests/extension-redis/onAwarenessChange.ts
Normal file
106
tests/extension-redis/onAwarenessChange.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
60
tests/extension-redis/onChange.ts
Normal file
60
tests/extension-redis/onChange.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
232
tests/extension-redis/onStateless.ts
Normal file
232
tests/extension-redis/onStateless.ts
Normal 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, []);
|
||||
});
|
||||
155
tests/extension-redis/onStoreDocument.ts
Normal file
155
tests/extension-redis/onStoreDocument.ts
Normal 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)
|
||||
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
74
tests/extension-redis/openDirectConnection.ts
Normal file
74
tests/extension-redis/openDirectConnection.ts
Normal 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);
|
||||
});
|
||||
205
tests/extension-redis/publishCoalescing.ts
Normal file
205
tests/extension-redis/publishCoalescing.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
173
tests/extension-redis/syncStep2Routing.ts
Normal file
173
tests/extension-redis/syncStep2Routing.ts
Normal 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
153
tests/extension-s3/fetch.ts
Normal 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}`);
|
||||
});
|
||||
103
tests/extension-throttle/banning.ts
Normal file
103
tests/extension-throttle/banning.ts
Normal 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()
|
||||
})
|
||||
26
tests/extension-throttle/configuration.ts
Normal file
26
tests/extension-throttle/configuration.ts
Normal 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
28
tests/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
79
tests/provider/awarenessEcho.ts
Normal file
79
tests/provider/awarenessEcho.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
138
tests/provider/flushDelay.ts
Normal file
138
tests/provider/flushDelay.ts
Normal 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')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
187
tests/provider/hasUnsyncedChanges.ts
Normal file
187
tests/provider/hasUnsyncedChanges.ts
Normal 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
83
tests/provider/observe.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
82
tests/provider/observeDeep.ts
Normal file
82
tests/provider/observeDeep.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
91
tests/provider/onAuthenticated.ts
Normal file
91
tests/provider/onAuthenticated.ts
Normal 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')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
20
tests/provider/onAuthenticationFailed.ts
Normal file
20
tests/provider/onAuthenticationFailed.ts
Normal 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')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
90
tests/provider/onAuthenticationFailedRetry.ts
Normal file
90
tests/provider/onAuthenticationFailedRetry.ts
Normal 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);
|
||||
});
|
||||
169
tests/provider/onAwarenessChange.ts
Normal file
169
tests/provider/onAwarenessChange.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
131
tests/provider/onAwarenessUpdate.ts
Normal file
131
tests/provider/onAwarenessUpdate.ts
Normal 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
35
tests/provider/onClose.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
28
tests/provider/onConnect.ts
Normal file
28
tests/provider/onConnect.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
36
tests/provider/onDisconnect.ts
Normal file
36
tests/provider/onDisconnect.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
28
tests/provider/onMessage.ts
Normal file
28
tests/provider/onMessage.ts
Normal 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
28
tests/provider/onOpen.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
21
tests/provider/onStateless.ts
Normal file
21
tests/provider/onStateless.ts
Normal 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
109
tests/provider/onSynced.ts
Normal 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')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
18
tests/providerwebsocket/configuration.ts
Normal file
18
tests/providerwebsocket/configuration.ts
Normal 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)
|
||||
})
|
||||
151
tests/providerwebsocket/maxAttempts.ts
Normal file
151
tests/providerwebsocket/maxAttempts.ts
Normal 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);
|
||||
});
|
||||
160
tests/providerwebsocket/messageQueueDeduplication.ts
Normal file
160
tests/providerwebsocket/messageQueueDeduplication.ts
Normal 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
61
tests/server/address.ts
Normal 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!')
|
||||
})
|
||||
102
tests/server/afterHandleMessage.ts
Normal file
102
tests/server/afterHandleMessage.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
65
tests/server/afterLoadDocument.ts
Normal file
65
tests/server/afterLoadDocument.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
47
tests/server/afterStoreDocument.ts
Normal file
47
tests/server/afterStoreDocument.ts
Normal 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'])
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
77
tests/server/afterUnloadDocument.ts
Normal file
77
tests/server/afterUnloadDocument.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
21
tests/server/beforeBroadcastStateless.ts
Normal file
21
tests/server/beforeBroadcastStateless.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
239
tests/server/beforeHandleAwareness.ts
Normal file
239
tests/server/beforeHandleAwareness.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
111
tests/server/beforeHandleMessage.ts
Normal file
111
tests/server/beforeHandleMessage.ts
Normal 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
162
tests/server/beforeSync.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
81
tests/server/beforeUnloadDocument.ts
Normal file
81
tests/server/beforeUnloadDocument.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
tests/server/broadcastEncoding.ts
Normal file
153
tests/server/broadcastEncoding.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
104
tests/server/broadcastStatelessBypass.ts
Normal file
104
tests/server/broadcastStatelessBypass.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
85
tests/server/closeConnections.ts
Normal file
85
tests/server/closeConnections.ts
Normal 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
31
tests/server/debounce.ts
Normal 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
27
tests/server/destroy.ts
Normal 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
268
tests/server/flushDelay.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
130
tests/server/getConnectionsCount.ts
Normal file
130
tests/server/getConnectionsCount.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
93
tests/server/getDocumentsCount.ts
Normal file
93
tests/server/getDocumentsCount.ts
Normal 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
105
tests/server/listen.ts
Normal 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!')
|
||||
})
|
||||
333
tests/server/onAuthenticate.ts
Normal file
333
tests/server/onAuthenticate.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
85
tests/server/onAwarenessUpdate.ts
Normal file
85
tests/server/onAwarenessUpdate.ts
Normal 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
158
tests/server/onChange.ts
Normal 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
70
tests/server/onClose.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
62
tests/server/onConfigure.ts
Normal file
62
tests/server/onConfigure.ts
Normal 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
330
tests/server/onConnect.ts
Normal 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
275
tests/server/onDestroy.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
114
tests/server/onDisconnect.ts
Normal file
114
tests/server/onDisconnect.ts
Normal 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
52
tests/server/onListen.ts
Normal 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')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
518
tests/server/onLoadDocument.ts
Normal file
518
tests/server/onLoadDocument.ts
Normal 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
81
tests/server/onRequest.ts
Normal 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
101
tests/server/onStateless.ts
Normal 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')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
768
tests/server/onStoreDocument.ts
Normal file
768
tests/server/onStoreDocument.ts
Normal 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
365
tests/server/onTokenSync.ts
Normal 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
90
tests/server/onUpgrade.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
466
tests/server/openDirectConnection.ts
Normal file
466
tests/server/openDirectConnection.ts
Normal 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);
|
||||
});
|
||||
101
tests/server/providerVersion.ts
Normal file
101
tests/server/providerVersion.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
329
tests/server/sessionAwareness.ts
Normal file
329
tests/server/sessionAwareness.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
227
tests/server/unauthenticatedQueueLimit.ts
Normal file
227
tests/server/unauthenticatedQueueLimit.ts
Normal 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')
|
||||
})
|
||||
38
tests/server/websocketError.ts
Normal file
38
tests/server/websocketError.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
73
tests/transformer/TiptapTransformer.ts
Normal file
73
tests/transformer/TiptapTransformer.ts
Normal 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'))
|
||||
})
|
||||
6
tests/utils/_force-exit.ts
Normal file
6
tests/utils/_force-exit.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import process from "node:process";
|
||||
import { registerCompletionHandler } from "ava";
|
||||
|
||||
registerCompletionHandler(() => {
|
||||
process.exit(); // eslint-disable-line
|
||||
});
|
||||
9
tests/utils/createDirectory.ts
Normal file
9
tests/utils/createDirectory.ts
Normal 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
13
tests/utils/flushRedis.ts
Normal 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
9
tests/utils/index.ts
Normal 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'
|
||||
35
tests/utils/newHocuspocus.ts
Normal file
35
tests/utils/newHocuspocus.ts
Normal 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();
|
||||
};
|
||||
32
tests/utils/newHocuspocusProvider.ts
Normal file
32
tests/utils/newHocuspocusProvider.ts
Normal 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;
|
||||
};
|
||||
22
tests/utils/newHocuspocusProviderWebsocket.ts
Normal file
22
tests/utils/newHocuspocusProviderWebsocket.ts
Normal 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;
|
||||
};
|
||||
3
tests/utils/randomInteger.ts
Normal file
3
tests/utils/randomInteger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const randomInteger = (min: number, max: number) => {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min)
|
||||
}
|
||||
4
tests/utils/redisConnectionSettings.ts
Normal file
4
tests/utils/redisConnectionSettings.ts
Normal 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,
|
||||
}
|
||||
27
tests/utils/removeDirectory.ts
Normal file
27
tests/utils/removeDirectory.ts
Normal 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 {
|
||||
//
|
||||
}
|
||||
}
|
||||
18
tests/utils/retryableAssertion.ts
Normal file
18
tests/utils/retryableAssertion.ts
Normal 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
5
tests/utils/sleep.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const sleep = (time: number) => {
|
||||
return new Promise(async resolve => {
|
||||
setTimeout(resolve, time)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user