@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.
- package/.prettierignore +4 -0
- package/.prettierrc.json +6 -0
- package/AGENTS.md +519 -0
- package/CLAUDE.md +512 -0
- package/README.md +734 -0
- 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
- 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
- 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
- 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
- package/eslint.config.js +30 -0
- package/examples/vanilla/index.html +33 -0
- package/examples/vanilla/main.js +247 -0
- package/examples/vanilla/package.json +15 -0
- package/examples/vanilla/public/runtime-config.js +10 -0
- package/examples/vanilla/style.css +99 -0
- package/package.json +31 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/client.ts +1132 -0
- package/packages/core/src/customer-support-client.ts +825 -0
- package/packages/core/src/customer-support-types.ts +123 -0
- package/packages/core/src/index.ts +79 -0
- package/packages/core/src/notification-center-client.ts +462 -0
- package/packages/core/src/notification-center-types.ts +154 -0
- package/packages/core/src/protocol.ts +237 -0
- package/packages/core/src/types.ts +170 -0
- package/packages/core/tests/client.test.ts +760 -0
- package/packages/core/tests/customer-support-client.test.ts +576 -0
- package/packages/core/tests/fake-websocket.ts +55 -0
- package/packages/core/tests/notification-center-client.test.ts +467 -0
- package/packages/core/tests/protocol.test.ts +124 -0
- package/packages/core/tsconfig.json +7 -0
- package/packages/core/tsup.config.ts +11 -0
- package/packages/web-components/package.json +35 -0
- package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
- package/packages/web-components/src/account-context.ts +1540 -0
- package/packages/web-components/src/application-switcher-transport.ts +243 -0
- package/packages/web-components/src/application-switcher.ts +1103 -0
- package/packages/web-components/src/chat.ts +1754 -0
- package/packages/web-components/src/customer-support-chat.ts +862 -0
- package/packages/web-components/src/customer-support-transport.ts +269 -0
- package/packages/web-components/src/index.ts +256 -0
- package/packages/web-components/src/notification-center.ts +1066 -0
- package/packages/web-components/src/notification-popover.ts +615 -0
- package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
- package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
- package/packages/web-components/src/organization-onboarding.ts +1216 -0
- package/packages/web-components/src/organization-selector.ts +534 -0
- package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
- package/packages/web-components/src/prototype-icons.ts +242 -0
- package/packages/web-components/src/safe-image-url.ts +29 -0
- package/packages/web-components/src/safe-markdown.ts +192 -0
- package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
- package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
- package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
- package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
- package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
- package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
- package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
- package/packages/web-components/src/user-login-transport.ts +563 -0
- package/packages/web-components/src/user-login.ts +2027 -0
- package/packages/web-components/src/user-menu.ts +880 -0
- package/packages/web-components/tests/account-context.test.ts +798 -0
- package/packages/web-components/tests/application-switcher.test.ts +562 -0
- package/packages/web-components/tests/chat.test.ts +1130 -0
- package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
- package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
- package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
- package/packages/web-components/tests/notification-center.test.ts +429 -0
- package/packages/web-components/tests/notification-popover.test.ts +283 -0
- package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
- package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
- package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
- package/packages/web-components/tests/organization-selector.test.ts +127 -0
- package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
- package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
- package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
- package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
- package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
- package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
- package/packages/web-components/tests/user-login-transport.test.ts +443 -0
- package/packages/web-components/tests/user-login.test.ts +626 -0
- package/packages/web-components/tests/user-menu.test.ts +271 -0
- package/packages/web-components/tsconfig.json +8 -0
- package/packages/web-components/tsup.config.ts +12 -0
- package/packages/web-components/tsup.runtime.config.ts +37 -0
- package/pnpm-workspace.yaml +6 -0
- package/tsconfig.base.json +21 -0
- package/vitest.config.ts +20 -0
- package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
AiEmployeeTransportError,
|
|
6
|
+
type AiEmployeeMessage,
|
|
7
|
+
type AiEmployeeTransport,
|
|
8
|
+
} from '@uniplat/ai-employee-client'
|
|
9
|
+
import '../src'
|
|
10
|
+
import type { UniplatAiEmployeeChat } from '../src'
|
|
11
|
+
|
|
12
|
+
class ComponentFakeWebSocket {
|
|
13
|
+
readonly url: string
|
|
14
|
+
readyState = 0
|
|
15
|
+
sent: string[] = []
|
|
16
|
+
closeCount = 0
|
|
17
|
+
onopen: ((event: Event) => void) | null = null
|
|
18
|
+
onmessage: ((event: MessageEvent) => void) | null = null
|
|
19
|
+
onerror: ((event: Event) => void) | null = null
|
|
20
|
+
onclose: ((event: CloseEvent) => void) | null = null
|
|
21
|
+
|
|
22
|
+
constructor(url: string) {
|
|
23
|
+
this.url = url
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
open(): void {
|
|
27
|
+
this.readyState = 1
|
|
28
|
+
this.onopen?.(new Event('open'))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
receive(payload: unknown): void {
|
|
32
|
+
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(payload) }))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
send(payload: string): void {
|
|
36
|
+
this.sent.push(payload)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
close(): void {
|
|
40
|
+
if (this.readyState >= 2) return
|
|
41
|
+
this.closeCount += 1
|
|
42
|
+
this.readyState = 3
|
|
43
|
+
this.onclose?.(new CloseEvent('close', { code: 1000 }))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
asWebSocket(): WebSocket {
|
|
47
|
+
return this as unknown as WebSocket
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createTransport(history: AiEmployeeMessage[] = []) {
|
|
52
|
+
return {
|
|
53
|
+
createSessionWithFirstMessage: vi.fn(async () => ({
|
|
54
|
+
sessionId: 'atomic-session-default',
|
|
55
|
+
messageId: 'atomic-message-default',
|
|
56
|
+
executionId: 'atomic-execution-default',
|
|
57
|
+
status: 'running' as const,
|
|
58
|
+
startedAt: '2026-08-18T08:00:00.000Z',
|
|
59
|
+
aeCode: 'recruit',
|
|
60
|
+
agentCode: 'default',
|
|
61
|
+
conversationType: 'default',
|
|
62
|
+
})),
|
|
63
|
+
listMessages: vi.fn(async () => history),
|
|
64
|
+
sendMessage: vi.fn(async () => undefined),
|
|
65
|
+
getAccessToken: vi.fn(async () => 'component-secret-jwt'),
|
|
66
|
+
} satisfies AiEmployeeTransport
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function settle(element: UniplatAiEmployeeChat): Promise<void> {
|
|
70
|
+
await Promise.resolve()
|
|
71
|
+
await element.updateComplete
|
|
72
|
+
await Promise.resolve()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function authenticateAndReadyComponent(socket: ComponentFakeWebSocket): Promise<void> {
|
|
76
|
+
socket.open()
|
|
77
|
+
await Promise.resolve()
|
|
78
|
+
socket.receive({
|
|
79
|
+
type: 'realtime_ready',
|
|
80
|
+
channels: { ai_employee: { available: true } },
|
|
81
|
+
heartbeat_interval_seconds: 25,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function deferred<T>() {
|
|
86
|
+
let resolve!: (value: T | PromiseLike<T>) => void
|
|
87
|
+
let reject!: (reason?: unknown) => void
|
|
88
|
+
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
89
|
+
resolve = promiseResolve
|
|
90
|
+
reject = promiseReject
|
|
91
|
+
})
|
|
92
|
+
return { promise, resolve, reject }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('<uniplat-ai-employee-chat>', () => {
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
document.body.replaceChildren()
|
|
98
|
+
vi.unstubAllGlobals()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('loads, sends, receives once, emits safe events and destroys its client', async () => {
|
|
102
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
103
|
+
const transport = createTransport()
|
|
104
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
105
|
+
const eventDetails: unknown[] = []
|
|
106
|
+
for (const type of [
|
|
107
|
+
'ready',
|
|
108
|
+
'message-sent',
|
|
109
|
+
'message-received',
|
|
110
|
+
'connection-state-change',
|
|
111
|
+
'error',
|
|
112
|
+
]) {
|
|
113
|
+
chat.addEventListener(type, (event) => eventDetails.push((event as CustomEvent).detail))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
chat.configure({
|
|
117
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
118
|
+
aeCode: 'recruit',
|
|
119
|
+
agentCode: 'default',
|
|
120
|
+
sessionId: 'session-1',
|
|
121
|
+
transport,
|
|
122
|
+
minimumHeartbeatMs: 60_000,
|
|
123
|
+
webSocketFactory: (url) => {
|
|
124
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
125
|
+
sockets.push(socket)
|
|
126
|
+
return socket.asWebSocket()
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
document.body.append(chat)
|
|
130
|
+
await settle(chat)
|
|
131
|
+
expect(sockets).toHaveLength(0)
|
|
132
|
+
expect(chat.shadowRoot?.querySelector('[role="dialog"]')).toBeNull()
|
|
133
|
+
|
|
134
|
+
chat.open()
|
|
135
|
+
await settle(chat)
|
|
136
|
+
expect(sockets).toHaveLength(1)
|
|
137
|
+
expect(chat.shadowRoot?.textContent).toContain('企业服务AI顾问')
|
|
138
|
+
expect(chat.shadowRoot?.textContent).toContain('7×24 小时在线,任何问题都可以问我')
|
|
139
|
+
expect(chat.shadowRoot?.querySelector('[data-prototype-icon="messages"]')).toBeTruthy()
|
|
140
|
+
expect(
|
|
141
|
+
chat.shadowRoot?.querySelector('.advisor-mark [data-prototype-icon="robot"]'),
|
|
142
|
+
).toBeTruthy()
|
|
143
|
+
expect(chat.shadowRoot?.textContent).toContain('开始和AI客服对话吧')
|
|
144
|
+
|
|
145
|
+
sockets[0]?.open()
|
|
146
|
+
await settle(chat)
|
|
147
|
+
sockets[0]?.receive({
|
|
148
|
+
type: 'realtime_ready',
|
|
149
|
+
channels: { ai_employee: { available: true } },
|
|
150
|
+
heartbeat_interval_seconds: 25,
|
|
151
|
+
})
|
|
152
|
+
await settle(chat)
|
|
153
|
+
|
|
154
|
+
const input = chat.shadowRoot?.querySelector('input')
|
|
155
|
+
const button = chat.shadowRoot?.querySelector<HTMLButtonElement>('.send-button')
|
|
156
|
+
expect(input).toBeTruthy()
|
|
157
|
+
expect(button).toBeTruthy()
|
|
158
|
+
input!.value = ' 帮我筛选候选人 '
|
|
159
|
+
input!.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
|
160
|
+
await chat.updateComplete
|
|
161
|
+
button!.click()
|
|
162
|
+
await settle(chat)
|
|
163
|
+
|
|
164
|
+
expect(transport.sendMessage).toHaveBeenCalledWith('session-1', '帮我筛选候选人')
|
|
165
|
+
expect(chat.shadowRoot?.textContent).toContain('帮我筛选候选人')
|
|
166
|
+
const userMessage = chat.shadowRoot?.querySelector<HTMLElement>('.message.user')
|
|
167
|
+
const userMessageRow = chat.shadowRoot?.querySelector<HTMLElement>('.message-row.user')
|
|
168
|
+
expect(userMessage).toBeTruthy()
|
|
169
|
+
expect(userMessageRow).toBeTruthy()
|
|
170
|
+
expect(userMessage?.textContent).toBe('帮我筛选候选人')
|
|
171
|
+
expect(getComputedStyle(userMessage!).width).toBe('fit-content')
|
|
172
|
+
expect(getComputedStyle(userMessage!).minWidth).toBe('0')
|
|
173
|
+
expect(getComputedStyle(userMessageRow!).alignItems).toBe('flex-start')
|
|
174
|
+
|
|
175
|
+
const reply = {
|
|
176
|
+
type: 'realtime_event',
|
|
177
|
+
channel: 'ai_employee',
|
|
178
|
+
event: {
|
|
179
|
+
type: 'session_message',
|
|
180
|
+
session_id: 'session-1',
|
|
181
|
+
payload: {
|
|
182
|
+
JsonPatch: [
|
|
183
|
+
{
|
|
184
|
+
path: '/0',
|
|
185
|
+
value: {
|
|
186
|
+
ws_message_id: 'reply-1',
|
|
187
|
+
entry_type: { type: 'assistant_message' },
|
|
188
|
+
content: JSON.stringify({ text: '已找到三位候选人。' }),
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
sockets[0]?.receive(reply)
|
|
196
|
+
sockets[0]?.receive(reply)
|
|
197
|
+
await settle(chat)
|
|
198
|
+
|
|
199
|
+
expect(chat.shadowRoot?.querySelectorAll('[data-message-id="reply-1"]')).toHaveLength(1)
|
|
200
|
+
expect(eventDetails.some((detail) => JSON.stringify(detail).includes('已找到三位候选人'))).toBe(
|
|
201
|
+
true,
|
|
202
|
+
)
|
|
203
|
+
expect(chat.outerHTML).not.toContain('component-secret-jwt')
|
|
204
|
+
expect(JSON.stringify(eventDetails)).not.toContain('component-secret-jwt')
|
|
205
|
+
expect(sockets[0]?.url).not.toContain('component-secret-jwt')
|
|
206
|
+
|
|
207
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.close-button')?.click()
|
|
208
|
+
await settle(chat)
|
|
209
|
+
expect(sockets[0]?.closeCount).toBe(1)
|
|
210
|
+
expect(chat.shadowRoot?.querySelector('[role="dialog"]')).toBeNull()
|
|
211
|
+
chat.remove()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('renders assistant replies as safe Markdown while keeping user messages as plain text', async () => {
|
|
215
|
+
const history: AiEmployeeMessage[] = [
|
|
216
|
+
{
|
|
217
|
+
id: 'user-markdown',
|
|
218
|
+
sessionId: 'session-1',
|
|
219
|
+
role: 'user',
|
|
220
|
+
content: '**用户原文**',
|
|
221
|
+
createdAt: '2026-08-21T08:00:00.000Z',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: 'assistant-markdown',
|
|
225
|
+
sessionId: 'session-1',
|
|
226
|
+
role: 'assistant',
|
|
227
|
+
content:
|
|
228
|
+
'# 建议\n\n**重点内容**与 `代码`\n\n- 第一项\n- 第二项\n\n[安全链接](https://example.com/guide)\n\n<script>alert(1)</script> [危险链接](javascript:alert(2))',
|
|
229
|
+
createdAt: '2026-08-21T08:00:01.000Z',
|
|
230
|
+
},
|
|
231
|
+
]
|
|
232
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
233
|
+
chat.configure({
|
|
234
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
235
|
+
aeCode: 'recruit',
|
|
236
|
+
agentCode: 'default',
|
|
237
|
+
sessionId: 'session-1',
|
|
238
|
+
transport: createTransport(history),
|
|
239
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
240
|
+
})
|
|
241
|
+
document.body.append(chat)
|
|
242
|
+
chat.open()
|
|
243
|
+
await vi.waitFor(() =>
|
|
244
|
+
expect(chat.shadowRoot?.querySelector('[data-message-id="assistant-markdown"]')).toBeTruthy(),
|
|
245
|
+
)
|
|
246
|
+
await settle(chat)
|
|
247
|
+
|
|
248
|
+
const assistant = chat.shadowRoot?.querySelector<HTMLElement>(
|
|
249
|
+
'[data-message-id="assistant-markdown"]',
|
|
250
|
+
)
|
|
251
|
+
expect(assistant?.querySelector('h1')?.textContent).toBe('建议')
|
|
252
|
+
expect(assistant?.querySelector('strong')?.textContent).toBe('重点内容')
|
|
253
|
+
expect(assistant?.querySelector('code')?.textContent).toBe('代码')
|
|
254
|
+
expect(assistant?.querySelectorAll('li')).toHaveLength(2)
|
|
255
|
+
const links = assistant?.querySelectorAll<HTMLAnchorElement>('a') ?? []
|
|
256
|
+
expect(links).toHaveLength(1)
|
|
257
|
+
expect(links[0]?.href).toBe('https://example.com/guide')
|
|
258
|
+
expect(links[0]?.target).toBe('_blank')
|
|
259
|
+
expect(links[0]?.rel).toBe('noopener noreferrer')
|
|
260
|
+
expect(assistant?.querySelector('script')).toBeNull()
|
|
261
|
+
expect(assistant?.innerHTML ?? '').toContain('<script>')
|
|
262
|
+
|
|
263
|
+
const user = chat.shadowRoot?.querySelector<HTMLElement>('[data-message-id="user-markdown"]')
|
|
264
|
+
expect(user?.textContent).toBe('**用户原文**')
|
|
265
|
+
expect(user?.querySelector('strong')).toBeNull()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('uses an anonymous JWT transparently and renders a safe throttling error once', async () => {
|
|
269
|
+
const anonymousToken = 'anonymous.component.jwt'
|
|
270
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
271
|
+
const transport = createTransport()
|
|
272
|
+
transport.getAccessToken.mockResolvedValue(anonymousToken)
|
|
273
|
+
transport.sendMessage.mockRejectedValueOnce(
|
|
274
|
+
Object.assign(new AiEmployeeTransportError('TOO_MANY_REQUESTS'), {
|
|
275
|
+
unsafeResponse: 'anonymous-user-31 backend trace',
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
278
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
279
|
+
const errors: unknown[] = []
|
|
280
|
+
chat.addEventListener('error', (event) =>
|
|
281
|
+
errors.push((event as unknown as CustomEvent<unknown>).detail),
|
|
282
|
+
)
|
|
283
|
+
chat.configure({
|
|
284
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
285
|
+
aeCode: 'recruit',
|
|
286
|
+
agentCode: 'default',
|
|
287
|
+
sessionId: 'session-1',
|
|
288
|
+
transport,
|
|
289
|
+
webSocketFactory: (url) => {
|
|
290
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
291
|
+
sockets.push(socket)
|
|
292
|
+
return socket.asWebSocket()
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
document.body.append(chat)
|
|
296
|
+
chat.open()
|
|
297
|
+
await settle(chat)
|
|
298
|
+
sockets[0]?.open()
|
|
299
|
+
await Promise.resolve()
|
|
300
|
+
sockets[0]?.receive({
|
|
301
|
+
type: 'realtime_ready',
|
|
302
|
+
principal_type: 'anonymous_user',
|
|
303
|
+
scope: { anonymous_user_id: 'anonymous-user-31' },
|
|
304
|
+
channels: { ai_employee: { available: true } },
|
|
305
|
+
heartbeat_interval_seconds: 25,
|
|
306
|
+
})
|
|
307
|
+
await settle(chat)
|
|
308
|
+
|
|
309
|
+
const input = chat.shadowRoot?.querySelector('input')
|
|
310
|
+
input!.value = '咨询匿名服务'
|
|
311
|
+
input!.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
|
312
|
+
await chat.updateComplete
|
|
313
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.send-button')?.click()
|
|
314
|
+
await vi.waitFor(() => expect(transport.sendMessage).toHaveBeenCalledTimes(1))
|
|
315
|
+
await settle(chat)
|
|
316
|
+
|
|
317
|
+
expect(errors).toEqual([
|
|
318
|
+
{
|
|
319
|
+
code: 'RATE_LIMITED',
|
|
320
|
+
message: '请求过于频繁,请稍后再试。',
|
|
321
|
+
recoverable: false,
|
|
322
|
+
},
|
|
323
|
+
])
|
|
324
|
+
expect(chat.shadowRoot?.textContent).toContain('请求过于频繁,请稍后再试。')
|
|
325
|
+
expect(chat.outerHTML).not.toContain(anonymousToken)
|
|
326
|
+
expect(chat.outerHTML).not.toContain('anonymous-user-31')
|
|
327
|
+
expect(JSON.stringify(errors)).not.toContain(anonymousToken)
|
|
328
|
+
expect(JSON.stringify(errors)).not.toContain('anonymous-user-31')
|
|
329
|
+
expect(JSON.stringify(errors)).not.toContain('backend trace')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('does not send blank content', async () => {
|
|
333
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
334
|
+
const transport = createTransport()
|
|
335
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
336
|
+
chat.configure({
|
|
337
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
338
|
+
aeCode: 'recruit',
|
|
339
|
+
agentCode: 'default',
|
|
340
|
+
sessionId: 'session-1',
|
|
341
|
+
transport,
|
|
342
|
+
webSocketFactory: (url) => {
|
|
343
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
344
|
+
sockets.push(socket)
|
|
345
|
+
return socket.asWebSocket()
|
|
346
|
+
},
|
|
347
|
+
})
|
|
348
|
+
document.body.append(chat)
|
|
349
|
+
chat.open()
|
|
350
|
+
await settle(chat)
|
|
351
|
+
|
|
352
|
+
const input = chat.shadowRoot?.querySelector('input')
|
|
353
|
+
input!.value = ' '
|
|
354
|
+
input!.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
|
355
|
+
await chat.updateComplete
|
|
356
|
+
expect(chat.shadowRoot?.querySelector<HTMLButtonElement>('.send-button')?.disabled).toBe(true)
|
|
357
|
+
expect(transport.sendMessage).not.toHaveBeenCalled()
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('sends a non-empty open argument as the atomic first message and preserves blank behavior', async () => {
|
|
361
|
+
const transport = createTransport()
|
|
362
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
363
|
+
chat.configure({
|
|
364
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
365
|
+
aeCode: 'recruit',
|
|
366
|
+
agentCode: 'default',
|
|
367
|
+
transport,
|
|
368
|
+
requestIdFactory: () => 'c7181ccb-2ff7-4de9-8229-c0ab61c257ef',
|
|
369
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
370
|
+
})
|
|
371
|
+
document.body.append(chat)
|
|
372
|
+
|
|
373
|
+
chat.open(' 我想咨询用工合规解决方案 ')
|
|
374
|
+
await vi.waitFor(() => expect(transport.createSessionWithFirstMessage).toHaveBeenCalledTimes(1))
|
|
375
|
+
await settle(chat)
|
|
376
|
+
|
|
377
|
+
expect(transport.createSessionWithFirstMessage).toHaveBeenCalledWith({
|
|
378
|
+
requestId: 'c7181ccb-2ff7-4de9-8229-c0ab61c257ef',
|
|
379
|
+
aeCode: 'recruit',
|
|
380
|
+
agentCode: 'default',
|
|
381
|
+
content: '我想咨询用工合规解决方案',
|
|
382
|
+
attachments: [],
|
|
383
|
+
trustedContext: null,
|
|
384
|
+
})
|
|
385
|
+
expect(transport.sendMessage).not.toHaveBeenCalled()
|
|
386
|
+
expect(chat.shadowRoot?.textContent).toContain('我想咨询用工合规解决方案')
|
|
387
|
+
|
|
388
|
+
chat.close()
|
|
389
|
+
chat.open(' ')
|
|
390
|
+
await settle(chat)
|
|
391
|
+
expect(transport.createSessionWithFirstMessage).toHaveBeenCalledTimes(1)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('sends a non-empty open argument as a follow-up when an existing session is configured', async () => {
|
|
395
|
+
const transport = createTransport()
|
|
396
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
397
|
+
chat.configure({
|
|
398
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
399
|
+
aeCode: 'recruit',
|
|
400
|
+
agentCode: 'default',
|
|
401
|
+
sessionId: 'existing-session',
|
|
402
|
+
transport,
|
|
403
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
404
|
+
})
|
|
405
|
+
document.body.append(chat)
|
|
406
|
+
|
|
407
|
+
chat.open(' 继续咨询招聘方案 ')
|
|
408
|
+
await vi.waitFor(() => expect(transport.sendMessage).toHaveBeenCalledTimes(1))
|
|
409
|
+
await settle(chat)
|
|
410
|
+
|
|
411
|
+
expect(transport.sendMessage).toHaveBeenCalledWith('existing-session', '继续咨询招聘方案')
|
|
412
|
+
expect(transport.createSessionWithFirstMessage).not.toHaveBeenCalled()
|
|
413
|
+
expect(chat.shadowRoot?.textContent).toContain('继续咨询招聘方案')
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('owns the drawer lifecycle and switches to the customer support component', async () => {
|
|
417
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
418
|
+
const transport = createTransport()
|
|
419
|
+
const customerSupportFetch = vi.fn(
|
|
420
|
+
async (input: string | URL | Request, init?: RequestInit) => {
|
|
421
|
+
void input
|
|
422
|
+
void init
|
|
423
|
+
return new Response(JSON.stringify({ rescode: 0, data: { list: [] } }), {
|
|
424
|
+
status: 200,
|
|
425
|
+
headers: { 'content-type': 'application/json' },
|
|
426
|
+
})
|
|
427
|
+
},
|
|
428
|
+
)
|
|
429
|
+
vi.stubGlobal('fetch', customerSupportFetch)
|
|
430
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
431
|
+
chat.configure({
|
|
432
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
433
|
+
aeCode: 'recruit',
|
|
434
|
+
agentCode: 'default',
|
|
435
|
+
sessionId: 'session-1',
|
|
436
|
+
transport,
|
|
437
|
+
webSocketFactory: (url) => {
|
|
438
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
439
|
+
sockets.push(socket)
|
|
440
|
+
return socket.asWebSocket()
|
|
441
|
+
},
|
|
442
|
+
})
|
|
443
|
+
document.body.append(chat)
|
|
444
|
+
chat.open()
|
|
445
|
+
await settle(chat)
|
|
446
|
+
sockets[0]?.open()
|
|
447
|
+
sockets[0]?.receive({
|
|
448
|
+
type: 'realtime_ready',
|
|
449
|
+
channels: { ai_employee: { available: true } },
|
|
450
|
+
heartbeat_interval_seconds: 25,
|
|
451
|
+
})
|
|
452
|
+
await settle(chat)
|
|
453
|
+
|
|
454
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.human-service-button')?.click()
|
|
455
|
+
await settle(chat)
|
|
456
|
+
await vi.waitFor(() => expect(customerSupportFetch).toHaveBeenCalledTimes(1))
|
|
457
|
+
const customerSupport = chat.shadowRoot?.querySelector('uniplat-customer-support-chat')
|
|
458
|
+
expect(customerSupport).toBeTruthy()
|
|
459
|
+
expect(customerSupport?.shadowRoot?.textContent).toContain('企业客服人工顾问')
|
|
460
|
+
expect(transport.sendMessage).not.toHaveBeenCalledWith('session-1', '请帮我转接人工顾问')
|
|
461
|
+
expect(String(customerSupportFetch.mock.calls[0]?.[0])).toContain(
|
|
462
|
+
'/api/general/project/hr_saas/service/customer_support_api/user_session_page',
|
|
463
|
+
)
|
|
464
|
+
expect(new Headers(customerSupportFetch.mock.calls[0]?.[1]?.headers).get('authorization')).toBe(
|
|
465
|
+
'Bearer component-secret-jwt',
|
|
466
|
+
)
|
|
467
|
+
expect(sockets[0]?.closeCount).toBe(1)
|
|
468
|
+
|
|
469
|
+
customerSupport?.shadowRoot?.querySelector<HTMLButtonElement>('.ai-service-button')?.click()
|
|
470
|
+
await settle(chat)
|
|
471
|
+
await vi.waitFor(() => expect(sockets.length).toBeGreaterThanOrEqual(2))
|
|
472
|
+
expect(chat.shadowRoot?.querySelector('uniplat-customer-support-chat')).toBeNull()
|
|
473
|
+
expect(chat.shadowRoot?.textContent).toContain('企业服务AI顾问')
|
|
474
|
+
const restoredAiSocket = sockets.at(-1)
|
|
475
|
+
restoredAiSocket?.open()
|
|
476
|
+
await Promise.resolve()
|
|
477
|
+
expect(JSON.parse(restoredAiSocket?.sent[0] ?? '{}')).toMatchObject({
|
|
478
|
+
type: 'authenticate',
|
|
479
|
+
requested_channels: ['ai_employee'],
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
483
|
+
await settle(chat)
|
|
484
|
+
expect(chat.shadowRoot?.querySelector('uniplat-customer-support-chat')).toBeNull()
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('opens the prototype conversation list and switches sessions inside the component', async () => {
|
|
488
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
489
|
+
const sessionTwoHistory: AiEmployeeMessage[] = [
|
|
490
|
+
{
|
|
491
|
+
id: '102',
|
|
492
|
+
sessionId: 'session-2',
|
|
493
|
+
role: 'assistant',
|
|
494
|
+
content: '资料齐全的话,明天可以提交。',
|
|
495
|
+
createdAt: '2026-08-14T01:00:00.000Z',
|
|
496
|
+
},
|
|
497
|
+
]
|
|
498
|
+
const baseTransport = createTransport()
|
|
499
|
+
const transport = {
|
|
500
|
+
...baseTransport,
|
|
501
|
+
listMessages: vi.fn(async (sessionId: string) =>
|
|
502
|
+
sessionId === 'session-2' ? sessionTwoHistory : [],
|
|
503
|
+
),
|
|
504
|
+
listSessions: vi.fn(async () => [
|
|
505
|
+
{
|
|
506
|
+
sessionId: 'session-1',
|
|
507
|
+
title: '企业服务顾问',
|
|
508
|
+
latestMessage: '可以。异地没有主体时,通常用…',
|
|
509
|
+
latestMessageId: '101',
|
|
510
|
+
updatedAt: new Date().toISOString(),
|
|
511
|
+
unreadCount: 0,
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
sessionId: 'session-2',
|
|
515
|
+
title: '异地用工服务咨询',
|
|
516
|
+
latestMessage: '资料齐全的话,明天可以提交…',
|
|
517
|
+
latestMessageId: '102',
|
|
518
|
+
updatedAt: '2026-08-14T01:00:00.000Z',
|
|
519
|
+
unreadCount: 2,
|
|
520
|
+
},
|
|
521
|
+
]),
|
|
522
|
+
markSessionRead: vi.fn(async () => undefined),
|
|
523
|
+
} satisfies AiEmployeeTransport
|
|
524
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
525
|
+
chat.configure({
|
|
526
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
527
|
+
aeCode: 'recruit',
|
|
528
|
+
agentCode: 'default',
|
|
529
|
+
sessionId: 'session-1',
|
|
530
|
+
transport,
|
|
531
|
+
webSocketFactory: (url) => {
|
|
532
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
533
|
+
sockets.push(socket)
|
|
534
|
+
return socket.asWebSocket()
|
|
535
|
+
},
|
|
536
|
+
})
|
|
537
|
+
document.body.append(chat)
|
|
538
|
+
chat.open()
|
|
539
|
+
await settle(chat)
|
|
540
|
+
|
|
541
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
542
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
543
|
+
await settle(chat)
|
|
544
|
+
|
|
545
|
+
expect(chat.shadowRoot?.textContent).toContain('我的会话')
|
|
546
|
+
expect(chat.shadowRoot?.textContent).toContain('异地用工服务咨询')
|
|
547
|
+
expect(chat.shadowRoot?.querySelectorAll('.session-item')).toHaveLength(2)
|
|
548
|
+
expect(chat.shadowRoot?.querySelector('[data-prototype-icon="arrow-left"]')).toBeTruthy()
|
|
549
|
+
|
|
550
|
+
const sessionItems = chat.shadowRoot?.querySelectorAll<HTMLButtonElement>('.session-item')
|
|
551
|
+
sessionItems?.[1]?.click()
|
|
552
|
+
await vi.waitFor(() => expect(transport.listMessages).toHaveBeenCalledWith('session-2'))
|
|
553
|
+
await settle(chat)
|
|
554
|
+
|
|
555
|
+
expect(sockets).toHaveLength(2)
|
|
556
|
+
expect(chat.shadowRoot?.textContent).not.toContain('我的会话')
|
|
557
|
+
expect(chat.shadowRoot?.textContent).toContain('企业服务AI顾问')
|
|
558
|
+
expect(chat.shadowRoot?.textContent).toContain('资料齐全的话,明天可以提交。')
|
|
559
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-2', '102')
|
|
560
|
+
expect(chat.outerHTML).not.toContain('session-2')
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('creates a new conversation from the conversation-list header', async () => {
|
|
564
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
565
|
+
const transport = {
|
|
566
|
+
...createTransport(),
|
|
567
|
+
listSessions: vi.fn(async () => [
|
|
568
|
+
{
|
|
569
|
+
sessionId: 'session-1',
|
|
570
|
+
title: '既有会话',
|
|
571
|
+
latestMessage: '既有消息',
|
|
572
|
+
updatedAt: '2026-08-15T01:00:00.000Z',
|
|
573
|
+
unreadCount: 0,
|
|
574
|
+
},
|
|
575
|
+
]),
|
|
576
|
+
listMessages: vi.fn(async () => []),
|
|
577
|
+
} satisfies AiEmployeeTransport
|
|
578
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
579
|
+
chat.configure({
|
|
580
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
581
|
+
aeCode: 'recruit',
|
|
582
|
+
agentCode: 'default',
|
|
583
|
+
sessionId: 'session-1',
|
|
584
|
+
transport,
|
|
585
|
+
webSocketFactory: (url) => {
|
|
586
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
587
|
+
sockets.push(socket)
|
|
588
|
+
return socket.asWebSocket()
|
|
589
|
+
},
|
|
590
|
+
})
|
|
591
|
+
document.body.append(chat)
|
|
592
|
+
chat.open()
|
|
593
|
+
await settle(chat)
|
|
594
|
+
|
|
595
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
596
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
597
|
+
await settle(chat)
|
|
598
|
+
|
|
599
|
+
const newButton = chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-new-button')
|
|
600
|
+
expect(newButton?.getAttribute('aria-label')).toBe('开启新对话')
|
|
601
|
+
expect(newButton?.querySelector('[data-prototype-icon="plus"]')).toBeTruthy()
|
|
602
|
+
newButton?.click()
|
|
603
|
+
await vi.waitFor(() => expect(chat.shadowRoot?.querySelector('.session-new-button')).toBeNull())
|
|
604
|
+
await settle(chat)
|
|
605
|
+
|
|
606
|
+
expect(transport.createSessionWithFirstMessage).not.toHaveBeenCalled()
|
|
607
|
+
expect(sockets).toHaveLength(2)
|
|
608
|
+
expect(chat.shadowRoot?.textContent).not.toContain('我的会话')
|
|
609
|
+
expect(chat.shadowRoot?.textContent).toContain('企业服务AI顾问')
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('keeps an atomic new conversation local and creates it only with the first message', async () => {
|
|
613
|
+
const transport = {
|
|
614
|
+
...createTransport(),
|
|
615
|
+
createSessionWithFirstMessage: vi.fn(async () => ({
|
|
616
|
+
sessionId: 'atomic-session-component',
|
|
617
|
+
messageId: 'atomic-message-component',
|
|
618
|
+
executionId: 'atomic-execution-component',
|
|
619
|
+
status: 'running' as const,
|
|
620
|
+
startedAt: '2026-08-18T08:45:00.000Z',
|
|
621
|
+
aeCode: 'recruit',
|
|
622
|
+
agentCode: 'default',
|
|
623
|
+
conversationType: 'default',
|
|
624
|
+
})),
|
|
625
|
+
listSessions: vi.fn(async () => []),
|
|
626
|
+
} satisfies AiEmployeeTransport
|
|
627
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
628
|
+
const sentEvents: unknown[] = []
|
|
629
|
+
chat.addEventListener('message-sent', (event) =>
|
|
630
|
+
sentEvents.push((event as CustomEvent<unknown>).detail),
|
|
631
|
+
)
|
|
632
|
+
chat.configure({
|
|
633
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
634
|
+
aeCode: 'recruit',
|
|
635
|
+
agentCode: 'default',
|
|
636
|
+
transport,
|
|
637
|
+
requestIdFactory: () => '6afdb617-5997-46cf-aef1-f043c22c32de',
|
|
638
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
639
|
+
})
|
|
640
|
+
document.body.append(chat)
|
|
641
|
+
chat.open()
|
|
642
|
+
await settle(chat)
|
|
643
|
+
|
|
644
|
+
expect(transport.createSessionWithFirstMessage).not.toHaveBeenCalled()
|
|
645
|
+
|
|
646
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
647
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
648
|
+
await settle(chat)
|
|
649
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-new-button')?.click()
|
|
650
|
+
await vi.waitFor(() => expect(chat.shadowRoot?.querySelector('.session-new-button')).toBeNull())
|
|
651
|
+
|
|
652
|
+
expect(transport.createSessionWithFirstMessage).not.toHaveBeenCalled()
|
|
653
|
+
|
|
654
|
+
const input = chat.shadowRoot?.querySelector<HTMLInputElement>('input[part="input"]')
|
|
655
|
+
input!.value = '首条消息原子创建会话'
|
|
656
|
+
input?.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
|
657
|
+
chat.shadowRoot?.querySelector<HTMLFormElement>('.composer')?.requestSubmit()
|
|
658
|
+
await vi.waitFor(() => expect(transport.createSessionWithFirstMessage).toHaveBeenCalledTimes(1))
|
|
659
|
+
await settle(chat)
|
|
660
|
+
|
|
661
|
+
expect(transport.createSessionWithFirstMessage).toHaveBeenCalledWith({
|
|
662
|
+
requestId: '6afdb617-5997-46cf-aef1-f043c22c32de',
|
|
663
|
+
aeCode: 'recruit',
|
|
664
|
+
agentCode: 'default',
|
|
665
|
+
content: '首条消息原子创建会话',
|
|
666
|
+
attachments: [],
|
|
667
|
+
trustedContext: null,
|
|
668
|
+
})
|
|
669
|
+
expect(transport.sendMessage).not.toHaveBeenCalled()
|
|
670
|
+
expect(chat.shadowRoot?.textContent).toContain('首条消息原子创建会话')
|
|
671
|
+
expect(JSON.stringify(sentEvents)).not.toContain('requestId')
|
|
672
|
+
expect(JSON.stringify(sentEvents)).not.toContain('atomic-execution-component')
|
|
673
|
+
expect(chat.outerHTML).not.toContain('atomic-session-component')
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it('updates the open conversation list when realtime supplies a title', async () => {
|
|
677
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
678
|
+
const transport = {
|
|
679
|
+
...createTransport(),
|
|
680
|
+
listSessions: vi.fn(async () => [
|
|
681
|
+
{
|
|
682
|
+
sessionId: 'session-1',
|
|
683
|
+
title: '新对话',
|
|
684
|
+
latestMessage: '上海员工如何缴社保?',
|
|
685
|
+
updatedAt: '2026-08-15T01:00:00.000Z',
|
|
686
|
+
unreadCount: 0,
|
|
687
|
+
},
|
|
688
|
+
]),
|
|
689
|
+
} satisfies AiEmployeeTransport
|
|
690
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
691
|
+
chat.configure({
|
|
692
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
693
|
+
aeCode: 'recruit',
|
|
694
|
+
agentCode: 'default',
|
|
695
|
+
sessionId: 'session-1',
|
|
696
|
+
transport,
|
|
697
|
+
webSocketFactory: (url) => {
|
|
698
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
699
|
+
sockets.push(socket)
|
|
700
|
+
return socket.asWebSocket()
|
|
701
|
+
},
|
|
702
|
+
})
|
|
703
|
+
document.body.append(chat)
|
|
704
|
+
chat.open()
|
|
705
|
+
await settle(chat)
|
|
706
|
+
await authenticateAndReadyComponent(sockets[0]!)
|
|
707
|
+
|
|
708
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
709
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
710
|
+
await settle(chat)
|
|
711
|
+
expect(chat.shadowRoot?.querySelector('.session-item-title')?.textContent).toBe(
|
|
712
|
+
'上海员工如何缴社保?',
|
|
713
|
+
)
|
|
714
|
+
expect(chat.shadowRoot?.querySelector('.session-item-title')?.textContent).not.toBe('新对话')
|
|
715
|
+
sockets[0]?.receive({
|
|
716
|
+
type: 'realtime_event',
|
|
717
|
+
channel: 'ai_employee',
|
|
718
|
+
event: {
|
|
719
|
+
type: 'session_title_updated',
|
|
720
|
+
session_id: 'session-1',
|
|
721
|
+
payload: { title: '上海异地员工社保办理' },
|
|
722
|
+
},
|
|
723
|
+
})
|
|
724
|
+
await settle(chat)
|
|
725
|
+
|
|
726
|
+
expect(chat.shadowRoot?.textContent).toContain('上海异地员工社保办理')
|
|
727
|
+
expect(chat.shadowRoot?.textContent).not.toContain('新对话')
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
it('treats the visible current conversation as read before Host synchronization finishes', async () => {
|
|
731
|
+
const completion = deferred<void>()
|
|
732
|
+
const latestMessageAt = '2026-08-18T07:00:00.000Z'
|
|
733
|
+
const currentHistory: AiEmployeeMessage[] = [
|
|
734
|
+
{
|
|
735
|
+
id: 'history-201',
|
|
736
|
+
sessionId: 'session-1',
|
|
737
|
+
role: 'assistant',
|
|
738
|
+
content: '等待阅读的回复',
|
|
739
|
+
createdAt: latestMessageAt,
|
|
740
|
+
},
|
|
741
|
+
]
|
|
742
|
+
const transport = {
|
|
743
|
+
...createTransport(currentHistory),
|
|
744
|
+
listSessions: vi.fn(async () => [
|
|
745
|
+
{
|
|
746
|
+
sessionId: 'session-1',
|
|
747
|
+
title: '企业服务顾问',
|
|
748
|
+
latestMessage: '等待阅读的回复',
|
|
749
|
+
latestMessageId: 'list-201',
|
|
750
|
+
updatedAt: '2026-08-18T07:00:01.000Z',
|
|
751
|
+
unreadCount: 1,
|
|
752
|
+
},
|
|
753
|
+
]),
|
|
754
|
+
markSessionRead: vi.fn(() => completion.promise),
|
|
755
|
+
} satisfies AiEmployeeTransport
|
|
756
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
757
|
+
chat.configure({
|
|
758
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
759
|
+
aeCode: 'recruit',
|
|
760
|
+
agentCode: 'default',
|
|
761
|
+
sessionId: 'session-1',
|
|
762
|
+
transport,
|
|
763
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
764
|
+
})
|
|
765
|
+
document.body.append(chat)
|
|
766
|
+
chat.open()
|
|
767
|
+
await vi.waitFor(() =>
|
|
768
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', 'history-201'),
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
772
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
773
|
+
await settle(chat)
|
|
774
|
+
expect(chat.shadowRoot?.querySelector('.session-item-unread')).toBeNull()
|
|
775
|
+
|
|
776
|
+
completion.resolve()
|
|
777
|
+
await vi.waitFor(() =>
|
|
778
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', 'list-201'),
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-back-button')?.click()
|
|
782
|
+
await settle(chat)
|
|
783
|
+
expect(chat.shadowRoot?.querySelector('.conversation-unread')).toBeNull()
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
it('keeps the visible current conversation locally read and retries a failed Host update', async () => {
|
|
787
|
+
const currentHistory: AiEmployeeMessage[] = [
|
|
788
|
+
{
|
|
789
|
+
id: '202',
|
|
790
|
+
sessionId: 'session-1',
|
|
791
|
+
role: 'assistant',
|
|
792
|
+
content: '尚未同步已读的回复',
|
|
793
|
+
createdAt: new Date().toISOString(),
|
|
794
|
+
},
|
|
795
|
+
]
|
|
796
|
+
const transport = {
|
|
797
|
+
...createTransport(currentHistory),
|
|
798
|
+
listSessions: vi.fn(async () => [
|
|
799
|
+
{
|
|
800
|
+
sessionId: 'session-1',
|
|
801
|
+
title: '企业服务顾问',
|
|
802
|
+
latestMessage: '尚未同步已读的回复',
|
|
803
|
+
latestMessageId: '202',
|
|
804
|
+
updatedAt: new Date().toISOString(),
|
|
805
|
+
unreadCount: 1,
|
|
806
|
+
},
|
|
807
|
+
]),
|
|
808
|
+
markSessionRead: vi.fn(async () => {
|
|
809
|
+
throw new Error('unsafe read response')
|
|
810
|
+
}),
|
|
811
|
+
} satisfies AiEmployeeTransport
|
|
812
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
813
|
+
const errors: unknown[] = []
|
|
814
|
+
chat.addEventListener('error', (event) =>
|
|
815
|
+
errors.push((event as unknown as CustomEvent<unknown>).detail),
|
|
816
|
+
)
|
|
817
|
+
chat.configure({
|
|
818
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
819
|
+
aeCode: 'recruit',
|
|
820
|
+
agentCode: 'default',
|
|
821
|
+
sessionId: 'session-1',
|
|
822
|
+
transport,
|
|
823
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
824
|
+
})
|
|
825
|
+
document.body.append(chat)
|
|
826
|
+
chat.open()
|
|
827
|
+
await vi.waitFor(() =>
|
|
828
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '202'),
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
832
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
833
|
+
await settle(chat)
|
|
834
|
+
expect(chat.shadowRoot?.querySelector('.session-item-unread')).toBeNull()
|
|
835
|
+
|
|
836
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-back-button')?.click()
|
|
837
|
+
await vi.waitFor(() => expect(transport.markSessionRead.mock.calls.length).toBeGreaterThan(1))
|
|
838
|
+
await settle(chat)
|
|
839
|
+
|
|
840
|
+
expect(chat.shadowRoot?.querySelector('.conversation-unread')).toBeNull()
|
|
841
|
+
expect(JSON.stringify(errors)).not.toContain('unsafe read response')
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it('keeps the selected current session badge hidden and serializes its later read update', async () => {
|
|
845
|
+
const completion = deferred<void>()
|
|
846
|
+
const currentHistory: AiEmployeeMessage[] = [
|
|
847
|
+
{
|
|
848
|
+
id: '203',
|
|
849
|
+
sessionId: 'session-1',
|
|
850
|
+
role: 'assistant',
|
|
851
|
+
content: '第一条未读回复',
|
|
852
|
+
createdAt: '2026-08-15T01:00:00.000Z',
|
|
853
|
+
},
|
|
854
|
+
]
|
|
855
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
856
|
+
const transport = {
|
|
857
|
+
...createTransport(currentHistory),
|
|
858
|
+
listSessions: vi.fn(async () => [
|
|
859
|
+
{
|
|
860
|
+
sessionId: 'session-1',
|
|
861
|
+
title: '企业服务顾问',
|
|
862
|
+
latestMessage: '第一条未读回复',
|
|
863
|
+
latestMessageId: '203',
|
|
864
|
+
updatedAt: '2026-08-15T01:00:00.000Z',
|
|
865
|
+
unreadCount: 1,
|
|
866
|
+
},
|
|
867
|
+
]),
|
|
868
|
+
markSessionRead: vi.fn(() => completion.promise),
|
|
869
|
+
} satisfies AiEmployeeTransport
|
|
870
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
871
|
+
chat.configure({
|
|
872
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
873
|
+
aeCode: 'recruit',
|
|
874
|
+
agentCode: 'default',
|
|
875
|
+
sessionId: 'session-1',
|
|
876
|
+
transport,
|
|
877
|
+
webSocketFactory: (url) => {
|
|
878
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
879
|
+
sockets.push(socket)
|
|
880
|
+
return socket.asWebSocket()
|
|
881
|
+
},
|
|
882
|
+
})
|
|
883
|
+
document.body.append(chat)
|
|
884
|
+
chat.open()
|
|
885
|
+
await vi.waitFor(() =>
|
|
886
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '203'),
|
|
887
|
+
)
|
|
888
|
+
await authenticateAndReadyComponent(sockets[0]!)
|
|
889
|
+
|
|
890
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
891
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
892
|
+
await settle(chat)
|
|
893
|
+
expect(chat.shadowRoot?.querySelector('.session-item-unread')).toBeNull()
|
|
894
|
+
|
|
895
|
+
sockets[0]?.receive({
|
|
896
|
+
type: 'realtime_event',
|
|
897
|
+
channel: 'ai_employee',
|
|
898
|
+
event: {
|
|
899
|
+
type: 'session_message',
|
|
900
|
+
session_id: 'session-1',
|
|
901
|
+
payload: {
|
|
902
|
+
JsonPatch: [
|
|
903
|
+
{
|
|
904
|
+
path: '/0',
|
|
905
|
+
value: {
|
|
906
|
+
ws_message_id: '204',
|
|
907
|
+
entry_type: { type: 'assistant_message' },
|
|
908
|
+
content: JSON.stringify({ text: '后来到达的未读回复' }),
|
|
909
|
+
create_time: '2026-08-15T01:01:00.000Z',
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
],
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
})
|
|
916
|
+
await settle(chat)
|
|
917
|
+
expect(chat.shadowRoot?.querySelector('.session-item-unread')).toBeNull()
|
|
918
|
+
|
|
919
|
+
completion.resolve()
|
|
920
|
+
await vi.waitFor(() => expect(transport.markSessionRead).toHaveBeenCalledTimes(1))
|
|
921
|
+
expect(chat.shadowRoot?.querySelector('.session-item-unread')).toBeNull()
|
|
922
|
+
|
|
923
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-item')?.click()
|
|
924
|
+
await vi.waitFor(() =>
|
|
925
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '204'),
|
|
926
|
+
)
|
|
927
|
+
await settle(chat)
|
|
928
|
+
expect(chat.shadowRoot?.querySelector('.conversation-unread')).toBeNull()
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
it('excludes the visible current conversation from the aggregate unread badge', async () => {
|
|
932
|
+
const currentHistory: AiEmployeeMessage[] = [
|
|
933
|
+
{
|
|
934
|
+
id: '301',
|
|
935
|
+
sessionId: 'session-1',
|
|
936
|
+
role: 'assistant',
|
|
937
|
+
content: '当前会话回复',
|
|
938
|
+
createdAt: '2026-08-15T01:00:00.000Z',
|
|
939
|
+
},
|
|
940
|
+
]
|
|
941
|
+
const transport = {
|
|
942
|
+
...createTransport(currentHistory),
|
|
943
|
+
listSessions: vi.fn(async () => [
|
|
944
|
+
{
|
|
945
|
+
sessionId: 'session-1',
|
|
946
|
+
title: '当前会话',
|
|
947
|
+
latestMessage: '当前会话回复',
|
|
948
|
+
latestMessageId: '301',
|
|
949
|
+
updatedAt: '2026-08-15T01:00:00.000Z',
|
|
950
|
+
unreadCount: 1,
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
sessionId: 'session-2',
|
|
954
|
+
title: '其他会话',
|
|
955
|
+
latestMessage: '其他会话回复',
|
|
956
|
+
latestMessageId: '302',
|
|
957
|
+
updatedAt: '2026-08-15T01:01:00.000Z',
|
|
958
|
+
unreadCount: 2,
|
|
959
|
+
},
|
|
960
|
+
]),
|
|
961
|
+
markSessionRead: vi.fn(async () => undefined),
|
|
962
|
+
} satisfies AiEmployeeTransport
|
|
963
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
964
|
+
chat.configure({
|
|
965
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
966
|
+
aeCode: 'recruit',
|
|
967
|
+
agentCode: 'default',
|
|
968
|
+
sessionId: 'session-1',
|
|
969
|
+
transport,
|
|
970
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
971
|
+
})
|
|
972
|
+
document.body.append(chat)
|
|
973
|
+
chat.open()
|
|
974
|
+
await vi.waitFor(() =>
|
|
975
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '301'),
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
979
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
980
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-back-button')?.click()
|
|
981
|
+
await settle(chat)
|
|
982
|
+
|
|
983
|
+
expect(chat.shadowRoot?.querySelector('.conversation-unread')?.textContent).toBe('2')
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
it('marks realtime replies read while the current conversation is visible', async () => {
|
|
987
|
+
const sockets: ComponentFakeWebSocket[] = []
|
|
988
|
+
const currentHistory: AiEmployeeMessage[] = [
|
|
989
|
+
{
|
|
990
|
+
id: '401',
|
|
991
|
+
sessionId: 'session-1',
|
|
992
|
+
role: 'assistant',
|
|
993
|
+
content: '当前历史回复',
|
|
994
|
+
createdAt: '2026-08-15T01:00:00.000Z',
|
|
995
|
+
},
|
|
996
|
+
]
|
|
997
|
+
const transport = {
|
|
998
|
+
...createTransport(currentHistory),
|
|
999
|
+
listSessions: vi.fn(async () => [
|
|
1000
|
+
{
|
|
1001
|
+
sessionId: 'session-1',
|
|
1002
|
+
title: '当前会话',
|
|
1003
|
+
latestMessage: '当前实时回复',
|
|
1004
|
+
latestMessageId: '402',
|
|
1005
|
+
updatedAt: '2026-08-15T01:02:00.000Z',
|
|
1006
|
+
unreadCount: 1,
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
sessionId: 'session-2',
|
|
1010
|
+
title: '其他会话',
|
|
1011
|
+
latestMessage: '其他会话回复',
|
|
1012
|
+
latestMessageId: '403',
|
|
1013
|
+
updatedAt: '2026-08-15T01:01:00.000Z',
|
|
1014
|
+
unreadCount: 2,
|
|
1015
|
+
},
|
|
1016
|
+
]),
|
|
1017
|
+
markSessionRead: vi.fn(async () => undefined),
|
|
1018
|
+
} satisfies AiEmployeeTransport
|
|
1019
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
1020
|
+
chat.configure({
|
|
1021
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
1022
|
+
aeCode: 'recruit',
|
|
1023
|
+
agentCode: 'default',
|
|
1024
|
+
sessionId: 'session-1',
|
|
1025
|
+
transport,
|
|
1026
|
+
webSocketFactory: (url) => {
|
|
1027
|
+
const socket = new ComponentFakeWebSocket(url)
|
|
1028
|
+
sockets.push(socket)
|
|
1029
|
+
return socket.asWebSocket()
|
|
1030
|
+
},
|
|
1031
|
+
})
|
|
1032
|
+
document.body.append(chat)
|
|
1033
|
+
chat.open()
|
|
1034
|
+
await vi.waitFor(() =>
|
|
1035
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '401'),
|
|
1036
|
+
)
|
|
1037
|
+
await authenticateAndReadyComponent(sockets[0]!)
|
|
1038
|
+
|
|
1039
|
+
sockets[0]?.receive({
|
|
1040
|
+
type: 'realtime_event',
|
|
1041
|
+
channel: 'ai_employee',
|
|
1042
|
+
event: {
|
|
1043
|
+
type: 'session_message',
|
|
1044
|
+
session_id: 'session-1',
|
|
1045
|
+
payload: {
|
|
1046
|
+
JsonPatch: [
|
|
1047
|
+
{
|
|
1048
|
+
path: '/0',
|
|
1049
|
+
value: {
|
|
1050
|
+
ws_message_id: '402',
|
|
1051
|
+
entry_type: { type: 'assistant_message' },
|
|
1052
|
+
content: JSON.stringify({ text: '当前实时回复' }),
|
|
1053
|
+
create_time: '2026-08-15T01:02:00.000Z',
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
],
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
})
|
|
1060
|
+
await vi.waitFor(() =>
|
|
1061
|
+
expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '402'),
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
1065
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
1066
|
+
expect(chat.shadowRoot?.querySelector('.session-item.current .session-item-unread')).toBeNull()
|
|
1067
|
+
|
|
1068
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-back-button')?.click()
|
|
1069
|
+
await settle(chat)
|
|
1070
|
+
expect(chat.shadowRoot?.querySelector('.conversation-unread')?.textContent).toBe('2')
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
it('shows the current conversation when the Host has not added session listing yet', async () => {
|
|
1074
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
1075
|
+
chat.configure({
|
|
1076
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
1077
|
+
aeCode: 'recruit',
|
|
1078
|
+
agentCode: 'default',
|
|
1079
|
+
sessionId: 'session-1',
|
|
1080
|
+
transport: createTransport(),
|
|
1081
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
1082
|
+
})
|
|
1083
|
+
document.body.append(chat)
|
|
1084
|
+
chat.open()
|
|
1085
|
+
await settle(chat)
|
|
1086
|
+
|
|
1087
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
1088
|
+
await settle(chat)
|
|
1089
|
+
expect(chat.shadowRoot?.textContent).toContain('企业服务顾问')
|
|
1090
|
+
expect(chat.shadowRoot?.querySelectorAll('.session-item')).toHaveLength(1)
|
|
1091
|
+
|
|
1092
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-back-button')?.click()
|
|
1093
|
+
await settle(chat)
|
|
1094
|
+
expect(chat.shadowRoot?.textContent).toContain('企业服务AI顾问')
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
it('keeps the current conversation visible when session listing fails', async () => {
|
|
1098
|
+
const transport = {
|
|
1099
|
+
...createTransport(),
|
|
1100
|
+
listSessions: vi.fn(async () => {
|
|
1101
|
+
throw new Error('unsafe upstream response')
|
|
1102
|
+
}),
|
|
1103
|
+
} satisfies AiEmployeeTransport
|
|
1104
|
+
const chat = document.createElement('uniplat-ai-employee-chat')
|
|
1105
|
+
const errors: unknown[] = []
|
|
1106
|
+
chat.addEventListener('error', (event) =>
|
|
1107
|
+
errors.push((event as unknown as CustomEvent<unknown>).detail),
|
|
1108
|
+
)
|
|
1109
|
+
chat.configure({
|
|
1110
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
1111
|
+
aeCode: 'recruit',
|
|
1112
|
+
agentCode: 'default',
|
|
1113
|
+
sessionId: 'session-1',
|
|
1114
|
+
transport,
|
|
1115
|
+
webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
|
|
1116
|
+
})
|
|
1117
|
+
document.body.append(chat)
|
|
1118
|
+
chat.open()
|
|
1119
|
+
await settle(chat)
|
|
1120
|
+
|
|
1121
|
+
chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
|
|
1122
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
|
|
1123
|
+
await settle(chat)
|
|
1124
|
+
|
|
1125
|
+
expect(chat.shadowRoot?.textContent).toContain('历史会话暂时无法加载,已显示当前会话。')
|
|
1126
|
+
expect(chat.shadowRoot?.textContent).toContain('企业服务顾问')
|
|
1127
|
+
expect(chat.shadowRoot?.querySelectorAll('.session-item')).toHaveLength(1)
|
|
1128
|
+
expect(JSON.stringify(errors)).not.toContain('unsafe upstream response')
|
|
1129
|
+
})
|
|
1130
|
+
})
|