/** * ================================================================ * 管理端(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') }); }); });