Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 

480 Zeilen
20 KiB

  1. import type { Locator, Page } from '@playwright/test'
  2. import { captureScreenshot, expect, test } from './fixtures'
  3. const loginContext = {
  4. defaultRoleCode: 'admin',
  5. roles: [
  6. {
  7. code: 'administrative',
  8. label: '行政',
  9. accountLabel: '教学系领导账号',
  10. organizationMode: 'SINGLE',
  11. organizationLabels: ['教学系'],
  12. },
  13. {
  14. code: 'teacher',
  15. label: '教员',
  16. accountLabel: '教员账号',
  17. organizationMode: 'CASCADE',
  18. organizationLabels: ['教学系', '教研室'],
  19. },
  20. {
  21. code: 'student',
  22. label: '学员',
  23. accountLabel: '学号或账号',
  24. organizationMode: 'NONE',
  25. organizationLabels: [],
  26. },
  27. {
  28. code: 'admin',
  29. label: '管理员',
  30. accountLabel: '管理员账号',
  31. organizationMode: 'NONE',
  32. organizationLabels: [],
  33. },
  34. ],
  35. departments: [
  36. {
  37. id: '100',
  38. code: 'EQUIPMENT',
  39. name: '工程装备系',
  40. parentId: null,
  41. children: [
  42. {
  43. id: '110',
  44. code: 'MAINTENANCE',
  45. name: '维修教研室',
  46. parentId: '100',
  47. children: [
  48. {
  49. id: '111',
  50. code: 'MAINTENANCE-GROUP-1',
  51. name: '维修一组',
  52. parentId: '110',
  53. children: [
  54. {
  55. id: '112',
  56. code: 'HYDROPOWER-A',
  57. name: '水电组A',
  58. parentId: '111',
  59. children: [],
  60. },
  61. ],
  62. },
  63. ],
  64. },
  65. {
  66. id: '120',
  67. code: 'SUPPORT',
  68. name: '智能保障教研室',
  69. parentId: '100',
  70. children: [],
  71. },
  72. ],
  73. },
  74. {
  75. id: '200',
  76. code: 'COMMAND',
  77. name: '指挥系',
  78. parentId: null,
  79. children: [
  80. {
  81. id: '210',
  82. code: 'COMMAND-TEACHING',
  83. name: '指挥教研室',
  84. parentId: '200',
  85. children: [],
  86. },
  87. ],
  88. },
  89. ],
  90. } as const
  91. const loginIdentities = {
  92. administrative: [
  93. { id: '301', displayName: '秦主任', departmentId: '100', departmentName: '工程装备系' },
  94. { id: '302', displayName: '周主任', departmentId: '200', departmentName: '指挥系' },
  95. ],
  96. teacher: [
  97. { id: '401', displayName: '许教员', departmentId: '100', departmentName: '工程装备系' },
  98. { id: '402', displayName: '杜晴', departmentId: '110', departmentName: '维修教研室' },
  99. { id: '403', displayName: '马雅柔', departmentId: '112', departmentName: '水电组A' },
  100. { id: '404', displayName: '韩教员', departmentId: '120', departmentName: '智能保障教研室' },
  101. ],
  102. student: [
  103. { id: '501', displayName: '学生甲', departmentId: '112', departmentName: '水电组A' },
  104. ],
  105. } as const
  106. const descendantDepartmentIds: Record<string, ReadonlySet<string>> = {
  107. '100': new Set(['100', '110', '111', '112', '120']),
  108. '110': new Set(['110', '111', '112']),
  109. '120': new Set(['120']),
  110. '200': new Set(['200', '210']),
  111. '210': new Set(['210']),
  112. }
  113. const mockLoginContext = async (page: Page) => {
  114. await page.route('**/api/auth/v1/auth/login-context', async (route) => {
  115. await route.fulfill({
  116. status: 200,
  117. contentType: 'application/json',
  118. body: JSON.stringify({
  119. code: 200,
  120. message: '成功',
  121. data: loginContext,
  122. timestamp: '2026-08-11T00:00:00+08:00',
  123. requestId: 'ute2e-login-context-success',
  124. }),
  125. })
  126. })
  127. await page.route('**/api/auth/v1/auth/login-identities**', async (route) => {
  128. const url = new URL(route.request().url())
  129. const roleCode = url.searchParams.get('roleCode')
  130. const departmentId = url.searchParams.get('departmentId')
  131. const source = roleCode && roleCode in loginIdentities
  132. ? loginIdentities[roleCode as keyof typeof loginIdentities]
  133. : []
  134. const allowedDepartmentIds = departmentId ? descendantDepartmentIds[departmentId] : null
  135. const identities = source.filter((identity) => !allowedDepartmentIds || allowedDepartmentIds.has(identity.departmentId))
  136. await route.fulfill({
  137. status: 200,
  138. contentType: 'application/json',
  139. body: JSON.stringify({
  140. code: 200,
  141. message: '成功',
  142. data: identities,
  143. timestamp: '2026-08-11T00:00:00+08:00',
  144. requestId: 'ute2e-login-identities-success',
  145. }),
  146. })
  147. })
  148. }
  149. const mockLoginContextFailure = async (page: Page) => {
  150. await page.route('**/api/auth/v1/auth/login-context', async (route) => {
  151. await route.fulfill({
  152. status: 503,
  153. contentType: 'application/json',
  154. body: JSON.stringify({
  155. code: 50300,
  156. message: '认证配置暂不可用',
  157. data: null,
  158. timestamp: '2026-08-11T00:00:00+08:00',
  159. requestId: 'ute2e-login-context-failure',
  160. }),
  161. })
  162. })
  163. }
  164. const openLoginWithContext = async (page: Page) => {
  165. await mockLoginContext(page)
  166. await page.goto('/login')
  167. const contextState = page.getByText('身份认证服务正常', { exact: true })
  168. await expect(contextState).toBeAttached()
  169. await expect(contextState).toHaveAttribute('aria-live', 'polite')
  170. await expect(contextState).toHaveCSS('position', 'absolute')
  171. await expect(contextState).toHaveCSS('clip-path', 'inset(50%)')
  172. }
  173. const adminUsernameInput = (page: Page) => page.locator('input[name="username"]')
  174. const passwordInput = (page: Page) => page.locator('input[name="password"]')
  175. const submitButton = (page: Page) => page.getByRole('button', { name: '登录系统' })
  176. const identitySelect = (page: Page) => page.getByTestId('login-identity')
  177. const openElementSelect = async (page: Page, select: Locator) => {
  178. const combobox = select.getByRole('combobox')
  179. await select.locator('.el-select__wrapper').click()
  180. await expect(combobox).toHaveAttribute('aria-expanded', 'true')
  181. const listboxId = await combobox.getAttribute('aria-controls')
  182. expect(listboxId).toBeTruthy()
  183. const listbox = page.locator(`[id="${listboxId}"]`)
  184. await expect(listbox).toBeVisible()
  185. return listbox
  186. }
  187. const selectElementOption = async (page: Page, select: Locator, name: string | RegExp) => {
  188. const listbox = await openElementSelect(page, select)
  189. await listbox.getByRole('option', { name, exact: typeof name === 'string' }).click()
  190. }
  191. const expectElementSelectOptions = async (page: Page, select: Locator, expected: string[]) => {
  192. const listbox = await openElementSelect(page, select)
  193. const labels = (await listbox.getByRole('option').allTextContents())
  194. .map((label) => label.replace(/\s+/g, ' ').trim())
  195. expect(labels).toEqual(expected)
  196. await page.keyboard.press('Escape')
  197. }
  198. const expectElementSelectPlaceholder = async (select: Locator, placeholder: string) => {
  199. await expect(select.locator('.el-select__placeholder').first()).toContainText(placeholder)
  200. }
  201. test.describe('登录上下文 UI 契约', () => {
  202. test('恢复上次成功登录的部门和账号但不保存密码', async ({ page }, testInfo) => {
  203. await page.addInitScript(() => {
  204. window.localStorage.setItem('unreal-tran:web:login-identity-preference:v1', JSON.stringify({
  205. lastRoleCode: 'teacher',
  206. selections: {
  207. teacher: { departmentId: '110', userId: '402', username: '' },
  208. },
  209. }))
  210. })
  211. await openLoginWithContext(page)
  212. await expect(page.locator('[data-role-code="teacher"]')).toHaveAttribute('aria-pressed', 'true')
  213. await expect(page.getByTestId('login-organization')).toContainText('工程装备系')
  214. await expect(page.getByTestId('login-department')).toContainText('维修教研室')
  215. await expect(identitySelect(page)).toContainText('杜晴')
  216. await expect(passwordInput(page)).toHaveValue('')
  217. await expect(submitButton(page)).toBeDisabled()
  218. await captureScreenshot(page, testInfo, 'restored-login-identity-without-password')
  219. })
  220. test('已删除的部门或账号不会被本地偏好错误回填', async ({ page }) => {
  221. await page.addInitScript(() => {
  222. window.localStorage.setItem('unreal-tran:web:login-identity-preference:v1', JSON.stringify({
  223. lastRoleCode: 'teacher',
  224. selections: {
  225. teacher: { departmentId: '999', userId: '999', username: '' },
  226. },
  227. }))
  228. })
  229. await openLoginWithContext(page)
  230. await expect(page.locator('[data-role-code="teacher"]')).toHaveAttribute('aria-pressed', 'true')
  231. await expectElementSelectPlaceholder(page.getByTestId('login-organization'), '请选择教学系')
  232. await expect(page.getByTestId('login-department')).toBeDisabled()
  233. await expect(identitySelect(page)).toBeDisabled()
  234. await expect(passwordInput(page)).toHaveValue('')
  235. })
  236. test('默认管理员且四个身份展示各自约定字段', async ({ page }, testInfo) => {
  237. await openLoginWithContext(page)
  238. const roleButtons = page.locator('[data-role-code]')
  239. await expect(roleButtons).toHaveCount(4)
  240. expect(await roleButtons.evaluateAll((buttons) => buttons.map((button) => ({
  241. code: button.getAttribute('data-role-code'),
  242. label: button.textContent?.trim(),
  243. })))).toEqual([
  244. { code: 'administrative', label: '行政' },
  245. { code: 'teacher', label: '教员' },
  246. { code: 'student', label: '学员' },
  247. { code: 'admin', label: '管理员' },
  248. ])
  249. await expect(page.locator('[data-role-code="admin"]')).toHaveAttribute('aria-pressed', 'true')
  250. await expect(adminUsernameInput(page)).toHaveAttribute('placeholder', '请输入管理员账号')
  251. await expect(identitySelect(page)).toHaveCount(0)
  252. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  253. await expect(page.getByTestId('login-department')).toHaveCount(0)
  254. await page.locator('[data-role-code="administrative"]').click()
  255. await expect(adminUsernameInput(page)).toHaveCount(0)
  256. await expect(identitySelect(page)).toBeVisible()
  257. await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号')
  258. await expect(identitySelect(page)).toBeDisabled()
  259. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  260. await expect(page.getByTestId('login-department')).toBeVisible()
  261. await page.locator('[data-role-code="teacher"]').click()
  262. await expect(adminUsernameInput(page)).toHaveCount(0)
  263. await expect(identitySelect(page)).toBeVisible()
  264. await expectElementSelectPlaceholder(identitySelect(page), '请选择教员账号')
  265. await expect(identitySelect(page)).toBeDisabled()
  266. await expect(page.getByTestId('login-organization')).toBeVisible()
  267. await expect(page.getByTestId('login-department')).toBeVisible()
  268. await page.locator('[data-role-code="student"]').click()
  269. await expect(adminUsernameInput(page)).toHaveCount(0)
  270. await expect(identitySelect(page)).toBeVisible()
  271. await expectElementSelectPlaceholder(identitySelect(page), '请选择学员账号')
  272. await expect(identitySelect(page)).toBeEnabled()
  273. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  274. await expect(page.getByTestId('login-department')).toHaveCount(0)
  275. await captureScreenshot(page, testInfo, 'four-role-fields')
  276. })
  277. test('行政身份按 SINGLE 模式只选择一个教学系', async ({ page }, testInfo) => {
  278. await openLoginWithContext(page)
  279. await page.locator('[data-role-code="administrative"]').click()
  280. const department = page.getByTestId('login-department')
  281. await expect(department).toBeEnabled()
  282. await expectElementSelectPlaceholder(department, '请选择教学系')
  283. await expectElementSelectOptions(page, department, [
  284. '工程装备系',
  285. '指挥系',
  286. ])
  287. await expect(submitButton(page)).toBeDisabled()
  288. await selectElementOption(page, department, '工程装备系')
  289. await expect(identitySelect(page)).toBeEnabled()
  290. await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号')
  291. await selectElementOption(page, identitySelect(page), /秦主任.*工程装备系/)
  292. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  293. await expect(submitButton(page)).toBeEnabled()
  294. await captureScreenshot(page, testInfo, 'administrative-single')
  295. })
  296. test('教员身份按 CASCADE 模式支持教学系本级和直属教研室', async ({ page }, testInfo) => {
  297. await openLoginWithContext(page)
  298. await page.locator('[data-role-code="teacher"]').click()
  299. const organization = page.getByTestId('login-organization')
  300. const department = page.getByTestId('login-department')
  301. await expect(organization).toBeEnabled()
  302. await expect(department).toBeDisabled()
  303. await expect(identitySelect(page)).toBeDisabled()
  304. await expect(submitButton(page)).toBeDisabled()
  305. await selectElementOption(page, organization, '工程装备系')
  306. await expect(department).toBeEnabled()
  307. await expectElementSelectPlaceholder(department, '请选择教研室')
  308. await expectElementSelectOptions(page, department, [
  309. '工程装备系(本级)',
  310. '维修教研室',
  311. '智能保障教研室',
  312. ])
  313. await selectElementOption(page, department, '工程装备系(本级)')
  314. await expect(department).toContainText('工程装备系(本级)')
  315. await expect(identitySelect(page)).toBeEnabled()
  316. await selectElementOption(page, identitySelect(page), /许教员.*工程装备系/)
  317. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  318. await expect(submitButton(page)).toBeEnabled()
  319. await selectElementOption(page, department, '维修教研室')
  320. await expect(department).toContainText('维修教研室')
  321. await expect(passwordInput(page)).toHaveValue('')
  322. await expect(identitySelect(page)).toBeEnabled()
  323. const identityListbox = await openElementSelect(page, identitySelect(page))
  324. await expect(identityListbox.getByRole('option', { name: /马雅柔.*水电组A/ })).toBeVisible()
  325. await identityListbox.getByRole('option', { name: /马雅柔.*水电组A/ }).click()
  326. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  327. await expect(submitButton(page)).toBeEnabled()
  328. await captureScreenshot(page, testInfo, 'teacher-cascade-current-and-child')
  329. })
  330. test('切换身份会清空账号密码和已选组织', async ({ page }, testInfo) => {
  331. await openLoginWithContext(page)
  332. await page.locator('[data-role-code="teacher"]').click()
  333. await selectElementOption(page, page.getByTestId('login-organization'), '工程装备系')
  334. await selectElementOption(page, page.getByTestId('login-department'), '维修教研室')
  335. await selectElementOption(page, identitySelect(page), /马雅柔.*水电组A/)
  336. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  337. await page.locator('[data-role-code="administrative"]').click()
  338. await expect(adminUsernameInput(page)).toHaveCount(0)
  339. await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号')
  340. await expect(passwordInput(page)).toHaveValue('')
  341. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  342. await expectElementSelectPlaceholder(page.getByTestId('login-department'), '请选择教学系')
  343. await selectElementOption(page, page.getByTestId('login-department'), '指挥系')
  344. await selectElementOption(page, identitySelect(page), /周主任.*指挥系/)
  345. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  346. await page.locator('[data-role-code="teacher"]').click()
  347. await expect(adminUsernameInput(page)).toHaveCount(0)
  348. await expectElementSelectPlaceholder(identitySelect(page), '请选择教员账号')
  349. await expect(passwordInput(page)).toHaveValue('')
  350. await expectElementSelectPlaceholder(page.getByTestId('login-organization'), '请选择教学系')
  351. await expectElementSelectPlaceholder(page.getByTestId('login-department'), '请选择教研室')
  352. await expect(page.getByTestId('login-department')).toBeDisabled()
  353. await captureScreenshot(page, testInfo, 'role-switch-clears-sensitive-fields')
  354. })
  355. test('登录页只按当前身份和组织加载安全账号候选且不会预填密码', async ({ page }) => {
  356. await openLoginWithContext(page)
  357. await expect(adminUsernameInput(page)).toHaveValue('')
  358. await expect(passwordInput(page)).toHaveValue('')
  359. await expect(adminUsernameInput(page)).toHaveAttribute('autocomplete', 'username')
  360. await expect(passwordInput(page)).toHaveAttribute('autocomplete', 'current-password')
  361. await expect(page.locator('.login-password-toggle')).toBeVisible()
  362. await expect(page.locator('.login-password-toggle svg')).toHaveCount(1)
  363. await expect(page.locator('.login-password-toggle')).toHaveAttribute('aria-label', '显示密码')
  364. await expect(page.locator('[data-password-visibility-icon="hidden"]')).toBeVisible()
  365. await page.locator('.login-password-toggle').click()
  366. await expect(passwordInput(page)).toHaveAttribute('type', 'text')
  367. await expect(page.locator('.login-password-toggle')).toHaveAttribute('aria-label', '隐藏密码')
  368. await expect(page.locator('[data-password-visibility-icon="visible"]')).toBeVisible()
  369. await page.locator('.login-password-toggle').click()
  370. await expect(passwordInput(page)).toHaveAttribute('type', 'password')
  371. await expect(page.locator('[data-password-visibility-icon="hidden"]')).toBeVisible()
  372. expect(await adminUsernameInput(page).getAttribute('list')).toBeNull()
  373. await expect(page.locator('datalist')).toHaveCount(0)
  374. await expect(page.locator('select[name*="user" i], select[name*="account" i]')).toHaveCount(0)
  375. await page.locator('[data-role-code="administrative"]').click()
  376. await expect(adminUsernameInput(page)).toHaveCount(0)
  377. await expect(identitySelect(page)).toBeDisabled()
  378. await expect(passwordInput(page)).toHaveValue('')
  379. await page.locator('[data-role-code="teacher"]').click()
  380. await expect(adminUsernameInput(page)).toHaveCount(0)
  381. await expect(identitySelect(page)).toBeDisabled()
  382. await expect(passwordInput(page)).toHaveValue('')
  383. await page.locator('[data-role-code="student"]').click()
  384. await expect(adminUsernameInput(page)).toHaveCount(0)
  385. await expect(identitySelect(page)).toBeEnabled()
  386. const studentListbox = await openElementSelect(page, identitySelect(page))
  387. await expect(studentListbox.getByRole('option', { name: /学生甲.*水电组A/ })).toBeVisible()
  388. await page.keyboard.press('Escape')
  389. await expect(passwordInput(page)).toHaveValue('')
  390. await page.locator('[data-role-code="admin"]').click()
  391. await expect(identitySelect(page)).toHaveCount(0)
  392. await expect(adminUsernameInput(page)).toHaveValue('')
  393. await expect(passwordInput(page)).toHaveValue('')
  394. })
  395. test('登录上下文失败时管理员账号密码入口仍可使用', async ({ page }, testInfo) => {
  396. await mockLoginContextFailure(page)
  397. await page.goto('/login')
  398. const contextState = page.getByText('认证配置连接失败', { exact: true })
  399. await expect(contextState).toBeAttached()
  400. await expect(contextState).toHaveAttribute('aria-live', 'polite')
  401. await expect(contextState).toBeVisible()
  402. await expect(page.locator('[data-role-code="admin"]')).toHaveAttribute('aria-pressed', 'true')
  403. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  404. await expect(page.getByTestId('login-department')).toHaveCount(0)
  405. await adminUsernameInput(page).fill('admin.contract')
  406. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  407. await expect(submitButton(page)).toBeEnabled()
  408. await captureScreenshot(page, testInfo, 'context-failure-admin-available')
  409. })
  410. })
  411. for (const viewport of [
  412. { width: 375, height: 812 },
  413. { width: 1024, height: 768 },
  414. { width: 1440, height: 900 },
  415. ]) {
  416. test(`登录页在 ${viewport.width}px 宽度无横向滚动`, async ({ page }, testInfo) => {
  417. await page.setViewportSize(viewport)
  418. await openLoginWithContext(page)
  419. const geometry = await page.evaluate(() => {
  420. const card = document.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect()
  421. return {
  422. documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
  423. bodyOverflow: document.body.scrollWidth - document.body.clientWidth,
  424. cardLeft: card?.left ?? -1,
  425. cardRight: card?.right ?? Number.POSITIVE_INFINITY,
  426. viewportWidth: window.innerWidth,
  427. }
  428. })
  429. expect(geometry.documentOverflow).toBeLessThanOrEqual(1)
  430. expect(geometry.bodyOverflow).toBeLessThanOrEqual(1)
  431. expect(geometry.cardLeft).toBeGreaterThanOrEqual(0)
  432. expect(geometry.cardRight).toBeLessThanOrEqual(geometry.viewportWidth + 1)
  433. await captureScreenshot(page, testInfo, `login-responsive-${viewport.width}`)
  434. })
  435. }