// 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' }); const boot1 = await p.evaluate(async () => (await (await fetch('/api/bootstrap')).json())); const wantTitle = boot1.needsInit ? '初始化超管账号' : '登录'; ok(`标题=${wantTitle}`, await $('#loginTitle').textContent() === wantTitle); ok('详情弹窗隐藏', await p.evaluate(() => getComputedStyle(document.getElementById('detailModal')).display === 'none')); await p.screenshot({ path: '/tmp/smoke-shots/01-init.png' }); 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); await p.keyboard.press('Enter'); // 回车登录路径 await p.waitForNavigation({ waitUntil: 'networkidle', timeout: 8000 }).catch(() => {}); await sleep(800); ok(boot.needsInit ? '注册超管成功' : '测试账号登录成功', await $('#main').isVisible()); 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('推荐')); const canvasN = await p.evaluate(() => document.querySelectorAll('#main canvas').length); ok(`图表已渲染(canvas=${canvasN},≥5)`, canvasN >= 5); console.log('== 4. 候选人列表与筛选(滑动到第二屏) =='); await p.evaluate(() => setPane(1)); await sleep(500); 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); 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); console.log('== 4b. 快捷标签页 =='); await p.evaluate(() => quickTab('valid')); await sleep(700); const validOk = await p.evaluate(() => { const rows = [...document.querySelectorAll('#tbody tr')]; return rows.length > 0 && rows.every(tr => tr.textContent.includes('推荐') || tr.textContent.includes('待定')); }); ok('"有效人员"页全部为推荐/待定', validOk); const todayRows = await p.evaluate(async () => { quickTab('today'); await new Promise(r => setTimeout(r, 600)); return document.querySelectorAll('#tbody tr.today').length; }); ok(`"今日"页当天高亮行(${todayRows})`, todayRows >= 0); await p.evaluate(() => quickTab('')); await sleep(500); console.log('== 5. 候选人详情弹窗 =='); await p.evaluate(() => document.querySelector('#tbody tr')?.click()); await sleep(800); ok('弹窗打开且含简历全文', (await $('#detailBody').innerText()).includes('简历全文')); 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('门槛备注')); console.log('== 7. 顶栏入口+弹框建HR账号 =='); ok('顶栏账号管理入口可见', await $('#userMgrBtn').isVisible()); await p.evaluate(() => openUserModal()); await sleep(600); ok('账号管理弹框打开', await $('#userModal').isVisible()); 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')); ok('停用/启用按钮存在', uText.includes('停用')); await p.screenshot({ path: '/tmp/smoke-shots/04-users.png' }); await p.evaluate(() => document.getElementById('userModal').classList.add('hidden')); 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()); ok('HR看不到账号管理入口', await p.evaluate(() => getComputedStyle(document.getElementById('userMgrBtn')).display === 'none')); 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); });