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.
 
 
 
 

437 Zeilen
18 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 openLoginWithContext(page)
  204. const roleButtons = page.locator('[data-role-code]')
  205. await expect(roleButtons).toHaveCount(4)
  206. expect(await roleButtons.evaluateAll((buttons) => buttons.map((button) => ({
  207. code: button.getAttribute('data-role-code'),
  208. label: button.textContent?.trim(),
  209. })))).toEqual([
  210. { code: 'administrative', label: '行政' },
  211. { code: 'teacher', label: '教员' },
  212. { code: 'student', label: '学员' },
  213. { code: 'admin', label: '管理员' },
  214. ])
  215. await expect(page.locator('[data-role-code="admin"]')).toHaveAttribute('aria-pressed', 'true')
  216. await expect(adminUsernameInput(page)).toHaveAttribute('placeholder', '请输入管理员账号')
  217. await expect(identitySelect(page)).toHaveCount(0)
  218. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  219. await expect(page.getByTestId('login-department')).toHaveCount(0)
  220. await page.locator('[data-role-code="administrative"]').click()
  221. await expect(adminUsernameInput(page)).toHaveCount(0)
  222. await expect(identitySelect(page)).toBeVisible()
  223. await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号')
  224. await expect(identitySelect(page)).toBeDisabled()
  225. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  226. await expect(page.getByTestId('login-department')).toBeVisible()
  227. await page.locator('[data-role-code="teacher"]').click()
  228. await expect(adminUsernameInput(page)).toHaveCount(0)
  229. await expect(identitySelect(page)).toBeVisible()
  230. await expectElementSelectPlaceholder(identitySelect(page), '请选择教员账号')
  231. await expect(identitySelect(page)).toBeDisabled()
  232. await expect(page.getByTestId('login-organization')).toBeVisible()
  233. await expect(page.getByTestId('login-department')).toBeVisible()
  234. await page.locator('[data-role-code="student"]').click()
  235. await expect(adminUsernameInput(page)).toHaveCount(0)
  236. await expect(identitySelect(page)).toBeVisible()
  237. await expectElementSelectPlaceholder(identitySelect(page), '请选择学员账号')
  238. await expect(identitySelect(page)).toBeEnabled()
  239. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  240. await expect(page.getByTestId('login-department')).toHaveCount(0)
  241. await captureScreenshot(page, testInfo, 'four-role-fields')
  242. })
  243. test('行政身份按 SINGLE 模式只选择一个教学系', async ({ page }, testInfo) => {
  244. await openLoginWithContext(page)
  245. await page.locator('[data-role-code="administrative"]').click()
  246. const department = page.getByTestId('login-department')
  247. await expect(department).toBeEnabled()
  248. await expectElementSelectPlaceholder(department, '请选择教学系')
  249. await expectElementSelectOptions(page, department, [
  250. '工程装备系',
  251. '指挥系',
  252. ])
  253. await expect(submitButton(page)).toBeDisabled()
  254. await selectElementOption(page, department, '工程装备系')
  255. await expect(identitySelect(page)).toBeEnabled()
  256. await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号')
  257. await selectElementOption(page, identitySelect(page), /秦主任.*工程装备系/)
  258. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  259. await expect(submitButton(page)).toBeEnabled()
  260. await captureScreenshot(page, testInfo, 'administrative-single')
  261. })
  262. test('教员身份按 CASCADE 模式支持教学系本级和直属教研室', async ({ page }, testInfo) => {
  263. await openLoginWithContext(page)
  264. await page.locator('[data-role-code="teacher"]').click()
  265. const organization = page.getByTestId('login-organization')
  266. const department = page.getByTestId('login-department')
  267. await expect(organization).toBeEnabled()
  268. await expect(department).toBeDisabled()
  269. await expect(identitySelect(page)).toBeDisabled()
  270. await expect(submitButton(page)).toBeDisabled()
  271. await selectElementOption(page, organization, '工程装备系')
  272. await expect(department).toBeEnabled()
  273. await expectElementSelectPlaceholder(department, '请选择教研室')
  274. await expectElementSelectOptions(page, department, [
  275. '工程装备系(本级)',
  276. '维修教研室',
  277. '智能保障教研室',
  278. ])
  279. await selectElementOption(page, department, '工程装备系(本级)')
  280. await expect(department).toContainText('工程装备系(本级)')
  281. await expect(identitySelect(page)).toBeEnabled()
  282. await selectElementOption(page, identitySelect(page), /许教员.*工程装备系/)
  283. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  284. await expect(submitButton(page)).toBeEnabled()
  285. await selectElementOption(page, department, '维修教研室')
  286. await expect(department).toContainText('维修教研室')
  287. await expect(passwordInput(page)).toHaveValue('')
  288. await expect(identitySelect(page)).toBeEnabled()
  289. const identityListbox = await openElementSelect(page, identitySelect(page))
  290. await expect(identityListbox.getByRole('option', { name: /马雅柔.*水电组A/ })).toBeVisible()
  291. await identityListbox.getByRole('option', { name: /马雅柔.*水电组A/ }).click()
  292. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  293. await expect(submitButton(page)).toBeEnabled()
  294. await captureScreenshot(page, testInfo, 'teacher-cascade-current-and-child')
  295. })
  296. test('切换身份会清空账号密码和已选组织', async ({ page }, testInfo) => {
  297. await openLoginWithContext(page)
  298. await page.locator('[data-role-code="teacher"]').click()
  299. await selectElementOption(page, page.getByTestId('login-organization'), '工程装备系')
  300. await selectElementOption(page, page.getByTestId('login-department'), '维修教研室')
  301. await selectElementOption(page, identitySelect(page), /马雅柔.*水电组A/)
  302. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  303. await page.locator('[data-role-code="administrative"]').click()
  304. await expect(adminUsernameInput(page)).toHaveCount(0)
  305. await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号')
  306. await expect(passwordInput(page)).toHaveValue('')
  307. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  308. await expectElementSelectPlaceholder(page.getByTestId('login-department'), '请选择教学系')
  309. await selectElementOption(page, page.getByTestId('login-department'), '指挥系')
  310. await selectElementOption(page, identitySelect(page), /周主任.*指挥系/)
  311. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  312. await page.locator('[data-role-code="teacher"]').click()
  313. await expect(adminUsernameInput(page)).toHaveCount(0)
  314. await expectElementSelectPlaceholder(identitySelect(page), '请选择教员账号')
  315. await expect(passwordInput(page)).toHaveValue('')
  316. await expectElementSelectPlaceholder(page.getByTestId('login-organization'), '请选择教学系')
  317. await expectElementSelectPlaceholder(page.getByTestId('login-department'), '请选择教研室')
  318. await expect(page.getByTestId('login-department')).toBeDisabled()
  319. await captureScreenshot(page, testInfo, 'role-switch-clears-sensitive-fields')
  320. })
  321. test('登录页只按当前身份和组织加载安全账号候选且不会预填密码', async ({ page }) => {
  322. await openLoginWithContext(page)
  323. await expect(adminUsernameInput(page)).toHaveValue('')
  324. await expect(passwordInput(page)).toHaveValue('')
  325. await expect(adminUsernameInput(page)).toHaveAttribute('autocomplete', 'username')
  326. await expect(passwordInput(page)).toHaveAttribute('autocomplete', 'current-password')
  327. await expect(page.locator('.login-password-toggle')).toBeVisible()
  328. await expect(page.locator('.login-password-toggle svg')).toHaveCount(1)
  329. await expect(page.locator('.login-password-toggle')).toHaveAttribute('aria-label', '显示密码')
  330. await page.locator('.login-password-toggle').click()
  331. await expect(passwordInput(page)).toHaveAttribute('type', 'text')
  332. await expect(page.locator('.login-password-toggle')).toHaveAttribute('aria-label', '隐藏密码')
  333. await page.locator('.login-password-toggle').click()
  334. await expect(passwordInput(page)).toHaveAttribute('type', 'password')
  335. expect(await adminUsernameInput(page).getAttribute('list')).toBeNull()
  336. await expect(page.locator('datalist')).toHaveCount(0)
  337. await expect(page.locator('select[name*="user" i], select[name*="account" i]')).toHaveCount(0)
  338. await page.locator('[data-role-code="administrative"]').click()
  339. await expect(adminUsernameInput(page)).toHaveCount(0)
  340. await expect(identitySelect(page)).toBeDisabled()
  341. await expect(passwordInput(page)).toHaveValue('')
  342. await page.locator('[data-role-code="teacher"]').click()
  343. await expect(adminUsernameInput(page)).toHaveCount(0)
  344. await expect(identitySelect(page)).toBeDisabled()
  345. await expect(passwordInput(page)).toHaveValue('')
  346. await page.locator('[data-role-code="student"]').click()
  347. await expect(adminUsernameInput(page)).toHaveCount(0)
  348. await expect(identitySelect(page)).toBeEnabled()
  349. const studentListbox = await openElementSelect(page, identitySelect(page))
  350. await expect(studentListbox.getByRole('option', { name: /学生甲.*水电组A/ })).toBeVisible()
  351. await page.keyboard.press('Escape')
  352. await expect(passwordInput(page)).toHaveValue('')
  353. await page.locator('[data-role-code="admin"]').click()
  354. await expect(identitySelect(page)).toHaveCount(0)
  355. await expect(adminUsernameInput(page)).toHaveValue('')
  356. await expect(passwordInput(page)).toHaveValue('')
  357. })
  358. test('登录上下文失败时管理员账号密码入口仍可使用', async ({ page }, testInfo) => {
  359. await mockLoginContextFailure(page)
  360. await page.goto('/login')
  361. const contextState = page.getByText('认证配置连接失败', { exact: true })
  362. await expect(contextState).toBeAttached()
  363. await expect(contextState).toHaveAttribute('aria-live', 'polite')
  364. await expect(contextState).toBeVisible()
  365. await expect(page.locator('[data-role-code="admin"]')).toHaveAttribute('aria-pressed', 'true')
  366. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  367. await expect(page.getByTestId('login-department')).toHaveCount(0)
  368. await adminUsernameInput(page).fill('admin.contract')
  369. await passwordInput(page).fill('ContractOnly-NotSubmitted')
  370. await expect(submitButton(page)).toBeEnabled()
  371. await captureScreenshot(page, testInfo, 'context-failure-admin-available')
  372. })
  373. })
  374. for (const viewport of [
  375. { width: 375, height: 812 },
  376. { width: 1024, height: 768 },
  377. { width: 1440, height: 900 },
  378. ]) {
  379. test(`登录页在 ${viewport.width}px 宽度无横向滚动`, async ({ page }, testInfo) => {
  380. await page.setViewportSize(viewport)
  381. await openLoginWithContext(page)
  382. const geometry = await page.evaluate(() => {
  383. const card = document.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect()
  384. return {
  385. documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
  386. bodyOverflow: document.body.scrollWidth - document.body.clientWidth,
  387. cardLeft: card?.left ?? -1,
  388. cardRight: card?.right ?? Number.POSITIVE_INFINITY,
  389. viewportWidth: window.innerWidth,
  390. }
  391. })
  392. expect(geometry.documentOverflow).toBeLessThanOrEqual(1)
  393. expect(geometry.bodyOverflow).toBeLessThanOrEqual(1)
  394. expect(geometry.cardLeft).toBeGreaterThanOrEqual(0)
  395. expect(geometry.cardRight).toBeLessThanOrEqual(geometry.viewportWidth + 1)
  396. await captureScreenshot(page, testInfo, `login-responsive-${viewport.width}`)
  397. })
  398. }