dsh-notify-say 0.4.21 → 0.4.25
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/README.md +216 -176
- package/docs/audio-audit/README.md +164 -0
- package/docs/audio-audit/fetch-snapshots.ps1 +76 -0
- package/docs/audio-audit/snapshots/ameharu-hau.txt +89 -0
- package/docs/audio-audit/snapshots/goki-nuigurumi.txt +73 -0
- package/docs/audio-audit/snapshots/kasukabe-tsumugi.txt +197 -0
- package/docs/audio-audit/snapshots/manbetsu-hanamaru.txt +27 -0
- package/docs/audio-audit/snapshots/meimei-meria.txt +84 -0
- package/docs/audio-audit/snapshots/mochiko.txt +27 -0
- package/docs/audio-audit/snapshots/namiine-ritsu.txt +76 -0
- package/docs/audio-audit/snapshots/shirakami-kotaro.txt +158 -0
- package/docs/audio-audit/snapshots/sss-anonymous-common.txt +142 -0
- package/docs/audio-audit/snapshots/sss-zundamon.txt +142 -0
- package/docs/audio-audit/snapshots/voicevox-software.txt +41 -0
- package/docs/audio-audit/snapshots/voicevox-vvm-terms.txt +385 -0
- package/docs/audio-audit/snapshots/yurei-chan.txt +112 -0
- package/docs//345/276/205/345/201/232/346/270/205/345/215/225.md +218 -0
- package/docs//350/257/255/351/237/263/345/214/205/345/210/206/345/217/221/346/226/271/346/241/210-/345/276/205/345/256/241/351/230/205.md +378 -0
- package/lib/client.js +50 -8
- package/lib/config-route.js +251 -209
- package/lib/http-guard.js +222 -0
- package/lib/index.js +1822 -1768
- package/lib/session-bridge.js +138 -117
- package/package.json +11 -2
- package/test/audio-source.test.mjs +184 -0
- package/test/audio-wiring.test.mjs +287 -0
- package/test/client-bundle.test.mjs +671 -0
- package/test/client-contract.test.mjs +118 -0
- package/test/config-coverage.test.mjs +109 -0
- package/test/config-route.test.mjs +212 -0
- package/test/config-schema.test.mjs +147 -0
- package/test/config-store.test.mjs +256 -0
- package/test/dev-reload.test.mjs +132 -0
- package/test/effective-for-client.test.mjs +142 -0
- package/test/fail-route.test.mjs +128 -0
- package/test/fail-voice.test.mjs +193 -0
- package/test/http-guard.test.mjs +143 -0
- package/test/http-integration.test.mjs +284 -0
- package/test/idle-gate.test.mjs +167 -0
- package/test/preview.test.mjs +92 -0
- package/test/real-cordis.test.mjs +204 -0
- package/test/release-voice-guard.test.mjs +185 -0
- package/test/reload-live.test.mjs +261 -0
- package/test/reload-verification.test.mjs +218 -0
- package/test/route-loader-register.mjs +22 -0
- package/test/scalar-guard.test.mjs +125 -0
- package/test/session-jump.test.mjs +266 -0
- package/test/stub-child-process.mjs +43 -0
- package/test/tts-lines.test.mjs +112 -0
- package/test/version-consistency.test.mjs +98 -0
- package/test/voice-availability.test.mjs +90 -0
- package/test/wav-gain.test.mjs +138 -0
- package/test/wiring.test.mjs +287 -0
- package/tools/notify-config.ps1 +472 -471
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 热重载实机测试:在真实 Cordis 上下文里跑完整重载流程。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这个测试不可替代
|
|
5
|
+
*
|
|
6
|
+
* R1~R10 验证了**零件**(指纹、代际穿透、校验规则),但没验证**整台机器
|
|
7
|
+
* 转起来**。2026-10-17 的假成功恰恰发生在 selfReload 的整体编排里 ——
|
|
8
|
+
* 零件都对,串起来报 ok 却没真换代码。
|
|
9
|
+
*
|
|
10
|
+
* ## 写这个测试时挖出的三个真问题(都已在源码里修)
|
|
11
|
+
*
|
|
12
|
+
* 1. **`ctx.loader` 直读会被 Cordis 拒绝**
|
|
13
|
+
* 报 `cannot get property "loader" without inject`。必须用
|
|
14
|
+
* `ctx.get('loader', false)` 这种非 strict 取法。这个异常当初被 catch
|
|
15
|
+
* 吞掉,表现为"热重载没反应",是最难查的形态。
|
|
16
|
+
*
|
|
17
|
+
* 2. **指纹判断力反了**
|
|
18
|
+
* 第一版用 `String(import.meta.url)` 做指纹 —— 代际一变 URL 就变、
|
|
19
|
+
* 指纹就变,于是"代码没改但重新求值了"也被判成"新代码生效"。
|
|
20
|
+
* 现在改为对**自身源码字节**做哈希:代码不变 → 指纹不变。
|
|
21
|
+
* S2/S5 就是锁这条的。
|
|
22
|
+
*
|
|
23
|
+
* 3. **假 loader 必须模拟"缓存清掉就重新求值"**
|
|
24
|
+
* 否则 `import(url)` 命中 Node 的 ESM 缓存,文件改了也拿到旧模块,
|
|
25
|
+
* 表现为"指纹未变"的假失败。
|
|
26
|
+
*
|
|
27
|
+
* ## 不碰真实插件目录
|
|
28
|
+
*
|
|
29
|
+
* 全程在系统临时目录造包,不会触发宿主热重载、不打断任何会话。
|
|
30
|
+
*/
|
|
31
|
+
import { test } from 'node:test';
|
|
32
|
+
import assert from 'node:assert/strict';
|
|
33
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { tmpdir } from 'node:os';
|
|
35
|
+
import { join, resolve, dirname } from 'node:path';
|
|
36
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
|
|
39
|
+
import { selfReload, computeFingerprint, verifyReload } from '../lib/dev-reload.js';
|
|
40
|
+
|
|
41
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
|
|
43
|
+
/** 定位真实 Cordis(DSH 用 @deepseek-ai/cordis,不是 npm 的 cordis)。 */
|
|
44
|
+
function loadRealCordis() {
|
|
45
|
+
const candidates = [
|
|
46
|
+
process.env.DSH_CORDIS_ENTRY || '', // CI/其他机器可指定
|
|
47
|
+
'D:\\Program Files (x86)\\DSH\\.runtime\\node_modules\\@deepseek-ai\\cordis\\lib\\index.js',
|
|
48
|
+
];
|
|
49
|
+
const req = createRequire(import.meta.url);
|
|
50
|
+
for (const p of candidates) {
|
|
51
|
+
// 显式设了 DSH_CORDIS_ENTRY 就只用它 —— 便于模拟"没有宿主"的 CI 环境
|
|
52
|
+
if (process.env.DSH_CORDIS_ENTRY) {
|
|
53
|
+
const only = process.env.DSH_CORDIS_ENTRY;
|
|
54
|
+
try { req.resolve(only); return pathToFileURL(only).href; } catch { return undefined; }
|
|
55
|
+
}
|
|
56
|
+
if (!p) continue;
|
|
57
|
+
try {
|
|
58
|
+
req.resolve(p);
|
|
59
|
+
return pathToFileURL(p).href;
|
|
60
|
+
} catch { /* 试下一个 */ }
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const REAL_CORDIS = loadRealCordis();
|
|
66
|
+
const skipLive = REAL_CORDIS === undefined;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 造一个可写的最小插件包。
|
|
70
|
+
*
|
|
71
|
+
* fingerprint 用**自身源码哈希**,与 lib/index.js 同构 —— 这点很重要:
|
|
72
|
+
* 用 `String(import.meta.url)` 会让代际一变指纹就变,"代码没改也判成生效"。
|
|
73
|
+
*/
|
|
74
|
+
function writePlugin(dir, markerValue) {
|
|
75
|
+
mkdirSync(join(dir, 'lib'), { recursive: true });
|
|
76
|
+
writeFileSync(join(dir, 'package.json'),
|
|
77
|
+
JSON.stringify({ name: 'reload-probe-pkg', version: '1.0.0', type: 'module' }, null, 2));
|
|
78
|
+
|
|
79
|
+
const src = [
|
|
80
|
+
"import { readFileSync } from 'node:fs';",
|
|
81
|
+
"import { fileURLToPath } from 'node:url';",
|
|
82
|
+
'',
|
|
83
|
+
'export const fingerprint = (() => {',
|
|
84
|
+
' try {',
|
|
85
|
+
' const buf = readFileSync(fileURLToPath(import.meta.url));',
|
|
86
|
+
' let hash = 5381;',
|
|
87
|
+
' for (let i = 0; i < buf.length; i++) hash = ((hash * 33) ^ buf[i]) >>> 0;',
|
|
88
|
+
" return hash.toString(16).padStart(8, '0');",
|
|
89
|
+
' } catch {',
|
|
90
|
+
" return '';",
|
|
91
|
+
' }',
|
|
92
|
+
'})();',
|
|
93
|
+
'',
|
|
94
|
+
'export const marker = ' + JSON.stringify(markerValue) + ';',
|
|
95
|
+
'',
|
|
96
|
+
'export function apply(ctx) {',
|
|
97
|
+
' globalThis.__reloadProbeCtx = ctx;',
|
|
98
|
+
'}',
|
|
99
|
+
'',
|
|
100
|
+
].join('\n');
|
|
101
|
+
writeFileSync(join(dir, 'lib', 'index.js'), src);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 提供一个假的 loader 服务。
|
|
106
|
+
*
|
|
107
|
+
* `internal.loadCache` 是 selfReload 真正依赖的东西:它在里面找本包的
|
|
108
|
+
* 模块条目并清掉。真宿主靠这个让 loader 重新求值;假 loader 模拟同样行为 ——
|
|
109
|
+
* 条目被清掉时给 URL 加唯一查询串强制重求值(等价于生产的 ?dns-reload=)。
|
|
110
|
+
*/
|
|
111
|
+
async function withLoaderService() {
|
|
112
|
+
const { Context, Service } = await import(REAL_CORDIS);
|
|
113
|
+
const root = new Context();
|
|
114
|
+
|
|
115
|
+
class FakeLoader extends Service {
|
|
116
|
+
constructor(ctx) {
|
|
117
|
+
super(ctx, 'loader', true);
|
|
118
|
+
this.internal = { loadCache: new Map() };
|
|
119
|
+
}
|
|
120
|
+
async import(url, _fallback) {
|
|
121
|
+
let target = url;
|
|
122
|
+
if (!this.internal.loadCache.has(url)) {
|
|
123
|
+
const u = new URL(url);
|
|
124
|
+
u.searchParams.set('probe', String(Date.now()) + '-' + Math.random().toString(36).slice(2, 8));
|
|
125
|
+
target = u.href;
|
|
126
|
+
}
|
|
127
|
+
const mod = await import(target);
|
|
128
|
+
this.internal.loadCache.set(url, mod);
|
|
129
|
+
return mod;
|
|
130
|
+
}
|
|
131
|
+
unwrapExports(mod) { return mod; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
root.plugin(FakeLoader);
|
|
135
|
+
// 等 service 就绪。不能用 root.lifecycle.flush():该版本的 root 上没有 lifecycle。
|
|
136
|
+
for (let i = 0; i < 100; i++) {
|
|
137
|
+
if (root.loader && root.loader.internal && root.loader.internal.loadCache) break;
|
|
138
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
139
|
+
}
|
|
140
|
+
assert.ok(root.loader, 'FakeLoader service 应已就绪');
|
|
141
|
+
|
|
142
|
+
return { root };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── S1: 完整重载真的换掉了代码(核心断言)─────────────────────────────
|
|
146
|
+
test('S1: 真实 Cordis 里 selfReload 换上了新代码(marker v1 → v2)', { skip: skipLive }, async () => {
|
|
147
|
+
const { root } = await withLoaderService();
|
|
148
|
+
|
|
149
|
+
const dir = mkdtempSync(join(tmpdir(), 'reload-probe-'));
|
|
150
|
+
writePlugin(dir, 'v1');
|
|
151
|
+
|
|
152
|
+
const pkgUrl = pathToFileURL(dir + '\\').href;
|
|
153
|
+
const entryUrl = pathToFileURL(join(dir, 'lib', 'index.js')).href;
|
|
154
|
+
|
|
155
|
+
const loader = root.loader;
|
|
156
|
+
const first = await loader.import(entryUrl);
|
|
157
|
+
assert.equal(first.marker, 'v1', '初版应是 v1');
|
|
158
|
+
const oldFp = first.fingerprint;
|
|
159
|
+
assert.ok(oldFp.length > 0, '初版应有指纹');
|
|
160
|
+
assert.ok(loader.internal.loadCache.size > 0, 'loadCache 应有条目');
|
|
161
|
+
|
|
162
|
+
// 用模块对象本身注册 —— registry.get(fiber.runtime.callback) 才能查到 runtime,
|
|
163
|
+
// 且被注册模块的 apply 会真的执行(拿到 ctx)。
|
|
164
|
+
const fiber = root.plugin(first);
|
|
165
|
+
await fiber.await();
|
|
166
|
+
const pluginCtx = globalThis.__reloadProbeCtx;
|
|
167
|
+
assert.ok(pluginCtx, '被注册模块的 apply 应执行并留下 ctx');
|
|
168
|
+
|
|
169
|
+
const prev = fiber.runtime.callback;
|
|
170
|
+
const rt = root.registry.get(prev);
|
|
171
|
+
assert.ok(rt, 'registry 应能用 callback 查到 runtime');
|
|
172
|
+
assert.ok([...rt.fibers].length >= 1, 'runtime 应有 live fiber');
|
|
173
|
+
|
|
174
|
+
// 改代码 → 磁盘变成 v2(内容变了,源码哈希也会变)
|
|
175
|
+
writePlugin(dir, 'v2');
|
|
176
|
+
assert.ok(computeFingerprint(dir).length > 0, '磁盘指纹应可算');
|
|
177
|
+
|
|
178
|
+
const r = await selfReload(pluginCtx, {
|
|
179
|
+
logger: { info() {}, warn() {} },
|
|
180
|
+
packageUrl: pkgUrl,
|
|
181
|
+
entryUrl,
|
|
182
|
+
differFrom: oldFp,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
assert.equal(r.ok, true, '重载应成功:' + (r.error || ''));
|
|
186
|
+
|
|
187
|
+
// 核心断言:重新求值后拿到 v2,且指纹变了(代码真的换了)
|
|
188
|
+
const reloaded = await import(entryUrl + '?probe=verify-' + Date.now());
|
|
189
|
+
assert.equal(reloaded.marker, 'v2', '重新求值后应是 v2(磁盘上的新代码)');
|
|
190
|
+
assert.notEqual(reloaded.fingerprint, oldFp, '源码变了,指纹必须不同');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── S2: 代码没变时,differFrom 校验必须拦住(不谎报成功)────────────────
|
|
194
|
+
test('S2: 代码未变时 differFrom 校验判失败(不假成功)', { skip: skipLive }, async () => {
|
|
195
|
+
const { root } = await withLoaderService();
|
|
196
|
+
|
|
197
|
+
const dir = mkdtempSync(join(tmpdir(), 'reload-same-'));
|
|
198
|
+
writePlugin(dir, 'same');
|
|
199
|
+
|
|
200
|
+
const pkgUrl = pathToFileURL(dir + '\\').href;
|
|
201
|
+
const entryUrl = pathToFileURL(join(dir, 'lib', 'index.js')).href;
|
|
202
|
+
|
|
203
|
+
const loader = root.loader;
|
|
204
|
+
const first = await loader.import(entryUrl);
|
|
205
|
+
|
|
206
|
+
const fiber = root.plugin(first);
|
|
207
|
+
await fiber.await();
|
|
208
|
+
const pluginCtx = globalThis.__reloadProbeCtx;
|
|
209
|
+
assert.ok(pluginCtx, 'apply 应执行');
|
|
210
|
+
|
|
211
|
+
// 不改代码,把当前指纹当 differFrom → 必须失败。
|
|
212
|
+
// 这是 2026-10-17 假成功的直接复刻:重新求值确实发生了,
|
|
213
|
+
// 但代码没变,所以"生效"这个结论不成立。
|
|
214
|
+
const r = await selfReload(pluginCtx, {
|
|
215
|
+
logger: { info() {}, warn() {} },
|
|
216
|
+
packageUrl: pkgUrl,
|
|
217
|
+
entryUrl,
|
|
218
|
+
differFrom: first.fingerprint,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
assert.equal(r.ok, false,
|
|
222
|
+
'代码未变时必须判失败 —— 否则就是 2026-10-17 那种假成功');
|
|
223
|
+
assert.match(String(r.error), /unchanged|verification failed/,
|
|
224
|
+
'错误信息应说明校验未通过,实际:' + r.error);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── S3: 缺 loader/registry 时安全返回,不抛异常 ─────────────────────────
|
|
228
|
+
test('S3: 上下文缺 loader 时返回结构化失败(不抛)', async () => {
|
|
229
|
+
const r = await selfReload({}, { logger: { info() {}, warn() {} } });
|
|
230
|
+
assert.equal(r.ok, false);
|
|
231
|
+
assert.match(String(r.error), /no loader\/registry/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── S4: computeFingerprint 对真实插件目录可用 ──────────────────────────
|
|
235
|
+
test('S4: computeFingerprint 对真实插件目录返回非空指纹', () => {
|
|
236
|
+
const fp = computeFingerprint(ROOT);
|
|
237
|
+
assert.ok(fp.length > 0, '真实插件目录应能算出指纹');
|
|
238
|
+
assert.match(fp, /^[0-9a-f]{8}$/, '应是 8 位十六进制');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── S5: 真实 index.js 的 fingerprint 不随代际变化(关键:判断力正确)────
|
|
242
|
+
test('S5: index.js 的 fingerprint 对同一份代码保持稳定(不随代际变)', async () => {
|
|
243
|
+
const base = pathToFileURL(join(ROOT, 'lib', 'index.js')).href;
|
|
244
|
+
const a = await import(base);
|
|
245
|
+
const b = await import(base + '?dns-reload=gen-different');
|
|
246
|
+
assert.equal(a.fingerprint, b.fingerprint,
|
|
247
|
+
'代码没变时,代际不同也必须得到相同指纹 —— 否则校验判断力是反的');
|
|
248
|
+
assert.ok(a.fingerprint.length > 0, '指纹不能为空');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ── S6: verifyReload 的四条规则(防逻辑漂移)────────────────────────────
|
|
252
|
+
test('S6: verifyReload 的四条规则', () => {
|
|
253
|
+
assert.equal(verifyReload({ gotFingerprint: 'aaa', differFrom: 'aaa' }).ok, false,
|
|
254
|
+
'相同指纹必须失败');
|
|
255
|
+
assert.equal(verifyReload({ gotFingerprint: 'bbb', differFrom: 'aaa' }).ok, true,
|
|
256
|
+
'不同指纹应通过');
|
|
257
|
+
assert.equal(verifyReload({ gotFingerprint: '', differFrom: 'aaa' }).ok, false,
|
|
258
|
+
'缺指纹必须失败(无法自证)');
|
|
259
|
+
assert.equal(verifyReload({ gotFingerprint: 'aaa' }).checked, false,
|
|
260
|
+
'未启用校验时 checked 应为 false');
|
|
261
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 热重载「真生效」验证测试(v0.4.20)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这批测试必须存在
|
|
5
|
+
*
|
|
6
|
+
* 2026-10-17 实测:`selfReload()` 返回 `{ ok: true }`,但代码**没有真的换掉**
|
|
7
|
+
* (`config-store.js` 换了、`index.js` 没换,或反之)。这种「假成功」造成的
|
|
8
|
+
* 误导极强 —— 我因此连续几轮都基于"新代码已运行"的错误前提分析问题。
|
|
9
|
+
*
|
|
10
|
+
* 原有 D1~D8 只测了 `packageCacheKeys` / `purgeCaches` 这些**工具函数**,
|
|
11
|
+
* 对"重载到底有没有生效"完全没有覆盖。所以它报成功,测试照样全绿。
|
|
12
|
+
*
|
|
13
|
+
* ## 这批测试验证什么
|
|
14
|
+
*
|
|
15
|
+
* 用**真实的 Node 模块系统**(不是 mock)验证三件事:
|
|
16
|
+
*
|
|
17
|
+
* 1. `fingerprint` 是真的会变的:同一文件的不同代际 URL 得到不同指纹,
|
|
18
|
+
* 不同模块对象、不同 apply 函数。
|
|
19
|
+
* 2. 代际绕过真的能穿透到依赖:入口带 `?dns-reload=x` 时,
|
|
20
|
+
* 它 import 的依赖也必须重新求值(这是"半新半旧"的根治点)。
|
|
21
|
+
* 3. 校验逻辑真的会拒绝假成功:指纹没变时必须抛错,而不是默默返回 ok。
|
|
22
|
+
*
|
|
23
|
+
* 这些用独立 node 进程跑真实 import,因此**不需要重启 DSH**。
|
|
24
|
+
*/
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import { join, resolve, dirname } from 'node:path';
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
import { registerHooks } from 'node:module';
|
|
32
|
+
|
|
33
|
+
import { computeFingerprint } from '../lib/dev-reload.js';
|
|
34
|
+
|
|
35
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
|
|
37
|
+
// ── R1: 本包 index.js 确实导出了 fingerprint ────────────────────────────
|
|
38
|
+
test('R1: lib/index.js 导出非空 fingerprint(生效校验的前提)', async () => {
|
|
39
|
+
const mod = await import(pathToFileURL(join(ROOT, 'lib', 'index.js')).href);
|
|
40
|
+
assert.equal(typeof mod.fingerprint, 'string',
|
|
41
|
+
'index.js 必须导出 string 类型的 fingerprint,否则重载无法自证生效');
|
|
42
|
+
assert.ok(mod.fingerprint.length > 0, 'fingerprint 不能为空');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── R2: 同一 URL 重复导入 → 指纹相同(模块缓存生效,符合预期)──────────
|
|
46
|
+
test('R2: 同 URL 重复 import 得到相同 fingerprint(未重新求值)', async () => {
|
|
47
|
+
const url = pathToFileURL(join(ROOT, 'lib', 'index.js')).href;
|
|
48
|
+
const a = await import(url);
|
|
49
|
+
const b = await import(url);
|
|
50
|
+
assert.equal(a.fingerprint, b.fingerprint,
|
|
51
|
+
'同一 URL 应命中缓存、不重新求值 —— 这正是热重载要打破的');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── R3: 不同代际 URL → 指纹不同、模块对象不同(代际绕过有效)────────────
|
|
55
|
+
test('R3: 代际 URL 得到新模块对象,但 fingerprint 相同(源码哈希语义)', async () => {
|
|
56
|
+
// ⚠️ 这条的断言在 v0.4.20 变过,原因很重要:
|
|
57
|
+
//
|
|
58
|
+
// 第一版 fingerprint 由 import.meta.url 派生,于是「代际不同 → 指纹不同」。
|
|
59
|
+
// 但那让校验**判断力反了** —— 代码没改、只是重新求值一次,指纹也变,
|
|
60
|
+
// 于是「没改代码」被判成「新代码已生效」(S2 测试当场抓到)。
|
|
61
|
+
//
|
|
62
|
+
// 现在 fingerprint 是**源码哈希**:代码不变 → 指纹不变,与代际无关。
|
|
63
|
+
// 所以这里断言「指纹相同、模块对象不同」,正好体现两者语义差异:
|
|
64
|
+
// · 代际(URL) 反映「是否重新求值」
|
|
65
|
+
// · 指纹 反映「代码内容是否变化」
|
|
66
|
+
const base = pathToFileURL(join(ROOT, 'lib', 'index.js')).href;
|
|
67
|
+
const a = await import(base);
|
|
68
|
+
const b = await import(base + '?dns-reload=gen-a');
|
|
69
|
+
const c = await import(base + '?dns-reload=gen-b');
|
|
70
|
+
|
|
71
|
+
// 代际确实让模块重新求值了
|
|
72
|
+
assert.notEqual(a, b, '不同代际应是不同的模块对象');
|
|
73
|
+
assert.equal(typeof b.apply, 'function');
|
|
74
|
+
assert.notEqual(a.apply, b.apply, 'apply 函数应是新的(不是复用旧对象)');
|
|
75
|
+
|
|
76
|
+
// 但代码没变,所以指纹必须一致 —— 这是校验能「拒绝假成功」的前提
|
|
77
|
+
assert.equal(a.fingerprint, b.fingerprint, '代码未变时,代际不同指纹也应相同');
|
|
78
|
+
assert.equal(b.fingerprint, c.fingerprint, '同理');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('R4: enableFreshDependencies 让代际穿透到依赖(真实实现,非手写钩子)', async () => {
|
|
82
|
+
// ⚠️ 设计要点:必须调用**真实的** enableFreshDependencies,
|
|
83
|
+
// 而不是在测试里手写一份同构的 hook。
|
|
84
|
+
//
|
|
85
|
+
// 第一版 R4 就是手写 hook,结果变异测试发现:把 dev-reload.js 里的
|
|
86
|
+
// 穿透逻辑改坏,R4 依然全绿(假阴性)。原因很简单 —— 它测的是自己。
|
|
87
|
+
//
|
|
88
|
+
// 这条对应 2026-10-17 的真实故障:入口带代际了、但入口里 import './x.js'
|
|
89
|
+
// 不带代际,于是依赖仍是旧模块,形成"半新半旧"。
|
|
90
|
+
const dir = mkdtempSync(join(tmpdir(), 'gen-pass-'));
|
|
91
|
+
mkdirSync(join(dir, 'lib'), { recursive: true });
|
|
92
|
+
writeFileSync(join(dir, 'lib', 'dep.js'),
|
|
93
|
+
'export const selfUrl = import.meta.url;\nexport const marker = "dep";\n');
|
|
94
|
+
writeFileSync(join(dir, 'lib', 'entry.js'),
|
|
95
|
+
'import { selfUrl, marker } from "./dep.js";\nexport { marker };\nexport const depUrl = selfUrl;\n');
|
|
96
|
+
|
|
97
|
+
const entryBase = pathToFileURL(join(dir, 'lib', 'entry.js')).href;
|
|
98
|
+
const depBase = pathToFileURL(join(dir, 'lib', 'dep.js')).href;
|
|
99
|
+
|
|
100
|
+
// ① 不带代际:依赖 URL 就是原样
|
|
101
|
+
const plain = await import(entryBase);
|
|
102
|
+
assert.equal(plain.depUrl, depBase, '不带代际时依赖 URL 应是原样');
|
|
103
|
+
|
|
104
|
+
// ② 用被测模块自己的实现装上穿透钩子
|
|
105
|
+
const { enableFreshDependencies } = await import('../lib/dev-reload.js');
|
|
106
|
+
const packageUrl = pathToFileURL(dir + '\\').href;
|
|
107
|
+
enableFreshDependencies(packageUrl);
|
|
108
|
+
|
|
109
|
+
// ③ 导入带代际的入口,检查依赖是否也继承了代际
|
|
110
|
+
const gened = await import(entryBase + '?dns-reload=testgen-' + Date.now());
|
|
111
|
+
assert.match(gened.depUrl, /dns-reload=testgen-/,
|
|
112
|
+
'依赖模块的 URL 应继承代际参数;否则依赖仍是旧模块("半新半旧"的机制)');
|
|
113
|
+
assert.notEqual(gened.depUrl, depBase, '依赖不应还是原 URL');
|
|
114
|
+
assert.equal(gened.marker, 'dep', '同一份磁盘代码,导出的值应一致');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('R5: computeFingerprint 随文件变化而变(不是固定值)', async () => {
|
|
118
|
+
const dir = mkdtempSync(join(tmpdir(), 'fp-test-'));
|
|
119
|
+
mkdirSync(join(dir, 'lib'), { recursive: true });
|
|
120
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"x","version":"1.0.0"}');
|
|
121
|
+
writeFileSync(join(dir, 'lib', 'a.js'), 'export const x = 1;');
|
|
122
|
+
|
|
123
|
+
const fp1 = computeFingerprint(dir);
|
|
124
|
+
assert.ok(fp1.length > 0, '应返回非空指纹');
|
|
125
|
+
|
|
126
|
+
// ⚠️ 指纹用的是 statSync 的 size + mtimeMs,而很多文件系统的 mtime 精度
|
|
127
|
+
// 只有 1ms~1s。连续写入可能落在同一时间片里,导致"文件变了但指纹没变"。
|
|
128
|
+
// 这不是代码缺陷(真实场景下改动间隔远大于此),但会让测试间歇性失败。
|
|
129
|
+
// 所以这里显式等到 mtime 真的变化,再写第二版。
|
|
130
|
+
const before = statSync(join(dir, 'lib', 'a.js')).mtimeMs;
|
|
131
|
+
let waited = 0;
|
|
132
|
+
while (statSync(join(dir, 'lib', 'a.js')).mtimeMs === before && waited < 2000) {
|
|
133
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
134
|
+
waited += 20;
|
|
135
|
+
}
|
|
136
|
+
writeFileSync(join(dir, 'lib', 'a.js'), 'export const x = 22222;');
|
|
137
|
+
|
|
138
|
+
const fp2 = computeFingerprint(dir);
|
|
139
|
+
assert.notEqual(fp1, fp2,
|
|
140
|
+
'文件变了指纹就该变 —— 否则"磁盘期望值"校验形同虚设');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ── R6: computeFingerprint 目录不存在时返回空串,不抛异常 ─────────────────
|
|
144
|
+
test('R6: computeFingerprint 对不存在目录返回空串(不抛)', () => {
|
|
145
|
+
let fp;
|
|
146
|
+
assert.doesNotThrow(() => { fp = computeFingerprint('Z:\\no-such-dir-xyz'); });
|
|
147
|
+
assert.equal(typeof fp, 'string');
|
|
148
|
+
assert.equal(fp, '', '不存在时应返回空串,让调用方知道"算不出来"');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── R7: computeFingerprint 对同一目录稳定(同样内容得到同样值)──────────
|
|
152
|
+
test('R7: computeFingerprint 对未改动的目录保持稳定', () => {
|
|
153
|
+
const dir = mkdtempSync(join(tmpdir(), 'fp-stable-'));
|
|
154
|
+
mkdirSync(join(dir, 'lib'), { recursive: true });
|
|
155
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"y"}');
|
|
156
|
+
writeFileSync(join(dir, 'lib', 'b.js'), 'export const y = 1;');
|
|
157
|
+
const a = computeFingerprint(dir);
|
|
158
|
+
const b = computeFingerprint(dir);
|
|
159
|
+
assert.equal(a, b, '同一份未改动的内容应得到相同指纹');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── R8: verifyReload 的四条规则(核心:防假成功)─────────────────────────
|
|
163
|
+
test('R8: verifyReload 在指纹相同时必须判失败(复刻 2026-10-17 假成功)', async () => {
|
|
164
|
+
const { verifyReload } = await import('../lib/dev-reload.js');
|
|
165
|
+
|
|
166
|
+
// 负向:相同指纹 → 必须失败。这正是当年"重载报 ok 但其实没换"的根因。
|
|
167
|
+
const same = verifyReload({ gotFingerprint: 'abc12345', differFrom: 'abc12345' });
|
|
168
|
+
assert.equal(same.ok, false, '指纹相同必须判失败,不能默默报 ok');
|
|
169
|
+
assert.equal(same.checked, true, '应标记为"已校验"');
|
|
170
|
+
assert.match(same.error, /unchanged/, '错误信息应说明指纹未变');
|
|
171
|
+
|
|
172
|
+
// 正向:不同指纹 → 通过
|
|
173
|
+
const diff = verifyReload({ gotFingerprint: 'def67890', differFrom: 'abc12345' });
|
|
174
|
+
assert.equal(diff.ok, true);
|
|
175
|
+
assert.equal(diff.checked, true);
|
|
176
|
+
|
|
177
|
+
// 负向:没有指纹 → 无法自证 → 失败(不能因为"拿不到"就放行)
|
|
178
|
+
const none = verifyReload({ gotFingerprint: '', differFrom: 'abc12345' });
|
|
179
|
+
assert.equal(none.ok, false, '拿不到指纹时必须失败 —— 否则等于没校验');
|
|
180
|
+
assert.match(none.error, /does not export fingerprint/);
|
|
181
|
+
|
|
182
|
+
// 未启用校验 → 通过,但 checked=false(调用方要知道"这次没验证")
|
|
183
|
+
const off = verifyReload({ gotFingerprint: 'abc' });
|
|
184
|
+
assert.equal(off.ok, true);
|
|
185
|
+
assert.equal(off.checked, false, '未启用校验时 checked 应为 false');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── R8b: expectedFingerprint 比 differFrom 更严 ──────────────────────────
|
|
189
|
+
test('R8b: expectedFingerprint 要求完全相等', async () => {
|
|
190
|
+
const { verifyReload } = await import('../lib/dev-reload.js');
|
|
191
|
+
|
|
192
|
+
const match = verifyReload({ gotFingerprint: 'aaa', expectedFingerprint: 'aaa' });
|
|
193
|
+
assert.equal(match.ok, true);
|
|
194
|
+
|
|
195
|
+
const mismatch = verifyReload({ gotFingerprint: 'bbb', expectedFingerprint: 'aaa' });
|
|
196
|
+
assert.equal(mismatch.ok, false);
|
|
197
|
+
assert.match(mismatch.error, /mismatch/, '错误信息应指出不匹配');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── R9: index.js 的 fingerprint 由 import.meta.url 派生(可区分代际)─────
|
|
201
|
+
test('R9: index.js 源码里的 fingerprint 依赖 import.meta.url', () => {
|
|
202
|
+
const src = readFileSync(join(ROOT, 'lib', 'index.js'), 'utf8');
|
|
203
|
+
assert.match(src, /export const fingerprint/, '必须导出 fingerprint');
|
|
204
|
+
assert.match(src, /import\.meta\.url/,
|
|
205
|
+
'fingerprint 必须由 import.meta.url 派生,否则不同代际会得到相同值');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── R10: dev-reload 源码必须含代际绕过(这次的根治点)───────────────────
|
|
209
|
+
test('R10: dev-reload.js 含代际绕过与 registerHooks', () => {
|
|
210
|
+
const src = readFileSync(join(ROOT, 'lib', 'dev-reload.js'), 'utf8');
|
|
211
|
+
assert.match(src, /registerHooks/, '必须用 registerHooks 把代际传给依赖');
|
|
212
|
+
assert.match(src, /searchParams\.set\(GEN_PARAM/,
|
|
213
|
+
'必须给 URL 加代际参数,否则缓存清不掉时无法强制重求值');
|
|
214
|
+
assert.match(src, /enableFreshDependencies/, '必须有代际穿透函数');
|
|
215
|
+
// 旧实现"清不掉就放弃"的写法必须消失
|
|
216
|
+
assert.doesNotMatch(src, /nothing was purged \(loadCache shape changed/,
|
|
217
|
+
'不应再有"清不掉就直接放弃"的分支 —— 那正是假成功的来源');
|
|
218
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 注册 `stub-child-process.mjs` loader,并置位 `globalThis.__STUB_CHILD_PROCESS__`
|
|
3
|
+
*(供 `fail-route.test.mjs` 自检"stub 是否真的生效")。
|
|
4
|
+
*
|
|
5
|
+
* 用法(只能用 --import,不能用 --loader,后者在新版 Node 已废弃):
|
|
6
|
+
* node --import ./test/route-loader-register.mjs --test test/fail-route.test.mjs
|
|
7
|
+
* 或直接 `npm run test:route`。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ 两点实测注意:
|
|
10
|
+
* ① register() 必须在**测试文件加载之前**执行 —— --import 保证这一点。
|
|
11
|
+
* ② 该文件与 stub 都**不是**测试文件(不以 .test. 命名),默认 `node --test` 不会执行它们。
|
|
12
|
+
*/
|
|
13
|
+
import { register } from 'node:module';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
globalThis.__STUB_CHILD_PROCESS__ = true;
|
|
19
|
+
process.env.DSH_ROUTE_LOADER = '1';
|
|
20
|
+
|
|
21
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
register(pathToFileURL(join(HERE, 'stub-child-process.mjs')));
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 标量过滤测试:防 "[object Object]" 漏进 PowerShell 参数。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这个 bug 值得专门测
|
|
5
|
+
*
|
|
6
|
+
* 2026-10-17 实测:`kindOr()` 从按事件字典取值时,拿到了 schemastery 的
|
|
7
|
+
* **ref 对象**(带 .get() 的包装)。它被原样拼进 PowerShell 的 `-Voice`/`-Rate`
|
|
8
|
+
* 参数,变成字符串 `"[object Object]"`:
|
|
9
|
+
*
|
|
10
|
+
* - 弹窗**正常**(那部分参数没错)
|
|
11
|
+
* - 声音**完全静默**(SAPI 收到非法参数,合成失败)
|
|
12
|
+
*
|
|
13
|
+
* 表象是"通知能用但没声音",非常容易误判成音频设备或播放链路问题。
|
|
14
|
+
* 所以我之前查了半小时音源判断逻辑,真正的原因却在这里。
|
|
15
|
+
*
|
|
16
|
+
* ## 测试策略
|
|
17
|
+
*
|
|
18
|
+
* 直接测 `effectiveForClient` 的输出:它必须只产出**标量**,
|
|
19
|
+
* 任何对象(包括 ref 包装)都要被丢弃或解包成标量。
|
|
20
|
+
*/
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
|
|
24
|
+
import { effectiveForClient } from '../lib/index.js';
|
|
25
|
+
|
|
26
|
+
/** 造一个 schemastery 风格的 ref(有 .get())。 */
|
|
27
|
+
function mkRef(value) {
|
|
28
|
+
return {
|
|
29
|
+
__isRef: true,
|
|
30
|
+
get() { return value; },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 基础 cfg 骨架。 */
|
|
35
|
+
function mkCfg(over) {
|
|
36
|
+
return {
|
|
37
|
+
voice: true, volume: 80, audioSource: 'tts', showToast: true,
|
|
38
|
+
voiceMode: 'fixed', voiceName: 'zundamon-normal', ttsVoice: '', ttsRate: 0,
|
|
39
|
+
debounceMs: 60_000, globalCooldownMs: 0,
|
|
40
|
+
audioSourceByKind: {}, ttsTexts: {},
|
|
41
|
+
kindTtsVoice: {}, kindTtsRate: {}, kindVoiceMode: {},
|
|
42
|
+
kindVoiceName: {}, kindSystemSound: {}, customFiles: {},
|
|
43
|
+
...over,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── X1: 字典里的 ref 对象必须被解包成标量 ────────────────────────────────
|
|
48
|
+
test('X1: 按事件字典里的 ref 对象被解包成标量(不是 [object Object])', () => {
|
|
49
|
+
const r = effectiveForClient(mkCfg({
|
|
50
|
+
kindTtsVoice: { done: mkRef('Microsoft Huihui Desktop') },
|
|
51
|
+
kindTtsRate: { done: mkRef(-3) },
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// 关键断言:必须是字符串/数字,不能是对象
|
|
55
|
+
assert.equal(typeof r.kindTtsVoiceDone, 'string',
|
|
56
|
+
`必须解包成字符串,实际 ${typeof r.kindTtsVoiceDone}`);
|
|
57
|
+
assert.equal(r.kindTtsVoiceDone, 'Microsoft Huihui Desktop');
|
|
58
|
+
assert.equal(typeof r.kindTtsRateDone, 'number');
|
|
59
|
+
assert.equal(r.kindTtsRateDone, -3);
|
|
60
|
+
|
|
61
|
+
// 绝对不能出现 "[object Object]"
|
|
62
|
+
assert.doesNotMatch(String(r.kindTtsVoiceDone), /\[object Object\]/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── X2: 无法解包的对象被丢弃,而不是原样传出 ─────────────────────────────
|
|
66
|
+
test('X2: 无法解包的对象被丢弃(宁可跟随全局,也不要脏值)', () => {
|
|
67
|
+
const r = effectiveForClient(mkCfg({
|
|
68
|
+
kindSystemSound: { fail: { some: 'object' } },
|
|
69
|
+
kindVoiceName: { question: ['array'] },
|
|
70
|
+
}));
|
|
71
|
+
assert.equal(r.kindSystemSoundFail, undefined,
|
|
72
|
+
'不可解包的对象应被丢弃,否则会变成 [object Object]');
|
|
73
|
+
assert.equal(r.kindVoiceNameQuestion, undefined);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── X3: 正常标量照常透传(防过度过滤)────────────────────────────────────
|
|
77
|
+
test('X3: 正常标量不受影响', () => {
|
|
78
|
+
const r = effectiveForClient(mkCfg({
|
|
79
|
+
kindSystemSound: { done: 'Asterisk', fail: 'Hand' },
|
|
80
|
+
kindVoiceName: { question: 'ankomon' },
|
|
81
|
+
ttsTexts: { done: '搞定啦' },
|
|
82
|
+
customFiles: { approval: 'D:\\a.wav' },
|
|
83
|
+
kindTtsRate: { done: 5 },
|
|
84
|
+
}));
|
|
85
|
+
assert.equal(r.kindSystemSoundDone, 'Asterisk');
|
|
86
|
+
assert.equal(r.kindSystemSoundFail, 'Hand');
|
|
87
|
+
assert.equal(r.kindVoiceNameQuestion, 'ankomon');
|
|
88
|
+
assert.equal(r.ttsTextDone, '搞定啦');
|
|
89
|
+
assert.equal(r.customFileApproval, 'D:\\a.wav');
|
|
90
|
+
assert.equal(r.kindTtsRateDone, 5);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── X4: 所有按事件扁平键都必须是标量 ─────────────────────────────────────
|
|
94
|
+
test('X4: 所有 kind* / ttsText* / customFile* 输出都是标量', () => {
|
|
95
|
+
// 故意塞入各种类型,看有没有漏出对象
|
|
96
|
+
const r = effectiveForClient(mkCfg({
|
|
97
|
+
kindTtsVoice: { done: mkRef('A'), fail: {}, question: [], approval: null, blocked: 0 },
|
|
98
|
+
kindTtsRate: { done: mkRef(1), fail: 'abc' },
|
|
99
|
+
kindVoiceMode: { done: mkRef('fixed') },
|
|
100
|
+
kindVoiceName: { done: 'x' },
|
|
101
|
+
kindSystemSound: { done: 'Beep' },
|
|
102
|
+
ttsTexts: { done: 'hi', fail: {} },
|
|
103
|
+
customFiles: { done: 'D:\\a.wav', fail: [] },
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
const perKind = Object.keys(r).filter((k) => /^(kind|ttsText|customFile)[A-Z]/.test(k));
|
|
107
|
+
assert.ok(perKind.length > 0, '应有按事件扁平键');
|
|
108
|
+
|
|
109
|
+
const bad = perKind.filter((k) => {
|
|
110
|
+
const v = r[k];
|
|
111
|
+
return typeof v === 'object' || Array.isArray(v);
|
|
112
|
+
});
|
|
113
|
+
assert.deepEqual(bad, [],
|
|
114
|
+
'以下按事件键的值是对象,会被 PowerShell 读成 [object Object]:' + JSON.stringify(bad));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── X5: 解包失败时不能抛异常 ─────────────────────────────────────────────
|
|
118
|
+
test('X5: ref.get() 抛异常时不崩,退回全局', () => {
|
|
119
|
+
const boom = { get() { throw new Error('boom'); } };
|
|
120
|
+
let r;
|
|
121
|
+
assert.doesNotThrow(() => {
|
|
122
|
+
r = effectiveForClient(mkCfg({ kindTtsVoice: { done: boom } }));
|
|
123
|
+
});
|
|
124
|
+
assert.equal(r.kindTtsVoiceDone, undefined, '解包失败应丢弃');
|
|
125
|
+
});
|