/* Browser plugin not available. 独立六栏面板:本机 HTTP + Chrome,不访问业务站点。 */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const http = require('node:http'); const { chromium } = require('playwright'); const workspace = path.resolve(__dirname, '..'); const panelSource = fs.readFileSync(path.join(workspace, 'assistant-panel.js'), 'utf8'); const qaDir = process.env.YIAN_TABS_QA_DIR || path.join(os.tmpdir(), 'yianxue-tabs-qa'); fs.mkdirSync(qaDir, { recursive: true }); async function main() { const server = http.createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); response.end('谊安学六栏面板验证

谊安学助手

离线模拟:按功能分栏,共用运行状态与全局暂停。

'); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); let browser; try { browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); const errors = []; page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); }); const origin = `http://127.0.0.1:${server.address().port}`; await page.goto(origin + '/panel-six-tabs'); assert.equal(page.url(), origin + '/panel-six-tabs'); assert.equal(await page.title(), '谊安学六栏面板验证'); await page.addScriptTag({ content: panelSource }); await page.evaluate(() => { window.qaCalls = { actions: [], settings: [], logs: [], search: [], auto: [], copy: [], export: [], exportEnabled: [], courses: [], imports: [], positions: [] }; window.qaPanel = YianAssistantPanel.create({ onTaskAction: action => qaCalls.actions.push(action), onLearningSetting: (key, value) => qaCalls.settings.push([key, JSON.parse(JSON.stringify(value))]), onLogAction: (action, value) => qaCalls.logs.push(value === undefined ? action : [action, value]), onSearch: (query, settings) => qaCalls.search.push({ query, settings }), onAutoChange: value => qaCalls.auto.push(value), onCopyChange: value => qaCalls.copy.push(value), onExport: format => qaCalls.export.push(format), onExportEnabledChange: value => qaCalls.exportEnabled.push(value), onCourseChange: value => qaCalls.courses.push(value), onImport: files => qaCalls.imports.push(files.map(file => file.name)), onPositionChange: position => qaCalls.positions.push(position) }); qaPanel.setState({ expanded: true, canAnswer: true, canExport: true, bankTotal: 5520, uniqueTotal: 1810, conflictTotal: 1, courses: ['安全领导力', '风险管理'], pageMode: 'test', pageStatus: '考试已就绪,按答案全文匹配。', metrics: { selected: 1, missing: 1, details: [{ number: 1, status: 'selected', question: '正确答案文字必须准确。', course: '安全领导力', correctTexts: ['技术控制'], currentLabels: ['D'] }, { number: 2, status: 'missing', reason: '题库未匹配' }] }, learning: { status: 'running', mode: 'all', execution: 'sequential', total: 9, completed: 2, skipped: 1, waiting: 1, failed: 0, etaSeconds: 362, message: '当前等待考试核验,视频正常学习。', currentTitle: '1-1安全生产方针和目标', tasks: [{ id: 'v1', type: 'video', title: '1-1安全生产方针和目标', status: 'playing' }, { id: 'e1', type: 'exam', title: '安全考试', status: 'waitingHuman', message: '预计得分 80 分,等待人工' }], video: { title: '1-1安全生产方针和目标', status: 'playing', currentTime: 45, duration: 633, learnedTime: 27, actualRate: 1.5, refreshCount: 1 }, exam: { title: '安全考试', status: 'waitingHuman', predictedScore: 80, remainingSeconds: 1000, elapsedSeconds: 361, preflightMessage: '已关联安全领导力', message: '正好 80 分,等待人工操作。' }, logs: [{ time: '10:00:00', level: 'info', message: '已识别学习任务' }, { time: '10:00:04', level: 'warning', message: '预计 80 分,等待人工' }, { time: '10:00:05', level: 'error', message: '模拟恢复失败' }] } }); }); const host = page.locator('#yian-question-assistant'); const tab = id => host.getByTestId('tab-' + id); const pane = id => host.getByTestId('pane-' + id); async function screenshot(name) { await page.screenshot({ path: path.join(qaDir, name), fullPage: false, animations: 'disabled' }); } async function visible() { await page.waitForFunction(() => { const box = qaPanel.host.getBoundingClientRect(); return box.width > 0 && box.height > 0 && box.left >= -1 && box.top >= -1 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1; }); } await visible(); assert.equal(await host.getByRole('tab').count(), 6); assert.equal(await tab('overview').getAttribute('aria-selected'), 'true'); assert(await pane('overview').isVisible()); assert.equal(await host.getByRole('radio').count(), 0, '统一题库不再有岗位选择'); assert(await host.getByTestId('learning-status').textContent().then(text => text.includes('等待人工'))); assert.equal(await host.getByTestId('setting-mode').inputValue(), 'all'); assert.equal(await host.getByTestId('setting-execution').inputValue(), 'sequential'); await screenshot('01-desktop-overview.png'); console.log('PASS six accessible tabs, unified bank, persistent status/progress and desktop layout'); const callsBeforeNavigation = await page.evaluate(() => JSON.stringify(qaCalls)); for (const key of ['video', 'exam', 'bank', 'console', 'settings', 'overview']) { await tab(key).click(); assert.equal(await tab(key).getAttribute('aria-selected'), 'true'); assert(await pane(key).isVisible()); assert(await host.getByTestId('global-pause').isVisible()); assert.equal(await host.getByRole('tabpanel').filter({ visible: true }).count(), 1); } assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsBeforeNavigation, '切栏不能修改设置、答题、导出或启动任务'); await tab('overview').focus(); await tab('overview').press('ArrowRight'); assert.equal(await tab('video').getAttribute('aria-selected'), 'true'); await tab('video').press('End'); assert.equal(await tab('settings').getAttribute('aria-selected'), 'true'); await tab('settings').press('Home'); assert.equal(await tab('overview').getAttribute('aria-selected'), 'true'); console.log('PASS navigation changes visibility only, supports keyboard and keeps global pause visible'); await tab('bank').click(); await host.getByRole('textbox', { name: '搜索题目' }).fill('安全控制'); await host.getByRole('textbox', { name: '搜索题目' }).press('Enter'); assert.equal(await page.evaluate(() => qaCalls.search.at(-1).query), '安全控制'); await host.getByRole('combobox', { name: '选择课程' }).selectOption('安全领导力'); assert.equal(await page.evaluate(() => qaCalls.courses.at(-1)), '安全领导力'); await page.evaluate(() => qaPanel.renderSearch({ total: 1, items: [{ type: 'single', question: '安全生产方针是什么?', options: [{ label: 'A', text: '安全第一' }, { label: 'B', text: '效率第一' }], answer: 'A', sources: [{ role: '主操', course: '安全领导力', file: '原题库.csv' }] }] })); assert.equal(await host.locator('.result').count(), 1); assert(await host.locator('.answer').textContent().then(text => text.includes('安全第一'))); await tab('console').click(); await tab('bank').click(); assert.equal(await host.getByRole('textbox', { name: '搜索题目' }).inputValue(), '安全控制'); assert.equal(await host.getByRole('combobox', { name: '选择课程' }).inputValue(), '安全领导力'); assert.equal(await host.locator('.result').count(), 1); await host.locator('input[type=file]').setInputFiles({ name: '统一题库.csv', mimeType: 'text/csv', buffer: Buffer.from('题目,答案\n题目,A') }); assert.deepEqual(await page.evaluate(() => qaCalls.imports), [['统一题库.csv']]); await host.getByText('课程与考试主题关联', { exact: true }).click(); await host.getByRole('textbox', { name: '关联题库课程' }).fill('安全领导力\n风险管理'); await host.getByRole('button', { name: '保存关联', exact: true }).click(); assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['associations', { '1-1安全生产方针和目标': ['安全领导力', '风险管理'] }]); await host.getByRole('button', { name: '删除关联', exact: true }).click(); assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['associations', {}]); console.log('PASS search/import without role, persistent query/filter/results and exact theme associations'); await tab('exam').click(); assert.equal(await host.getByTestId('setting-answerInterval').inputValue(), '4'); assert.equal(await host.getByTestId('setting-minSubmitMinutes').inputValue(), '6'); assert(await pane('exam').textContent().then(text => text.includes('80 分(题库估计)'))); const incompleteSwitch = host.getByTestId('setting-manualConfirmIncomplete'); assert.equal(await incompleteSwitch.getAttribute('role'), 'switch'); assert.equal(await incompleteSwitch.getAttribute('aria-label'), '题目不全时手动确认'); assert(await incompleteSwitch.isChecked(), '缺题手动确认默认开启'); assert(await host.getByTestId('exam-threshold').textContent().then(text => text.includes('题目不全时由用户在原网页手动确认'))); assert(await host.getByTestId('incomplete-confirmation-hint').textContent().then(text => text.includes('试卷未完整加载均禁止自动交卷'))); await incompleteSwitch.uncheck(); assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['manualConfirmIncomplete', false]); assert(await host.getByTestId('exam-threshold').textContent().then(text => text.includes('允许题库部分缺题时自动交卷') && text.includes('严格大于 80 分') && text.includes('所有可匹配题均已核验'))); await screenshot('02a-desktop-incomplete-switch-off.png'); const callsAfterIncompleteOff = await page.evaluate(() => JSON.stringify(qaCalls)); await tab('overview').click(); await page.evaluate(() => qaPanel.setState({ learning: { etaSeconds: 360 } })); await tab('exam').click(); assert(!await incompleteSwitch.isChecked(), '切栏及部分状态更新保留关闭状态'); assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsAfterIncompleteOff, '切栏不能修改缺题开关、自动答题或交卷'); await incompleteSwitch.check(); assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['manualConfirmIncomplete', true]); assert.deepEqual(await page.evaluate(() => qaCalls.settings.filter(([key]) => key === 'manualConfirmIncomplete')), [['manualConfirmIncomplete', false], ['manualConfirmIncomplete', true]]); assert.deepEqual(await page.evaluate(() => qaCalls.auto), [], '缺题开关与自动选择答案独立'); assert.deepEqual(await page.evaluate(() => qaCalls.actions), [], '缺题开关不启动或提交考试'); console.log('PASS incomplete-question switch defaults on, boolean setting callbacks, persistent tab state and independent answer controls'); await host.getByTestId('setting-answerInterval').fill('5'); await host.getByTestId('setting-answerInterval').press('Tab'); assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['answerInterval', 5]); await host.getByRole('switch', { name: '自动选择答案', exact: true }).check(); assert.deepEqual(await page.evaluate(() => qaCalls.auto), [true]); await host.locator('.answer-check summary').click(); assert(await host.locator('.answer-check-options').textContent().then(text => text.includes('D. 技术控制'))); await tab('overview').click(); await page.evaluate(() => qaPanel.setState({ learning: { etaSeconds: 359 } })); await tab('exam').click(); assert(await host.getByRole('switch', { name: '自动选择答案', exact: true }).isChecked()); assert(await host.locator('.answer-check').getAttribute('open') !== null); assert.equal(await host.getByTestId('setting-answerInterval').inputValue(), '5'); await host.getByTestId('task-examResume').click(); assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'examResume'); await page.evaluate(() => qaPanel.setState({ learning: { exam: { title: '安全考试', status: 'waiting', total: 25, verifiedCount: 21, unverifiedCount: 4, predictedScore: 84, requiresManualSubmission: true, elapsedSeconds: 360, remainingSeconds: 1000, message: '题目未全部核验,请在网页手动确认交卷' } } })); assert(await pane('exam').textContent().then(text => text.includes('84 分(题库估计)') && text.includes('21 / 25 题') && text.includes('原网页手动确认'))); await page.evaluate(() => qaPanel.setState({ learning: { manualConfirmIncomplete: false } })); assert(!await incompleteSwitch.isChecked()); assert(await pane('exam').textContent().then(text => text.includes('题目未全部核验或网站提示未答完,请在原网页手动确认')), '控制器要求人工时优先显示其提示'); await page.evaluate(() => qaPanel.setState({ learning: { manualConfirmIncomplete: true } })); assert(await incompleteSwitch.isChecked(), '面板反映外部恢复的开关配置'); assert.equal(await pane('exam').getByRole('button', { name: '确认交卷', exact: true }).count(), 0, '面板不能提供跳过缺题的交卷确认入口'); await host.locator('.panes').evaluate(node => { node.scrollTop = 0; }); await screenshot('02-desktop-exam.png'); console.log('PASS exam defaults/score/full answer evidence, partial learning updates and persistent switch/details state'); await tab('video').click(); assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('平台确认视频完成后') && text.includes('已开放的关联考试') && text.includes('人工保护仍生效'))); const callsBeforeModeHints = await page.evaluate(() => JSON.stringify(qaCalls)); for (const mode of ['video', 'videoQuiz']) { await page.evaluate(mode => qaPanel.setState({ learning: { mode } }), mode); assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('不会进入独立或关联考试') && text.includes('任务总览选择“视频+考试”'))); } await page.evaluate(() => qaPanel.setState({ learning: { mode: 'exam' } })); assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('本模式未启用视频'))); await page.evaluate(() => qaPanel.setState({ learning: { mode: 'all', skipSingleAttemptQuiz: false } })); assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('单次机会保护已关闭'))); await page.evaluate(() => qaPanel.setState({ learning: { skipSingleAttemptQuiz: true } })); assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsBeforeModeHints, '模式说明更新只展示状态,不启动任务或改设置'); const playbackSwitches = [ ['autoContinuePlayback', '自动继续上次播放'], ['skipSingleAttemptQuiz', '仅 1 次答题机会时交给我处理'], ['resumePlaybackOnReturn', '切回页面时自动恢复播放'] ]; for (const [key, label] of playbackSwitches) { const control = host.getByTestId('setting-' + key); assert.equal(await control.getAttribute('role'), 'switch'); assert.equal(await control.getAttribute('aria-label'), label); assert(await control.isChecked(), label + '默认开启'); await control.uncheck(); assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), [key, false]); } const callsAfterPlaybackOff = await page.evaluate(() => JSON.stringify(qaCalls)); await tab('overview').click(); await page.evaluate(() => qaPanel.setState({ learning: { etaSeconds: 358 } })); await tab('video').click(); for (const [key] of playbackSwitches) assert(!await host.getByTestId('setting-' + key).isChecked(), '切栏及部分更新保留视频开关状态'); assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsAfterPlaybackOff, '切栏不能启动续播或自动答题'); await page.evaluate(() => qaPanel.setState({ learning: { autoContinuePlayback: true, skipSingleAttemptQuiz: true, resumePlaybackOnReturn: true } })); for (const [key] of playbackSwitches) assert(await host.getByTestId('setting-' + key).isChecked(), '显示外部恢复的视频开关状态'); assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsAfterPlaybackOff, '读取保存状态不触发设置或任务回调'); assert(await host.getByTestId('playback-return-hint').textContent().then(text => text.includes('手动暂停优先') && text.includes('不会自动播放'))); await page.evaluate(() => qaPanel.setState({ learning: { video: { title: '1-1安全生产方针和目标', status: 'backgroundPaused', currentTime: 45, duration: 633, learnedTime: 27, actualRate: 1.5, refreshCount: 1 } } })); assert(await host.locator('.worker-status').textContent().then(text => text.includes('后台暂停,等待返回'))); await page.evaluate(() => qaPanel.setState({ learning: { video: { title: '1-1安全生产方针和目标', status: 'playing', currentTime: 45, duration: 633, learnedTime: 27, actualRate: 1.5, refreshCount: 1 } } })); await screenshot('02b-desktop-playback-switches.png'); for (const [key] of playbackSwitches) { await host.getByTestId('setting-' + key).uncheck(); await host.getByTestId('setting-' + key).check(); assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), [key, true]); } console.log('PASS playback switches default on, boolean callbacks, persisted rendered state and manual-pause guidance'); await host.getByTestId('setting-rate').selectOption('2'); assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['rate', 2]); await host.getByTestId('task-videoPause').click(); assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'videoPause'); assert(await pane('video').textContent().then(text => text.includes('00:27') && text.includes('尚未确认'))); await tab('settings').click(); assert(!await host.locator('.export-content').isVisible()); await host.getByRole('switch', { name: '允许复制文字', exact: true }).uncheck(); assert.deepEqual(await page.evaluate(() => qaCalls.copy), [false]); await host.getByRole('switch', { name: '导出题库', exact: true }).check(); assert(await host.locator('.export-content').isVisible()); await host.getByRole('button', { name: '导出 JSON', exact: true }).click(); assert.deepEqual(await page.evaluate(() => qaCalls.export), ['json']); await host.getByTestId('setting-maxRefresh').fill('2'); await host.getByTestId('setting-maxRefresh').press('Tab'); assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['maxRefresh', 2]); await tab('bank').click(); await tab('settings').click(); assert.deepEqual(await page.evaluate(() => qaCalls.export), ['json'], '切栏不会再次导出'); assert.equal(await host.getByTestId('setting-maxRefresh').inputValue(), '2'); console.log('PASS video rate/local pause, copy and gated folded export, configurable bounded recovery'); await tab('console').click(); await host.getByRole('combobox', { name: '日志级别' }).selectOption('warning'); assert.equal(await host.locator('.log-entry').count(), 1); await host.getByRole('button', { name: '复制日志', exact: true }).click(); await host.getByRole('button', { name: '清空显示', exact: true }).click(); assert.deepEqual(await page.evaluate(() => qaCalls.logs), ['copy', 'clear']); const diagnosticSwitch = host.getByRole('switch', { name: '持续记录诊断日志', exact: true }); assert(await diagnosticSwitch.isChecked(), '持久诊断记录默认开启'); await diagnosticSwitch.uncheck(); assert.deepEqual(await page.evaluate(() => qaCalls.logs.at(-1)), ['record', false]); await page.evaluate(() => qaPanel.setState({ diagnostics: { enabled: false, total: 37, dropped: 2, pending: 1, storage: 'userscript' } })); assert((await host.getByTestId('diagnostic-status').textContent()).includes('已停止新增记录')); assert((await host.getByTestId('diagnostic-status').textContent()).includes('已记录 37 条')); await tab('bank').click(); await tab('console').click(); assert.equal(await diagnosticSwitch.isChecked(), false, '切栏保留日志开关'); for (const [name, action] of [['导出诊断 JSON', 'exportJson'], ['导出诊断 TXT', 'exportText'], ['清空已保存诊断', 'clearSaved']]) { await host.getByRole('button', { name, exact: true }).click(); assert.equal(await page.evaluate(() => qaCalls.logs.at(-1)), action); } await page.evaluate(() => qaPanel.setState({ diagnostics: { storageError: '模拟配额不足' } })); assert((await host.getByTestId('diagnostic-status').textContent()).includes('可先导出当前记录')); assert.equal(await diagnosticSwitch.isChecked(), false, '部分诊断状态更新保留关闭值'); await diagnosticSwitch.check(); assert.deepEqual(await page.evaluate(() => qaCalls.logs.at(-1)), ['record', true]); await page.evaluate(() => qaPanel.setState({ diagnostics: { enabled: true, storageError: '', pending: 0 } })); await diagnosticSwitch.scrollIntoViewIfNeeded(); await screenshot('05-desktop-diagnostics.png'); await page.evaluate(() => qaPanel.setState({ learning: { logs: Array.from({ length: 510 }, (_, index) => ({ time: '10:00:00', level: 'info', message: '任务状态 ' + index })) } })); await host.getByRole('combobox', { name: '日志级别' }).selectOption('all'); assert.equal(await host.locator('.log-entry').count(), 500); await host.getByTestId('global-pause').click(); assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'pause'); await page.evaluate(() => qaPanel.setState({ learning: { status: 'paused' } })); assert.equal(await host.getByTestId('global-pause').textContent(), '继续全部'); await host.getByTestId('global-pause').click(); assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'resume'); await page.evaluate(() => qaPanel.setState({ learning: { status: 'running', waiting: 0, leaseWaiting: 1, video: { status: 'waitingLease' }, tasks: [{ id: 'lease1', type: 'exam', title: '等待中的考试', status: 'waitingLease' }] } })); assert((await host.getByTestId('learning-status').textContent()).includes('1 项等待执行权')); assert(!(await host.getByTestId('learning-status').textContent()).includes('等待人工')); await tab('overview').click(); assert((await pane('overview').textContent()).includes('等待执行权(自动重试)')); console.log('PASS filtered/capped runtime logs, persisted diagnostics controls/export/clear and shared global pause/resume; lease waits are distinct from manual waits'); await tab('overview').click(); await page.evaluate(() => qaPanel.setState({ position: { left: 100, top: 20 }, learning: { logs: [], status: 'running' } })); await visible(); const box = await host.boundingBox(); const brand = await host.locator('.brand').boundingBox(); await page.mouse.move(brand.x + 20, brand.y + brand.height / 2); await page.mouse.down(); await page.mouse.move(brand.x + 55, brand.y + brand.height / 2 + 20, { steps: 5 }); await page.mouse.up(); assert.equal(await host.locator('.toggle').getAttribute('aria-expanded'), 'true'); assert.equal(await page.evaluate(() => qaCalls.positions.length), 1); const moved = await host.boundingBox(); assert(Math.abs(moved.x - box.x - 35) <= 2); await page.setViewportSize({ width: 390, height: 844 }); await visible(); assert.equal(await host.locator('.navigation').evaluate(element => getComputedStyle(element).flexDirection), 'row'); await tab('video').click(); for (const [key] of playbackSwitches) { const control = host.getByTestId('setting-' + key); await control.scrollIntoViewIfNeeded(); assert(await control.isVisible()); assert(await control.isChecked()); } await screenshot('03b-mobile-playback-switches.png'); await tab('console').click(); await diagnosticSwitch.scrollIntoViewIfNeeded(); assert(await diagnosticSwitch.isVisible()); assert(await diagnosticSwitch.isChecked()); await screenshot('06-mobile-diagnostics.png'); await tab('exam').click(); await incompleteSwitch.scrollIntoViewIfNeeded(); assert(await incompleteSwitch.isVisible()); assert(await incompleteSwitch.isChecked()); await screenshot('03a-mobile-incomplete-switch-on.png'); await tab('bank').click(); await screenshot('03-mobile-bank.png'); await page.setViewportSize({ width: 320, height: 480 }); await visible(); await tab('overview').click(); await screenshot('04-small-overview.png'); await host.locator('.toggle').click(); assert.equal(await host.locator('.toggle').getAttribute('aria-expanded'), 'false'); assert(await host.locator('.collapse-summary').isVisible()); await visible(); await host.locator('.toggle').click(); await visible(); assert.equal(await tab('overview').getAttribute('aria-selected'), 'true'); assert.deepEqual(errors, []); assert.equal(await page.locator('vite-error-overlay, nextjs-portal, #webpack-dev-server-client-overlay').count(), 0); console.log('PASS desktop drag, mobile horizontal tabs, small viewport clamp, collapse summary and no console/runtime errors'); console.log('截图:' + qaDir); } finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); } } main().catch(error => { console.error(error); process.exitCode = 1; });