| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177 |
- /**
- * 验证「全站提示统一走 Ant Design 的 message」这条链路 —— 重点是**语音输入那几处**。
- *
- * 用户 2026-09-23:先要求全局 toast 换成顶部弹出的轻提示(AntD message),
- * 随后要求「另外那几处语音的 window.alert 一同更换」。
- *
- * 为什么用静态检查而不是跑 UI:语音失败路径要真触发得**拒绝麦克风权限**,
- * 没法在 harness 里稳定复现;而这次改动恰恰是「**调用点**从阻塞弹窗换成轻提示」,
- * 用源码断言正好覆盖它的失效方式(改回 alert、漏掉某一处、提示通道串线)。
- *
- * 怎么跑(无需打包):
- * node harness/tools/verify-toast-channel.mjs
- */
- import { readFileSync, readdirSync, statSync } from 'node:fs';
- import { join, relative } from 'node:path';
- let pass = 0;
- let fail = 0;
- const check = (name, ok, extra = '') => {
- if (ok) {
- pass++;
- console.log(' ok ' + name);
- } else {
- fail++;
- console.log(' FAIL ' + name + ' ' + extra);
- }
- };
- const ROOT = 'src';
- const read = (p) => readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
- /** 去掉注释再判 —— 注释里写「不要用 window.alert」是**说明**,不该被当成调用点 */
- const stripComments = (src) =>
- src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1 ');
- /**
- * 阻塞式弹窗调用点:`window.alert(...)` 或裸 `alert(...)`。
- *
- * ⚠️ 两条分支缺一不可 —— 第一版只写了 `(^|[^.\w$])alert\(`,本意是排除 `onAlert(` / `myAlert(`,
- * 结果把 `window.alert(`(点号也在排除集里)一起排掉了,断言**永远为真**。
- * 是「阉掉修复即失败」的变异测试把这枚哑弹抓出来的(见 progress.md 续 27)。
- */
- const ALERT_RE = /window\s*\.\s*alert\s*\(|(^|[^.\w$])alert\s*\(/;
- const walk = (dir, out = []) => {
- for (const name of readdirSync(dir)) {
- const full = join(dir, name);
- if (statSync(full).isDirectory()) walk(full, out);
- else if (/\.(ts|vue|js|tsx)$/.test(name)) out.push(full);
- }
- return out;
- };
- const files = walk(ROOT);
- console.log(`\n【1】全仓库(${files.length} 个源文件)没有阻塞式 alert 调用点`);
- {
- const hits = [];
- for (const file of files) {
- stripComments(read(file))
- .split('\n')
- .forEach((line, i) => {
- if (ALERT_RE.test(line)) hits.push(`${relative(ROOT, file)}:${i + 1} ${line.trim()}`);
- });
- }
- check(`src 下 0 处 alert(`, hits.length === 0, hits.join(' | '));
- // 用户 2026-09-23 之前确实有 4 处(PC 1 + Mobile 3),钉住数量为 0 而不是"变少了"
- const voiceFiles = ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue'];
- for (const f of voiceFiles) {
- check(`${f} 里没有 alert(`, !ALERT_RE.test(stripComments(read(join(ROOT, f)))));
- }
- }
- /** 取出一个顶层 `const xxx = async () => { ... };` 的函数体(到顶格 `};` 为止) */
- const extractFn = (src, startMarker) => {
- const start = src.indexOf(startMarker);
- if (start < 0) return '';
- const end = src.indexOf('\n};', start);
- return end < 0 ? src.slice(start) : src.slice(start, end);
- };
- console.log('\n【2】语音输入 toggleVoiceInput:失败路径逐处换成 showToast');
- const voice = {
- PC: extractFn(read(join(ROOT, 'components/BusinessAssistantPC.vue')), 'const toggleVoiceInput = async () => {'),
- Mobile: extractFn(
- read(join(ROOT, 'components/BusinessAssistantMobile.vue')),
- 'const toggleVoiceInput = async () => {'
- ),
- };
- {
- check('两端的 toggleVoiceInput 都取到了(不是空片段)', voice.PC.length > 200 && voice.Mobile.length > 200,
- `PC=${voice.PC.length} Mobile=${voice.Mobile.length}`);
- const micText = '未获取到麦克风权限,请在系统或浏览器设置中启用麦克风';
- const count = (s, sub) => s.split(sub).length - 1;
- // PC:1 处(catch 里的 error.message,原来就是 alert)
- check('PC:catch 里改成 showToast(含 error.message 兜底)',
- voice.PC.includes('showToast(error instanceof Error ? error.message : "语音输入初始化失败")'));
- check('PC:语音段里没有 alert', !ALERT_RE.test(voice.PC));
- // Mobile:3 处(麦克风权限 ×2 + 初始化失败 ×1)
- check('Mobile:麦克风失败提示出现 2 次(抛错路径 + openMicroPhone 返回 false 路径)',
- count(voice.Mobile, `showToast("${micText}")`) === 2,
- `实际 showToast ${count(voice.Mobile, `showToast("${micText}")`)} 次`);
- check('Mobile:初始化失败改成 showToast(含「请稍后重试」文案)',
- voice.Mobile.includes('showToast(error instanceof Error ? error.message : "语音输入初始化失败,请稍后重试")'));
- check('Mobile:语音段里 showToast 恰好 3 处(不多不少)', count(voice.Mobile, 'showToast(') === 3,
- `实际 ${count(voice.Mobile, 'showToast(')} 处`);
- check('Mobile:语音段里没有 alert', !ALERT_RE.test(voice.Mobile));
- // 提示不能把原来的收尾动作挤掉:麦克风失败后仍要释放实例 / 复位状态
- check('Mobile:麦克风失败后仍 releaseVoiceInputInstance()', count(voice.Mobile, 'releaseVoiceInputInstance()') >= 2,
- `实际 ${count(voice.Mobile, 'releaseVoiceInputInstance()')} 处`);
- }
- console.log('\n【3】showToast 的可见性与实现链路');
- {
- const chatHook = read(join(ROOT, 'components/business-assistant/useBusinessAssistantChat.ts'));
- const appMessage = read(join(ROOT, 'utils/app-message.ts'));
- for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) {
- const src = read(join(ROOT, f));
- // showToast 必须从 hook 解构出来(否则语音里那句 showToast 会 ReferenceError)
- const destructured = /}\s*=\s*useBusinessAssistantChat\(\{/.test(src) && /^\s*showToast,\s*$/m.test(src);
- check(`${f}:showToast 由 useBusinessAssistantChat 解构(setup 顶层可见)`, destructured);
- // 解构必须发生在 toggleVoiceInput 之前(TDZ 保护;顺序反了运行时会炸)
- check(`${f}:解构位置在 toggleVoiceInput 之前`, src.indexOf('showToast,') < src.indexOf('const toggleVoiceInput'));
- }
- check('hook:showToast 调 showAppMessage(不再自绘 toastMessage)', /showToast = \(message: string\) => \{\s*showAppMessage\(message\);/.test(chatHook));
- check('app-message:用 ant-design-vue 的 message', /import \{ message \} from "ant-design-vue"/.test(appMessage));
- check('app-message:四种类型齐全(error/warning/success/info)',
- ["error", "warning", "success", "info"].every((t) => appMessage.includes(`"${t}"`)));
- const duration = appMessage.match(/APP_MESSAGE_DURATION = (\d+(?:\.\d+)?)/);
- check('app-message:时长常量可解析且是短提示(≤5s)', duration && Number(duration[1]) <= 5,
- duration ? duration[1] : '没匹配到');
- check('app-message:空文案早返回(不弹空气泡)', /if \(!content\) return;/.test(appMessage));
- // 之前踩过:antd message 返回 Promise,不吞掉会变成 unhandled rejection
- check('app-message:antd 的 Promise 与异常都被吞掉(不会 unhandled rejection)',
- appMessage.includes('.catch(') && appMessage.includes('try {') && appMessage.includes('catch (err)'));
- check('app-message:clearAppMessages 仍导出(切会话清场用)', /export const clearAppMessages/.test(appMessage));
- }
- console.log('\n【4】同类的另两条通道没被这次改动带偏');
- {
- const uploadHook = read(join(ROOT, 'components/business-assistant/useBusinessAssistantUpload.ts'));
- check('上传校验模块没有 alert(附件提示走 notify 回调)', !ALERT_RE.test(stripComments(uploadHook)));
- for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) {
- const src = read(join(ROOT, f));
- check(`${f}:notifyUpload 仍接通 showToast`, /notifyUpload = showToast;/.test(src));
- }
- // 保留项:这两处「确认要清空吗」是需要用户做决定的**模态确认**,不是轻提示 —— 不要顺手换掉
- for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) {
- check(`${f}:清空输入的 window.confirm 保留(模态确认 ≠ 轻提示)`,
- read(join(ROOT, f)).includes('window.confirm("可能会清空之前的文字,确认要清空吗?")'));
- }
- }
- console.log('\n【5】已知残留(不算失败,但要自己知道)');
- {
- const pc = read(join(ROOT, 'components/BusinessAssistantPC.vue'));
- const silent = /const success = await asrInstance.openMicroPhone\(\);\s*\n\s*if \(!success\) return;/.test(pc);
- console.log(
- silent
- ? ' ⚠ PC:openMicroPhone 返回 false 时仍是静默 return(无任何提示)——\n' +
- ' Mobile 同样情况会弹「未获取到麦克风权限」。**未擅自补**(用户只要求替换 alert),\n' +
- ' 已报告,等用户决定是否补齐。'
- : ' (PC 的 openMicroPhone 失败分支已不是静默 return,脚本可删掉这段提示)'
- );
- }
- console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
- process.exit(fail ? 1 : 0);
|