| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- /**
- * ================================================================
- * 管理端(cfc-web)线上 UI 级 E2E · TC-A-U01 ~ TC-A-U06
- * ================================================================
- * 目标站点:http://cfc.iwintrue.com/admin
- * 场景来源:docs/flows/admin-overview-flow.md(管理员登录 → 后台主框架 → 核心菜单)
- *
- * 只读约束:不新增/修改/删除任何业务数据;仅执行登录(会话建立)。
- * 凭据来源:环境变量 CFC_ADMIN_PHONE / CFC_ADMIN_PASSWORD(缺省用测试环境公共测试账号),
- * 禁止在报告/日志中打印明文口令。
- *
- * DOM 依据(2026-11-07 实测):
- * input[placeholder="用户名 / 手机号"]、input[placeholder="密码"]、
- * input[placeholder="登录身份(输入手机号后自动带出)"]、
- * 滑块验证 .slider-captcha > .slider-track / .slider-thumb、按钮「登 录」
- * ================================================================
- */
- const { test, expect } = require('@playwright/test');
- const fs = require('fs');
- const path = require('path');
- const ART = path.join(__dirname, '..', '..', '_artifacts_live');
- if (!fs.existsSync(ART)) fs.mkdirSync(ART, { recursive: true });
- const PHONE = process.env.CFC_ADMIN_PHONE || '13800138000';
- const PASSWORD = process.env.CFC_ADMIN_PASSWORD || 'admin123';
- /**
- * 等待「登录身份」自动带出(输入手机号后 500ms 防抖查询 account-roles)。
- * ⚠️ B-46 根因:主动 click 该下拉会展开 popper,浮层正好覆盖下方滑块,
- * 导致后续 mouse.down 落在浮层上、滑块位移恒为 0、登录按钮永久 disabled。
- * 因此一律「等它自己带出」,不点开、并在拖动前兜底关闭残留浮层。
- */
- async function waitIdentityAutoFilled(page, timeoutMs = 12000) {
- const roleInput = page.locator('input[placeholder="登录身份(输入手机号后自动带出)"]');
- const deadline = Date.now() + timeoutMs;
- let val = '';
- while (Date.now() < deadline) {
- val = await roleInput.inputValue().catch(() => '');
- if (val) break;
- await page.waitForTimeout(200);
- }
- return val;
- }
- /**
- * 等登录表单「可交互」再输入(R97 / B-76 根因)。
- * ⚠️ 全量串行跑时 U01~U03 已多次导航,U04 的 goto 可能只到 domcontentloaded
- * 而 Vue 仍未挂载 → 此时 fill() 触发的 @input 没有监听器,500ms 防抖查询永不发出
- * → 「登录身份」恒为空 → 断言红(孤立单跑 20s 内必过,属典型时序竞态)。
- * 判据:手机号输入框可见 + 可编辑 + 登录按钮已渲染。
- */
- async function waitLoginFormReady(page) {
- const phoneInput = page.locator('input[placeholder="用户名 / 手机号"]');
- await phoneInput.waitFor({ state: 'visible', timeout: 20000 });
- await page.locator('input[placeholder="密码"]').waitFor({ state: 'visible', timeout: 20000 });
- await page.getByRole('button', { name: /登\s*录/ }).waitFor({ state: 'visible', timeout: 20000 });
- await page.waitForFunction(
- () => {
- const el = document.querySelector('input[placeholder="用户名 / 手机号"]');
- return !!el && !el.disabled && !el.readOnly;
- },
- { timeout: 20000 }
- );
- }
- /**
- * 填手机号并「确定性地」等 account-roles XHR 返回(不靠固定 sleep)。
- * 返回该响应的 HTTP 状态码,便于失败时给出可诊断信息。
- */
- async function fillPhoneAndAwaitRoles(page, phone) {
- const respPromise = page
- .waitForResponse((r) => r.url().includes('/api/admin-auth/account-roles'), { timeout: 20000 })
- .catch(() => null);
- await page.locator('input[placeholder="用户名 / 手机号"]').fill(phone);
- const resp = await respPromise;
- return resp ? resp.status() : 0;
- }
- /** 关闭可能残留的下拉浮层,避免遮挡滑块(B-46 直接诱因) */
- async function dismissPoppers(page) {
- await page.keyboard.press('Escape').catch(() => {});
- await page.waitForTimeout(150);
- const visible = await page.locator('.el-select-dropdown').filter({ visible: true }).count().catch(() => 0);
- if (visible > 0) {
- await page.mouse.click(5, 5).catch(() => {});
- await page.waitForTimeout(150);
- }
- }
- /** 拖动滑块到最右侧(Element UI 滑块验证),并确认「验证通过」 */
- async function dragSliderToEnd(page) {
- await dismissPoppers(page);
- const thumb = page.locator('.slider-thumb').first();
- const track = page.locator('.slider-track').first();
- await thumb.waitFor({ state: 'visible', timeout: 15000 });
- const tb = await thumb.boundingBox();
- const rb = await track.boundingBox();
- if (!tb || !rb) throw new Error('滑块/轨道 boundingBox 不可得');
- const y = tb.y + tb.height / 2;
- const endX = rb.x + rb.width - tb.width / 2 - 2;
- await page.mouse.move(tb.x + tb.width / 2, y);
- await page.mouse.down();
- // 分步移动,模拟真人拖动(部分实现按轨迹校验)
- const steps = 18;
- for (let i = 1; i <= steps; i++) {
- const x = tb.x + tb.width / 2 + ((endX - (tb.x + tb.width / 2)) * i) / steps;
- await page.mouse.move(x, y, { steps: 2 });
- await page.waitForTimeout(18);
- }
- await page.mouse.up();
- // 必须真正通过:历史 B-46 期间「拖动无效」被弱断言判绿,此处强校验
- await expect(page.locator('.captcha-hint')).toContainText('验证通过', { timeout: 8000 });
- }
- test.describe('管理端 UI · 登录与主框架', () => {
- test('TC-A-U01 登录页可渲染(标题/账号/密码/登录按钮)', async ({ page }) => {
- const resp = await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
- expect(resp, '登录页应有 HTTP 响应').not.toBeNull();
- expect(resp.status(), '登录页状态码应 < 400').toBeLessThan(400);
- await expect(page).toHaveTitle(/浠艾福管理后台/);
- await expect(page.locator('input[placeholder="用户名 / 手机号"]')).toBeVisible();
- await expect(page.locator('input[placeholder="密码"]')).toBeVisible();
- await expect(page.getByRole('button', { name: /登\s*录/ })).toBeVisible();
- await page.screenshot({ path: path.join(ART, 'admin-01-login.png') });
- });
- test('TC-A-U02 滑块人机验证存在且可拖动通过', async ({ page }) => {
- await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
- await expect(page.locator('.slider-captcha')).toBeVisible();
- await expect(page.locator('.captcha-hint')).toContainText(/拖动到最右侧/);
- await dragSliderToEnd(page);
- // 成功后提示文案必须为「验证通过」(原断言仅判「不含待拖文案」,验证失败态会假绿)
- const hint = page.locator('.captcha-hint');
- await expect(hint).toContainText('验证通过');
- const text = (await hint.innerText()).trim();
- expect(text.length, '滑块通过后应有反馈文案').toBeGreaterThan(0);
- // 通过后登录按钮必须解除禁用
- await expect(page.getByRole('button', { name: /登\s*录/ })).toBeEnabled();
- await page.screenshot({ path: path.join(ART, 'admin-02-captcha-passed.png') });
- });
- test('TC-A-U03 未登录访问后台受保护路由被拦截到登录页', async ({ page }) => {
- await page.goto('/admin/', { waitUntil: 'domcontentloaded' });
- await page.waitForTimeout(2500);
- const url = page.url();
- const loginVisible = await page.locator('input[placeholder="密码"]').isVisible().catch(() => false);
- expect(
- url.includes('/login') || loginVisible,
- `未登录访问 /admin/ 应落到登录页,实际 URL=${url} 登录表单可见=${loginVisible}`
- ).toBeTruthy();
- });
- test('TC-A-U04 管理员真实登录(滑块 + 凭据)进入后台主框架', async ({ page }) => {
- await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
- await waitLoginFormReady(page);
- const rolesStatus = await fillPhoneAndAwaitRoles(page, PHONE);
- expect(rolesStatus, 'account-roles 接口应 200(身份带出的前置条件)').toBe(200);
- await page.locator('input[placeholder="密码"]').fill(PASSWORD);
- // 「登录身份」为只读带出项:等自动带出,禁止点开下拉(点开会用 popper 遮住滑块 → B-46)
- const roleVal = await waitIdentityAutoFilled(page);
- expect(roleVal, '输入手机号后「登录身份」应自动带出').not.toBe('');
- await dragSliderToEnd(page);
- await page.waitForTimeout(600);
- await page.getByRole('button', { name: /登\s*录/ }).click();
- await page.waitForURL(/\/admin\/dashboard/, { timeout: 20000 });
- const afterUrl = page.url();
- const loggedIn = !afterUrl.includes('/login');
- // 兼容后端拒绝(例如身份未选)时给出可诊断的信息
- expect(loggedIn, `登录后应离开登录页,实际 URL=${afterUrl}`).toBeTruthy();
- // 后台主框架特征:侧边菜单项存在
- const menuCount = await page.locator('.el-menu-item, .el-submenu__title, .sidebar-container a').count();
- expect(menuCount, '登录后应渲染后台菜单').toBeGreaterThan(3);
- await page.screenshot({ path: path.join(ART, 'admin-04-logged-in.png'), fullPage: false });
- });
- test('TC-A-U05 后台核心菜单可访问(用户管理/数据看板)', async ({ page }) => {
- await page.goto('/admin/login', { waitUntil: 'domcontentloaded' });
- await waitLoginFormReady(page);
- const rolesStatus2 = await fillPhoneAndAwaitRoles(page, PHONE);
- expect(rolesStatus2, 'account-roles 接口应 200(身份带出的前置条件)').toBe(200);
- await page.locator('input[placeholder="密码"]').fill(PASSWORD);
- const roleVal2 = await waitIdentityAutoFilled(page);
- expect(roleVal2, '输入手机号后「登录身份」应自动带出').not.toBe('');
- await dragSliderToEnd(page);
- await page.waitForTimeout(600);
- await page.getByRole('button', { name: /登\s*录/ }).click();
- await page.waitForURL(/\/admin\/dashboard/, { timeout: 20000 });
- // B-59 修正(R133):原固定 waitForTimeout(5000) 在负载高时过短,登录跳转后台尚未完成即断言,
- // 导致 U04/U05 偶发「前置登录未通过」。改为 waitForURL 等待后台落地页稳态;R97 已移除 test.skip 吞失败逻辑。
- expect(
- page.url().includes('/login'),
- `前置登录未通过,本用例无法进入后台(实际 URL=${page.url()})`
- ).toBeFalsy();
- const menuTexts = await page.locator('.el-menu-item, .el-submenu__title').allInnerTexts();
- const joined = menuTexts.join('|').replace(/\s+/g, '');
- expect(joined.length, '菜单文案不应为空').toBeGreaterThan(0);
- const errors = [];
- page.on('pageerror', (e) => errors.push(String(e)));
- // 逐个点击前 6 个一级菜单,断言 URL/内容变化且无未捕获异常
- const items = page.locator('.el-menu-item');
- const n = Math.min(await items.count(), 6);
- let navigated = 0;
- for (let i = 0; i < n; i++) {
- const before = page.url();
- await items.nth(i).click({ timeout: 8000 }).catch(() => {});
- await page.waitForTimeout(1200);
- if (page.url() !== before || (await page.locator('.el-main, .main-container, #app > div').count()) > 0) navigated++;
- }
- expect(navigated, '至少应有一个菜单可切换视图').toBeGreaterThan(0);
- expect(errors, '菜单遍历期间不应出现未捕获 JS 异常').toHaveLength(0);
- await page.screenshot({ path: path.join(ART, 'admin-05-menu-walk.png') });
- });
- });
|