Files
Toolbox/dev_test_scripts/debug/debug_schedule_reminder_frontend.js
T
yangxiangyuan a3e8911deb feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
2026-10-09 14:05:36 +08:00

355 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 作息报时器(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)
})
})()