Files
moeka-project/packages/server-sdk/test/client.test.ts
T
2026-08-26 19:49:58 +08:00

481 lines
13 KiB
TypeScript

import type { ClientConnection, ClientConnector, ClientEvents } from '@proj-airi/better-ws'
import type { WebSocketEvent, WebSocketEventOf } from '@proj-airi/server-shared/types'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Client } from '../src/client'
class Deferred<T> {
promise: Promise<T>
reject!: (error: unknown) => void
resolve!: (value: T) => void
constructor() {
this.promise = new Promise<T>((resolve, reject) => {
this.resolve = resolve
this.reject = reject
})
}
}
class FakeConnection<C = undefined> implements ClientConnection<WebSocketEvent<C>> {
closed = false
readonly pongs: number[] = []
readonly sent: Array<WebSocketEvent<C>> = []
constructor(private readonly events: ClientEvents<WebSocketEvent<C>>) {}
close() {
if (this.closed) {
return
}
this.closed = true
this.events.close({ code: 1000, reason: 'closed', wasClean: true })
}
pong() {
this.pongs.push(Date.now())
return true
}
send(message: WebSocketEvent<C>) {
this.sent.push(message)
return true
}
}
class FakeConnector<C = undefined> implements ClientConnector<WebSocketEvent<C>> {
readonly attempts: Array<{
connection?: FakeConnection<C>
deferred: Deferred<ClientConnection<WebSocketEvent<C>>>
events: ClientEvents<WebSocketEvent<C>>
}> = []
connect(events: ClientEvents<WebSocketEvent<C>>) {
const deferred = new Deferred<ClientConnection<WebSocketEvent<C>>>()
this.attempts.push({ deferred, events })
return deferred.promise
}
emit(message: WebSocketEvent<C>, index = this.attempts.length - 1) {
const attempt = this.attempts[index]
if (!attempt) {
throw new Error(`Missing fake connector attempt at index ${index}.`)
}
attempt.events.message(message)
}
open(index = this.attempts.length - 1) {
const attempt = this.attempts[index]
if (!attempt) {
throw new Error(`Missing fake connector attempt at index ${index}.`)
}
const connection = new FakeConnection<C>(attempt.events)
attempt.connection = connection
attempt.deferred.resolve(connection)
return connection
}
reject(error: unknown, index = this.attempts.length - 1) {
const attempt = this.attempts[index]
if (!attempt) {
throw new Error(`Missing fake connector attempt at index ${index}.`)
}
attempt.deferred.reject(error)
}
}
async function flushAsyncTasks() {
await flushMicrotasks()
await new Promise(resolve => setTimeout(resolve, 0))
}
async function flushMicrotasks() {
await Promise.resolve()
await Promise.resolve()
}
function serverEvent<E extends WebSocketEvent['type']>(
type: E,
data: WebSocketEventOf<E>['data'],
): WebSocketEventOf<E> {
return {
data,
metadata: {
event: { id: `${type}-1` },
source: { id: 'server-1', kind: 'plugin', plugin: { id: 'server' } },
},
type,
} as WebSocketEventOf<E>
}
afterEach(() => {
vi.useRealTimers()
})
describe('client', () => {
it('routes default autoConnect failures through onError without unhandled rejections', async () => {
const connector = new FakeConnector()
const onError = vi.fn()
const unhandledRejections: unknown[] = []
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason)
}
process.on('unhandledRejection', onUnhandledRejection)
let client: Client | undefined
try {
client = new Client({
autoReconnect: false,
connector,
name: 'test-plugin',
onError,
})
const failure = new Error('server unavailable')
connector.reject(failure)
await flushAsyncTasks()
expect(onError).toHaveBeenCalledWith(failure)
expect(unhandledRejections).toEqual([])
}
finally {
client?.close()
process.off('unhandledRejection', onUnhandledRejection)
}
})
it('runs module authentication and announcement in the better-ws prepare step', async () => {
const connector = new FakeConnector()
const client = new Client({
autoConnect: false,
autoReconnect: false,
connector,
name: 'test-plugin',
token: 'secret',
})
const connected = client.connect()
const connection = connector.open()
await flushMicrotasks()
expect(client.connectionStatus).toBe('authenticating')
expect(connection.sent.at(-1)).toMatchObject({
data: { token: 'secret' },
type: 'module:authenticate',
})
connector.emit(serverEvent('module:authenticated', { authenticated: true }))
await flushMicrotasks()
const announceEvent = connection.sent.at(-1) as WebSocketEventOf<'extension:module:announce'>
expect(client.connectionStatus).toBe('announcing')
expect(announceEvent).toMatchObject({
data: { name: 'test-plugin' },
type: 'extension:module:announce',
})
connector.emit(serverEvent('extension:module:announced', {
identity: announceEvent.data.identity,
name: 'test-plugin',
}))
await expect(connected).resolves.toBeUndefined()
expect(client.connectionStatus).toBe('ready')
expect(client.isReady).toBe(true)
})
it('accepts registry sync as the module announcement completion signal', async () => {
const connector = new FakeConnector()
const onReady = vi.fn()
const client = new Client({
autoConnect: false,
autoReconnect: false,
connector,
name: 'test-plugin',
onReady,
})
const connected = client.connect()
const connection = connector.open()
await flushMicrotasks()
const announceEvent = connection.sent.at(-1) as WebSocketEventOf<'extension:module:announce'>
connector.emit(serverEvent('registry:modules:sync', {
modules: [{ identity: announceEvent.data.identity, name: 'test-plugin' }],
}))
connector.emit(serverEvent('extension:module:announced', {
identity: announceEvent.data.identity,
name: 'test-plugin',
}))
await expect(connected).resolves.toBeUndefined()
expect(client.connectionStatus).toBe('ready')
expect(onReady).toHaveBeenCalledTimes(1)
})
it('keeps manual automatic reconnects non-ready until peer authentication and extension announcement arrive', async () => {
vi.useFakeTimers()
const connector = new FakeConnector()
const onReady = vi.fn()
const client = new Client({
autoConnect: false,
autoReconnect: true,
connector,
handshake: 'manual',
name: 'test-extension',
onReady,
})
const connected = client.connect()
const firstConnection = connector.open(0)
await flushMicrotasks()
await expect(connected).resolves.toBeUndefined()
expect(client.connectionStatus).toBe('ready')
expect(onReady).toHaveBeenCalledTimes(1)
firstConnection.close()
await vi.advanceTimersByTimeAsync(1_000)
expect(connector.attempts).toHaveLength(2)
const secondConnection = connector.open(1)
await flushMicrotasks()
expect(client.connectionStatus).toBe('authenticating')
expect(client.isReady).toBe(false)
expect(secondConnection.sent).toEqual([])
connector.emit(serverEvent('peer:authenticated', { authenticated: true }), 1)
await flushMicrotasks()
expect(client.connectionStatus).toBe('announcing')
expect(onReady).toHaveBeenCalledTimes(1)
connector.emit(serverEvent('extension:announced', {
identity: { id: 'other-extension' },
}), 1)
await flushMicrotasks()
expect(client.connectionStatus).toBe('announcing')
expect(onReady).toHaveBeenCalledTimes(1)
connector.emit(serverEvent('extension:announced', {
identity: { id: 'test-extension' },
}), 1)
await expect(client.ensureConnected()).resolves.toBeUndefined()
expect(client.connectionStatus).toBe('ready')
expect(onReady).toHaveBeenCalledTimes(2)
})
it('injects source and event metadata before sending through better-ws', async () => {
const connector = new FakeConnector()
const onAnySend = vi.fn()
const client = new Client({
autoConnect: false,
autoReconnect: false,
connector,
handshake: 'manual',
name: 'test-extension',
onAnySend,
})
const connected = client.connect()
const connection = connector.open()
await connected
const sent = client.send({
data: { text: 'hello' },
type: 'input:text',
})
expect(sent).toBe(true)
expect(connection.sent.at(-1)).toMatchObject({
data: { text: 'hello' },
metadata: {
event: { id: expect.any(String) },
source: {
id: expect.any(String),
kind: 'plugin',
plugin: { id: 'test-extension' },
},
},
type: 'input:text',
})
expect(onAnySend).toHaveBeenCalledWith(connection.sent.at(-1))
})
it('fails without retrying terminal authentication errors', async () => {
vi.useFakeTimers()
const connector = new FakeConnector()
const onError = vi.fn()
const client = new Client({
autoConnect: false,
autoReconnect: true,
connector,
name: 'test-plugin',
onError,
token: 'wrong-token',
})
const connected = client.connect()
connector.open()
await flushMicrotasks()
connector.emit(serverEvent('error', { message: 'invalid token' }))
await expect(connected).rejects.toThrow('invalid token')
expect(client.connectionStatus).toBe('failed')
expect(connector.attempts).toHaveLength(1)
expect(onError).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledWith(expect.any(Error))
})
it('dispatches typed events and answers transport heartbeat pings with pong', async () => {
const connector = new FakeConnector()
const listener = vi.fn()
const onAnyMessage = vi.fn()
const client = new Client({
autoConnect: false,
autoReconnect: false,
connector,
handshake: 'manual',
name: 'test-extension',
onAnyMessage,
})
const connected = client.connect()
const connection = connector.open()
await connected
client.onEvent('input:text', listener)
const input = serverEvent('input:text', { text: 'hello' })
connector.emit(input)
connector.emit(serverEvent('transport:connection:heartbeat', {
kind: 'ping',
message: 'ping',
}))
await flushMicrotasks()
expect(listener).toHaveBeenCalledWith(input)
expect(onAnyMessage).toHaveBeenCalledWith(input)
expect(connection.sent.at(-1)).toMatchObject({
data: { kind: 'pong' },
type: 'transport:connection:heartbeat',
})
})
it('keeps generated event ids when caller metadata has an undefined id', async () => {
const connector = new FakeConnector()
const client = new Client({
autoConnect: false,
autoReconnect: false,
connector,
handshake: 'manual',
name: 'test-extension',
})
const connected = client.connect()
const connection = connector.open()
await connected
client.send({
data: { text: 'hello' },
metadata: {
event: { id: undefined },
},
type: 'input:text',
})
expect(connection.sent.at(-1)?.metadata.event.id).toEqual(expect.any(String))
})
it('can disable protocol heartbeat', async () => {
const connector = new FakeConnector()
const client = new Client({
autoConnect: false,
autoReconnect: false,
connector,
handshake: 'manual',
heartbeat: false,
name: 'test-extension',
})
const connected = client.connect()
connector.open()
await connected
expect(client.isReady).toBe(true)
})
it('races local timeouts without cancelling the shared connect task', async () => {
vi.useFakeTimers()
const connector = new FakeConnector()
const client = new Client({
autoConnect: false,
autoReconnect: false,
connector,
name: 'test-plugin',
})
const timedOut = client.ensureConnected({ timeout: 50 })
const timedOutAssertion = expect(timedOut).rejects.toThrow('Connection timed out after 50ms')
await vi.advanceTimersByTimeAsync(50)
await timedOutAssertion
const connection = connector.open()
await flushMicrotasks()
const announceEvent = connection.sent.at(-1) as WebSocketEventOf<'extension:module:announce'>
connector.emit(serverEvent('extension:module:announced', {
identity: announceEvent.data.identity,
name: 'test-plugin',
}))
await expect(client.ensureConnected()).resolves.toBeUndefined()
expect(client.isReady).toBe(true)
})
it('races local aborts without cancelling the shared connect task', async () => {
const connector = new FakeConnector()
const client = new Client({
autoConnect: false,
autoReconnect: false,
connector,
name: 'test-plugin',
})
const controller = new AbortController()
const connecting = client.connect({ abortSignal: controller.signal })
controller.abort()
await expect(connecting).rejects.toThrow('Connection aborted')
expect(client.connectionStatus).toBe('connecting')
const connection = connector.open()
await flushMicrotasks()
const announceEvent = connection.sent.at(-1) as WebSocketEventOf<'extension:module:announce'>
connector.emit(serverEvent('extension:module:announced', {
identity: announceEvent.data.identity,
name: 'test-plugin',
}))
await expect(client.ready()).resolves.toBeUndefined()
expect(client.isReady).toBe(true)
})
})