Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion plugins/text-editor-resources/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"build:watch": "compile ui",
"_phase:build": "compile ui",
"_phase:format": "format src",
"_phase:validate": "compile validate"
"_phase:validate": "compile validate",
"test": "jest --passWithNoTests --silent",
"_phase:test": "jest --passWithNoTests --silent"
},
"devDependencies": {
"@hcengineering/platform-rig": "workspace:^0.7.19",
Expand Down
155 changes: 155 additions & 0 deletions plugins/text-editor-resources/src/provider/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { createRemoteProvider } from './utils'

const baseDestroy = jest.fn()
let lastConfig: any

jest.mock('@hcengineering/core', () => ({
generateId: () => 'guid-1'
}))

jest.mock('@hcengineering/presentation', () => ({
__esModule: true,
default: {
metadata: {
Token: 'token',
CollaboratorUrl: 'collaboratorUrl',
WorkspaceUuid: 'workspaceUuid'
}
}
}))

jest.mock('@hcengineering/platform', () => {
const getMetadata = jest.fn()
const setPlatformStatus = jest.fn()

class Status {
severity: any
code: any
params: any

constructor (severity: any, code: any, params: any) {
this.severity = severity
this.code = code
this.params = params
}
}

return {
OK: { code: 'OK' },
Severity: { ERROR: 'ERROR' },
Status,
getMetadata,
setPlatformStatus
}
})

jest.mock('@hcengineering/collaborator-client', () => {
const encodeDocumentId = jest.fn()
return { encodeDocumentId }
})

jest.mock('../plugin', () => ({
__esModule: true,
default: {
string: {
CannotConnectToCollaborationService: 'CannotConnectToCollaborationService'
}
}
}))

jest.mock('./hocuspocus', () => ({
HocuspocusCollabProvider: class {
destroy: () => void

constructor (config: any) {
lastConfig = config
this.destroy = baseDestroy
}
}
}))

describe('createRemoteProvider reconnect grace behavior', () => {
const RECONNECT_GRACE_MS = 5000
const platformMock = jest.requireMock('@hcengineering/platform')
const collaboratorClientMock = jest.requireMock('@hcengineering/collaborator-client')

beforeEach(() => {
jest.useFakeTimers()
jest.clearAllMocks()
baseDestroy.mockReset()
lastConfig = undefined

platformMock.getMetadata.mockImplementation((key: string) => {
if (key === 'token') return 'token-1'
if (key === 'collaboratorUrl') return 'wss://collab.example/ws'
if (key === 'workspaceUuid') return 'ws-1'
return undefined
})
collaboratorClientMock.encodeDocumentId.mockReturnValue('encoded-doc-id')
})

afterEach(() => {
jest.useRealTimers()
})

it('does not report error before grace timeout', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
createRemoteProvider({} as any, 'doc-1' as any, null)

lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(RECONNECT_GRACE_MS - 1)

expect(errorSpy).not.toHaveBeenCalled()
expect(platformMock.setPlatformStatus).not.toHaveBeenCalled()

errorSpy.mockRestore()
})

it('starts grace timer once and does not postpone on repeated 1006 closes', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
createRemoteProvider({} as any, 'doc-1' as any, null)

lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(2000)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(2000)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(1000)

expect(errorSpy).toHaveBeenCalledTimes(1)
expect(platformMock.setPlatformStatus).toHaveBeenCalledTimes(1)

errorSpy.mockRestore()
})

it('clears pending error when reconnect succeeds in grace window', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
createRemoteProvider({} as any, 'doc-1' as any, null)

lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(RECONNECT_GRACE_MS - 1000)
lastConfig.onConnect()
jest.advanceTimersByTime(RECONNECT_GRACE_MS + 1000)

expect(errorSpy).not.toHaveBeenCalled()
expect(platformMock.setPlatformStatus).toHaveBeenCalledTimes(1)
expect(platformMock.setPlatformStatus).toHaveBeenCalledWith({ code: 'OK' })

errorSpy.mockRestore()
})

it('clears pending error timer on provider destroy', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
const provider = createRemoteProvider({} as any, 'doc-1' as any, null)

lastConfig.onClose({ event: { code: 1006 } })
void provider.destroy()
jest.advanceTimersByTime(RECONNECT_GRACE_MS + 1000)

expect(baseDestroy).toHaveBeenCalledTimes(1)
expect(errorSpy).not.toHaveBeenCalled()
expect(platformMock.setPlatformStatus).not.toHaveBeenCalled()

errorSpy.mockRestore()
})
})
30 changes: 27 additions & 3 deletions plugins/text-editor-resources/src/provider/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import plugin from '../plugin'
import { HocuspocusCollabProvider } from './hocuspocus'
import { type Provider } from './types'

/** After idle/tab sleep the WS often closes with 1006; Hocuspocus reconnects. Defer user-visible errors. */
const COLLABORATOR_RECONNECT_GRACE_MS = 5000

function getDocumentId (doc: CollaborativeDoc): string {
const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
return encodeDocumentId(workspace, doc)
Expand All @@ -35,24 +38,45 @@ export function createRemoteProvider (ydoc: Ydoc, doc: CollaborativeDoc, content

const documentId = getDocumentId(doc)

let reconnectGraceTimeout: ReturnType<typeof setTimeout> | undefined

const clearReconnectGrace = (): void => {
if (reconnectGraceTimeout !== undefined) {
clearTimeout(reconnectGraceTimeout)
reconnectGraceTimeout = undefined
}
}

const provider = new HocuspocusCollabProvider({
url: collaboratorUrl,
name: documentId,
document: ydoc,
token,
parameters: { content },
onConnect: () => {
clearReconnectGrace()
void setPlatformStatus(OK)
},
onClose: (data) => {
if (data.event.code === 1006) {
console.error('Failed to connect to collaborator', data.event)
const status = new Status(Severity.ERROR, plugin.string.CannotConnectToCollaborationService, {})
void setPlatformStatus(status)
if (reconnectGraceTimeout === undefined) {
reconnectGraceTimeout = setTimeout(() => {
reconnectGraceTimeout = undefined
console.error('Failed to connect to collaborator', data.event)
const status = new Status(Severity.ERROR, plugin.string.CannotConnectToCollaborationService, {})
void setPlatformStatus(status)
}, COLLABORATOR_RECONNECT_GRACE_MS)
}
}
}
})

const baseDestroy = provider.destroy.bind(provider)
provider.destroy = (): void => {
clearReconnectGrace()
baseDestroy()
}

return provider
}

Expand Down
Loading