liu
4 小时以前 6c0fd2cb801554f7999d14e45a94ef0f0d0dfb8d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
/**
 * 二维码大批量导出——端到端实测
 *
 * 覆盖:分流判断 → 后台任务生成(真实进度)→ 隐藏为浮标 → 完成后下载 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);
});