2026-08-27 12:20:31 +08:00
|
|
|
// smoke.js — zhaopin-admin 全流程冒烟测试(浏览器级)
|
|
|
|
|
// 用法: node scripts/smoke.js [baseUrl]
|
|
|
|
|
// 注意: 会在目标实例注册超管(testsuper)并创建HR(hr01) — 生产实例跑完需重置 users.db
|
|
|
|
|
'use strict';
|
|
|
|
|
const { chromium } = require('/www/projects/zc_recruitment-screener/scripts/node_modules/playwright-core');
|
|
|
|
|
const BASE = process.argv[2] || 'http://192.168.100.133:8300';
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
fs.mkdirSync('/tmp/smoke-shots', { recursive: true });
|
|
|
|
|
|
|
|
|
|
let passed = 0, failed = 0;
|
|
|
|
|
const ok = (name, cond) => { cond ? passed++ : failed++; console.log(` ${cond ? '✅' : '❌'} ${name}`); };
|
|
|
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
|
const b = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
|
|
|
|
|
const p = await b.newPage({ viewport: { width: 1400, height: 900 } });
|
|
|
|
|
const $ = (sel) => p.locator(sel);
|
|
|
|
|
|
|
|
|
|
console.log('== 1. 初始化页面 ==');
|
|
|
|
|
await p.goto(BASE + '/', { waitUntil: 'networkidle' });
|
2026-08-27 14:23:34 +08:00
|
|
|
const boot1 = await p.evaluate(async () => (await (await fetch('/api/bootstrap')).json()));
|
|
|
|
|
const wantTitle = boot1.needsInit ? '初始化超管账号' : '登录';
|
|
|
|
|
ok(`标题=${wantTitle}`, await $('#loginTitle').textContent() === wantTitle);
|
2026-08-27 12:20:31 +08:00
|
|
|
ok('详情弹窗隐藏', await p.evaluate(() => getComputedStyle(document.getElementById('detailModal')).display === 'none'));
|
|
|
|
|
await p.screenshot({ path: '/tmp/smoke-shots/01-init.png' });
|
|
|
|
|
|
2026-08-27 14:21:39 +08:00
|
|
|
console.log('== 2. 登录(新实例注册超管/已有实例用测试账号) ==');
|
|
|
|
|
const boot = await p.evaluate(async () => (await (await fetch('/api/bootstrap')).json()));
|
|
|
|
|
const SU = process.env.SMOKE_USER || 'testsuper';
|
|
|
|
|
const SP = process.env.SMOKE_PASS || 'test123456';
|
|
|
|
|
await $('#f_user').fill(SU);
|
|
|
|
|
await $('#f_pass').fill(SP);
|
2026-08-27 14:59:49 +08:00
|
|
|
await p.keyboard.press('Enter'); // 回车登录路径
|
2026-08-27 12:20:31 +08:00
|
|
|
await p.waitForNavigation({ waitUntil: 'networkidle', timeout: 8000 }).catch(() => {});
|
|
|
|
|
await sleep(800);
|
2026-08-27 14:21:39 +08:00
|
|
|
ok(boot.needsInit ? '注册超管成功' : '测试账号登录成功', await $('#main').isVisible());
|
2026-08-27 12:20:31 +08:00
|
|
|
ok('进入主界面', await $('#main').isVisible());
|
|
|
|
|
ok('身份=超管', (await $('#whoami').textContent()).includes('超管'));
|
|
|
|
|
await p.screenshot({ path: '/tmp/smoke-shots/02-dashboard.png' });
|
|
|
|
|
|
|
|
|
|
console.log('== 3. 看板 ==');
|
|
|
|
|
const statText = await $('#statCards').innerText();
|
|
|
|
|
ok('累计候选人为五位数(数据在库)', /\d{5,}/.test(statText));
|
|
|
|
|
ok('级别分布含推荐', statText.includes('推荐'));
|
2026-08-27 14:37:06 +08:00
|
|
|
const canvasN = await p.evaluate(() => document.querySelectorAll('#main canvas').length);
|
2026-08-27 15:51:44 +08:00
|
|
|
ok(`图表已渲染(canvas=${canvasN},≥4)`, canvasN >= 4);
|
2026-08-27 12:20:31 +08:00
|
|
|
|
2026-08-27 15:04:12 +08:00
|
|
|
console.log('== 4. 候选人列表与筛选(滑动到第二屏) ==');
|
|
|
|
|
await p.evaluate(() => setPane(1)); await sleep(500);
|
2026-08-27 15:14:44 +08:00
|
|
|
const loopCheck = await p.evaluate(() => { setPane(1); slide(1); return document.getElementById('sliderTrack').style.transform; });
|
|
|
|
|
ok('循环滑动(末屏点右绕回首屏)', loopCheck === 'translateX(0%)');
|
|
|
|
|
await p.evaluate(() => setPane(1)); await sleep(400);
|
2026-08-27 12:20:31 +08:00
|
|
|
const rows1 = await p.evaluate(() => document.querySelectorAll('#tbody tr').length);
|
|
|
|
|
ok(`默认列表有数据(${rows1}行)`, rows1 > 0);
|
|
|
|
|
await p.selectOption('#q_level', '推荐');
|
|
|
|
|
await $('button:has-text("查询")').click(); await sleep(700);
|
|
|
|
|
const lvOk = await p.evaluate(() => [...document.querySelectorAll('#tbody tr')].every(tr => tr.textContent.includes('推荐')));
|
|
|
|
|
ok('筛选"推荐"后全部为推荐', lvOk);
|
|
|
|
|
await p.fill('#q_kw', '外贸'); await p.selectOption('#q_level', '');
|
|
|
|
|
await $('button:has-text("查询")').click(); await sleep(700);
|
|
|
|
|
const kwOk = await p.evaluate(() => document.querySelectorAll('#tbody tr').length >= 0);
|
|
|
|
|
ok('关键词筛选不报错', kwOk);
|
|
|
|
|
|
2026-08-27 15:49:55 +08:00
|
|
|
console.log('== 4b. 组合标签(时间×级别) ==');
|
|
|
|
|
await p.evaluate(() => levelTab('valid')); await sleep(700);
|
2026-08-27 15:45:26 +08:00
|
|
|
const validOk = await p.evaluate(() => {
|
|
|
|
|
const rows = [...document.querySelectorAll('#tbody tr')];
|
|
|
|
|
return rows.length > 0 && rows.every(tr => tr.textContent.includes('推荐') || tr.textContent.includes('待定'));
|
|
|
|
|
});
|
2026-08-27 15:49:55 +08:00
|
|
|
ok('"有效人员"全部为推荐/待定', validOk);
|
|
|
|
|
await p.evaluate(() => timeTab('today')); await sleep(700);
|
|
|
|
|
const combo = await p.evaluate(() => {
|
|
|
|
|
const rows = [...document.querySelectorAll('#tbody tr')];
|
|
|
|
|
const allToday = rows.every(tr => tr.classList.contains('today'));
|
|
|
|
|
const allValid = rows.every(tr => tr.textContent.includes('推荐') || tr.textContent.includes('待定'));
|
|
|
|
|
const tabs = [document.querySelector('#timeTabs .tab.on')?.dataset.q, document.querySelector('#levelTabs .tab.on')?.dataset.q];
|
|
|
|
|
return { n: rows.length, allToday, allValid, tabs: tabs.join('+') };
|
|
|
|
|
});
|
|
|
|
|
ok(`组合"今日+有效"正确(${combo.n}行, 标签=${combo.tabs})`, (combo.n === 0 || combo.allToday) && (combo.n === 0 || combo.allValid) && combo.tabs === 'today+valid');
|
|
|
|
|
await p.evaluate(() => { timeTab(''); levelTab(''); }); await sleep(500);
|
2026-08-27 15:45:26 +08:00
|
|
|
|
2026-08-27 12:20:31 +08:00
|
|
|
console.log('== 5. 候选人详情弹窗 ==');
|
|
|
|
|
await p.evaluate(() => document.querySelector('#tbody tr')?.click()); await sleep(800);
|
2026-08-27 16:04:46 +08:00
|
|
|
const detText = await $('#detailBody').innerText();
|
|
|
|
|
ok('弹窗打开且含简历全文', detText.includes('简历全文'));
|
|
|
|
|
ok('时间格式为 Y-m-d H:i:s', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(detText));
|
2026-08-27 12:20:31 +08:00
|
|
|
await p.screenshot({ path: '/tmp/smoke-shots/03-detail.png' });
|
|
|
|
|
await p.evaluate(() => document.getElementById('detailModal').classList.add('hidden'));
|
|
|
|
|
|
|
|
|
|
console.log('== 6. CSV导出(带登录态) ==');
|
|
|
|
|
const csv = await p.evaluate(async () => {
|
|
|
|
|
const r = await fetch('/api/export.csv?level=' + encodeURIComponent('不相关'));
|
|
|
|
|
return (await r.text()).slice(0, 100);
|
|
|
|
|
});
|
|
|
|
|
ok('CSV表头正确', csv.includes('姓名') && csv.includes('门槛备注'));
|
|
|
|
|
|
2026-08-27 14:21:39 +08:00
|
|
|
console.log('== 7. 顶栏入口+弹框建HR账号 ==');
|
|
|
|
|
ok('顶栏账号管理入口可见', await $('#userMgrBtn').isVisible());
|
|
|
|
|
await p.evaluate(() => openUserModal()); await sleep(600);
|
|
|
|
|
ok('账号管理弹框打开', await $('#userModal').isVisible());
|
2026-08-27 12:20:31 +08:00
|
|
|
await $('#nu_user').fill('hr01'); await $('#nu_pass').fill('hr123456');
|
|
|
|
|
await $('button:has-text("创建HR账号")').click(); await sleep(700);
|
|
|
|
|
const uText = await $('#utable').innerText();
|
|
|
|
|
ok('用户列表含hr01', uText.includes('hr01'));
|
2026-08-27 14:21:39 +08:00
|
|
|
ok('停用/启用按钮存在', uText.includes('停用'));
|
2026-08-27 12:20:31 +08:00
|
|
|
await p.screenshot({ path: '/tmp/smoke-shots/04-users.png' });
|
2026-08-27 14:21:39 +08:00
|
|
|
await p.evaluate(() => document.getElementById('userModal').classList.add('hidden'));
|
2026-08-27 12:20:31 +08:00
|
|
|
|
2026-08-27 16:14:34 +08:00
|
|
|
console.log('== 7b. 访问记录(超管) ==');
|
|
|
|
|
ok('顶栏访问记录入口可见', await $('#accessLogBtn').isVisible());
|
|
|
|
|
await p.evaluate(() => openAccessModal()); await sleep(600);
|
|
|
|
|
const alogText = await p.evaluate(() => document.getElementById('alogBody').innerText);
|
|
|
|
|
ok('访问记录含本次登录', alogText.includes('登录成功') && alogText.includes(SU));
|
|
|
|
|
await p.evaluate(() => document.getElementById('accessModal').classList.add('hidden'));
|
|
|
|
|
|
2026-08-27 16:40:29 +08:00
|
|
|
console.log('== 7c. 筛选规则(所有人) ==');
|
|
|
|
|
ok('顶栏筛选规则入口可见', await $('#rulesBtn').isVisible());
|
|
|
|
|
await p.evaluate(() => openRulesModal()); await sleep(600);
|
|
|
|
|
const rulesText = await p.evaluate(() => document.getElementById('rulesBody').innerText);
|
|
|
|
|
ok('规则页含核心内容', rulesText.includes('硬性预筛门槛') && rulesText.includes('七维评分'));
|
|
|
|
|
await p.evaluate(() => document.getElementById('rulesModal').classList.add('hidden'));
|
|
|
|
|
|
2026-08-27 12:20:31 +08:00
|
|
|
console.log('== 8. 退出 → HR登录 ==');
|
|
|
|
|
await p.evaluate(() => logout()); await sleep(800);
|
|
|
|
|
await p.goto(BASE + '/', { waitUntil: 'networkidle' });
|
|
|
|
|
await $('#f_user').fill('hr01'); await $('#f_pass').fill('hr123456');
|
|
|
|
|
await $('#loginBtn').click(); await sleep(900);
|
|
|
|
|
ok('HR进入主界面', await $('#main').isVisible());
|
2026-08-27 17:17:06 +08:00
|
|
|
const chips = await p.evaluate(() => document.getElementById('taskStatus').innerText);
|
|
|
|
|
ok(`任务状态灯显示双账号(${chips.replace(/\s+/g,' ').trim().slice(0,30)})`, chips.includes('hr1') && chips.includes('hr2'));
|
2026-08-27 14:21:39 +08:00
|
|
|
ok('HR看不到账号管理入口', await p.evaluate(() => getComputedStyle(document.getElementById('userMgrBtn')).display === 'none'));
|
2026-08-27 16:40:29 +08:00
|
|
|
ok('HR能看到筛选规则入口', await p.evaluate(() => getComputedStyle(document.getElementById('rulesBtn')).display !== 'none'));
|
|
|
|
|
const rulesForbidden = await p.evaluate(async () => (await fetch('/api/rules')).status);
|
|
|
|
|
ok('HR可访问规则API', rulesForbidden === 200);
|
2026-08-27 16:14:34 +08:00
|
|
|
ok('HR看不到访问记录入口', await p.evaluate(() => getComputedStyle(document.getElementById('accessLogBtn')).display === 'none'));
|
|
|
|
|
const alogForbidden = await p.evaluate(async () => (await fetch('/api/access-log')).status);
|
|
|
|
|
ok('HR访问记录API被403拦截', alogForbidden === 403);
|
2026-08-27 12:20:31 +08:00
|
|
|
const forbidden = await p.evaluate(async () => {
|
|
|
|
|
const r = await fetch('/api/users'); return r.status;
|
|
|
|
|
});
|
|
|
|
|
ok('HR访问用户API被403拦截', forbidden === 403);
|
|
|
|
|
|
|
|
|
|
console.log('== 9. 错误密码 ==');
|
|
|
|
|
await p.evaluate(() => logout()); await sleep(600);
|
|
|
|
|
await p.goto(BASE + '/', { waitUntil: 'networkidle' });
|
|
|
|
|
await $('#f_user').fill('hr01'); await $('#f_pass').fill('wrongpwd');
|
|
|
|
|
await $('#loginBtn').click(); await sleep(700);
|
|
|
|
|
ok('错误提示显示', (await $('#loginErr').innerText()).includes('错误'));
|
|
|
|
|
|
|
|
|
|
await b.close();
|
|
|
|
|
console.log(`\n结果: ${passed} 通过, ${failed} 失败`);
|
|
|
|
|
process.exit(failed ? 1 : 0);
|
|
|
|
|
})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
|