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