/**
|
* 二维码大批量导出——端到端实测
|
*
|
* 覆盖:分流判断 → 后台任务生成(真实进度)→ 隐藏为浮标 → 完成后下载 ZIP
|
*
|
* 用法:node qr-export-big-batch.cjs [--headed] [--uuid=<生成组>] [--cancel-after=<秒>]
|
* --cancel-after 设定后,生成到该秒数时点“取消”,只验证取消链路(不等完成)
|
*
|
* 说明:真实使用时前端会先弹「保存位置」对话框(File System Access API),边下载边写盘;
|
* 自动化下该对话框会阻塞流程,故本脚本把它降级,走浏览器原生下载以便断言产物。
|
*/
|
const { chromium } = require('playwright');
|
const path = require('node:path');
|
const fs = require('node:fs');
|
|
const BASE = process.env.QR_TEST_BASE || 'http://127.0.0.1:5666';
|
const API = process.env.QR_TEST_API || 'http://127.0.0.1:48080';
|
const USERNAME = process.env.QR_TEST_USER || 'admin';
|
const PASSWORD = process.env.QR_TEST_PWD || 'admin123';
|
const DEFAULT_UUID = 'ae1229655a3e48d7b4bc842e3e74e15c';
|
|
const args = process.argv.slice(2);
|
const HEADED = args.includes('--headed');
|
/** 跑完后保留浏览器窗口不关,供人工查看二维码 */
|
const KEEP_OPEN = args.includes('--keep-open');
|
const uuidArg = args.find((a) => a.startsWith('--uuid='));
|
const UUID = uuidArg ? uuidArg.slice('--uuid='.length) : DEFAULT_UUID;
|
const cancelArg = args.find((a) => a.startsWith('--cancel-after='));
|
const CANCEL_AFTER_SEC = cancelArg ? Number(cancelArg.split('=')[1]) : 0;
|
|
const OUT_DIR = path.resolve(__dirname, 'qr-export-test-out');
|
|
/**
|
* 进度浮层与浮标的选择器
|
* 用结构特征而非 .last() 之类的模糊定位:页面里其它元素(如 Ant 的 message 提示)
|
* 同样含“二维码导出”文案,模糊定位会点到提示条上,导致误判。
|
*/
|
const PANEL = 'div.fixed.bottom-6.w-80';
|
const PILL = 'div.fixed.bottom-6.cursor-pointer';
|
const DOWNLOAD_DIR = path.join(OUT_DIR, 'downloads');
|
const LOG_FILE = path.join(OUT_DIR, 'run.log');
|
|
fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
|
fs.writeFileSync(LOG_FILE, '');
|
|
const started = Date.now();
|
function log(message) {
|
const line = `[${((Date.now() - started) / 1000).toFixed(1)}s] ${message}`;
|
console.log(line);
|
fs.appendFileSync(LOG_FILE, line + '\n');
|
}
|
function step(m) {
|
log(`\n=== ${m} ===`);
|
}
|
function fail(m) {
|
log(`FAIL: ${m}`);
|
throw new Error(m);
|
}
|
|
/**
|
* 按文本点按钮:容忍 Ant Design 在两个汉字之间自动插入的空格(“搜索”会被渲染成“搜 索”)
|
*/
|
async function clickButton(page, text, options = {}) {
|
const re = new RegExp(text.split('').join('\\s*'));
|
const locator = page.locator('button').filter({ hasText: re });
|
const count = await locator.count();
|
if (count === 0) fail(`找不到按钮:${text}`);
|
await locator.first().click(options);
|
}
|
|
/** 页面进度浮层上的按钮(我的组件用 2 字中文,同样会被插空格) */
|
async function clickPanelButton(page, text) {
|
const re = new RegExp(text.split('').join('\\s*'));
|
const locator = page.locator('button').filter({ hasText: re });
|
if ((await locator.count()) === 0) fail(`进度浮层上找不到「${text}」按钮`);
|
await locator.first().click();
|
}
|
|
async function apiPreflight() {
|
step('阶段 0:接口预检(确认后端已含本次改动)');
|
const loginRes = await fetch(`${API}/admin-api/system/auth/login`, {
|
method: 'POST',
|
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify({ username: USERNAME, password: PASSWORD }),
|
}).then((r) => r.json());
|
if (loginRes.code !== 0) fail(`登录失败:${JSON.stringify(loginRes)}`);
|
log(`接口登录成功,userId=${loginRes.data.userId}`);
|
|
const countRes = await fetch(
|
`${API}/admin-api/mes/pro/qr-export/count?bizType=BAG_LABEL&uuid=${UUID}`,
|
{ headers: { Authorization: `Bearer ${loginRes.data.accessToken}` } },
|
).then((r) => r.json());
|
if (countRes.code === 404) fail('新接口返回 404 —— 后端未含本次改动');
|
if (countRes.code !== 0) fail(`统计接口异常:${JSON.stringify(countRes)}`);
|
const { totalCount, syncThreshold, mode } = countRes.data;
|
log(`接口统计:${totalCount} 张,分界线 ${syncThreshold},方式 ${mode}`);
|
return { totalCount, mode, syncThreshold };
|
}
|
|
/** 校验下载产物:体积、ZIP 头尾签名 */
|
async function verifyDownload(download) {
|
const saved = path.join(DOWNLOAD_DIR, download.suggestedFilename());
|
await download.saveAs(saved);
|
const size = fs.statSync(saved).size;
|
log(`下载完成:${download.suggestedFilename()},${(size / 1024 / 1024).toFixed(2)} MB`);
|
const fd = fs.openSync(saved, 'r');
|
const head = Buffer.alloc(4);
|
fs.readSync(fd, head, 0, 4, 0);
|
const tail = Buffer.alloc(22);
|
fs.readSync(fd, tail, 0, 22, size - 22);
|
fs.closeSync(fd);
|
const headOk = head.readUInt32LE(0) === 0x04034b50;
|
const tailOk = tail.readUInt32LE(0) === 0x06054b50;
|
log(`ZIP 头签名 PK\\x03\\x04:${headOk}`);
|
log(`ZIP 尾 EOCD 签名 PK\\x05\\x06:${tailOk}`);
|
if (!headOk || !tailOk) fail('下载产物不是结构完整的 ZIP');
|
return { saved, size };
|
}
|
|
async function main() {
|
const { totalCount, mode, syncThreshold } = await apiPreflight();
|
|
step('阶段 1:启动浏览器并登录');
|
const browser = await chromium.launch({
|
headless: !HEADED,
|
channel: HEADED ? 'msedge' : undefined,
|
});
|
const context = await browser.newContext({
|
acceptDownloads: true,
|
viewport: { width: 1600, height: 900 },
|
});
|
const page = await context.newPage();
|
// 降级 File System Access API,使下载走浏览器原生通道,便于断言产物
|
await page.addInitScript(() => {
|
Object.defineProperty(window, 'showSaveFilePicker', {
|
value: undefined,
|
configurable: true,
|
});
|
});
|
const pageErrors = [];
|
page.on('pageerror', (e) => {
|
pageErrors.push(e.message);
|
log(`!! 页面 JS 错误: ${e.message}`);
|
});
|
|
try {
|
await page.goto(`${BASE}/auth/login`, { waitUntil: 'domcontentloaded' });
|
await page.locator('input[name=username]').waitFor({ timeout: 60_000 });
|
await page.locator('input[name=username]').fill(USERNAME);
|
await page.locator('input[name=password]').fill(PASSWORD);
|
await page.locator('button:has-text("登录")').last().click();
|
await page.waitForURL((u) => !u.pathname.includes('/auth/login'), { timeout: 60_000 });
|
log('登录成功:' + page.url());
|
|
step('阶段 2:进入袋码列表页并按生成组筛选');
|
await page.goto(`${BASE}/bagcode/bag-code`, { waitUntil: 'domcontentloaded' });
|
await page.getByRole('button', { name: '导出二维码' }).first().waitFor({ timeout: 60_000 });
|
await page.screenshot({ path: path.join(OUT_DIR, '01-列表页.png') });
|
|
await page.getByPlaceholder('请输入生成组编号').fill(UUID);
|
log(`已填写生成组:${UUID}`);
|
await clickButton(page, '搜索');
|
await page.waitForTimeout(4000);
|
await page.screenshot({ path: path.join(OUT_DIR, '02-筛选后.png') });
|
|
step(`阶段 3:点击「导出二维码」(${totalCount} 张,分界线 ${syncThreshold},预期走${mode === 'SYNC' ? '同步下载' : '后台任务'})`);
|
// 下载事件必须先挂上,避免事件早于监听而漏掉
|
const downloadPromise = page.waitForEvent('download', { timeout: 60 * 60 * 1000 });
|
await page.getByRole('button', { name: '导出二维码' }).first().click();
|
|
if (mode === 'SYNC') {
|
// 小批量:秒级同步下载,不进后台任务、不排队、不受 CPU 闸门约束
|
await page
|
.locator(PANEL)
|
.waitFor({ timeout: 30_000 })
|
.catch(() => log('(同步路径未展示浮层,直接下载)'));
|
const download = await downloadPromise;
|
await verifyDownload(download);
|
await page.screenshot({ path: path.join(OUT_DIR, 'sync-完成.png') });
|
if (pageErrors.length > 0) log(`注意:测试期间出现 ${pageErrors.length} 条页面 JS 错误`);
|
log(`\nPASS:${totalCount} 张未超过分界线 ${syncThreshold},走同步下载,产物完整`);
|
return;
|
}
|
|
await page.locator(PANEL).waitFor({ timeout: 90_000 });
|
log('进度浮层已出现');
|
await page.waitForTimeout(3000);
|
|
// 提交可能被并发闸门拒绝(已有任务在跑);明确报出来,避免后续步骤以讹传讹
|
const afterClick = await page.locator('body').innerText();
|
if (/导出失败/.test(afterClick)) {
|
const busy = afterClick.match(/已有二维码导出任务正在进行[^\n]*/);
|
fail(busy ? `被并发闸门拒绝:${busy[0]}` : '导出失败(见页面提示)');
|
}
|
|
await page.screenshot({ path: path.join(OUT_DIR, '03-进度浮层.png') });
|
const cancelBtn = page.locator('button').filter({ hasText: /取\s*消/ });
|
if ((await cancelBtn.count()) === 0) fail('进度浮层上找不到「取消」按钮');
|
log('进度浮层已出现,「取消」按钮存在 ✓');
|
|
step('阶段 4:验证「隐藏」→ 收成浮标 → 点浮标再展开');
|
await page.screenshot({ path: path.join(OUT_DIR, '04a-隐藏前.png') });
|
await clickPanelButton(page, '隐藏');
|
await page.waitForTimeout(1500);
|
if ((await page.locator(PILL).count()) === 0) fail('点击「隐藏」后浮标未出现');
|
log('已收起为浮标 ✓');
|
await page.screenshot({ path: path.join(OUT_DIR, '04b-隐藏后浮标.png') });
|
|
await page.locator(PILL).first().click();
|
await page.waitForTimeout(1200);
|
if ((await page.locator(PANEL).count()) === 0) fail('点击浮标后进度面板未恢复');
|
log('点击浮标后进度面板恢复 ✓');
|
await page.screenshot({ path: path.join(OUT_DIR, '05-恢复面板.png') });
|
|
if (CANCEL_AFTER_SEC > 0) {
|
step(`阶段 5:等待 ${CANCEL_AFTER_SEC}s 后验证「取消」链路`);
|
await page.waitForTimeout(CANCEL_AFTER_SEC * 1000);
|
await clickPanelButton(page, '取消');
|
await page.waitForTimeout(5000);
|
await page.screenshot({ path: path.join(OUT_DIR, '06-取消后.png') });
|
const canceled = await page
|
.locator('text=/已取消/')
|
.first()
|
.isVisible()
|
.catch(() => false);
|
log(`取消后界面提示「已取消」:${canceled}`);
|
if (!canceled) fail('点击取消后界面未进入已取消状态');
|
log('取消链路 ✓');
|
return;
|
}
|
|
step(`阶段 5:等待后台生成完成(共 ${totalCount} 张)`);
|
let lastLogged = '';
|
for (let i = 0; i < 3600; i++) {
|
await page.waitForTimeout(2000);
|
const body = await page.locator('body').innerText().catch(() => '');
|
const m = body.match(/正在生成((\d+)\s*\/\s*(\d+))/);
|
if (m) {
|
const cur = `${m[1]}/${m[2]} (${((Number(m[1]) / Number(m[2])) * 100).toFixed(1)}%)`;
|
if (cur !== lastLogged) {
|
log(`生成进度 ${cur}`);
|
lastLogged = cur;
|
}
|
}
|
const phase = body.match(/正在下载|导出完成|导出失败/);
|
if (phase) {
|
log(`进入下一阶段:${phase[0]}`);
|
break;
|
}
|
}
|
await page.screenshot({ path: path.join(OUT_DIR, '06-生成完成.png') });
|
|
step('阶段 6:等待下载落盘并校验产物');
|
const download = await downloadPromise;
|
await verifyDownload(download);
|
await page.screenshot({ path: path.join(OUT_DIR, '07-下载完成.png') });
|
|
if (pageErrors.length > 0) log(`注意:测试期间出现 ${pageErrors.length} 条页面 JS 错误`);
|
log(`\nPASS:分流 → 后台生成 ${totalCount} 张 → 真实进度 → 隐藏/恢复 → 下载 ZIP 全链路通过`);
|
} catch (error) {
|
await page.screenshot({ path: path.join(OUT_DIR, 'error.png') }).catch(() => {});
|
log(`失败截图:${path.join(OUT_DIR, 'error.png')}`);
|
throw error;
|
} finally {
|
if (KEEP_OPEN) {
|
// 保持浏览器打开供人工查看二维码;等待页面被手动关闭后进程才退出
|
log('(--keep-open:浏览器保持打开,请自行查看,关闭窗口后本进程退出)');
|
await page
|
.waitForEvent('close', { timeout: 0 })
|
.catch(() => {});
|
await browser.close().catch(() => {});
|
return;
|
}
|
if (HEADED) {
|
// 留出几秒让人看清最终界面,再自动收尾,避免留下悬挂的浏览器与 node 进程
|
log('(headed 模式:10 秒后自动关闭浏览器)');
|
await page.waitForTimeout(10_000).catch(() => {});
|
}
|
await browser.close().catch(() => {});
|
}
|
}
|
|
main().catch((e) => {
|
log(`\nFAILED: ${e.message}`);
|
process.exit(1);
|
});
|