@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,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
|
+
})
|