@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,562 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ MOCK_APPLICATION_ENTRIES,
6
+ MOCK_ORGANIZATIONS,
7
+ createHttpApplicationSwitcherTransport,
8
+ type ApplicationEntry,
9
+ type ApplicationSwitcherTransport,
10
+ type OrganizationEntry,
11
+ } from '../src'
12
+ import {
13
+ type ApplicationSwitcherErrorDetail,
14
+ UniplatApplicationSwitcher,
15
+ } from '../src/application-switcher'
16
+
17
+ const INTERNAL_APPLICATION_SWITCHER_TAG = 'uniplat-internal-application-switcher'
18
+
19
+ async function nextUpdate(switcher: UniplatApplicationSwitcher): Promise<void> {
20
+ await Promise.resolve()
21
+ await switcher.updateComplete
22
+ }
23
+
24
+ function waitForLoaded(switcher: UniplatApplicationSwitcher) {
25
+ return new Promise<readonly ApplicationEntry[]>((resolve) => {
26
+ switcher.addEventListener(
27
+ 'entries-loaded',
28
+ (event) =>
29
+ resolve((event as CustomEvent<{ entries: readonly ApplicationEntry[] }>).detail.entries),
30
+ { once: true },
31
+ )
32
+ })
33
+ }
34
+
35
+ describe('内部 Application Switcher', () => {
36
+ afterEach(() => {
37
+ document.body.replaceChildren()
38
+ vi.restoreAllMocks()
39
+ })
40
+
41
+ it('仅供账户工作台注册,并渲染 3 列应用网格', async () => {
42
+ expect(customElements.get('uniplat-application-switcher')).toBeUndefined()
43
+ expect(customElements.get(INTERNAL_APPLICATION_SWITCHER_TAG)).toBe(UniplatApplicationSwitcher)
44
+ const token = 'mock.header.payload'
45
+ const getAccessToken = vi.fn(async () => token)
46
+ const switcher = document.createElement(
47
+ INTERNAL_APPLICATION_SWITCHER_TAG,
48
+ ) as UniplatApplicationSwitcher
49
+ const loaded = waitForLoaded(switcher)
50
+ switcher.configure({ getAccessToken })
51
+ document.body.append(switcher)
52
+
53
+ expect(await loaded).toHaveLength(MOCK_APPLICATION_ENTRIES.length)
54
+ await nextUpdate(switcher)
55
+ expect(getAccessToken).toHaveBeenCalledTimes(1)
56
+
57
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.launcher')?.click()
58
+ await nextUpdate(switcher)
59
+ expect(switcher.shadowRoot?.querySelectorAll('[role="menuitem"]')).toHaveLength(9)
60
+ for (const entry of switcher.shadowRoot?.querySelectorAll<HTMLAnchorElement>(
61
+ '[role="menuitem"]',
62
+ ) ?? []) {
63
+ expect(entry.target).toBe('_blank')
64
+ expect(entry.rel).toBe('noopener noreferrer')
65
+ }
66
+ const organizationSelector = switcher.shadowRoot?.querySelector('.organization-selector')
67
+ const applicationGridShell = switcher.shadowRoot?.querySelector('.application-grid-shell')
68
+ const applicationGrid = switcher.shadowRoot?.querySelector('.grid')
69
+ expect(organizationSelector?.nextElementSibling).toBe(applicationGridShell)
70
+ expect(applicationGridShell?.firstElementChild).toBe(applicationGrid)
71
+ expect(organizationSelector?.textContent).toContain(MOCK_ORGANIZATIONS[0]?.name)
72
+
73
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.organization-trigger')?.click()
74
+ await nextUpdate(switcher)
75
+ expect(switcher.shadowRoot?.querySelectorAll('[role="option"]')).toHaveLength(
76
+ MOCK_ORGANIZATIONS.length,
77
+ )
78
+ const organizationMenu = switcher.shadowRoot?.querySelector<HTMLElement>('.organization-menu')
79
+ expect(getComputedStyle(organizationMenu!).position).toBe('absolute')
80
+ expect(organizationSelector?.nextElementSibling).toBe(applicationGridShell)
81
+ expect(switcher.shadowRoot?.textContent).toContain('数字员工')
82
+ expect(switcher.outerHTML).not.toContain(token)
83
+ expect(switcher.shadowRoot?.innerHTML).not.toContain(token)
84
+ })
85
+
86
+ it('内嵌工作台不请求组织列表', async () => {
87
+ const listApplications = vi.fn(async () => [
88
+ { id: 'personal', name: '个人中心', url: '/personal' },
89
+ ])
90
+ const listOrganizations = vi.fn(async () => ({
91
+ organizations: [{ id: 'organization-a', name: '组织 A' }],
92
+ currentOrganizationId: 'organization-a',
93
+ }))
94
+ const switcher = document.createElement(
95
+ INTERNAL_APPLICATION_SWITCHER_TAG,
96
+ ) as UniplatApplicationSwitcher
97
+ switcher.configure({
98
+ getAccessToken: async () => 'internal-workbench.jwt',
99
+ transport: { listApplications, listOrganizations },
100
+ presentation: 'embedded',
101
+ showOrganizationSelector: false,
102
+ })
103
+ document.body.append(switcher)
104
+
105
+ await vi.waitFor(() => expect(listApplications).toHaveBeenCalledTimes(1))
106
+ expect(listOrganizations).not.toHaveBeenCalled()
107
+ expect(switcher.shadowRoot?.querySelector('.organization-selector')).toBeNull()
108
+ })
109
+
110
+ it('always reserves a fixed 3-by-3 application grid', async () => {
111
+ const transport: ApplicationSwitcherTransport = {
112
+ async listApplications() {
113
+ return [
114
+ { id: 'app-a', name: '应用 A', url: '/apps/a' },
115
+ { id: 'app-b', name: '应用 B', url: '/apps/b' },
116
+ ]
117
+ },
118
+ }
119
+ const switcher = document.createElement(
120
+ INTERNAL_APPLICATION_SWITCHER_TAG,
121
+ ) as UniplatApplicationSwitcher
122
+ const loaded = waitForLoaded(switcher)
123
+ switcher.configure({ getAccessToken: async () => 'grid.jwt', transport })
124
+ document.body.append(switcher)
125
+
126
+ expect(await loaded).toHaveLength(2)
127
+ await nextUpdate(switcher)
128
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.launcher')?.click()
129
+ await nextUpdate(switcher)
130
+
131
+ const grid = switcher.shadowRoot?.querySelector<HTMLElement>('.grid')
132
+ expect(grid?.children).toHaveLength(9)
133
+ expect(grid?.querySelectorAll('.application')).toHaveLength(2)
134
+ expect(grid?.querySelectorAll('.application-empty-slot')).toHaveLength(7)
135
+ expect(grid?.querySelectorAll('.application-empty-slot[role]')).toHaveLength(0)
136
+ expect(getComputedStyle(grid!).gridTemplateColumns).toContain('repeat(3')
137
+ expect(getComputedStyle(grid!).gridTemplateRows).toBe('repeat(3, 92px)')
138
+ expect(getComputedStyle(grid!).height).toBe('292px')
139
+ })
140
+
141
+ it('renders every returned application beyond the first nine in a scrollable grid', async () => {
142
+ const returnedEntries = Array.from({ length: 11 }, (_, index) => ({
143
+ id: `app-${index + 1}`,
144
+ name: `应用 ${index + 1}`,
145
+ url: `/apps/${index + 1}`,
146
+ }))
147
+ const transport: ApplicationSwitcherTransport = {
148
+ async listApplications() {
149
+ return returnedEntries
150
+ },
151
+ }
152
+ const switcher = document.createElement(
153
+ INTERNAL_APPLICATION_SWITCHER_TAG,
154
+ ) as UniplatApplicationSwitcher
155
+ const loaded = waitForLoaded(switcher)
156
+ switcher.configure({ getAccessToken: async () => 'all-apps.jwt', transport })
157
+ document.body.append(switcher)
158
+
159
+ expect(await loaded).toHaveLength(11)
160
+ await nextUpdate(switcher)
161
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.launcher')?.click()
162
+ await nextUpdate(switcher)
163
+
164
+ const shell = switcher.shadowRoot?.querySelector<HTMLElement>('.application-grid-shell')
165
+ const grid = switcher.shadowRoot?.querySelector<HTMLElement>('.grid')
166
+ expect(grid?.children).toHaveLength(12)
167
+ expect(grid?.querySelectorAll('.application')).toHaveLength(11)
168
+ expect(grid?.querySelectorAll('.application-empty-slot')).toHaveLength(1)
169
+ expect(grid?.querySelector('[data-application-id="app-11"]')).not.toBeNull()
170
+ expect(getComputedStyle(shell!).height).toBe('292px')
171
+ expect(getComputedStyle(shell!).overflowY).toBe('auto')
172
+ expect(getComputedStyle(grid!).gridAutoRows).toBe('92px')
173
+ expect(getComputedStyle(grid!).height).toBe('392px')
174
+ })
175
+
176
+ it('shows a visible empty message without collapsing the fixed grid', async () => {
177
+ const transport: ApplicationSwitcherTransport = {
178
+ async listApplications() {
179
+ return []
180
+ },
181
+ }
182
+ const switcher = document.createElement(
183
+ INTERNAL_APPLICATION_SWITCHER_TAG,
184
+ ) as UniplatApplicationSwitcher
185
+ const loaded = waitForLoaded(switcher)
186
+ switcher.configure({ getAccessToken: async () => 'empty.jwt', transport })
187
+ document.body.append(switcher)
188
+
189
+ expect(await loaded).toEqual([])
190
+ await nextUpdate(switcher)
191
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.launcher')?.click()
192
+ await nextUpdate(switcher)
193
+
194
+ const grid = switcher.shadowRoot?.querySelector<HTMLElement>('.grid')
195
+ expect(grid?.children).toHaveLength(9)
196
+ expect(grid?.querySelectorAll('.application-empty-slot')).toHaveLength(9)
197
+ expect(switcher.shadowRoot?.querySelector('[role="status"]')?.textContent).toContain(
198
+ '暂无可用应用',
199
+ )
200
+ })
201
+
202
+ it('passes JWT to an injected transport but removes it from entries and public events', async () => {
203
+ const token = 'private.jwt.value'
204
+ const listApplications = vi.fn(async () => [
205
+ { id: 'safe', name: '安全应用', url: '/safe' },
206
+ { id: 'unsafe', name: `泄露 ${token}`, url: `/unsafe?token=${token}` },
207
+ ])
208
+ const transport: ApplicationSwitcherTransport = {
209
+ listApplications,
210
+ async listOrganizations() {
211
+ return {
212
+ organizations: [
213
+ { id: 'safe-org', name: '安全组织' },
214
+ { id: 'unsafe-org', name: `泄露 ${token}` },
215
+ ],
216
+ currentOrganizationId: 'safe-org',
217
+ }
218
+ },
219
+ }
220
+ const switcher = document.createElement(
221
+ INTERNAL_APPLICATION_SWITCHER_TAG,
222
+ ) as UniplatApplicationSwitcher
223
+ const eventDetails: unknown[] = []
224
+ switcher.addEventListener('entries-loaded', (event) => {
225
+ eventDetails.push((event as CustomEvent).detail)
226
+ })
227
+ const loaded = waitForLoaded(switcher)
228
+ switcher.configure({ getAccessToken: async () => token, transport })
229
+ document.body.append(switcher)
230
+
231
+ const entries = await loaded
232
+ await nextUpdate(switcher)
233
+ expect(listApplications).toHaveBeenCalledWith(token)
234
+ expect(entries).toHaveLength(1)
235
+ expect(entries[0]?.name).toBe('安全应用')
236
+ expect(JSON.stringify(eventDetails)).not.toContain(token)
237
+ expect(switcher.shadowRoot?.innerHTML).not.toContain(token)
238
+ })
239
+
240
+ it('keeps the organization unselected when the transport reports oid=0', async () => {
241
+ const switcher = document.createElement(
242
+ INTERNAL_APPLICATION_SWITCHER_TAG,
243
+ ) as UniplatApplicationSwitcher
244
+ const loaded = waitForLoaded(switcher)
245
+ switcher.configure({
246
+ getAccessToken: async () => 'open-application.jwt',
247
+ transport: {
248
+ async listApplications() {
249
+ return [{ id: 'OfficialWebsite:0', name: '官方网站', url: '/website' }]
250
+ },
251
+ async listOrganizations() {
252
+ return {
253
+ organizations: [{ id: 'organization-a', name: '组织 A' }],
254
+ currentOrganizationId: '',
255
+ }
256
+ },
257
+ },
258
+ })
259
+ document.body.append(switcher)
260
+
261
+ await loaded
262
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.launcher')?.click()
263
+ await nextUpdate(switcher)
264
+ expect(switcher.shadowRoot?.querySelector('.organization-name')?.textContent).toContain(
265
+ '请选择组织',
266
+ )
267
+ })
268
+
269
+ it('switches organization with a fresh JWT and reloads applications with another fresh JWT', async () => {
270
+ const token = 'organization.jwt.value'
271
+ let currentOrganizationId = 'org-a'
272
+ let finishReload: (() => void) | undefined
273
+ const getAccessToken = vi.fn(async () => token)
274
+ const listApplications = vi.fn(async () => {
275
+ if (currentOrganizationId === 'org-b') {
276
+ await new Promise<void>((resolve) => {
277
+ finishReload = resolve
278
+ })
279
+ }
280
+ return [
281
+ {
282
+ id: `application-${currentOrganizationId}`,
283
+ name: `应用 ${currentOrganizationId}`,
284
+ url: `/apps/${currentOrganizationId}`,
285
+ },
286
+ ]
287
+ })
288
+ const listOrganizations = vi.fn(async () => ({
289
+ organizations: [
290
+ { id: 'org-a', name: '组织 A' },
291
+ { id: 'org-b', name: '组织 B' },
292
+ ],
293
+ currentOrganizationId,
294
+ }))
295
+ const switchOrganization = vi.fn(async (_accessToken: string, organizationId: string) => {
296
+ currentOrganizationId = organizationId
297
+ })
298
+ const transport: ApplicationSwitcherTransport = {
299
+ listApplications,
300
+ listOrganizations,
301
+ switchOrganization,
302
+ }
303
+ const switcher = document.createElement(
304
+ INTERNAL_APPLICATION_SWITCHER_TAG,
305
+ ) as UniplatApplicationSwitcher
306
+ const changes: OrganizationEntry[] = []
307
+ switcher.addEventListener('organization-changed', (event) => {
308
+ changes.push((event as CustomEvent<{ organization: OrganizationEntry }>).detail.organization)
309
+ })
310
+ const initiallyLoaded = waitForLoaded(switcher)
311
+ switcher.configure({ getAccessToken, transport })
312
+ document.body.append(switcher)
313
+ await initiallyLoaded
314
+ await nextUpdate(switcher)
315
+
316
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.launcher')?.click()
317
+ await nextUpdate(switcher)
318
+ const panelContent = switcher.shadowRoot?.querySelector<HTMLElement>('.panel-content')
319
+ expect(getComputedStyle(panelContent!).minHeight).toBe('373px')
320
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.organization-trigger')?.click()
321
+ await nextUpdate(switcher)
322
+ const reloaded = waitForLoaded(switcher)
323
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('[data-organization-id="org-b"]')?.click()
324
+
325
+ await vi.waitFor(() => expect(finishReload).toBeTypeOf('function'))
326
+ await nextUpdate(switcher)
327
+ const loadingPanelContent = switcher.shadowRoot?.querySelector<HTMLElement>('.panel-content')
328
+ expect(loadingPanelContent).toBe(panelContent)
329
+ expect(loadingPanelContent?.classList.contains('has-organizations')).toBe(true)
330
+ expect(getComputedStyle(loadingPanelContent!).minHeight).toBe('373px')
331
+ expect(switcher.shadowRoot?.querySelector('[part="loading"]')).not.toBeNull()
332
+ finishReload?.()
333
+
334
+ expect(await reloaded).toEqual([
335
+ {
336
+ id: 'application-org-b',
337
+ name: '应用 org-b',
338
+ url: new URL('/apps/org-b', document.baseURI).href,
339
+ },
340
+ ])
341
+ await nextUpdate(switcher)
342
+ expect(getComputedStyle(panelContent!).minHeight).toBe('373px')
343
+ expect(switchOrganization).toHaveBeenCalledWith(token, 'org-b')
344
+ expect(getAccessToken).toHaveBeenCalledTimes(3)
345
+ expect(listApplications).toHaveBeenCalledTimes(2)
346
+ expect(listOrganizations).toHaveBeenCalledTimes(2)
347
+ expect(changes).toEqual([{ id: 'org-b', name: '组织 B' }])
348
+ expect(JSON.stringify(changes)).not.toContain(token)
349
+ expect(switcher.shadowRoot?.textContent).toContain('组织 B')
350
+ expect(switcher.shadowRoot?.innerHTML).not.toContain(token)
351
+ })
352
+
353
+ it('provides an HTTP transport that sends JWT only in Authorization headers', async () => {
354
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
355
+ const url = String(input)
356
+ if (url.endsWith('/organizations') && init?.method === 'GET') {
357
+ return new Response(
358
+ JSON.stringify({
359
+ organizations: [{ id: 'org-a', name: '组织 A' }],
360
+ currentOrganizationId: 'org-a',
361
+ }),
362
+ { status: 200, headers: { 'content-type': 'application/json' } },
363
+ )
364
+ }
365
+ if (url.endsWith('/current-organization') && init?.method === 'POST') {
366
+ return new Response(null, { status: 204 })
367
+ }
368
+ return new Response(
369
+ JSON.stringify({
370
+ entries: [
371
+ {
372
+ id: 'hr',
373
+ name: '人事管理',
374
+ url: 'https://apps.example.com/hr',
375
+ openMode: 'blank',
376
+ },
377
+ ],
378
+ }),
379
+ { status: 200, headers: { 'content-type': 'application/json' } },
380
+ )
381
+ })
382
+ const transport = createHttpApplicationSwitcherTransport({
383
+ endpoint: 'https://api.example.com/application-entries',
384
+ organizationsEndpoint: 'https://api.example.com/organizations',
385
+ switchOrganizationEndpoint: 'https://api.example.com/current-organization',
386
+ fetch: fetchMock as typeof fetch,
387
+ })
388
+ const httpToken = 'http.jwt.value'
389
+ const entries = await transport.listApplications(httpToken)
390
+ const organizations = await transport.listOrganizations?.(httpToken)
391
+ await transport.switchOrganization?.(httpToken, 'org-a')
392
+
393
+ expect(entries).toEqual([
394
+ {
395
+ id: 'hr',
396
+ name: '人事管理',
397
+ url: 'https://apps.example.com/hr',
398
+ openMode: 'blank',
399
+ },
400
+ ])
401
+ expect(organizations).toEqual({
402
+ organizations: [{ id: 'org-a', name: '组织 A' }],
403
+ currentOrganizationId: 'org-a',
404
+ })
405
+ expect(fetchMock).toHaveBeenCalledTimes(3)
406
+ for (const [url, init] of fetchMock.mock.calls) {
407
+ expect(String(url)).not.toContain(httpToken)
408
+ expect(new Headers(init?.headers).get('authorization')).toBe(`Bearer ${httpToken}`)
409
+ }
410
+ const [entryUrl] = fetchMock.mock.calls[0]!
411
+ expect(entryUrl).toBe('https://api.example.com/application-entries')
412
+ const [, switchInit] = fetchMock.mock.calls[2]!
413
+ expect(switchInit?.method).toBe('POST')
414
+ expect(switchInit?.body).toBe(JSON.stringify({ organizationId: 'org-a' }))
415
+ expect(String(switchInit?.body)).not.toContain(httpToken)
416
+ })
417
+
418
+ it('shows and emits only a sanitized error when token acquisition fails', async () => {
419
+ const switcher = document.createElement(
420
+ INTERNAL_APPLICATION_SWITCHER_TAG,
421
+ ) as UniplatApplicationSwitcher
422
+ const errors: ApplicationSwitcherErrorDetail[] = []
423
+ switcher.addEventListener('error', (event) => {
424
+ errors.push((event as unknown as CustomEvent<ApplicationSwitcherErrorDetail>).detail)
425
+ })
426
+ switcher.configure({
427
+ getAccessToken: async () => {
428
+ throw new Error('backend stack with jwt=provider-secret')
429
+ },
430
+ })
431
+ document.body.append(switcher)
432
+ await vi.waitFor(() => expect(errors).toHaveLength(1))
433
+ await nextUpdate(switcher)
434
+
435
+ expect(errors[0]).toEqual({
436
+ code: 'LOAD_FAILED',
437
+ message: '应用入口加载失败,请稍后重试。',
438
+ recoverable: true,
439
+ })
440
+ expect(JSON.stringify(errors)).not.toContain('provider-secret')
441
+ expect(switcher.shadowRoot?.textContent).not.toContain('backend stack')
442
+ })
443
+
444
+ it('emits a safe selection event and closes the panel', async () => {
445
+ const switcher = document.createElement(
446
+ INTERNAL_APPLICATION_SWITCHER_TAG,
447
+ ) as UniplatApplicationSwitcher
448
+ const loaded = waitForLoaded(switcher)
449
+ switcher.configure({ getAccessToken: async () => 'selection.jwt' })
450
+ document.body.append(switcher)
451
+ await loaded
452
+ await nextUpdate(switcher)
453
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.launcher')?.click()
454
+ await nextUpdate(switcher)
455
+
456
+ const selections: ApplicationEntry[] = []
457
+ switcher.addEventListener('application-selected', (event) => {
458
+ selections.push((event as CustomEvent<{ entry: ApplicationEntry }>).detail.entry)
459
+ })
460
+ switcher.addEventListener('click', (event) => event.preventDefault())
461
+ switcher.shadowRoot?.querySelector<HTMLAnchorElement>('[role="menuitem"]')?.click()
462
+ await nextUpdate(switcher)
463
+
464
+ expect(selections).toHaveLength(1)
465
+ expect(selections[0]?.id).toBe('ai-employee')
466
+ expect(switcher.shadowRoot?.querySelector('[part="panel"]')).toBeNull()
467
+ expect(JSON.stringify(selections)).not.toContain('selection.jwt')
468
+ })
469
+
470
+ it('switches the application identity before navigating a pre-opened new window', async () => {
471
+ const token = 'application-switch.jwt'
472
+ const applicationTicket = 'ticket_for_component_navigation_12345'
473
+ const switchApplication = vi.fn(async () => ({
474
+ url: `${new URL('/target', document.baseURI).href}#uniplat_application_ticket=${applicationTicket}`,
475
+ }))
476
+ const transport: ApplicationSwitcherTransport = {
477
+ async listApplications() {
478
+ return [{ id: 'target-xid', name: '目标应用', url: '/target' }]
479
+ },
480
+ switchApplication,
481
+ }
482
+ const getAccessToken = vi.fn(async () => token)
483
+ const replace = vi.fn()
484
+ const close = vi.fn()
485
+ const applicationWindow = {
486
+ closed: false,
487
+ close,
488
+ location: { replace },
489
+ opener: window,
490
+ } as unknown as Window
491
+ const open = vi.spyOn(window, 'open').mockReturnValue(applicationWindow)
492
+ const switcher = document.createElement(
493
+ INTERNAL_APPLICATION_SWITCHER_TAG,
494
+ ) as UniplatApplicationSwitcher
495
+ const loaded = waitForLoaded(switcher)
496
+ switcher.configure({ getAccessToken, transport })
497
+ document.body.append(switcher)
498
+ await loaded
499
+ await nextUpdate(switcher)
500
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.launcher')?.click()
501
+ await nextUpdate(switcher)
502
+
503
+ const selections: ApplicationEntry[] = []
504
+ switcher.addEventListener('application-selected', (event) => {
505
+ selections.push((event as CustomEvent<{ entry: ApplicationEntry }>).detail.entry)
506
+ })
507
+ const entry = switcher.shadowRoot?.querySelector<HTMLAnchorElement>('[role="menuitem"]')
508
+ expect(entry?.hasAttribute('href')).toBe(false)
509
+ expect(entry?.target).toBe('_blank')
510
+ entry?.click()
511
+
512
+ expect(open).toHaveBeenCalledWith('about:blank', '_blank')
513
+ expect(applicationWindow.opener).toBeNull()
514
+
515
+ await vi.waitFor(() => expect(selections).toHaveLength(1))
516
+ await nextUpdate(switcher)
517
+ expect(switchApplication).toHaveBeenCalledWith(token, 'target-xid')
518
+ expect(getAccessToken).toHaveBeenCalledTimes(2)
519
+ expect(replace).toHaveBeenCalledWith(
520
+ `${new URL('/target', document.baseURI).href}#uniplat_application_ticket=${applicationTicket}`,
521
+ )
522
+ expect(close).not.toHaveBeenCalled()
523
+ expect(switcher.shadowRoot?.querySelector('[part="panel"]')).toBeNull()
524
+ expect(JSON.stringify(selections)).not.toContain(token)
525
+ expect(JSON.stringify(selections)).not.toContain(applicationTicket)
526
+ expect(switcher.shadowRoot?.innerHTML).not.toContain(applicationTicket)
527
+ })
528
+
529
+ it('closes the pre-opened window when Host cancels application navigation', async () => {
530
+ const close = vi.fn()
531
+ const replace = vi.fn()
532
+ const applicationWindow = {
533
+ closed: false,
534
+ close,
535
+ location: { replace },
536
+ opener: window,
537
+ } as unknown as Window
538
+ vi.spyOn(window, 'open').mockReturnValue(applicationWindow)
539
+ const transport: ApplicationSwitcherTransport = {
540
+ async listApplications() {
541
+ return [{ id: 'cancel-target', name: '取消导航应用', url: '/cancel-target' }]
542
+ },
543
+ async switchApplication() {},
544
+ }
545
+ const switcher = document.createElement(
546
+ INTERNAL_APPLICATION_SWITCHER_TAG,
547
+ ) as UniplatApplicationSwitcher
548
+ const loaded = waitForLoaded(switcher)
549
+ switcher.configure({ getAccessToken: async () => 'cancel-navigation.jwt', transport })
550
+ document.body.append(switcher)
551
+ await loaded
552
+ await nextUpdate(switcher)
553
+ switcher.shadowRoot?.querySelector<HTMLButtonElement>('.launcher')?.click()
554
+ await nextUpdate(switcher)
555
+ switcher.addEventListener('application-selected', (event) => event.preventDefault())
556
+
557
+ switcher.shadowRoot?.querySelector<HTMLAnchorElement>('[role="menuitem"]')?.click()
558
+
559
+ await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1))
560
+ expect(replace).not.toHaveBeenCalled()
561
+ })
562
+ })