Files
OpenOPC/opc/plugins/office_ui/frontend_src/lib/wsClient.test.ts
T
2026-07-13 23:02:39 +08:00

218 lines
8.8 KiB
TypeScript

import assert from 'node:assert/strict'
import { VisualSocketClient } from './wsClient'
type TestSocketClient = {
handleMessage: (raw: unknown) => void
pendingQueue: string[]
pendingSessionDetailRequests: Array<{
queued: boolean
settled: boolean
timeout: ReturnType<typeof setTimeout> | null
}>
timeoutSessionDetailRequest: (index: number) => void
}
const deliverAck = (
client: VisualSocketClient,
payload: Record<string, unknown>,
) => {
;(client as unknown as TestSocketClient).handleMessage(JSON.stringify({
type: 'ack',
payload,
}))
}
const flushPromises = async () => {
await Promise.resolve()
await Promise.resolve()
}
const client = new VisualSocketClient('ws://unit.test', {})
// A summary and a full request for the same task are distinct correlations.
// Neither Promise may settle merely because the request was queued locally.
const summaryPromise = client.sessionDetail('project-a', 'task-1', { detailLevel: 'summary' })
const fullPromise = client.sessionDetail('project-a', 'task-1', { detailLevel: 'full' })
const summarySettlements: Array<Record<string, unknown>> = []
const fullSettlements: Array<Record<string, unknown>> = []
void summaryPromise.then(payload => { summarySettlements.push(payload) })
void fullPromise.then(payload => { fullSettlements.push(payload) })
await flushPromises()
assert.equal(summarySettlements.length, 0, 'summary Promise must remain pending before its ACK')
assert.equal(fullSettlements.length, 0, 'full Promise must remain pending before its ACK')
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-1',
detail_level: 'full',
marker: 'full-ack',
})
await flushPromises()
assert.equal(fullSettlements[0]?.marker, 'full-ack', 'the matching full ACK must settle the full request')
assert.equal(summarySettlements.length, 0, 'a full ACK must not settle the same task\'s summary request')
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-1',
detail_level: 'summary',
marker: 'summary-ack',
})
await flushPromises()
assert.equal(summarySettlements[0]?.marker, 'summary-ack', 'the matching summary ACK must settle the summary request')
assert.equal(summarySettlements[0]?.client_history_page, false, 'a request without a cursor is not a history page')
const historyPagePromise = client.sessionDetail('project-a', 'task-history', {
detailLevel: 'summary',
beforeCreatedAt: 123,
})
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-history',
detail_level: 'summary',
})
assert.equal((await historyPagePromise).client_history_page, true, 'a cursor request must be identified as a history page')
// Requests with the same correlation fields are settled in request order.
const firstPromise = client.sessionDetail('project-a', 'task-2', { detailLevel: 'summary' })
const secondPromise = client.sessionDetail('project-a', 'task-2', { detailLevel: 'summary' })
const firstSettlements: Array<Record<string, unknown>> = []
const secondSettlements: Array<Record<string, unknown>> = []
void firstPromise.then(payload => { firstSettlements.push(payload) })
void secondPromise.then(payload => { secondSettlements.push(payload) })
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-2',
detail_level: 'summary',
marker: 'first-ack',
})
await flushPromises()
assert.equal(firstSettlements[0]?.marker, 'first-ack', 'the first matching ACK must settle the oldest request')
assert.equal(secondSettlements.length, 0, 'the second same-scope request must remain pending after one ACK')
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-2',
detail_level: 'summary',
marker: 'second-ack',
})
await flushPromises()
assert.equal(secondSettlements[0]?.marker, 'second-ack', 'the next matching ACK must settle the next FIFO request')
// The backend's early store-not-ready path cannot echo request fields. The
// client must still correlate and normalize that error instead of leaving the
// history single-flight Promise pending forever.
const storeNotReadyPromise = client.sessionDetail('project-a', 'task-store', {
detailLevel: 'summary',
viewGeneration: 9,
})
const storeNotReadySettlements: Array<Record<string, unknown>> = []
void storeNotReadyPromise.then(payload => { storeNotReadySettlements.push(payload) })
deliverAck(client, {
ok: false,
action: 'create_session',
error: 'store_not_ready',
project_id: 'project-a',
view_generation: 9,
})
await flushPromises()
assert.equal(
storeNotReadySettlements.length,
0,
'store_not_ready for another explicit action must not settle a session_detail request',
)
deliverAck(client, {
ok: false,
error: 'store_not_ready',
project_id: 'project-a',
view_generation: 9,
})
const storeNotReady = await storeNotReadyPromise
assert.equal(storeNotReady.action, 'session_detail')
assert.equal(storeNotReady.task_id, 'task-store')
assert.equal(storeNotReady.detail_level, 'summary')
// A sent request timeout releases its caller, but leaves a settled FIFO
// tombstone so a late ACK cannot be mis-correlated to a newer request.
const timeoutClient = new VisualSocketClient('ws://unit.test', {})
const sentPromise = timeoutClient.sessionDetail('project-a', 'task-timeout', {
detailLevel: 'summary',
})
const sentSettlements: Array<Record<string, unknown>> = []
void sentPromise.then(payload => { sentSettlements.push(payload) })
const timeoutInternals = timeoutClient as unknown as TestSocketClient
timeoutInternals.pendingSessionDetailRequests[0].queued = false
timeoutInternals.pendingQueue = []
timeoutInternals.timeoutSessionDetailRequest(0)
await flushPromises()
assert.equal(sentSettlements[0]?.error, 'request_timeout', 'sent timeout must release the loading caller')
assert.equal(timeoutInternals.pendingSessionDetailRequests.length, 1, 'sent timeout must retain a FIFO tombstone')
assert.equal(timeoutInternals.pendingSessionDetailRequests[0].settled, true, 'the retained request must be a settled tombstone')
assert.equal(timeoutInternals.pendingSessionDetailRequests[0].timeout, null, 'sent request timer must be released')
const afterTimeoutPromise = timeoutClient.sessionDetail('project-a', 'task-timeout', {
detailLevel: 'summary',
})
const afterTimeoutSettlements: Array<Record<string, unknown>> = []
void afterTimeoutPromise.then(payload => { afterTimeoutSettlements.push(payload) })
deliverAck(timeoutClient, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-timeout',
detail_level: 'summary',
marker: 'old-request-ack',
})
await flushPromises()
assert.equal(sentSettlements.length, 1, 'a late ACK must be consumed by the older tombstone')
assert.equal(sentSettlements[0]?.error, 'request_timeout', 'a late ACK cannot resettle the timed-out Promise')
assert.equal(afterTimeoutSettlements.length, 0, 'the first ACK must not settle the newer same-scope request')
assert.equal(timeoutInternals.pendingSessionDetailRequests.length, 1, 'the newer request must remain pending')
deliverAck(timeoutClient, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-timeout',
detail_level: 'summary',
marker: 'current-request-ack',
})
await flushPromises()
assert.equal(afterTimeoutSettlements[0]?.marker, 'current-request-ack', 'the next ACK must settle the newer request')
const queuedTimeoutClient = new VisualSocketClient('ws://unit.test', {})
const queuedTimeout = queuedTimeoutClient.sessionDetail('project-a', 'task-queued-timeout', {
beforeMessageId: 'older-message',
})
const queuedTimeoutInternals = queuedTimeoutClient as unknown as TestSocketClient
queuedTimeoutInternals.timeoutSessionDetailRequest(0)
const queuedTimeoutFailure = await queuedTimeout
assert.equal(queuedTimeoutFailure.error, 'request_timeout', 'a request still queued locally may time out')
assert.equal(queuedTimeoutFailure.client_history_page, true, 'synthetic failures must preserve history-page correlation')
assert.equal(queuedTimeoutInternals.pendingSessionDetailRequests.length, 0)
assert.equal(queuedTimeoutInternals.pendingQueue.length, 0)
const disconnectClient = new VisualSocketClient('ws://unit.test', {})
const disconnected = disconnectClient.sessionDetail('project-a', 'task-disconnect')
disconnectClient.disconnect()
assert.equal((await disconnected).error, 'disconnected', 'disconnect must settle every pending detail request')
const saturatedClient = new VisualSocketClient('ws://unit.test', {})
;(saturatedClient as unknown as TestSocketClient).pendingQueue = Array.from({ length: 100 }, () => '{}')
const saturated = await saturatedClient.sessionDetail('project-a', 'task-saturated')
assert.equal(saturated.error, 'send_queue_full', 'a saturated transport queue must fail immediately')
console.log('wsClient.test.ts: OK (session_detail correlation and lifecycle cleanup)')