dsh-notify-say 0.4.56 → 0.4.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +9 -3
- package/lib/client.js +78 -19
- package/lib/dev-reload.js +66 -60
- package/lib/index.js +157 -86
- package/package.json +1 -1
- package/test/client-bundle.test.mjs +10 -6
- package/test/reload-once.test.mjs +145 -0
- package/test/reload-review.test.mjs +43 -32
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -266,8 +266,14 @@ Windows 的 Toast 协议激活是一个固定串(`dsh://open`),没有地
|
|
|
266
266
|
- **跳会话依赖未文档化 API**(`uiWorkspace`),官方随时可能改;坏了会自动降级为"只唤起窗口"
|
|
267
267
|
- `blocked`(回合被拦截)只用系统蜂鸣,不播失败语音 —— 语义是"被拦下"而非"失败"
|
|
268
268
|
- **失败语音只覆盖部分音色**:选到未覆盖的音色时,会在同池内换一个音色播,不会哑掉、也不会播错语义
|
|
269
|
-
-
|
|
270
|
-
|
|
269
|
+
- **自重载是实验性功能,而且只有"点一次"**:设置页「高级设置 → 开发者选项」里有个
|
|
270
|
+
「重载现在生效」按钮,点一下尝试一次;**没有开关、不会自动监听文件**。
|
|
271
|
+
|
|
272
|
+
为什么改成这样:2026-10-18 实测「监听文件自动重载」会把宿主拖崩 —— 一次改 UI 期间
|
|
273
|
+
触发了 40 多次失败重载,每次销毁实例都会释放宿主仍在用的会话引用,最后渲染进程崩溃。
|
|
274
|
+
危险的不是"重载失败",是"**反复**重载"
|
|
275
|
+
- **自重载不能替代重启**:它只能重新导入「同一路径上的」代码。用 `dsh plugin update`
|
|
276
|
+
升级后,新版本装在 `.pnpm/<name>@<ver>/` 另一个路径,自重载做不到 —— 必须重启 DSH
|
|
271
277
|
- **13 个音色的许可结论是条件性判断**(依据官方通用条款),不代表各权利人出具过专项授权
|
|
272
278
|
|
|
273
279
|
---
|
|
@@ -330,7 +336,7 @@ npm run test:route # 需要 --import loader 的接线测试
|
|
|
330
336
|
npm run test:audio-wire
|
|
331
337
|
```
|
|
332
338
|
|
|
333
|
-
**
|
|
339
|
+
**426 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
|
|
334
340
|
|
|
335
341
|
少数用例需要真实的 DSH 宿主(用于验证 Cordis 上下文行为)。
|
|
336
342
|
找不到宿主时它们会**自动 skip** —— 注意 skip 不等于通过。
|
package/lib/client.js
CHANGED
|
@@ -610,6 +610,12 @@ window.__ModuleLoader__.load({
|
|
|
610
610
|
var sndset = sndst[1]
|
|
611
611
|
var sndPref = sndst[0].open
|
|
612
612
|
|
|
613
|
+
// 一次性重载的界面状态(v0.4.57)。独立 state:与配置无关,
|
|
614
|
+
// 也不该因为保存配置(主 state 25 处 set 重建)被清掉。
|
|
615
|
+
var rlst = React.useState({ busy: false, msg: "", err: "" })
|
|
616
|
+
var rlset = rlst[1]
|
|
617
|
+
var rlState = rlst[0]
|
|
618
|
+
|
|
613
619
|
var st = React.useState({
|
|
614
620
|
loaded: false,
|
|
615
621
|
values: {
|
|
@@ -965,6 +971,46 @@ window.__ModuleLoader__.load({
|
|
|
965
971
|
//
|
|
966
972
|
// 只检查、不安装。升级由用户自己执行 DSH 命令。
|
|
967
973
|
// 详见 lib/update-check.js 头注释(为什么不自动更新)。
|
|
974
|
+
/**
|
|
975
|
+
* 一次性自重载(v0.4.57)。
|
|
976
|
+
*
|
|
977
|
+
* 用户要求:「点一下重载一下」,不要"开了就一直重载"。
|
|
978
|
+
* 所以这是一次显式请求,服务端也只尝试一次、失败不重试。
|
|
979
|
+
*
|
|
980
|
+
* ⚠️ 成功后当前实例已被 dispose、新实例接管,**这个响应可能发不回来**。
|
|
981
|
+
* 所以 .catch 与 !r 都按"可能已经重载"提示用户刷新页面,
|
|
982
|
+
* 而不是报"失败" —— 否则用户会以为没生效,反复点。
|
|
983
|
+
*/
|
|
984
|
+
function doReload() {
|
|
985
|
+
rlset({ busy: true, msg: "", err: "" })
|
|
986
|
+
fetch(cfgUrl('reload'), {
|
|
987
|
+
method: 'POST',
|
|
988
|
+
cache: 'no-store',
|
|
989
|
+
credentials: 'same-origin',
|
|
990
|
+
headers: { 'content-type': 'application/json' },
|
|
991
|
+
body: '{}',
|
|
992
|
+
})
|
|
993
|
+
.then(function (r) { return r ? r.json().catch(function () { return null }) : null })
|
|
994
|
+
.then(function (r) {
|
|
995
|
+
if (!r) {
|
|
996
|
+
rlset({ busy: false, msg: '', err: '没有收到回应 —— 可能已经重载,请刷新页面确认;若未生效请重启 DSH。' })
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
if (r.ok && r.noop) {
|
|
1000
|
+
rlset({ busy: false, msg: '磁盘上的代码与正在运行的一致,无需重载。', err: '' })
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
if (r.ok) {
|
|
1004
|
+
rlset({ busy: false, msg: '已重载。请刷新页面,让设置界面也用新代码。', err: '' })
|
|
1005
|
+
return
|
|
1006
|
+
}
|
|
1007
|
+
rlset({ busy: false, msg: '', err: r.hint || '重载没有成功 —— 请重启 DSH。' })
|
|
1008
|
+
})
|
|
1009
|
+
.catch(function () {
|
|
1010
|
+
rlset({ busy: false, msg: '', err: '请求失败 —— 可能已经重载,请刷新页面确认;若未生效请重启 DSH。' })
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
1013
|
+
|
|
968
1014
|
function checkVersion(force) {
|
|
969
1015
|
vset(function (p) {
|
|
970
1016
|
return { info: p.info, loading: true, copied: false }
|
|
@@ -2276,25 +2322,38 @@ window.__ModuleLoader__.load({
|
|
|
2276
2322
|
rows.push(h('div', { key: 'dev-warn', style: S.subHint },
|
|
2277
2323
|
'⚠️ 实验性:依赖宿主内部结构与 Node 模块加载机制,'
|
|
2278
2324
|
+ 'DSH 升级后可能失效。失效时只报错,不影响播报。'))
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2325
|
+
// ── 一次性自重载(v0.4.57)────────────────────────────────
|
|
2326
|
+
//
|
|
2327
|
+
// 用户 2026-10-18 明确要求:「点一下重载一下」,
|
|
2328
|
+
// 不要"开了就一直重载"。所以这里**没有开关**,只有一个按钮。
|
|
2329
|
+
//
|
|
2330
|
+
// 为什么把自动监听删了:2026-10-18 实测它把宿主拖崩 ——
|
|
2331
|
+
// watcher 在我改 UI 期间触发了 40 多次失败重载,每次 dispose
|
|
2332
|
+
// 都释放宿主仍在用的会话引用,最后渲染进程崩在损坏的 client 包上。
|
|
2333
|
+
rows.push(h('div', { key: 'dr-row', style: S.row },
|
|
2334
|
+
h('button', {
|
|
2335
|
+
type: 'button',
|
|
2336
|
+
disabled: rlState.busy,
|
|
2337
|
+
style: S.btn,
|
|
2338
|
+
onClick: function () { doReload() },
|
|
2339
|
+
}, rlState.busy ? '重载中…' : '重载现在生效'),
|
|
2340
|
+
h('span', { style: S.hint }, '点一次试一次,不会自动重复'),
|
|
2341
|
+
))
|
|
2342
|
+
rows.push(h('div', { key: 'dr-hint', style: S.subHint },
|
|
2343
|
+
'用途:改完 lib/ 下的代码后,不想重启 DSH 就点一下。'
|
|
2344
|
+
+ '**只对同一路径上的代码有效**;如果刚用 dsh plugin update '
|
|
2345
|
+
+ '升级了版本,新版本装在另一个路径,自重载做不到,必须重启。'))
|
|
2346
|
+
if (rlState.msg) {
|
|
2347
|
+
rows.push(h('div', { key: 'dr-msg', style: S.ok }, rlState.msg))
|
|
2348
|
+
}
|
|
2349
|
+
if (rlState.err) {
|
|
2350
|
+
rows.push(h('div', { key: 'dr-err', style: S.err }, rlState.err))
|
|
2351
|
+
}
|
|
2352
|
+
// 重载后宿主里的会话引用会被释放 —— 用户必须知道要刷新页面,
|
|
2353
|
+
// 否则会遇到"侧边栏点不开"却不知道为什么。
|
|
2354
|
+
rows.push(h('div', { key: 'dr-warn', style: S.subHint },
|
|
2355
|
+
'重载会重建插件实例,宿主里已打开的会话引用会失效。'
|
|
2356
|
+
+ '重载成功后请**刷新页面**;不行就重启 DSH。'))
|
|
2298
2357
|
}
|
|
2299
2358
|
}
|
|
2300
2359
|
})
|
package/lib/dev-reload.js
CHANGED
|
@@ -682,65 +682,71 @@ export function selfReload(ctx, options = {}) {
|
|
|
682
682
|
return inFlight.finally(() => { inFlight = null; });
|
|
683
683
|
}
|
|
684
684
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
return
|
|
685
|
+
// ── 重载决策(v0.4.57)──────────────────────────────────────────────────
|
|
686
|
+
//
|
|
687
|
+
// 把「这次点击该不该真的重载」抽成纯函数 —— 因为它正是上次崩溃的关键:
|
|
688
|
+
// 在自动监听时代,**每一次文件保存都直接进入重载流程**,没有做任何
|
|
689
|
+
// "现在真的需要重载吗"的判断,于是 40 多次保存换来 40 多次 dispose。
|
|
690
|
+
//
|
|
691
|
+
// 抽成纯函数的好处是它能被完整测试(不需要活宿主):
|
|
692
|
+
// · 代码没变 → noop(一次 dispose 都不做)
|
|
693
|
+
// · 插件根真实路径变了(pnpm 升级到新版本)→ 拒绝,并告知必须重启
|
|
694
|
+
// · 确实变了 → reload(一次)
|
|
695
|
+
//
|
|
696
|
+
// @param {{ diskFingerprint?: string, runningFingerprint?: string,
|
|
697
|
+
// resolvedRootAtLoad?: string, resolvedRootNow?: string }} input
|
|
698
|
+
// @returns {{ action: 'noop'|'refuse-path-changed'|'reload',
|
|
699
|
+
// reason: string, hint?: string }}
|
|
700
|
+
export function planReload(input = {}) {
|
|
701
|
+
const disk = String(input.diskFingerprint || '');
|
|
702
|
+
const running = String(input.runningFingerprint || '');
|
|
703
|
+
|
|
704
|
+
// ① 读不到磁盘指纹:无法证明"变了",但也不能断言"没变"。
|
|
705
|
+
// 按 reload 处理会让用户在只读沙箱里反复触发失败 ——
|
|
706
|
+
// 按 noop 处理则可能让用户以为已生效。
|
|
707
|
+
// 这里选 reload(唯一有机会真正生效的选择),失败会如实报错。
|
|
708
|
+
if (!disk) {
|
|
709
|
+
return { action: 'reload', reason: 'fingerprint-unreadable' };
|
|
710
710
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
watcher.on('change', (eventType, filename) => {
|
|
735
|
-
try { opts.diag?.('event ', String(filename), ' accepted=', String(isSourceFile(filename))); } catch {}
|
|
736
|
-
schedule(filename);
|
|
737
|
-
});
|
|
738
|
-
watcher.on('error', (error) => {
|
|
739
|
-
try { logger.warn?.(`[dsh-notify-say] watcher error: ${(error && error.message) || error}`); } catch { /* ignore */ }
|
|
740
|
-
});
|
|
741
|
-
return () => {
|
|
742
|
-
paused = true;
|
|
743
|
-
clearTimeout(timer);
|
|
744
|
-
try { watcher.close(); } catch { /* 已关闭 */ }
|
|
745
|
-
};
|
|
711
|
+
|
|
712
|
+
// ② 磁盘内容 == 当前实例内容 → 毫无必要重载。
|
|
713
|
+
// **这一条就是上次 40 多次无效 dispose 的止血点。**
|
|
714
|
+
if (running && disk === running) {
|
|
715
|
+
return { action: 'noop', reason: 'already-current' };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ③ 插件根的真实路径变了 → 自重载救不了。
|
|
719
|
+
//
|
|
720
|
+
// `dsh plugin update` 升级后 pnpm 会把包装到新的 .pnpm/<name>@<ver>/,
|
|
721
|
+
// 而我们手上只有加载时那个路径;从旧路径重新导入拿到的是旧代码。
|
|
722
|
+
// 与其让用户点完一个"看起来成功其实没换"的按钮,不如直接说清楚。
|
|
723
|
+
const atLoad = String(input.resolvedRootAtLoad || '');
|
|
724
|
+
const now = String(input.resolvedRootNow || '');
|
|
725
|
+
if (atLoad && now && atLoad !== now) {
|
|
726
|
+
return {
|
|
727
|
+
action: 'refuse-path-changed',
|
|
728
|
+
reason: 'path-changed',
|
|
729
|
+
hint: '新版本装在另一个路径,自重载无法生效 —— 请重启 DSH。',
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return { action: 'reload', reason: 'changed' };
|
|
746
734
|
}
|
|
735
|
+
// ── 自动监听已移除(v0.4.57)─────────────────────────────────────────────
|
|
736
|
+
//
|
|
737
|
+
// 这里原来导出 `watchPackage()`:监视包目录,文件一变就触发一次自重载。
|
|
738
|
+
//
|
|
739
|
+
// **2026-10-18 实测它把宿主拖崩了**,崩溃日志给的两条硬证据:
|
|
740
|
+
//
|
|
741
|
+
// ① 45 条「热重载失败:fingerprint unchanged(旧代码仍在运行)」
|
|
742
|
+
// —— 那天为调 UI 改了 40 多次文件,每次保存都触发一次注定失败的尝试;
|
|
743
|
+
// ② 同一时刻反复出现「Session reference "..." is released」
|
|
744
|
+
// —— 每次重载都 dispose 旧实例,而 dispose 会释放渲染进程仍在用的
|
|
745
|
+
// 会话引用。反复 45 次之后渲染进程拿到损坏的 client 包,
|
|
746
|
+
// 抛 SyntaxError,宿主退出。
|
|
747
|
+
//
|
|
748
|
+
// 结论:危险的不是「重载失败」,而是「**反复**重载」。
|
|
749
|
+
// 现在改由用户在设置页点一次按钮、调一次 `selfReload()`——
|
|
750
|
+
// 一次点击 = 一次尝试,最坏结果是「这次没成功」,不再累积。
|
|
751
|
+
//
|
|
752
|
+
// 相关:lib/index.js 的 `reloadOnce()` 与 `POST /reload` 路由。
|
package/lib/index.js
CHANGED
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
* 失败一律 fail-visible:判定异常时走主任务分支照常播报,不静默用户。
|
|
85
85
|
*/
|
|
86
86
|
import { spawn } from 'node:child_process';
|
|
87
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
|
|
87
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync } from 'node:fs';
|
|
88
88
|
import { fileURLToPath } from 'node:url';
|
|
89
89
|
import { dirname, join } from 'node:path';
|
|
90
90
|
|
|
@@ -102,11 +102,26 @@ import { checkForUpdate } from './update-check.js';
|
|
|
102
102
|
import { createVoicePackRoute } from './voice-pack-route.js';
|
|
103
103
|
import { isDebug, redactText } from './log-privacy.js';
|
|
104
104
|
import { exportDiagnostics } from './diagnostics.js';
|
|
105
|
-
|
|
105
|
+
// ⚠️ 不再导入 watchPackage(v0.4.57):自动监听重载已移除,见 dev-reload.js 末尾注释。
|
|
106
|
+
import { selfReload, computeFingerprint, planReload } from './dev-reload.js';
|
|
106
107
|
import { pickTtsText, sanitizeTtsText, MAX_TEXT_LEN } from './tts-lines.js';
|
|
107
108
|
|
|
108
109
|
const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 加载那一刻插件根的**真实路径**(解开 Junction / 符号链接)。
|
|
113
|
+
*
|
|
114
|
+
* 用途:判断"自重载还能不能救"。
|
|
115
|
+
*
|
|
116
|
+
* 自重载只能重新导入「**同一路径上的**」代码。`dsh plugin update` 升级后,
|
|
117
|
+
* pnpm 会把包装到新的 `.pnpm/<name>@<ver>/` 下,而这里的 PLUGIN_ROOT
|
|
118
|
+
* 在加载时就已解析成旧路径 —— 那时从旧路径重新导入,拿到的仍是旧代码。
|
|
119
|
+
* 所以点按钮时比一次,路径变了就**如实说"必须重启"**,不给假希望。
|
|
120
|
+
*/
|
|
121
|
+
const RESOLVED_PLUGIN_ROOT = (() => {
|
|
122
|
+
try { return realpathSync(PLUGIN_ROOT); } catch { return PLUGIN_ROOT; }
|
|
123
|
+
})();
|
|
124
|
+
|
|
110
125
|
|
|
111
126
|
// 检查更新的宿主侧缓存(v0.4.48)。
|
|
112
127
|
//
|
|
@@ -749,6 +764,11 @@ export function effectiveForClient(c) {
|
|
|
749
764
|
}
|
|
750
765
|
|
|
751
766
|
export function apply(ctx, config) {
|
|
767
|
+
// 一次性自重载的实现槽(v0.4.57)。
|
|
768
|
+
//
|
|
769
|
+
// 路由在 apply 中段注册、实现挂在 apply 末尾 —— 用 let 槽连接两者。
|
|
770
|
+
// (不写成函数声明:它包在 try 块里,严格模式下块级作用域不会提升出去。)
|
|
771
|
+
let reloadOnce = null;
|
|
752
772
|
// ── 配置来源(v0.4.0 起三源合并)──────────────────────────────────────────
|
|
753
773
|
//
|
|
754
774
|
// 优先级(后覆盖前):
|
|
@@ -1610,7 +1630,10 @@ function hasVoiceLine(psKind) {
|
|
|
1610
1630
|
(head === 'config' && /\/set\b/.test(pathname))
|
|
1611
1631
|
|| head === 'consume'
|
|
1612
1632
|
|| head === 'diagnostics'
|
|
1613
|
-
|| (head === 'voicepacks' && (/\/install\b/.test(pathname) || /\/uninstall\b/.test(pathname)))
|
|
1633
|
+
|| (head === 'voicepacks' && (/\/install\b/.test(pathname) || /\/uninstall\b/.test(pathname)))
|
|
1634
|
+
// v0.4.57:一次性自重载会 dispose 当前实例并重新求值模块,
|
|
1635
|
+
// 是不可逆副作用,按写操作处理。
|
|
1636
|
+
|| head === 'reload';
|
|
1614
1637
|
const guard = checkLocalOrigin(req, { write: isWriteRoute });
|
|
1615
1638
|
if (!guard.ok) {
|
|
1616
1639
|
try {
|
|
@@ -1627,6 +1650,56 @@ function hasVoiceLine(psKind) {
|
|
|
1627
1650
|
//
|
|
1628
1651
|
// 为什么不接受客户端传 URL:那等于让本机访问任意地址(SSRF)。
|
|
1629
1652
|
// 查询地址固定在 update-check.js 里。
|
|
1653
|
+
// ── 一次性自重载(v0.4.57)──────────────────────────────────
|
|
1654
|
+
//
|
|
1655
|
+
// 用户 2026-10-18 明确要求:「点一下重载一下」,不要"开了就一直重载"。
|
|
1656
|
+
// 所以这里是**纯显式动作**:没有任何自动触发(watcher 已删除)。
|
|
1657
|
+
//
|
|
1658
|
+
// 只接受 POST —— 它不可逆。
|
|
1659
|
+
if (head === 'reload') {
|
|
1660
|
+
if (String((req && req.method) || 'GET').toUpperCase() !== 'POST') {
|
|
1661
|
+
try {
|
|
1662
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', allow: 'POST' });
|
|
1663
|
+
res.end(JSON.stringify({ ok: false, reason: 'method-not-allowed' }));
|
|
1664
|
+
} catch { /* 响应已发出 */ }
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
if (!reloadOnce) {
|
|
1668
|
+
try {
|
|
1669
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
1670
|
+
res.end(JSON.stringify({ ok: false, reason: 'reload-unavailable' }));
|
|
1671
|
+
} catch { /* 响应已发出 */ }
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
// 限流:5 秒内只允许一次(防连点、也防脚本反复触发 dispose)
|
|
1675
|
+
const rlGate = throttle('reload', 5000);
|
|
1676
|
+
if (!rlGate.ok) {
|
|
1677
|
+
try {
|
|
1678
|
+
res.writeHead(429, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'retry-after': String(Math.ceil((rlGate.retryAfterMs || 1000) / 1000)) });
|
|
1679
|
+
res.end(JSON.stringify({ ok: false, reason: 'throttled', retryAfterMs: rlGate.retryAfterMs }));
|
|
1680
|
+
} catch { /* 响应已发出 */ }
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
// ⚠️ 成功后当前实例已被 dispose、新实例接管 ——
|
|
1684
|
+
// 这个响应**可能发不出去**。客户端必须把"请求失败"也当作
|
|
1685
|
+
// "可能已重载",提示用户刷新页面。
|
|
1686
|
+
Promise.resolve()
|
|
1687
|
+
.then(() => reloadOnce())
|
|
1688
|
+
.then((r) => {
|
|
1689
|
+
try {
|
|
1690
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
1691
|
+
res.end(JSON.stringify(r));
|
|
1692
|
+
} catch { /* 响应已发出 */ }
|
|
1693
|
+
})
|
|
1694
|
+
.catch((e) => {
|
|
1695
|
+
try {
|
|
1696
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
1697
|
+
res.end(JSON.stringify({ ok: false, reason: 'exception', error: String((e && e.message) || e) }));
|
|
1698
|
+
} catch { /* 响应已发出 */ }
|
|
1699
|
+
});
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1630
1703
|
if (head === 'version') {
|
|
1631
1704
|
// ?force=1 表示用户手动点了「检查更新」,绕过 24 小时缓存
|
|
1632
1705
|
const force = /[?&]force=1\b/.test(String((req && req.url) || ''));
|
|
@@ -1878,92 +1951,90 @@ function hasVoiceLine(psKind) {
|
|
|
1878
1951
|
// ⚠️ 防无限循环:重载后新实例的 apply 会**再次**读到 devReload=true,
|
|
1879
1952
|
// 若不做标记就会重载→重载→…。故用 globalThis 上的时间戳做冷却:
|
|
1880
1953
|
// 30 秒内已重载过就跳过。key 带插件名,避免与其它插件冲突。
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1954
|
+
// ── 一次性自重载的实现(v0.4.57)──────────────────────────────────────
|
|
1955
|
+
//
|
|
1956
|
+
// 用户要求:点一下重载一次;不点就不动。这里只是**把能力准备好**,
|
|
1957
|
+
// 不做任何自动触发 —— 触发点只有 `POST /reload`(设置页的按钮)。
|
|
1958
|
+
//
|
|
1959
|
+
// ## 为什么删掉 watcher
|
|
1960
|
+
//
|
|
1961
|
+
// 2026-10-18 实测它把宿主拖崩,崩溃日志两条硬证据:
|
|
1962
|
+
// ① 45 条「fingerprint unchanged(旧代码仍在运行)」
|
|
1963
|
+
// —— 那天为调 UI 改了 40 多次文件,每次保存触发一次注定失败的尝试;
|
|
1964
|
+
// ② 同一时刻反复「Session reference "..." is released」
|
|
1965
|
+
// —— 每次重载都 dispose 旧实例,而 dispose 会释放渲染进程仍在用的
|
|
1966
|
+
// 会话引用;反复几十次后渲染进程拿到损坏的 client 包,
|
|
1967
|
+
// 抛 SyntaxError,宿主退出。
|
|
1968
|
+
//
|
|
1969
|
+
// 结论:危险的不是"失败",是"**反复**"。一次点击一次尝试,不再累积。
|
|
1970
|
+
reloadOnce = async function () {
|
|
1971
|
+
// ① 前置:磁盘内容与当前实例相同 → 什么都不做
|
|
1972
|
+
//
|
|
1973
|
+
// 同一件事的"没变"判断放在最前面,因为它最省钱也最安全:
|
|
1974
|
+
// 代码没变就不该 dispose 任何东西。
|
|
1975
|
+
let diskFingerprint = '';
|
|
1976
|
+
try { diskFingerprint = computeFingerprint(PLUGIN_ROOT); } catch { /* 读不到就继续 */ }
|
|
1977
|
+
// ② 决策抽在 dev-reload.js 的 planReload() —— 纯函数,可完整测试。
|
|
1978
|
+
//
|
|
1979
|
+
// 代码没变 → noop(一次 dispose 都不做)
|
|
1980
|
+
// 插件根真实路径变了 → 拒绝,告知必须重启
|
|
1981
|
+
// 确实变了 → reload(一次)
|
|
1982
|
+
let nowReal = '';
|
|
1983
|
+
try { nowReal = realpathSync(PLUGIN_ROOT); } catch { /* 解不开就不比 */ }
|
|
1984
|
+
const plan = planReload({
|
|
1985
|
+
diskFingerprint,
|
|
1986
|
+
runningFingerprint: fingerprint,
|
|
1987
|
+
resolvedRootAtLoad: RESOLVED_PLUGIN_ROOT,
|
|
1988
|
+
resolvedRootNow: nowReal,
|
|
1989
|
+
});
|
|
1990
|
+
if (plan.action === 'noop') {
|
|
1991
|
+
return { ok: true, noop: true, reason: plan.reason, fingerprint };
|
|
1992
|
+
}
|
|
1993
|
+
if (plan.action === 'refuse-path-changed') {
|
|
1994
|
+
return { ok: false, reason: plan.reason, hint: plan.hint };
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// ③ 一次尝试。失败**不重试**,直接如实返回让用户去重启。
|
|
1998
|
+
const eventsBefore = readEventCount();
|
|
1999
|
+
let r;
|
|
2000
|
+
try {
|
|
2001
|
+
r = await selfReload(ctx, {
|
|
1895
2002
|
logger: console,
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
const last = Number(globalThis[KEY]) || 0;
|
|
1900
|
-
// 冷却:一次保存可能触发多个文件事件,也防止重载后立刻又重载
|
|
1901
|
-
if (Date.now() - last < 3000) return;
|
|
1902
|
-
globalThis[KEY] = Date.now();
|
|
1903
|
-
|
|
1904
|
-
// v0.4.36:记下重载前的事件计数,成功后算差值。
|
|
1905
|
-
// 差值 > 0 说明重载窗口内有事件到达,那些播报可能丢了。
|
|
1906
|
-
const eventsBeforeReload = readEventCount();
|
|
1907
|
-
|
|
1908
|
-
// ── 计算"磁盘上现在应该是什么"(v0.4.20)──────────────────────
|
|
1909
|
-
//
|
|
1910
|
-
// 重载必须能自证:换上来的代码 == 磁盘上的代码。
|
|
1911
|
-
// 只靠"拿到新模块对象"不够 —— 那可能只是入口换了、依赖还是旧的。
|
|
1912
|
-
//
|
|
1913
|
-
// 这里算出磁盘内容的指纹,连同代际标记一起交给 selfReload;
|
|
1914
|
-
// 它会在**刚加载的模块里**比对这个值,不一致就判失败并回滚。
|
|
1915
|
-
//
|
|
1916
|
-
// 注意:这个指纹是"内容哈希",而 index.js 导出的 fingerprint 是
|
|
1917
|
-
// "代际标记",两者用途不同,不能混。
|
|
1918
|
-
const diskFingerprint = computeFingerprint(PLUGIN_ROOT);
|
|
1919
|
-
selfReload(ctx, {
|
|
1920
|
-
logger: console,
|
|
1921
|
-
// 期望新模块导出一个非空 fingerprint(证明它被重新求值过)
|
|
1922
|
-
expectedFingerprint: undefined,
|
|
1923
|
-
// 但必须要求:新模块的 fingerprint 与**当前这个实例**不同
|
|
1924
|
-
differFrom: fingerprint,
|
|
1925
|
-
diskFingerprint,
|
|
1926
|
-
}).then((r) => {
|
|
1927
|
-
try {
|
|
1928
|
-
if (r.ok) {
|
|
1929
|
-
// 重载窗口内是否有事件被吞掉(见 EVENT_COUNTER_KEY 的注释)
|
|
1930
|
-
try {
|
|
1931
|
-
const missed = readEventCount() - eventsBeforeReload;
|
|
1932
|
-
if (missed > 0) {
|
|
1933
|
-
// ⚠️ 文案必须准确(外部审阅 2026-10-18 指出):
|
|
1934
|
-
// 这个计数器只在 session/event 回调里 +1 ——
|
|
1935
|
-
// 若重载期间**根本没有监听器**,事件被宿主发出但插件没收到,
|
|
1936
|
-
// 计数不会增加,missed 却是 0(漏检)。
|
|
1937
|
-
// 反过来旧/新实例正常处理了事件,计数增加但通知已发出(误报)。
|
|
1938
|
-
//
|
|
1939
|
-
// 所以它只能说明"重载窗口内插件观察到过事件",
|
|
1940
|
-
// 不能当漏报数量。文案按这个口径写。
|
|
1941
|
-
appendNotifyLog(`[reload] 重载窗口内观察到 ${missed} 个事件(非漏报数,仅供排查参考)`);
|
|
1942
|
-
console.warn(`[dsh-notify-say] 重载窗口内观察到 ${missed} 个事件(非漏报数)`);
|
|
1943
|
-
}
|
|
1944
|
-
} catch { /* ignore */ }
|
|
1945
|
-
console.log(`[dsh-notify-say] 已热重载(${r.fibers} 个 fiber`
|
|
1946
|
-
+ `${r.busted ? ',走了代际绕过' : ''}`
|
|
1947
|
-
+ `${r.verified ? ',指纹校验通过' : ''})`);
|
|
1948
|
-
} else {
|
|
1949
|
-
appendNotifyLog(`[reload] FAILED ${r.error}`);
|
|
1950
|
-
appendNotifyLog(`[reload] FAILED ${r.error}${r.restored ? ' (旧代码仍在跑)' : ' (回滚也失败)'}`);
|
|
1951
|
-
console.warn(`[dsh-notify-say] 热重载失败:${r.error}`
|
|
1952
|
-
+ (r.restored ? '(旧代码仍在运行)' : '(回滚也失败,建议重启宿主)'));
|
|
1953
|
-
}
|
|
1954
|
-
} catch { /* ignore */ }
|
|
1955
|
-
}).catch(() => { /* selfReload 内部已兜底 */ });
|
|
1956
|
-
},
|
|
2003
|
+
expectedFingerprint: undefined,
|
|
2004
|
+
differFrom: fingerprint,
|
|
2005
|
+
diskFingerprint,
|
|
1957
2006
|
});
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2007
|
+
} catch (e) {
|
|
2008
|
+
return { ok: false, reason: 'exception', error: String((e && e.message) || e) };
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (r && r.ok) {
|
|
2012
|
+
// 重载窗口内是否观察过事件(只作排查参考,不是漏报数 ——
|
|
2013
|
+
// 口径见 EVENT_COUNTER_KEY 的注释)。
|
|
2014
|
+
let observed = 0;
|
|
2015
|
+
try { observed = readEventCount() - eventsBefore; } catch { /* ignore */ }
|
|
2016
|
+
appendNotifyLog('[reload] OK by-user' + (observed > 0 ? ' observed=' + observed : ''));
|
|
2017
|
+
return {
|
|
2018
|
+
ok: true,
|
|
2019
|
+
reason: 'reloaded',
|
|
2020
|
+
fibers: r.fibers,
|
|
2021
|
+
busted: !!r.busted,
|
|
2022
|
+
verified: !!r.verified,
|
|
2023
|
+
observed,
|
|
2024
|
+
};
|
|
1965
2025
|
}
|
|
1966
|
-
|
|
2026
|
+
|
|
2027
|
+
const err = String((r && r.error) || 'unknown');
|
|
2028
|
+
appendNotifyLog('[reload] FAILED ' + err);
|
|
2029
|
+
return {
|
|
2030
|
+
ok: false,
|
|
2031
|
+
reason: 'reload-failed',
|
|
2032
|
+
error: err,
|
|
2033
|
+
restored: !!(r && r.restored),
|
|
2034
|
+
hint: '自重载没有成功,旧代码仍在运行 —— 请重启 DSH。',
|
|
2035
|
+
};
|
|
2036
|
+
};
|
|
2037
|
+
|
|
1967
2038
|
}
|
|
1968
2039
|
|
|
1969
2040
|
// ── 官方 Config schema 导出(v0.4.0)─────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-notify-say",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.57",
|
|
4
4
|
"description": "Desktop notifications for DSH Desktop that actually speak: Windows TTS out of the box (zero download), VOICEVOX voice packs optional, a selectable system-sound fallback, and an idle gate that waits for running subagents before saying the turn is done. Includes a settings-page GUI (sound source, per-event overrides, jump-back-to-session, cooldowns) and a self hot-reload for development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -555,12 +555,16 @@ test('C19: 高级折叠区含五种事件的音源覆盖', () => {
|
|
|
555
555
|
// 用户 2026-10-17 要求:"UI 里面没有放上热重载吗?让他选支不支持"
|
|
556
556
|
test('C20: GUI 有热重载开关,且说明其风险', () => {
|
|
557
557
|
const src = readClient();
|
|
558
|
-
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
assert.
|
|
558
|
+
// ⚠️ v0.4.57:从「自动热重载开关」改成「点一次重载一次」的按钮。
|
|
559
|
+
//
|
|
560
|
+
// 原因(2026-10-18 实测宿主崩溃):watcher 在我改 UI 期间触发了 40 多次
|
|
561
|
+
// 失败重载,每次 dispose 都释放宿主仍在用的会话引用,最后渲染进程崩溃。
|
|
562
|
+
// 现在没有开关、没有自动触发,只有显式按钮。
|
|
563
|
+
assert.doesNotMatch(src, /apply\(\{ devReload: !!e\.target\.checked \}/,
|
|
564
|
+
'不应再有 devReload 的自动重载开关 —— 自动触发已被移除');
|
|
565
|
+
assert.match(src, /重载现在生效/, '应有一键重载按钮');
|
|
566
|
+
assert.match(src, /function doReload\(\)/, '按钮应触发一次显式重载请求');
|
|
567
|
+
assert.match(src, /cfgUrl\('reload'\)/, '重载应走 POST /reload');
|
|
564
568
|
// 必须说明风险:依赖宿主内部结构、失效不影响播报
|
|
565
569
|
assert.match(src, /依赖宿主内部结构/, '应说明它依赖内部结构');
|
|
566
570
|
assert.match(src, /失效时只报错/, '应说明失效时的行为');
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性自重载的行为测试(v0.4.57)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这个文件存在
|
|
5
|
+
*
|
|
6
|
+
* 2026-10-18 宿主崩了。崩溃日志给了两条硬证据:
|
|
7
|
+
*
|
|
8
|
+
* ① 45 条「热重载失败:fingerprint unchanged(旧代码仍在运行)」
|
|
9
|
+
* —— 那天为调 UI 改了 40 多次文件,每次保存都触发一次注定失败的尝试;
|
|
10
|
+
* ② 同一时刻反复「Session reference "..." is released」
|
|
11
|
+
* —— 每次重载都 dispose 旧实例,而 dispose 会释放渲染进程仍在用的
|
|
12
|
+
* 会话引用;反复几十次后渲染进程拿到损坏的 client 包,抛
|
|
13
|
+
* SyntaxError,宿主退出。
|
|
14
|
+
*
|
|
15
|
+
* **危险的不是"重载失败",是"反复重载"。**
|
|
16
|
+
*
|
|
17
|
+
* 所以设计改成:删掉 watcher,只在用户点按钮时尝试一次。
|
|
18
|
+
* 而"该不该真的试"的判断抽成 `planReload()` 纯函数 —— 它就是这个文件的主角:
|
|
19
|
+
*
|
|
20
|
+
* · 代码没变 → noop(**一次 dispose 都不做**,这是止血点)
|
|
21
|
+
* · 插件根路径变了(pnpm 升级)→ 拒绝,并如实说必须重启
|
|
22
|
+
* · 确实变了 → reload(一次,失败不重试)
|
|
23
|
+
*/
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { resolve, dirname, join } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
import { planReload } from '../lib/dev-reload.js';
|
|
31
|
+
|
|
32
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const IDX = readFileSync(join(ROOT, 'lib', 'index.js'), 'utf8');
|
|
34
|
+
const DR = readFileSync(join(ROOT, 'lib', 'dev-reload.js'), 'utf8');
|
|
35
|
+
const CLIENT = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8');
|
|
36
|
+
|
|
37
|
+
/** 去掉注释,只看真正会执行的代码。 */
|
|
38
|
+
function code(src) {
|
|
39
|
+
return src
|
|
40
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
41
|
+
.split(/\r?\n/)
|
|
42
|
+
.filter((l) => !/^\s*\/\//.test(l))
|
|
43
|
+
.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── P1: 代码没变 → 一次 dispose 都不做 ──────────────────────────────────
|
|
47
|
+
test('P1: 磁盘指纹与运行中一致时 → noop(不重载)', () => {
|
|
48
|
+
// 这是整个修复的止血点:上次那 45 次失败尝试,全部属于"点了但没必要"或
|
|
49
|
+
// "自动触发但没必要"的情形。noop 让它们连 dispose 都不会发生。
|
|
50
|
+
const r = planReload({
|
|
51
|
+
diskFingerprint: 'abc123',
|
|
52
|
+
runningFingerprint: 'abc123',
|
|
53
|
+
resolvedRootAtLoad: 'E:/p',
|
|
54
|
+
resolvedRootNow: 'E:/p',
|
|
55
|
+
});
|
|
56
|
+
assert.equal(r.action, 'noop');
|
|
57
|
+
assert.equal(r.reason, 'already-current');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── P2: 插件根路径变了 → 拒绝并说清原因 ─────────────────────────────────
|
|
61
|
+
test('P2: 插件根真实路径变了 → 拒绝重载,提示重启', () => {
|
|
62
|
+
// pnpm 升级会把包装到新的 .pnpm/<name>@<ver>/ 下。
|
|
63
|
+
// 我们从加载时的旧路径重新导入,拿到的仍是旧代码 —— 与其给一个
|
|
64
|
+
// "看起来成功其实没换"的按钮,不如直接说清楚。
|
|
65
|
+
const r = planReload({
|
|
66
|
+
diskFingerprint: 'NEW',
|
|
67
|
+
runningFingerprint: 'OLD',
|
|
68
|
+
resolvedRootAtLoad: 'C:/d/.pnpm/pkg@0.4.56/node_modules/pkg',
|
|
69
|
+
resolvedRootNow: 'C:/d/.pnpm/pkg@0.5.0/node_modules/pkg',
|
|
70
|
+
});
|
|
71
|
+
assert.equal(r.action, 'refuse-path-changed');
|
|
72
|
+
assert.equal(r.reason, 'path-changed');
|
|
73
|
+
assert.match(r.hint, /重启/, '必须明确告知只能重启');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── P3: 确实变了 → 重载一次 ─────────────────────────────────────────────
|
|
77
|
+
test('P3: 代码确实变了且路径未变 → reload', () => {
|
|
78
|
+
const r = planReload({
|
|
79
|
+
diskFingerprint: 'NEW',
|
|
80
|
+
runningFingerprint: 'OLD',
|
|
81
|
+
resolvedRootAtLoad: 'E:/p',
|
|
82
|
+
resolvedRootNow: 'E:/p',
|
|
83
|
+
});
|
|
84
|
+
assert.equal(r.action, 'reload');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── P4: 读不到指纹 → 不能假装"已是最新" ─────────────────────────────────
|
|
88
|
+
test('P4: 读不到磁盘指纹时不清空判断(按 reload 处理)', () => {
|
|
89
|
+
// 只读沙箱里 computeFingerprint 会返回 ''。
|
|
90
|
+
// 这时若判 noop,用户会以为已生效;判 reload 至少有机会真的生效,
|
|
91
|
+
// 失败也会如实报错。
|
|
92
|
+
const r = planReload({
|
|
93
|
+
diskFingerprint: '',
|
|
94
|
+
runningFingerprint: 'abc',
|
|
95
|
+
resolvedRootAtLoad: 'E:/p',
|
|
96
|
+
resolvedRootNow: 'E:/p',
|
|
97
|
+
});
|
|
98
|
+
assert.equal(r.action, 'reload', '不能因为读不到指纹就静默 noop');
|
|
99
|
+
assert.equal(r.reason, 'fingerprint-unreadable');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── P5: 唯一入口是 POST /reload,且算写操作 ─────────────────────────────
|
|
103
|
+
test('P5: 重载只经 POST /reload,且被当作写操作保护', () => {
|
|
104
|
+
assert.match(IDX, /head === 'reload'/, '应有 /reload 路由');
|
|
105
|
+
assert.match(IDX, /toUpperCase\(\) !== 'POST'/, '必须只接受 POST(不可逆操作)');
|
|
106
|
+
// 写操作 → 走本地来源防护(Origin/跨站检查)
|
|
107
|
+
const writeAt = IDX.indexOf('const isWriteRoute =');
|
|
108
|
+
assert.ok(writeAt > 0, '应能定位 isWriteRoute');
|
|
109
|
+
const seg = IDX.slice(writeAt, writeAt + 900);
|
|
110
|
+
assert.match(seg, /head === 'reload'/, '/reload 必须列进 isWriteRoute');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── P6: 没有任何自动触发路径 ────────────────────────────────────────────
|
|
114
|
+
test('P6: 没有任何自动触发(无 watcher、无定时器驱动重载)', () => {
|
|
115
|
+
// 这一条锁住"用户要求:点一下重载一下,不要一直在重载"。
|
|
116
|
+
const dr = code(DR);
|
|
117
|
+
assert.doesNotMatch(dr, /fs\.watch/, 'dev-reload.js 不应再监听文件系统');
|
|
118
|
+
assert.doesNotMatch(dr, /watchPackage/, 'watchPackage 应已彻底移除');
|
|
119
|
+
|
|
120
|
+
const idx = code(IDX);
|
|
121
|
+
assert.doesNotMatch(idx, /watchPackage/, 'index.js 不应再调用 watchPackage');
|
|
122
|
+
assert.doesNotMatch(idx, /devReload\)\s*\{\s*[\s\S]{0,200}selfReload/,
|
|
123
|
+
'不应再有「配置开着就自动重载」的路径');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ── P7: GUI 只有一个按钮,没有开关 ──────────────────────────────────────
|
|
127
|
+
test('P7: GUI 是按钮而不是开关', () => {
|
|
128
|
+
assert.match(CLIENT, /重载现在生效/, '应有一次性重载按钮');
|
|
129
|
+
assert.doesNotMatch(CLIENT, /apply\(\{ devReload: !!e\.target\.checked \}/,
|
|
130
|
+
'不应再有自动热重载开关');
|
|
131
|
+
// 按钮必须说明它不会自动重复,以及"升级后必须重启"
|
|
132
|
+
assert.match(CLIENT, /不会自动重复/, '应说明不会自动重复');
|
|
133
|
+
assert.match(CLIENT, /新版本装在另一个路径/, '应说明升级场景自重载做不到');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── P8: 每次尝试都留可观测痕迹 ──────────────────────────────────────────
|
|
137
|
+
test('P8: 成功与失败都写进 notify.log', () => {
|
|
138
|
+
// watcher 时代的 `[watch]` 信号没了,可观测性要补在重载结果上,
|
|
139
|
+
// 否则"点了没反应"无法排查。
|
|
140
|
+
assert.match(IDX, /\[reload\] OK by-user/);
|
|
141
|
+
assert.match(IDX, /\[reload\] FAILED/);
|
|
142
|
+
const okAt = IDX.indexOf('[reload] OK by-user');
|
|
143
|
+
const seg = IDX.slice(Math.max(0, okAt - 400), okAt + 80);
|
|
144
|
+
assert.match(seg, /appendNotifyLog/, '必须写文件,不能只进 console');
|
|
145
|
+
});
|
|
@@ -97,26 +97,40 @@ test('X4: 事件计数日志如实说明「非漏报数」', () => {
|
|
|
97
97
|
// · 重载期间没有监听器 → 事件没被观察到,missed=0(漏检)
|
|
98
98
|
// · 旧/新实例正常处理了 → 计数增加但通知已发出(误报)
|
|
99
99
|
// 所以它只能叫"观察到的事件数",不能说"漏报数"。
|
|
100
|
-
assert.match(IDX,
|
|
100
|
+
assert.match(IDX, /非漏报数|不是漏报数/,
|
|
101
101
|
'日志文案必须说明这个数字不是漏报数量 —— 否则排查时会被误导');
|
|
102
102
|
|
|
103
103
|
assert.doesNotMatch(IDX, /对应播报可能已丢失/,
|
|
104
104
|
'不能声称"播报已丢失"—— 计数器证明不了这件事');
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
// ── X5:
|
|
108
|
-
test('X5: 只有源码变更才触发重载', () => {
|
|
109
|
-
const c = code(DR);
|
|
110
|
-
|
|
111
|
-
assert.match(c, /isSourceFile/,
|
|
112
|
-
'应有源码文件过滤 —— 否则改文档/测试也会触发一次注定失败的重载');
|
|
107
|
+
// ── X5: 没有任何自动触发(只能用户点击)────────────────────────────────
|
|
113
108
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
109
|
+
test('X5: 重载只能由用户点击触发,没有 watcher', () => {
|
|
110
|
+
// v0.4.57 之前这里是「监听要有源码过滤」。
|
|
111
|
+
// 那个保障针对的是"改了文档也会触发一次注定失败的重载"。
|
|
112
|
+
//
|
|
113
|
+
// 现在设计变了:**根本没有 watcher**,所以那类问题从根上消失。
|
|
114
|
+
// 保障升级为更强的一条:不许出现任何自动触发路径。
|
|
115
|
+
//
|
|
116
|
+
// 背景(2026-10-18 宿主崩溃):watcher 在我改 UI 期间触发了 40 多次
|
|
117
|
+
// 失败重载 —— 每次 dispose 都释放宿主仍在用的会话引用。
|
|
118
|
+
// 去掉自动触发不是"少了个功能",而是这个崩溃的根因修复。
|
|
119
|
+
const dr = code(DR);
|
|
120
|
+
assert.doesNotMatch(dr, /fs\.watch/,
|
|
121
|
+
'dev-reload.js 不应再监听文件系统 —— 自动重载是崩溃根因');
|
|
122
|
+
assert.doesNotMatch(dr, /setInterval|setTimeout\([^)]*reload/i,
|
|
123
|
+
'不应有任何定时器驱动的重载');
|
|
124
|
+
|
|
125
|
+
// index.js 里也不能再启动 watcher
|
|
126
|
+
assert.doesNotMatch(code(IDX), /watchPackage\(/,
|
|
127
|
+
'index.js 不应再调用 watchPackage');
|
|
128
|
+
|
|
129
|
+
// 唯一入口必须是显式路由
|
|
130
|
+
assert.match(IDX, /head === 'reload'/,
|
|
131
|
+
'重载只能经 POST /reload 触发');
|
|
132
|
+
assert.match(IDX, /toUpperCase\(\) !== 'POST'/,
|
|
133
|
+
'该路由必须只接受 POST(不可逆操作)');
|
|
120
134
|
});
|
|
121
135
|
|
|
122
136
|
// ── X6: 成功返回值要带 busted / verified ────────────────────────────────
|
|
@@ -137,22 +151,19 @@ test('X6: selfReload 成功时返回 busted 与 verified', () => {
|
|
|
137
151
|
});
|
|
138
152
|
|
|
139
153
|
// ── X7: watcher 必须可观测(区分"没触发"与"触发了但失败")──────────────
|
|
140
|
-
test('X7:
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
assert.match(IDX, /\[watch\]/,
|
|
157
|
-
'日志前缀应是 [watch]');
|
|
158
|
-
});
|
|
154
|
+
test('X7: 每次重载尝试的结果都写进 notify.log', () => {
|
|
155
|
+
// 原本这里断言 watcher 的 `[watch] watching/event` 可观测信号。
|
|
156
|
+
// watcher 删除后,需要同样的可观测性 —— 改到**重载结果**上:
|
|
157
|
+
// 成功与失败都必须落进日志文件,否则"点了没反应"无法排查。
|
|
158
|
+
assert.match(IDX, /\[reload\] OK by-user/,
|
|
159
|
+
'成功要写日志(带 by-user 标记,与旧的自动重载区分)');
|
|
160
|
+
assert.match(IDX, /\[reload\] FAILED/,
|
|
161
|
+
'失败也要写日志,并留下原因');
|
|
162
|
+
|
|
163
|
+
// 必须落文件,不能只进 console
|
|
164
|
+
const okAt = IDX.indexOf('[reload] OK by-user');
|
|
165
|
+
assert.ok(okAt > 0, '应能定位成功日志');
|
|
166
|
+
const seg = IDX.slice(Math.max(0, okAt - 400), okAt + 80);
|
|
167
|
+
assert.match(seg, /appendNotifyLog/,
|
|
168
|
+
'日志必须走 appendNotifyLog(写文件),只进 console 我们看不到');
|
|
169
|
+
});
|