verify-toast-channel.mjs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. /**
  2. * 验证「全站提示统一走 Ant Design 的 message」这条链路 —— 重点是**语音输入那几处**。
  3. *
  4. * 用户 2026-09-23:先要求全局 toast 换成顶部弹出的轻提示(AntD message),
  5. * 随后要求「另外那几处语音的 window.alert 一同更换」。
  6. *
  7. * 为什么用静态检查而不是跑 UI:语音失败路径要真触发得**拒绝麦克风权限**,
  8. * 没法在 harness 里稳定复现;而这次改动恰恰是「**调用点**从阻塞弹窗换成轻提示」,
  9. * 用源码断言正好覆盖它的失效方式(改回 alert、漏掉某一处、提示通道串线)。
  10. *
  11. * 怎么跑(无需打包):
  12. * node harness/tools/verify-toast-channel.mjs
  13. */
  14. import { readFileSync, readdirSync, statSync } from 'node:fs';
  15. import { join, relative } from 'node:path';
  16. let pass = 0;
  17. let fail = 0;
  18. const check = (name, ok, extra = '') => {
  19. if (ok) {
  20. pass++;
  21. console.log(' ok ' + name);
  22. } else {
  23. fail++;
  24. console.log(' FAIL ' + name + ' ' + extra);
  25. }
  26. };
  27. const ROOT = 'src';
  28. const read = (p) => readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
  29. /** 去掉注释再判 —— 注释里写「不要用 window.alert」是**说明**,不该被当成调用点 */
  30. const stripComments = (src) =>
  31. src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1 ');
  32. /**
  33. * 阻塞式弹窗调用点:`window.alert(...)` 或裸 `alert(...)`。
  34. *
  35. * ⚠️ 两条分支缺一不可 —— 第一版只写了 `(^|[^.\w$])alert\(`,本意是排除 `onAlert(` / `myAlert(`,
  36. * 结果把 `window.alert(`(点号也在排除集里)一起排掉了,断言**永远为真**。
  37. * 是「阉掉修复即失败」的变异测试把这枚哑弹抓出来的(见 progress.md 续 27)。
  38. */
  39. const ALERT_RE = /window\s*\.\s*alert\s*\(|(^|[^.\w$])alert\s*\(/;
  40. const walk = (dir, out = []) => {
  41. for (const name of readdirSync(dir)) {
  42. const full = join(dir, name);
  43. if (statSync(full).isDirectory()) walk(full, out);
  44. else if (/\.(ts|vue|js|tsx)$/.test(name)) out.push(full);
  45. }
  46. return out;
  47. };
  48. const files = walk(ROOT);
  49. console.log(`\n【1】全仓库(${files.length} 个源文件)没有阻塞式 alert 调用点`);
  50. {
  51. const hits = [];
  52. for (const file of files) {
  53. stripComments(read(file))
  54. .split('\n')
  55. .forEach((line, i) => {
  56. if (ALERT_RE.test(line)) hits.push(`${relative(ROOT, file)}:${i + 1} ${line.trim()}`);
  57. });
  58. }
  59. check(`src 下 0 处 alert(`, hits.length === 0, hits.join(' | '));
  60. // 用户 2026-09-23 之前确实有 4 处(PC 1 + Mobile 3),钉住数量为 0 而不是"变少了"
  61. const voiceFiles = ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue'];
  62. for (const f of voiceFiles) {
  63. check(`${f} 里没有 alert(`, !ALERT_RE.test(stripComments(read(join(ROOT, f)))));
  64. }
  65. }
  66. /** 取出一个顶层 `const xxx = async () => { ... };` 的函数体(到顶格 `};` 为止) */
  67. const extractFn = (src, startMarker) => {
  68. const start = src.indexOf(startMarker);
  69. if (start < 0) return '';
  70. const end = src.indexOf('\n};', start);
  71. return end < 0 ? src.slice(start) : src.slice(start, end);
  72. };
  73. console.log('\n【2】语音输入 toggleVoiceInput:失败路径逐处换成 showToast');
  74. const voice = {
  75. PC: extractFn(read(join(ROOT, 'components/BusinessAssistantPC.vue')), 'const toggleVoiceInput = async () => {'),
  76. Mobile: extractFn(
  77. read(join(ROOT, 'components/BusinessAssistantMobile.vue')),
  78. 'const toggleVoiceInput = async () => {'
  79. ),
  80. };
  81. {
  82. check('两端的 toggleVoiceInput 都取到了(不是空片段)', voice.PC.length > 200 && voice.Mobile.length > 200,
  83. `PC=${voice.PC.length} Mobile=${voice.Mobile.length}`);
  84. const micText = '未获取到麦克风权限,请在系统或浏览器设置中启用麦克风';
  85. const count = (s, sub) => s.split(sub).length - 1;
  86. // PC:1 处(catch 里的 error.message,原来就是 alert)
  87. check('PC:catch 里改成 showToast(含 error.message 兜底)',
  88. voice.PC.includes('showToast(error instanceof Error ? error.message : "语音输入初始化失败")'));
  89. check('PC:语音段里没有 alert', !ALERT_RE.test(voice.PC));
  90. // Mobile:3 处(麦克风权限 ×2 + 初始化失败 ×1)
  91. check('Mobile:麦克风失败提示出现 2 次(抛错路径 + openMicroPhone 返回 false 路径)',
  92. count(voice.Mobile, `showToast("${micText}")`) === 2,
  93. `实际 showToast ${count(voice.Mobile, `showToast("${micText}")`)} 次`);
  94. check('Mobile:初始化失败改成 showToast(含「请稍后重试」文案)',
  95. voice.Mobile.includes('showToast(error instanceof Error ? error.message : "语音输入初始化失败,请稍后重试")'));
  96. check('Mobile:语音段里 showToast 恰好 3 处(不多不少)', count(voice.Mobile, 'showToast(') === 3,
  97. `实际 ${count(voice.Mobile, 'showToast(')} 处`);
  98. check('Mobile:语音段里没有 alert', !ALERT_RE.test(voice.Mobile));
  99. // 提示不能把原来的收尾动作挤掉:麦克风失败后仍要释放实例 / 复位状态
  100. check('Mobile:麦克风失败后仍 releaseVoiceInputInstance()', count(voice.Mobile, 'releaseVoiceInputInstance()') >= 2,
  101. `实际 ${count(voice.Mobile, 'releaseVoiceInputInstance()')} 处`);
  102. }
  103. console.log('\n【3】showToast 的可见性与实现链路');
  104. {
  105. const chatHook = read(join(ROOT, 'components/business-assistant/useBusinessAssistantChat.ts'));
  106. const appMessage = read(join(ROOT, 'utils/app-message.ts'));
  107. for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) {
  108. const src = read(join(ROOT, f));
  109. // showToast 必须从 hook 解构出来(否则语音里那句 showToast 会 ReferenceError)
  110. const destructured = /}\s*=\s*useBusinessAssistantChat\(\{/.test(src) && /^\s*showToast,\s*$/m.test(src);
  111. check(`${f}:showToast 由 useBusinessAssistantChat 解构(setup 顶层可见)`, destructured);
  112. // 解构必须发生在 toggleVoiceInput 之前(TDZ 保护;顺序反了运行时会炸)
  113. check(`${f}:解构位置在 toggleVoiceInput 之前`, src.indexOf('showToast,') < src.indexOf('const toggleVoiceInput'));
  114. }
  115. check('hook:showToast 调 showAppMessage(不再自绘 toastMessage)', /showToast = \(message: string\) => \{\s*showAppMessage\(message\);/.test(chatHook));
  116. check('app-message:用 ant-design-vue 的 message', /import \{ message \} from "ant-design-vue"/.test(appMessage));
  117. check('app-message:四种类型齐全(error/warning/success/info)',
  118. ["error", "warning", "success", "info"].every((t) => appMessage.includes(`"${t}"`)));
  119. const duration = appMessage.match(/APP_MESSAGE_DURATION = (\d+(?:\.\d+)?)/);
  120. check('app-message:时长常量可解析且是短提示(≤5s)', duration && Number(duration[1]) <= 5,
  121. duration ? duration[1] : '没匹配到');
  122. check('app-message:空文案早返回(不弹空气泡)', /if \(!content\) return;/.test(appMessage));
  123. // 之前踩过:antd message 返回 Promise,不吞掉会变成 unhandled rejection
  124. check('app-message:antd 的 Promise 与异常都被吞掉(不会 unhandled rejection)',
  125. appMessage.includes('.catch(') && appMessage.includes('try {') && appMessage.includes('catch (err)'));
  126. check('app-message:clearAppMessages 仍导出(切会话清场用)', /export const clearAppMessages/.test(appMessage));
  127. }
  128. console.log('\n【4】同类的另两条通道没被这次改动带偏');
  129. {
  130. const uploadHook = read(join(ROOT, 'components/business-assistant/useBusinessAssistantUpload.ts'));
  131. check('上传校验模块没有 alert(附件提示走 notify 回调)', !ALERT_RE.test(stripComments(uploadHook)));
  132. for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) {
  133. const src = read(join(ROOT, f));
  134. check(`${f}:notifyUpload 仍接通 showToast`, /notifyUpload = showToast;/.test(src));
  135. }
  136. // 保留项:这两处「确认要清空吗」是需要用户做决定的**模态确认**,不是轻提示 —— 不要顺手换掉
  137. for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) {
  138. check(`${f}:清空输入的 window.confirm 保留(模态确认 ≠ 轻提示)`,
  139. read(join(ROOT, f)).includes('window.confirm("可能会清空之前的文字,确认要清空吗?")'));
  140. }
  141. }
  142. console.log('\n【5】已知残留(不算失败,但要自己知道)');
  143. {
  144. const pc = read(join(ROOT, 'components/BusinessAssistantPC.vue'));
  145. const silent = /const success = await asrInstance.openMicroPhone\(\);\s*\n\s*if \(!success\) return;/.test(pc);
  146. console.log(
  147. silent
  148. ? ' ⚠ PC:openMicroPhone 返回 false 时仍是静默 return(无任何提示)——\n' +
  149. ' Mobile 同样情况会弹「未获取到麦克风权限」。**未擅自补**(用户只要求替换 alert),\n' +
  150. ' 已报告,等用户决定是否补齐。'
  151. : ' (PC 的 openMicroPhone 失败分支已不是静默 return,脚本可删掉这段提示)'
  152. );
  153. }
  154. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  155. process.exit(fail ? 1 : 0);