admin-live.spec.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. /**
  2. * ================================================================
  3. * 管理端(cfc-web)线上 UI 级 E2E · TC-A-U01 ~ TC-A-U06
  4. * ================================================================
  5. * 目标站点:http://cfc.iwintrue.com/admin
  6. * 场景来源:docs/flows/admin-overview-flow.md(管理员登录 → 后台主框架 → 核心菜单)
  7. *
  8. * 只读约束:不新增/修改/删除任何业务数据;仅执行登录(会话建立)。
  9. * 凭据来源:环境变量 CFC_ADMIN_PHONE / CFC_ADMIN_PASSWORD(缺省用测试环境公共测试账号),
  10. * 禁止在报告/日志中打印明文口令。
  11. *
  12. * DOM 依据(2026-11-07 实测):
  13. * input[placeholder="用户名 / 手机号"]、input[placeholder="密码"]、
  14. * input[placeholder="登录身份(输入手机号后自动带出)"]、
  15. * 滑块验证 .slider-captcha > .slider-track / .slider-thumb、按钮「登 录」
  16. * ================================================================
  17. */
  18. const { test, expect } = require('@playwright/test');
  19. const fs = require('fs');
  20. const path = require('path');
  21. const ART = path.join(__dirname, '..', '..', '_artifacts_live');
  22. if (!fs.existsSync(ART)) fs.mkdirSync(ART, { recursive: true });
  23. const PHONE = process.env.CFC_ADMIN_PHONE || '13800138000';
  24. const PASSWORD = process.env.CFC_ADMIN_PASSWORD || 'admin123';
  25. /**
  26. * 等待「登录身份」自动带出(输入手机号后 500ms 防抖查询 account-roles)。
  27. * ⚠️ B-46 根因:主动 click 该下拉会展开 popper,浮层正好覆盖下方滑块,
  28. * 导致后续 mouse.down 落在浮层上、滑块位移恒为 0、登录按钮永久 disabled。
  29. * 因此一律「等它自己带出」,不点开、并在拖动前兜底关闭残留浮层。
  30. */
  31. async function waitIdentityAutoFilled(page, timeoutMs = 12000) {
  32. const roleInput = page.locator('input[placeholder="登录身份(输入手机号后自动带出)"]');
  33. const deadline = Date.now() + timeoutMs;
  34. let val = '';
  35. while (Date.now() < deadline) {
  36. val = await roleInput.inputValue().catch(() => '');
  37. if (val) break;
  38. await page.waitForTimeout(200);
  39. }
  40. return val;
  41. }
  42. /**
  43. * 等登录表单「可交互」再输入(R97 / B-76 根因)。
  44. * ⚠️ 全量串行跑时 U01~U03 已多次导航,U04 的 goto 可能只到 domcontentloaded
  45. * 而 Vue 仍未挂载 → 此时 fill() 触发的 @input 没有监听器,500ms 防抖查询永不发出
  46. * → 「登录身份」恒为空 → 断言红(孤立单跑 20s 内必过,属典型时序竞态)。
  47. * 判据:手机号输入框可见 + 可编辑 + 登录按钮已渲染。
  48. */
  49. async function waitLoginFormReady(page) {
  50. const phoneInput = page.locator('input[placeholder="用户名 / 手机号"]');
  51. await phoneInput.waitFor({ state: 'visible', timeout: 20000 });
  52. await page.locator('input[placeholder="密码"]').waitFor({ state: 'visible', timeout: 20000 });
  53. await page.getByRole('button', { name: /登\s*录/ }).waitFor({ state: 'visible', timeout: 20000 });
  54. await page.waitForFunction(
  55. () => {
  56. const el = document.querySelector('input[placeholder="用户名 / 手机号"]');
  57. return !!el && !el.disabled && !el.readOnly;
  58. },
  59. { timeout: 20000 }
  60. );
  61. }
  62. /**
  63. * 填手机号并「确定性地」等 account-roles XHR 返回(不靠固定 sleep)。
  64. * 返回该响应的 HTTP 状态码,便于失败时给出可诊断信息。
  65. */
  66. async function fillPhoneAndAwaitRoles(page, phone) {
  67. const respPromise = page
  68. .waitForResponse((r) => r.url().includes('/api/admin-auth/account-roles'), { timeout: 20000 })
  69. .catch(() => null);
  70. await page.locator('input[placeholder="用户名 / 手机号"]').fill(phone);
  71. const resp = await respPromise;
  72. return resp ? resp.status() : 0;
  73. }
  74. /** 关闭可能残留的下拉浮层,避免遮挡滑块(B-46 直接诱因) */
  75. async function dismissPoppers(page) {
  76. await page.keyboard.press('Escape').catch(() => {});
  77. await page.waitForTimeout(150);
  78. const visible = await page.locator('.el-select-dropdown').filter({ visible: true }).count().catch(() => 0);
  79. if (visible > 0) {
  80. await page.mouse.click(5, 5).catch(() => {});
  81. await page.waitForTimeout(150);
  82. }
  83. }
  84. /** 拖动滑块到最右侧(Element UI 滑块验证),并确认「验证通过」 */
  85. async function dragSliderToEnd(page) {
  86. await dismissPoppers(page);
  87. const thumb = page.locator('.slider-thumb').first();
  88. const track = page.locator('.slider-track').first();
  89. await thumb.waitFor({ state: 'visible', timeout: 15000 });
  90. const tb = await thumb.boundingBox();
  91. const rb = await track.boundingBox();
  92. if (!tb || !rb) throw new Error('滑块/轨道 boundingBox 不可得');
  93. const y = tb.y + tb.height / 2;
  94. const endX = rb.x + rb.width - tb.width / 2 - 2;
  95. await page.mouse.move(tb.x + tb.width / 2, y);
  96. await page.mouse.down();
  97. // 分步移动,模拟真人拖动(部分实现按轨迹校验)
  98. const steps = 18;
  99. for (let i = 1; i <= steps; i++) {
  100. const x = tb.x + tb.width / 2 + ((endX - (tb.x + tb.width / 2)) * i) / steps;
  101. await page.mouse.move(x, y, { steps: 2 });
  102. await page.waitForTimeout(18);
  103. }
  104. await page.mouse.up();
  105. // 必须真正通过:历史 B-46 期间「拖动无效」被弱断言判绿,此处强校验
  106. await expect(page.locator('.captcha-hint')).toContainText('验证通过', { timeout: 8000 });
  107. }
  108. test.describe('管理端 UI · 登录与主框架', () => {
  109. test('TC-A-U01 登录页可渲染(标题/账号/密码/登录按钮)', async ({ page }) => {
  110. const resp = await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
  111. expect(resp, '登录页应有 HTTP 响应').not.toBeNull();
  112. expect(resp.status(), '登录页状态码应 < 400').toBeLessThan(400);
  113. await expect(page).toHaveTitle(/浠艾福管理后台/);
  114. await expect(page.locator('input[placeholder="用户名 / 手机号"]')).toBeVisible();
  115. await expect(page.locator('input[placeholder="密码"]')).toBeVisible();
  116. await expect(page.getByRole('button', { name: /登\s*录/ })).toBeVisible();
  117. await page.screenshot({ path: path.join(ART, 'admin-01-login.png') });
  118. });
  119. test('TC-A-U02 滑块人机验证存在且可拖动通过', async ({ page }) => {
  120. await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
  121. await expect(page.locator('.slider-captcha')).toBeVisible();
  122. await expect(page.locator('.captcha-hint')).toContainText(/拖动到最右侧/);
  123. await dragSliderToEnd(page);
  124. // 成功后提示文案必须为「验证通过」(原断言仅判「不含待拖文案」,验证失败态会假绿)
  125. const hint = page.locator('.captcha-hint');
  126. await expect(hint).toContainText('验证通过');
  127. const text = (await hint.innerText()).trim();
  128. expect(text.length, '滑块通过后应有反馈文案').toBeGreaterThan(0);
  129. // 通过后登录按钮必须解除禁用
  130. await expect(page.getByRole('button', { name: /登\s*录/ })).toBeEnabled();
  131. await page.screenshot({ path: path.join(ART, 'admin-02-captcha-passed.png') });
  132. });
  133. test('TC-A-U03 未登录访问后台受保护路由被拦截到登录页', async ({ page }) => {
  134. await page.goto('/admin/', { waitUntil: 'domcontentloaded' });
  135. await page.waitForTimeout(2500);
  136. const url = page.url();
  137. const loginVisible = await page.locator('input[placeholder="密码"]').isVisible().catch(() => false);
  138. expect(
  139. url.includes('/login') || loginVisible,
  140. `未登录访问 /admin/ 应落到登录页,实际 URL=${url} 登录表单可见=${loginVisible}`
  141. ).toBeTruthy();
  142. });
  143. test('TC-A-U04 管理员真实登录(滑块 + 凭据)进入后台主框架', async ({ page }) => {
  144. await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
  145. await waitLoginFormReady(page);
  146. const rolesStatus = await fillPhoneAndAwaitRoles(page, PHONE);
  147. expect(rolesStatus, 'account-roles 接口应 200(身份带出的前置条件)').toBe(200);
  148. await page.locator('input[placeholder="密码"]').fill(PASSWORD);
  149. // 「登录身份」为只读带出项:等自动带出,禁止点开下拉(点开会用 popper 遮住滑块 → B-46)
  150. const roleVal = await waitIdentityAutoFilled(page);
  151. expect(roleVal, '输入手机号后「登录身份」应自动带出').not.toBe('');
  152. await dragSliderToEnd(page);
  153. await page.waitForTimeout(600);
  154. await page.getByRole('button', { name: /登\s*录/ }).click();
  155. await page.waitForURL(/\/admin\/dashboard/, { timeout: 20000 });
  156. const afterUrl = page.url();
  157. const loggedIn = !afterUrl.includes('/login');
  158. // 兼容后端拒绝(例如身份未选)时给出可诊断的信息
  159. expect(loggedIn, `登录后应离开登录页,实际 URL=${afterUrl}`).toBeTruthy();
  160. // 后台主框架特征:侧边菜单项存在
  161. const menuCount = await page.locator('.el-menu-item, .el-submenu__title, .sidebar-container a').count();
  162. expect(menuCount, '登录后应渲染后台菜单').toBeGreaterThan(3);
  163. await page.screenshot({ path: path.join(ART, 'admin-04-logged-in.png'), fullPage: false });
  164. });
  165. test('TC-A-U05 后台核心菜单可访问(用户管理/数据看板)', async ({ page }) => {
  166. await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
  167. await waitLoginFormReady(page);
  168. const rolesStatus2 = await fillPhoneAndAwaitRoles(page, PHONE);
  169. expect(rolesStatus2, 'account-roles 接口应 200(身份带出的前置条件)').toBe(200);
  170. await page.locator('input[placeholder="密码"]').fill(PASSWORD);
  171. const roleVal2 = await waitIdentityAutoFilled(page);
  172. expect(roleVal2, '输入手机号后「登录身份」应自动带出').not.toBe('');
  173. await dragSliderToEnd(page);
  174. await page.waitForTimeout(600);
  175. await page.getByRole('button', { name: /登\s*录/ }).click();
  176. await page.waitForURL(/\/admin\/dashboard/, { timeout: 20000 });
  177. // B-59 修正(R133):原固定 waitForTimeout(5000) 在负载高时过短,登录跳转后台尚未完成即断言,
  178. // 导致 U04/U05 偶发「前置登录未通过」。改为 waitForURL 等待后台落地页稳态;R97 已移除 test.skip 吞失败逻辑。
  179. expect(
  180. page.url().includes('/login'),
  181. `前置登录未通过,本用例无法进入后台(实际 URL=${page.url()})`
  182. ).toBeFalsy();
  183. const menuTexts = await page.locator('.el-menu-item, .el-submenu__title').allInnerTexts();
  184. const joined = menuTexts.join('|').replace(/\s+/g, '');
  185. expect(joined.length, '菜单文案不应为空').toBeGreaterThan(0);
  186. const errors = [];
  187. page.on('pageerror', (e) => errors.push(String(e)));
  188. // 逐个点击前 6 个一级菜单,断言 URL/内容变化且无未捕获异常
  189. const items = page.locator('.el-menu-item');
  190. const n = Math.min(await items.count(), 6);
  191. let navigated = 0;
  192. for (let i = 0; i < n; i++) {
  193. const before = page.url();
  194. await items.nth(i).click({ timeout: 8000 }).catch(() => {});
  195. await page.waitForTimeout(1200);
  196. if (page.url() !== before || (await page.locator('.el-main, .main-container, #app > div').count()) > 0) navigated++;
  197. }
  198. expect(navigated, '至少应有一个菜单可切换视图').toBeGreaterThan(0);
  199. expect(errors, '菜单遍历期间不应出现未捕获 JS 异常').toHaveLength(0);
  200. await page.screenshot({ path: path.join(ART, 'admin-05-menu-walk.png') });
  201. });
  202. });