admin-live.spec.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  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. /** 拖动滑块到最右侧(Element UI 滑块验证) */
  26. async function dragSliderToEnd(page) {
  27. const thumb = page.locator('.slider-thumb').first();
  28. const track = page.locator('.slider-track').first();
  29. await thumb.waitFor({ state: 'visible', timeout: 15000 });
  30. const tb = await thumb.boundingBox();
  31. const rb = await track.boundingBox();
  32. if (!tb || !rb) throw new Error('滑块/轨道 boundingBox 不可得');
  33. const y = tb.y + tb.height / 2;
  34. const endX = rb.x + rb.width - tb.width / 2 - 2;
  35. await page.mouse.move(tb.x + tb.width / 2, y);
  36. await page.mouse.down();
  37. // 分步移动,模拟真人拖动(部分实现按轨迹校验)
  38. const steps = 18;
  39. for (let i = 1; i <= steps; i++) {
  40. const x = tb.x + tb.width / 2 + ((endX - (tb.x + tb.width / 2)) * i) / steps;
  41. await page.mouse.move(x, y, { steps: 2 });
  42. await page.waitForTimeout(18);
  43. }
  44. await page.mouse.up();
  45. }
  46. test.describe('管理端 UI · 登录与主框架', () => {
  47. test('TC-A-U01 登录页可渲染(标题/账号/密码/登录按钮)', async ({ page }) => {
  48. const resp = await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
  49. expect(resp, '登录页应有 HTTP 响应').not.toBeNull();
  50. expect(resp.status(), '登录页状态码应 < 400').toBeLessThan(400);
  51. await expect(page).toHaveTitle(/浠艾福管理后台/);
  52. await expect(page.locator('input[placeholder="用户名 / 手机号"]')).toBeVisible();
  53. await expect(page.locator('input[placeholder="密码"]')).toBeVisible();
  54. await expect(page.getByRole('button', { name: /登\s*录/ })).toBeVisible();
  55. await page.screenshot({ path: path.join(ART, 'admin-01-login.png') });
  56. });
  57. test('TC-A-U02 滑块人机验证存在且可拖动通过', async ({ page }) => {
  58. await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
  59. await expect(page.locator('.slider-captcha')).toBeVisible();
  60. await expect(page.locator('.captcha-hint')).toContainText(/拖动到最右侧/);
  61. await dragSliderToEnd(page);
  62. // 成功后提示文案变化(验证通过/成功),或滑块填充色铺满
  63. const hint = page.locator('.captcha-hint');
  64. await expect(hint).not.toContainText(/拖动到最右侧/, { timeout: 10000 });
  65. const text = (await hint.innerText()).trim();
  66. expect(text.length, '滑块通过后应有反馈文案').toBeGreaterThan(0);
  67. await page.screenshot({ path: path.join(ART, 'admin-02-captcha-passed.png') });
  68. });
  69. test('TC-A-U03 未登录访问后台受保护路由被拦截到登录页', async ({ page }) => {
  70. await page.goto('/admin/', { waitUntil: 'domcontentloaded' });
  71. await page.waitForTimeout(2500);
  72. const url = page.url();
  73. const loginVisible = await page.locator('input[placeholder="密码"]').isVisible().catch(() => false);
  74. expect(
  75. url.includes('/login') || loginVisible,
  76. `未登录访问 /admin/ 应落到登录页,实际 URL=${url} 登录表单可见=${loginVisible}`
  77. ).toBeTruthy();
  78. });
  79. test('TC-A-U04 管理员真实登录(滑块 + 凭据)进入后台主框架', async ({ page }) => {
  80. await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
  81. await page.locator('input[placeholder="用户名 / 手机号"]').fill(PHONE);
  82. await page.locator('input[placeholder="密码"]').fill(PASSWORD);
  83. // 「登录身份」为只读带出项,若为空则尝试点选首个身份选项
  84. const roleInput = page.locator('input[placeholder="登录身份(输入手机号后自动带出)"]');
  85. if (await roleInput.isVisible().catch(() => false)) {
  86. const roleVal = await roleInput.inputValue().catch(() => '');
  87. if (!roleVal) {
  88. await roleInput.click();
  89. const opt = page.locator('.el-select-dropdown__item, .el-option').first();
  90. if (await opt.isVisible({ timeout: 3000 }).catch(() => false)) await opt.click();
  91. }
  92. }
  93. await dragSliderToEnd(page);
  94. await page.waitForTimeout(600);
  95. await page.getByRole('button', { name: /登\s*录/ }).click();
  96. await page.waitForTimeout(5000);
  97. const afterUrl = page.url();
  98. const loggedIn = !afterUrl.includes('/login');
  99. // 兼容后端拒绝(例如身份未选)时给出可诊断的信息
  100. expect(loggedIn, `登录后应离开登录页,实际 URL=${afterUrl}`).toBeTruthy();
  101. // 后台主框架特征:侧边菜单项存在
  102. const menuCount = await page.locator('.el-menu-item, .el-submenu__title, .sidebar-container a').count();
  103. expect(menuCount, '登录后应渲染后台菜单').toBeGreaterThan(3);
  104. await page.screenshot({ path: path.join(ART, 'admin-04-logged-in.png'), fullPage: false });
  105. });
  106. test('TC-A-U05 后台核心菜单可访问(用户管理/数据看板)', async ({ page }) => {
  107. await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
  108. await page.locator('input[placeholder="用户名 / 手机号"]').fill(PHONE);
  109. await page.locator('input[placeholder="密码"]').fill(PASSWORD);
  110. const roleInput = page.locator('input[placeholder="登录身份(输入手机号后自动带出)"]');
  111. if (await roleInput.isVisible().catch(() => false)) {
  112. const v = await roleInput.inputValue().catch(() => '');
  113. if (!v) {
  114. await roleInput.click();
  115. const opt = page.locator('.el-select-dropdown__item, .el-option').first();
  116. if (await opt.isVisible({ timeout: 3000 }).catch(() => false)) await opt.click();
  117. }
  118. }
  119. await dragSliderToEnd(page);
  120. await page.waitForTimeout(600);
  121. await page.getByRole('button', { name: /登\s*录/ }).click();
  122. await page.waitForTimeout(5000);
  123. test.skip(page.url().includes('/login'), '登录未通过(见 TC-A-U04),本用例无法在无会话下判定');
  124. const menuTexts = await page.locator('.el-menu-item, .el-submenu__title').allInnerTexts();
  125. const joined = menuTexts.join('|').replace(/\s+/g, '');
  126. expect(joined.length, '菜单文案不应为空').toBeGreaterThan(0);
  127. const errors = [];
  128. page.on('pageerror', (e) => errors.push(String(e)));
  129. // 逐个点击前 6 个一级菜单,断言 URL/内容变化且无未捕获异常
  130. const items = page.locator('.el-menu-item');
  131. const n = Math.min(await items.count(), 6);
  132. let navigated = 0;
  133. for (let i = 0; i < n; i++) {
  134. const before = page.url();
  135. await items.nth(i).click({ timeout: 8000 }).catch(() => {});
  136. await page.waitForTimeout(1200);
  137. if (page.url() !== before || (await page.locator('.el-main, .main-container, #app > div').count()) > 0) navigated++;
  138. }
  139. expect(navigated, '至少应有一个菜单可切换视图').toBeGreaterThan(0);
  140. expect(errors, '菜单遍历期间不应出现未捕获 JS 异常').toHaveLength(0);
  141. await page.screenshot({ path: path.join(ART, 'admin-05-menu-walk.png') });
  142. });
  143. });