fix(ui): stabilize workplace chat scrolling
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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)')
|
||||
Reference in New Issue
Block a user