/** * 作息报时器(schedule_reminder)前端端到端验证(一次性调试脚本) * * 做法: * 1. 在 8979 起一个精简 express(静态 public + app_order_box API),不碰 8976 主服务; * 2. 用项目自带的 puppeteer 打开 web_order_box 页面,走完整用户链路: * 进入报时器 → 新建日程(时刻设为"当前北京时间分钟",使其立刻到点)→ 保存 → * 用这套待机 → 校验到点播报弹窗 + 播报记录 → 点「我知道了」→ 校验记录变"已确认"; * 3. 收集页面 pageerror / console.error,最后打印结果。 * * 运行:node dev_test_scripts/debug/debug_schedule_reminder_frontend.js */ const fs = require('fs') const path = require('path') const express = require('express') const puppeteer = require('puppeteer') const PORT = 8979 const BASE = `http://127.0.0.1:${PORT}` const DB_FILE = path.join(process.cwd(), 'data', 'app_order_box_schedule_reminder.db') const DB_EXISTED_BEFORE = fs.existsSync(DB_FILE) const sleep = ms => new Promise(r => setTimeout(r, ms)) // 本机未下载 puppeteer 自带 Chrome,改用系统已安装的 Chrome / Edge const CHROME_CANDIDATES = [ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', ] const findChrome = () => CHROME_CANDIDATES.find(p => fs.existsSync(p)) || undefined const beijingHHmm = (offsetMs) => new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', }).format(new Date(Date.now() + (offsetMs || 0))) let passed = 0 let failed = 0 const check = (name, ok, extra) => { if (ok) { passed += 1; console.log(` ✔ ${name}`) } else { failed += 1; console.log(` ✘ ${name}${extra ? ' → ' + extra : ''}`) } } const buildServer = () => { const app = express() app.use(express.json()) const pub = path.join(process.cwd(), 'public') app.use(express.static(pub)) app.get('/tools/web_order_box', (req, res) => res.sendFile(path.join(pub, 'tools', 'web_order_box', 'index.html'))) const { bindRoutes } = require('../../src/server/app_order_box') bindRoutes(app) return app.listen(PORT) } const cleanupDb = () => { if (DB_EXISTED_BEFORE) return ;['', '-wal', '-shm'].forEach(suffix => { const p = DB_FILE + suffix try { if (fs.existsSync(p)) fs.unlinkSync(p) } catch (_) { /* 静默 */ } }) console.log('(测试库为本次新建,已清理:' + path.basename(DB_FILE) + ')') } const clickByText = (page, selector, text) => page.evaluate((sel, txt) => { const btns = Array.from(document.querySelectorAll(sel)) const target = btns.find(b => String(b.textContent || '').indexOf(txt) >= 0) if (target) { target.click(); return true } return false }, selector, text) const visible = (page, selector) => page.evaluate(sel => { const el = document.querySelector(sel) if (!el) return false const st = window.getComputedStyle(el) return st.display !== 'none' && st.visibility !== 'hidden' }, selector) const textOf = (page, selector) => page.evaluate(sel => { const el = document.querySelector(sel) return el ? String(el.textContent || '').trim() : '' }, selector) /** 模拟用户点掉浮层(语音检查弹窗 / 交互详情弹窗),避免其遮挡后续点击 */ const dismissOverlays = async (page) => { await page.evaluate(() => { const pick = (id, re) => { const root = document.getElementById(id) if (!root || window.getComputedStyle(root).display === 'none') return const b = Array.from(root.querySelectorAll('button')).find(x => re.test(String(x.textContent || ''))) if (b) b.click() } pick('dialogOverlay', /听到了|知道了|确定|取消|没听到/) pick('interactionOverlay', /关闭/) }) await sleep(250) } const run = async (page) => { console.log('=== 作息报时器 · 前端端到端验证 ===\n') // ---------- 1. 进入报时器 ---------- console.log('[1] 进入作息报时器') // 先确保框子停在 Hub(否则 activeKit 残留会让页面自动进入 kit,Hub 卡片不可点) await fetch(`${BASE}/api/app_order_box/state/active-kit`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }), }) await page.goto(`${BASE}/tools/web_order_box/index.html`, { waitUntil: 'networkidle2', timeout: 30000 }) await page.waitForFunction(() => { const b = document.getElementById('btnEnterSchedule') return b && !b.disabled }, { timeout: 15000 }) // ⚠️ 安全底线:快照已有日程 id,清理阶段只删本次新建的, // 绝不删除数据库里原本就存在的用户数据(曾因无差别删除误删过用户真实日程)。 const existingRes = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json() const existingIds = (existingRes.data || []).map(s => s.id) await page.evaluate(ids => { window.__PRE_EXISTING_IDS = ids }, existingIds) if (existingIds.length) console.log(`(检测到已有 ${existingIds.length} 套日程,本次不会删除它们)\n`) check('Hub 上作息报时器卡片已启用', true) await page.click('#btnEnterSchedule') await sleep(600) check('切换到报时器视图', await visible(page, '#viewSchedule')) await sleep(3600) // 等交互详情弹窗(normal 2s / debug 3s)收起 await dismissOverlays(page) await dismissOverlays(page) const clock = await textOf(page, '#schedClock') check('大字时钟已按 HH:mm:ss 走字', /^\d{2}:\d{2}:\d{2}$/.test(clock), clock) const dateText = await textOf(page, '#schedDate') check('日期为北京时间文案', dateText.indexOf('星期') >= 0, dateText) // ---------- 2. 新建日程 ---------- console.log('\n[2] 新建作息日程(时刻设为当前分钟,便于立刻到点)') await page.click('#btnNewSchedule') await sleep(400) check('编辑器已打开', await visible(page, '#schedEditorModal')) // 生效星期 chip:默认全选 → 每个都带 √;取消勾选 √ 消失;chip 不换行 const readWeekdayChips = () => page.$$eval('.weekday-chip', els => els.map(e => ({ text: String(e.textContent || '').trim(), active: e.classList.contains('active'), height: e.offsetHeight, }))) const clickWeekday = (w) => page.evaluate(n => { const b = document.querySelector('.weekday-chip[data-weekday="' + n + '"]') if (b) b.click() }, w) let chips = await readWeekdayChips() check('星期 chip 共 7 个', chips.length === 7, chips.length) check('默认全选:7 个都带 √', chips.every(c => c.text.indexOf('√') === 0), JSON.stringify(chips.map(c => c.text))) check('星期 chip 未换行(高度一致)', new Set(chips.map(c => c.height)).size === 1, chips.map(c => c.height).join(',')) await clickWeekday(3) await sleep(200) chips = await readWeekdayChips() let wed = chips.find(c => c.text.indexOf('周三') >= 0) check('取消周三后不再带 √ 且不为选中态', !!wed && wed.text.indexOf('√') < 0 && !wed.active, JSON.stringify(wed)) await clickWeekday(3) await sleep(200) chips = await readWeekdayChips() wed = chips.find(c => c.text.indexOf('周三') >= 0) check('重新勾选周三后恢复 √ 与选中态', !!wed && wed.text.indexOf('√') === 0 && wed.active, JSON.stringify(wed)) // 目标时刻取"当前北京时间分钟":启动待机时可立即判定为到点(60 秒内 < 90 秒错过阈值) const targetTime = beijingHHmm() console.log(` 目标时刻 = ${targetTime}`) await page.type('#schedEditorTitleInput', '自动化验证日程') await page.$eval('.sched-item-editor input[data-field="time"]', (el, v) => { el.value = v el.dispatchEvent(new Event('input', { bubbles: true })) }, targetTime) await page.$eval('.sched-item-editor input[data-field="name"]', (el) => { el.value = '验证事项' el.dispatchEvent(new Event('input', { bubbles: true })) }) await page.$eval('.sched-item-editor input[data-field="repeatTimes"]', (el) => { el.value = '5' el.dispatchEvent(new Event('input', { bubbles: true })) }) // 间隔压到最小值 5 秒,便于在测试里观察到"自动复播" await page.$eval('.sched-item-editor input[data-field="intervalSec"]', (el) => { el.value = '5' el.dispatchEvent(new Event('input', { bubbles: true })) }) const hintText = await textOf(page, '.sched-item-editor .sched-item-hint') check('编辑器实时预览播报文案', hintText.indexOf('现在是') >= 0 && hintText.indexOf('验证事项') >= 0, hintText) const statText = await textOf(page, '#schedEditorStats') check('编辑器统计已渲染', statText.indexOf('生效星期') >= 0, statText) await clickByText(page, '#schedEditorBody button', '保存日程') await sleep(3200) // 等交互详情弹窗收起 + 列表刷新 await dismissOverlays(page) check('编辑器已关闭', !(await visible(page, '#schedEditorModal'))) const listText = await textOf(page, '#schedulesList') check('列表出现新日程', listText.indexOf('自动化验证日程') >= 0, listText.slice(0, 80)) check('列表展示时刻预览', listText.indexOf(targetTime) >= 0 && listText.indexOf('验证事项') >= 0) // ---------- 3. 开始待机并验证到点播报 ---------- console.log('\n[3] 用这套待机 → 验证到点播报') await clickByText(page, '#schedulesList button', '用这套待机') await sleep(800) const statusText = await textOf(page, '#schedStatus') check('状态进入待机中', statusText.indexOf('待机中') >= 0, statusText) await page.waitForSelector('#schedAlertModal', { visible: true, timeout: 8000 }).then( () => check('到点触发全屏播报弹窗', true), () => check('到点触发全屏播报弹窗', false, '弹窗未出现') ) const alertName = await textOf(page, '#schedAlertName') const alertTime = await textOf(page, '#schedAlertTime') const alertRepeat = await textOf(page, '#schedAlertRepeat') check('播报弹窗显示事项名', alertName === '验证事项', alertName) check('播报弹窗显示时刻', alertTime === targetTime, alertTime) check('播报弹窗显示第 1/5 次', alertRepeat.indexOf('第 1 / 5 次') >= 0, alertRepeat) // ---------- 4. 自动重复播报 + 我知道了 ---------- console.log('\n[4] 验证按间隔自动复播,再点「我知道了」提前结束本条') await sleep(6500) // 间隔 5 秒,等第 2 次自动播报 const repeat2 = await textOf(page, '#schedAlertRepeat') check('到点后按间隔自动复播(第 2 次)', repeat2.indexOf('第 2 / 5 次') >= 0, repeat2) await page.click('#btnSchedAck') await sleep(600) check('点「我知道了」后弹窗关闭', !(await visible(page, '#schedAlertModal'))) await sleep(6500) // 再等一个完整间隔,确认已彻底停止 check('确认后不再继续复播', !(await visible(page, '#schedAlertModal'))) // ---------- 5. 播报记录 ---------- console.log('\n[5] 播报记录') await clickByText(page, '#schedTabBar button', '记录') await sleep(3200) check('切到记录页', await visible(page, '#pageSchedLogs')) const logsText = await textOf(page, '#schedLogsList') check('记录页出现播报条目', logsText.indexOf('验证事项') >= 0, logsText.slice(0, 100)) check('记录状态为已确认', logsText.indexOf('已确认') >= 0, logsText.slice(0, 100)) // ---------- 6. 错过显示条 ---------- console.log('\n[6] 错过显示条(把时刻改成"已过 2.5 分钟")') const schedApiRes = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json() const targetSchedule = (schedApiRes.data || [])[0] const pastHHmm = beijingHHmm(-150 * 1000) await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: targetSchedule.id, title: targetSchedule.title, weekdays: targetSchedule.weekdays, items: [{ id: 'missed-item', time: pastHHmm, name: '错过测试', repeatTimes: 1, intervalSec: 30 }], }), }) // 切回"日程"页会触发 loadSchedules 刷新前端缓存,随后 tick 把该时刻判定为错过 await clickByText(page, '#schedTabBar button', '日程') await sleep(3000) check('错过显示条已出现', await visible(page, '#schedMissed')) const missedText = await textOf(page, '#schedMissedDesc') check('错过条列出该时刻', missedText.indexOf('错过测试') >= 0, missedText) await clickByText(page, '#btnSchedMissedClear', '知道了') await sleep(400) check('点「知道了」后错过条消失', !(await visible(page, '#schedMissed'))) // ---------- 7. 停止待机 ---------- console.log('\n[7] 停止待机') await page.click('#btnSchedStandby') await sleep(800) const stoppedText = await textOf(page, '#schedStatus') check('已停止待机', stoppedText.indexOf('未待机') >= 0, stoppedText) // ---------- 8. 清理测试数据(只删本次新建的那一套,绝不碰已有数据) ---------- console.log('\n[8] 清理测试日程') // 从列表里精确定位"本脚本创建的那张卡片",再点它的删除按钮; // 绝不能靠"列表里第一个删除按钮"这种位置假设(曾因无差别删除误删过用户真实日程) const newIds = await page.evaluate(() => { const ids = Array.from(document.querySelectorAll('#schedulesList .card-item')) .map(el => el.dataset.id) return ids.filter(id => !(window.__PRE_EXISTING_IDS || []).includes(id)) }) const createdId = newIds.length ? newIds[0] : null check('定位到本次新建的日程', !!createdId, JSON.stringify(newIds)) if (createdId) { await page.evaluate(id => { const card = document.querySelector('#schedulesList .card-item[data-id="' + id + '"]') const target = card && Array.from(card.querySelectorAll('button')) .find(b => String(b.textContent || '').indexOf('删除') >= 0) if (target) target.click() }, createdId) await sleep(300) await clickByText(page, '#dialogActions button', '确认') await sleep(3200) } const afterDel = await textOf(page, '#schedulesList') check('测试日程已删除', afterDel.indexOf('自动化验证日程') < 0, afterDel.slice(0, 80)) const preserved = await page.evaluate(ids => ids.every(id => !!document.querySelector('#schedulesList .card-item[data-id="' + id + '"]')), (await page.evaluate(() => window.__PRE_EXISTING_IDS || []))) check('原有日程未被误删', preserved) // ---------- 9. 还原框子激活态 ---------- console.log('\n[9] 还原框子激活 kit') await fetch(`${BASE}/api/app_order_box/state/active-kit`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }), }) check('active-kit 已清空(回到 Hub)', true) } ;(async () => { const server = buildServer() await sleep(500) const browser = await puppeteer.launch({ headless: true, executablePath: findChrome(), args: ['--no-sandbox', '--disable-setuid-sandbox', '--mute-audio'], }) const page = await browser.newPage() await page.setViewport({ width: 414, height: 900 }) const pageErrors = [] page.on('pageerror', e => pageErrors.push('pageerror: ' + String(e && e.message || e))) page.on('console', m => { if (m.type() === 'error') pageErrors.push('console.error: ' + m.text()) }) // 用 normal 模式跑:查看类操作降级为 Toast,减少弹窗对点击的遮挡 await page.evaluateOnNewDocument(() => { try { localStorage.setItem('order_box_ui_mode', 'normal') } catch (e) { /* 静默 */ } }) let ok = false try { await run(page) // 语音探测弹窗若还在,点掉它,避免影响后续判断 await sleep(300) ok = failed === 0 } catch (e) { console.log('\n✘ 验证执行异常:' + String((e && e.stack) || e)) failed += 1 } console.log('\n--- 页面错误 ---') if (!pageErrors.length) console.log(' (无 pageerror / console.error)') else pageErrors.slice(0, 20).forEach(t => console.log(' ! ' + t)) check('页面无 JS 报错', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | ')) console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`) await browser.close() server.close(() => { cleanupDb() process.exit(ok ? 0 : 1) }) })()