您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

317 行
13 KiB

  1. import type { 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. ],
  50. },
  51. {
  52. id: '200',
  53. code: 'COMMAND',
  54. name: '指挥系',
  55. parentId: null,
  56. children: [],
  57. },
  58. ],
  59. } as const
  60. const loginIdentities = {
  61. administrative: [
  62. { id: '301', displayName: '秦主任', departmentId: '100', departmentName: '工程装备系' },
  63. ],
  64. teacher: [
  65. { id: '401', displayName: '许教员', departmentId: '100', departmentName: '工程装备系' },
  66. { id: '402', displayName: '杜晨', departmentId: '110', departmentName: '维修教研室' },
  67. ],
  68. student: [
  69. { id: '501', displayName: '学生甲', departmentId: '110', departmentName: '维修教研室' },
  70. ],
  71. } as const
  72. const envelope = (data: unknown, requestId: string) => JSON.stringify({
  73. code: 200,
  74. message: '成功',
  75. data,
  76. timestamp: '2026-08-17T00:00:00+08:00',
  77. requestId,
  78. })
  79. interface RuntimeIssues {
  80. consoleErrors: string[]
  81. pageErrors: string[]
  82. }
  83. const watchRuntimeIssues = (page: Page): RuntimeIssues => {
  84. const issues: RuntimeIssues = { consoleErrors: [], pageErrors: [] }
  85. page.on('console', (message) => {
  86. if (message.type() === 'error') issues.consoleErrors.push(message.text())
  87. })
  88. page.on('pageerror', (error) => issues.pageErrors.push(error.message))
  89. return issues
  90. }
  91. const mockLoginPage = async (page: Page) => {
  92. await page.route('**/api/auth/v1/system-config/public', (route) => route.fulfill({
  93. status: 200,
  94. contentType: 'application/json',
  95. body: envelope({
  96. systemName: '某类装备维修实训数字车间',
  97. logoConfigured: false,
  98. logoUrl: null,
  99. faviconConfigured: false,
  100. faviconUrl: null,
  101. }, 'login-visual-public-config'),
  102. }))
  103. await page.route('**/api/auth/v1/auth/login-context', (route) => route.fulfill({
  104. status: 200,
  105. contentType: 'application/json',
  106. body: envelope(loginContext, 'login-visual-context'),
  107. }))
  108. await page.route('**/api/auth/v1/auth/login-identities**', (route) => {
  109. const url = new URL(route.request().url())
  110. const roleCode = url.searchParams.get('roleCode')
  111. const departmentId = url.searchParams.get('departmentId')
  112. const source = roleCode && roleCode in loginIdentities
  113. ? loginIdentities[roleCode as keyof typeof loginIdentities]
  114. : []
  115. const identities = source.filter((identity) => !departmentId || identity.departmentId === departmentId)
  116. return route.fulfill({
  117. status: 200,
  118. contentType: 'application/json',
  119. body: envelope(identities, 'login-visual-identities'),
  120. })
  121. })
  122. }
  123. const openLoginPage = async (page: Page) => {
  124. await mockLoginPage(page)
  125. await page.goto('/login')
  126. const contextState = page.locator('.login-context-state')
  127. await expect(contextState).toHaveText('身份认证服务正常')
  128. await expect(contextState).toHaveAttribute('aria-live', 'polite')
  129. await expect(contextState).toHaveCSS('position', 'absolute')
  130. await expect(contextState).toHaveCSS('clip-path', 'inset(50%)')
  131. }
  132. const expectNoHorizontalOverflow = async (page: Page) => {
  133. const geometry = await page.evaluate(() => {
  134. const card = document.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect()
  135. return {
  136. documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
  137. bodyOverflow: document.body.scrollWidth - document.body.clientWidth,
  138. cardLeft: card?.left ?? -1,
  139. cardRight: card?.right ?? Number.POSITIVE_INFINITY,
  140. viewportWidth: window.innerWidth,
  141. }
  142. })
  143. expect(geometry.documentOverflow).toBeLessThanOrEqual(1)
  144. expect(geometry.bodyOverflow).toBeLessThanOrEqual(1)
  145. expect(geometry.cardLeft).toBeGreaterThanOrEqual(0)
  146. expect(geometry.cardRight).toBeLessThanOrEqual(geometry.viewportWidth + 1)
  147. }
  148. const expectNoRuntimeIssues = (issues: RuntimeIssues) => {
  149. expect(issues.consoleErrors, `console.error: ${issues.consoleErrors.join('\n')}`).toEqual([])
  150. expect(issues.pageErrors, `pageerror: ${issues.pageErrors.join('\n')}`).toEqual([])
  151. }
  152. test.describe('新原型登录页视觉与安全交互回归', () => {
  153. test('1920x945 展示新背景、校标、450x500 卡片且 SSO 只提示未接入', async ({ page }, testInfo) => {
  154. await page.setViewportSize({ width: 1920, height: 945 })
  155. const issues = watchRuntimeIssues(page)
  156. let loginRequestCount = 0
  157. page.on('request', (request) => {
  158. const url = new URL(request.url())
  159. if (request.method() === 'POST' && url.pathname === '/api/auth/v1/auth/login') loginRequestCount += 1
  160. })
  161. await openLoginPage(page)
  162. await expect(page.locator('.login-page')).toBeVisible()
  163. await expect(page.locator('.login-visual')).toBeVisible()
  164. await expect(page.locator('.login-copy')).toContainText('装备数字车间')
  165. await expect(page.locator('.login-copy')).toContainText('统一承载内容制作、虚实训练、训练考核与资源管理。')
  166. await expect(page.locator('.login-flow')).toContainText('01身份选择')
  167. await expect(page.locator('.login-flow')).toContainText('02账号认证')
  168. await expect(page.locator('.login-flow')).toContainText('03进入系统')
  169. const pageBackground = await page.locator('.login-page').evaluate((element) => getComputedStyle(element).backgroundImage)
  170. expect(pageBackground).toContain('login_bg.jpg')
  171. const backgroundResponse = await page.request.get(new URL('/login_bg.jpg', page.url()).toString())
  172. expect(backgroundResponse.status()).toBe(200)
  173. expect(backgroundResponse.headers()['content-type']).toContain('image/jpeg')
  174. expect((await backgroundResponse.body()).byteLength).toBeGreaterThan(500_000)
  175. const logo = page.locator('.login-logo')
  176. await expect(logo).toHaveAttribute('src', '/logo-title.png')
  177. await expect(logo).toBeVisible()
  178. const logoGeometry = await logo.evaluate((element: HTMLImageElement) => ({
  179. complete: element.complete,
  180. naturalWidth: element.naturalWidth,
  181. naturalHeight: element.naturalHeight,
  182. }))
  183. expect(logoGeometry).toEqual({ complete: true, naturalWidth: 342, naturalHeight: 65 })
  184. const logoResponse = await page.request.get(new URL('/logo-title.png', page.url()).toString())
  185. expect(logoResponse.status()).toBe(200)
  186. expect(logoResponse.headers()['content-type']).toContain('image/png')
  187. expect((await logoResponse.body()).byteLength).toBeGreaterThan(30_000)
  188. const cardGeometry = await page.locator('.login-card').evaluate((element) => {
  189. const box = element.getBoundingClientRect()
  190. return { width: box.width, height: box.height }
  191. })
  192. expect(Math.abs(cardGeometry.width - 450)).toBeLessThanOrEqual(1)
  193. expect(Math.abs(cardGeometry.height - 500)).toBeLessThanOrEqual(1)
  194. await expect(page.locator('[data-role-code]')).toHaveCount(4)
  195. await expect(page.getByText('其他登录方式', { exact: true })).toBeVisible()
  196. const ssoButton = page.getByRole('button', { name: '统一身份认证(SSO)' })
  197. await expect(ssoButton).toBeVisible()
  198. const username = page.locator('input[name="username"]')
  199. const password = page.locator('input[name="password"]')
  200. const remember = page.locator('input[name="rememberMe"]')
  201. await username.fill('admin.visual-contract')
  202. await password.fill('VisualOnly-NotSubmitted!')
  203. await remember.check()
  204. const urlBeforeSso = page.url()
  205. const pageCountBeforeSso = page.context().pages().length
  206. await ssoButton.click()
  207. await expect(page.getByText('统一身份认证暂未接入,请先使用账号密码登录', { exact: true })).toBeVisible()
  208. expect(page.url()).toBe(urlBeforeSso)
  209. expect(page.context().pages()).toHaveLength(pageCountBeforeSso)
  210. expect(loginRequestCount).toBe(0)
  211. await expect(page.locator('[data-role-code="admin"]')).toHaveAttribute('aria-pressed', 'true')
  212. await expect(username).toHaveValue('admin.visual-contract')
  213. await expect(password).toHaveValue('VisualOnly-NotSubmitted!')
  214. await expect(remember).toBeChecked()
  215. await captureScreenshot(page, testInfo, 'login-prototype-desktop-1920x945')
  216. expectNoRuntimeIssues(issues)
  217. })
  218. test('390x844 无横向溢出且四身份、组织字段与全部登录控件可滚动操作', async ({ page }, testInfo) => {
  219. await page.setViewportSize({ width: 390, height: 844 })
  220. const issues = watchRuntimeIssues(page)
  221. await openLoginPage(page)
  222. await expect(page.locator('.login-visual')).toBeHidden()
  223. await expectNoHorizontalOverflow(page)
  224. const roleButtons = page.locator('[data-role-code]')
  225. await expect(roleButtons).toHaveCount(4)
  226. expect(await roleButtons.evaluateAll((buttons) => buttons.map((button) => ({
  227. code: button.getAttribute('data-role-code'),
  228. label: button.textContent?.trim(),
  229. })))).toEqual([
  230. { code: 'administrative', label: '行政' },
  231. { code: 'teacher', label: '教员' },
  232. { code: 'student', label: '学员' },
  233. { code: 'admin', label: '管理员' },
  234. ])
  235. await page.locator('[data-role-code="administrative"]').scrollIntoViewIfNeeded()
  236. await page.locator('[data-role-code="administrative"]').click()
  237. await expect(page.getByTestId('login-department')).toBeVisible()
  238. await expect(page.getByTestId('login-department')).toBeEnabled()
  239. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  240. await expect(page.getByTestId('login-identity')).toBeVisible()
  241. await expect(page.getByTestId('login-identity')).toBeDisabled()
  242. await page.locator('[data-role-code="teacher"]').click()
  243. await expect(page.getByTestId('login-organization')).toBeVisible()
  244. await expect(page.getByTestId('login-organization')).toBeEnabled()
  245. await expect(page.getByTestId('login-department')).toBeVisible()
  246. await expect(page.getByTestId('login-department')).toBeDisabled()
  247. await expect(page.getByTestId('login-identity')).toBeVisible()
  248. await expect(page.getByTestId('login-identity')).toBeDisabled()
  249. await page.locator('[data-role-code="student"]').click()
  250. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  251. await expect(page.getByTestId('login-department')).toHaveCount(0)
  252. await expect(page.getByTestId('login-identity')).toBeVisible()
  253. await expect(page.getByTestId('login-identity')).toBeEnabled()
  254. await page.locator('[data-role-code="admin"]').click()
  255. await expect(page.getByTestId('login-identity')).toHaveCount(0)
  256. await expect(page.getByTestId('login-organization')).toHaveCount(0)
  257. await expect(page.getByTestId('login-department')).toHaveCount(0)
  258. const username = page.locator('input[name="username"]')
  259. const password = page.locator('input[name="password"]')
  260. const passwordToggle = page.locator('.login-password-toggle')
  261. const remember = page.locator('input[name="rememberMe"]')
  262. const submit = page.getByRole('button', { name: '登录系统' })
  263. const sso = page.getByRole('button', { name: '统一身份认证(SSO)' })
  264. for (const control of [username, password, passwordToggle, remember, submit, sso]) {
  265. await control.scrollIntoViewIfNeeded()
  266. await expect(control).toBeVisible()
  267. }
  268. await username.fill('admin.mobile-contract')
  269. await password.fill('MobileOnly-NotSubmitted!')
  270. await passwordToggle.click()
  271. await expect(password).toHaveAttribute('type', 'text')
  272. await remember.check()
  273. await expect(submit).toBeEnabled()
  274. await expectNoHorizontalOverflow(page)
  275. const visibleControlBounds = await page.locator('.login-card button:visible, .login-card input:visible').evaluateAll((controls) => controls.map((control) => {
  276. const box = control.getBoundingClientRect()
  277. return { left: box.left, right: box.right }
  278. }))
  279. for (const bounds of visibleControlBounds) {
  280. expect(bounds.left).toBeGreaterThanOrEqual(0)
  281. expect(bounds.right).toBeLessThanOrEqual(391)
  282. }
  283. await sso.scrollIntoViewIfNeeded()
  284. await captureScreenshot(page, testInfo, 'login-prototype-mobile-390x844')
  285. expectNoRuntimeIssues(issues)
  286. })
  287. })