/** * 作息报时器 · 老 Safari CSS 兼容性复现与验证(一次性调试脚本) * * 背景:iPhone 6s 上反馈两个现象: * 1. 待机大屏的时间没有大字体 * 2. 到点循环播报了 5 次,但屏幕上没有「我知道了」按钮可点 * * 假设:该机 Safari 版本较老,不支持 clamp() / dvh / inset 简写。 * 浏览器遇到「不支持的属性值」时会**只丢弃那一条声明**,同一条规则里的其它声明照常生效。 * → 于是 .standby-clock 的 font-size 被丢弃(字体退回默认 16px,但金色/粗体仍在) * → .modal-overlay 的 inset:0 被丢弃(遮罩没有定位到四边,跑到文档静态位置,即视口外) * * 本脚本用请求拦截把这三处改成「浏览器不认识的值」,等同于老 Safari 的行为, * 以此复现现象、验证因果;修完后同脚本应转为全部通过。 * * 运行:node dev_test_scripts/debug/debug_schedule_reminder_legacy_safari.js */ const fs = require('fs') const path = require('path') const express = require('express') const puppeteer = require('puppeteer') const PORT = 8981 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 VIEWPORT = { width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true } 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 sleep = ms => new Promise(r => setTimeout(r, ms)) // ⚠️ 安全底线:主流程开始前快照已有日程 id,清理时只删"本次新建"的, // 绝不删除数据库里原本就存在的用户数据(曾因无差别删除误删过用户真实日程)。 let PRE_EXISTING_IDS = new Set() /** * 把"老 Safari 不支持的写法"替换成浏览器不认识的值 → 该声明被丢弃。 * 这是对老浏览器行为的高保真模拟:只丢那一条,同规则其它声明不受影响。 */ const degradeForLegacySafari = css => String(css) .replace(/\bclamp\(/g, 'clampx(') // 不支持 clamp() → 整条 font-size 被丢弃 .replace(/\b(\d+)dvh\b/g, '$1dvhx') // 不支持 dvh 单位 → 整条 height 被丢弃 .replace(/inset:\s*0;/g, 'inset-legacy: 0;') // 不支持 inset 简写 → 整条被丢弃 const beijingHHmm = () => new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', }).format(new Date()) const buildServer = () => { const app = express() app.use(express.json()) app.use(express.static(path.join(process.cwd(), 'public'))) 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 (_) { /* 静默 */ } }) } /** 造一套"当前时刻"日程并进入待机,返回观测结果 */ const probe = async (browser, legacy) => { const page = await browser.newPage() await page.setViewport(VIEWPORT) await page.setCacheEnabled(false) const pageErrors = [] page.on('pageerror', e => pageErrors.push(String(e && e.message || e))) await page.setRequestInterception(true) page.on('request', async req => { const url = req.url() if (legacy && url.indexOf('order_box.css') >= 0) { try { const css = await (await fetch(url)).text() return req.respond({ status: 200, contentType: 'text/css; charset=utf-8', body: degradeForLegacySafari(css) }) } catch (e) { return req.continue() } } return req.continue() }) // 准备数据:当前分钟到点的日程 await fetch(`${BASE}/api/app_order_box/state/active-kit`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }), }) const targetTime = beijingHHmm() await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '兼容性验证日程', weekdays: [1, 2, 3, 4, 5, 6, 7], items: [{ time: targetTime, name: '验证事项', repeatTimes: 5, intervalSec: 30 }], }), }) await page.evaluateOnNewDocument(() => { try { localStorage.setItem('order_box_ui_mode', 'normal') } catch (e) { /* 静默 */ } }) 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 }) await page.click('#btnEnterSchedule') await sleep(4000) await page.evaluate(() => { const modal = document.getElementById('dialogOverlay') if (modal && window.getComputedStyle(modal).display !== 'none') { const b = Array.from(modal.querySelectorAll('button')).find(x => /听到了|知道了|确定/.test(x.textContent || '')) if (b) b.click() } }) await sleep(300) // 进入待机 → 触发到点播报 await page.evaluate(() => { window.scrollTo(0, 0) }) await page.evaluate(() => { const b = Array.from(document.querySelectorAll('#schedulesList button')) .find(x => String(x.textContent || '').indexOf('用这套待机') >= 0) if (b) b.click() }) // 等播报弹窗出现 let alertAppeared = false for (let i = 0; i < 24; i += 1) { alertAppeared = await page.evaluate(() => window.getComputedStyle(document.getElementById('schedAlertModal')).display !== 'none') if (alertAppeared) break await sleep(500) } await sleep(600) const result = await page.evaluate(() => { const clock = document.getElementById('schedClock') const modal = document.getElementById('schedAlertModal') const ack = document.getElementById('btnSchedAck') const overlay = window.getComputedStyle(modal) const modalRect = modal.getBoundingClientRect() const ackRect = ack.getBoundingClientRect() const vh = window.innerHeight const vw = window.innerWidth // 遮罩是否真的覆盖了视口(老 Safari 丢 inset 后,遮罩会停在文档静态位置、跑出视口) const modalOverlapsViewport = modalRect.bottom > 0 && modalRect.top < vh && modalRect.right > 0 && modalRect.left < vw const ackVisible = ackRect.top >= 0 && ackRect.bottom <= vh && ackRect.width > 0 return { clockFontSize: window.getComputedStyle(clock).fontSize, clockText: clock.textContent, modalDisplay: overlay.display, modalRect: { top: Math.round(modalRect.top), bottom: Math.round(modalRect.bottom), h: Math.round(modalRect.height) }, ackRect: { top: Math.round(ackRect.top), bottom: Math.round(ackRect.bottom), w: Math.round(ackRect.width) }, viewport: { vw, vh }, docScrollH: document.documentElement.scrollHeight, modalOverlapsViewport, ackVisible, } }) // 清理 await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/logs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemId: 'x', itemName: 'x', status: 'fired' }), }).catch(() => {}) // 清理:只删本次新建的日程,绝不删除数据库里原本就存在的用户数据 const list = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json() for (const s of (list.data || [])) { if (PRE_EXISTING_IDS.has(s.id)) continue await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules?id=${encodeURIComponent(s.id)}`, { method: 'DELETE' }) } await fetch(`${BASE}/api/app_order_box/state/active-kit`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }), }) await page.close() return { result, pageErrors, targetTime } } ;(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 existing = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json() PRE_EXISTING_IDS = new Set((existing.data || []).map(s => s.id)) console.log('=== 老 Safari CSS 兼容性复现 / 验证 ===') if (PRE_EXISTING_IDS.size) { console.log(`(检测到已有 ${PRE_EXISTING_IDS.size} 套日程,本次不会删除它们)`) } console.log('') console.log('[A] 模拟老 Safari(clamp / dvh / inset 均不被支持)') const legacy = await probe(browser, true) console.log(` 目标时刻 ${legacy.targetTime}`) console.log(` 待机时钟字号 = ${legacy.result.clockFontSize}(文字 ${legacy.result.clockText})`) console.log(` 播报弹窗 display=${legacy.result.modalDisplay},位置 ${JSON.stringify(legacy.result.modalRect)},视口高 ${legacy.result.viewport.vh}`) console.log(` 「我知道了」按钮 ${JSON.stringify(legacy.result.ackRect)}`) console.log(` 弹窗是否落在视口内 = ${legacy.result.modalOverlapsViewport},按钮是否可见 = ${legacy.result.ackVisible}`) const clockSmall = parseFloat(legacy.result.clockFontSize) < 30 console.log(`\n → 现象1(时间没有大字体)${clockSmall ? '已复现 ✔' : '未复现 ✘'}`) console.log(` → 现象2(没有「我知道了」按钮)${(!legacy.result.ackVisible || !legacy.result.modalOverlapsViewport) ? '已复现 ✔' : '未复现 ✘'}`) console.log('\n[B] 正常模式(现代浏览器,不降级)') const modern = await probe(browser, false) console.log(` 待机时钟字号 = ${modern.result.clockFontSize}`) console.log(` 弹窗落视口内 = ${modern.result.modalOverlapsViewport},按钮可见 = ${modern.result.ackVisible}`) console.log(` → 与 iPhone 18 等新机表现一致:${parseFloat(modern.result.clockFontSize) >= 30 && modern.result.ackVisible ? '正常 ✔' : '异常 ✘'}`) if (legacy.pageErrors.length || modern.pageErrors.length) { console.log('\n页面报错:') ;[...legacy.pageErrors, ...modern.pageErrors].slice(0, 10).forEach(t => console.log(' ! ' + t)) } console.log('\n判定标准:修完后,[A] 段的时钟字号应 >= 30px,且「我知道了」按钮应在视口内可见。') console.log(`当前 [A] 是否已修复:${!clockSmall && legacy.result.ackVisible && legacy.result.modalOverlapsViewport ? '是 ✔' : '否 ✘(仍需修复)'}`) await browser.close() server.close(() => { cleanupDb(); process.exit(0) }) })()