/**
|
* 审批流程改造 - 浏览器端到端测试
|
*
|
* 覆盖:
|
* 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);
|
})();
|