fix(stage-ui): channel-server should handle onReady as well (#1550)

This commit is contained in:
Neko
2026-04-02 15:29:44 +08:00
committed by GitHub
parent 7d77935c53
commit 7d15244698
2 changed files with 170 additions and 0 deletions
@@ -0,0 +1,165 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const serverSdkMocks = vi.hoisted(() => {
class MockClient {
static instances: MockClient[] = []
readonly listeners = new Map<string, Set<(event: any) => void | Promise<void>>>()
readonly sent: any[] = []
constructor(public readonly options: Record<string, any>) {
MockClient.instances.push(this)
}
onEvent(type: string, callback: (event: any) => void | Promise<void>) {
let callbacks = this.listeners.get(type)
if (!callbacks) {
callbacks = new Set()
this.listeners.set(type, callbacks)
}
callbacks.add(callback)
return () => {
this.offEvent(type, callback)
}
}
offEvent(type: string, callback?: (event: any) => void | Promise<void>) {
const callbacks = this.listeners.get(type)
if (!callbacks) {
return
}
if (callback) {
callbacks.delete(callback)
if (!callbacks.size) {
this.listeners.delete(type)
}
return
}
this.listeners.delete(type)
}
send(event: any) {
this.sent.push(event)
return true
}
close() {
this.options.onClose?.()
}
emit(type: string, data: any) {
const event = { type, data }
for (const callback of this.listeners.get(type) ?? []) {
void callback(event)
}
}
simulateAuthenticated() {
this.emit('module:authenticated', { authenticated: true })
}
simulateTransientDisconnect() {
this.options.onClose?.()
}
simulateReconnectReady() {
this.options.onReady?.()
}
}
return {
MockClient,
}
})
vi.mock('@proj-airi/server-sdk', () => ({
Client: serverSdkMocks.MockClient,
WebSocketEventSource: {
StageTamagotchi: 'proj-airi:stage-tamagotchi',
StageWeb: 'proj-airi:stage-web',
},
}))
vi.mock('@proj-airi/stage-shared', () => ({
isStageTamagotchi: () => true,
isStageWeb: () => false,
}))
vi.mock('@vueuse/core', async () => {
const { ref } = await import('vue')
return {
useLocalStorage: (_key: string, initialValue: string) => ref(initialValue),
}
})
vi.mock('../../../devtools/websocket-inspector', () => ({
useWebSocketInspectorStore: () => ({
add: vi.fn(),
}),
}))
const { useModsServerChannelStore } = await import('./channel-server')
describe('channel-server store reconnect', () => {
beforeEach(() => {
setActivePinia(createPinia())
serverSdkMocks.MockClient.instances.length = 0
})
afterEach(() => {
vi.clearAllMocks()
})
// Regression coverage for https://github.com/moeru-ai/airi/issues/1545
it('issue #1545: restores connected state and flushes queued sends when the client reports ready after a reconnect', async () => {
const store = useModsServerChannelStore()
store.send({
type: 'spark:notify',
data: { message: 'before-init' },
} as any)
const initializePromise = store.initialize({ token: 'secret' })
const client = serverSdkMocks.MockClient.instances[0]
client.simulateAuthenticated()
await initializePromise
expect(store.connected).toBe(true)
expect(store.pendingSendCount).toBe(0)
expect(client.sent).toEqual(expect.arrayContaining([
expect.objectContaining({
type: 'spark:notify',
data: { message: 'before-init' },
}),
]))
client.simulateTransientDisconnect()
expect(store.connected).toBe(false)
store.send({
type: 'spark:notify',
data: { message: 'queued-during-disconnect' },
} as any)
expect(store.pendingSendCount).toBe(1)
client.simulateReconnectReady()
expect(store.connected).toBe(true)
expect(store.pendingSendCount).toBe(0)
expect(client.sent).toEqual(expect.arrayContaining([
expect.objectContaining({
type: 'spark:notify',
data: { message: 'queued-during-disconnect' },
}),
]))
})
})
@@ -102,6 +102,11 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
console.warn('WebSocket server connection closed')
},
onReady: () => {
connected.value = true
flush()
initializeListeners()
},
})
client.value.onEvent('module:authenticated', (event) => {