feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 作息报时器(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)
|
||||
})
|
||||
})()
|
||||
Reference in New Issue
Block a user