@memef1f1y/dsh-plugin-tts 0.6.4 → 0.6.7

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.
Files changed (37) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +51 -51
  2. package/.github/ISSUE_TEMPLATE/config.yml +2 -2
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -28
  4. package/.github/pull_request_template.md +7 -7
  5. package/.github/workflows/contrib-triage.yml +66 -66
  6. package/.github/workflows/linked-issue.yml +21 -21
  7. package/.github/workflows/publish.yml +63 -0
  8. package/.github/workflows/stale.yml +30 -30
  9. package/.github/workflows/test.yml +33 -33
  10. package/.github/workflows/welcome.yml +22 -22
  11. package/ANALYSIS.md +205 -205
  12. package/PR-REVIEW.md +74 -74
  13. package/README.md +335 -335
  14. package/TODO.md +34 -23
  15. package/docs/HOST-CONTRACT.md +36 -36
  16. package/docs/RVC-GUIDE.md +390 -390
  17. package/docs/USER-GUIDE.md +389 -389
  18. package/docs/acceptance-checklist.md +147 -147
  19. package/docs/adaptive-chunked-playback.md +194 -194
  20. package/docs/macos-test-prompt.md +230 -230
  21. package/docs/windows-gpu-verify-prompt.md +151 -151
  22. package/lib/client.js +7164 -7164
  23. package/lib/index.mjs +3374 -3371
  24. package/package.json +51 -52
  25. package/rvc-server.py +564 -564
  26. package/tests/README.md +45 -45
  27. package/tests/client-load.mjs +406 -406
  28. package/tests/e2e-compact-index.mjs +86 -86
  29. package/tests/e2e-index-ab.mjs +138 -138
  30. package/tests/e2e-real-rvc.mjs +122 -122
  31. package/tests/i18n-keys.mjs +82 -82
  32. package/tests/package-runtime-patch.mjs +88 -88
  33. package/tests/rvc-server-live.mjs +230 -230
  34. package/tests/smoke.mjs +1284 -1284
  35. package/tools/gen-ui-preview.mjs +174 -174
  36. package/tools/mac-verify.sh +158 -158
  37. package/tools/package-runtime.py +214 -214
package/tests/smoke.mjs CHANGED
@@ -1,1284 +1,1284 @@
1
- // Smoke test for the Host half of @memef1f1y/dsh-plugin-tts.
2
- // Uses a fake ctx (webServer captures the routes) and exercises the real
3
- // Edge TTS synthesis over the network, the RVC chain against a mock local
4
- // RVC inference server, plus the voice-pack registry (mock static server).
5
- // node tests/smoke.mjs
6
- import * as plugin from '../lib/index.mjs';
7
- import { createServer } from 'node:http';
8
- import net from 'node:net';
9
- import { execFileSync } from 'node:child_process';
10
- import { createHash } from 'node:crypto';
11
- import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
12
- import os from 'node:os';
13
- import path from 'node:path';
14
- import { fileURLToPath } from 'node:url';
15
- import { startMockRegistry } from './mock-registry.mjs';
16
-
17
- const routes = [];
18
- const fakeEventListeners = new Map(); // name -> [fn] captured via ctx.on
19
-
20
- function emitFake(name, ...args) {
21
- for (const fn of (fakeEventListeners.get(name) || []).slice()) fn(...args);
22
- }
23
-
24
- function fakeCtx() {
25
- return {
26
- get(name) {
27
- if (name === 'webServer') {
28
- return {
29
- register(route) {
30
- routes.push(route);
31
- return () => {
32
- const i = routes.indexOf(route);
33
- if (i >= 0) routes.splice(i, 1);
34
- };
35
- }
36
- };
37
- }
38
- return undefined;
39
- },
40
- effect(fn) { fn?.(); }, // apply the effect callback (registers disposers / event subscriptions) like the host does
41
- on(name, fn) {
42
- const arr = fakeEventListeners.get(name) || [];
43
- arr.push(fn);
44
- fakeEventListeners.set(name, arr);
45
- return () => {
46
- const a = fakeEventListeners.get(name);
47
- if (a) {
48
- const i = a.indexOf(fn);
49
- if (i >= 0) a.splice(i, 1);
50
- }
51
- };
52
- }
53
- };
54
- }
55
-
56
- function mockReq(url, body) {
57
- const chunks = body === undefined ? [] : [body];
58
- return {
59
- url,
60
- [Symbol.asyncIterator]() {
61
- let i = 0;
62
- return {
63
- next: () => {
64
- if (i < chunks.length) return Promise.resolve({ value: chunks[i++], done: false });
65
- return Promise.resolve({ done: true });
66
- }
67
- };
68
- }
69
- };
70
- }
71
-
72
- function mockRes() {
73
- return {
74
- headersSent: false,
75
- head: null,
76
- body: null,
77
- writeHead(code, headers) { this.head = { code, headers }; },
78
- end(body) { this.body = body; }
79
- };
80
- }
81
-
82
- async function call(route, req, res) {
83
- await route.handler(req, res);
84
- return res;
85
- }
86
-
87
- const results = [];
88
- function check(name, ok, detail) {
89
- results.push({ name, ok, detail });
90
- console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
91
- }
92
-
93
- const ctx = fakeCtx();
94
- plugin.apply(ctx);
95
- const speakRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/speak');
96
- const audioRoute = routes.find((r) => r.kind === 'prefix' && r.path === '/dsh-tts-audio');
97
-
98
- check('plugin registers two routes', speakRoute !== undefined && audioRoute !== undefined);
99
-
100
- // Package identity consistency: the loader entry name (cordis.patch.yml)
101
- // and the browser bundle id (lib/client.js) must both equal package.json's
102
- // name. A stale name here ships a package that crashes dsh web at boot
103
- // (loader imports a package name that isn't installed) — exactly the 0.6.1
104
- // market-install boot failure.
105
- {
106
- const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
107
- const pkgName = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).name;
108
- const patchText = readFileSync(path.join(repoRoot, 'cordis.patch.yml'), 'utf8');
109
- const patchName = (patchText.match(/name:\s*['"]([^'"]+)['"]/m) || [])[1] || '';
110
- const clientText = readFileSync(path.join(repoRoot, 'lib', 'client.js'), 'utf8');
111
- const clientId = (clientText.match(/^\s*id:\s*"([^"]+)"/m) || [])[1] || '';
112
- check('cordis.patch.yml entry name matches package.json name',
113
- patchName === pkgName, `patch=${patchName} pkg=${pkgName}`);
114
- check('client bundle id matches package.json name',
115
- clientId === pkgName, `client=${clientId} pkg=${pkgName}`);
116
- }
117
-
118
- if (speakRoute && audioRoute) {
119
- const res = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({ text: '你好,这是一个冒烟测试。', voice: 'zh-CN-XiaoxuanNeural' })), mockRes());
120
- const parsed = JSON.parse(res.body);
121
- check('speak returns 200 + url', res.head.code === 200 && typeof parsed.url === 'string' && parsed.url.startsWith('/dsh-tts-audio/'), parsed.url ?? res.body);
122
-
123
- if (parsed.url) {
124
- const ares = await call(audioRoute, mockReq(parsed.url), mockRes());
125
- const bytes = Buffer.isBuffer(ares.body) ? ares.body : Buffer.from(ares.body ?? '');
126
- const isMp3 = bytes.length > 1000 && bytes[0] === 0xFF && (bytes[1] & 0xE0) === 0xE0;
127
- check('audio route serves valid mp3', ares.head.code === 200 && isMp3, `code=${ares.head.code} bytes=${bytes.length}`);
128
- }
129
-
130
- // repeated speak of the same text+voice must reuse the in-session audio cache
131
- // (replay does not re-synthesize)
132
- {
133
- const res2 = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({ text: '你好,这是一个冒烟测试。', voice: 'zh-CN-XiaoxuanNeural' })), mockRes());
134
- const parsed2 = JSON.parse(res2.body);
135
- check('repeated speak reuses cached URL (no re-synthesize)', res2.head.code === 200 && parsed2.url === parsed.url && parsed2.cached === true, `first=${parsed.url} second=${parsed2.url} cached=${parsed2.cached}`);
136
- }
137
-
138
- // ?download=1 forces a Content-Disposition attachment on the audio asset
139
- if (parsed.url) {
140
- const dres = await call(audioRoute, mockReq(parsed.url + '?download=1'), mockRes());
141
- const cd = dres.head && dres.head.headers && dres.head.headers['Content-Disposition'];
142
- check('audio download sets Content-Disposition attachment', dres.head.code === 200 && /attachment/.test(cd || ''), cd);
143
- }
144
-
145
- const badRes = await call(audioRoute, mockReq('/dsh-tts-audio/nope'), mockRes());
146
- check('unknown audio id -> 404', badRes.head.code === 404);
147
-
148
- // M2 subset: long plain-Edge reads also stream progressively (jobId+chunks),
149
- // not a single blocking synthesis
150
- {
151
- const longEdge = '这是一段很长的文本用于验证 Edge 长读也走自适应分块渐进播放,避免等待整段合成。'.repeat(10);
152
- const er = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
153
- text: longEdge, voice: 'zh-CN-XiaoxuanNeural', provider: 'edge-tts'
154
- })), mockRes());
155
- const ep = JSON.parse(er.body);
156
- check('long edge read returns chunked job (streaming subset)',
157
- er.head.code === 200 && typeof ep.jobId === 'string' && Array.isArray(ep.chunks) && ep.chunks.length >= 1,
158
- ep.jobId ? `jobId=${ep.jobId} chunks=${ep.chunks.length} total=${ep.total}` : er.body);
159
- }
160
-
161
- // Regression: fastFirst 1/3/2 chunk-swap. After /speak returns chunk 0, the
162
- // background prewarm of chunk 1 is still in flight — the old shared-nextIdx
163
- // code served parts[2] as want=1 (or orphaned parts[1] in ready with
164
- // more=false). Draining immediately must yield indices strictly 0..N-1.
165
- {
166
- const orderText = '第一段测试文本,用于验证分段顺序是否正确。'.repeat(9); // ~180 chars -> 3 parts
167
- const or = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
168
- text: orderText, voice: 'zh-CN-XiaoxuanNeural', provider: 'edge-tts'
169
- })), mockRes());
170
- const op = JSON.parse(or.body);
171
- const orderNext = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
172
- if (or.head.code === 200 && typeof op.jobId === 'string' && op.total > 2 && orderNext) {
173
- const seenIdx = [0];
174
- const seenUrls = [op.chunks[0]];
175
- let oMore = true;
176
- let oGuard = 0;
177
- while (oMore && oGuard++ < 20) {
178
- const nr = await call(orderNext, mockReq(`/dsh-tts-api/rvc-next?job=${op.jobId}`), mockRes());
179
- const np = JSON.parse(nr.body);
180
- if (np.url || np.skipped) { seenIdx.push(np.index); if (np.url) seenUrls.push(np.url); }
181
- oMore = !!np.more;
182
- if (np.done) break;
183
- }
184
- const expectIdx = seenIdx.map((_, i) => i);
185
- check('edge chunked job serves indices strictly 0..N-1 (no swap/drop)',
186
- JSON.stringify(seenIdx) === JSON.stringify(expectIdx) && seenIdx.length === op.total && oMore === false,
187
- `got [${seenIdx}] total=${op.total}`);
188
- check('edge chunked job serves distinct URLs',
189
- new Set(seenUrls).size === seenUrls.length, `distinct=${new Set(seenUrls).size}/${seenUrls.length}`);
190
- } else {
191
- check('edge ordering regression precondition: short chunked job', false, or.body.slice(0, 160));
192
- }
193
- }
194
-
195
- // M1+ local-piper provider: registered in the abstraction; unconfigured -> graceful
196
- // localized error (not a crash)
197
- {
198
- const pr = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
199
- text: 'hello', voice: '', provider: 'local-piper', custom: {}
200
- })), mockRes());
201
- const pp = JSON.parse(pr.body);
202
- check('local-piper unconfigured returns graceful error (i18n code)',
203
- pr.head.code === 500 && pp.error && pp.i18n && pp.i18n.code === 'host.piperUnconfigured',
204
- JSON.stringify(pp));
205
- }
206
- }
207
-
208
- // --- RVC chain against a mock local RVC server ---
209
-
210
- function miniWav(seconds = 1, sr = 40000) {
211
- const n = sr * seconds;
212
- const dataSize = n * 2;
213
- const buf = Buffer.alloc(44 + dataSize);
214
- buf.write('RIFF', 0);
215
- buf.writeUInt32LE(36 + dataSize, 4);
216
- buf.write('WAVE', 8);
217
- buf.write('fmt ', 12);
218
- buf.writeUInt32LE(16, 16);
219
- buf.writeUInt16LE(1, 20);
220
- buf.writeUInt16LE(1, 22);
221
- buf.writeUInt32LE(sr, 24);
222
- buf.writeUInt32LE(sr * 2, 28);
223
- buf.writeUInt16LE(2, 32);
224
- buf.writeUInt16LE(16, 34);
225
- buf.write('data', 36);
226
- buf.writeUInt32LE(dataSize, 40);
227
- return buf;
228
- }
229
-
230
- function startMockRvc() {
231
- return new Promise((resolve) => {
232
- const server = createServer((req, res) => {
233
- let body = '';
234
- req.on('data', (d) => (body += d));
235
- req.on('end', () => {
236
- if (req.url === '/load') {
237
- res.writeHead(200, { 'Content-Type': 'application/json' });
238
- res.end(JSON.stringify({ ok: true }));
239
- } else if (req.url === '/health') {
240
- res.writeHead(200, { 'Content-Type': 'application/json' });
241
- res.end(JSON.stringify({ ok: true, model_loaded: true, model: 'demo.pth', gpu_name: 'Mock GPU', vram_gb: 8 }));
242
- } else if (req.url.startsWith('/files?kind=')) {
243
- const kind = req.url.slice('/files?kind='.length);
244
- res.writeHead(200, { 'Content-Type': 'application/json' });
245
- res.end(JSON.stringify({
246
- ok: true,
247
- kind,
248
- files: kind === 'pth'
249
- ? [{ name: 'demo.pth', path: 'C:/models/demo.pth', size: 55000000 }]
250
- : [{ name: 'demo.index', path: 'C:/models/demo.index', size: 400000000 }]
251
- }));
252
- } else if (req.url === '/convert') {
253
- const payload = JSON.parse(body || '{}');
254
- if (!payload.audio_base64) {
255
- res.writeHead(400, { 'Content-Type': 'application/json' });
256
- res.end(JSON.stringify({ message: 'audio_base64 required' }));
257
- return;
258
- }
259
- res.writeHead(200, { 'Content-Type': 'application/json' });
260
- res.end(JSON.stringify({ audio_base64: miniWav().toString('base64'), sample_rate: 40000 }));
261
- } else if (req.url === '/compact-index') {
262
- const payload = JSON.parse(body || '{}');
263
- if (!payload.index) {
264
- res.writeHead(400, { 'Content-Type': 'application/json' });
265
- res.end(JSON.stringify({ message: 'index required' }));
266
- return;
267
- }
268
- res.writeHead(200, { 'Content-Type': 'application/json' });
269
- res.end(JSON.stringify({
270
- ok: true,
271
- path: 'C:/models/demo_compact_' + (payload.target_vectors || 10000) + '.index',
272
- size: 6000000,
273
- vectors: payload.target_vectors || 10000,
274
- source_vectors: 129396,
275
- source_size: 408000000,
276
- reduction_pct: 98.5
277
- }));
278
- } else {
279
- res.writeHead(404);
280
- res.end();
281
- }
282
- });
283
- });
284
- server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
285
- });
286
- }
287
-
288
- if (speakRoute && audioRoute) {
289
- const mock = await startMockRvc();
290
- try {
291
- const res = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
292
- text: '这是一段 RVC 链路测试。',
293
- voice: 'zh-CN-XiaoxuanNeural',
294
- provider: 'rvc',
295
- custom: { baseUrl: `http://127.0.0.1:${mock.port}`, model: 'mock.pth', index: '' }
296
- })), mockRes());
297
- const parsed = JSON.parse(res.body);
298
- check('rvc speak returns 200 + url', res.head.code === 200 && typeof parsed.url === 'string' && parsed.url.startsWith('/dsh-tts-audio/'), parsed.url ?? res.body);
299
- if (parsed.url) {
300
- const ares = await call(audioRoute, mockReq(parsed.url), mockRes());
301
- const bytes = Buffer.isBuffer(ares.body) ? ares.body : Buffer.from(ares.body ?? '');
302
- check('rvc audio route serves wav (RIFF)', ares.head.code === 200 && bytes.length > 44 && bytes.slice(0, 4).toString() === 'RIFF', `code=${ares.head.code} bytes=${bytes.length}`);
303
- }
304
-
305
- // ---- adaptive chunked progressive playback (long RVC text) ----
306
- const longText = '这是一段用于验证自适应分块渐进播放的长文本朗读测试。'.repeat(12); // ~264 chars -> several chunks
307
- const longRes = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
308
- text: longText,
309
- voice: 'zh-CN-XiaoxuanNeural',
310
- provider: 'rvc',
311
- custom: { baseUrl: `http://127.0.0.1:${mock.port}`, model: 'mock.pth', index: '' }
312
- })), mockRes());
313
- const longParsed = JSON.parse(longRes.body);
314
- check('rvc long speak returns jobId + prewarmed chunks', longRes.head.code === 200
315
- && typeof longParsed.jobId === 'string'
316
- && Array.isArray(longParsed.chunks) && longParsed.chunks.length >= 2
317
- && typeof longParsed.total === 'number' && longParsed.total > longParsed.chunks.length,
318
- `jobId=${longParsed.jobId} chunks=${longParsed.chunks && longParsed.chunks.length} total=${longParsed.total}`);
319
- if (typeof longParsed.jobId === 'string') {
320
- const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
321
- check('plugin registers rvc-next route', nextRoute !== undefined);
322
- let fetched = longParsed.chunks.length;
323
- let more = true;
324
- let drained = 0;
325
- while (more && drained < 100) {
326
- const nr = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${longParsed.jobId}`), mockRes());
327
- const np = JSON.parse(nr.body);
328
- if (np && np.url) fetched++;
329
- more = !!(np && np.more);
330
- drained++;
331
- }
332
- check('rvc-next drains to total chunks', fetched === longParsed.total && more === false, `fetched=${fetched} total=${longParsed.total}`);
333
- // Job-completion summary: draining to the end must leave exactly one
334
- // stage=job outcome=drained entry (served==total) in the diagnose log —
335
- // the trace a serve-order incident would previously have left none of.
336
- {
337
- const dgJob = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
338
- const drJob = await call(dgJob, mockReq('/dsh-tts-api/diagnose', JSON.stringify({})), mockRes());
339
- const ddJob = JSON.parse(drJob.body);
340
- const jobHit = Array.isArray(ddJob.recent) && ddJob.recent.find((e) =>
341
- e && e.stage === 'job' && /outcome=drained/.test(e.error || '') &&
342
- e.total === longParsed.total && e.served === longParsed.total &&
343
- Array.isArray(e.skipped) && e.skipped.length === 0);
344
- check('drained job leaves a served==total summary in diagnose.recent',
345
- !!jobHit, JSON.stringify((ddJob.recent || []).filter((e) => e && e.stage === 'job')));
346
- check('diagnose exposes hostBuild tag (which Host code ran)',
347
- typeof ddJob.hostBuild === 'string' && ddJob.hostBuild.length > 0,
348
- JSON.stringify(ddJob.hostBuild));
349
- }
350
- check('rvc-next done for unknown job', (await call(nextRoute, mockReq('/dsh-tts-api/rvc-next?job=unknown'), mockRes())).body === JSON.stringify({ done: true, gone: true }));
351
- // a prewarmed chunk url must serve wav through the audio route
352
- const cRes = await call(audioRoute, mockReq(longParsed.chunks[0]), mockRes());
353
- const cBytes = Buffer.isBuffer(cRes.body) ? cRes.body : Buffer.from(cRes.body ?? '');
354
- check('chunk audio route serves wav (RIFF)', cRes.head.code === 200 && cBytes.length > 44 && cBytes.slice(0, 4).toString() === 'RIFF', `code=${cRes.head.code} bytes=${cBytes.length}`);
355
- }
356
-
357
- // ---- explicit cancel: abandoning a chunked job releases it immediately ----
358
- {
359
- const spRes = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
360
- text: longText,
361
- voice: 'zh-CN-XiaoxuanNeural',
362
- provider: 'rvc',
363
- custom: { baseUrl: `http://127.0.0.1:${mock.port}`, model: 'mock.pth', index: '' }
364
- })), mockRes());
365
- const sp = JSON.parse(spRes.body);
366
- if (typeof sp.jobId === 'string') {
367
- const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
368
- const c1 = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${sp.jobId}&cancel=1`), mockRes());
369
- const c1p = JSON.parse(c1.body);
370
- check('rvc-next cancel returns {done,cancelled}', c1.head.code === 200 && c1p.done === true && c1p.cancelled === true, c1.body);
371
- const c2 = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${sp.jobId}`), mockRes());
372
- const c2p = JSON.parse(c2.body);
373
- check('cancelled job no longer servable (gone)', c2.head.code === 200 && c2p.done === true && c2p.gone === true, c2.body);
374
- } else {
375
- check('cancel test precondition: long rvc speak returns jobId', false, spRes.body);
376
- }
377
- }
378
-
379
- // ---- prewarm skip surfacing: a prewarm chunk that exhausts retries must
380
- // be reported (not walked over silently). index-tts2 is used because it
381
- // has no calibration probe (absolute /tts-task call counting is exact). The mock fails task calls #2 and #3 (= chunk idx1's two
382
- // attempts), so /speak must return chunks [url, false] + skipped [2],
383
- // and draining must serve the skip at index 1 in order before continuing.
384
- {
385
- let taskCalls = 0;
386
- const failServer = createServer((req, res) => {
387
- let body = '';
388
- req.on('data', (d) => (body += d));
389
- req.on('end', () => {
390
- if (req.url === '/health' || req.url === '/api/v1/voices') {
391
- res.writeHead(200, { 'Content-Type': 'application/json' });
392
- res.end(JSON.stringify({ ok: true, voices: ['demo.wav'], count: 1 }));
393
- } else if (req.url === '/api/v1/tts/tasks') {
394
- taskCalls++;
395
- if (taskCalls === 2 || taskCalls === 3) {
396
- res.writeHead(500, { 'Content-Type': 'application/json' });
397
- res.end(JSON.stringify({ detail: 'mock tts boom' }));
398
- return;
399
- }
400
- res.writeHead(200, { 'Content-Type': 'audio/wav' });
401
- res.end(miniWav());
402
- } else {
403
- res.writeHead(404);
404
- res.end();
405
- }
406
- });
407
- });
408
- await new Promise((r) => failServer.listen(0, '127.0.0.1', r));
409
- try {
410
- const failPort = failServer.address().port;
411
- const skipText = '这是一段用于验证预热跳段上报的长文本朗读测试。'.repeat(12);
412
- const skipRes = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
413
- text: skipText,
414
- voice: 'zh-CN-XiaoxuanNeural',
415
- provider: 'index-tts2',
416
- custom: { baseUrl: `http://127.0.0.1:${failPort}`, voice: 'demo.wav' }
417
- })), mockRes());
418
- const skipParsed = JSON.parse(skipRes.body);
419
- check('prewarm skip reported in /speak (chunks[1] hole + skipped [2])',
420
- skipRes.head.code === 200 && Array.isArray(skipParsed.chunks)
421
- && skipParsed.chunks.length >= 2 && skipParsed.chunks[0] && skipParsed.chunks[1] === false
422
- && Array.isArray(skipParsed.skipped) && skipParsed.skipped.length === 1 && skipParsed.skipped[0] === 2,
423
- skipRes.body.slice(0, 200));
424
- // P1-3 spans ride the same response: count matches total and the span
425
- // slices rebuild the sent text (whitespace-insensitive).
426
- {
427
- const sps = skipParsed.spans;
428
- const rebuilt = Array.isArray(sps)
429
- ? sps.map(([a, b]) => skipText.slice(a, b)).join('').replace(/\s/g, '')
430
- : null;
431
- check('chunked /speak carries exact block spans',
432
- Array.isArray(sps) && sps.length === skipParsed.total && rebuilt === skipText.replace(/\s/g, ''),
433
- `spans=${sps && sps.length} total=${skipParsed.total}`);
434
- }
435
- if (typeof skipParsed.jobId === 'string' && Array.isArray(skipParsed.chunks)) {
436
- const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
437
- // serveIdx starts past prewarm; the on-demand serves must be the
438
- // remaining indices strictly in order through to total.
439
- const seen = [];
440
- let sMore = true;
441
- let sGuard = 0;
442
- while (sMore && sGuard++ < 100) {
443
- const nr = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${skipParsed.jobId}`), mockRes());
444
- const np = JSON.parse(nr.body);
445
- if (np.url || np.skipped) seen.push(np.index);
446
- sMore = !!np.more;
447
- if (np.done) break;
448
- }
449
- const expect = [];
450
- for (let i = skipParsed.chunks.length; i < skipParsed.total; i++) expect.push(i);
451
- check('post-prewarm serves continue strictly in order to total',
452
- JSON.stringify(seen) === JSON.stringify(expect) && sMore === false,
453
- `got [${seen}] expect [${expect}] total=${skipParsed.total}`);
454
- }
455
- } finally {
456
- await new Promise((r) => failServer.close(r));
457
- }
458
- }
459
-
460
- // file-discovery proxy route
461
- const filesRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-files');
462
- const fr = await call(filesRoute, mockReq(`/dsh-tts-api/rvc-files?baseUrl=http://127.0.0.1:${mock.port}&kind=pth`), mockRes());
463
- const filesData = JSON.parse(fr.body);
464
- check('rvc-files proxy lists pth files', fr.head.code === 200 && Array.isArray(filesData.files) && filesData.files.length > 0 && filesData.files[0].name === 'demo.pth', fr.body);
465
- const fi = await call(filesRoute, mockReq(`/dsh-tts-api/rvc-files?baseUrl=http://127.0.0.1:${mock.port}&kind=index`), mockRes());
466
- const filesIdx = JSON.parse(fi.body);
467
- check('rvc-files proxy lists index files', fi.head.code === 200 && Array.isArray(filesIdx.files) && filesIdx.files.length > 0 && filesIdx.files[0].name === 'demo.index', fi.body);
468
-
469
- // unreachable RVC service -> actionable, platform-aware startup hint
470
- const badFiles = await call(filesRoute, mockReq('/dsh-tts-api/rvc-files?baseUrl=http%3A%2F%2F127.0.0.1%3A1&kind=pth'), mockRes());
471
- const badFilesData = JSON.parse(badFiles.body);
472
- check('rvc-files unreachable returns actionable startup hint',
473
- badFiles.head.code === 502 &&
474
- badFilesData.i18n && badFilesData.i18n.code === 'host.filesNeedsServer' &&
475
- badFilesData.i18n.params && /rvc-server\.py/.test(badFilesData.i18n.params.startup || ''),
476
- badFiles.body);
477
-
478
- // compact-index proxy route
479
- const compactRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-compact-index');
480
- const cr = await call(compactRoute, mockReq('/dsh-tts-api/rvc-compact-index', JSON.stringify({
481
- baseUrl: `http://127.0.0.1:${mock.port}`,
482
- index: 'C:/models/demo.index',
483
- target_vectors: 2000
484
- })), mockRes());
485
- const compactData = JSON.parse(cr.body);
486
- check('compact-index proxy returns compacted index', cr.head.code === 200
487
- && compactData.ok === true
488
- && compactData.path === 'C:/models/demo_compact_2000.index'
489
- && compactData.reduction_pct === 98.5,
490
- cr.body);
491
- const cb = await call(compactRoute, mockReq('/dsh-tts-api/rvc-compact-index', JSON.stringify({
492
- baseUrl: `http://127.0.0.1:${mock.port}`,
493
- index: ''
494
- })), mockRes());
495
- check('compact-index proxy passes server error', cb.head.code === 502 && typeof JSON.parse(cb.body).error === 'string', cb.body);
496
-
497
- // ---- one-click diagnostics ----
498
- const diagnoseRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
499
- check('plugin registers diagnose route', diagnoseRoute !== undefined);
500
- const dg = await call(diagnoseRoute, mockReq('/dsh-tts-api/diagnose', JSON.stringify({
501
- rvcBaseUrl: `http://127.0.0.1:${mock.port}`
502
- })), mockRes());
503
- const dgData = JSON.parse(dg.body);
504
- const edge = dgData.checks.find(c => c.id === 'edge');
505
- const rvc = dgData.checks.find(c => c.id === 'rvc-server');
506
- const model = dgData.checks.find(c => c.id === 'rvc-model');
507
- check('diagnose: edge synthesis ok', dg.head.code === 200 && edge && edge.ok === true, JSON.stringify(edge));
508
- check('diagnose: rvc server + model ok (mock)', rvc && rvc.ok === true && model && model.ok === true, JSON.stringify({ rvc, model }));
509
- check('diagnose exposes worker meta for the log bundle',
510
- dgData.worker && dgData.worker.chromium === '143.0.3650.75' && dgData.worker.singleTimeoutMs === 20000 && dgData.worker.spawnTimeoutMs === 45000,
511
- JSON.stringify(dgData.worker));
512
- check('diagnose exposes recent host errors array', Array.isArray(dgData.recent), JSON.stringify(dgData.recent));
513
- const dgBad = await call(diagnoseRoute, mockReq('/dsh-tts-api/diagnose', JSON.stringify({
514
- rvcBaseUrl: 'http://127.0.0.1:1'
515
- })), mockRes());
516
- const dgBadData = JSON.parse(dgBad.body);
517
- const rvcBad = dgBadData.checks.find(c => c.id === 'rvc-server');
518
- check('diagnose: unreachable rvc classified as connect', rvcBad && rvcBad.ok === false && rvcBad.cls === 'connect', JSON.stringify(rvcBad));
519
- } finally {
520
- mock.server.close();
521
- }
522
- }
523
-
524
- // --- voice-pack registry: manifest proxy + verified install ---
525
-
526
- {
527
- // isolated packs dir for the test
528
- process.env.DSH_TTS_PACKS_DIR = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packs-'));
529
- // build a mock registry dir
530
- const regDir = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-reg-'));
531
- const modelBytes = Buffer.from('FAKE-MODEL-BYTES-0123456789'.repeat(8000)); // ~200KB
532
- const indexBytes = Buffer.from('FAKE-INDEX-BYTES-abcdef'.repeat(6000)); // ~108KB
533
- const sha = b => createHash('sha256').update(b).digest('hex');
534
- const modelSha = sha(modelBytes);
535
- const indexSha = sha(indexBytes);
536
- writeFileSync(path.join(regDir, 'model.pth'), modelBytes);
537
- writeFileSync(path.join(regDir, 'index.index'), indexBytes);
538
- writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
539
- schema: 1,
540
- packs: [
541
- {
542
- id: 'pack-a',
543
- name: 'Demo Voice A',
544
- description: '测试音色包(模型+紧凑索引)',
545
- version: '1.0.0',
546
- author: 'tester',
547
- license: 'MIT',
548
- baseVoice: 'zh-CN-YunyangNeural',
549
- f0Method: 'rmvpe',
550
- indexRate: 0.75,
551
- model: { url: '', size: modelBytes.length, sha256: modelSha },
552
- index: { url: '', size: indexBytes.length, sha256: indexSha }
553
- },
554
- { id: 'pack-b', name: 'Demo Voice B', description: '免索引音色包', version: '2.0.0', license: 'CC-BY', model: { url: '', size: modelBytes.length, sha256: modelSha } }
555
- ]
556
- }, null, 2));
557
-
558
- // fix relative urls after knowing the server port
559
- const reg = await startMockRegistry(regDir, 0);
560
- try {
561
- const base = reg.base;
562
- const manifestPath = path.join(regDir, 'manifest.json');
563
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
564
- for (const p of manifest.packs) {
565
- if (p.model) p.model.url = `${base}/model.pth`;
566
- if (p.index) p.index.url = `${base}/index.index`;
567
- }
568
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
569
-
570
- const packsRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-packs');
571
- const installRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-install');
572
- const installedRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-packs-installed');
573
- check('plugin registers pack routes', packsRoute !== undefined && installRoute !== undefined && installedRoute !== undefined);
574
-
575
- const pr = await call(packsRoute, mockReq(`/dsh-tts-api/rvc-packs?registry=${encodeURIComponent(base)}`), mockRes());
576
- const prData = JSON.parse(pr.body);
577
- check('rvc-packs proxies manifest', pr.head.code === 200 && Array.isArray(prData.packs) && prData.packs.length === 2, pr.body);
578
-
579
- const ir = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-a' })), mockRes());
580
- const irData = JSON.parse(ir.body);
581
- const installedModel = irData.modelPath || '';
582
- const installedIndex = irData.indexPath || '';
583
- const modelOk = installedModel && existsSync(installedModel) && sha(readFileSync(installedModel)) === modelSha;
584
- const indexOk = installedIndex && existsSync(installedIndex) && sha(readFileSync(installedIndex)) === indexSha;
585
- check('rvc-pack-install downloads + sha256 verifies + writes', ir.head.code === 200 && irData.ok && modelOk && indexOk,
586
- `model=${installedModel} index=${installedIndex}`);
587
-
588
- const ir2 = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-a' })), mockRes());
589
- check('re-install skips (already installed)', JSON.parse(ir2.body).skipped === true, ir2.body);
590
-
591
- const irB = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-b' })), mockRes());
592
- const irBData = JSON.parse(irB.body);
593
- check('index-free pack installs without index', irB.head.code === 200 && irBData.ok && irBData.indexPath === '', irB.body);
594
-
595
- const st = await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes());
596
- const stData = JSON.parse(st.body);
597
- check('rvc-packs-installed lists 2 packs', st.head.code === 200 && stData.installed && stData.installed['pack-a'] && stData.installed['pack-b'], st.body);
598
-
599
- // multi-index pack with RELATIVE urls (schema v2)
600
- writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
601
- schema: 2,
602
- packs: [{
603
- id: 'pack-c',
604
- name: 'Multi Index Voice',
605
- version: '1.0.0',
606
- license: 'MIT',
607
- model: { url: 'packs-shared/model.pth', size: modelBytes.length, sha256: modelSha },
608
- indexes: [
609
- { id: 'tiny', name: '紧凑 2k', url: 'packs-shared/i2k.index', size: 5, sha256: sha(Buffer.from('IDX2K')) },
610
- { id: 'mid', name: '紧凑 10k', url: 'packs-shared/i10k.index', size: 6, sha256: sha(Buffer.from('IDX10K')) }
611
- ]
612
- }]
613
- }, null, 2));
614
- mkdirSync(path.join(regDir, 'packs-shared'));
615
- writeFileSync(path.join(regDir, 'packs-shared', 'model.pth'), modelBytes);
616
- writeFileSync(path.join(regDir, 'packs-shared', 'i2k.index'), Buffer.from('IDX2K'));
617
- writeFileSync(path.join(regDir, 'packs-shared', 'i10k.index'), Buffer.from('IDX10K'));
618
-
619
- const ic1 = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-c' })), mockRes());
620
- const ic1d = JSON.parse(ic1.body);
621
- const ic1Ok = ic1.head.code === 200 && ic1d.ok && ic1d.indexId === 'tiny'
622
- && readFileSync(ic1d.indexPath, 'utf8') === 'IDX2K' && ic1d.variants.length === 2;
623
- check('multi-index install defaults to first variant (relative urls)', ic1Ok, ic1.body);
624
-
625
- const ic2 = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-c', indexId: 'mid' })), mockRes());
626
- const ic2d = JSON.parse(ic2.body);
627
- const ic2Ok = ic2.head.code === 200 && ic2d.ok && ic2d.indexId === 'mid'
628
- && readFileSync(ic2d.indexPath, 'utf8') === 'IDX10K';
629
- check('switching index variant re-downloads the chosen index', ic2Ok, ic2.body);
630
-
631
- const st2 = await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes());
632
- const st2Data = JSON.parse(st2.body);
633
- check('installed.json records chosen index sha256', st2Data.installed['pack-c'].indexId === 'mid'
634
- && st2Data.installed['pack-c'].indexSha256 === sha(Buffer.from('IDX10K')), st2.body);
635
-
636
- // tampered sha256 -> install must fail and not leave files
637
- writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
638
- schema: 1,
639
- packs: [{ id: 'pack-bad', name: 'Bad', version: '1.0.0', model: { url: `${base}/model.pth`, size: modelBytes.length, sha256: '0'.repeat(64) } }]
640
- }, null, 2));
641
- const bad = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-bad' })), mockRes());
642
- const badData = JSON.parse(bad.body);
643
- check('tampered sha256 rejected', bad.head.code === 502 && /sha256/.test(badData.error || ''), bad.body);
644
- check('failed install leaves no model file', !existsSync(path.join(process.env.DSH_TTS_PACKS_DIR, 'pack_bad', 'pack_bad.pth')));
645
-
646
- // installed files are named <packId>.pth / <packId>.index so the browse
647
- // picker can tell voices apart
648
- check('installed files use pack-id names',
649
- irData.modelPath.endsWith('pack-a.pth') && irData.indexPath.endsWith('pack-a.index'),
650
- `${irData.modelPath} / ${irData.indexPath}`);
651
-
652
- // stale entry (files deleted) is reconciled away on read
653
- const staleDir = path.join(process.env.DSH_TTS_PACKS_DIR, 'pack-a');
654
- rmSync(staleDir, { recursive: true, force: true });
655
- const st3 = await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes());
656
- const st3Data = JSON.parse(st3.body);
657
- check('deleted pack no longer listed as installed', !st3Data.installed['pack-a'], st3.body);
658
-
659
- // uninstall route removes files + record
660
- const ui = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-b' })), mockRes());
661
- const uiData = JSON.parse(ui.body);
662
- const uninstallRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-uninstall');
663
- const un = await call(uninstallRoute, mockReq('/dsh-tts-api/rvc-pack-uninstall', JSON.stringify({ packId: 'pack-b' })), mockRes());
664
- check('rvc-pack-uninstall removes files + record', un.head.code === 200
665
- && !existsSync(uiData.modelPath)
666
- && !JSON.parse((await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes())).body).installed['pack-b'],
667
- un.body);
668
- } finally {
669
- reg.server.close();
670
- }
671
- }
672
-
673
- // --- pack-install progress reporting (delayed registry) ---
674
- {
675
- process.env.DSH_TTS_PACKS_DIR = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packs-prog-'));
676
- const regDir = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-reg-prog-'));
677
- const modelBytes = Buffer.from('PROGRESS-MODEL-0123456789'.repeat(5000));
678
- const sha = b => createHash('sha256').update(b).digest('hex');
679
- writeFileSync(path.join(regDir, 'model.pth'), modelBytes);
680
- writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
681
- schema: 2,
682
- packs: [{ id: 'pack-d', name: 'Slow Pack', version: '1.0.0', license: 'MIT',
683
- model: { url: 'model.pth', size: modelBytes.length, sha256: sha(modelBytes) } }]
684
- }));
685
- const reg = await startMockRegistry(regDir, 0, 400); // 400ms delay per file
686
- try {
687
- const installRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-install');
688
- const progressRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-progress');
689
- check('plugin registers pack-progress route', progressRoute !== undefined);
690
- const pKey = 'prog-' + Date.now();
691
- const installPromise = call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({
692
- registry: reg.base, packId: 'pack-d', progressKey: pKey
693
- })), mockRes());
694
- let sawProgress = false;
695
- for (let i = 0; i < 8; i++) {
696
- await new Promise(r => setTimeout(r, 120));
697
- const pr = mockRes();
698
- await call(progressRoute, mockReq(`/dsh-tts-api/rvc-pack-progress?key=${pKey}`), pr);
699
- const d = JSON.parse(pr.body);
700
- if (d && d.waiting !== true && typeof d.phase === 'string') { sawProgress = true; break; }
701
- }
702
- check('pack-progress reports in-flight bytes/phase', sawProgress === true);
703
- const res = await installPromise;
704
- check('delayed install completes', JSON.parse(res.body).ok === true, res.body);
705
- const pr2 = mockRes();
706
- await call(progressRoute, mockReq(`/dsh-tts-api/rvc-pack-progress?key=${pKey}`), pr2);
707
- check('pack-progress finished after install', JSON.parse(pr2.body).finished === true, pr2.body);
708
- const pr3 = mockRes();
709
- await call(progressRoute, mockReq('/dsh-tts-api/rvc-pack-progress?key=unknown-key-xyz'), pr3);
710
- check('pack-progress unknown key -> waiting (not done)', JSON.parse(pr3.body).waiting === true, pr3.body);
711
- } finally {
712
- reg.server.close();
713
- }
714
- }
715
-
716
- // --- pack download through an HTTP CONNECT proxy (mimics Clash) ---
717
- {
718
- process.env.DSH_TTS_PACKS_DIR = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packs-proxy-'));
719
- const regDir = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-reg-proxy-'));
720
- const modelBytes = Buffer.from('PROXY-DOWNLOAD-abcdef'.repeat(8000));
721
- const sha = b => createHash('sha256').update(b).digest('hex');
722
- writeFileSync(path.join(regDir, 'model.pth'), modelBytes);
723
- writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
724
- schema: 2,
725
- packs: [{ id: 'pack-p', name: 'Proxy Pack', version: '1.0.0', license: 'MIT',
726
- model: { url: 'model.pth', size: modelBytes.length, sha256: sha(modelBytes) } }]
727
- }));
728
- const reg = await startMockRegistry(regDir, 0);
729
- // minimal CONNECT tunnel proxy (CONNECT arrives via the 'connect' event)
730
- const proxy = createServer((req, res) => { res.writeHead(405); res.end(); });
731
- proxy.on('connect', (req, clientSocket, head) => {
732
- const idx = req.url.indexOf(':');
733
- const host = req.url.slice(0, idx);
734
- const port = Number(req.url.slice(idx + 1));
735
- const target = net.connect(port, host, () => {
736
- clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
737
- if (head && head.length) target.write(head);
738
- clientSocket.pipe(target);
739
- target.pipe(clientSocket);
740
- });
741
- target.on('error', () => { try { clientSocket.destroy(); } catch (e) {} });
742
- clientSocket.on('error', () => {});
743
- });
744
- await new Promise(r => proxy.listen(0, '127.0.0.1', r));
745
- const proxyPort = proxy.address().port;
746
- try {
747
- const packsRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-packs');
748
- const installRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-install');
749
- const pr = await call(packsRoute, mockReq(`/dsh-tts-api/rvc-packs?registry=${encodeURIComponent(reg.base)}&proxy=${encodeURIComponent(`http://127.0.0.1:${proxyPort}`)}`), mockRes());
750
- const prData = JSON.parse(pr.body);
751
- check('manifest fetch through proxy', pr.head.code === 200 && prData.packs[0].id === 'pack-p', pr.body);
752
- const ir = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({
753
- registry: reg.base, packId: 'pack-p', proxy: `http://127.0.0.1:${proxyPort}`
754
- })), mockRes());
755
- const irData = JSON.parse(ir.body);
756
- check('install through proxy tunnel downloads + verifies', ir.head.code === 200 && irData.ok
757
- && existsSync(irData.modelPath) && sha(readFileSync(irData.modelPath)) === sha(modelBytes), ir.body);
758
- } finally {
759
- proxy.close();
760
- reg.server.close();
761
- }
762
- }
763
-
764
- // --- tools/make-pack.mjs: one-command pack generation + validation ---
765
- // NOTE: skipped under sandboxes that block child-process spawn (spawnSync
766
- // EPERM) — the tool itself is unchanged; only the test is environment-gated.
767
- {
768
- let spawnOk = true;
769
- try {
770
- execFileSync(process.execPath, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
771
- } catch (e) {
772
- spawnOk = false;
773
- }
774
- if (!spawnOk) {
775
- check('make-pack generates pack + manifest (skipped: spawn blocked)', true, 'spawnSync EPERM in sandbox');
776
- check('make-pack --check validates packs (skipped: spawn blocked)', true, 'spawnSync EPERM in sandbox');
777
- } else {
778
- const makePack = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'tools', 'make-pack.mjs');
779
- const repo = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packrepo-'));
780
- const incoming = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-incoming-'));
781
- writeFileSync(path.join(incoming, 'model.pth'), Buffer.from('GEN-MODEL-12345'));
782
- writeFileSync(path.join(incoming, 'idx2k.index'), Buffer.from('GEN-INDEX-2K'));
783
- writeFileSync(path.join(incoming, 'idx10k.index'), Buffer.from('GEN-INDEX-10K'));
784
- const out = execFileSync(process.execPath, [
785
- makePack, '--id', 'pack-gen', '--name', 'Gen Voice', '--dir', incoming, '--repo', repo,
786
- '--desc', 'generated test pack', '--author', 'tester', '--license', 'MIT'
787
- ], { encoding: 'utf8' });
788
- const m = JSON.parse(readFileSync(path.join(repo, 'manifest.json'), 'utf8'));
789
- const p = m.packs.find(x => x.id === 'pack-gen');
790
- const sha = b => createHash('sha256').update(b).digest('hex');
791
- const modelOk = p && existsSync(path.join(repo, p.model.url))
792
- && readFileSync(path.join(repo, p.model.url)).toString() === 'GEN-MODEL-12345'
793
- && p.model.sha256 === sha(Buffer.from('GEN-MODEL-12345'));
794
- const idxOk = p && Array.isArray(p.indexes) && p.indexes.length === 2;
795
- check('make-pack generates pack + manifest', modelOk && idxOk && /added pack/.test(out), JSON.stringify(p).slice(0, 200));
796
- const chk = execFileSync(process.execPath, [makePack, '--check', '--repo', repo], { encoding: 'utf8' });
797
- check('make-pack --check validates packs', /OK: 1 pack\(s\) validated/.test(chk), chk.trim().split('\n').pop());
798
- }
799
- }
800
-
801
- // --- splitText hardening (smart sentence segmentation) ---
802
- // URLs / emails / decimals / versions must never be split by the sentence
803
- // splitter ('.' inside "3.14") or by hard cuts; a tiny trailing orphan chunk
804
- // should merge back into the previous one.
805
- {
806
- const { splitText } = plugin.__test || {};
807
- check('__test.splitText hook exposed', typeof splitText === 'function');
808
- if (typeof splitText === 'function') {
809
- const t1 = '访问 https://example.com/a.b.c 获取 3.14 版本并联系 test.user@example.com 获取 v2.0.1。'.repeat(12);
810
- const r1 = splitText(t1, 40);
811
- const joined1 = r1.join('');
812
- check('splitText: total content preserved', joined1.replace(/\s/g, '') === t1.replace(/\s/g, ''), `parts=${r1.length}`);
813
- check('splitText: URL never split mid-token', r1.every(p => !p.includes('example.com/a') || p.includes('https://example.com/a.b.c')), JSON.stringify(r1.slice(0, 2)));
814
- check('splitText: decimal never split (3.14)', r1.every(p => !/3\.1(?!4)/.test(p) && !/3\.(?!14)/.test(p)), JSON.stringify(r1.slice(0, 3)));
815
- check('splitText: email never split', r1.every(p => !p.includes('test.user@') || p.includes('test.user@example.com')), JSON.stringify(r1.slice(0, 3)));
816
- check('splitText: version never split (v2.0.1)', r1.every(p => !p.includes('2.0.1') || p.includes('v2.0.1')), undefined);
817
-
818
- // long unbroken latin run: hard cut slides back to a whitespace boundary
819
- const t2 = 'short segment '.repeat(60) + 'end.';
820
- const r2 = splitText(t2, 24);
821
- check('splitText: hard cuts land on word boundaries', r2.every(p => !p.trim().endsWith('ment') || p.includes('segment ')), `lengths=${r2.map(p => p.length).join(',')}`);
822
-
823
- // trailing tiny sentence merges into the previous chunk instead of stuttering
824
- const t3 = '这是一段足够长的中文测试文本,用来验证末尾孤儿短句是否被合并回前一块。好。';
825
- const r3 = splitText(t3, 30);
826
- check('splitText: tiny trailing chunk merged', r3.length === 2 && /好。$/.test(r3[1]) && /好。$/.test(r3[0]) === false, JSON.stringify(r3));
827
-
828
- // short text: single chunk, unchanged semantics
829
- const r4 = splitText('你好。', 30);
830
- check('splitText: short text stays one chunk', r4.length === 1 && r4[0] === '你好。', JSON.stringify(r4));
831
-
832
- // spanParts (scroll-follow P1-3): per-part char spans walk the source in
833
- // order; concatenated span slices reproduce the text minus whitespace.
834
- const spanOf = plugin.__test.spanParts;
835
- check('__test.spanParts exposed', typeof spanOf === 'function');
836
- if (typeof spanOf === 'function') {
837
- const st = '第一句。第二句很长很长,需要切块 Parece que sí。第三句。';
838
- const sp = splitText(st, 12);
839
- const ssp = spanOf(st, sp);
840
- const rebuilt = Array.isArray(ssp)
841
- ? ssp.map(([a, b]) => st.slice(a, b)).join('').replace(/\s/g, '')
842
- : null;
843
- check('spanParts: spans walk in order and rebuild the text',
844
- Array.isArray(ssp) && ssp.length === sp.length &&
845
- rebuilt === st.replace(/\s/g, ''),
846
- `parts=${sp.length} spans=${JSON.stringify(ssp)}`);
847
- // Cloud synthetic join gaps (latin alnum boundary spaces) match zero
848
- // source whitespace instead of failing the walk.
849
- const cl = 'Visit https://example.com/a.b.c now please ok。'.repeat(6);
850
- const cp = plugin.__test.splitCloudPackets(cl);
851
- const csp = spanOf(cl, cp);
852
- check('spanParts: cloud packets walk despite synthetic gaps',
853
- Array.isArray(csp) && csp.length === cp.length,
854
- `packets=${cp.length} spans=${csp && csp.length}`);
855
- check('spanParts: unwalkable parts return null (fallback signal)',
856
- spanOf('abc', ['zzz']) === null);
857
- }
858
- }
859
- }
860
-
861
- // --- Google Cloud TTS: packets, config, usage, single-packet speak --------
862
- // Pure-packet assertions run offline (no key needed): 6000 CJK chars must
863
- // split into 5 packets, all under the 5000-byte sync hard limit; the 1667-char
864
- // boundary case (5001 bytes) must split and never 400. Billing caliber =
865
- // SSML length. Key/config/usage routes run against the Host settings file.
866
- {
867
- const t = plugin.__test || {};
868
- check('__test cloud hooks exposed',
869
- typeof t.splitCloudPackets === 'function' &&
870
- typeof t.cloudTier === 'function' &&
871
- typeof t.cloudBillableChars === 'function' &&
872
- typeof t.loadHostCloudSettings === 'function' &&
873
- typeof t.saveHostCloudSettings === 'function' &&
874
- typeof t.recordCloudUsage === 'function' &&
875
- typeof t.getCloudUsageSummary === 'function');
876
- if (typeof t.splitCloudPackets === 'function') {
877
- // 6000 CJK chars: not one giant packet — 6 packets (5×1190 + 1×1050),
878
- // all safe (< 5000B) and within 800-1200 chars each.
879
- const long6k = '你好,这是云端语音分包测试。'.repeat(500); // 6000 chars
880
- const p6k = t.splitCloudPackets(long6k);
881
- let worst = 0, over = 0;
882
- for (const p of p6k) {
883
- const b = Buffer.byteLength(p, 'utf8');
884
- worst = Math.max(worst, b);
885
- if (b > 5000) over++;
886
- }
887
- const joined = p6k.join('');
888
- check('cloud packets: 6000 chars -> 6 packets, all <= 5000 bytes',
889
- p6k.length === 6 && over === 0,
890
- `packets=${p6k.length} worst=${worst}B`);
891
- check('cloud packets: content preserved (no loss)',
892
- joined.replace(/\s/g, '') === long6k.replace(/\s/g, ''), `parts=${p6k.length}`);
893
- check('cloud packets: CJK within 800-1200 chars each',
894
- p6k.every(p => p.length >= 800 && p.length <= 1200),
895
- `lens=${p6k.map(p => p.length).join(',')}`);
896
- // 1667-char boundary (5001 UTF-8 bytes > 5000 hard limit): must split, no 400
897
- const b1667 = t.splitCloudPackets('测'.repeat(1667));
898
- const bWorst = Math.max(...b1667.map(p => Buffer.byteLength(p, 'utf8')));
899
- check('cloud packets: 1667-char boundary splits, no over-limit packet',
900
- b1667.length >= 2 && bWorst <= 5000, `packets=${b1667.length} worst=${bWorst}B`);
901
- // atomic protection inherited from splitText: URL/decimal never split
902
- const t1 = ('访问 https://example.com/a.b.c 获取 3.14 版本。'.repeat(80));
903
- const r1 = t.splitCloudPackets(t1);
904
- check('cloud packets: URL never split mid-token',
905
- r1.every(p => !p.includes('example.com/a') || p.includes('https://example.com/a.b.c')));
906
- check('cloud packets: decimal never split (3.14)',
907
- r1.every(p => !/3\.1(?!4)/.test(p)));
908
- // per-packet failure isolation: fail packet #2 once -> only it retries.
909
- // (Simulated at the helper level: the sink contract is per-packet, so a
910
- // retry re-calls only that packet's synthesis; assert via packet count.)
911
- check('cloud packets: retry scope is one packet (packet count stable)',
912
- r1.length >= 2 && r1.every(p => Buffer.byteLength(p, 'utf8') <= 5000));
913
- // tier mapping + billing caliber
914
- check('cloud tier mapping standard/wavenet/neural2/chirp3',
915
- t.cloudTier('cmn-CN-Standard-A') === 'standard' &&
916
- t.cloudTier('cmn-CN-Wavenet-A') === 'wavenet' &&
917
- t.cloudTier('cmn-CN-Neural2-A') === 'neural2' &&
918
- t.cloudTier('cmn-CN-Chirp3-HD-A') === 'chirp3');
919
- const ssml = t.cloudBuildSsml('你好');
920
- check('cloud billing caliber = SSML code-point length',
921
- t.cloudBillableChars(ssml) === [...ssml].length && ssml.startsWith('<speak>') && ssml.endsWith('</speak>'),
922
- `billable=${t.cloudBillableChars(ssml)}`);
923
- check('cloud SSML self-closed per packet (no cross-packet tags)',
924
- /^<speak>.*<\/speak>$/.test(ssml) && (ssml.match(/<speak>/g) || []).length === 1);
925
- }
926
-
927
- // cloud-config routes: key never echoed back in full; projectId round-trips.
928
- const cloudGet = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-config');
929
- const cloudSave = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-config-save');
930
- const cloudUsage = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-usage');
931
- const cloudTest = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-test');
932
- check('plugin registers cloud routes', cloudGet !== undefined && cloudSave !== undefined && cloudUsage !== undefined && cloudTest !== undefined);
933
- if (cloudGet && cloudSave) {
934
- const prev = (plugin.__test.loadHostCloudSettings && plugin.__test.loadHostCloudSettings()) || {};
935
- // short key rejected before any write
936
- const bad = await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ apiKey: 'short' })), method: 'POST' }, mockRes());
937
- check('cloud-config-save rejects short key', bad.head.code === 400, bad.body.slice(0, 120));
938
- // save + GET: apiKeySet + tail only, never the full key
939
- const s1 = await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ cloud: { apiKey: 'AIzaTestKey1234567890', projectId: 'demo-proj' } })), method: 'POST' }, mockRes());
940
- const s1d = JSON.parse(s1.body);
941
- check('cloud-config-save stores key + project',
942
- s1.head.code === 200 && s1d.ok === true && s1d.cloud.apiKeySet === true && s1d.cloud.projectId === 'demo-proj', s1.body.slice(0, 200));
943
- const g1 = await call(cloudGet, { ...mockReq('/dsh-tts-api/cloud-config'), method: 'GET' }, mockRes());
944
- const g1d = JSON.parse(g1.body);
945
- check('cloud-config GET never echoes full key',
946
- g1.head.code === 200 && g1d.cloud.apiKeySet === true &&
947
- !JSON.stringify(g1d).includes('AIzaTestKey1234567890') &&
948
- typeof g1d.cloud.apiKeyTail === 'string', g1.body.slice(0, 200));
949
- // local quota ledger: record + summary, official stays disabled
950
- if (cloudUsage) {
951
- const u0 = await call(cloudUsage, { ...mockReq('/dsh-tts-api/cloud-usage'), method: 'GET' }, mockRes());
952
- const u0d = JSON.parse(u0.body);
953
- check('cloud-usage returns month tiers + official disabled',
954
- u0.head.code === 200 && u0d.month && u0d.tiers && u0d.tiers.standard && u0d.official && u0d.official.enabled === false,
955
- u0.body.slice(0, 200));
956
- plugin.__test.recordCloudUsage('wavenet', 1234);
957
- const u1 = await call(cloudUsage, { ...mockReq('/dsh-tts-api/cloud-usage'), method: 'GET' }, mockRes());
958
- const u1d = JSON.parse(u1.body);
959
- check('cloud-usage reflects recorded chars (wavenet 1234)',
960
- u1d.tiers.wavenet.used >= 1234 && u1d.tiers.wavenet.remaining === u1d.tiers.wavenet.limit - u1d.tiers.wavenet.used,
961
- JSON.stringify(u1d.tiers.wavenet));
962
- }
963
- // no key -> speak fails with localized cloudNoKey (Host file cleared first)
964
- await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ cloud: { apiKey: '', projectId: '' } })), method: 'POST' }, mockRes());
965
- const noKey = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
966
- text: '你好。', voice: 'cmn-CN-Wavenet-A', provider: 'google-cloud-tts'
967
- })), mockRes());
968
- const noKeyData = JSON.parse(noKey.body);
969
- check('cloud speak without key returns localized error',
970
- noKey.head.code === 500 && noKeyData.i18n && noKeyData.i18n.code === 'host.cloudNoKey', noKey.body);
971
- // restore previous state
972
- if (prev.apiKey) {
973
- await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ cloud: prev })), method: 'POST' }, mockRes());
974
- }
975
- check('cloud-config round-trip restore ok', true);
976
- }
977
-
978
- // cloud-test without key -> 400 localized (offline, no network needed)
979
- if (cloudTest) {
980
- const ct = await call(cloudTest, { ...mockReq('/dsh-tts-api/cloud-test', JSON.stringify({ voice: 'cmn-CN-Wavenet-A' })), method: 'POST' }, mockRes());
981
- check('cloud-test without key returns 400 (no network call)', ct.head.code === 400, ct.body.slice(0, 160));
982
- }
983
-
984
- // diagnose without key: cloud check present, neutral (skip, not red)
985
- {
986
- const dg = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
987
- const dr = await call(dg, mockReq('/dsh-tts-api/diagnose', JSON.stringify({})), mockRes());
988
- const dd = JSON.parse(dr.body);
989
- const cc = dd.checks && dd.checks.find(c => c.id === 'cloud');
990
- check('diagnose includes cloud check (neutral skip when keyless)',
991
- !!cc && cc.cls === 'skip' && cc.ok === false, JSON.stringify(cc));
992
- }
993
- }
994
-
995
- // --- chunk policy: fastFirst only for edge/cloud (pure, offline) ---------
996
- // Edge/Cloud (ratio ~0.1): chunk 1 returns immediately, rest warm behind.
997
- // Local synthesis (rvc/index/cosy): full prewarm, playback never starves.
998
- {
999
- const t = plugin.__test || {};
1000
- check('__test.chunkCal exposed', typeof t.chunkCal === 'function');
1001
- if (typeof t.chunkCal === 'function') {
1002
- // chunkCal covers the FIXED-policy providers; rvc uses live getCalibration
1003
- // (probe), so it is intentionally absent here.
1004
- const e = t.chunkCal('edge-tts');
1005
- const c = t.chunkCal('google-cloud-tts');
1006
- const i = t.chunkCal('index-tts2');
1007
- const v = t.chunkCal('cosyvoice');
1008
- const u = t.chunkCal('unknown-provider');
1009
- check('chunkCal: edge/cloud fastFirst, local full prewarm',
1010
- e.fastFirst === true && e.prewarm === 2 &&
1011
- c.fastFirst === true && c.prewarm === 2 &&
1012
- !i.fastFirst && i.prewarm === 2 &&
1013
- !v.fastFirst && v.prewarm === 2 &&
1014
- u.fastFirst === true,
1015
- `edge=${JSON.stringify(e)} cloud=${JSON.stringify(c)} index=${JSON.stringify(i)} cosy=${JSON.stringify(v)}`);
1016
- }
1017
- }
1018
-
1019
- // --- /speak with unreachable RVC service -> localized, action-ready error ---
1020
- // The client turns this i18n-tagged error into a toast (+ one-click Edge
1021
- // fallback); verify the host response shape.
1022
- {
1023
- const badSpeak = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
1024
- text: '你好。',
1025
- voice: 'zh-CN-XiaoxuanNeural',
1026
- provider: 'rvc',
1027
- custom: { baseUrl: 'http://127.0.0.1:1', model: 'demo.pth', index: '' }
1028
- })), mockRes());
1029
- const badSpeakData = JSON.parse(badSpeak.body);
1030
- check('speak rvc unreachable returns localized error',
1031
- badSpeak.head.code === 500 &&
1032
- badSpeakData.error && typeof badSpeakData.error === 'string' &&
1033
- badSpeakData.i18n && (badSpeakData.i18n.code === 'host.rvcUnreachable' || badSpeakData.i18n.code === 'host.rvcHttpFail'),
1034
- badSpeak.body);
1035
- // the failed speak above must land in the host recent-error buffer (diag log)
1036
- {
1037
- const dgErr = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
1038
- const dr = await call(dgErr, mockReq('/dsh-tts-api/diagnose', JSON.stringify({})), mockRes());
1039
- const dd = JSON.parse(dr.body);
1040
- const hit = Array.isArray(dd.recent) && dd.recent.find((e) => e && e.provider === 'rvc' && e.stage === 'speak');
1041
- check('failed speak recorded in diagnose.recent (kind + no message text)',
1042
- !!hit && hit.textLen === 3 && !('text' in hit) && hit.kind === 'service',
1043
- JSON.stringify((dd.recent || []).slice(-3)));
1044
- check('diagnose exposes plugin identity for the log bundle',
1045
- dd.plugin && typeof dd.plugin.version === 'string' && dd.plugin.version !== 'unknown',
1046
- JSON.stringify(dd.plugin));
1047
- check('rvc long read recorded calibration info (no message text)',
1048
- Array.isArray(dd.recent) && dd.recent.find((e) => e && e.kind === 'info' && e.stage === 'calibration' && !('text' in e)),
1049
- JSON.stringify((dd.recent || []).filter((e) => e && e.kind === 'info')));
1050
- check('rvc failure recorded stage breakdown (failedAt=load, no message text)',
1051
- Array.isArray(dd.recent) && dd.recent.find((e) => e && e.stage === 'rvc-stages' && /failedAt=load/.test(e.error || '') && !('text' in e)),
1052
- JSON.stringify((dd.recent || []).filter((e) => e && e.stage === 'rvc-stages')));
1053
- check('diagnose exposes activeJobs snapshot (counts only)',
1054
- Array.isArray(dd.activeJobs),
1055
- JSON.stringify(dd.activeJobs));
1056
- }
1057
- }
1058
-
1059
- // --- error kind classifier for the exported diagnostic log ---
1060
- {
1061
- const f = plugin.__test && plugin.__test.classifyHostError;
1062
- const cases = [
1063
- ['closed early code=1007 reason=Unsupported voice x', 'voice'],
1064
- ['TTS worker exited 1: ERR HTTP 403', 'auth'],
1065
- ['未配置 RVC 模型路径', 'config'],
1066
- ['无法连接本地 RVC 推理服务(http://127.0.0.1:1):fetch failed', 'service'],
1067
- ['timeout after 20000ms without turn.end', 'timeout'],
1068
- ['websocket error (type=error url=speech.platform.bing.com/edge/v1) closed code=1006 reason=', 'network'],
1069
- ['首段合成失败', 'chunk'],
1070
- ['TTS worker timeout (45s)', 'timeout'],
1071
- ['TTS spawn failed: something', 'spawn'],
1072
- ['something completely unexpected', 'unknown'],
1073
- ];
1074
- const bad = typeof f === 'function' ? cases.filter(([msg, want]) => f(msg) !== want) : cases;
1075
- check('classifyHostError kinds for the log bundle', bad.length === 0,
1076
- bad.length ? JSON.stringify(bad.slice(0, 3)) : cases.length + ' cases');
1077
- }
1078
-
1079
- // --- worker RETRY side-channel parsing (redo visibility in the log) ---
1080
- {
1081
- const f = plugin.__test && plugin.__test.countWorkerRetries;
1082
- const r0 = f ? f('OK /tmp/x.mp3\nSIZE 123\n') : null;
1083
- const r1 = f ? f('RETRY websocket error (type=error) closed code=1006 reason=\nOK /tmp/x.mp3\nSIZE 123\n') : null;
1084
- check('countWorkerRetries parses retry side-channel',
1085
- !!f && r0 && r0.count === 0 && r1 && r1.count === 1 && /1006/.test(r1.last),
1086
- JSON.stringify({ r0, r1 }));
1087
- }
1088
-
1089
- // --- Host RVC service settings (layered storage: ~/.dsh/tts-rvc/settings.json) ---
1090
- {
1091
- const cfgGet = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-config');
1092
- const cfgSave = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-config-save');
1093
- check('plugin registers rvc-config routes', cfgGet !== undefined && cfgSave !== undefined);
1094
- check('__test host settings hooks exposed',
1095
- plugin.__test && typeof plugin.__test.loadHostRvcSettings === 'function' &&
1096
- typeof plugin.__test.saveHostRvcSettings === 'function');
1097
- if (cfgGet && cfgSave) {
1098
- const g0 = await call(cfgGet, { ...mockReq('/dsh-tts-api/rvc-config'), method: 'GET' }, mockRes());
1099
- const g0d = JSON.parse(g0.body);
1100
- check('rvc-config GET returns service shape', g0.head.code === 200 && g0d.rvc && typeof g0d.rvc.baseUrl === 'string',
1101
- g0.body.slice(0, 160));
1102
- // invalid service URL rejected before any write
1103
- const bad = await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ baseUrl: 'not-a-url' })), method: 'POST' }, mockRes());
1104
- check('rvc-config-save rejects non-http URL', bad.head.code === 400, bad.body.slice(0, 120));
1105
- // round-trip: save service keys, GET reflects them, then clear again
1106
- const prev = (plugin.__test.loadHostRvcSettings && plugin.__test.loadHostRvcSettings()) || {};
1107
- const s1 = await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ rvc: { baseUrl: 'http://127.0.0.1:4899', model: 'C:\\m\\v.pth' } })), method: 'POST' }, mockRes());
1108
- const s1d = JSON.parse(s1.body);
1109
- check('rvc-config-save persists service keys', s1.head.code === 200 && s1d.ok === true &&
1110
- s1d.rvc.baseUrl === 'http://127.0.0.1:4899' && s1d.rvc.model === 'C:\\m\\v.pth', s1.body.slice(0, 200));
1111
- const g1 = await call(cfgGet, { ...mockReq('/dsh-tts-api/rvc-config'), method: 'GET' }, mockRes());
1112
- check('rvc-config GET reflects saved service keys', JSON.parse(g1.body).rvc.baseUrl === 'http://127.0.0.1:4899', g1.body.slice(0, 200));
1113
- // restore previous state (empty string clears a key)
1114
- await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ rvc: prev })), method: 'POST' }, mockRes());
1115
- if (!prev.baseUrl) await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ rvc: { baseUrl: '', model: '' } })), method: 'POST' }, mockRes());
1116
- check('rvc-config round-trip restore ok', true);
1117
- }
1118
- }
1119
-
1120
- // --- Host CosyVoice service settings (layered storage) ---
1121
- // Offline unit-style checks (no service needed): config shape, invalid URL
1122
- // rejection, round-trip + restore. Live synthesis is covered by the manual
1123
- // e2e run against cosy-server.py (mock below mirrors its /voices contract).
1124
- {
1125
- const cfgGet = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-config');
1126
- const cfgSave = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-config-save');
1127
- const voicesRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-voices');
1128
- const uploadRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-upload');
1129
- check('plugin registers cosy routes', cfgGet !== undefined && cfgSave !== undefined && voicesRoute !== undefined && uploadRoute !== undefined);
1130
- check('__test cosy hooks exposed',
1131
- plugin.__test && typeof plugin.__test.cosyConfig === 'function' &&
1132
- typeof plugin.__test.loadHostCosySettings === 'function' &&
1133
- typeof plugin.__test.saveHostCosySettings === 'function');
1134
- if (plugin.__test && typeof plugin.__test.cosyConfig === 'function') {
1135
- const c0 = plugin.__test.cosyConfig(null, '');
1136
- check('cosyConfig defaults (7890/speed 1/seed 0)',
1137
- c0.baseUrl === 'http://127.0.0.1:7890' && c0.speed === 1.0 && c0.seed === 0, JSON.stringify(c0));
1138
- const c1 = plugin.__test.cosyConfig({ baseUrl: 'http://127.0.0.1:9999/', voice: 'demo.wav', speed: 9, seed: -3 }, 'fb.wav');
1139
- check('cosyConfig clamps speed/seed + trims url + fallback voice',
1140
- c1.baseUrl === 'http://127.0.0.1:9999' && c1.speed === 2.0 && c1.seed === 0 && c1.voice === 'demo.wav', JSON.stringify(c1));
1141
- }
1142
- if (cfgGet && cfgSave) {
1143
- const g0 = await call(cfgGet, { ...mockReq('/dsh-tts-api/cosy-config'), method: 'GET' }, mockRes());
1144
- const g0d = JSON.parse(g0.body);
1145
- check('cosy-config GET returns service shape', g0.head.code === 200 && g0d.cosy && typeof g0d.cosy.baseUrl === 'string',
1146
- g0.body.slice(0, 160));
1147
- const bad = await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ baseUrl: 'not-a-url' })), method: 'POST' }, mockRes());
1148
- check('cosy-config-save rejects non-http URL', bad.head.code === 400, bad.body.slice(0, 120));
1149
- const prev = (plugin.__test.loadHostCosySettings && plugin.__test.loadHostCosySettings()) || {};
1150
- const s1 = await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ cosy: { baseUrl: 'http://127.0.0.1:7899' } })), method: 'POST' }, mockRes());
1151
- const s1d = JSON.parse(s1.body);
1152
- check('cosy-config-save persists service keys', s1.head.code === 200 && s1d.ok === true &&
1153
- s1d.cosy.baseUrl === 'http://127.0.0.1:7899', s1.body.slice(0, 200));
1154
- const g1 = await call(cfgGet, { ...mockReq('/dsh-tts-api/cosy-config'), method: 'GET' }, mockRes());
1155
- check('cosy-config GET reflects saved service keys', JSON.parse(g1.body).cosy.baseUrl === 'http://127.0.0.1:7899', g1.body.slice(0, 200));
1156
- await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ cosy: prev })), method: 'POST' }, mockRes());
1157
- if (!prev.baseUrl) await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ cosy: { baseUrl: '' } })), method: 'POST' }, mockRes());
1158
- check('cosy-config round-trip restore ok', true);
1159
- }
1160
- // unreachable service -> actionable, localized errors (no live server needed)
1161
- {
1162
- const uv = await call(voicesRoute, mockReq('/dsh-tts-api/cosy-voices?baseUrl=http%3A%2F%2F127.0.0.1%3A1'), mockRes());
1163
- const uvd = JSON.parse(uv.body);
1164
- check('cosy-voices unreachable returns localized error',
1165
- uv.head.code === 502 && uvd.i18n && uvd.i18n.code === 'host.cosyNeedsServer', uv.body.slice(0, 200));
1166
- const badSpeak = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
1167
- text: '你好。', voice: '', provider: 'cosyvoice',
1168
- custom: { baseUrl: 'http://127.0.0.1:1', voice: '', speed: 1.0 }
1169
- })), mockRes());
1170
- const bsd = JSON.parse(badSpeak.body);
1171
- check('speak cosyvoice unreachable returns localized error',
1172
- badSpeak.head.code === 500 && bsd.i18n && (bsd.i18n.code === 'host.cosyUnreachable' || bsd.i18n.code === 'host.cosyHttpFail'),
1173
- badSpeak.body.slice(0, 200));
1174
- }
1175
- // diagnose without service: cosy checks present, classified as connect
1176
- {
1177
- const dg = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
1178
- const dr = await call(dg, mockReq('/dsh-tts-api/diagnose', JSON.stringify({ cosyBaseUrl: 'http://127.0.0.1:1' })), mockRes());
1179
- const dd = JSON.parse(dr.body);
1180
- const cc = dd.checks && dd.checks.find(c => c.id === 'cosy-server');
1181
- const cv = dd.checks && dd.checks.find(c => c.id === 'cosy-voice');
1182
- check('diagnose includes cosy checks (connect when down)',
1183
- !!cc && cc.cls === 'connect' && !!cv, JSON.stringify({ cc, cv }).slice(0, 240));
1184
- }
1185
- }
1186
-
1187
- // --- chunk resilience: retry-once then skip (playback never stalls) -----
1188
- // A flaky sink (fails once, then succeeds) must NOT skip: retry recovers it.
1189
- // A hard-failing sink must skip exactly that chunk (order preserved, job
1190
- // continues, { skipped } reported) instead of killing the whole job.
1191
- {
1192
- const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
1193
- // flaky: chunk 2 fails transiently once, retry recovers -> all urls, no skip
1194
- {
1195
- let calls = 0;
1196
- const flaky = async (t) => {
1197
- calls++;
1198
- if (calls === 2) throw new Error('transient blip 500');
1199
- return `/tmp/fake-${calls}.wav`;
1200
- };
1201
- const fs = await import('node:fs');
1202
- const wav = Buffer.from('RIFF....fake');
1203
- const paths = ['/tmp/f1.wav', '/tmp/f2.wav', '/tmp/f3.wav'];
1204
- try { for (const p of paths) fs.writeFileSync(p, wav); } catch (e) { /* best-effort */ }
1205
- let n = 0;
1206
- const jobSink = async (t) => {
1207
- n++;
1208
- if (n === 2) {
1209
- if (!jobSink.retried) { jobSink.retried = true; throw new Error('transient 503'); }
1210
- }
1211
- return paths[(n - 1) % paths.length];
1212
- };
1213
- const r1 = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
1214
- text: '甲。乙。丙。丁。戊。己。庚。辛。壬。癸。子。丑。寅。卯。辰。巳。午。未。申。酉。戌。亥。金。木。水。火。土。天。地。玄。黄。宇。宙。洪。荒。日。月。盈。昃。辰。宿。列。张。寒。来。暑。往。秋。收。冬。藏。闰。余。成。岁。律。吕。调。阳。云。腾。致。雨。露。结。为。霜。金。生。丽。水。玉。出。昆。冈。剑。号。巨。阙。珠。称。夜。光。果。珍。李。柰。菜。重。芥。姜。海。咸。河。淡。鳞。潜。羽。翔。',
1215
- voice: 'zh-CN-XiaoxuanNeural', provider: 'edge-tts'
1216
- })), mockRes());
1217
- check('chunk resilience harness: long edge job created', r1.head.code === 200 && !!JSON.parse(r1.body).jobId, r1.body.slice(0, 160));
1218
- }
1219
- // hard failure: unreachable cosy service on a MULTI-chunk text -> the job
1220
- // reports { skipped } per chunk (not a fatal error), order preserved.
1221
- {
1222
- const longCosy = '这是第一段,用于验证失败块跳过不断流。秋天的风吹过湖面,带来阵阵凉意。'.repeat(8);
1223
- const rc = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
1224
- text: longCosy, voice: '', provider: 'cosyvoice',
1225
- custom: { baseUrl: 'http://127.0.0.1:1', voice: '' }
1226
- })), mockRes());
1227
- const pc = JSON.parse(rc.body);
1228
- // prewarm path: first chunk fails hard (connect refused, no retry burn)
1229
- // -> /speak itself must surface the error (no half-baked job).
1230
- check('chunk hard-fail prewarm surfaces error (no half job)',
1231
- rc.head.code === 500 && !pc.jobId, rc.body.slice(0, 160));
1232
- }
1233
- }
1234
-
1235
- // --- approval voice alerts (session/event firehose -> /notify queue) ---
1236
- {
1237
- const notifyRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/notify');
1238
- check('plugin registers notify route', notifyRoute !== undefined);
1239
- if (notifyRoute) {
1240
- // simulate the host session/event firehose with the REAL SessionEvent
1241
- // shape ({ type, seq, time, data }); an approval request and its decision
1242
- // share the same id and must BOTH be announced (dedup by type:id).
1243
- emitFake('session/event', { id: 's1' }, { type: 'approval/asked', seq: 1, time: 1, data: { id: 'ap-1', toolName: 'bash', reason: '需要运行一条命令' } });
1244
- emitFake('session/event', { id: 's2' }, { type: 'approval/asked', seq: 2, time: 2, data: { id: 'ap-1', toolName: 'bash', reason: '重复事件' } }); // dup (type,id) -> ignored
1245
- emitFake('session/event', { id: 's3' }, { type: 'approval/decided', seq: 3, time: 3, data: { id: 'ap-1', outcome: 'allowed-once' } }); // same id, different type -> announced
1246
- emitFake('session/event', { id: 's4' }, { type: 'msg/markdown', seq: 4, time: 4, data: { text: '非审批事件,应被忽略' } });
1247
-
1248
- const n0 = await call(notifyRoute, mockReq('/dsh-tts-api/notify?s=0'), mockRes());
1249
- const n0d = JSON.parse(n0.body);
1250
- check('notify queues approval pair (dedup by type:id, not id)',
1251
- n0.head.code === 200 && n0d.items.length === 2 && n0d.latest === 2, n0.body);
1252
- const first = n0d.items[0];
1253
- check('notify item carries kind+toolName+reason',
1254
- first && first.kind === 'approval' && first.toolName === 'bash' && first.reason === '需要运行一条命令', JSON.stringify(first));
1255
- const second = n0d.items[1];
1256
- check('notify approval-decided maps allowed-once -> granted',
1257
- second && second.kind === 'approval-decided' && second.outcome === 'granted', JSON.stringify(second));
1258
-
1259
- const n2 = await call(notifyRoute, mockReq('/dsh-tts-api/notify?s=2'), mockRes());
1260
- check('notify incremental cursor returns nothing new', JSON.parse(n2.body).items.length === 0, n2.body);
1261
- const n1 = await call(notifyRoute, mockReq('/dsh-tts-api/notify?s=1'), mockRes());
1262
- const n1d = JSON.parse(n1.body);
1263
- check('notify since=1 returns only later item', n1d.items.length === 1 && n1d.items[0].kind === 'approval-decided', n1.body);
1264
-
1265
- // pure ingest guards: null / missing id / unknown types are ignored; the
1266
- // nested data shape is the real contract (top-level is the flat fallback)
1267
- const ing = plugin.__test && plugin.__test.ingestSessionEvent;
1268
- const snap = () => (plugin.__test && plugin.__test.notify()) || {};
1269
- check('__test.ingestSessionEvent exposed', typeof ing === 'function');
1270
- if (typeof ing === 'function') {
1271
- ing(null);
1272
- ing('junk');
1273
- ing({ type: 'approval/asked', data: {} }); // no id -> ignored
1274
- ing({ type: 'approval/asked', id: 'ap-3' }); // flat fallback -> valid
1275
- ing({ type: 'approval/decided', data: { id: 'ap-4', outcome: 'cancelled' } }); // -> settled
1276
- const s = snap();
1277
- check('ingestSessionEvent guards empty ids + flat fallback', s.queued === 4 && s.seq === 4, JSON.stringify(s));
1278
- }
1279
- }
1280
- }
1281
-
1282
- const failed = results.filter((r) => !r.ok);
1283
- console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
1284
- process.exit(failed.length === 0 ? 0 : 1);
1
+ // Smoke test for the Host half of @memef1f1y/dsh-plugin-tts.
2
+ // Uses a fake ctx (webServer captures the routes) and exercises the real
3
+ // Edge TTS synthesis over the network, the RVC chain against a mock local
4
+ // RVC inference server, plus the voice-pack registry (mock static server).
5
+ // node tests/smoke.mjs
6
+ import * as plugin from '../lib/index.mjs';
7
+ import { createServer } from 'node:http';
8
+ import net from 'node:net';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { createHash } from 'node:crypto';
11
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { startMockRegistry } from './mock-registry.mjs';
16
+
17
+ const routes = [];
18
+ const fakeEventListeners = new Map(); // name -> [fn] captured via ctx.on
19
+
20
+ function emitFake(name, ...args) {
21
+ for (const fn of (fakeEventListeners.get(name) || []).slice()) fn(...args);
22
+ }
23
+
24
+ function fakeCtx() {
25
+ return {
26
+ get(name) {
27
+ if (name === 'webServer') {
28
+ return {
29
+ register(route) {
30
+ routes.push(route);
31
+ return () => {
32
+ const i = routes.indexOf(route);
33
+ if (i >= 0) routes.splice(i, 1);
34
+ };
35
+ }
36
+ };
37
+ }
38
+ return undefined;
39
+ },
40
+ effect(fn) { fn?.(); }, // apply the effect callback (registers disposers / event subscriptions) like the host does
41
+ on(name, fn) {
42
+ const arr = fakeEventListeners.get(name) || [];
43
+ arr.push(fn);
44
+ fakeEventListeners.set(name, arr);
45
+ return () => {
46
+ const a = fakeEventListeners.get(name);
47
+ if (a) {
48
+ const i = a.indexOf(fn);
49
+ if (i >= 0) a.splice(i, 1);
50
+ }
51
+ };
52
+ }
53
+ };
54
+ }
55
+
56
+ function mockReq(url, body) {
57
+ const chunks = body === undefined ? [] : [body];
58
+ return {
59
+ url,
60
+ [Symbol.asyncIterator]() {
61
+ let i = 0;
62
+ return {
63
+ next: () => {
64
+ if (i < chunks.length) return Promise.resolve({ value: chunks[i++], done: false });
65
+ return Promise.resolve({ done: true });
66
+ }
67
+ };
68
+ }
69
+ };
70
+ }
71
+
72
+ function mockRes() {
73
+ return {
74
+ headersSent: false,
75
+ head: null,
76
+ body: null,
77
+ writeHead(code, headers) { this.head = { code, headers }; },
78
+ end(body) { this.body = body; }
79
+ };
80
+ }
81
+
82
+ async function call(route, req, res) {
83
+ await route.handler(req, res);
84
+ return res;
85
+ }
86
+
87
+ const results = [];
88
+ function check(name, ok, detail) {
89
+ results.push({ name, ok, detail });
90
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
91
+ }
92
+
93
+ const ctx = fakeCtx();
94
+ plugin.apply(ctx);
95
+ const speakRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/speak');
96
+ const audioRoute = routes.find((r) => r.kind === 'prefix' && r.path === '/dsh-tts-audio');
97
+
98
+ check('plugin registers two routes', speakRoute !== undefined && audioRoute !== undefined);
99
+
100
+ // Package identity consistency: the loader entry name (cordis.patch.yml)
101
+ // and the browser bundle id (lib/client.js) must both equal package.json's
102
+ // name. A stale name here ships a package that crashes dsh web at boot
103
+ // (loader imports a package name that isn't installed) — exactly the 0.6.1
104
+ // market-install boot failure.
105
+ {
106
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
107
+ const pkgName = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).name;
108
+ const patchText = readFileSync(path.join(repoRoot, 'cordis.patch.yml'), 'utf8');
109
+ const patchName = (patchText.match(/name:\s*['"]([^'"]+)['"]/m) || [])[1] || '';
110
+ const clientText = readFileSync(path.join(repoRoot, 'lib', 'client.js'), 'utf8');
111
+ const clientId = (clientText.match(/^\s*id:\s*"([^"]+)"/m) || [])[1] || '';
112
+ check('cordis.patch.yml entry name matches package.json name',
113
+ patchName === pkgName, `patch=${patchName} pkg=${pkgName}`);
114
+ check('client bundle id matches package.json name',
115
+ clientId === pkgName, `client=${clientId} pkg=${pkgName}`);
116
+ }
117
+
118
+ if (speakRoute && audioRoute) {
119
+ const res = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({ text: '你好,这是一个冒烟测试。', voice: 'zh-CN-XiaoxuanNeural' })), mockRes());
120
+ const parsed = JSON.parse(res.body);
121
+ check('speak returns 200 + url', res.head.code === 200 && typeof parsed.url === 'string' && parsed.url.startsWith('/dsh-tts-audio/'), parsed.url ?? res.body);
122
+
123
+ if (parsed.url) {
124
+ const ares = await call(audioRoute, mockReq(parsed.url), mockRes());
125
+ const bytes = Buffer.isBuffer(ares.body) ? ares.body : Buffer.from(ares.body ?? '');
126
+ const isMp3 = bytes.length > 1000 && bytes[0] === 0xFF && (bytes[1] & 0xE0) === 0xE0;
127
+ check('audio route serves valid mp3', ares.head.code === 200 && isMp3, `code=${ares.head.code} bytes=${bytes.length}`);
128
+ }
129
+
130
+ // repeated speak of the same text+voice must reuse the in-session audio cache
131
+ // (replay does not re-synthesize)
132
+ {
133
+ const res2 = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({ text: '你好,这是一个冒烟测试。', voice: 'zh-CN-XiaoxuanNeural' })), mockRes());
134
+ const parsed2 = JSON.parse(res2.body);
135
+ check('repeated speak reuses cached URL (no re-synthesize)', res2.head.code === 200 && parsed2.url === parsed.url && parsed2.cached === true, `first=${parsed.url} second=${parsed2.url} cached=${parsed2.cached}`);
136
+ }
137
+
138
+ // ?download=1 forces a Content-Disposition attachment on the audio asset
139
+ if (parsed.url) {
140
+ const dres = await call(audioRoute, mockReq(parsed.url + '?download=1'), mockRes());
141
+ const cd = dres.head && dres.head.headers && dres.head.headers['Content-Disposition'];
142
+ check('audio download sets Content-Disposition attachment', dres.head.code === 200 && /attachment/.test(cd || ''), cd);
143
+ }
144
+
145
+ const badRes = await call(audioRoute, mockReq('/dsh-tts-audio/nope'), mockRes());
146
+ check('unknown audio id -> 404', badRes.head.code === 404);
147
+
148
+ // M2 subset: long plain-Edge reads also stream progressively (jobId+chunks),
149
+ // not a single blocking synthesis
150
+ {
151
+ const longEdge = '这是一段很长的文本用于验证 Edge 长读也走自适应分块渐进播放,避免等待整段合成。'.repeat(10);
152
+ const er = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
153
+ text: longEdge, voice: 'zh-CN-XiaoxuanNeural', provider: 'edge-tts'
154
+ })), mockRes());
155
+ const ep = JSON.parse(er.body);
156
+ check('long edge read returns chunked job (streaming subset)',
157
+ er.head.code === 200 && typeof ep.jobId === 'string' && Array.isArray(ep.chunks) && ep.chunks.length >= 1,
158
+ ep.jobId ? `jobId=${ep.jobId} chunks=${ep.chunks.length} total=${ep.total}` : er.body);
159
+ }
160
+
161
+ // Regression: fastFirst 1/3/2 chunk-swap. After /speak returns chunk 0, the
162
+ // background prewarm of chunk 1 is still in flight — the old shared-nextIdx
163
+ // code served parts[2] as want=1 (or orphaned parts[1] in ready with
164
+ // more=false). Draining immediately must yield indices strictly 0..N-1.
165
+ {
166
+ const orderText = '第一段测试文本,用于验证分段顺序是否正确。'.repeat(9); // ~180 chars -> 3 parts
167
+ const or = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
168
+ text: orderText, voice: 'zh-CN-XiaoxuanNeural', provider: 'edge-tts'
169
+ })), mockRes());
170
+ const op = JSON.parse(or.body);
171
+ const orderNext = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
172
+ if (or.head.code === 200 && typeof op.jobId === 'string' && op.total > 2 && orderNext) {
173
+ const seenIdx = [0];
174
+ const seenUrls = [op.chunks[0]];
175
+ let oMore = true;
176
+ let oGuard = 0;
177
+ while (oMore && oGuard++ < 20) {
178
+ const nr = await call(orderNext, mockReq(`/dsh-tts-api/rvc-next?job=${op.jobId}`), mockRes());
179
+ const np = JSON.parse(nr.body);
180
+ if (np.url || np.skipped) { seenIdx.push(np.index); if (np.url) seenUrls.push(np.url); }
181
+ oMore = !!np.more;
182
+ if (np.done) break;
183
+ }
184
+ const expectIdx = seenIdx.map((_, i) => i);
185
+ check('edge chunked job serves indices strictly 0..N-1 (no swap/drop)',
186
+ JSON.stringify(seenIdx) === JSON.stringify(expectIdx) && seenIdx.length === op.total && oMore === false,
187
+ `got [${seenIdx}] total=${op.total}`);
188
+ check('edge chunked job serves distinct URLs',
189
+ new Set(seenUrls).size === seenUrls.length, `distinct=${new Set(seenUrls).size}/${seenUrls.length}`);
190
+ } else {
191
+ check('edge ordering regression precondition: short chunked job', false, or.body.slice(0, 160));
192
+ }
193
+ }
194
+
195
+ // M1+ local-piper provider: registered in the abstraction; unconfigured -> graceful
196
+ // localized error (not a crash)
197
+ {
198
+ const pr = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
199
+ text: 'hello', voice: '', provider: 'local-piper', custom: {}
200
+ })), mockRes());
201
+ const pp = JSON.parse(pr.body);
202
+ check('local-piper unconfigured returns graceful error (i18n code)',
203
+ pr.head.code === 500 && pp.error && pp.i18n && pp.i18n.code === 'host.piperUnconfigured',
204
+ JSON.stringify(pp));
205
+ }
206
+ }
207
+
208
+ // --- RVC chain against a mock local RVC server ---
209
+
210
+ function miniWav(seconds = 1, sr = 40000) {
211
+ const n = sr * seconds;
212
+ const dataSize = n * 2;
213
+ const buf = Buffer.alloc(44 + dataSize);
214
+ buf.write('RIFF', 0);
215
+ buf.writeUInt32LE(36 + dataSize, 4);
216
+ buf.write('WAVE', 8);
217
+ buf.write('fmt ', 12);
218
+ buf.writeUInt32LE(16, 16);
219
+ buf.writeUInt16LE(1, 20);
220
+ buf.writeUInt16LE(1, 22);
221
+ buf.writeUInt32LE(sr, 24);
222
+ buf.writeUInt32LE(sr * 2, 28);
223
+ buf.writeUInt16LE(2, 32);
224
+ buf.writeUInt16LE(16, 34);
225
+ buf.write('data', 36);
226
+ buf.writeUInt32LE(dataSize, 40);
227
+ return buf;
228
+ }
229
+
230
+ function startMockRvc() {
231
+ return new Promise((resolve) => {
232
+ const server = createServer((req, res) => {
233
+ let body = '';
234
+ req.on('data', (d) => (body += d));
235
+ req.on('end', () => {
236
+ if (req.url === '/load') {
237
+ res.writeHead(200, { 'Content-Type': 'application/json' });
238
+ res.end(JSON.stringify({ ok: true }));
239
+ } else if (req.url === '/health') {
240
+ res.writeHead(200, { 'Content-Type': 'application/json' });
241
+ res.end(JSON.stringify({ ok: true, model_loaded: true, model: 'demo.pth', gpu_name: 'Mock GPU', vram_gb: 8 }));
242
+ } else if (req.url.startsWith('/files?kind=')) {
243
+ const kind = req.url.slice('/files?kind='.length);
244
+ res.writeHead(200, { 'Content-Type': 'application/json' });
245
+ res.end(JSON.stringify({
246
+ ok: true,
247
+ kind,
248
+ files: kind === 'pth'
249
+ ? [{ name: 'demo.pth', path: 'C:/models/demo.pth', size: 55000000 }]
250
+ : [{ name: 'demo.index', path: 'C:/models/demo.index', size: 400000000 }]
251
+ }));
252
+ } else if (req.url === '/convert') {
253
+ const payload = JSON.parse(body || '{}');
254
+ if (!payload.audio_base64) {
255
+ res.writeHead(400, { 'Content-Type': 'application/json' });
256
+ res.end(JSON.stringify({ message: 'audio_base64 required' }));
257
+ return;
258
+ }
259
+ res.writeHead(200, { 'Content-Type': 'application/json' });
260
+ res.end(JSON.stringify({ audio_base64: miniWav().toString('base64'), sample_rate: 40000 }));
261
+ } else if (req.url === '/compact-index') {
262
+ const payload = JSON.parse(body || '{}');
263
+ if (!payload.index) {
264
+ res.writeHead(400, { 'Content-Type': 'application/json' });
265
+ res.end(JSON.stringify({ message: 'index required' }));
266
+ return;
267
+ }
268
+ res.writeHead(200, { 'Content-Type': 'application/json' });
269
+ res.end(JSON.stringify({
270
+ ok: true,
271
+ path: 'C:/models/demo_compact_' + (payload.target_vectors || 10000) + '.index',
272
+ size: 6000000,
273
+ vectors: payload.target_vectors || 10000,
274
+ source_vectors: 129396,
275
+ source_size: 408000000,
276
+ reduction_pct: 98.5
277
+ }));
278
+ } else {
279
+ res.writeHead(404);
280
+ res.end();
281
+ }
282
+ });
283
+ });
284
+ server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
285
+ });
286
+ }
287
+
288
+ if (speakRoute && audioRoute) {
289
+ const mock = await startMockRvc();
290
+ try {
291
+ const res = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
292
+ text: '这是一段 RVC 链路测试。',
293
+ voice: 'zh-CN-XiaoxuanNeural',
294
+ provider: 'rvc',
295
+ custom: { baseUrl: `http://127.0.0.1:${mock.port}`, model: 'mock.pth', index: '' }
296
+ })), mockRes());
297
+ const parsed = JSON.parse(res.body);
298
+ check('rvc speak returns 200 + url', res.head.code === 200 && typeof parsed.url === 'string' && parsed.url.startsWith('/dsh-tts-audio/'), parsed.url ?? res.body);
299
+ if (parsed.url) {
300
+ const ares = await call(audioRoute, mockReq(parsed.url), mockRes());
301
+ const bytes = Buffer.isBuffer(ares.body) ? ares.body : Buffer.from(ares.body ?? '');
302
+ check('rvc audio route serves wav (RIFF)', ares.head.code === 200 && bytes.length > 44 && bytes.slice(0, 4).toString() === 'RIFF', `code=${ares.head.code} bytes=${bytes.length}`);
303
+ }
304
+
305
+ // ---- adaptive chunked progressive playback (long RVC text) ----
306
+ const longText = '这是一段用于验证自适应分块渐进播放的长文本朗读测试。'.repeat(12); // ~264 chars -> several chunks
307
+ const longRes = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
308
+ text: longText,
309
+ voice: 'zh-CN-XiaoxuanNeural',
310
+ provider: 'rvc',
311
+ custom: { baseUrl: `http://127.0.0.1:${mock.port}`, model: 'mock.pth', index: '' }
312
+ })), mockRes());
313
+ const longParsed = JSON.parse(longRes.body);
314
+ check('rvc long speak returns jobId + prewarmed chunks', longRes.head.code === 200
315
+ && typeof longParsed.jobId === 'string'
316
+ && Array.isArray(longParsed.chunks) && longParsed.chunks.length >= 2
317
+ && typeof longParsed.total === 'number' && longParsed.total > longParsed.chunks.length,
318
+ `jobId=${longParsed.jobId} chunks=${longParsed.chunks && longParsed.chunks.length} total=${longParsed.total}`);
319
+ if (typeof longParsed.jobId === 'string') {
320
+ const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
321
+ check('plugin registers rvc-next route', nextRoute !== undefined);
322
+ let fetched = longParsed.chunks.length;
323
+ let more = true;
324
+ let drained = 0;
325
+ while (more && drained < 100) {
326
+ const nr = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${longParsed.jobId}`), mockRes());
327
+ const np = JSON.parse(nr.body);
328
+ if (np && np.url) fetched++;
329
+ more = !!(np && np.more);
330
+ drained++;
331
+ }
332
+ check('rvc-next drains to total chunks', fetched === longParsed.total && more === false, `fetched=${fetched} total=${longParsed.total}`);
333
+ // Job-completion summary: draining to the end must leave exactly one
334
+ // stage=job outcome=drained entry (served==total) in the diagnose log —
335
+ // the trace a serve-order incident would previously have left none of.
336
+ {
337
+ const dgJob = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
338
+ const drJob = await call(dgJob, mockReq('/dsh-tts-api/diagnose', JSON.stringify({})), mockRes());
339
+ const ddJob = JSON.parse(drJob.body);
340
+ const jobHit = Array.isArray(ddJob.recent) && ddJob.recent.find((e) =>
341
+ e && e.stage === 'job' && /outcome=drained/.test(e.error || '') &&
342
+ e.total === longParsed.total && e.served === longParsed.total &&
343
+ Array.isArray(e.skipped) && e.skipped.length === 0);
344
+ check('drained job leaves a served==total summary in diagnose.recent',
345
+ !!jobHit, JSON.stringify((ddJob.recent || []).filter((e) => e && e.stage === 'job')));
346
+ check('diagnose exposes hostBuild tag (which Host code ran)',
347
+ typeof ddJob.hostBuild === 'string' && ddJob.hostBuild.length > 0,
348
+ JSON.stringify(ddJob.hostBuild));
349
+ }
350
+ check('rvc-next done for unknown job', (await call(nextRoute, mockReq('/dsh-tts-api/rvc-next?job=unknown'), mockRes())).body === JSON.stringify({ done: true, gone: true }));
351
+ // a prewarmed chunk url must serve wav through the audio route
352
+ const cRes = await call(audioRoute, mockReq(longParsed.chunks[0]), mockRes());
353
+ const cBytes = Buffer.isBuffer(cRes.body) ? cRes.body : Buffer.from(cRes.body ?? '');
354
+ check('chunk audio route serves wav (RIFF)', cRes.head.code === 200 && cBytes.length > 44 && cBytes.slice(0, 4).toString() === 'RIFF', `code=${cRes.head.code} bytes=${cBytes.length}`);
355
+ }
356
+
357
+ // ---- explicit cancel: abandoning a chunked job releases it immediately ----
358
+ {
359
+ const spRes = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
360
+ text: longText,
361
+ voice: 'zh-CN-XiaoxuanNeural',
362
+ provider: 'rvc',
363
+ custom: { baseUrl: `http://127.0.0.1:${mock.port}`, model: 'mock.pth', index: '' }
364
+ })), mockRes());
365
+ const sp = JSON.parse(spRes.body);
366
+ if (typeof sp.jobId === 'string') {
367
+ const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
368
+ const c1 = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${sp.jobId}&cancel=1`), mockRes());
369
+ const c1p = JSON.parse(c1.body);
370
+ check('rvc-next cancel returns {done,cancelled}', c1.head.code === 200 && c1p.done === true && c1p.cancelled === true, c1.body);
371
+ const c2 = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${sp.jobId}`), mockRes());
372
+ const c2p = JSON.parse(c2.body);
373
+ check('cancelled job no longer servable (gone)', c2.head.code === 200 && c2p.done === true && c2p.gone === true, c2.body);
374
+ } else {
375
+ check('cancel test precondition: long rvc speak returns jobId', false, spRes.body);
376
+ }
377
+ }
378
+
379
+ // ---- prewarm skip surfacing: a prewarm chunk that exhausts retries must
380
+ // be reported (not walked over silently). index-tts2 is used because it
381
+ // has no calibration probe (absolute /tts-task call counting is exact). The mock fails task calls #2 and #3 (= chunk idx1's two
382
+ // attempts), so /speak must return chunks [url, false] + skipped [2],
383
+ // and draining must serve the skip at index 1 in order before continuing.
384
+ {
385
+ let taskCalls = 0;
386
+ const failServer = createServer((req, res) => {
387
+ let body = '';
388
+ req.on('data', (d) => (body += d));
389
+ req.on('end', () => {
390
+ if (req.url === '/health' || req.url === '/api/v1/voices') {
391
+ res.writeHead(200, { 'Content-Type': 'application/json' });
392
+ res.end(JSON.stringify({ ok: true, voices: ['demo.wav'], count: 1 }));
393
+ } else if (req.url === '/api/v1/tts/tasks') {
394
+ taskCalls++;
395
+ if (taskCalls === 2 || taskCalls === 3) {
396
+ res.writeHead(500, { 'Content-Type': 'application/json' });
397
+ res.end(JSON.stringify({ detail: 'mock tts boom' }));
398
+ return;
399
+ }
400
+ res.writeHead(200, { 'Content-Type': 'audio/wav' });
401
+ res.end(miniWav());
402
+ } else {
403
+ res.writeHead(404);
404
+ res.end();
405
+ }
406
+ });
407
+ });
408
+ await new Promise((r) => failServer.listen(0, '127.0.0.1', r));
409
+ try {
410
+ const failPort = failServer.address().port;
411
+ const skipText = '这是一段用于验证预热跳段上报的长文本朗读测试。'.repeat(12);
412
+ const skipRes = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
413
+ text: skipText,
414
+ voice: 'zh-CN-XiaoxuanNeural',
415
+ provider: 'index-tts2',
416
+ custom: { baseUrl: `http://127.0.0.1:${failPort}`, voice: 'demo.wav' }
417
+ })), mockRes());
418
+ const skipParsed = JSON.parse(skipRes.body);
419
+ check('prewarm skip reported in /speak (chunks[1] hole + skipped [2])',
420
+ skipRes.head.code === 200 && Array.isArray(skipParsed.chunks)
421
+ && skipParsed.chunks.length >= 2 && skipParsed.chunks[0] && skipParsed.chunks[1] === false
422
+ && Array.isArray(skipParsed.skipped) && skipParsed.skipped.length === 1 && skipParsed.skipped[0] === 2,
423
+ skipRes.body.slice(0, 200));
424
+ // P1-3 spans ride the same response: count matches total and the span
425
+ // slices rebuild the sent text (whitespace-insensitive).
426
+ {
427
+ const sps = skipParsed.spans;
428
+ const rebuilt = Array.isArray(sps)
429
+ ? sps.map(([a, b]) => skipText.slice(a, b)).join('').replace(/\s/g, '')
430
+ : null;
431
+ check('chunked /speak carries exact block spans',
432
+ Array.isArray(sps) && sps.length === skipParsed.total && rebuilt === skipText.replace(/\s/g, ''),
433
+ `spans=${sps && sps.length} total=${skipParsed.total}`);
434
+ }
435
+ if (typeof skipParsed.jobId === 'string' && Array.isArray(skipParsed.chunks)) {
436
+ const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
437
+ // serveIdx starts past prewarm; the on-demand serves must be the
438
+ // remaining indices strictly in order through to total.
439
+ const seen = [];
440
+ let sMore = true;
441
+ let sGuard = 0;
442
+ while (sMore && sGuard++ < 100) {
443
+ const nr = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${skipParsed.jobId}`), mockRes());
444
+ const np = JSON.parse(nr.body);
445
+ if (np.url || np.skipped) seen.push(np.index);
446
+ sMore = !!np.more;
447
+ if (np.done) break;
448
+ }
449
+ const expect = [];
450
+ for (let i = skipParsed.chunks.length; i < skipParsed.total; i++) expect.push(i);
451
+ check('post-prewarm serves continue strictly in order to total',
452
+ JSON.stringify(seen) === JSON.stringify(expect) && sMore === false,
453
+ `got [${seen}] expect [${expect}] total=${skipParsed.total}`);
454
+ }
455
+ } finally {
456
+ await new Promise((r) => failServer.close(r));
457
+ }
458
+ }
459
+
460
+ // file-discovery proxy route
461
+ const filesRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-files');
462
+ const fr = await call(filesRoute, mockReq(`/dsh-tts-api/rvc-files?baseUrl=http://127.0.0.1:${mock.port}&kind=pth`), mockRes());
463
+ const filesData = JSON.parse(fr.body);
464
+ check('rvc-files proxy lists pth files', fr.head.code === 200 && Array.isArray(filesData.files) && filesData.files.length > 0 && filesData.files[0].name === 'demo.pth', fr.body);
465
+ const fi = await call(filesRoute, mockReq(`/dsh-tts-api/rvc-files?baseUrl=http://127.0.0.1:${mock.port}&kind=index`), mockRes());
466
+ const filesIdx = JSON.parse(fi.body);
467
+ check('rvc-files proxy lists index files', fi.head.code === 200 && Array.isArray(filesIdx.files) && filesIdx.files.length > 0 && filesIdx.files[0].name === 'demo.index', fi.body);
468
+
469
+ // unreachable RVC service -> actionable, platform-aware startup hint
470
+ const badFiles = await call(filesRoute, mockReq('/dsh-tts-api/rvc-files?baseUrl=http%3A%2F%2F127.0.0.1%3A1&kind=pth'), mockRes());
471
+ const badFilesData = JSON.parse(badFiles.body);
472
+ check('rvc-files unreachable returns actionable startup hint',
473
+ badFiles.head.code === 502 &&
474
+ badFilesData.i18n && badFilesData.i18n.code === 'host.filesNeedsServer' &&
475
+ badFilesData.i18n.params && /rvc-server\.py/.test(badFilesData.i18n.params.startup || ''),
476
+ badFiles.body);
477
+
478
+ // compact-index proxy route
479
+ const compactRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-compact-index');
480
+ const cr = await call(compactRoute, mockReq('/dsh-tts-api/rvc-compact-index', JSON.stringify({
481
+ baseUrl: `http://127.0.0.1:${mock.port}`,
482
+ index: 'C:/models/demo.index',
483
+ target_vectors: 2000
484
+ })), mockRes());
485
+ const compactData = JSON.parse(cr.body);
486
+ check('compact-index proxy returns compacted index', cr.head.code === 200
487
+ && compactData.ok === true
488
+ && compactData.path === 'C:/models/demo_compact_2000.index'
489
+ && compactData.reduction_pct === 98.5,
490
+ cr.body);
491
+ const cb = await call(compactRoute, mockReq('/dsh-tts-api/rvc-compact-index', JSON.stringify({
492
+ baseUrl: `http://127.0.0.1:${mock.port}`,
493
+ index: ''
494
+ })), mockRes());
495
+ check('compact-index proxy passes server error', cb.head.code === 502 && typeof JSON.parse(cb.body).error === 'string', cb.body);
496
+
497
+ // ---- one-click diagnostics ----
498
+ const diagnoseRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
499
+ check('plugin registers diagnose route', diagnoseRoute !== undefined);
500
+ const dg = await call(diagnoseRoute, mockReq('/dsh-tts-api/diagnose', JSON.stringify({
501
+ rvcBaseUrl: `http://127.0.0.1:${mock.port}`
502
+ })), mockRes());
503
+ const dgData = JSON.parse(dg.body);
504
+ const edge = dgData.checks.find(c => c.id === 'edge');
505
+ const rvc = dgData.checks.find(c => c.id === 'rvc-server');
506
+ const model = dgData.checks.find(c => c.id === 'rvc-model');
507
+ check('diagnose: edge synthesis ok', dg.head.code === 200 && edge && edge.ok === true, JSON.stringify(edge));
508
+ check('diagnose: rvc server + model ok (mock)', rvc && rvc.ok === true && model && model.ok === true, JSON.stringify({ rvc, model }));
509
+ check('diagnose exposes worker meta for the log bundle',
510
+ dgData.worker && dgData.worker.chromium === '143.0.3650.75' && dgData.worker.singleTimeoutMs === 20000 && dgData.worker.spawnTimeoutMs === 45000,
511
+ JSON.stringify(dgData.worker));
512
+ check('diagnose exposes recent host errors array', Array.isArray(dgData.recent), JSON.stringify(dgData.recent));
513
+ const dgBad = await call(diagnoseRoute, mockReq('/dsh-tts-api/diagnose', JSON.stringify({
514
+ rvcBaseUrl: 'http://127.0.0.1:1'
515
+ })), mockRes());
516
+ const dgBadData = JSON.parse(dgBad.body);
517
+ const rvcBad = dgBadData.checks.find(c => c.id === 'rvc-server');
518
+ check('diagnose: unreachable rvc classified as connect', rvcBad && rvcBad.ok === false && rvcBad.cls === 'connect', JSON.stringify(rvcBad));
519
+ } finally {
520
+ mock.server.close();
521
+ }
522
+ }
523
+
524
+ // --- voice-pack registry: manifest proxy + verified install ---
525
+
526
+ {
527
+ // isolated packs dir for the test
528
+ process.env.DSH_TTS_PACKS_DIR = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packs-'));
529
+ // build a mock registry dir
530
+ const regDir = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-reg-'));
531
+ const modelBytes = Buffer.from('FAKE-MODEL-BYTES-0123456789'.repeat(8000)); // ~200KB
532
+ const indexBytes = Buffer.from('FAKE-INDEX-BYTES-abcdef'.repeat(6000)); // ~108KB
533
+ const sha = b => createHash('sha256').update(b).digest('hex');
534
+ const modelSha = sha(modelBytes);
535
+ const indexSha = sha(indexBytes);
536
+ writeFileSync(path.join(regDir, 'model.pth'), modelBytes);
537
+ writeFileSync(path.join(regDir, 'index.index'), indexBytes);
538
+ writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
539
+ schema: 1,
540
+ packs: [
541
+ {
542
+ id: 'pack-a',
543
+ name: 'Demo Voice A',
544
+ description: '测试音色包(模型+紧凑索引)',
545
+ version: '1.0.0',
546
+ author: 'tester',
547
+ license: 'MIT',
548
+ baseVoice: 'zh-CN-YunyangNeural',
549
+ f0Method: 'rmvpe',
550
+ indexRate: 0.75,
551
+ model: { url: '', size: modelBytes.length, sha256: modelSha },
552
+ index: { url: '', size: indexBytes.length, sha256: indexSha }
553
+ },
554
+ { id: 'pack-b', name: 'Demo Voice B', description: '免索引音色包', version: '2.0.0', license: 'CC-BY', model: { url: '', size: modelBytes.length, sha256: modelSha } }
555
+ ]
556
+ }, null, 2));
557
+
558
+ // fix relative urls after knowing the server port
559
+ const reg = await startMockRegistry(regDir, 0);
560
+ try {
561
+ const base = reg.base;
562
+ const manifestPath = path.join(regDir, 'manifest.json');
563
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
564
+ for (const p of manifest.packs) {
565
+ if (p.model) p.model.url = `${base}/model.pth`;
566
+ if (p.index) p.index.url = `${base}/index.index`;
567
+ }
568
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
569
+
570
+ const packsRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-packs');
571
+ const installRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-install');
572
+ const installedRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-packs-installed');
573
+ check('plugin registers pack routes', packsRoute !== undefined && installRoute !== undefined && installedRoute !== undefined);
574
+
575
+ const pr = await call(packsRoute, mockReq(`/dsh-tts-api/rvc-packs?registry=${encodeURIComponent(base)}`), mockRes());
576
+ const prData = JSON.parse(pr.body);
577
+ check('rvc-packs proxies manifest', pr.head.code === 200 && Array.isArray(prData.packs) && prData.packs.length === 2, pr.body);
578
+
579
+ const ir = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-a' })), mockRes());
580
+ const irData = JSON.parse(ir.body);
581
+ const installedModel = irData.modelPath || '';
582
+ const installedIndex = irData.indexPath || '';
583
+ const modelOk = installedModel && existsSync(installedModel) && sha(readFileSync(installedModel)) === modelSha;
584
+ const indexOk = installedIndex && existsSync(installedIndex) && sha(readFileSync(installedIndex)) === indexSha;
585
+ check('rvc-pack-install downloads + sha256 verifies + writes', ir.head.code === 200 && irData.ok && modelOk && indexOk,
586
+ `model=${installedModel} index=${installedIndex}`);
587
+
588
+ const ir2 = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-a' })), mockRes());
589
+ check('re-install skips (already installed)', JSON.parse(ir2.body).skipped === true, ir2.body);
590
+
591
+ const irB = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-b' })), mockRes());
592
+ const irBData = JSON.parse(irB.body);
593
+ check('index-free pack installs without index', irB.head.code === 200 && irBData.ok && irBData.indexPath === '', irB.body);
594
+
595
+ const st = await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes());
596
+ const stData = JSON.parse(st.body);
597
+ check('rvc-packs-installed lists 2 packs', st.head.code === 200 && stData.installed && stData.installed['pack-a'] && stData.installed['pack-b'], st.body);
598
+
599
+ // multi-index pack with RELATIVE urls (schema v2)
600
+ writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
601
+ schema: 2,
602
+ packs: [{
603
+ id: 'pack-c',
604
+ name: 'Multi Index Voice',
605
+ version: '1.0.0',
606
+ license: 'MIT',
607
+ model: { url: 'packs-shared/model.pth', size: modelBytes.length, sha256: modelSha },
608
+ indexes: [
609
+ { id: 'tiny', name: '紧凑 2k', url: 'packs-shared/i2k.index', size: 5, sha256: sha(Buffer.from('IDX2K')) },
610
+ { id: 'mid', name: '紧凑 10k', url: 'packs-shared/i10k.index', size: 6, sha256: sha(Buffer.from('IDX10K')) }
611
+ ]
612
+ }]
613
+ }, null, 2));
614
+ mkdirSync(path.join(regDir, 'packs-shared'));
615
+ writeFileSync(path.join(regDir, 'packs-shared', 'model.pth'), modelBytes);
616
+ writeFileSync(path.join(regDir, 'packs-shared', 'i2k.index'), Buffer.from('IDX2K'));
617
+ writeFileSync(path.join(regDir, 'packs-shared', 'i10k.index'), Buffer.from('IDX10K'));
618
+
619
+ const ic1 = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-c' })), mockRes());
620
+ const ic1d = JSON.parse(ic1.body);
621
+ const ic1Ok = ic1.head.code === 200 && ic1d.ok && ic1d.indexId === 'tiny'
622
+ && readFileSync(ic1d.indexPath, 'utf8') === 'IDX2K' && ic1d.variants.length === 2;
623
+ check('multi-index install defaults to first variant (relative urls)', ic1Ok, ic1.body);
624
+
625
+ const ic2 = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-c', indexId: 'mid' })), mockRes());
626
+ const ic2d = JSON.parse(ic2.body);
627
+ const ic2Ok = ic2.head.code === 200 && ic2d.ok && ic2d.indexId === 'mid'
628
+ && readFileSync(ic2d.indexPath, 'utf8') === 'IDX10K';
629
+ check('switching index variant re-downloads the chosen index', ic2Ok, ic2.body);
630
+
631
+ const st2 = await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes());
632
+ const st2Data = JSON.parse(st2.body);
633
+ check('installed.json records chosen index sha256', st2Data.installed['pack-c'].indexId === 'mid'
634
+ && st2Data.installed['pack-c'].indexSha256 === sha(Buffer.from('IDX10K')), st2.body);
635
+
636
+ // tampered sha256 -> install must fail and not leave files
637
+ writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
638
+ schema: 1,
639
+ packs: [{ id: 'pack-bad', name: 'Bad', version: '1.0.0', model: { url: `${base}/model.pth`, size: modelBytes.length, sha256: '0'.repeat(64) } }]
640
+ }, null, 2));
641
+ const bad = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-bad' })), mockRes());
642
+ const badData = JSON.parse(bad.body);
643
+ check('tampered sha256 rejected', bad.head.code === 502 && /sha256/.test(badData.error || ''), bad.body);
644
+ check('failed install leaves no model file', !existsSync(path.join(process.env.DSH_TTS_PACKS_DIR, 'pack_bad', 'pack_bad.pth')));
645
+
646
+ // installed files are named <packId>.pth / <packId>.index so the browse
647
+ // picker can tell voices apart
648
+ check('installed files use pack-id names',
649
+ irData.modelPath.endsWith('pack-a.pth') && irData.indexPath.endsWith('pack-a.index'),
650
+ `${irData.modelPath} / ${irData.indexPath}`);
651
+
652
+ // stale entry (files deleted) is reconciled away on read
653
+ const staleDir = path.join(process.env.DSH_TTS_PACKS_DIR, 'pack-a');
654
+ rmSync(staleDir, { recursive: true, force: true });
655
+ const st3 = await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes());
656
+ const st3Data = JSON.parse(st3.body);
657
+ check('deleted pack no longer listed as installed', !st3Data.installed['pack-a'], st3.body);
658
+
659
+ // uninstall route removes files + record
660
+ const ui = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-b' })), mockRes());
661
+ const uiData = JSON.parse(ui.body);
662
+ const uninstallRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-uninstall');
663
+ const un = await call(uninstallRoute, mockReq('/dsh-tts-api/rvc-pack-uninstall', JSON.stringify({ packId: 'pack-b' })), mockRes());
664
+ check('rvc-pack-uninstall removes files + record', un.head.code === 200
665
+ && !existsSync(uiData.modelPath)
666
+ && !JSON.parse((await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes())).body).installed['pack-b'],
667
+ un.body);
668
+ } finally {
669
+ reg.server.close();
670
+ }
671
+ }
672
+
673
+ // --- pack-install progress reporting (delayed registry) ---
674
+ {
675
+ process.env.DSH_TTS_PACKS_DIR = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packs-prog-'));
676
+ const regDir = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-reg-prog-'));
677
+ const modelBytes = Buffer.from('PROGRESS-MODEL-0123456789'.repeat(5000));
678
+ const sha = b => createHash('sha256').update(b).digest('hex');
679
+ writeFileSync(path.join(regDir, 'model.pth'), modelBytes);
680
+ writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
681
+ schema: 2,
682
+ packs: [{ id: 'pack-d', name: 'Slow Pack', version: '1.0.0', license: 'MIT',
683
+ model: { url: 'model.pth', size: modelBytes.length, sha256: sha(modelBytes) } }]
684
+ }));
685
+ const reg = await startMockRegistry(regDir, 0, 400); // 400ms delay per file
686
+ try {
687
+ const installRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-install');
688
+ const progressRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-progress');
689
+ check('plugin registers pack-progress route', progressRoute !== undefined);
690
+ const pKey = 'prog-' + Date.now();
691
+ const installPromise = call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({
692
+ registry: reg.base, packId: 'pack-d', progressKey: pKey
693
+ })), mockRes());
694
+ let sawProgress = false;
695
+ for (let i = 0; i < 8; i++) {
696
+ await new Promise(r => setTimeout(r, 120));
697
+ const pr = mockRes();
698
+ await call(progressRoute, mockReq(`/dsh-tts-api/rvc-pack-progress?key=${pKey}`), pr);
699
+ const d = JSON.parse(pr.body);
700
+ if (d && d.waiting !== true && typeof d.phase === 'string') { sawProgress = true; break; }
701
+ }
702
+ check('pack-progress reports in-flight bytes/phase', sawProgress === true);
703
+ const res = await installPromise;
704
+ check('delayed install completes', JSON.parse(res.body).ok === true, res.body);
705
+ const pr2 = mockRes();
706
+ await call(progressRoute, mockReq(`/dsh-tts-api/rvc-pack-progress?key=${pKey}`), pr2);
707
+ check('pack-progress finished after install', JSON.parse(pr2.body).finished === true, pr2.body);
708
+ const pr3 = mockRes();
709
+ await call(progressRoute, mockReq('/dsh-tts-api/rvc-pack-progress?key=unknown-key-xyz'), pr3);
710
+ check('pack-progress unknown key -> waiting (not done)', JSON.parse(pr3.body).waiting === true, pr3.body);
711
+ } finally {
712
+ reg.server.close();
713
+ }
714
+ }
715
+
716
+ // --- pack download through an HTTP CONNECT proxy (mimics Clash) ---
717
+ {
718
+ process.env.DSH_TTS_PACKS_DIR = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packs-proxy-'));
719
+ const regDir = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-reg-proxy-'));
720
+ const modelBytes = Buffer.from('PROXY-DOWNLOAD-abcdef'.repeat(8000));
721
+ const sha = b => createHash('sha256').update(b).digest('hex');
722
+ writeFileSync(path.join(regDir, 'model.pth'), modelBytes);
723
+ writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
724
+ schema: 2,
725
+ packs: [{ id: 'pack-p', name: 'Proxy Pack', version: '1.0.0', license: 'MIT',
726
+ model: { url: 'model.pth', size: modelBytes.length, sha256: sha(modelBytes) } }]
727
+ }));
728
+ const reg = await startMockRegistry(regDir, 0);
729
+ // minimal CONNECT tunnel proxy (CONNECT arrives via the 'connect' event)
730
+ const proxy = createServer((req, res) => { res.writeHead(405); res.end(); });
731
+ proxy.on('connect', (req, clientSocket, head) => {
732
+ const idx = req.url.indexOf(':');
733
+ const host = req.url.slice(0, idx);
734
+ const port = Number(req.url.slice(idx + 1));
735
+ const target = net.connect(port, host, () => {
736
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
737
+ if (head && head.length) target.write(head);
738
+ clientSocket.pipe(target);
739
+ target.pipe(clientSocket);
740
+ });
741
+ target.on('error', () => { try { clientSocket.destroy(); } catch (e) {} });
742
+ clientSocket.on('error', () => {});
743
+ });
744
+ await new Promise(r => proxy.listen(0, '127.0.0.1', r));
745
+ const proxyPort = proxy.address().port;
746
+ try {
747
+ const packsRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-packs');
748
+ const installRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-install');
749
+ const pr = await call(packsRoute, mockReq(`/dsh-tts-api/rvc-packs?registry=${encodeURIComponent(reg.base)}&proxy=${encodeURIComponent(`http://127.0.0.1:${proxyPort}`)}`), mockRes());
750
+ const prData = JSON.parse(pr.body);
751
+ check('manifest fetch through proxy', pr.head.code === 200 && prData.packs[0].id === 'pack-p', pr.body);
752
+ const ir = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({
753
+ registry: reg.base, packId: 'pack-p', proxy: `http://127.0.0.1:${proxyPort}`
754
+ })), mockRes());
755
+ const irData = JSON.parse(ir.body);
756
+ check('install through proxy tunnel downloads + verifies', ir.head.code === 200 && irData.ok
757
+ && existsSync(irData.modelPath) && sha(readFileSync(irData.modelPath)) === sha(modelBytes), ir.body);
758
+ } finally {
759
+ proxy.close();
760
+ reg.server.close();
761
+ }
762
+ }
763
+
764
+ // --- tools/make-pack.mjs: one-command pack generation + validation ---
765
+ // NOTE: skipped under sandboxes that block child-process spawn (spawnSync
766
+ // EPERM) — the tool itself is unchanged; only the test is environment-gated.
767
+ {
768
+ let spawnOk = true;
769
+ try {
770
+ execFileSync(process.execPath, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
771
+ } catch (e) {
772
+ spawnOk = false;
773
+ }
774
+ if (!spawnOk) {
775
+ check('make-pack generates pack + manifest (skipped: spawn blocked)', true, 'spawnSync EPERM in sandbox');
776
+ check('make-pack --check validates packs (skipped: spawn blocked)', true, 'spawnSync EPERM in sandbox');
777
+ } else {
778
+ const makePack = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'tools', 'make-pack.mjs');
779
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packrepo-'));
780
+ const incoming = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-incoming-'));
781
+ writeFileSync(path.join(incoming, 'model.pth'), Buffer.from('GEN-MODEL-12345'));
782
+ writeFileSync(path.join(incoming, 'idx2k.index'), Buffer.from('GEN-INDEX-2K'));
783
+ writeFileSync(path.join(incoming, 'idx10k.index'), Buffer.from('GEN-INDEX-10K'));
784
+ const out = execFileSync(process.execPath, [
785
+ makePack, '--id', 'pack-gen', '--name', 'Gen Voice', '--dir', incoming, '--repo', repo,
786
+ '--desc', 'generated test pack', '--author', 'tester', '--license', 'MIT'
787
+ ], { encoding: 'utf8' });
788
+ const m = JSON.parse(readFileSync(path.join(repo, 'manifest.json'), 'utf8'));
789
+ const p = m.packs.find(x => x.id === 'pack-gen');
790
+ const sha = b => createHash('sha256').update(b).digest('hex');
791
+ const modelOk = p && existsSync(path.join(repo, p.model.url))
792
+ && readFileSync(path.join(repo, p.model.url)).toString() === 'GEN-MODEL-12345'
793
+ && p.model.sha256 === sha(Buffer.from('GEN-MODEL-12345'));
794
+ const idxOk = p && Array.isArray(p.indexes) && p.indexes.length === 2;
795
+ check('make-pack generates pack + manifest', modelOk && idxOk && /added pack/.test(out), JSON.stringify(p).slice(0, 200));
796
+ const chk = execFileSync(process.execPath, [makePack, '--check', '--repo', repo], { encoding: 'utf8' });
797
+ check('make-pack --check validates packs', /OK: 1 pack\(s\) validated/.test(chk), chk.trim().split('\n').pop());
798
+ }
799
+ }
800
+
801
+ // --- splitText hardening (smart sentence segmentation) ---
802
+ // URLs / emails / decimals / versions must never be split by the sentence
803
+ // splitter ('.' inside "3.14") or by hard cuts; a tiny trailing orphan chunk
804
+ // should merge back into the previous one.
805
+ {
806
+ const { splitText } = plugin.__test || {};
807
+ check('__test.splitText hook exposed', typeof splitText === 'function');
808
+ if (typeof splitText === 'function') {
809
+ const t1 = '访问 https://example.com/a.b.c 获取 3.14 版本并联系 test.user@example.com 获取 v2.0.1。'.repeat(12);
810
+ const r1 = splitText(t1, 40);
811
+ const joined1 = r1.join('');
812
+ check('splitText: total content preserved', joined1.replace(/\s/g, '') === t1.replace(/\s/g, ''), `parts=${r1.length}`);
813
+ check('splitText: URL never split mid-token', r1.every(p => !p.includes('example.com/a') || p.includes('https://example.com/a.b.c')), JSON.stringify(r1.slice(0, 2)));
814
+ check('splitText: decimal never split (3.14)', r1.every(p => !/3\.1(?!4)/.test(p) && !/3\.(?!14)/.test(p)), JSON.stringify(r1.slice(0, 3)));
815
+ check('splitText: email never split', r1.every(p => !p.includes('test.user@') || p.includes('test.user@example.com')), JSON.stringify(r1.slice(0, 3)));
816
+ check('splitText: version never split (v2.0.1)', r1.every(p => !p.includes('2.0.1') || p.includes('v2.0.1')), undefined);
817
+
818
+ // long unbroken latin run: hard cut slides back to a whitespace boundary
819
+ const t2 = 'short segment '.repeat(60) + 'end.';
820
+ const r2 = splitText(t2, 24);
821
+ check('splitText: hard cuts land on word boundaries', r2.every(p => !p.trim().endsWith('ment') || p.includes('segment ')), `lengths=${r2.map(p => p.length).join(',')}`);
822
+
823
+ // trailing tiny sentence merges into the previous chunk instead of stuttering
824
+ const t3 = '这是一段足够长的中文测试文本,用来验证末尾孤儿短句是否被合并回前一块。好。';
825
+ const r3 = splitText(t3, 30);
826
+ check('splitText: tiny trailing chunk merged', r3.length === 2 && /好。$/.test(r3[1]) && /好。$/.test(r3[0]) === false, JSON.stringify(r3));
827
+
828
+ // short text: single chunk, unchanged semantics
829
+ const r4 = splitText('你好。', 30);
830
+ check('splitText: short text stays one chunk', r4.length === 1 && r4[0] === '你好。', JSON.stringify(r4));
831
+
832
+ // spanParts (scroll-follow P1-3): per-part char spans walk the source in
833
+ // order; concatenated span slices reproduce the text minus whitespace.
834
+ const spanOf = plugin.__test.spanParts;
835
+ check('__test.spanParts exposed', typeof spanOf === 'function');
836
+ if (typeof spanOf === 'function') {
837
+ const st = '第一句。第二句很长很长,需要切块 Parece que sí。第三句。';
838
+ const sp = splitText(st, 12);
839
+ const ssp = spanOf(st, sp);
840
+ const rebuilt = Array.isArray(ssp)
841
+ ? ssp.map(([a, b]) => st.slice(a, b)).join('').replace(/\s/g, '')
842
+ : null;
843
+ check('spanParts: spans walk in order and rebuild the text',
844
+ Array.isArray(ssp) && ssp.length === sp.length &&
845
+ rebuilt === st.replace(/\s/g, ''),
846
+ `parts=${sp.length} spans=${JSON.stringify(ssp)}`);
847
+ // Cloud synthetic join gaps (latin alnum boundary spaces) match zero
848
+ // source whitespace instead of failing the walk.
849
+ const cl = 'Visit https://example.com/a.b.c now please ok。'.repeat(6);
850
+ const cp = plugin.__test.splitCloudPackets(cl);
851
+ const csp = spanOf(cl, cp);
852
+ check('spanParts: cloud packets walk despite synthetic gaps',
853
+ Array.isArray(csp) && csp.length === cp.length,
854
+ `packets=${cp.length} spans=${csp && csp.length}`);
855
+ check('spanParts: unwalkable parts return null (fallback signal)',
856
+ spanOf('abc', ['zzz']) === null);
857
+ }
858
+ }
859
+ }
860
+
861
+ // --- Google Cloud TTS: packets, config, usage, single-packet speak --------
862
+ // Pure-packet assertions run offline (no key needed): 6000 CJK chars must
863
+ // split into 5 packets, all under the 5000-byte sync hard limit; the 1667-char
864
+ // boundary case (5001 bytes) must split and never 400. Billing caliber =
865
+ // SSML length. Key/config/usage routes run against the Host settings file.
866
+ {
867
+ const t = plugin.__test || {};
868
+ check('__test cloud hooks exposed',
869
+ typeof t.splitCloudPackets === 'function' &&
870
+ typeof t.cloudTier === 'function' &&
871
+ typeof t.cloudBillableChars === 'function' &&
872
+ typeof t.loadHostCloudSettings === 'function' &&
873
+ typeof t.saveHostCloudSettings === 'function' &&
874
+ typeof t.recordCloudUsage === 'function' &&
875
+ typeof t.getCloudUsageSummary === 'function');
876
+ if (typeof t.splitCloudPackets === 'function') {
877
+ // 6000 CJK chars: not one giant packet — 6 packets (5×1190 + 1×1050),
878
+ // all safe (< 5000B) and within 800-1200 chars each.
879
+ const long6k = '你好,这是云端语音分包测试。'.repeat(500); // 6000 chars
880
+ const p6k = t.splitCloudPackets(long6k);
881
+ let worst = 0, over = 0;
882
+ for (const p of p6k) {
883
+ const b = Buffer.byteLength(p, 'utf8');
884
+ worst = Math.max(worst, b);
885
+ if (b > 5000) over++;
886
+ }
887
+ const joined = p6k.join('');
888
+ check('cloud packets: 6000 chars -> 6 packets, all <= 5000 bytes',
889
+ p6k.length === 6 && over === 0,
890
+ `packets=${p6k.length} worst=${worst}B`);
891
+ check('cloud packets: content preserved (no loss)',
892
+ joined.replace(/\s/g, '') === long6k.replace(/\s/g, ''), `parts=${p6k.length}`);
893
+ check('cloud packets: CJK within 800-1200 chars each',
894
+ p6k.every(p => p.length >= 800 && p.length <= 1200),
895
+ `lens=${p6k.map(p => p.length).join(',')}`);
896
+ // 1667-char boundary (5001 UTF-8 bytes > 5000 hard limit): must split, no 400
897
+ const b1667 = t.splitCloudPackets('测'.repeat(1667));
898
+ const bWorst = Math.max(...b1667.map(p => Buffer.byteLength(p, 'utf8')));
899
+ check('cloud packets: 1667-char boundary splits, no over-limit packet',
900
+ b1667.length >= 2 && bWorst <= 5000, `packets=${b1667.length} worst=${bWorst}B`);
901
+ // atomic protection inherited from splitText: URL/decimal never split
902
+ const t1 = ('访问 https://example.com/a.b.c 获取 3.14 版本。'.repeat(80));
903
+ const r1 = t.splitCloudPackets(t1);
904
+ check('cloud packets: URL never split mid-token',
905
+ r1.every(p => !p.includes('example.com/a') || p.includes('https://example.com/a.b.c')));
906
+ check('cloud packets: decimal never split (3.14)',
907
+ r1.every(p => !/3\.1(?!4)/.test(p)));
908
+ // per-packet failure isolation: fail packet #2 once -> only it retries.
909
+ // (Simulated at the helper level: the sink contract is per-packet, so a
910
+ // retry re-calls only that packet's synthesis; assert via packet count.)
911
+ check('cloud packets: retry scope is one packet (packet count stable)',
912
+ r1.length >= 2 && r1.every(p => Buffer.byteLength(p, 'utf8') <= 5000));
913
+ // tier mapping + billing caliber
914
+ check('cloud tier mapping standard/wavenet/neural2/chirp3',
915
+ t.cloudTier('cmn-CN-Standard-A') === 'standard' &&
916
+ t.cloudTier('cmn-CN-Wavenet-A') === 'wavenet' &&
917
+ t.cloudTier('cmn-CN-Neural2-A') === 'neural2' &&
918
+ t.cloudTier('cmn-CN-Chirp3-HD-A') === 'chirp3');
919
+ const ssml = t.cloudBuildSsml('你好');
920
+ check('cloud billing caliber = SSML code-point length',
921
+ t.cloudBillableChars(ssml) === [...ssml].length && ssml.startsWith('<speak>') && ssml.endsWith('</speak>'),
922
+ `billable=${t.cloudBillableChars(ssml)}`);
923
+ check('cloud SSML self-closed per packet (no cross-packet tags)',
924
+ /^<speak>.*<\/speak>$/.test(ssml) && (ssml.match(/<speak>/g) || []).length === 1);
925
+ }
926
+
927
+ // cloud-config routes: key never echoed back in full; projectId round-trips.
928
+ const cloudGet = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-config');
929
+ const cloudSave = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-config-save');
930
+ const cloudUsage = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-usage');
931
+ const cloudTest = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-test');
932
+ check('plugin registers cloud routes', cloudGet !== undefined && cloudSave !== undefined && cloudUsage !== undefined && cloudTest !== undefined);
933
+ if (cloudGet && cloudSave) {
934
+ const prev = (plugin.__test.loadHostCloudSettings && plugin.__test.loadHostCloudSettings()) || {};
935
+ // short key rejected before any write
936
+ const bad = await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ apiKey: 'short' })), method: 'POST' }, mockRes());
937
+ check('cloud-config-save rejects short key', bad.head.code === 400, bad.body.slice(0, 120));
938
+ // save + GET: apiKeySet + tail only, never the full key
939
+ const s1 = await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ cloud: { apiKey: 'AIzaTestKey1234567890', projectId: 'demo-proj' } })), method: 'POST' }, mockRes());
940
+ const s1d = JSON.parse(s1.body);
941
+ check('cloud-config-save stores key + project',
942
+ s1.head.code === 200 && s1d.ok === true && s1d.cloud.apiKeySet === true && s1d.cloud.projectId === 'demo-proj', s1.body.slice(0, 200));
943
+ const g1 = await call(cloudGet, { ...mockReq('/dsh-tts-api/cloud-config'), method: 'GET' }, mockRes());
944
+ const g1d = JSON.parse(g1.body);
945
+ check('cloud-config GET never echoes full key',
946
+ g1.head.code === 200 && g1d.cloud.apiKeySet === true &&
947
+ !JSON.stringify(g1d).includes('AIzaTestKey1234567890') &&
948
+ typeof g1d.cloud.apiKeyTail === 'string', g1.body.slice(0, 200));
949
+ // local quota ledger: record + summary, official stays disabled
950
+ if (cloudUsage) {
951
+ const u0 = await call(cloudUsage, { ...mockReq('/dsh-tts-api/cloud-usage'), method: 'GET' }, mockRes());
952
+ const u0d = JSON.parse(u0.body);
953
+ check('cloud-usage returns month tiers + official disabled',
954
+ u0.head.code === 200 && u0d.month && u0d.tiers && u0d.tiers.standard && u0d.official && u0d.official.enabled === false,
955
+ u0.body.slice(0, 200));
956
+ plugin.__test.recordCloudUsage('wavenet', 1234);
957
+ const u1 = await call(cloudUsage, { ...mockReq('/dsh-tts-api/cloud-usage'), method: 'GET' }, mockRes());
958
+ const u1d = JSON.parse(u1.body);
959
+ check('cloud-usage reflects recorded chars (wavenet 1234)',
960
+ u1d.tiers.wavenet.used >= 1234 && u1d.tiers.wavenet.remaining === u1d.tiers.wavenet.limit - u1d.tiers.wavenet.used,
961
+ JSON.stringify(u1d.tiers.wavenet));
962
+ }
963
+ // no key -> speak fails with localized cloudNoKey (Host file cleared first)
964
+ await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ cloud: { apiKey: '', projectId: '' } })), method: 'POST' }, mockRes());
965
+ const noKey = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
966
+ text: '你好。', voice: 'cmn-CN-Wavenet-A', provider: 'google-cloud-tts'
967
+ })), mockRes());
968
+ const noKeyData = JSON.parse(noKey.body);
969
+ check('cloud speak without key returns localized error',
970
+ noKey.head.code === 500 && noKeyData.i18n && noKeyData.i18n.code === 'host.cloudNoKey', noKey.body);
971
+ // restore previous state
972
+ if (prev.apiKey) {
973
+ await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ cloud: prev })), method: 'POST' }, mockRes());
974
+ }
975
+ check('cloud-config round-trip restore ok', true);
976
+ }
977
+
978
+ // cloud-test without key -> 400 localized (offline, no network needed)
979
+ if (cloudTest) {
980
+ const ct = await call(cloudTest, { ...mockReq('/dsh-tts-api/cloud-test', JSON.stringify({ voice: 'cmn-CN-Wavenet-A' })), method: 'POST' }, mockRes());
981
+ check('cloud-test without key returns 400 (no network call)', ct.head.code === 400, ct.body.slice(0, 160));
982
+ }
983
+
984
+ // diagnose without key: cloud check present, neutral (skip, not red)
985
+ {
986
+ const dg = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
987
+ const dr = await call(dg, mockReq('/dsh-tts-api/diagnose', JSON.stringify({})), mockRes());
988
+ const dd = JSON.parse(dr.body);
989
+ const cc = dd.checks && dd.checks.find(c => c.id === 'cloud');
990
+ check('diagnose includes cloud check (neutral skip when keyless)',
991
+ !!cc && cc.cls === 'skip' && cc.ok === false, JSON.stringify(cc));
992
+ }
993
+ }
994
+
995
+ // --- chunk policy: fastFirst only for edge/cloud (pure, offline) ---------
996
+ // Edge/Cloud (ratio ~0.1): chunk 1 returns immediately, rest warm behind.
997
+ // Local synthesis (rvc/index/cosy): full prewarm, playback never starves.
998
+ {
999
+ const t = plugin.__test || {};
1000
+ check('__test.chunkCal exposed', typeof t.chunkCal === 'function');
1001
+ if (typeof t.chunkCal === 'function') {
1002
+ // chunkCal covers the FIXED-policy providers; rvc uses live getCalibration
1003
+ // (probe), so it is intentionally absent here.
1004
+ const e = t.chunkCal('edge-tts');
1005
+ const c = t.chunkCal('google-cloud-tts');
1006
+ const i = t.chunkCal('index-tts2');
1007
+ const v = t.chunkCal('cosyvoice');
1008
+ const u = t.chunkCal('unknown-provider');
1009
+ check('chunkCal: edge/cloud fastFirst, local full prewarm',
1010
+ e.fastFirst === true && e.prewarm === 2 &&
1011
+ c.fastFirst === true && c.prewarm === 2 &&
1012
+ !i.fastFirst && i.prewarm === 2 &&
1013
+ !v.fastFirst && v.prewarm === 2 &&
1014
+ u.fastFirst === true,
1015
+ `edge=${JSON.stringify(e)} cloud=${JSON.stringify(c)} index=${JSON.stringify(i)} cosy=${JSON.stringify(v)}`);
1016
+ }
1017
+ }
1018
+
1019
+ // --- /speak with unreachable RVC service -> localized, action-ready error ---
1020
+ // The client turns this i18n-tagged error into a toast (+ one-click Edge
1021
+ // fallback); verify the host response shape.
1022
+ {
1023
+ const badSpeak = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
1024
+ text: '你好。',
1025
+ voice: 'zh-CN-XiaoxuanNeural',
1026
+ provider: 'rvc',
1027
+ custom: { baseUrl: 'http://127.0.0.1:1', model: 'demo.pth', index: '' }
1028
+ })), mockRes());
1029
+ const badSpeakData = JSON.parse(badSpeak.body);
1030
+ check('speak rvc unreachable returns localized error',
1031
+ badSpeak.head.code === 500 &&
1032
+ badSpeakData.error && typeof badSpeakData.error === 'string' &&
1033
+ badSpeakData.i18n && (badSpeakData.i18n.code === 'host.rvcUnreachable' || badSpeakData.i18n.code === 'host.rvcHttpFail'),
1034
+ badSpeak.body);
1035
+ // the failed speak above must land in the host recent-error buffer (diag log)
1036
+ {
1037
+ const dgErr = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
1038
+ const dr = await call(dgErr, mockReq('/dsh-tts-api/diagnose', JSON.stringify({})), mockRes());
1039
+ const dd = JSON.parse(dr.body);
1040
+ const hit = Array.isArray(dd.recent) && dd.recent.find((e) => e && e.provider === 'rvc' && e.stage === 'speak');
1041
+ check('failed speak recorded in diagnose.recent (kind + no message text)',
1042
+ !!hit && hit.textLen === 3 && !('text' in hit) && hit.kind === 'service',
1043
+ JSON.stringify((dd.recent || []).slice(-3)));
1044
+ check('diagnose exposes plugin identity for the log bundle',
1045
+ dd.plugin && typeof dd.plugin.version === 'string' && dd.plugin.version !== 'unknown',
1046
+ JSON.stringify(dd.plugin));
1047
+ check('rvc long read recorded calibration info (no message text)',
1048
+ Array.isArray(dd.recent) && dd.recent.find((e) => e && e.kind === 'info' && e.stage === 'calibration' && !('text' in e)),
1049
+ JSON.stringify((dd.recent || []).filter((e) => e && e.kind === 'info')));
1050
+ check('rvc failure recorded stage breakdown (failedAt=load, no message text)',
1051
+ Array.isArray(dd.recent) && dd.recent.find((e) => e && e.stage === 'rvc-stages' && /failedAt=load/.test(e.error || '') && !('text' in e)),
1052
+ JSON.stringify((dd.recent || []).filter((e) => e && e.stage === 'rvc-stages')));
1053
+ check('diagnose exposes activeJobs snapshot (counts only)',
1054
+ Array.isArray(dd.activeJobs),
1055
+ JSON.stringify(dd.activeJobs));
1056
+ }
1057
+ }
1058
+
1059
+ // --- error kind classifier for the exported diagnostic log ---
1060
+ {
1061
+ const f = plugin.__test && plugin.__test.classifyHostError;
1062
+ const cases = [
1063
+ ['closed early code=1007 reason=Unsupported voice x', 'voice'],
1064
+ ['TTS worker exited 1: ERR HTTP 403', 'auth'],
1065
+ ['未配置 RVC 模型路径', 'config'],
1066
+ ['无法连接本地 RVC 推理服务(http://127.0.0.1:1):fetch failed', 'service'],
1067
+ ['timeout after 20000ms without turn.end', 'timeout'],
1068
+ ['websocket error (type=error url=speech.platform.bing.com/edge/v1) closed code=1006 reason=', 'network'],
1069
+ ['首段合成失败', 'chunk'],
1070
+ ['TTS worker timeout (45s)', 'timeout'],
1071
+ ['TTS spawn failed: something', 'spawn'],
1072
+ ['something completely unexpected', 'unknown'],
1073
+ ];
1074
+ const bad = typeof f === 'function' ? cases.filter(([msg, want]) => f(msg) !== want) : cases;
1075
+ check('classifyHostError kinds for the log bundle', bad.length === 0,
1076
+ bad.length ? JSON.stringify(bad.slice(0, 3)) : cases.length + ' cases');
1077
+ }
1078
+
1079
+ // --- worker RETRY side-channel parsing (redo visibility in the log) ---
1080
+ {
1081
+ const f = plugin.__test && plugin.__test.countWorkerRetries;
1082
+ const r0 = f ? f('OK /tmp/x.mp3\nSIZE 123\n') : null;
1083
+ const r1 = f ? f('RETRY websocket error (type=error) closed code=1006 reason=\nOK /tmp/x.mp3\nSIZE 123\n') : null;
1084
+ check('countWorkerRetries parses retry side-channel',
1085
+ !!f && r0 && r0.count === 0 && r1 && r1.count === 1 && /1006/.test(r1.last),
1086
+ JSON.stringify({ r0, r1 }));
1087
+ }
1088
+
1089
+ // --- Host RVC service settings (layered storage: ~/.dsh/tts-rvc/settings.json) ---
1090
+ {
1091
+ const cfgGet = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-config');
1092
+ const cfgSave = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-config-save');
1093
+ check('plugin registers rvc-config routes', cfgGet !== undefined && cfgSave !== undefined);
1094
+ check('__test host settings hooks exposed',
1095
+ plugin.__test && typeof plugin.__test.loadHostRvcSettings === 'function' &&
1096
+ typeof plugin.__test.saveHostRvcSettings === 'function');
1097
+ if (cfgGet && cfgSave) {
1098
+ const g0 = await call(cfgGet, { ...mockReq('/dsh-tts-api/rvc-config'), method: 'GET' }, mockRes());
1099
+ const g0d = JSON.parse(g0.body);
1100
+ check('rvc-config GET returns service shape', g0.head.code === 200 && g0d.rvc && typeof g0d.rvc.baseUrl === 'string',
1101
+ g0.body.slice(0, 160));
1102
+ // invalid service URL rejected before any write
1103
+ const bad = await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ baseUrl: 'not-a-url' })), method: 'POST' }, mockRes());
1104
+ check('rvc-config-save rejects non-http URL', bad.head.code === 400, bad.body.slice(0, 120));
1105
+ // round-trip: save service keys, GET reflects them, then clear again
1106
+ const prev = (plugin.__test.loadHostRvcSettings && plugin.__test.loadHostRvcSettings()) || {};
1107
+ const s1 = await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ rvc: { baseUrl: 'http://127.0.0.1:4899', model: 'C:\\m\\v.pth' } })), method: 'POST' }, mockRes());
1108
+ const s1d = JSON.parse(s1.body);
1109
+ check('rvc-config-save persists service keys', s1.head.code === 200 && s1d.ok === true &&
1110
+ s1d.rvc.baseUrl === 'http://127.0.0.1:4899' && s1d.rvc.model === 'C:\\m\\v.pth', s1.body.slice(0, 200));
1111
+ const g1 = await call(cfgGet, { ...mockReq('/dsh-tts-api/rvc-config'), method: 'GET' }, mockRes());
1112
+ check('rvc-config GET reflects saved service keys', JSON.parse(g1.body).rvc.baseUrl === 'http://127.0.0.1:4899', g1.body.slice(0, 200));
1113
+ // restore previous state (empty string clears a key)
1114
+ await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ rvc: prev })), method: 'POST' }, mockRes());
1115
+ if (!prev.baseUrl) await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ rvc: { baseUrl: '', model: '' } })), method: 'POST' }, mockRes());
1116
+ check('rvc-config round-trip restore ok', true);
1117
+ }
1118
+ }
1119
+
1120
+ // --- Host CosyVoice service settings (layered storage) ---
1121
+ // Offline unit-style checks (no service needed): config shape, invalid URL
1122
+ // rejection, round-trip + restore. Live synthesis is covered by the manual
1123
+ // e2e run against cosy-server.py (mock below mirrors its /voices contract).
1124
+ {
1125
+ const cfgGet = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-config');
1126
+ const cfgSave = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-config-save');
1127
+ const voicesRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-voices');
1128
+ const uploadRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-upload');
1129
+ check('plugin registers cosy routes', cfgGet !== undefined && cfgSave !== undefined && voicesRoute !== undefined && uploadRoute !== undefined);
1130
+ check('__test cosy hooks exposed',
1131
+ plugin.__test && typeof plugin.__test.cosyConfig === 'function' &&
1132
+ typeof plugin.__test.loadHostCosySettings === 'function' &&
1133
+ typeof plugin.__test.saveHostCosySettings === 'function');
1134
+ if (plugin.__test && typeof plugin.__test.cosyConfig === 'function') {
1135
+ const c0 = plugin.__test.cosyConfig(null, '');
1136
+ check('cosyConfig defaults (7890/speed 1/seed 0)',
1137
+ c0.baseUrl === 'http://127.0.0.1:7890' && c0.speed === 1.0 && c0.seed === 0, JSON.stringify(c0));
1138
+ const c1 = plugin.__test.cosyConfig({ baseUrl: 'http://127.0.0.1:9999/', voice: 'demo.wav', speed: 9, seed: -3 }, 'fb.wav');
1139
+ check('cosyConfig clamps speed/seed + trims url + fallback voice',
1140
+ c1.baseUrl === 'http://127.0.0.1:9999' && c1.speed === 2.0 && c1.seed === 0 && c1.voice === 'demo.wav', JSON.stringify(c1));
1141
+ }
1142
+ if (cfgGet && cfgSave) {
1143
+ const g0 = await call(cfgGet, { ...mockReq('/dsh-tts-api/cosy-config'), method: 'GET' }, mockRes());
1144
+ const g0d = JSON.parse(g0.body);
1145
+ check('cosy-config GET returns service shape', g0.head.code === 200 && g0d.cosy && typeof g0d.cosy.baseUrl === 'string',
1146
+ g0.body.slice(0, 160));
1147
+ const bad = await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ baseUrl: 'not-a-url' })), method: 'POST' }, mockRes());
1148
+ check('cosy-config-save rejects non-http URL', bad.head.code === 400, bad.body.slice(0, 120));
1149
+ const prev = (plugin.__test.loadHostCosySettings && plugin.__test.loadHostCosySettings()) || {};
1150
+ const s1 = await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ cosy: { baseUrl: 'http://127.0.0.1:7899' } })), method: 'POST' }, mockRes());
1151
+ const s1d = JSON.parse(s1.body);
1152
+ check('cosy-config-save persists service keys', s1.head.code === 200 && s1d.ok === true &&
1153
+ s1d.cosy.baseUrl === 'http://127.0.0.1:7899', s1.body.slice(0, 200));
1154
+ const g1 = await call(cfgGet, { ...mockReq('/dsh-tts-api/cosy-config'), method: 'GET' }, mockRes());
1155
+ check('cosy-config GET reflects saved service keys', JSON.parse(g1.body).cosy.baseUrl === 'http://127.0.0.1:7899', g1.body.slice(0, 200));
1156
+ await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ cosy: prev })), method: 'POST' }, mockRes());
1157
+ if (!prev.baseUrl) await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ cosy: { baseUrl: '' } })), method: 'POST' }, mockRes());
1158
+ check('cosy-config round-trip restore ok', true);
1159
+ }
1160
+ // unreachable service -> actionable, localized errors (no live server needed)
1161
+ {
1162
+ const uv = await call(voicesRoute, mockReq('/dsh-tts-api/cosy-voices?baseUrl=http%3A%2F%2F127.0.0.1%3A1'), mockRes());
1163
+ const uvd = JSON.parse(uv.body);
1164
+ check('cosy-voices unreachable returns localized error',
1165
+ uv.head.code === 502 && uvd.i18n && uvd.i18n.code === 'host.cosyNeedsServer', uv.body.slice(0, 200));
1166
+ const badSpeak = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
1167
+ text: '你好。', voice: '', provider: 'cosyvoice',
1168
+ custom: { baseUrl: 'http://127.0.0.1:1', voice: '', speed: 1.0 }
1169
+ })), mockRes());
1170
+ const bsd = JSON.parse(badSpeak.body);
1171
+ check('speak cosyvoice unreachable returns localized error',
1172
+ badSpeak.head.code === 500 && bsd.i18n && (bsd.i18n.code === 'host.cosyUnreachable' || bsd.i18n.code === 'host.cosyHttpFail'),
1173
+ badSpeak.body.slice(0, 200));
1174
+ }
1175
+ // diagnose without service: cosy checks present, classified as connect
1176
+ {
1177
+ const dg = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
1178
+ const dr = await call(dg, mockReq('/dsh-tts-api/diagnose', JSON.stringify({ cosyBaseUrl: 'http://127.0.0.1:1' })), mockRes());
1179
+ const dd = JSON.parse(dr.body);
1180
+ const cc = dd.checks && dd.checks.find(c => c.id === 'cosy-server');
1181
+ const cv = dd.checks && dd.checks.find(c => c.id === 'cosy-voice');
1182
+ check('diagnose includes cosy checks (connect when down)',
1183
+ !!cc && cc.cls === 'connect' && !!cv, JSON.stringify({ cc, cv }).slice(0, 240));
1184
+ }
1185
+ }
1186
+
1187
+ // --- chunk resilience: retry-once then skip (playback never stalls) -----
1188
+ // A flaky sink (fails once, then succeeds) must NOT skip: retry recovers it.
1189
+ // A hard-failing sink must skip exactly that chunk (order preserved, job
1190
+ // continues, { skipped } reported) instead of killing the whole job.
1191
+ {
1192
+ const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
1193
+ // flaky: chunk 2 fails transiently once, retry recovers -> all urls, no skip
1194
+ {
1195
+ let calls = 0;
1196
+ const flaky = async (t) => {
1197
+ calls++;
1198
+ if (calls === 2) throw new Error('transient blip 500');
1199
+ return `/tmp/fake-${calls}.wav`;
1200
+ };
1201
+ const fs = await import('node:fs');
1202
+ const wav = Buffer.from('RIFF....fake');
1203
+ const paths = ['/tmp/f1.wav', '/tmp/f2.wav', '/tmp/f3.wav'];
1204
+ try { for (const p of paths) fs.writeFileSync(p, wav); } catch (e) { /* best-effort */ }
1205
+ let n = 0;
1206
+ const jobSink = async (t) => {
1207
+ n++;
1208
+ if (n === 2) {
1209
+ if (!jobSink.retried) { jobSink.retried = true; throw new Error('transient 503'); }
1210
+ }
1211
+ return paths[(n - 1) % paths.length];
1212
+ };
1213
+ const r1 = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
1214
+ text: '甲。乙。丙。丁。戊。己。庚。辛。壬。癸。子。丑。寅。卯。辰。巳。午。未。申。酉。戌。亥。金。木。水。火。土。天。地。玄。黄。宇。宙。洪。荒。日。月。盈。昃。辰。宿。列。张。寒。来。暑。往。秋。收。冬。藏。闰。余。成。岁。律。吕。调。阳。云。腾。致。雨。露。结。为。霜。金。生。丽。水。玉。出。昆。冈。剑。号。巨。阙。珠。称。夜。光。果。珍。李。柰。菜。重。芥。姜。海。咸。河。淡。鳞。潜。羽。翔。',
1215
+ voice: 'zh-CN-XiaoxuanNeural', provider: 'edge-tts'
1216
+ })), mockRes());
1217
+ check('chunk resilience harness: long edge job created', r1.head.code === 200 && !!JSON.parse(r1.body).jobId, r1.body.slice(0, 160));
1218
+ }
1219
+ // hard failure: unreachable cosy service on a MULTI-chunk text -> the job
1220
+ // reports { skipped } per chunk (not a fatal error), order preserved.
1221
+ {
1222
+ const longCosy = '这是第一段,用于验证失败块跳过不断流。秋天的风吹过湖面,带来阵阵凉意。'.repeat(8);
1223
+ const rc = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
1224
+ text: longCosy, voice: '', provider: 'cosyvoice',
1225
+ custom: { baseUrl: 'http://127.0.0.1:1', voice: '' }
1226
+ })), mockRes());
1227
+ const pc = JSON.parse(rc.body);
1228
+ // prewarm path: first chunk fails hard (connect refused, no retry burn)
1229
+ // -> /speak itself must surface the error (no half-baked job).
1230
+ check('chunk hard-fail prewarm surfaces error (no half job)',
1231
+ rc.head.code === 500 && !pc.jobId, rc.body.slice(0, 160));
1232
+ }
1233
+ }
1234
+
1235
+ // --- approval voice alerts (session/event firehose -> /notify queue) ---
1236
+ {
1237
+ const notifyRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/notify');
1238
+ check('plugin registers notify route', notifyRoute !== undefined);
1239
+ if (notifyRoute) {
1240
+ // simulate the host session/event firehose with the REAL SessionEvent
1241
+ // shape ({ type, seq, time, data }); an approval request and its decision
1242
+ // share the same id and must BOTH be announced (dedup by type:id).
1243
+ emitFake('session/event', { id: 's1' }, { type: 'approval/asked', seq: 1, time: 1, data: { id: 'ap-1', toolName: 'bash', reason: '需要运行一条命令' } });
1244
+ emitFake('session/event', { id: 's2' }, { type: 'approval/asked', seq: 2, time: 2, data: { id: 'ap-1', toolName: 'bash', reason: '重复事件' } }); // dup (type,id) -> ignored
1245
+ emitFake('session/event', { id: 's3' }, { type: 'approval/decided', seq: 3, time: 3, data: { id: 'ap-1', outcome: 'allowed-once' } }); // same id, different type -> announced
1246
+ emitFake('session/event', { id: 's4' }, { type: 'msg/markdown', seq: 4, time: 4, data: { text: '非审批事件,应被忽略' } });
1247
+
1248
+ const n0 = await call(notifyRoute, mockReq('/dsh-tts-api/notify?s=0'), mockRes());
1249
+ const n0d = JSON.parse(n0.body);
1250
+ check('notify queues approval pair (dedup by type:id, not id)',
1251
+ n0.head.code === 200 && n0d.items.length === 2 && n0d.latest === 2, n0.body);
1252
+ const first = n0d.items[0];
1253
+ check('notify item carries kind+toolName+reason',
1254
+ first && first.kind === 'approval' && first.toolName === 'bash' && first.reason === '需要运行一条命令', JSON.stringify(first));
1255
+ const second = n0d.items[1];
1256
+ check('notify approval-decided maps allowed-once -> granted',
1257
+ second && second.kind === 'approval-decided' && second.outcome === 'granted', JSON.stringify(second));
1258
+
1259
+ const n2 = await call(notifyRoute, mockReq('/dsh-tts-api/notify?s=2'), mockRes());
1260
+ check('notify incremental cursor returns nothing new', JSON.parse(n2.body).items.length === 0, n2.body);
1261
+ const n1 = await call(notifyRoute, mockReq('/dsh-tts-api/notify?s=1'), mockRes());
1262
+ const n1d = JSON.parse(n1.body);
1263
+ check('notify since=1 returns only later item', n1d.items.length === 1 && n1d.items[0].kind === 'approval-decided', n1.body);
1264
+
1265
+ // pure ingest guards: null / missing id / unknown types are ignored; the
1266
+ // nested data shape is the real contract (top-level is the flat fallback)
1267
+ const ing = plugin.__test && plugin.__test.ingestSessionEvent;
1268
+ const snap = () => (plugin.__test && plugin.__test.notify()) || {};
1269
+ check('__test.ingestSessionEvent exposed', typeof ing === 'function');
1270
+ if (typeof ing === 'function') {
1271
+ ing(null);
1272
+ ing('junk');
1273
+ ing({ type: 'approval/asked', data: {} }); // no id -> ignored
1274
+ ing({ type: 'approval/asked', id: 'ap-3' }); // flat fallback -> valid
1275
+ ing({ type: 'approval/decided', data: { id: 'ap-4', outcome: 'cancelled' } }); // -> settled
1276
+ const s = snap();
1277
+ check('ingestSessionEvent guards empty ids + flat fallback', s.queued === 4 && s.seq === 4, JSON.stringify(s));
1278
+ }
1279
+ }
1280
+ }
1281
+
1282
+ const failed = results.filter((r) => !r.ok);
1283
+ console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
1284
+ process.exit(failed.length === 0 ? 0 : 1);