Testing
Prove the parts that actually break — that authorize denies, that a version snapshot is the version's, that the client survives a remount, and that a storage failure is reported — using in-memory storage, fake transports and no server.
Real-time code has an unusual test profile: the sync engine is somebody else's well-tested library, and the bugs are almost always in the seams around it. Four things are worth your test budget, and none of them need a WebSocket.
1. That authorize denies
This is the one that matters most, because the library fails closed and a rule that accidentally allows is invisible until it is a support ticket.
The manager resolves the same way the handshake and the token endpoint do, so testing it directly tests both paths:
import { CollaborationManager, defineDocument } from '@adonis-agora/collaboration'
const manager = new CollaborationManager({
engine: 'yjs',
documents: {
'researches/:id/writing': defineDocument<{ id: string }>({
authorize: async (ctx, { params }) => {
const research = await Research.find(params.id)
const mine = research?.ownerId === ctx.userId
return { canRead: mine, canWrite: mine, canComment: mine }
},
}),
},
})
test('the owner may read', async ({ assert }) => {
const permission = await manager.authorize({ userId: owner.id }, `researches/${research.id}/writing`)
assert.isTrue(permission.canRead)
})
test('a stranger may not', async ({ assert }) => {
const permission = await manager.authorize({ userId: other.id }, `researches/${research.id}/writing`)
assert.isFalse(permission.canRead)
})
test('an undeclared document is denied, not allowed', async ({ assert }) => {
const permission = await manager.authorize({ userId: owner.id }, 'secrets/1')
assert.deepEqual(permission, { canRead: false, canWrite: false, canComment: false })
})That third test is the one people skip and the one that catches a mistyped pattern.
Assert the denial, not just the grant
A rule that returns { canRead: true } unconditionally passes every "the owner may read" test ever
written. The negative cases are the test.
2. That storage honours its contract
If you wrote your own storage, the contract that fails
silently is loadVersionSnapshot: returning the current document instead of the version's bytes
makes restoreVersion a no-op and diffVersions always report zero, with no error anywhere.
test('a version snapshot is the version, not the present', async ({ assert }) => {
const storage = new MyStorage()
await storage.saveDocument('doc/1', new Uint8Array([1, 2, 3]))
await storage.saveVersion(
'doc/1',
{ id: 'v1', seq: 1, label: null, createdBy: null, createdAt: new Date().toISOString() },
new Uint8Array([1, 2, 3]),
)
// the document moves on…
await storage.saveDocument('doc/1', new Uint8Array([9, 9, 9]))
// …and the snapshot must not
assert.deepEqual(await storage.loadVersionSnapshot('doc/1', 'v1'), new Uint8Array([1, 2, 3]))
})
test('saveComment upserts rather than inserting twice', async ({ assert }) => {
await storage.saveComment('doc/1', comment)
await storage.saveComment('doc/1', { ...comment, resolvedAt: new Date().toISOString() })
const comments = await storage.listComments('doc/1')
assert.lengthOf(comments, 1)
assert.isNotNull(comments[0].resolvedAt)
})InMemoryCollaborationStorage is exported and implements the contract correctly, so it doubles as
the reference to compare against — and as the storage for any test that needs a backend without
caring which.
import { InMemoryCollaborationStorage } from '@adonis-agora/collaboration'
const manager = new CollaborationManager({
engine: 'yjs',
storage: new InMemoryCollaborationStorage(),
authorize: async () => ({ canRead: true, canWrite: true, canComment: true }),
})3. That the client behaves without a server
The provider takes two injection points — fetchImpl for REST and createTransport for the
socket — so component tests never open a connection:
import { CollaborationProvider } from '@adonis-agora/collaboration-client'
import * as Y from 'yjs'
class FakeTransport {
status: 'connecting' | 'connected' | 'disconnected' | 'error' = 'connecting'
synced = false
awareness = null
#listeners = new Set<() => void>()
constructor(readonly doc: Y.Doc) {}
getStatus() { return this.status }
// Optional on the interface — implement it and your test can hold the
// component in its pre-sync state, which is the one most UIs get wrong.
// A transport without it reports `connected` as synced.
isSynced() { return this.synced }
subscribe(listener: () => void) { this.#listeners.add(listener); return () => this.#listeners.delete(listener) }
destroy() {}
/** Drive the states the UI has to handle. */
emit(status: typeof this.status, synced = this.synced) {
this.status = status
this.synced = synced
for (const listener of this.#listeners) listener()
}
}
function renderEditor() {
const fetchImpl = async () =>
new Response(JSON.stringify({ token: 't', wsUrl: '/collaboration', engine: 'yjs' }))
return render(
<CollaborationProvider
baseUrl="http://test"
fetchImpl={fetchImpl as typeof fetch}
createTransport={(_info, doc) => new FakeTransport(doc)}
>
<WritingEditor researchId="42" />
</CollaborationProvider>,
)
}With that in place you can assert the things that are genuinely easy to get wrong: nothing paints
the document until synced, an error renders a message instead of a blank page, and a
disconnected transition shows the reconnecting state without locking the editor — a dropped
socket must not take the writer's keyboard away.
const transport = /* the FakeTransport your factory handed out */
transport.emit('connected', false) // socket open, state not applied yet
// → still the loading state
transport.emit('connected', true)
// → the document renders
transport.emit('disconnected')
// → a reconnecting indicator, and the editor is STILL editableThe other lifecycle worth a test is the remount. Unmount the component and render it again: the
provider hands back the same session and the same Y.Doc, so whatever was typed is still there.
That is the reference count doing its job — a test that fails here is the class of bug where
navigating back gave people a blank editor.
REST-backed hooks need only fetchImpl:
test('renders the comment sidebar', async () => {
const fetchImpl = async () => new Response(JSON.stringify([anchoredComment]))
// …assert the sidebar renders it
})4. That a failure is observable
Worth a few lines because the failure it guards against is invisible by construction: storage errors
happen inside socket hooks that swallow throws. onCollaborationError is the seam, and it is
subscribable from a test:
import { onCollaborationError } from '@adonis-agora/collaboration'
test('a storage outage is reported, not swallowed', async ({ assert }) => {
const seen: string[] = []
const off = onCollaborationError((event) => seen.push(`${event.scope}.${event.operation}`))
// …drive a save against a storage whose saveDocument rejects…
off()
assert.include(seen, 'storage.saveDocument')
})Break it on purpose first: a listener that never fires passes this test just as happily as one that fires correctly.
What not to test
- That Yjs converges. It is a well-tested library with a formal argument behind it. Two
in-process
Y.Docs merging is a test of Yjs, not of your app. - The transports. They are thin adapters over Hocuspocus and PartyKit clients.
- That the built-in routes route. They are covered by the package's own suite. Test your controllers, if you wrote any.
Integration, when it is worth it
A single end-to-end test — open two clients, type in one, assert the other sees it — is worth having as a smoke test and expensive as a habit. Run it against a real server with in-memory storage, keep it to one, and put the rest of your budget into the three sections above.
A test that passes before the fix measures nothing
Especially for the negative cases here: break the rule on purpose and confirm the test goes red
before you trust it. A permission test that passes against a wide-open authorize is not a
permission test.
Tiptap collaboration
Turn a single-user Tiptap editor into a shared one — the provider, the shared Y.Doc, remote cursors, the undo trap, and what the saved document actually looks like.
Production
The operational checklist — why documents are process-bound and what sticky routing has to guarantee, tuning the debounce against data loss, Redis presence, the failure stream, shutdown, secrets, growth and backup.