@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,626 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ USER_LOGIN_TAG,
6
+ UniplatUserLogin,
7
+ UserLoginTransportError,
8
+ type UserLoginErrorDetail,
9
+ type UserLoginTransport,
10
+ } from '../src'
11
+
12
+ async function settle(login: UniplatUserLogin): Promise<void> {
13
+ await Promise.resolve()
14
+ await login.updateComplete
15
+ await Promise.resolve()
16
+ }
17
+
18
+ function updateInput(login: UniplatUserLogin, selector: string, value: string): void {
19
+ const input = login.shadowRoot?.querySelector<HTMLInputElement>(selector)
20
+ if (!input) throw new Error(`Missing input: ${selector}`)
21
+ input.value = value
22
+ input.dispatchEvent(new InputEvent('input', { bubbles: true }))
23
+ }
24
+
25
+ function updateCheckbox(login: UniplatUserLogin, selector: string, checked: boolean): void {
26
+ const input = login.shadowRoot?.querySelector<HTMLInputElement>(selector)
27
+ if (!input) throw new Error(`Missing checkbox: ${selector}`)
28
+ input.checked = checked
29
+ input.dispatchEvent(new Event('change', { bubbles: true }))
30
+ }
31
+
32
+ function blur(login: UniplatUserLogin, selector: string): void {
33
+ const element = login.shadowRoot?.querySelector<HTMLElement>(selector)
34
+ if (!element) throw new Error(`Missing element: ${selector}`)
35
+ element.dispatchEvent(new FocusEvent('blur', { bubbles: true }))
36
+ }
37
+
38
+ function createTransport(overrides: Partial<UserLoginTransport> = {}): UserLoginTransport {
39
+ return {
40
+ sendSmsCode: vi.fn(async () => undefined),
41
+ login: vi.fn(async () => ({ applicationName: 'HrSaas' })),
42
+ ...overrides,
43
+ }
44
+ }
45
+
46
+ describe('<uniplat-user-login>', () => {
47
+ afterEach(() => {
48
+ document.body.replaceChildren()
49
+ vi.useRealTimers()
50
+ })
51
+
52
+ it('registers, renders the verification login form and completes a safe login flow', async () => {
53
+ expect(customElements.get(USER_LOGIN_TAG)).toBe(UniplatUserLogin)
54
+ const transport = createTransport()
55
+ const login = document.createElement(USER_LOGIN_TAG)
56
+ const loginEvents: unknown[] = []
57
+ const smsEvents: unknown[] = []
58
+ login.addEventListener('login-success', (event) => {
59
+ loginEvents.push((event as CustomEvent).detail)
60
+ })
61
+ login.addEventListener('sms-sent', (event) => {
62
+ smsEvents.push((event as CustomEvent).detail)
63
+ })
64
+ login.configure({
65
+ transport,
66
+ applicationName: 'HrSaas',
67
+ countdownSeconds: 60,
68
+ headerTitle: '登录企业工作台',
69
+ headerSubtitle: '手机号验证后进入企业服务平台',
70
+ headerIconUrl: 'https://assets.example.com/login-shield.svg',
71
+ securityHint: '登录凭证仅用于企业工作台认证,请勿向他人透露验证码',
72
+ })
73
+ document.body.append(login)
74
+ await settle(login)
75
+
76
+ expect(login.shadowRoot?.querySelector('.shell')?.getAttribute('part')).toBe('shell')
77
+ expect(login.shadowRoot?.querySelector('.header-title')?.textContent).toContain(
78
+ '登录企业工作台',
79
+ )
80
+ expect(login.shadowRoot?.querySelector('.header-subtitle')?.textContent).toContain(
81
+ '手机号验证后进入企业服务平台',
82
+ )
83
+ expect(login.shadowRoot?.querySelector<HTMLImageElement>('.header-icon')?.src).toBe(
84
+ 'https://assets.example.com/login-shield.svg',
85
+ )
86
+ expect(login.shadowRoot?.querySelector('.security-footer')?.textContent).toContain(
87
+ '登录凭证仅用于企业工作台认证,请勿向他人透露验证码',
88
+ )
89
+ expect(login.shadowRoot?.textContent).toContain('验证码登录')
90
+ expect(login.shadowRoot?.querySelectorAll('[role="tab"]')).toHaveLength(3)
91
+ expect(
92
+ login.shadowRoot?.querySelector<HTMLButtonElement>('[role="tab"]:first-child')?.disabled,
93
+ ).toBe(true)
94
+ expect(
95
+ login.shadowRoot?.querySelector<HTMLButtonElement>('[role="tab"]:last-child')?.disabled,
96
+ ).toBe(true)
97
+ expect(login.shadowRoot?.querySelector('#uniplat-login-image-code')).toBeNull()
98
+ expect(login.shadowRoot?.querySelector('.captcha-image')).toBeNull()
99
+ expect(login.shadowRoot?.textContent).not.toContain('图形验证码')
100
+ expect(login.shadowRoot?.querySelector('.sms-row')).not.toBeNull()
101
+ expect(login.shadowRoot?.querySelector('.sms-action')).not.toBeNull()
102
+ expect(
103
+ login.shadowRoot?.querySelector('#uniplat-login-sms-code')?.getAttribute('aria-label'),
104
+ ).toBe('手机验证码')
105
+ updateInput(login, '#uniplat-login-mobile', '13800138000')
106
+ await settle(login)
107
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.send-button')?.click()
108
+ await vi.waitFor(() => expect(transport.sendSmsCode).toHaveBeenCalledTimes(1))
109
+ expect(transport.sendSmsCode).toHaveBeenCalledWith({
110
+ mobile: '13800138000',
111
+ })
112
+ expect(smsEvents).toEqual([{}])
113
+
114
+ updateInput(login, '#uniplat-login-sms-code', '123456')
115
+ updateCheckbox(login, '.agreement .checkbox', true)
116
+ await settle(login)
117
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.submit')?.click()
118
+ await vi.waitFor(() => expect(transport.login).toHaveBeenCalledTimes(1))
119
+ await settle(login)
120
+
121
+ expect(transport.login).toHaveBeenCalledWith({
122
+ mobile: '13800138000',
123
+ smsCode: '123456',
124
+ applicationName: 'HrSaas',
125
+ })
126
+ expect(loginEvents).toEqual([{ applicationName: 'HrSaas' }])
127
+ expect(JSON.stringify(loginEvents)).not.toContain('13800138000')
128
+ expect(login.shadowRoot?.innerHTML).not.toContain('HrSaas')
129
+ expect(login.shadowRoot?.textContent).toContain('登录成功')
130
+ })
131
+
132
+ it('does not call a legacy image-captcha provider', async () => {
133
+ const createCaptcha = vi.fn(async () => ({
134
+ seed: 'legacy-seed',
135
+ imageUrl: 'https://api.example.com/general/imageToVerify/legacy-seed',
136
+ }))
137
+ const login = document.createElement(USER_LOGIN_TAG)
138
+ login.configure({
139
+ transport: createTransport({ createCaptcha }),
140
+ applicationName: 'HrSaas',
141
+ })
142
+ document.body.append(login)
143
+ await settle(login)
144
+
145
+ expect(createCaptcha).not.toHaveBeenCalled()
146
+ expect(login.shadowRoot?.textContent).not.toContain('图形验证码')
147
+ expect(login.shadowRoot?.innerHTML).not.toContain('legacy-seed')
148
+ })
149
+
150
+ it('renders prototype field and agreement validation errors beside their controls', async () => {
151
+ const login = document.createElement(USER_LOGIN_TAG)
152
+ login.configure({ transport: createTransport(), applicationName: 'HrSaas' })
153
+ document.body.append(login)
154
+ await settle(login)
155
+
156
+ updateInput(login, '#uniplat-login-mobile', '1380013')
157
+ updateInput(login, '#uniplat-login-sms-code', '12093')
158
+ blur(login, '#uniplat-login-mobile')
159
+ blur(login, '#uniplat-login-sms-code')
160
+ blur(login, '.agreement .checkbox')
161
+ await settle(login)
162
+
163
+ expect(login.shadowRoot?.textContent).toContain('请输入正确的 11 位手机号')
164
+ expect(login.shadowRoot?.textContent).toContain('请输入正确的手机验证码')
165
+ expect(login.shadowRoot?.textContent).toContain('请先阅读并同意服务协议')
166
+ expect(
167
+ login.shadowRoot?.querySelector('#uniplat-login-mobile')?.getAttribute('aria-invalid'),
168
+ ).toBe('true')
169
+ expect(
170
+ login.shadowRoot?.querySelector('#uniplat-login-sms-code')?.getAttribute('aria-invalid'),
171
+ ).toBe('true')
172
+ expect(
173
+ login.shadowRoot?.querySelector('.agreement .checkbox')?.getAttribute('aria-invalid'),
174
+ ).toBe('true')
175
+ expect(login.shadowRoot?.querySelector('.agreement .checkbox.error')).not.toBeNull()
176
+ expect(login.shadowRoot?.querySelector<HTMLButtonElement>('.submit')?.disabled).toBe(true)
177
+ expect(login.shadowRoot?.querySelector<HTMLButtonElement>('.send-button')?.disabled).toBe(true)
178
+ expect(login.shadowRoot?.querySelector('.helper')).toBeNull()
179
+ })
180
+
181
+ it('places a stable expired-code error below the SMS field without exposing backend text', async () => {
182
+ const login = document.createElement(USER_LOGIN_TAG)
183
+ const errors: UserLoginErrorDetail[] = []
184
+ login.addEventListener('error', (event) => {
185
+ errors.push((event as unknown as CustomEvent<UserLoginErrorDetail>).detail)
186
+ })
187
+ login.configure({
188
+ transport: createTransport({
189
+ login: vi.fn(async () => {
190
+ throw new UserLoginTransportError('SMS_CODE_INVALID_OR_EXPIRED')
191
+ }),
192
+ }),
193
+ applicationName: 'HrSaas',
194
+ initiallyAgreed: true,
195
+ })
196
+ document.body.append(login)
197
+ await settle(login)
198
+
199
+ updateInput(login, '#uniplat-login-mobile', '13800138000')
200
+ updateInput(login, '#uniplat-login-sms-code', '120933')
201
+ await settle(login)
202
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.submit')?.click()
203
+ await vi.waitFor(() => expect(errors).toHaveLength(1))
204
+ await settle(login)
205
+
206
+ expect(login.shadowRoot?.textContent).toContain('验证码错误或已失效,请重新获取')
207
+ expect(
208
+ login.shadowRoot?.querySelector('#uniplat-login-sms-code')?.getAttribute('aria-invalid'),
209
+ ).toBe('true')
210
+ expect(login.shadowRoot?.querySelector<HTMLButtonElement>('.send-button')).not.toBeNull()
211
+ expect(login.shadowRoot?.querySelector<HTMLButtonElement>('.send-button')?.disabled).toBe(false)
212
+ expect(login.shadowRoot?.querySelector('.helper')).toBeNull()
213
+ expect(errors).toEqual([
214
+ {
215
+ code: 'SMS_CODE_INVALID_OR_EXPIRED',
216
+ message: '验证码错误或已失效,请重新获取',
217
+ recoverable: true,
218
+ },
219
+ ])
220
+ })
221
+
222
+ it('requires the Host to provide a non-empty applicationName', () => {
223
+ const login = document.createElement(USER_LOGIN_TAG)
224
+
225
+ expect(() => login.configure({ transport: createTransport(), applicationName: ' ' })).toThrow(
226
+ TypeError,
227
+ )
228
+ })
229
+
230
+ it('updates Host-provided header and security content when reconfigured', async () => {
231
+ const transport = createTransport()
232
+ const login = document.createElement(USER_LOGIN_TAG)
233
+ login.configure({
234
+ transport,
235
+ applicationName: 'HrSaas',
236
+ headerTitle: '登录企业工作台',
237
+ headerSubtitle: '原副标题',
238
+ headerIconUrl: 'https://assets.example.com/original.svg',
239
+ securityHint: '原安全提示',
240
+ })
241
+ document.body.append(login)
242
+ await settle(login)
243
+
244
+ login.configure({
245
+ transport,
246
+ applicationName: 'NewWorkspace',
247
+ headerTitle: '登录新的工作台',
248
+ securityHint: '新的安全提示',
249
+ })
250
+ await settle(login)
251
+
252
+ expect(login.shadowRoot?.querySelector('.header-title')?.textContent).toContain(
253
+ '登录新的工作台',
254
+ )
255
+ expect(login.shadowRoot?.querySelector('.header-subtitle')).toBeNull()
256
+ expect(login.shadowRoot?.querySelector('.header-icon')).toBeNull()
257
+ expect(login.shadowRoot?.querySelector('.security-footer')?.textContent).toContain(
258
+ '新的安全提示',
259
+ )
260
+ expect(login.shadowRoot?.textContent).not.toContain('原安全提示')
261
+ })
262
+
263
+ it('rejects a Host icon URL carrying authentication parameters', () => {
264
+ const login = document.createElement(USER_LOGIN_TAG)
265
+
266
+ expect(() =>
267
+ login.configure({
268
+ transport: createTransport(),
269
+ applicationName: 'HrSaas',
270
+ headerIconUrl: 'https://assets.example.com/login.svg?token=secret',
271
+ }),
272
+ ).toThrow(TypeError)
273
+ })
274
+
275
+ it('maps transport failures to stable errors without exposing the original exception', async () => {
276
+ const sensitiveText = 'backend stack with jwt=should-never-render'
277
+ const transport = createTransport({
278
+ login: vi.fn(async () => {
279
+ throw new Error(sensitiveText)
280
+ }),
281
+ })
282
+ const login = document.createElement(USER_LOGIN_TAG)
283
+ const errors: UserLoginErrorDetail[] = []
284
+ login.addEventListener('error', (event) => {
285
+ errors.push((event as unknown as CustomEvent<UserLoginErrorDetail>).detail)
286
+ })
287
+ login.configure({ transport, applicationName: 'HrSaas', initiallyAgreed: true })
288
+ document.body.append(login)
289
+ await settle(login)
290
+
291
+ updateInput(login, '#uniplat-login-mobile', '13800138000')
292
+ updateInput(login, '#uniplat-login-sms-code', '123456')
293
+ await settle(login)
294
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.submit')?.click()
295
+ await vi.waitFor(() => expect(errors).toHaveLength(1))
296
+ await settle(login)
297
+
298
+ expect(errors).toEqual([
299
+ {
300
+ code: 'LOGIN_FAILED',
301
+ message: '登录失败,请检查手机验证码后重试。',
302
+ recoverable: true,
303
+ },
304
+ ])
305
+ expect(login.shadowRoot?.innerHTML).not.toContain(sensitiveText)
306
+ expect(JSON.stringify(errors)).not.toContain(sensitiveText)
307
+ })
308
+
309
+ it('renders a safe application access denial message supplied by the transport', async () => {
310
+ const transport = createTransport({
311
+ login: vi.fn(async () => {
312
+ throw new UserLoginTransportError('ORG_ACCESS_DENIED', '当前用户没有可用的平台设置应用实例')
313
+ }),
314
+ })
315
+ const login = document.createElement(USER_LOGIN_TAG)
316
+ const errors: UserLoginErrorDetail[] = []
317
+ login.addEventListener('error', (event) => {
318
+ errors.push((event as unknown as CustomEvent<UserLoginErrorDetail>).detail)
319
+ })
320
+ login.configure({
321
+ transport,
322
+ applicationName: 'qqxb-plat-admin',
323
+ initiallyAgreed: true,
324
+ })
325
+ document.body.append(login)
326
+ await settle(login)
327
+
328
+ updateInput(login, '#uniplat-login-mobile', '13800138000')
329
+ updateInput(login, '#uniplat-login-sms-code', '123456')
330
+ await settle(login)
331
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.submit')?.click()
332
+ await vi.waitFor(() => expect(errors).toHaveLength(1))
333
+ await settle(login)
334
+
335
+ const message = '当前用户没有可用的平台设置应用实例'
336
+ expect(errors).toEqual([
337
+ {
338
+ code: 'ORG_ACCESS_DENIED',
339
+ message,
340
+ recoverable: false,
341
+ },
342
+ ])
343
+ expect(login.shadowRoot?.textContent).toContain(message)
344
+ })
345
+
346
+ it('clears the SMS countdown timer when removed', async () => {
347
+ vi.useFakeTimers()
348
+ const transport = createTransport()
349
+ const login = document.createElement(USER_LOGIN_TAG)
350
+ login.configure({ transport, applicationName: 'HrSaas', countdownSeconds: 60 })
351
+ document.body.append(login)
352
+ await settle(login)
353
+
354
+ updateInput(login, '#uniplat-login-mobile', '13800138000')
355
+ await settle(login)
356
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.send-button')?.click()
357
+ await settle(login)
358
+ expect(vi.getTimerCount()).toBe(1)
359
+
360
+ login.remove()
361
+ expect(vi.getTimerCount()).toBe(0)
362
+ })
363
+
364
+ it('renders the registration-success transition and requests onboarding after two seconds', async () => {
365
+ vi.useFakeTimers()
366
+ const login = document.createElement(USER_LOGIN_TAG)
367
+ const loginEvents: unknown[] = []
368
+ const onboardingEvents: unknown[] = []
369
+ login.addEventListener('login-success', (event) => {
370
+ loginEvents.push((event as CustomEvent).detail)
371
+ })
372
+ login.addEventListener('onboarding-requested', (event) => {
373
+ onboardingEvents.push((event as CustomEvent).detail)
374
+ })
375
+ login.configure({
376
+ transport: createTransport({
377
+ login: vi.fn(async () => ({ applicationName: 'HrSaas', isNewUser: true })),
378
+ }),
379
+ applicationName: 'HrSaas',
380
+ initiallyAgreed: true,
381
+ })
382
+ document.body.append(login)
383
+ await settle(login)
384
+
385
+ updateInput(login, '#uniplat-login-mobile', '13800138000')
386
+ updateInput(login, '#uniplat-login-sms-code', '123456')
387
+ await settle(login)
388
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.submit')?.click()
389
+ await settle(login)
390
+
391
+ expect(loginEvents).toEqual([{ applicationName: 'HrSaas', isNewUser: true }])
392
+ expect(login.shadowRoot?.querySelector('[role="tablist"]')).toBeNull()
393
+ expect(login.shadowRoot?.textContent).toContain('注册成功,欢迎加入亲亲企服')
394
+ expect(login.shadowRoot?.textContent).toContain('接下来创建你的团队 / 企业,即可开始办理业务')
395
+ expect(login.shadowRoot?.textContent).toContain('2 秒后自动进入入驻引导')
396
+ expect(login.shadowRoot?.textContent).toContain('立即开始')
397
+ expect(vi.getTimerCount()).toBe(1)
398
+
399
+ await vi.advanceTimersByTimeAsync(1_999)
400
+ expect(onboardingEvents).toEqual([])
401
+ await vi.advanceTimersByTimeAsync(1)
402
+ await settle(login)
403
+ expect(onboardingEvents).toEqual([{ applicationName: 'HrSaas', trigger: 'automatic' }])
404
+ expect(vi.getTimerCount()).toBe(0)
405
+ })
406
+
407
+ it('starts onboarding immediately without leaving a duplicate automatic timer', async () => {
408
+ vi.useFakeTimers()
409
+ const login = document.createElement(USER_LOGIN_TAG)
410
+ const onboardingEvents: unknown[] = []
411
+ login.addEventListener('onboarding-requested', (event) => {
412
+ onboardingEvents.push((event as CustomEvent).detail)
413
+ })
414
+ login.configure({
415
+ transport: createTransport({
416
+ login: vi.fn(async () => ({ applicationName: 'HrSaas', isNewUser: true })),
417
+ }),
418
+ applicationName: 'HrSaas',
419
+ initiallyAgreed: true,
420
+ })
421
+ document.body.append(login)
422
+ await settle(login)
423
+
424
+ updateInput(login, '#uniplat-login-mobile', '13800138000')
425
+ updateInput(login, '#uniplat-login-sms-code', '123456')
426
+ await settle(login)
427
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.submit')?.click()
428
+ await settle(login)
429
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.onboarding-button')?.click()
430
+
431
+ expect(onboardingEvents).toEqual([{ applicationName: 'HrSaas', trigger: 'manual' }])
432
+ expect(vi.getTimerCount()).toBe(0)
433
+ await vi.advanceTimersByTimeAsync(2_000)
434
+ expect(onboardingEvents).toHaveLength(1)
435
+ })
436
+
437
+ it('clears the automatic onboarding timer when removed', async () => {
438
+ vi.useFakeTimers()
439
+ const login = document.createElement(USER_LOGIN_TAG)
440
+ const onboardingEvents: unknown[] = []
441
+ login.addEventListener('onboarding-requested', (event) => {
442
+ onboardingEvents.push((event as CustomEvent).detail)
443
+ })
444
+ login.configure({
445
+ transport: createTransport({
446
+ login: vi.fn(async () => ({ applicationName: 'HrSaas', isNewUser: true })),
447
+ }),
448
+ applicationName: 'HrSaas',
449
+ initiallyAgreed: true,
450
+ })
451
+ document.body.append(login)
452
+ await settle(login)
453
+
454
+ updateInput(login, '#uniplat-login-mobile', '13800138000')
455
+ updateInput(login, '#uniplat-login-sms-code', '123456')
456
+ await settle(login)
457
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.submit')?.click()
458
+ await settle(login)
459
+ expect(vi.getTimerCount()).toBe(1)
460
+
461
+ login.remove()
462
+ expect(vi.getTimerCount()).toBe(0)
463
+ await vi.advanceTimersByTimeAsync(2_000)
464
+ expect(onboardingEvents).toEqual([])
465
+ })
466
+
467
+ it('supports an injected password login without exposing credentials in events', async () => {
468
+ const loginWithPassword = vi.fn(async () => ({ applicationName: 'HrSaas' }))
469
+ const transport = createTransport({ loginWithPassword })
470
+ const login = document.createElement(USER_LOGIN_TAG)
471
+ const loginEvents: unknown[] = []
472
+ login.addEventListener('login-success', (event) => {
473
+ loginEvents.push((event as CustomEvent).detail)
474
+ })
475
+ login.configure({ transport, applicationName: 'HrSaas', initialMethod: 'password' })
476
+ document.body.append(login)
477
+ await settle(login)
478
+
479
+ expect(
480
+ login.shadowRoot?.querySelector<HTMLButtonElement>('[role="tab"][aria-selected="true"]')
481
+ ?.textContent,
482
+ ).toContain('密码登录')
483
+ updateInput(login, '#uniplat-login-mobile', '13800138000')
484
+ updateInput(login, '#uniplat-login-password', 'safe-test-password')
485
+ const passwordInput =
486
+ login.shadowRoot?.querySelector<HTMLInputElement>('#uniplat-login-password')
487
+ await settle(login)
488
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.submit')?.click()
489
+ await vi.waitFor(() => expect(loginWithPassword).toHaveBeenCalledTimes(1))
490
+ await settle(login)
491
+
492
+ expect(loginWithPassword).toHaveBeenCalledWith({
493
+ mobile: '13800138000',
494
+ password: 'safe-test-password',
495
+ applicationName: 'HrSaas',
496
+ rememberSession: true,
497
+ })
498
+ expect(loginEvents).toEqual([{ applicationName: 'HrSaas' }])
499
+ expect(JSON.stringify(loginEvents)).not.toContain('13800138000')
500
+ expect(JSON.stringify(loginEvents)).not.toContain('safe-test-password')
501
+ expect(login.shadowRoot?.innerHTML).not.toContain('safe-test-password')
502
+ login.remove()
503
+ expect(passwordInput?.value).toBe('')
504
+ })
505
+
506
+ it('keeps QR status in one panel and clears polling after confirmation', async () => {
507
+ vi.useFakeTimers()
508
+ const createQrChallenge = vi.fn(async () => ({
509
+ challengeId: 'obviously-fake-qr-challenge',
510
+ imageUrl:
511
+ 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="120" height="120"%3E%3Crect width="120" height="120"/%3E%3C/svg%3E',
512
+ pollIntervalMs: 750,
513
+ }))
514
+ const pollQrChallenge = vi
515
+ .fn()
516
+ .mockResolvedValueOnce({ status: 'scanned' })
517
+ .mockResolvedValueOnce({
518
+ status: 'confirmed',
519
+ result: { applicationName: 'HrSaas' },
520
+ })
521
+ const transport = createTransport({ createQrChallenge, pollQrChallenge })
522
+ const login = document.createElement(USER_LOGIN_TAG)
523
+ const loginEvents: unknown[] = []
524
+ login.addEventListener('login-success', (event) => {
525
+ loginEvents.push((event as CustomEvent).detail)
526
+ })
527
+ login.configure({
528
+ transport,
529
+ applicationName: 'HrSaas',
530
+ initialMethod: 'qr-code',
531
+ qrPollIntervalMs: 500,
532
+ })
533
+ document.body.append(login)
534
+ await settle(login)
535
+
536
+ expect(createQrChallenge).toHaveBeenCalledWith({ applicationName: 'HrSaas' })
537
+ expect(login.shadowRoot?.querySelector<HTMLImageElement>('.qr-image')?.src).toContain(
538
+ 'data:image/svg+xml',
539
+ )
540
+ expect(login.shadowRoot?.textContent).toContain('请使用微信扫描小程序码登录')
541
+ expect(login.shadowRoot?.textContent).toContain('未登录时先完成登录')
542
+ expect(login.shadowRoot?.textContent).toContain('电脑端同步登录')
543
+ expect(vi.getTimerCount()).toBe(1)
544
+ expect(login.shadowRoot?.innerHTML).not.toContain('obviously-fake-qr-challenge')
545
+
546
+ await vi.advanceTimersByTimeAsync(500)
547
+ expect(pollQrChallenge).not.toHaveBeenCalled()
548
+ await vi.advanceTimersByTimeAsync(250)
549
+ await settle(login)
550
+ expect(login.shadowRoot?.textContent).toContain('扫码成功,请在手机上确认')
551
+ expect(vi.getTimerCount()).toBe(1)
552
+ expect(pollQrChallenge).toHaveBeenCalledWith('obviously-fake-qr-challenge', 'HrSaas')
553
+
554
+ await vi.advanceTimersByTimeAsync(750)
555
+ await settle(login)
556
+ expect(loginEvents).toEqual([{ applicationName: 'HrSaas' }])
557
+ expect(JSON.stringify(loginEvents)).not.toContain('obviously-fake-qr-challenge')
558
+ expect(vi.getTimerCount()).toBe(0)
559
+ })
560
+
561
+ it('stops polling and offers a refresh after QR login is rejected', async () => {
562
+ vi.useFakeTimers()
563
+ const transport = createTransport({
564
+ createQrChallenge: vi.fn(async () => ({
565
+ challengeId: 'obviously-fake-rejected-challenge',
566
+ imageUrl: 'data:image/png;base64,b2J2aW91c2x5LWZha2UtcG5n',
567
+ pollIntervalMs: 500,
568
+ })),
569
+ pollQrChallenge: vi.fn(async () => ({ status: 'rejected' as const })),
570
+ })
571
+ const errors: UserLoginErrorDetail[] = []
572
+ const login = document.createElement(USER_LOGIN_TAG)
573
+ login.addEventListener('error', (event) => {
574
+ errors.push((event as unknown as CustomEvent<UserLoginErrorDetail>).detail)
575
+ })
576
+ login.configure({ transport, applicationName: 'HrSaas', initialMethod: 'qr-code' })
577
+ document.body.append(login)
578
+ await settle(login)
579
+
580
+ await vi.advanceTimersByTimeAsync(500)
581
+ await settle(login)
582
+
583
+ expect(login.shadowRoot?.textContent).toContain('登录已取消')
584
+ expect(login.shadowRoot?.textContent).toContain('已在手机上取消登录,请点击刷新。')
585
+ expect(vi.getTimerCount()).toBe(0)
586
+ expect(errors).toEqual([
587
+ {
588
+ code: 'QR_LOGIN_REJECTED',
589
+ message: '已在手机上取消登录,请点击刷新。',
590
+ recoverable: true,
591
+ },
592
+ ])
593
+ })
594
+
595
+ it('maps a locked password account to a stable safe component error', async () => {
596
+ const transport = createTransport({
597
+ loginWithPassword: vi.fn(async () => {
598
+ throw new UserLoginTransportError('ACCOUNT_LOCKED')
599
+ }),
600
+ })
601
+ const errors: UserLoginErrorDetail[] = []
602
+ const login = document.createElement(USER_LOGIN_TAG)
603
+ login.addEventListener('error', (event) => {
604
+ errors.push((event as unknown as CustomEvent<UserLoginErrorDetail>).detail)
605
+ })
606
+ login.configure({ transport, applicationName: 'HrSaas', initialMethod: 'password' })
607
+ document.body.append(login)
608
+ await settle(login)
609
+
610
+ updateInput(login, '#uniplat-login-mobile', '13800138000')
611
+ updateInput(login, '#uniplat-login-password', 'safe-test-password')
612
+ await settle(login)
613
+ login.shadowRoot?.querySelector<HTMLButtonElement>('.submit')?.click()
614
+ await vi.waitFor(() => expect(errors).toHaveLength(1))
615
+ await settle(login)
616
+
617
+ expect(errors).toEqual([
618
+ {
619
+ code: 'ACCOUNT_LOCKED',
620
+ message: '账号暂时锁定,请稍后重试。',
621
+ recoverable: false,
622
+ },
623
+ ])
624
+ expect(login.shadowRoot?.innerHTML).not.toContain('safe-test-password')
625
+ })
626
+ })