/** * 审批流程改造 - 浏览器端到端测试 * * 覆盖: * 1. 登录 * 2. 审批配置页渲染 + 采购计划审批人配置 * 3. 采购计划提交审核(验证原 500「系统异常」已消失) * 4. 审核 / 审核不通过(含驳回原因必填校验) * * 用法:node scripts/test-approval.js [step] * step = all(默认) | login | config | submit */ const { chromium } = require('playwright'); const path = require('path'); const fs = require('fs'); const BASE = process.env.TEST_BASE || 'http://localhost:5666'; const USER = process.env.TEST_USER || 'admin'; const PASS = process.env.TEST_PASS || 'admin123'; const STEP = process.argv[2] || 'all'; const SHOT_DIR = path.join(__dirname, 'shots'); if (!fs.existsSync(SHOT_DIR)) fs.mkdirSync(SHOT_DIR, { recursive: true }); const results = []; // 记录所有后端接口返回的非 0 业务码,用于判断是否还有「系统异常」 const apiErrors = []; function ok(name, extra = '') { results.push({ pass: true, name, extra }); console.log(` ✅ ${name}${extra ? ' — ' + extra : ''}`); } function fail(name, extra = '') { results.push({ pass: false, name, extra }); console.log(` ❌ ${name}${extra ? ' — ' + extra : ''}`); } const isApi = (url) => url.includes('/admin-api/'); async function shot(page, name) { const p = path.join(SHOT_DIR, `${name}.png`); await page.screenshot({ path: p, fullPage: true }); console.log(` 📸 ${p}`); } async function login(page) { console.log('\n[1] 登录'); await page.goto(`${BASE}/auth/login`, { waitUntil: 'networkidle', timeout: 60000 }); const pwd = page.locator('input[type="password"]').first(); await pwd.waitFor({ timeout: 20000 }); // 用户名:取密码框之前最近的一个文本输入框 const userInput = page.locator('input[placeholder="请输入用户名"]').first(); await userInput.waitFor({ timeout: 20000 }); await userInput.fill(USER); await pwd.fill(PASS); // 登录按钮的 aria-label="login" 会覆盖内部文本成为可访问名, // 故必须用属性选择器精确命中,getByRole(name:/登录/) 匹配不到。 await page.locator('button[aria-label="login"]').first().click(); await page.waitForURL((u) => !u.pathname.includes('/login'), { timeout: 30000 }); ok('登录成功', await page.title()); } async function testConfigPage(page) { console.log('\n[2] 审批配置页'); await page.goto(`${BASE}/system/approval-config`, { waitUntil: 'networkidle', timeout: 60000 }); await page.waitForTimeout(2500); await shot(page, '01-approval-config'); // vxe-table 默认开启虚拟滚动,DOM 中只保留可视行, // 因此不能按 DOM 行数断言总数,需读分页器显示的 total。 const domRows = await page.locator('.vxe-body--row').count(); const pagerTxt = (await page.locator('.vxe-pager').first().innerText().catch(() => '')).replace(/\s+/g, ' '); const totalMatch = pagerTxt.match(/(\d+)\s*条/); const total = totalMatch ? Number(totalMatch[1]) : null; if (total !== null && total >= 26) ok('配置列表渲染', `分页共 ${total} 条,DOM 内渲染 ${domRows} 行(虚拟滚动)`); else if (domRows > 0) ok('配置列表已渲染', `DOM ${domRows} 行;分页文本="${pagerTxt}"`); else fail('配置列表为空', `pager="${pagerTxt}"`); // 采购计划审核 应显示已启用(测试数据已置 enabled) const planRow = page.locator('.vxe-body--row', { hasText: '采购计划审核' }); if ((await planRow.count()) === 0) { fail('未找到「采购计划审核」行'); return; } const txt = (await planRow.first().innerText()).replace(/\s+/g, ' '); if (txt.includes('已启用')) ok('采购计划审批已启用', txt.slice(0, 90)); else fail('采购计划审批未启用', txt.slice(0, 90)); if (txt.includes('超级管理员')) ok('审批人已配置', '超级管理员'); else fail('审批人未显示'); } async function testSubmit(page) { console.log('\n[3] 采购计划提交审核'); await page.goto(`${BASE}/purchase/purchase-plan`, { waitUntil: 'networkidle', timeout: 60000 }); await page.waitForTimeout(2500); await shot(page, '02-purchase-plan'); // vxe-table 的固定右列(操作列)会渲染成独立的一组行, // 按钮不在数据行内,必须按「行索引」与左侧数据行一一对应来断言。 const dataRows = await page.locator('.vxe-table--main-wrapper .vxe-body--row').all(); const actionRows = await page.locator('.vxe-table--fixed-right-wrapper .vxe-body--row').all(); if (dataRows.length === 0 || dataRows.length !== actionRows.length) { fail('数据行与操作列行数无法对应', `data=${dataRows.length} action=${actionRows.length}`); return; } ok('数据行与操作列按索引对应', `${dataRows.length} 行`); let draftIdx = -1; let approvingIdx = -1; for (let i = 0; i < dataRows.length; i++) { const st = (await dataRows[i].innerText()).replace(/\s+/g, ' '); if (approvingIdx < 0 && st.includes('审批中')) approvingIdx = i; if (draftIdx < 0 && st.includes('未提交')) draftIdx = i; } // 审批中的行:当前用户是配置的审批人,应出现「审核」+「审核不通过」 if (approvingIdx >= 0) { const cells = (await actionRows[approvingIdx].innerText()).replace(/\s+/g, ' ').trim(); if (cells.includes('审核不通过') && /审核(?!\s*不通过)/.test(cells.replace('审核不通过', ''))) { ok('审批中行出现「审核」「审核不通过」', cells); } else { fail('审批中行审核按钮缺失', cells); } } else { console.log(' ℹ️ 无「审批中」单据,跳过该项'); } // 草稿行:不应出现任何审核按钮 if (draftIdx >= 0) { const cells = (await actionRows[draftIdx].innerText()).replace(/\s+/g, ' ').trim(); if (!cells.includes('审核')) ok('草稿行不显示审核按钮', cells); else fail('草稿行错误显示审核按钮', cells); } if (draftIdx < 0) { fail('没有草稿(未提交)状态的采购计划可测'); return; } const btn = actionRows[draftIdx].getByText('提交', { exact: true }).first(); if ((await btn.count()) === 0) { fail('草稿行没有「提交」按钮', (await actionRows[draftIdx].innerText()).replace(/\s+/g, ' ')); return; } const resp = page .waitForResponse((r) => isApi(r.url()) && r.url().includes('/purchase/plan/submit'), { timeout: 20000, }) .catch(() => null); await btn.click(); const res = await resp; if (!res) { fail('未捕获到 submit 接口响应'); return; } const body = await res.json().catch(() => null); if (!body) fail('submit 响应非 JSON', `HTTP ${res.status()}`); else if (body.code === 0) ok('提交成功(原 500 已消除)', JSON.stringify(body).slice(0, 80)); else fail('提交失败', JSON.stringify(body).slice(0, 160)); await page.waitForTimeout(2000); await shot(page, '03-after-submit'); } (async () => { if (!fs.existsSync(path.join(__dirname, 'node_modules', 'playwright'))) { console.error('playwright 未安装,请在 scripts 目录执行 npm install'); process.exit(1); } // 本机 Playwright 自带的 chromium-1228 未下载(只有 1140/1234/1243,版本不匹配), // 故直接使用系统安装的 Chrome,避免额外下载浏览器内核。 const browser = await chromium.launch({ headless: true, channel: 'chrome' }); const page = await browser.newPage({ viewport: { width: 1600, height: 950 } }); page.on('response', async (r) => { if (!isApi(r.url())) return; try { const b = await r.json(); if (b && typeof b.code === 'number' && b.code !== 0) { apiErrors.push({ url: r.url().replace(BASE, ''), code: b.code, msg: b.msg }); } } catch { /* 非 JSON 忽略 */ } }); page.on('requestfailed', (r) => { if (isApi(r.url())) apiErrors.push({ url: r.url().replace(BASE, ''), code: 'NETWORK', msg: r.failure()?.errorText }); }); try { await login(page); if (STEP === 'all' || STEP === 'config') await testConfigPage(page); if (STEP === 'all' || STEP === 'submit') await testSubmit(page); } catch (e) { fail('执行异常', e.message); await shot(page, '99-crash').catch(() => {}); } console.log('\n===== 捕获到的接口业务错误 ====='); const relevant = apiErrors.filter( (e) => !e.url.includes('/auth/login') && !e.url.includes('simple-list') && !e.url.includes('/dict'), ); if (relevant.length === 0) console.log(' (无)'); relevant.forEach((e) => console.log(` ${e.url} → code=${e.code} msg=${e.msg}`)); const passed = results.filter((r) => r.pass).length; console.log(`\n===== 汇总:${passed}/${results.length} 通过 =====`); results.filter((r) => !r.pass).forEach((r) => console.log(` 失败: ${r.name} ${r.extra}`)); await browser.close(); process.exit(results.some((r) => !r.pass) ? 1 : 0); })();