@csntgao/uni-base 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.prettierignore +4 -0
  2. package/.prettierrc.json +6 -0
  3. package/AGENTS.md +519 -0
  4. package/CLAUDE.md +512 -0
  5. package/README.md +734 -0
  6. package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
  7. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
  8. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
  9. package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
  10. package/eslint.config.js +30 -0
  11. package/examples/vanilla/index.html +33 -0
  12. package/examples/vanilla/main.js +247 -0
  13. package/examples/vanilla/package.json +15 -0
  14. package/examples/vanilla/public/runtime-config.js +10 -0
  15. package/examples/vanilla/style.css +99 -0
  16. package/package.json +31 -0
  17. package/packages/core/package.json +30 -0
  18. package/packages/core/src/client.ts +1132 -0
  19. package/packages/core/src/customer-support-client.ts +825 -0
  20. package/packages/core/src/customer-support-types.ts +123 -0
  21. package/packages/core/src/index.ts +79 -0
  22. package/packages/core/src/notification-center-client.ts +462 -0
  23. package/packages/core/src/notification-center-types.ts +154 -0
  24. package/packages/core/src/protocol.ts +237 -0
  25. package/packages/core/src/types.ts +170 -0
  26. package/packages/core/tests/client.test.ts +760 -0
  27. package/packages/core/tests/customer-support-client.test.ts +576 -0
  28. package/packages/core/tests/fake-websocket.ts +55 -0
  29. package/packages/core/tests/notification-center-client.test.ts +467 -0
  30. package/packages/core/tests/protocol.test.ts +124 -0
  31. package/packages/core/tsconfig.json +7 -0
  32. package/packages/core/tsup.config.ts +11 -0
  33. package/packages/web-components/package.json +35 -0
  34. package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
  35. package/packages/web-components/src/account-context.ts +1540 -0
  36. package/packages/web-components/src/application-switcher-transport.ts +243 -0
  37. package/packages/web-components/src/application-switcher.ts +1103 -0
  38. package/packages/web-components/src/chat.ts +1754 -0
  39. package/packages/web-components/src/customer-support-chat.ts +862 -0
  40. package/packages/web-components/src/customer-support-transport.ts +269 -0
  41. package/packages/web-components/src/index.ts +256 -0
  42. package/packages/web-components/src/notification-center.ts +1066 -0
  43. package/packages/web-components/src/notification-popover.ts +615 -0
  44. package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
  45. package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
  46. package/packages/web-components/src/organization-onboarding.ts +1216 -0
  47. package/packages/web-components/src/organization-selector.ts +534 -0
  48. package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
  49. package/packages/web-components/src/prototype-icons.ts +242 -0
  50. package/packages/web-components/src/safe-image-url.ts +29 -0
  51. package/packages/web-components/src/safe-markdown.ts +192 -0
  52. package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
  53. package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
  54. package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
  55. package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
  56. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
  57. package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
  58. package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
  59. package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
  60. package/packages/web-components/src/user-login-transport.ts +563 -0
  61. package/packages/web-components/src/user-login.ts +2027 -0
  62. package/packages/web-components/src/user-menu.ts +880 -0
  63. package/packages/web-components/tests/account-context.test.ts +798 -0
  64. package/packages/web-components/tests/application-switcher.test.ts +562 -0
  65. package/packages/web-components/tests/chat.test.ts +1130 -0
  66. package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
  67. package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
  68. package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
  69. package/packages/web-components/tests/notification-center.test.ts +429 -0
  70. package/packages/web-components/tests/notification-popover.test.ts +283 -0
  71. package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
  72. package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
  73. package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
  74. package/packages/web-components/tests/organization-selector.test.ts +127 -0
  75. package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
  76. package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
  77. package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
  78. package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
  79. package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
  80. package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
  81. package/packages/web-components/tests/user-login-transport.test.ts +443 -0
  82. package/packages/web-components/tests/user-login.test.ts +626 -0
  83. package/packages/web-components/tests/user-menu.test.ts +271 -0
  84. package/packages/web-components/tsconfig.json +8 -0
  85. package/packages/web-components/tsup.config.ts +12 -0
  86. package/packages/web-components/tsup.runtime.config.ts +37 -0
  87. package/pnpm-workspace.yaml +6 -0
  88. package/tsconfig.base.json +21 -0
  89. package/vitest.config.ts +20 -0
  90. package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
@@ -0,0 +1,576 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ CustomerSupportClient,
4
+ type CustomerSupportMessage,
5
+ type CustomerSupportSession,
6
+ type CustomerSupportTransport,
7
+ } from '../src'
8
+ import { FakeWebSocket } from './fake-websocket'
9
+
10
+ const session: CustomerSupportSession = {
11
+ id: 'support-1',
12
+ sessionNo: 'CS-1',
13
+ title: '企业服务顾问',
14
+ status: 'active',
15
+ statusText: '服务中',
16
+ primaryAgent: null,
17
+ lastMessage: '您好,我是人工顾问。',
18
+ unreadCount: 0,
19
+ openedAt: '2026-08-18T08:00:00.000Z',
20
+ updatedAt: '2026-08-18T08:00:00.000Z',
21
+ version: '1',
22
+ }
23
+
24
+ const history: CustomerSupportMessage[] = [
25
+ {
26
+ id: 'support-message-1',
27
+ sessionId: 'support-1',
28
+ sequence: '1',
29
+ clientMessageId: '',
30
+ senderType: 'agent',
31
+ senderName: '王悦',
32
+ content: '您好,我是人工顾问。',
33
+ createdAt: '2026-08-18T08:00:00.000Z',
34
+ },
35
+ ]
36
+
37
+ function createTransport(): CustomerSupportTransport {
38
+ return {
39
+ getAccessToken: vi.fn(async () => 'customer.support.jwt'),
40
+ listSessions: vi.fn(async () => [session]),
41
+ createSession: vi.fn(async () => ({ session })),
42
+ listMessages: vi.fn(async () => ({
43
+ messages: history,
44
+ latestSequence: '1',
45
+ sessionVersion: '1',
46
+ })),
47
+ sendMessage: vi.fn(async (_sessionId, clientMessageId, content) => ({
48
+ id: 'support-message-2',
49
+ sessionId: 'support-1',
50
+ sequence: '2',
51
+ clientMessageId,
52
+ senderType: 'customer' as const,
53
+ senderName: '',
54
+ content,
55
+ createdAt: '2026-08-18T08:01:00.000Z',
56
+ })),
57
+ markRead: vi.fn(async () => undefined),
58
+ }
59
+ }
60
+
61
+ function deferred<T>() {
62
+ let resolve!: (value: T | PromiseLike<T>) => void
63
+ let reject!: (reason?: unknown) => void
64
+ const promise = new Promise<T>((promiseResolve, promiseReject) => {
65
+ resolve = promiseResolve
66
+ reject = promiseReject
67
+ })
68
+ return { promise, resolve, reject }
69
+ }
70
+
71
+ describe('CustomerSupportClient', () => {
72
+ beforeEach(() => vi.useFakeTimers())
73
+ afterEach(() => vi.useRealTimers())
74
+
75
+ it('loads the current support session and authenticates the dedicated realtime channel', async () => {
76
+ const sockets: FakeWebSocket[] = []
77
+ const transport = createTransport()
78
+ const client = new CustomerSupportClient({
79
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
80
+ transport,
81
+ requestIdFactory: () => 'b95e15b8-55df-4d84-9fbe-c00dc45ae3a7',
82
+ minimumHeartbeatMs: 1,
83
+ webSocketFactory: (url) => {
84
+ const socket = new FakeWebSocket(url)
85
+ sockets.push(socket)
86
+ return socket.asWebSocket()
87
+ },
88
+ })
89
+
90
+ await client.start()
91
+ expect(client.session?.id).toBe('support-1')
92
+ expect(client.messages).toEqual(history)
93
+ expect(transport.markRead).toHaveBeenCalledWith('support-1', '1')
94
+ sockets[0]?.open()
95
+ await Promise.resolve()
96
+ sockets[0]?.receive({
97
+ type: 'realtime_ready',
98
+ channels: { customer_support: { available: true } },
99
+ heartbeat_interval_seconds: 25,
100
+ })
101
+
102
+ expect(sockets[0]?.sent.map((frame) => JSON.parse(frame))).toEqual([
103
+ {
104
+ type: 'authenticate',
105
+ protocol_version: 1,
106
+ access_token: 'customer.support.jwt',
107
+ requested_channels: ['customer_support'],
108
+ cursors: {},
109
+ },
110
+ ])
111
+ expect(client.connectionState).toBe('connected')
112
+ expect(sockets[0]?.url).not.toContain('customer.support.jwt')
113
+ await Promise.resolve()
114
+ expect(transport.markRead).toHaveBeenCalledTimes(1)
115
+ for (let index = 0; index < 5; index += 1) {
116
+ sockets[0]?.receive({
117
+ type: 'realtime_event',
118
+ channel: 'customer_support',
119
+ event: {
120
+ type: 'support_event',
121
+ event_type: 'support_read_state_changed',
122
+ session_id: 'support-1',
123
+ payload: { last_read_sequence: '1' },
124
+ },
125
+ })
126
+ }
127
+ await Promise.resolve()
128
+ expect(transport.listMessages).toHaveBeenCalledTimes(1)
129
+ expect(transport.markRead).toHaveBeenCalledTimes(1)
130
+ vi.mocked(transport.listMessages).mockResolvedValue({
131
+ messages: [
132
+ ...history,
133
+ {
134
+ id: 'support-message-2',
135
+ sessionId: 'support-1',
136
+ sequence: '2',
137
+ clientMessageId: '',
138
+ senderType: 'agent',
139
+ senderName: '王悦',
140
+ content: '新的实时回复',
141
+ createdAt: '2026-08-18T08:01:00.000Z',
142
+ },
143
+ ],
144
+ latestSequence: '2',
145
+ sessionVersion: '2',
146
+ })
147
+ sockets[0]?.receive({
148
+ type: 'realtime_event',
149
+ channel: 'customer_support',
150
+ event: {
151
+ type: 'support_event',
152
+ event_type: 'support_message_created',
153
+ session_id: 'support-1',
154
+ payload: {},
155
+ },
156
+ })
157
+ await vi.waitFor(() => expect(client.messages).toHaveLength(2))
158
+ await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(2))
159
+ expect(transport.markRead).toHaveBeenLastCalledWith('support-1', '2')
160
+ expect(client.messages[1]?.content).toBe('新的实时回复')
161
+ sockets[0]?.receive({ type: 'channel_resync_required', channel: 'customer_support' })
162
+ await vi.waitFor(() => expect(transport.listMessages).toHaveBeenCalledTimes(3))
163
+ expect(transport.markRead).toHaveBeenCalledTimes(2)
164
+ client.destroy()
165
+ })
166
+
167
+ it('refreshes session projection without reloading history for non-message events', async () => {
168
+ const sockets: FakeWebSocket[] = []
169
+ const transport = {
170
+ ...createTransport(),
171
+ getSession: vi.fn(async () => ({ ...session, status: 'closed' as const, version: '2' })),
172
+ }
173
+ const client = new CustomerSupportClient({
174
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
175
+ transport,
176
+ webSocketFactory: (url) => {
177
+ const socket = new FakeWebSocket(url)
178
+ sockets.push(socket)
179
+ return socket.asWebSocket()
180
+ },
181
+ })
182
+ await client.start()
183
+ await Promise.resolve()
184
+
185
+ sockets[0]?.receive({
186
+ type: 'realtime_event',
187
+ channel: 'customer_support',
188
+ event: {
189
+ type: 'support_event',
190
+ event_type: 'support_session_closed',
191
+ session_id: 'support-1',
192
+ payload: {},
193
+ },
194
+ })
195
+
196
+ await vi.waitFor(() => expect(transport.getSession).toHaveBeenCalledTimes(1))
197
+ expect(client.session?.status).toBe('closed')
198
+ expect(transport.listMessages).toHaveBeenCalledTimes(1)
199
+ expect(transport.markRead).toHaveBeenCalledTimes(1)
200
+ client.destroy()
201
+ })
202
+
203
+ it('refreshes the session list for events from another support conversation', async () => {
204
+ const sockets: FakeWebSocket[] = []
205
+ const otherSession: CustomerSupportSession = {
206
+ ...session,
207
+ id: 'support-2',
208
+ sessionNo: 'CS-2',
209
+ title: '其他客服会话',
210
+ lastMessage: '旧消息',
211
+ unreadCount: 0,
212
+ }
213
+ const updatedOtherSession = {
214
+ ...otherSession,
215
+ lastMessage: '其他会话的新回复',
216
+ unreadCount: 1,
217
+ version: '2',
218
+ }
219
+ const transport = createTransport()
220
+ vi.mocked(transport.listSessions)
221
+ .mockResolvedValueOnce([session, otherSession])
222
+ .mockResolvedValue([session, updatedOtherSession])
223
+ const client = new CustomerSupportClient({
224
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
225
+ transport,
226
+ webSocketFactory: (url) => {
227
+ const socket = new FakeWebSocket(url)
228
+ sockets.push(socket)
229
+ return socket.asWebSocket()
230
+ },
231
+ })
232
+ const sessionLists: Array<readonly CustomerSupportSession[]> = []
233
+ client.on('sessions', ({ sessions }) => sessionLists.push(sessions))
234
+ await client.start()
235
+
236
+ sockets[0]?.receive({
237
+ type: 'realtime_event',
238
+ channel: 'customer_support',
239
+ event: {
240
+ type: 'support_event',
241
+ event_type: 'support_message_created',
242
+ session_id: 'support-2',
243
+ payload: {},
244
+ },
245
+ })
246
+
247
+ await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(2))
248
+ expect(sessionLists.at(-1)?.find((candidate) => candidate.id === 'support-2')).toEqual(
249
+ updatedOtherSession,
250
+ )
251
+ expect(transport.listMessages).toHaveBeenCalledTimes(1)
252
+ expect(transport.markRead).toHaveBeenCalledTimes(1)
253
+ expect(client.messages).toEqual(history)
254
+ client.destroy()
255
+ })
256
+
257
+ it('uses read-state events to refresh unread projections without reloading history', async () => {
258
+ const sockets: FakeWebSocket[] = []
259
+ const otherSession: CustomerSupportSession = {
260
+ ...session,
261
+ id: 'support-2',
262
+ sessionNo: 'CS-2',
263
+ unreadCount: 2,
264
+ }
265
+ const transport = createTransport()
266
+ vi.mocked(transport.listSessions)
267
+ .mockResolvedValueOnce([session, otherSession])
268
+ .mockResolvedValue([session, { ...otherSession, unreadCount: 0 }])
269
+ const client = new CustomerSupportClient({
270
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
271
+ transport,
272
+ webSocketFactory: (url) => {
273
+ const socket = new FakeWebSocket(url)
274
+ sockets.push(socket)
275
+ return socket.asWebSocket()
276
+ },
277
+ })
278
+ await client.start()
279
+
280
+ sockets[0]?.receive({
281
+ type: 'realtime_event',
282
+ channel: 'customer_support',
283
+ event: {
284
+ type: 'support_event',
285
+ event_type: 'support_read_state_changed',
286
+ session_id: 'support-2',
287
+ payload: { last_read_sequence: '2' },
288
+ },
289
+ })
290
+
291
+ await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(2))
292
+ expect(transport.listMessages).toHaveBeenCalledTimes(1)
293
+ expect(transport.markRead).toHaveBeenCalledTimes(1)
294
+ client.destroy()
295
+ })
296
+
297
+ it('deduplicates in-flight cursors and submits only the newest pending cursor', async () => {
298
+ const sockets: FakeWebSocket[] = []
299
+ const firstRead = deferred<void>()
300
+ const transport = createTransport()
301
+ const markRead = vi.fn<(sessionId: string, cursor: string) => Promise<void>>(async () => {})
302
+ transport.markRead = markRead
303
+ vi.mocked(transport.listMessages).mockResolvedValue({
304
+ messages: history,
305
+ latestSequence: '5',
306
+ sessionVersion: '1',
307
+ })
308
+ markRead.mockImplementationOnce(() => firstRead.promise).mockResolvedValue(undefined)
309
+ const client = new CustomerSupportClient({
310
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
311
+ transport,
312
+ webSocketFactory: (url) => {
313
+ const socket = new FakeWebSocket(url)
314
+ sockets.push(socket)
315
+ return socket.asWebSocket()
316
+ },
317
+ })
318
+ await client.start()
319
+ expect(transport.markRead).toHaveBeenCalledTimes(1)
320
+
321
+ for (const [index, latestSequence] of ['5', '4', '6', '6'].entries()) {
322
+ vi.mocked(transport.listMessages).mockResolvedValueOnce({
323
+ messages: history,
324
+ latestSequence,
325
+ sessionVersion: '1',
326
+ })
327
+ sockets[0]?.receive({ type: 'channel_resync_required', channel: 'customer_support' })
328
+ await vi.waitFor(() => expect(transport.listMessages).toHaveBeenCalledTimes(index + 2))
329
+ }
330
+ expect(transport.markRead).toHaveBeenCalledTimes(1)
331
+
332
+ firstRead.resolve()
333
+ await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(2))
334
+ expect(transport.markRead).toHaveBeenNthCalledWith(1, 'support-1', '5')
335
+ expect(transport.markRead).toHaveBeenNthCalledWith(2, 'support-1', '6')
336
+ client.destroy()
337
+ })
338
+
339
+ it('keeps acknowledged read cursors isolated across session switches', async () => {
340
+ const secondSession: CustomerSupportSession = {
341
+ ...session,
342
+ id: 'support-2',
343
+ sessionNo: 'CS-2',
344
+ version: '3',
345
+ }
346
+ const transport = createTransport()
347
+ vi.mocked(transport.listSessions).mockResolvedValue([session, secondSession])
348
+ vi.mocked(transport.listMessages).mockImplementation(async (sessionId) => ({
349
+ messages: [],
350
+ latestSequence: sessionId === 'support-1' ? '2' : '7',
351
+ sessionVersion: sessionId === 'support-1' ? '1' : '3',
352
+ }))
353
+ const client = new CustomerSupportClient({
354
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
355
+ transport,
356
+ webSocketFactory: (url) => new FakeWebSocket(url).asWebSocket(),
357
+ })
358
+
359
+ await client.start()
360
+ await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(1))
361
+ await client.bindSession('support-2')
362
+ await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(2))
363
+ await client.bindSession('support-1')
364
+ await Promise.resolve()
365
+
366
+ expect(transport.markRead).toHaveBeenCalledTimes(2)
367
+ expect(transport.markRead).toHaveBeenNthCalledWith(1, 'support-1', '2')
368
+ expect(transport.markRead).toHaveBeenNthCalledWith(2, 'support-2', '7')
369
+ client.destroy()
370
+ })
371
+
372
+ it('retries a failed cursor only after a later message refresh', async () => {
373
+ const sockets: FakeWebSocket[] = []
374
+ const transport = createTransport()
375
+ const markRead = vi.fn<(sessionId: string, cursor: string) => Promise<void>>(async () => {})
376
+ transport.markRead = markRead
377
+ vi.mocked(transport.listMessages).mockResolvedValue({
378
+ messages: history,
379
+ latestSequence: '3',
380
+ sessionVersion: '1',
381
+ })
382
+ markRead.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValue(undefined)
383
+ const client = new CustomerSupportClient({
384
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
385
+ transport,
386
+ webSocketFactory: (url) => {
387
+ const socket = new FakeWebSocket(url)
388
+ sockets.push(socket)
389
+ return socket.asWebSocket()
390
+ },
391
+ })
392
+ await client.start()
393
+ await Promise.resolve()
394
+ await Promise.resolve()
395
+ expect(transport.markRead).toHaveBeenCalledTimes(1)
396
+
397
+ for (let index = 0; index < 5; index += 1) {
398
+ sockets[0]?.receive({
399
+ type: 'realtime_event',
400
+ channel: 'customer_support',
401
+ event: {
402
+ type: 'support_event',
403
+ event_type: 'support_read_state_changed',
404
+ session_id: 'support-1',
405
+ payload: {},
406
+ },
407
+ })
408
+ }
409
+ await Promise.resolve()
410
+ expect(transport.markRead).toHaveBeenCalledTimes(1)
411
+
412
+ sockets[0]?.receive({
413
+ type: 'realtime_event',
414
+ channel: 'customer_support',
415
+ event: {
416
+ type: 'support_event',
417
+ event_type: 'support_message_created',
418
+ session_id: 'support-1',
419
+ payload: {},
420
+ },
421
+ })
422
+ await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(2))
423
+ expect(transport.markRead).toHaveBeenLastCalledWith('support-1', '3')
424
+ client.destroy()
425
+ })
426
+
427
+ it('creates the first support session atomically and emits one local message', async () => {
428
+ const sockets: FakeWebSocket[] = []
429
+ const transport = createTransport()
430
+ vi.mocked(transport.listSessions).mockResolvedValue([])
431
+ vi.mocked(transport.createSession).mockResolvedValue({
432
+ session,
433
+ initialMessage: {
434
+ ...history[0]!,
435
+ senderType: 'customer',
436
+ content: '需要人工协助',
437
+ },
438
+ })
439
+ const client = new CustomerSupportClient({
440
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
441
+ transport,
442
+ requestIdFactory: () => 'b95e15b8-55df-4d84-9fbe-c00dc45ae3a7',
443
+ webSocketFactory: (url) => {
444
+ const socket = new FakeWebSocket(url)
445
+ sockets.push(socket)
446
+ return socket.asWebSocket()
447
+ },
448
+ })
449
+ const received: string[] = []
450
+ client.on('message', ({ message }) => received.push(message.id))
451
+ await client.start()
452
+ const message = await client.sendMessage('需要人工协助')
453
+
454
+ expect(transport.createSession).toHaveBeenCalledWith({
455
+ clientRequestId: 'b95e15b8-55df-4d84-9fbe-c00dc45ae3a7',
456
+ title: '需要人工协助',
457
+ initialMessage: '需要人工协助',
458
+ })
459
+ expect(message.content).toBe('需要人工协助')
460
+ expect(received).toEqual(['support-message-1'])
461
+ client.destroy()
462
+ })
463
+
464
+ it('does not restore an empty waiting session before the customer sends a message', async () => {
465
+ const emptyWaitingSession: CustomerSupportSession = {
466
+ ...session,
467
+ status: 'waiting',
468
+ statusText: '等待接入',
469
+ lastMessage: '',
470
+ }
471
+ const initialMessage: CustomerSupportMessage = {
472
+ ...history[0]!,
473
+ senderType: 'customer',
474
+ senderName: '',
475
+ content: '首次咨询',
476
+ }
477
+ const transport = createTransport()
478
+ vi.mocked(transport.listSessions).mockResolvedValue([emptyWaitingSession])
479
+ vi.mocked(transport.createSession).mockResolvedValue({
480
+ session: { ...emptyWaitingSession, lastMessage: '首次咨询' },
481
+ initialMessage,
482
+ })
483
+ const client = new CustomerSupportClient({
484
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
485
+ transport,
486
+ requestIdFactory: () => 'b95e15b8-55df-4d84-9fbe-c00dc45ae3a7',
487
+ webSocketFactory: (url) => new FakeWebSocket(url).asWebSocket(),
488
+ })
489
+
490
+ await client.start()
491
+ expect(client.session).toBeNull()
492
+ expect(transport.listMessages).not.toHaveBeenCalled()
493
+ expect(transport.createSession).not.toHaveBeenCalled()
494
+
495
+ const message = await client.sendMessage('首次咨询')
496
+ expect(transport.createSession).toHaveBeenCalledWith({
497
+ clientRequestId: 'b95e15b8-55df-4d84-9fbe-c00dc45ae3a7',
498
+ title: '首次咨询',
499
+ initialMessage: '首次咨询',
500
+ })
501
+ expect(message).toEqual(initialMessage)
502
+ expect(client.session?.status).toBe('waiting')
503
+ client.destroy()
504
+ })
505
+
506
+ it('refreshes an expired support credential once and then stops retrying', async () => {
507
+ const sockets: FakeWebSocket[] = []
508
+ const transport = createTransport()
509
+ const client = new CustomerSupportClient({
510
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
511
+ transport,
512
+ reconnect: { initialDelayMs: 1_000, maximumDelayMs: 1_000, jitter: 0 },
513
+ webSocketFactory: (url) => {
514
+ const socket = new FakeWebSocket(url)
515
+ sockets.push(socket)
516
+ return socket.asWebSocket()
517
+ },
518
+ })
519
+ const errors: Array<{ code: string; message: string; recoverable: boolean }> = []
520
+ client.on('error', (detail) => errors.push(detail))
521
+ await client.start()
522
+
523
+ sockets[0]?.open()
524
+ await Promise.resolve()
525
+ sockets[0]?.receive({ type: 'authentication_error', code: 'UNAUTHORIZED' })
526
+ sockets[0]?.serverClose(4401)
527
+ await vi.advanceTimersByTimeAsync(1_000)
528
+ sockets[1]?.open()
529
+ await Promise.resolve()
530
+ sockets[1]?.receive({ type: 'authentication_error', code: 'UNAUTHORIZED' })
531
+ sockets[1]?.serverClose(4401)
532
+
533
+ expect(sockets).toHaveLength(2)
534
+ expect(transport.getAccessToken).toHaveBeenCalledTimes(2)
535
+ expect(client.connectionState).toBe('disconnected')
536
+ expect(vi.getTimerCount()).toBe(0)
537
+ expect(errors.at(-1)).toEqual({
538
+ code: 'AUTHENTICATION_FAILED',
539
+ message: '人工客服身份验证失败,请重新登录后再试。',
540
+ recoverable: false,
541
+ })
542
+ client.destroy()
543
+ })
544
+
545
+ it('does not label access denial as an expired credential or retry it', async () => {
546
+ const sockets: FakeWebSocket[] = []
547
+ const transport = createTransport()
548
+ const client = new CustomerSupportClient({
549
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
550
+ transport,
551
+ webSocketFactory: (url) => {
552
+ const socket = new FakeWebSocket(url)
553
+ sockets.push(socket)
554
+ return socket.asWebSocket()
555
+ },
556
+ })
557
+ const errors: Array<{ code: string; message: string; recoverable: boolean }> = []
558
+ client.on('error', (detail) => errors.push(detail))
559
+ await client.start()
560
+ sockets[0]?.open()
561
+ await Promise.resolve()
562
+ sockets[0]?.receive({ type: 'authentication_error', code: 'ACCESS_DENIED' })
563
+ sockets[0]?.serverClose(4403)
564
+
565
+ expect(client.connectionState).toBe('disconnected')
566
+ expect(vi.getTimerCount()).toBe(0)
567
+ expect(errors).toEqual([
568
+ {
569
+ code: 'AUTHENTICATION_FAILED',
570
+ message: '当前身份无权使用人工客服。',
571
+ recoverable: false,
572
+ },
573
+ ])
574
+ client.destroy()
575
+ })
576
+ })
@@ -0,0 +1,55 @@
1
+ export class FakeWebSocket {
2
+ readonly url: string
3
+ readyState = 0
4
+ sent: string[] = []
5
+ closeCount = 0
6
+ onopen: ((event: Event) => void) | null = null
7
+ onmessage: ((event: MessageEvent) => void) | null = null
8
+ onerror: ((event: Event) => void) | null = null
9
+ onclose: ((event: CloseEvent) => void) | null = null
10
+
11
+ constructor(url: string) {
12
+ this.url = url
13
+ }
14
+
15
+ open(): void {
16
+ this.readyState = 1
17
+ this.onopen?.(new Event('open'))
18
+ }
19
+
20
+ receive(payload: unknown): void {
21
+ this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(payload) }))
22
+ }
23
+
24
+ fail(): void {
25
+ this.onerror?.(new Event('error'))
26
+ }
27
+
28
+ serverClose(code = 1006): void {
29
+ this.readyState = 3
30
+ this.onclose?.(createCloseEvent(code))
31
+ }
32
+
33
+ send(payload: string): void {
34
+ if (this.readyState !== 1) throw new Error('socket is not open')
35
+ this.sent.push(payload)
36
+ }
37
+
38
+ close(code = 1000): void {
39
+ if (this.readyState >= 2) return
40
+ this.closeCount += 1
41
+ this.readyState = 3
42
+ this.onclose?.(createCloseEvent(code))
43
+ }
44
+
45
+ asWebSocket(): WebSocket {
46
+ return this as unknown as WebSocket
47
+ }
48
+ }
49
+
50
+ function createCloseEvent(code: number): CloseEvent {
51
+ if (typeof CloseEvent === 'function') return new CloseEvent('close', { code })
52
+ const event = new Event('close') as CloseEvent
53
+ Object.defineProperty(event, 'code', { value: code })
54
+ return event
55
+ }