@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,467 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { NotificationCenterClient } from '../src/notification-center-client'
3
+ import type {
4
+ NotificationCenterTransport,
5
+ NotificationDto,
6
+ NotificationRealtimeChange,
7
+ NotificationTypeSummary,
8
+ } from '../src/notification-center-types'
9
+
10
+ function notification(overrides: Partial<NotificationDto> = {}): NotificationDto {
11
+ return {
12
+ id: overrides.id ?? 'n1',
13
+ type: overrides.type ?? 'report',
14
+ typeName: overrides.typeName ?? '',
15
+ title: overrides.title ?? '5月人事月报已生成',
16
+ summary: overrides.summary ?? '含在职、入离职、合规、薪酬',
17
+ content: overrides.content ?? '正文',
18
+ contentType: overrides.contentType ?? 'text',
19
+ priority: overrides.priority ?? 'normal',
20
+ source: overrides.source ?? {
21
+ type: 'ai_employee',
22
+ code: 'archive',
23
+ id: '',
24
+ name: '数据分析师',
25
+ avatarUrl: '',
26
+ },
27
+ resources: overrides.resources ?? [],
28
+ attachments: overrides.attachments ?? [],
29
+ readStatus: overrides.readStatus ?? 'unread',
30
+ readAt: overrides.readAt ?? null,
31
+ archivedAt: overrides.archivedAt ?? null,
32
+ createdAt: overrides.createdAt ?? '2026-05-30T16:35:00.000Z',
33
+ expiresAt: overrides.expiresAt ?? null,
34
+ }
35
+ }
36
+
37
+ function baseTransport(overrides: Partial<NotificationCenterTransport> = {}): {
38
+ transport: NotificationCenterTransport
39
+ inbox: ReturnType<typeof vi.fn>
40
+ unread: ReturnType<typeof vi.fn>
41
+ markRead: ReturnType<typeof vi.fn>
42
+ markAllRead: ReturnType<typeof vi.fn>
43
+ } {
44
+ const inbox = vi.fn(async () => ({ items: [notification()], hasMore: false, nextId: null }))
45
+ const unread = vi.fn(async () => ({ unreadCount: 3 }))
46
+ const markRead = vi.fn(async () => ({ unreadCount: 2, readAt: '2026-05-30T17:00:00.000Z' }))
47
+ const markAllRead = vi.fn(async () => ({ markedCount: 3, unreadCount: 0 }))
48
+ const transport: NotificationCenterTransport = {
49
+ listInbox: inbox as unknown as NotificationCenterTransport['listInbox'],
50
+ getUnreadCount: unread as unknown as NotificationCenterTransport['getUnreadCount'],
51
+ markRead: markRead as unknown as NotificationCenterTransport['markRead'],
52
+ markAllRead: markAllRead as unknown as NotificationCenterTransport['markAllRead'],
53
+ downloadAttachment: vi.fn(async () => new Blob(['x'])),
54
+ ...overrides,
55
+ }
56
+ return { transport, inbox, unread, markRead, markAllRead }
57
+ }
58
+
59
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
60
+
61
+ describe('NotificationCenterClient', () => {
62
+ it('loads inbox + unread with applicationName in the request body and never opens a socket', async () => {
63
+ const { transport, inbox, unread } = baseTransport()
64
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
65
+ const ready = vi.fn()
66
+ const unreadEvents: number[] = []
67
+ client.on('ready', ready)
68
+ client.on('unread-count-change', ({ unreadCount }) => unreadEvents.push(unreadCount))
69
+ await client.start()
70
+
71
+ expect(inbox).toHaveBeenCalledWith(
72
+ expect.objectContaining({ pageSize: 20, beforeId: null, applicationName: 'HrSaas' }),
73
+ )
74
+ expect(unread).toHaveBeenCalledWith({ applicationName: 'HrSaas' })
75
+ expect(client.notifications).toHaveLength(1)
76
+ expect(unreadEvents).toContain(3)
77
+ expect(ready).toHaveBeenCalledTimes(1)
78
+ // The client owns no realtime connection: it never asks for a token or a socket.
79
+ expect(Object.keys(transport)).not.toContain('getAccessToken')
80
+ expect('connectionState' in client).toBe(false)
81
+ client.destroy()
82
+ })
83
+
84
+ it('rejects construction without a transport', () => {
85
+ expect(
86
+ () =>
87
+ new NotificationCenterClient({
88
+ transport: undefined as unknown as NotificationCenterTransport,
89
+ applicationName: 'HrSaas',
90
+ }),
91
+ ).toThrow(/transport/)
92
+ })
93
+
94
+ it('uses an empty applicationName as the all-applications projection', async () => {
95
+ const { transport, inbox, unread } = baseTransport()
96
+ const client = new NotificationCenterClient({ transport, applicationName: ' ' })
97
+ await client.start()
98
+
99
+ expect(inbox).toHaveBeenCalledWith(expect.objectContaining({ applicationName: '' }))
100
+ expect(unread).toHaveBeenCalledWith({ applicationName: '' })
101
+ client.destroy()
102
+ })
103
+
104
+ it('applies type filter through listInbox notification_types', async () => {
105
+ const { transport, inbox } = baseTransport()
106
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
107
+ await client.start()
108
+ await client.setTypeFilter(['alert'])
109
+ expect(inbox).toHaveBeenLastCalledWith(
110
+ expect.objectContaining({ notificationTypes: ['alert'], beforeId: null }),
111
+ )
112
+ expect(client.getTypeFilter()).toEqual(['alert'])
113
+ client.destroy()
114
+ })
115
+
116
+ it('supports an unread-only inbox projection for compact consumers', async () => {
117
+ const { transport, inbox } = baseTransport()
118
+ const client = new NotificationCenterClient({
119
+ transport,
120
+ applicationName: 'OfficialWebsite',
121
+ pageSize: 4,
122
+ readStatus: 'unread',
123
+ })
124
+ await client.start()
125
+ expect(inbox).toHaveBeenCalledWith(
126
+ expect.objectContaining({
127
+ pageSize: 4,
128
+ beforeId: null,
129
+ readStatus: 'unread',
130
+ applicationName: 'OfficialWebsite',
131
+ }),
132
+ )
133
+ client.destroy()
134
+ })
135
+
136
+ it('derives the type catalog from loaded pages when the backend sends no facets', async () => {
137
+ const inbox = vi.fn(async () => ({
138
+ items: [
139
+ notification({ id: 'n1', type: 'report', typeName: '报告' }),
140
+ notification({ id: 'n2', type: 'alert', typeName: '预警' }),
141
+ // 后端字典缺该码的展示名时保留空串,由 UI 回退到 code。
142
+ notification({ id: 'n3', type: 'interview', typeName: '' }),
143
+ notification({ id: 'n4', type: 'alert', typeName: '预警' }),
144
+ ],
145
+ hasMore: false,
146
+ nextId: null,
147
+ }))
148
+ const { transport } = baseTransport({
149
+ listInbox: inbox as unknown as NotificationCenterTransport['listInbox'],
150
+ })
151
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
152
+ const emitted: NotificationTypeSummary[][] = []
153
+ client.on('types-change', ({ types }) => emitted.push([...types]))
154
+ await client.start()
155
+ expect(client.types).toEqual([
156
+ { type: 'report', name: '报告' },
157
+ { type: 'alert', name: '预警' },
158
+ { type: 'interview', name: '' },
159
+ ])
160
+ expect(emitted.at(-1)).toEqual(client.types)
161
+ client.destroy()
162
+ })
163
+
164
+ it('requests inbox type facets and prefers them over page-local types', async () => {
165
+ const inbox = vi.fn(async () => ({
166
+ items: [notification({ id: 'n1', type: 'report', typeName: '报告' })],
167
+ hasMore: false,
168
+ nextId: null,
169
+ types: [
170
+ { type: 'care', name: '关怀' },
171
+ { type: 'sales_lead_assigned', name: '新线索' },
172
+ { type: 'todo', name: '待办' },
173
+ ],
174
+ }))
175
+ const { transport } = baseTransport({
176
+ listInbox: inbox as unknown as NotificationCenterTransport['listInbox'],
177
+ })
178
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
179
+ await client.start()
180
+ // 目录随 inbox 同一请求取回,不额外发请求。
181
+ expect(inbox).toHaveBeenCalledTimes(1)
182
+ expect(inbox).toHaveBeenCalledWith(expect.objectContaining({ includeTypeFacets: true }))
183
+ // 后端目录为权威:顺序照搬,且不并入仅在当前页出现的 report。
184
+ expect(client.types).toEqual([
185
+ { type: 'care', name: '关怀' },
186
+ { type: 'sales_lead_assigned', name: '新线索' },
187
+ { type: 'todo', name: '待办' },
188
+ ])
189
+ client.destroy()
190
+ })
191
+
192
+ it('fills a missing catalog display name from the loaded items', async () => {
193
+ const inbox = vi.fn(async () => ({
194
+ items: [notification({ id: 'n1', type: 'report', typeName: '报告' })],
195
+ hasMore: false,
196
+ nextId: null,
197
+ types: [{ type: 'report', name: '' }],
198
+ }))
199
+ const { transport } = baseTransport({
200
+ listInbox: inbox as unknown as NotificationCenterTransport['listInbox'],
201
+ })
202
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
203
+ await client.start()
204
+ expect(client.types).toEqual([{ type: 'report', name: '报告' }])
205
+ client.destroy()
206
+ })
207
+
208
+ it('keeps discovered types after a filtered reload and honours preferredTypeOrder', async () => {
209
+ let filtered = false
210
+ const inbox = vi.fn(async (query: { notificationTypes?: readonly string[] }) => {
211
+ filtered = (query.notificationTypes ?? []).length > 0
212
+ const items = filtered
213
+ ? [notification({ id: 'n2', type: 'alert' })]
214
+ : [notification({ id: 'n1', type: 'report' }), notification({ id: 'n2', type: 'alert' })]
215
+ return { items, hasMore: false, nextId: null }
216
+ })
217
+ const { transport } = baseTransport({
218
+ listInbox: inbox as unknown as NotificationCenterTransport['listInbox'],
219
+ })
220
+ const client = new NotificationCenterClient({
221
+ transport,
222
+ applicationName: 'HrSaas',
223
+ preferredTypeOrder: ['alert'],
224
+ })
225
+ await client.start()
226
+ // preferredTypeOrder 把 alert 顶到最前,其余按发现顺序。
227
+ expect(client.types.map((entry) => entry.type)).toEqual(['alert', 'report'])
228
+ await client.setTypeFilter(['alert'])
229
+ // 服务端只回 alert,但目录不得塌缩成单个分类。
230
+ expect(filtered).toBe(true)
231
+ expect(client.types.map((entry) => entry.type)).toEqual(['alert', 'report'])
232
+ client.destroy()
233
+ })
234
+
235
+ it('registers a brand-new type arriving through a host-fed created event', async () => {
236
+ const parseRealtimeEvent = vi.fn((): NotificationRealtimeChange => ({
237
+ kind: 'created',
238
+ notification: notification({ id: 'n9', type: 'interview' }),
239
+ unreadCount: 4,
240
+ }))
241
+ const { transport } = baseTransport({ parseRealtimeEvent })
242
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
243
+ await client.start()
244
+ expect(client.types.map((entry) => entry.type)).toEqual(['report'])
245
+ client.applyRealtimeEvent({ event_type: 'notification_created' })
246
+ expect(client.types.map((entry) => entry.type)).toEqual(['report', 'interview'])
247
+ client.destroy()
248
+ })
249
+
250
+ it('marks a single notification read and updates unread count', async () => {
251
+ const { transport, markRead } = baseTransport()
252
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
253
+ const readEvents: string[] = []
254
+ const unreadEvents: number[] = []
255
+ client.on('notification-read', ({ notificationId }) => readEvents.push(notificationId))
256
+ client.on('unread-count-change', ({ unreadCount }) => unreadEvents.push(unreadCount))
257
+ await client.start()
258
+ await client.markRead('n1')
259
+ expect(markRead).toHaveBeenCalledWith({ notificationId: 'n1', applicationName: 'HrSaas' })
260
+ expect(client.notifications[0]?.readStatus).toBe('read')
261
+ expect(readEvents).toEqual(['n1'])
262
+ expect(unreadEvents).toContain(2)
263
+ client.destroy()
264
+ })
265
+
266
+ it('marks all read and zeroes the unread count', async () => {
267
+ const { transport, markAllRead } = baseTransport()
268
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
269
+ await client.start()
270
+ await client.markAllRead()
271
+ expect(markAllRead).toHaveBeenCalledWith({ applicationName: 'HrSaas' })
272
+ expect(client.notifications.every((item) => item.readStatus === 'read')).toBe(true)
273
+ expect(client.unread).toBe(0)
274
+ client.destroy()
275
+ })
276
+
277
+ it('inserts a host-fed created event in place via parseRealtimeEvent', async () => {
278
+ const parseRealtimeEvent = vi.fn((): NotificationRealtimeChange => ({
279
+ kind: 'created',
280
+ notification: notification({ id: 'n2', title: '新预警', type: 'alert' }),
281
+ unreadCount: 4,
282
+ }))
283
+ const { transport, inbox } = baseTransport({ parseRealtimeEvent })
284
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
285
+ await client.start()
286
+ inbox.mockClear()
287
+ client.applyRealtimeEvent({ event_type: 'notification_created' })
288
+
289
+ expect(parseRealtimeEvent).toHaveBeenCalledWith({ event_type: 'notification_created' })
290
+ expect(client.notifications[0]?.id).toBe('n2')
291
+ expect(client.unread).toBe(4)
292
+ // An in-place change does not trigger another inbox round trip.
293
+ expect(inbox).not.toHaveBeenCalled()
294
+ client.destroy()
295
+ })
296
+
297
+ it('refreshes the aggregate unread count for all-applications realtime events', async () => {
298
+ const parseRealtimeEvent = vi.fn((): NotificationRealtimeChange => ({
299
+ kind: 'created',
300
+ notification: notification({ id: 'n2' }),
301
+ // Existing realtime payloads are application-scoped and cannot replace
302
+ // the aggregate count used when applicationName is empty.
303
+ unreadCount: 99,
304
+ }))
305
+ const { transport, unread } = baseTransport({ parseRealtimeEvent })
306
+ const client = new NotificationCenterClient({ transport, applicationName: '' })
307
+ await client.start()
308
+ unread.mockClear()
309
+
310
+ client.applyRealtimeEvent({ event_type: 'notification_created' })
311
+ await flush()
312
+
313
+ expect(unread).toHaveBeenCalledWith({ applicationName: '' })
314
+ expect(client.unread).toBe(3)
315
+ client.destroy()
316
+ })
317
+
318
+ it('deduplicates a host-fed created event by notification id', async () => {
319
+ const parseRealtimeEvent = vi.fn((): NotificationRealtimeChange => ({
320
+ kind: 'created',
321
+ notification: notification({ id: 'n1' }),
322
+ unreadCount: 3,
323
+ }))
324
+ const { transport } = baseTransport({ parseRealtimeEvent })
325
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
326
+ await client.start()
327
+ client.applyRealtimeEvent({ event_type: 'notification_created' })
328
+ client.applyRealtimeEvent({ event_type: 'notification_created' })
329
+ expect(client.notifications).toHaveLength(1)
330
+ client.destroy()
331
+ })
332
+
333
+ it('applies host-fed read and archived events', async () => {
334
+ let change: NotificationRealtimeChange = {
335
+ kind: 'read',
336
+ notificationIds: ['n1'],
337
+ unreadCount: 0,
338
+ readAt: '2026-05-30T18:00:00.000Z',
339
+ }
340
+ const { transport } = baseTransport({ parseRealtimeEvent: () => change })
341
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
342
+ await client.start()
343
+ client.applyRealtimeEvent({})
344
+ expect(client.notifications[0]?.readStatus).toBe('read')
345
+ expect(client.unread).toBe(0)
346
+
347
+ change = { kind: 'archived', notificationIds: ['n1'], unreadCount: 0 }
348
+ client.applyRealtimeEvent({})
349
+ expect(client.notifications).toHaveLength(0)
350
+ client.destroy()
351
+ })
352
+
353
+ it('falls back to a full refresh when the event cannot be mapped', async () => {
354
+ const { transport, inbox, unread } = baseTransport({ parseRealtimeEvent: () => null })
355
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
356
+ await client.start()
357
+ inbox.mockClear()
358
+ unread.mockClear()
359
+ client.applyRealtimeEvent({ event_type: 'notification_unknown' })
360
+ await flush()
361
+ expect(inbox).toHaveBeenCalledTimes(1)
362
+ expect(unread).toHaveBeenCalledTimes(1)
363
+ client.destroy()
364
+ })
365
+
366
+ it('falls back to a full refresh when parseRealtimeEvent throws or is absent', async () => {
367
+ const throwing = baseTransport({
368
+ parseRealtimeEvent: () => {
369
+ throw new Error('bad event')
370
+ },
371
+ })
372
+ const client = new NotificationCenterClient({
373
+ transport: throwing.transport,
374
+ applicationName: 'HrSaas',
375
+ })
376
+ await client.start()
377
+ throwing.inbox.mockClear()
378
+ client.applyRealtimeEvent({})
379
+ await flush()
380
+ expect(throwing.inbox).toHaveBeenCalledTimes(1)
381
+ client.destroy()
382
+
383
+ const plain = baseTransport()
384
+ const second = new NotificationCenterClient({
385
+ transport: plain.transport,
386
+ applicationName: 'HrSaas',
387
+ })
388
+ await second.start()
389
+ plain.inbox.mockClear()
390
+ second.applyRealtimeEvent({})
391
+ await flush()
392
+ expect(plain.inbox).toHaveBeenCalledTimes(1)
393
+ second.destroy()
394
+ })
395
+
396
+ it('resyncs instead of losing an event that arrives while the inbox is in flight', async () => {
397
+ let release = (): void => {}
398
+ const gate = new Promise<void>((resolve) => {
399
+ release = resolve
400
+ })
401
+ let firstCall = true
402
+ const parseRealtimeEvent = vi.fn((): NotificationRealtimeChange => ({
403
+ kind: 'created',
404
+ notification: notification({ id: 'n2' }),
405
+ unreadCount: 4,
406
+ }))
407
+ const inbox = vi.fn(async () => {
408
+ if (firstCall) {
409
+ firstCall = false
410
+ await gate
411
+ }
412
+ return { items: [notification()], hasMore: false, nextId: null }
413
+ })
414
+ const { transport } = baseTransport({
415
+ parseRealtimeEvent,
416
+ listInbox: inbox as unknown as NotificationCenterTransport['listInbox'],
417
+ })
418
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
419
+ const starting = client.start()
420
+ await flush()
421
+ // The event lands mid-request: applying it in place would be clobbered by the
422
+ // pending server page, so it is deferred into a resync instead.
423
+ client.applyRealtimeEvent({ event_type: 'notification_created' })
424
+ expect(parseRealtimeEvent).not.toHaveBeenCalled()
425
+ release()
426
+ await starting
427
+ await flush()
428
+ expect(inbox).toHaveBeenCalledTimes(2)
429
+ client.destroy()
430
+ })
431
+
432
+ it('ignores host-fed events after destroy without throwing', async () => {
433
+ const { transport, inbox } = baseTransport()
434
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
435
+ await client.start()
436
+ client.destroy()
437
+ inbox.mockClear()
438
+ expect(() => client.applyRealtimeEvent({ event_type: 'notification_created' })).not.toThrow()
439
+ await flush()
440
+ expect(inbox).not.toHaveBeenCalled()
441
+ })
442
+
443
+ it('surfaces a recoverable error when the inbox request fails', async () => {
444
+ const { transport } = baseTransport({
445
+ listInbox: vi.fn(async () => {
446
+ throw new Error('backend stack trace 5xx')
447
+ }) as unknown as NotificationCenterTransport['listInbox'],
448
+ })
449
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
450
+ const errors: Array<{ code: string; recoverable: boolean; message: string }> = []
451
+ client.on('error', (detail) => errors.push(detail))
452
+ await client.start()
453
+ expect(errors[0]?.code).toBe('INBOX_LOAD_FAILED')
454
+ expect(errors[0]?.recoverable).toBe(true)
455
+ // The raw backend error never reaches the surfaced detail.
456
+ expect(JSON.stringify(errors)).not.toContain('backend stack trace')
457
+ client.destroy()
458
+ })
459
+
460
+ it('throws when used after destroy', async () => {
461
+ const { transport } = baseTransport()
462
+ const client = new NotificationCenterClient({ transport, applicationName: 'HrSaas' })
463
+ await client.start()
464
+ client.destroy()
465
+ await expect(client.markRead('n1')).rejects.toThrow(/销毁/)
466
+ })
467
+ })
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createAuthenticateFrame, createSubscriptionFrame, parseAiEmployeeEvent } from '../src'
3
+
4
+ describe('Kanban user realtime protocol', () => {
5
+ it('creates a JWT authentication first frame without identity claims', () => {
6
+ expect(createAuthenticateFrame('secret-jwt')).toEqual({
7
+ type: 'authenticate',
8
+ protocol_version: 1,
9
+ access_token: 'secret-jwt',
10
+ requested_channels: ['ai_employee'],
11
+ cursors: {},
12
+ })
13
+ expect(JSON.stringify(createAuthenticateFrame('secret-jwt'))).not.toContain('org_id')
14
+ expect(JSON.stringify(createAuthenticateFrame('secret-jwt'))).not.toContain('user_id')
15
+ })
16
+
17
+ it('creates the session subscription used after realtime_ready', () => {
18
+ expect(createSubscriptionFrame('session-1')).toEqual({
19
+ action: 'subscribe',
20
+ channel: 'ai_employee',
21
+ resource_type: 'session',
22
+ resource_id: 'session-1',
23
+ replay_history: false,
24
+ })
25
+ })
26
+
27
+ it('parses AI text patches, processing markers, results and title events', () => {
28
+ const parsed = parseAiEmployeeEvent({
29
+ type: 'session_message',
30
+ session_id: 'session-1',
31
+ payload: {
32
+ Ready: true,
33
+ JsonPatch: [
34
+ {
35
+ op: 'add',
36
+ path: '/0',
37
+ value: {
38
+ ws_message_id: 'message-1',
39
+ entry_type: { type: 'assistant_message' },
40
+ content: JSON.stringify({ text: '你好' }),
41
+ timestamp: '2026-08-03T01:00:00.000Z',
42
+ },
43
+ },
44
+ {
45
+ op: 'add',
46
+ path: '/1',
47
+ value: {
48
+ entry_type: { type: 'thinking' },
49
+ content: JSON.stringify({ thinking: 'internal' }),
50
+ },
51
+ },
52
+ ],
53
+ },
54
+ })
55
+
56
+ expect(parsed).toMatchObject({
57
+ kind: 'session-message',
58
+ sessionId: 'session-1',
59
+ result: {
60
+ processing: true,
61
+ messages: [{ id: 'message-1', role: 'assistant', content: '你好' }],
62
+ },
63
+ })
64
+ expect(
65
+ parseAiEmployeeEvent({
66
+ type: 'session_title_updated',
67
+ session_id: 'session-1',
68
+ payload: { title: '招聘助手' },
69
+ }),
70
+ ).toEqual({ kind: 'session-title', sessionId: 'session-1', title: '招聘助手' })
71
+ })
72
+
73
+ it('never exposes a backend error body as chat content', () => {
74
+ const parsed = parseAiEmployeeEvent({
75
+ type: 'session_message',
76
+ session_id: 'session-1',
77
+ payload: { error: 'Traceback: access_token=secret-jwt' },
78
+ })
79
+ expect(JSON.stringify(parsed)).not.toContain('secret-jwt')
80
+ expect(JSON.stringify(parsed)).not.toContain('Traceback')
81
+ })
82
+
83
+ it('keeps a top-level result separate as an assistant fallback', () => {
84
+ const parsed = parseAiEmployeeEvent({
85
+ type: 'session_message',
86
+ session_id: 'session-1',
87
+ payload: { result: '兜底回复' },
88
+ })
89
+
90
+ expect(parsed).toMatchObject({
91
+ kind: 'session-message',
92
+ sessionId: 'session-1',
93
+ result: {
94
+ messages: [],
95
+ fallbackResult: { role: 'assistant', content: '兜底回复' },
96
+ },
97
+ })
98
+ })
99
+
100
+ it('uses the database message id for user messages when realtime also supplies a frame id', () => {
101
+ const parsed = parseAiEmployeeEvent({
102
+ type: 'session_message',
103
+ session_id: 'atomic-session',
104
+ payload: {
105
+ JsonPatch: [
106
+ {
107
+ path: '/0',
108
+ value: {
109
+ message_id: '107021',
110
+ ws_message_id: '0d3805dd-d2d0-49ed-b335-e5eeec7fc3f9',
111
+ entry_type: { type: 'user_message' },
112
+ content: JSON.stringify({ text: '原子首条消息' }),
113
+ },
114
+ },
115
+ ],
116
+ },
117
+ })
118
+
119
+ expect(parsed).toMatchObject({
120
+ kind: 'session-message',
121
+ result: { messages: [{ id: '107021', role: 'user', content: '原子首条消息' }] },
122
+ })
123
+ })
124
+ })
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true
5
+ },
6
+ "include": ["src", "tests", "tsup.config.ts"]
7
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'tsup'
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm'],
6
+ dts: true,
7
+ sourcemap: true,
8
+ clean: true,
9
+ target: 'es2022',
10
+ treeshake: true,
11
+ })
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@uniplat/ai-employee-web-components",
3
+ "version": "0.23.3",
4
+ "description": "Framework-independent Web Components for Uniplat account, login, application switching and AI Employee chat",
5
+ "type": "module",
6
+ "sideEffects": true,
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "main": "./dist/index.js",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "tsup && pnpm build:runtime",
21
+ "build:runtime": "tsup --config tsup.runtime.config.ts && node scripts/verify-runtime-build.mjs",
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "vitest run"
24
+ },
25
+ "dependencies": {
26
+ "@uniplat/ai-employee-client": "workspace:*",
27
+ "lit": "^3.3.1"
28
+ },
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }