/* 桌面考试端到端验证:只载入中和后的保存 DOM,原站脚本及业务请求均不运行。 */ 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 { pathToFileURL } = require('node:url'); const { chromium } = require('playwright'); const workspace = path.resolve(__dirname, '..'); const scriptPath = path.join(workspace, '谊安学助手 2.0.user.js'); const script = fs.readFileSync(scriptPath, 'utf8'); const coreScript = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8'); assert(script.includes('// @name 谊安学助手 2.0')); assert.deepEqual(Array.from(script.matchAll(/^\/\/ @match\s+(.+)$/gm), match => match[1].trim()), ['http://10.119.67.7/clientcn/*']); const qaDir = process.env.YIAN_EXAM_QA_DIR || path.join(os.tmpdir(), 'yianxue-exam-qa'); fs.mkdirSync(qaDir, { recursive: true }); const roles = ['主操', '班长', '副操']; const bankPaths = roles.map(role => path.join(workspace, '题库', role + '_合并题库.csv')); const samplePath = path.join(workspace, '网页样本', '谊安学考试', '谊安学.html'); const realSample = fs.existsSync(samplePath) && bankPaths.every(file => fs.existsSync(file)) && process.env.YIAN_TEST_SYNTHETIC_EXAM !== '1'; const normalize = text => String(text == null ? '' : text).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim(); const sortedTexts = texts => texts.map(normalize).sort(); const escapeHTML = text => String(text).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); function syntheticFixture() { const course = '考试自动选择验证'; const records = []; const questions = Array.from({ length: 40 }, (_, index) => { const type = index < 15 ? '单选' : index < 30 ? '多选' : '判断'; const texts = type === '判断' ? ['对', '错'] : type === '多选' ? ['正确甲' + index, '正确乙' + index, '错误丙' + index, '错误丁' + index] : ['正确甲' + index, '错误乙' + index, '错误丙' + index, '错误丁' + index]; const question = '考试核对题 ' + (index + 1) + ':请选择符合要求的选项。'; roles.forEach(role => records.push({ role, course, number: index + 1, type, question, options: texts.map((text, i) => ({ label: String.fromCharCode(65 + i), text })), answer: type === '多选' ? 'AB' : 'A' })); return { type, question, texts: index < 20 ? texts.slice(1).concat(texts[0]) : texts }; }); const html = `
${course}
${questions.map((q, index) => `

[${q.type}] ${index + 1}. ${q.question}(2分)

难度低
${q.texts.map((text, i) => `
${String.fromCharCode(65 + i)}${text}
`).join('')}
`).join('')}
倒计时:00:29:24

0

40

${questions.map((_, i) => `
${i + 1}
`).join('')}
交卷
`; return { html, title: course, records }; } function documentHTML(fixture) { return `助手考试验证

考试答案全文核对

本地验证:40 道保存题目,交卷由用户操作。

${fixture.html}`; } async function activate(page, key) { const expand = page.getByRole('button', { name: '展开题库助手', exact: true }); if (await expand.isVisible()) await expand.click(); await page.getByTestId('tab-' + key).click(); } // 模拟题目组件自己的作答状态。点击先更新独立模型,再根据模型渲染 class 与答题卡。 // 不加载原站 Angular,也不实现提交接口。 function installExamModel() { const CHECKED = 'examtest_content_list_des_checked'; const root = document.querySelector('app-examtest'); const extracted = QuestionBankCore.extract(document); const boxes = Array.from(root.querySelectorAll('.pc_examtest_content')); const cards = Array.from(root.querySelectorAll('.examtestcard_count_defult')); document.querySelectorAll('.qa-confirm-dialog').forEach(node => node.remove()); const confirmation = document.createElement('div'); confirmation.className = 'modal dialog_box qa-confirm-dialog'; confirmation.setAttribute('aria-hidden', 'true'); const message = document.createElement('div'); message.className = 'modal-body'; message.textContent = '是否确认交卷?'; confirmation.appendChild(message); document.body.appendChild(confirmation); const model = { questions: extracted.questions.map(q => ({ number: q.number, type: q.type, options: q.options.map(option => ({ label: option.label, text: option.text })) })), userAnswer: [], clicks: [], commitClicks: 0 }; model.userAnswer = boxes.map(box => Array.from(box.querySelectorAll('.pc_examtest_content_list_des')).filter(node => node.classList.contains(CHECKED)).map(node => node.querySelector('.examtest_content_list_des_optionsort').textContent.trim())); const render = index => { const selected = new Set(model.userAnswer[index]); Array.from(boxes[index].querySelectorAll('.pc_examtest_content_list_des')).forEach((node, optionIndex) => node.classList.toggle(CHECKED, selected.has(model.questions[index].options[optionIndex].label))); if (cards[index]) cards[index].classList.toggle('examtestcard_count_assignment', selected.size > 0); const count = model.userAnswer.filter(answer => answer.length > 0).length; const counters = root.querySelectorAll('.pc_examtest_right_col h4'); if (counters[0]) counters[0].textContent = String(count); if (counters[1]) counters[1].textContent = String(boxes.length - count); }; boxes.forEach((box, index) => { const question = model.questions[index]; Array.from(box.querySelectorAll('.pc_examtest_content_list_des')).forEach((node, optionIndex) => { node.addEventListener('click', () => { const option = question.options[optionIndex]; model.clicks.push({ number: question.number, label: option.label, text: option.text }); if (question.type === '多选') { const next = new Set(model.userAnswer[index]); if (next.has(option.label)) next.delete(option.label); else next.add(option.label); model.userAnswer[index] = Array.from(next); } else model.userAnswer[index] = [option.label]; render(index); }); }); render(index); }); const commit = root.querySelector('.pc_examtest_commit'); if (commit) commit.addEventListener('click', () => { model.commitClicks++; confirmation.classList.add('show'); confirmation.setAttribute('aria-hidden', 'false'); }); window.examModel = model; } (async () => { const startedAt = Date.now(); const memory = new Map(); const errors = [], externalRequests = [], downloads = []; let browser, server; try { browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const context = await browser.newContext({ viewport: { width: 1366, height: 900 }, acceptDownloads: true }); await context.exposeBinding('__gmRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback); await context.exposeBinding('__gmWrite', (_, key, value) => { memory.set(key, structuredClone(value)); }); const sandboxScript = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);const GM_setValue=(key,value)=>window.__gmWrite(key,value);\n' + script + '\n})();'; const origin = 'http://10.119.67.7'; let localOrigin = ''; await context.route('**/*', async route => { const url = new URL(route.request().url()); if (url.origin === origin && localOrigin) { const response = await route.fetch({ url: localOrigin + url.pathname + url.search }); return route.fulfill({ response }); } if (localOrigin && url.origin === localOrigin || url.protocol === 'file:' && url.href.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue(); externalRequests.push(url.href); return route.abort(); }); const capture = page => { page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); page.on('download', download => downloads.push(download.suggestedFilename())); }; const prepare = await context.newPage(); capture(prepare); await prepare.addScriptTag({ content: coreScript }); let fixture; if (realSample) { fixture = await prepare.evaluate(raw => { const inert = QuestionBankCore.parseHTML(raw); const root = inert.querySelector('app-examtest'); if (!root) throw new Error('真实考试样本缺少 app-examtest'); const bank = QuestionBankCore.extract(inert); if (root.querySelector('script,[src],[srcset],iframe[src],link[href]')) throw new Error('样本中存在未中和的可加载资源'); return { html: root.innerHTML, title: bank.title, count: bank.total }; }, fs.readFileSync(samplePath, 'utf8')); assert.equal(fixture.count, 40, '真实考试应有 40 题'); } else fixture = syntheticFixture(); await prepare.close(); const pageHTML = documentHTML(fixture); server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(pageHTML); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); localOrigin = 'http://127.0.0.1:' + server.address().port; const examURL = origin + '/clientcn/exam/nested/exam-path?flag=test&title=' + encodeURIComponent(fixture.title); const page = await context.newPage(); capture(page); async function open(page, url) { await page.goto(url); await page.addScriptTag({ content: sandboxScript }); await page.evaluate(installExamModel); await page.evaluate(() => YianAssistant.ready); await page.evaluate(() => YianAssistant.setLearningSetting('answerInterval', 0)); await activate(page, 'exam'); } await open(page, examURL); assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false); await activate(page, 'settings'); assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false); assert.equal(await page.locator('#yian-question-assistant .section-title').filter({ hasText: '当前考试' }).count(), 1); const extracted = await page.evaluate(() => YianAssistant.collectCurrent().questions); assert.equal(extracted.length, 40); extracted.forEach(question => { assert(!/[((]\s*\d+(?:\.\d+)?\s*分\s*[))]/.test(question.question), '题干应去除分值'); assert(!/(?:难度低|难度中|难度高)/.test(question.question), '题干应去除难度'); }); const bankFiles = realSample ? bankPaths : { name: '三岗位考试题库.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(fixture.records)) }; await activate(page, 'bank'); await page.locator('#yian-question-assistant input[type=file]').setInputFiles(bankFiles); await page.waitForFunction(() => YianAssistant.getSnapshot().counts.主操 > 0 && YianAssistant.getSnapshot().counts.班长 > 0 && YianAssistant.getSnapshot().counts.副操 > 0); console.log('PASS ' + (realSample ? '真实保存考试 DOM 与仓库三岗位 CSV 导入' : '可独立运行的 40 题 / 三岗位内部 fixture') + '、考试识别、默认关闭、分值与难度去除'); const pages = [page, await context.newPage(), await context.newPage()]; pages.slice(1).forEach(capture); await Promise.all(pages.map(async (target, index) => { if (index) await open(target, examURL + '&sessioncase=' + index); await target.evaluate(async () => { const title = YianAssistant.collectCurrent().title; const courses = Array.from(new Set(YianAssistant.getSnapshot().records.map(record => record.course))).filter(Boolean); await YianAssistant.setLearningSetting('associations', { [title]: courses }); }); await activate(target, 'exam'); assert.equal(await target.getByRole('radio').count(), 0, '统一题库没有岗位选择'); await target.waitForFunction(() => YianAssistant.getContext().ready); assert((await target.evaluate(() => YianAssistant.getContext().courses || [])).length > 0, '明确关联后须确认可用题库主题,再启动答题'); const expectations = await target.evaluate(() => { const context = YianAssistant.getContext(), snapshot = YianAssistant.getSnapshot(); return YianAssistant.collectCurrent().questions.map(question => { const matched = YianAnswerMatcher.match(question, context, snapshot); return { number: question.number, type: question.type, status: matched.status, reason: matched.reason, originalAnswer: matched.records[0] && matched.records[0].answer, bankTexts: matched.records[0] && matched.records[0].correctTexts, currentLabels: matched.correctLabels, evidenceTexts: matched.correctTexts, sourceRoles: Array.from(new Set((matched.records || []).map(record => record.role))).sort() }; }); }); assert.equal(expectations.filter(item => item.status === 'matched').length, 40, '统一题库应匹配 40 / 40;' + JSON.stringify(expectations.filter(item => item.status !== 'matched'))); expectations.forEach(item => assert.deepEqual(item.sourceRoles, roles.slice().sort(), '同题聚合保留三个岗位来源')); assert.equal(expectations.filter(item => item.currentLabels.join('') !== item.originalAnswer).length, 20, '应有 20 题需要按全文重新映射字母'); target.__expectations = expectations; await target.getByRole('switch', { name: '自动选择答案' }).check(); await target.waitForFunction(() => YianAssistant.getState().metrics.selected === 40, undefined, { timeout: 35000 }).catch(async error => { console.error('考试超时诊断:' + JSON.stringify(await target.evaluate(() => { const state = YianAssistant.getState(), context = YianAssistant.getContext(); return { autoAnswer: state.autoAnswer, metrics: state.metrics, examStage: state.exam.stage, examMessage: state.exam.message, ready: context.ready, courses: context.courses, clicks: examModel.clicks.length, notice: document.querySelector('#yian-question-assistant').shadowRoot.querySelector('.notice').textContent }; }))); throw error; }); const actual = await target.evaluate(() => { const bank = YianAssistant.collectCurrent(); const rows = Array.from(document.querySelectorAll('.pc_examtest_content')); return { state: YianAssistant.getState(), commitClicks: examModel.commitClicks, cards: document.querySelectorAll('.examtestcard_count_assignment').length, selectedTexts: rows.map((box, index) => Array.from(box.querySelectorAll('.pc_examtest_content_list_des')).flatMap((node, i) => node.classList.contains('examtest_content_list_des_checked') ? [bank.questions[index].options[i].text] : [])), modelTexts: examModel.questions.map((question, index) => question.options.filter(option => examModel.userAnswer[index].includes(option.label)).map(option => option.text)), evidence: Array.from(document.querySelector('#yian-question-assistant').shadowRoot.querySelectorAll('.answer-check-item')).map(node => node.textContent) }; }); assert.equal(actual.state.metrics.pending, 0, '考试多选不应显示待练习确认'); assert.equal(actual.state.metrics.failed + actual.state.metrics.locked + actual.state.metrics.missing + actual.state.metrics.conflict + actual.state.metrics.unsupported, 0); assert.equal(actual.commitClicks, 0, '脚本不得点击交卷'); assert.equal(actual.cards, 40, '独立模型应更新全部答题卡'); assert.equal(actual.evidence.length, 40); expectations.forEach((expected, qIndex) => { assert.deepEqual(sortedTexts(actual.selectedTexts[qIndex]), sortedTexts(expected.bankTexts), '会话 ' + (index + 1) + ' 第 ' + expected.number + ' 题 DOM 应等于题库全文'); assert.deepEqual(sortedTexts(actual.modelTexts[qIndex]), sortedTexts(expected.bankTexts), '会话 ' + (index + 1) + ' 第 ' + expected.number + ' 题独立作答模型应等于题库全文'); expected.evidenceTexts.forEach((text, optionIndex) => assert(normalize(actual.evidence[qIndex]).includes(normalize(expected.currentLabels[optionIndex] + '. ' + text)), '核对面板应显示原库全文与对应当前字母')); }); await target.getByRole('switch', { name: '自动选择答案' }).uncheck(); console.log('PASS 统一题库会话 ' + (index + 1) + ':40 / 40 已选择,20 题字母变化;三岗位来源聚合,所有 DOM、独立模型及核对面板均与正确答案全文一致'); })); await Promise.all(pages.slice(1).map(target => target.close())); const evidenceDetails = page.locator('#yian-question-assistant .answer-check'); await evidenceDetails.locator('summary').click(); await page.locator('#yian-question-assistant .answer-check-item').first().evaluate(node => { node.dataset.qaPersistent = '1'; }); await page.waitForTimeout(650); assert.equal(await evidenceDetails.evaluate(node => node.open), true); assert.equal(await page.locator('#yian-question-assistant .answer-check-item').first().getAttribute('data-qa-persistent'), '1', '刷新不应重建核对节点'); await page.screenshot({ path: path.join(qaDir, 'exam-desktop.png') }); await page.locator('#yian-question-assistant').screenshot({ path: path.join(qaDir, 'exam-answer-panel.png') }); await page.setViewportSize({ width: 390, height: 844 }); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), '小屏页面不得横向溢出'); await page.screenshot({ path: path.join(qaDir, 'exam-mobile.png') }); await page.setViewportSize({ width: 1366, height: 900 }); await activate(page, 'settings'); await page.evaluate(() => YianAssistant.setExportEnabled(true)); assert.equal(await page.getByRole('button', { name: '导出 CSV', exact: true }).isDisabled(), true); assert.equal(await page.evaluate(() => { try { YianAssistant.exportCurrent('csv'); return ''; } catch (error) { return error.message; } }), '请先进入支持的练习页面'); await page.evaluate(() => YianAssistant.setExportEnabled(false)); await activate(page, 'exam'); console.log('PASS 答案核对展开状态 / 节点复用、桌面与小屏截图、考试禁用导出'); let sequence = 0; async function reset(mode = 'test', mutate, waitReady = true) { await page.evaluate(({ html, mode, title, sequence }) => { YianAssistant.setAutoAnswer(false); const root = document.createElement('app-examtest'); root.innerHTML = html; document.querySelector('app-examtest').replaceWith(root); history.replaceState({}, '', '/clientcn/exam/case-' + sequence + '?flag=' + mode + '&title=' + encodeURIComponent(title)); if (mode === 'exercise') { root.querySelector('.pc_examtest_commit').textContent = '重新练习'; root.querySelectorAll('.pc_examtest_content_question p').forEach(p => { const button = document.createElement('span'); button.className = 'pc_examtest_content_question_showanswer'; button.textContent = '查看答案'; p.appendChild(button); }); } }, { html: fixture.html, mode, title: fixture.title, sequence: ++sequence }); if (mutate) await page.evaluate(mutate); await page.evaluate(installExamModel); if (waitReady) await page.waitForFunction(() => YianAssistant.getContext().ready); else await page.waitForTimeout(1600); await activate(page, 'exam'); } await reset(); await page.getByRole('switch', { name: '自动选择答案' }).check(); await page.waitForFunction(() => examModel.clicks.length > 0); await page.evaluate(() => YianAssistant.setAutoAnswer(false)); const stoppedAt = await page.evaluate(() => examModel.clicks.length); await page.waitForTimeout(400); assert.equal(await page.evaluate(() => examModel.clicks.length), stoppedAt); assert(stoppedAt < 40, '应在后续题目之前关闭'); // 考试的单选可以改选:关闭后手动改第一题,再开启应重新核对已答题。 const wrongIndex = await page.evaluate(() => { const q = YianAssistant.collectCurrent().questions[0]; const matched = YianAnswerMatcher.match(q, YianAssistant.getContext(), YianAssistant.getSnapshot()); return q.options.findIndex(option => !matched.correctLabels.includes(option.label)); }); await page.locator('.pc_examtest_content').first().locator('.pc_examtest_content_list_des').nth(wrongIndex).click(); await page.getByRole('switch', { name: '自动选择答案' }).check(); await page.waitForFunction(() => { const q = YianAssistant.collectCurrent().questions[0]; const matched = YianAnswerMatcher.match(q, YianAssistant.getContext(), YianAssistant.getSnapshot()); return examModel.userAnswer[0].length === matched.correctLabels.length && matched.correctLabels.every(label => examModel.userAnswer[0].includes(label)); }); await page.evaluate(() => YianAssistant.setAutoAnswer(false)); console.log('PASS 关闭立即停止后续点击,恢复后重新核对人工改过的单选'); await reset(); await page.getByRole('switch', { name: '自动选择答案' }).check(); await page.waitForFunction(() => examModel.clicks.length > 0); await page.getByRole('button', { name: '收起题库助手' }).click(); await page.locator('.pc_examtest_commit').click(); const modalClicks = await page.evaluate(() => examModel.clicks.length); await page.waitForFunction(() => YianAssistant.getState().autoAnswer === false); await page.waitForTimeout(250); assert.equal(await page.evaluate(() => examModel.clicks.length), modalClicks, '交卷确认打开后不得继续选择'); assert.equal(await page.evaluate(() => examModel.commitClicks), 1, '只有测试模拟的用户点击了一次交卷'); assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false); console.log('PASS 用户打开交卷确认后停止,确认期间拒绝重新开启'); await reset('test', () => { const blocker = document.createElement('div'); blocker.id = 'examtestDisabled'; document.querySelector('app-examtest').appendChild(blocker); }); assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false); assert.equal(await page.evaluate(() => examModel.clicks.length), 0); await reset('test', () => { const blocker = document.createElement('div'); blocker.setAttribute('beingsubmitted', ''); blocker.textContent = '正在提交'; document.querySelector('app-examtest').appendChild(blocker); }); assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false); assert.equal(await page.evaluate(() => examModel.clicks.length), 0); await reset(); await page.getByRole('switch', { name: '自动选择答案' }).check(); await page.waitForFunction(() => examModel.clicks.length > 0); await page.evaluate(() => { document.querySelector('.pc_examtest_countdown .time').textContent = '00:00:00'; }); const expiredAt = await page.evaluate(() => examModel.clicks.length); await page.waitForFunction(() => YianAssistant.getState().autoAnswer === false); assert.equal(await page.evaluate(() => examModel.clicks.length), expiredAt); assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false); console.log('PASS 提交 disabled 状态拒绝开启,倒计时 00 停止'); await reset('unknown'); assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false); await reset('test', () => { document.querySelector('.pc_examtest_left').className = 'mobile_examtest_left'; }); assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false); await reset('test', () => { document.querySelectorAll('.examtestcard_count_defult').forEach(node => node.remove()); }, false); assert.equal(await page.evaluate(() => YianAssistant.getState().pageReady), false, '空答题卡不能确认完整试卷'); assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false); const filePath = path.join(qaDir, 'saved-exam.html'); fs.writeFileSync(filePath, '\n' + pageHTML); const saved = await context.newPage(); capture(saved); await saved.goto(pathToFileURL(filePath).href); const inspectSaved = () => saved.evaluate(() => ({ body: document.body.innerHTML, select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), style: document.body.getAttribute('style'), userSelect: getComputedStyle(document.body).userSelect, selected: document.querySelectorAll('.examtest_content_list_des_checked').length })); const beforeSaved = await inspectSaved(); await saved.addScriptTag({ content: sandboxScript }); assert.equal(await saved.locator('#yian-question-assistant').count(), 0); assert.deepEqual(await saved.evaluate(() => [typeof window.JSZip, typeof window.QuestionBankCore, typeof window.YianAssistant]), ['undefined', 'undefined', 'undefined']); assert.deepEqual(await inspectSaved(), beforeSaved, 'file 保存考试不能注入或改变复制限制和选项'); await saved.close(); await reset('exercise'); const navigation = await page.evaluate(({ html, title }) => { const enabled = YianAssistant.setAutoAnswer(true); const root = document.createElement('app-examtest'); root.innerHTML = html; document.querySelector('app-examtest').replaceWith(root); history.pushState({}, '', '/clientcn/exam/final-exam?flag=test&title=' + encodeURIComponent(title)); dispatchEvent(new PopStateEvent('popstate')); return { enabled, afterNavigation: YianAssistant.getState().autoAnswer }; }, { html: fixture.html, title: fixture.title }); assert.equal(navigation.enabled, true); assert.equal(navigation.afterNavigation, false); await page.waitForTimeout(250); assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false); console.log('PASS 未知 flag、移动页面拒绝自动选择;file 保存考试完全不注入;练习转考试恢复关闭'); assert.deepEqual(externalRequests, [], '测试不允许任何外部业务请求'); assert.deepEqual(downloads, [], '考试不得自动或手动发起题库下载'); assert.deepEqual(errors, [], '页面和控制台不应出现错误'); console.log('PASS 零外部请求、零交卷接口调用、零导出下载、无页面 / 控制台错误'); console.log('截图:' + qaDir); console.log('总耗时:' + ((Date.now() - startedAt) / 1000).toFixed(1) + ' 秒'); } finally { if (browser) await browser.close(); if (server) await new Promise(resolve => server.close(resolve)); } })().catch(error => { console.error(error); process.exitCode = 1; });