@memef1f1y/dsh-plugin-tts 0.6.5 → 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 -34
  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 -3374
  24. package/package.json +51 -51
  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
@@ -1,406 +1,406 @@
1
- // Loads lib/client.js in a mocked browser env, runs apply(), then renders each
2
- // injected component with a minimal React shim to confirm the i18n refactor
3
- // (t() everywhere + language switcher) does not crash at load/render time.
4
- // node tests/client-load.mjs
5
- import { readFileSync } from 'node:fs';
6
- import { fileURLToPath } from 'node:url';
7
- import path from 'node:path';
8
-
9
- const results = [];
10
- function check(name, ok, detail) { results.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
11
-
12
- // ---- minimal React shim (enough for client.js's createElement + hooks) ----
13
- const states = new Map(); // hook memory keyed by componentId
14
- function makeHookCtx() {
15
- let id = Math.random().toString(36).slice(2);
16
- let cursor = 0;
17
- const arr = states.get(id) || { mem: [] };
18
- states.set(id, arr);
19
- function useState(init) {
20
- const i = cursor++;
21
- if (!(i in arr.mem)) arr.mem[i] = typeof init === 'function' ? init() : init;
22
- const set = v => { arr.mem[i] = typeof v === 'function' ? v(arr.mem[i]) : v; };
23
- return [arr.mem[i], set];
24
- }
25
- const useEffect = (fn, deps) => { cursor += 1; return fn; }; // run nothing on demand
26
- const useRef = init => ({ current: init });
27
- const useMemo = (fn) => fn();
28
- const useStateForce = () => useState(0);
29
- return { id, useState, useEffect, useRef, useMemo, reset() { cursor = 0; } };
30
- }
31
-
32
- const react = {
33
- createElement(type, props, ...children) {
34
- // normalize children
35
- if (type === react.Fragment) return { type, props: props || {}, children };
36
- return { type, props: props || {}, children, $$node: true };
37
- },
38
- Fragment: Symbol('Fragment'),
39
- useState: () => { throw new Error('useState outside shim'); },
40
- useEffect: () => {},
41
- useRef: () => ({ current: undefined }),
42
- useMemo: f => f(),
43
- };
44
-
45
- // ---- browser globals ----
46
- const injectedComponents = [];
47
- const slots = {
48
- inject(slot, fn) { injectedComponents.push({ slot, fn }); },
49
- register(spec, component) { return component; }, // real host returns the registered component
50
- };
51
- const ctx = {
52
- get(name) { if (name === 'slots') return slots; return undefined; },
53
- effect() {},
54
- };
55
- try { Object.defineProperty(globalThis, 'navigator', { value: { language: 'zh-CN' }, configurable: true }); }
56
- catch (e) { /* already settable */ }
57
- globalThis.window = {
58
- addEventListener() {}, removeEventListener() {},
59
- AudioContext: function(){}, webkitAudioContext: function(){},
60
- confirm: () => true,
61
- };
62
- globalThis.document = {
63
- createElement: () => ({ style: {}, setAttribute(){}, appendChild(){}, removeChild(){}, pause(){}, removeAttribute(){}, play: () => Promise.resolve(), addEventListener(){}, removeEventListener(){} }),
64
- body: { appendChild() {}, removeChild() {} },
65
- head: { appendChild() {} },
66
- };
67
- globalThis.Audio = function(){ this.play = () => Promise.resolve(); this.pause=()=>{}; this.setAttribute=()=>{}; this.removeAttribute=()=>{}; };
68
- // in-memory localStorage so the i18n persistence (loadPersistedLang/setLang) works
69
- const memStore = new Map();
70
- globalThis.localStorage = {
71
- getItem: k => (memStore.has(k) ? memStore.get(k) : null),
72
- setItem: (k, v) => memStore.set(k, String(v)),
73
- removeItem: k => memStore.delete(k),
74
- };
75
-
76
- // ---- load the bundle ----
77
- let failed = false;
78
- const clientSrc = readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'client.js'), 'utf8');
79
- try {
80
- globalThis.window.__ModuleLoader__ = {
81
- load({ id, factory }) {
82
- // run factory with a require that returns our react shim
83
- const require_ = name => { if (name === 'react') return react; throw new Error('unknown require: ' + name); };
84
- const mod = factory(require_);
85
- if (!mod || !mod.apply) throw new Error('factory did not export apply');
86
- if (!Array.isArray(mod.inject) || !mod.inject.includes('slots'))
87
- throw new Error('factory must export inject including "slots" (fiber inject waiting)');
88
- globalThis.__dshTtsClientMod = mod;
89
- mod.apply(ctx);
90
- },
91
- };
92
- // evaluate client.js in this context
93
- const fn = new Function('window', 'navigator', 'document', 'Audio', clientSrc + '\n;return window.__ModuleLoader__;');
94
- globalThis.__dshTtsClientSrc = clientSrc;
95
- const ml = fn(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
96
- check('client.js loads + apply() runs', injectedComponents.length > 0, `${injectedComponents.length} slot(s) injected`);
97
- check('client bundle exports inject with slots', Array.isArray(globalThis.__dshTtsClientMod?.inject) && globalThis.__dshTtsClientMod.inject.includes('slots'));
98
- // ---- i18n preference persistence (round-trip) ----
99
- try {
100
- const hooks = globalThis.window.__dshTtsI18n;
101
- check('i18n hook exposed for tests', !!hooks && typeof hooks.setLang === 'function');
102
- // switch to English -> must persist
103
- hooks.setLang('en');
104
- check('setLang("en") persists to localStorage', memStore.get('dsh-tts-lang') === 'en',
105
- 'stored=' + memStore.get('dsh-tts-lang'));
106
- check('active locale resolves to en', hooks.current() === 'en', 'current()=' + hooks.current());
107
- // simulate reload by re-invoking the loader factory in a fresh module eval that
108
- // calls loadPersistedLang() from the same localStorage
109
- const src2 = globalThis.__dshTtsClientSrc;
110
- const fn2 = new Function('window', 'navigator', 'document', 'Audio', src2 + '\n;return window.__ModuleLoader__;');
111
- const ml2 = fn2(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
112
- const I18N2 = globalThis.window.__dshTtsI18n;
113
- check('persisted language survives reload (lang=en)', I18N2.lang === 'en', 'lang=' + I18N2.lang);
114
- check('persisted language resolves to en after reload', I18N2.current() === 'en');
115
- // reset to auto for isolation
116
- hooks.setLang('auto');
117
- } catch (e) {
118
- check('i18n preference persistence round-trip', false, String(e && e.message || e));
119
- }
120
-
121
- // ---- settings persistence round-trip (voice/auto-read/provider/rvc) ----
122
- try {
123
- const S1 = globalThis.window.__dshTtsSettings;
124
- check('settings hook exposed for tests', !!S1 && typeof S1.get === 'function' && typeof S1.reset === 'function');
125
- // seed a "user-changed" snapshot into localStorage, then simulate a reload by
126
- // re-running the factory so loadSettings() applies it
127
- memStore.set('dsh-tts-settings', JSON.stringify({
128
- autoRead: true, voice: 'zh-CN-YunyangNeural', provider: 'rvc', rvcAutoFallback: true, rawMarkdown: true,
129
- notify: { enabled: true, approval: false, approvalResult: true, voice: 'zh-CN-YunxiNeural' },
130
- rvc: { baseUrl: 'http://127.0.0.1:9999', model: '/x.pth', indexRate: 0.5 },
131
- }));
132
- const srcS = globalThis.__dshTtsClientSrc;
133
- const fnS = new Function('window', 'navigator', 'document', 'Audio', srcS + '\n;return window.__ModuleLoader__;');
134
- const mlS = fnS(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
135
- const S2 = globalThis.window.__dshTtsSettings;
136
- const s2 = S2.get();
137
- check('settings loaded from localStorage', s2.autoRead === true && s2.voice === 'zh-CN-YunyangNeural' && s2.provider === 'rvc', JSON.stringify(s2));
138
- check('rvcAutoFallback loaded from localStorage', s2.rvcAutoFallback === true, 'rvcAutoFallback=' + s2.rvcAutoFallback);
139
- check('notify settings loaded from localStorage', s2.notify.enabled === true && s2.notify.approval === false && s2.notify.approvalResult === true && s2.notify.voice === 'zh-CN-YunxiNeural', JSON.stringify(s2.notify));
140
- check('rvc settings loaded from localStorage', s2.rvc.baseUrl === 'http://127.0.0.1:9999' && s2.rvc.model === '/x.pth' && s2.rvc.indexRate === 0.5, JSON.stringify(s2.rvc));
141
- check('rawMarkdown loaded from localStorage', s2.rawMarkdown === true, 'rawMarkdown=' + s2.rawMarkdown);
142
- // layered storage: save() must NOT persist service-class keys to localStorage
143
- S2.save();
144
- try {
145
- const persisted = JSON.parse(memStore.get('dsh-tts-settings'));
146
- const hasService = persisted && persisted.rvc &&
147
- ('baseUrl' in persisted.rvc || 'model' in persisted.rvc || 'index' in persisted.rvc);
148
- check('save() strips Host service keys from localStorage', hasService === false, JSON.stringify(persisted.rvc));
149
- check('save() keeps UI prefs in localStorage', persisted.rawMarkdown === true && persisted.rvc.indexRate === 0.5, JSON.stringify(persisted));
150
- } catch (e) {
151
- check('save() strips Host service keys from localStorage', false, String(e && e.message || e));
152
- }
153
- // reset: restore defaults + drop stored settings
154
- S2.reset();
155
- const r = S2.get();
156
- check('reset restores defaults', r.autoRead === false && r.voice === 'zh-CN-XiaoxuanNeural' && r.provider === 'edge-tts' && r.rvcAutoFallback === false, JSON.stringify(r));
157
- check('reset restores notify defaults', r.notify.enabled === false && r.notify.approval === true && r.notify.approvalResult === false && r.notify.voice === 'zh-CN-XiaoxuanNeural', JSON.stringify(r.notify));
158
- check('reset clears stored settings', !memStore.has('dsh-tts-settings'));
159
- // corrupt stored JSON must not crash; falls back to defaults
160
- memStore.set('dsh-tts-settings', '{not json');
161
- const fnC = new Function('window', 'navigator', 'document', 'Audio', srcS + '\n;return window.__ModuleLoader__;');
162
- fnC(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
163
- const S3 = globalThis.window.__dshTtsSettings;
164
- check('corrupt stored settings ignored (defaults)', S3.get().voice === 'zh-CN-XiaoxuanNeural');
165
- memStore.delete('dsh-tts-settings');
166
- } catch (e) {
167
- check('settings persistence round-trip', false, String(e && e.stack || e));
168
- }
169
- } catch (e) {
170
- check('client.js loads + apply() runs', false, String(e && e.stack || e));
171
- failed = true;
172
- }
173
-
174
- // ---- render each injected component with the shim to catch t()/render errors ----
175
- if (!failed) {
176
- for (const { slot, fn } of injectedComponents) {
177
- try {
178
- const Component = fn(); // fn returns a component function
179
- if (typeof Component !== 'function') { check(`render ${slot}`, false, 'slot fn did not return a component'); continue; }
180
- const h = makeHookCtx();
181
- let prev = react;
182
- // temporarily bind react hooks to the shim's hooks
183
- const originalReact = globalThis.__reactShim || react;
184
- // The component was defined against `react` from the factory closure. We can't
185
- // easily intercept that closure's react, but we CAN mock react's hooks via the
186
- // module-level `react` object we passed. Since useState threw by default, patch it:
187
- const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
188
- react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
189
- const node = Component({
190
- useSession: sel => sel({ nodes: [] }),
191
- messageId: 'm1',
192
- });
193
- react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
194
- // `null` is a legitimate conditional render (e.g. the toast host when no
195
- // toast is active); only `undefined`/throw counts as broken.
196
- check(`render ${slot}`, node !== undefined, node === null ? '(conditional null)' : undefined);
197
- } catch (e) {
198
- check(`render ${slot}`, false, String(e && e.stack || e).slice(0, 200));
199
- }
200
- }
201
- }
202
-
203
- // P2-1: auto-read toggle is now a labeled pill (headphones + dot), distinct
204
- // from a mic/speaker — verifies the reworked element structure renders.
205
- {
206
- const comp = injectedComponents.find(c => c.slot === 'conversation.input.left');
207
- if (comp) {
208
- try {
209
- const h = makeHookCtx();
210
- const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
211
- react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
212
- const node = comp.fn()({});
213
- react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
214
- const hasClass = (n, cls) => {
215
- if (!n) return false;
216
- if (n.props && n.props.className === cls) return true;
217
- const ch = n.children;
218
- if (Array.isArray(ch)) { for (const c of ch) { if (hasClass(c, cls)) return true; } }
219
- return false;
220
- };
221
- check('auto-read rendered as labeled pill', hasClass(node, 'dsh-tts-auto-pill') && hasClass(node, 'dsh-tts-auto-label'), undefined);
222
- } catch (e) {
223
- check('auto-read rendered as labeled pill', false, String(e && e.stack || e).slice(0, 200));
224
- }
225
- }
226
- }
227
-
228
- // Toast: showToast() must render a visible toast node in shell.overlay with the
229
- // message; dismissToast() must remove it. (This is the error-surfacing channel
230
- // added for message read-aloud / auto-read failures + RVC fallback notices.)
231
- {
232
- const toastApi = globalThis.window.__dshTtsToast;
233
- if (toastApi && typeof toastApi.show === 'function') {
234
- const hasClass = (n, cls) => {
235
- if (!n) return false;
236
- if (n.props && typeof n.props.className === "string" &&
237
- n.props.className.split(/\s+/).includes(cls)) return true;
238
- const ch = n.children;
239
- if (Array.isArray(ch)) { for (const c of ch) { if (hasClass(c, cls)) return true; } }
240
- return false;
241
- };
242
- const allText = n => {
243
- if (!n) return '';
244
- if (typeof n === 'string' || typeof n === 'number') return String(n);
245
- const ch = n.children;
246
- if (Array.isArray(ch)) return ch.map(allText).join('');
247
- return '';
248
- };
249
- const overlayComps = injectedComponents.filter(c => c.slot === 'shell.overlay');
250
- try {
251
- toastApi.show('语音合成失败:boom', 'error');
252
- // render with the shim hooks
253
- const h = makeHookCtx();
254
- const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
255
- react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
256
- const rendered = overlayComps.map(({ fn }) => fn()({}));
257
- react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
258
- const toastNodes = rendered.filter(n => hasClass(n, 'dsh-tts-toast'));
259
- const withText = toastNodes.filter(n => allText(n).includes('语音合成失败:boom'));
260
- check('toast renders in shell.overlay with message', toastNodes.length >= 1 && withText.length >= 1,
261
- `toastNodes=${toastNodes.length} textFound=${withText.length}`);
262
- // dismiss -> no toast rendered anymore
263
- toastApi.dismiss();
264
- const h2 = makeHookCtx();
265
- const orig2 = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
266
- react.useState = h2.useState; react.useEffect = h2.useEffect; react.useRef = h2.useRef; react.useMemo = h2.useMemo;
267
- const rendered2 = overlayComps.map(({ fn }) => fn()({}));
268
- react.useState = orig2.useState; react.useEffect = orig2.useEffect; react.useRef = orig2.useRef; react.useMemo = orig2.useMemo;
269
- check('toast dismissed removes toast node', rendered2.filter(n => hasClass(n, 'dsh-tts-toast')).length === 0);
270
- } catch (e) {
271
- check('toast renders + dismisses', false, String(e && e.stack || e).slice(0, 200));
272
- }
273
- } else {
274
- check('toast hook exposed (__dshTtsToast)', false, 'hook missing');
275
- }
276
- }
277
-
278
- // Settings tab renders the approval voice-alert module (title visible).
279
- {
280
- const comp = injectedComponents.find(c => c.slot === 'settings.plugins.tab');
281
- if (comp) {
282
- try {
283
- const allText = n => {
284
- if (!n) return '';
285
- if (typeof n === 'string' || typeof n === 'number') return String(n);
286
- const ch = n.children;
287
- if (Array.isArray(ch)) return ch.map(allText).join('');
288
- return '';
289
- };
290
- const h = makeHookCtx();
291
- const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
292
- react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
293
- const node = comp.fn()({ useSession: sel => sel({ nodes: [] }), messageId: 'm1' });
294
- react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
295
- check('settings renders approval voice-alert module', allText(node).includes('事件语音提醒') && allText(node).includes('启用审批语音提醒'), undefined);
296
- check('settings renders export-diagnostic-log button', allText(node).includes('导出诊断日志') && allText(node).includes('运行诊断'), undefined);
297
- // Entry CTAs removed: provider switch is the only entry (no shortcut cards)
298
- check('settings hides removed entry CTAs (edge mode)', !allText(node).includes('需要克隆音色') && !allText(node).includes('Index-TTS2 音色'), undefined);
299
- check('settings shows rawMarkdown toggle', allText(node).includes('朗读原始'), undefined);
300
- check('settings bottom shows bug-report hint', allText(node).includes('导出诊断日志') && allText(node).includes('红色报错') && allText(node).includes('https://github.com/1624318455/dsh-plugin-tts/issues'), undefined);
301
- } catch (e) {
302
- check('settings renders approval voice-alert module', false, String(e && e.stack || e).slice(0, 200));
303
- }
304
- }
305
- }
306
-
307
- // Single-file (short-text) reads must seed chunkProgress/readingIndex so the
308
- // overlay, scroll-follow and click-to-pause activate without the chunked path.
309
- // Regression test for "朗读高度跟随有时不生效 / 点击朗读中按钮无反应".
310
- {
311
- try {
312
- const src = globalThis.__dshTtsClientSrc || '';
313
- // 1) speakText seeds 1/1 progress right after readingSentenceIdx reset
314
- const seedOk = /shared\.readingSentenceIdx\s*=\s*0;[\s\S]{0,1200}?shared\.chunkProgress\s*=\s*\{\s*index:\s*1,\s*total:\s*1\s*\}[\s\S]{0,200}?shared\.readingIndex\s*=\s*1;/.test(src);
315
- check('speakText seeds 1/1 progress for single-file reads', seedOk);
316
- // 2) overlay visible gate accepts total >= 1 (not > 1)
317
- check('overlay visible accepts total >= 1', /cp\s*&&\s*cp\.total\s*>=\s*1/.test(src));
318
- // 3) hidden bar must not intercept clicks: pointer-events:none by default,
319
- // auto only under [data-visible]
320
- const barDefaultNone = /\.dsh-tts-reading-bar\{[^}]*pointer-events:none/.test(src);
321
- const barVisibleAuto = /\.dsh-tts-reading-overlay\[data-visible\][^{]*\.dsh-tts-reading-bar\{[^}]*pointer-events:auto/.test(src);
322
- check('hidden bar does not intercept clicks', barDefaultNone && barVisibleAuto,
323
- `defaultNone=${barDefaultNone} visibleAuto=${barVisibleAuto}`);
324
- // 4) onBarClick never throws on missing event + reads live shared.speaking
325
- const clickOk = /const onBarClick[\s\S]{0,600}?typeof e\.stopPropagation[\s\S]{0,200}?shared\.speaking\) togglePause\(\)/.test(src);
326
- check('onBarClick hardened (no-throw + live speaking)', clickOk);
327
- // 5) scroll-follow v2: block-index stepwise signal, visibility-driven
328
- // minimal scrolling (no polling loop), latch-off with manual resume.
329
- const stepOk = /const ttsBlockIdx = \(\) =>/.test(src) && /ttsBlockHead/.test(src) &&
330
- !/ttsLiveRatio/.test(src) && !/chunkStartedAt \|\| Date\.now\(\)\)\) \/ 1000/.test(src);
331
- const noPoll = !/setInterval\(keepVisible/.test(src) && !/setInterval\(ttsFollowTick/.test(src);
332
- const latchOk = /ttsFollow\.latched = false/.test(src) && /follow\.resume/.test(src) &&
333
- !/ttsUserScrollAt\.t < 5000/.test(src);
334
- check('scroll-follow v2 stepwise signal, no poll, latch-off resume', stepOk && noPoll && latchOk,
335
- `step=${stepOk} noPoll=${noPoll} latch=${latchOk}`);
336
- // 5b) self-scroll fence via timestamp + scrollend (covers smooth-scroll
337
- // duration); takeover sources are wheel/touchmove/pointer-drag only, so
338
- // host autoscroll during streaming never latches follow off.
339
- const selfOk = /ttsFollow\.selfUntil = Date\.now\(\) \+ 600/.test(src) && /scrollend/.test(src);
340
- const takeOk = /onTouchMove/.test(src) && /onPointerMove/.test(src) && /onWheel/.test(src);
341
- check('follow self-fence + gesture takeover sources', selfOk && takeOk,
342
- `self=${selfOk} take=${takeOk}`);
343
- // 5c) message anchor: button click climbs to the message root (WeakRef),
344
- // fingerprint relocate survives re-renders; paragraph hits are never
345
- // cached as anchors (fragile, single-tick use).
346
- const anchorOk = /shared\.readingMsgEl = new WeakRef\(best\)/.test(src) &&
347
- /shared\.readingMsgEl \? shared\.readingMsgEl\.deref\(\) : null/.test(src);
348
- check('follow uses anchored message element', anchorOk);
349
- // 5d) anchor survives re-render (fingerprint relocate) + wrong-element
350
- // guard (never rubber-band on an absurd hit) + nearest-scroll.
351
- const relocOk = /shared\.readingMsgKey = \{ head/.test(src) && /mark\("relocated"\)/.test(src);
352
- const guardOk = /mark\("bad-target"\)/.test(src) && /block:"nearest"/.test(src);
353
- check('anchor relocates + bad target skipped', relocOk && guardOk,
354
- `reloc=${relocOk} guard=${guardOk}`);
355
- // 5e) reduced-motion respected: follow bails before touching the view.
356
- check('follow respects prefers-reduced-motion', /ttsReducedMotion\(\)/.test(src) && /mark\("reduced-motion"\)/.test(src));
357
- // 5f) exact server spans feed the block locator when present (P1-3),
358
- // equal-division fallback otherwise; spans reset per utterance.
359
- const spansOk = /shared\.chunkSpans = Array\.isArray\(result\.spans\)/.test(src) &&
360
- /shared\.chunkSpans = null/.test(src) && /sp\[idx - 1\]\[0\]/.test(src);
361
- check('follow consumes server block spans with fallback', spansOk);
362
- // 6) compact bar: no "n / total" counter in bar text (tooltip only)
363
- const noCounter = !/overlay\.reading"\)\) \+ " " \+ idx/.test(src) && !/\("overlay\.paused"\) : t\("overlay\.reading"\)\) \+/.test(src);
364
- const pausedOk = /shared\.paused \? t\("overlay\.paused"\) : t\("overlay\.reading"\)(?!\) \+)/.test(src);
365
- check('bar compact: paused/reading text only, no counter', noCounter && pausedOk,
366
- `noCounter=${noCounter} pausedOk=${pausedOk}`);
367
- // 7) v2 block highlight restored as the visual anchor for follow: soft
368
- // background, never the old box-shadow style.
369
- const hlCss = /\.dsh-tts-sentence-active\{[^}]*background/.test(src);
370
- const noShadowCss = !/\.dsh-tts-sentence-active\{[^}]*box-shadow/.test(src);
371
- const addActive = /classList\.add\("dsh-tts-sentence-active"\)/.test(src);
372
- check('block highlight restored (soft bg, no shadow)', hlCss && noShadowCss && addActive,
373
- `css=${hlCss} noShadow=${noShadowCss} add=${addActive}`);
374
- // 8) playback mutual exclusion: playSeq generation guards all audio
375
- // callbacks; stop hard-aborts decode via load(); chunk queue is indexed
376
- // (no push-on-resolve reorder).
377
- const seqBump = /shared\.playSeq = \(shared\.playSeq \|\| 0\) \+ 1/.test(src);
378
- const seqGuard = (src.match(/shared\.playSeq === mySeq/g) || []).length >= 4;
379
- const loadAbort = /el\.load\(\);/.test(src);
380
- const indexedQ = /const queue = new Array\(total\)/.test(src) && /queue\[want\] = r\.url/.test(src) && /queue\[cursor\]/.test(src);
381
- const noPush = !/queue\.push\(r\.url\)/.test(src);
382
- check('playback generation guards callbacks (no overlap)', seqBump && seqGuard,
383
- `bump=${seqBump} guards=${(src.match(/shared\.playSeq === mySeq/g) || []).length}`);
384
- check('stop aborts decode via load()', loadAbort);
385
- check('chunk queue indexed by chunk idx (no reorder)', indexedQ && noPush,
386
- `indexed=${indexedQ} noPush=${noPush}`);
387
- // 9) fluency trio: skipped-slot advance, stall watchdog (text-only,
388
- // never touches playbackRate — varispeed would deepen the timbre).
389
- const skipSlot = /queue\[want\] = false/.test(src) && /queue\[cursor\] === false/.test(src);
390
- const stallDog = /shared\.stalling = true/.test(src) && /chunk\.stalling/.test(src)
391
- && !/stallStretch/.test(src) && !/\*\s*0\.85/.test(src);
392
- check('skipped chunks advance without gap', skipSlot);
393
- check('stall watchdog shows synthesizing, rate untouched', stallDog);
394
- // 10) starvation clamp: a chunk decoded late must start at now, never in
395
- // the past (past start plays immediately while bookkeeping lags, so the
396
- // next chunk overlaps the tail — sustained RTF > 1, e.g. local CosyVoice).
397
- const starveClamp = /Math\.max\(nextStart,\s*ctx\.currentTime/.test(src);
398
- check('starved chunk clamps to now (no tail overlap)', starveClamp);
399
- } catch (e) {
400
- check('single-file progress regression checks', false, String(e && e.stack || e).slice(0, 200));
401
- }
402
- }
403
-
404
- const failedCount = results.filter(r => !r.ok).length;
405
- console.log(`\n${results.length - failedCount}/${results.length} client-load checks passed`);
406
- process.exit(failedCount === 0 ? 0 : 1);
1
+ // Loads lib/client.js in a mocked browser env, runs apply(), then renders each
2
+ // injected component with a minimal React shim to confirm the i18n refactor
3
+ // (t() everywhere + language switcher) does not crash at load/render time.
4
+ // node tests/client-load.mjs
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import path from 'node:path';
8
+
9
+ const results = [];
10
+ function check(name, ok, detail) { results.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
11
+
12
+ // ---- minimal React shim (enough for client.js's createElement + hooks) ----
13
+ const states = new Map(); // hook memory keyed by componentId
14
+ function makeHookCtx() {
15
+ let id = Math.random().toString(36).slice(2);
16
+ let cursor = 0;
17
+ const arr = states.get(id) || { mem: [] };
18
+ states.set(id, arr);
19
+ function useState(init) {
20
+ const i = cursor++;
21
+ if (!(i in arr.mem)) arr.mem[i] = typeof init === 'function' ? init() : init;
22
+ const set = v => { arr.mem[i] = typeof v === 'function' ? v(arr.mem[i]) : v; };
23
+ return [arr.mem[i], set];
24
+ }
25
+ const useEffect = (fn, deps) => { cursor += 1; return fn; }; // run nothing on demand
26
+ const useRef = init => ({ current: init });
27
+ const useMemo = (fn) => fn();
28
+ const useStateForce = () => useState(0);
29
+ return { id, useState, useEffect, useRef, useMemo, reset() { cursor = 0; } };
30
+ }
31
+
32
+ const react = {
33
+ createElement(type, props, ...children) {
34
+ // normalize children
35
+ if (type === react.Fragment) return { type, props: props || {}, children };
36
+ return { type, props: props || {}, children, $$node: true };
37
+ },
38
+ Fragment: Symbol('Fragment'),
39
+ useState: () => { throw new Error('useState outside shim'); },
40
+ useEffect: () => {},
41
+ useRef: () => ({ current: undefined }),
42
+ useMemo: f => f(),
43
+ };
44
+
45
+ // ---- browser globals ----
46
+ const injectedComponents = [];
47
+ const slots = {
48
+ inject(slot, fn) { injectedComponents.push({ slot, fn }); },
49
+ register(spec, component) { return component; }, // real host returns the registered component
50
+ };
51
+ const ctx = {
52
+ get(name) { if (name === 'slots') return slots; return undefined; },
53
+ effect() {},
54
+ };
55
+ try { Object.defineProperty(globalThis, 'navigator', { value: { language: 'zh-CN' }, configurable: true }); }
56
+ catch (e) { /* already settable */ }
57
+ globalThis.window = {
58
+ addEventListener() {}, removeEventListener() {},
59
+ AudioContext: function(){}, webkitAudioContext: function(){},
60
+ confirm: () => true,
61
+ };
62
+ globalThis.document = {
63
+ createElement: () => ({ style: {}, setAttribute(){}, appendChild(){}, removeChild(){}, pause(){}, removeAttribute(){}, play: () => Promise.resolve(), addEventListener(){}, removeEventListener(){} }),
64
+ body: { appendChild() {}, removeChild() {} },
65
+ head: { appendChild() {} },
66
+ };
67
+ globalThis.Audio = function(){ this.play = () => Promise.resolve(); this.pause=()=>{}; this.setAttribute=()=>{}; this.removeAttribute=()=>{}; };
68
+ // in-memory localStorage so the i18n persistence (loadPersistedLang/setLang) works
69
+ const memStore = new Map();
70
+ globalThis.localStorage = {
71
+ getItem: k => (memStore.has(k) ? memStore.get(k) : null),
72
+ setItem: (k, v) => memStore.set(k, String(v)),
73
+ removeItem: k => memStore.delete(k),
74
+ };
75
+
76
+ // ---- load the bundle ----
77
+ let failed = false;
78
+ const clientSrc = readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'client.js'), 'utf8');
79
+ try {
80
+ globalThis.window.__ModuleLoader__ = {
81
+ load({ id, factory }) {
82
+ // run factory with a require that returns our react shim
83
+ const require_ = name => { if (name === 'react') return react; throw new Error('unknown require: ' + name); };
84
+ const mod = factory(require_);
85
+ if (!mod || !mod.apply) throw new Error('factory did not export apply');
86
+ if (!Array.isArray(mod.inject) || !mod.inject.includes('slots'))
87
+ throw new Error('factory must export inject including "slots" (fiber inject waiting)');
88
+ globalThis.__dshTtsClientMod = mod;
89
+ mod.apply(ctx);
90
+ },
91
+ };
92
+ // evaluate client.js in this context
93
+ const fn = new Function('window', 'navigator', 'document', 'Audio', clientSrc + '\n;return window.__ModuleLoader__;');
94
+ globalThis.__dshTtsClientSrc = clientSrc;
95
+ const ml = fn(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
96
+ check('client.js loads + apply() runs', injectedComponents.length > 0, `${injectedComponents.length} slot(s) injected`);
97
+ check('client bundle exports inject with slots', Array.isArray(globalThis.__dshTtsClientMod?.inject) && globalThis.__dshTtsClientMod.inject.includes('slots'));
98
+ // ---- i18n preference persistence (round-trip) ----
99
+ try {
100
+ const hooks = globalThis.window.__dshTtsI18n;
101
+ check('i18n hook exposed for tests', !!hooks && typeof hooks.setLang === 'function');
102
+ // switch to English -> must persist
103
+ hooks.setLang('en');
104
+ check('setLang("en") persists to localStorage', memStore.get('dsh-tts-lang') === 'en',
105
+ 'stored=' + memStore.get('dsh-tts-lang'));
106
+ check('active locale resolves to en', hooks.current() === 'en', 'current()=' + hooks.current());
107
+ // simulate reload by re-invoking the loader factory in a fresh module eval that
108
+ // calls loadPersistedLang() from the same localStorage
109
+ const src2 = globalThis.__dshTtsClientSrc;
110
+ const fn2 = new Function('window', 'navigator', 'document', 'Audio', src2 + '\n;return window.__ModuleLoader__;');
111
+ const ml2 = fn2(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
112
+ const I18N2 = globalThis.window.__dshTtsI18n;
113
+ check('persisted language survives reload (lang=en)', I18N2.lang === 'en', 'lang=' + I18N2.lang);
114
+ check('persisted language resolves to en after reload', I18N2.current() === 'en');
115
+ // reset to auto for isolation
116
+ hooks.setLang('auto');
117
+ } catch (e) {
118
+ check('i18n preference persistence round-trip', false, String(e && e.message || e));
119
+ }
120
+
121
+ // ---- settings persistence round-trip (voice/auto-read/provider/rvc) ----
122
+ try {
123
+ const S1 = globalThis.window.__dshTtsSettings;
124
+ check('settings hook exposed for tests', !!S1 && typeof S1.get === 'function' && typeof S1.reset === 'function');
125
+ // seed a "user-changed" snapshot into localStorage, then simulate a reload by
126
+ // re-running the factory so loadSettings() applies it
127
+ memStore.set('dsh-tts-settings', JSON.stringify({
128
+ autoRead: true, voice: 'zh-CN-YunyangNeural', provider: 'rvc', rvcAutoFallback: true, rawMarkdown: true,
129
+ notify: { enabled: true, approval: false, approvalResult: true, voice: 'zh-CN-YunxiNeural' },
130
+ rvc: { baseUrl: 'http://127.0.0.1:9999', model: '/x.pth', indexRate: 0.5 },
131
+ }));
132
+ const srcS = globalThis.__dshTtsClientSrc;
133
+ const fnS = new Function('window', 'navigator', 'document', 'Audio', srcS + '\n;return window.__ModuleLoader__;');
134
+ const mlS = fnS(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
135
+ const S2 = globalThis.window.__dshTtsSettings;
136
+ const s2 = S2.get();
137
+ check('settings loaded from localStorage', s2.autoRead === true && s2.voice === 'zh-CN-YunyangNeural' && s2.provider === 'rvc', JSON.stringify(s2));
138
+ check('rvcAutoFallback loaded from localStorage', s2.rvcAutoFallback === true, 'rvcAutoFallback=' + s2.rvcAutoFallback);
139
+ check('notify settings loaded from localStorage', s2.notify.enabled === true && s2.notify.approval === false && s2.notify.approvalResult === true && s2.notify.voice === 'zh-CN-YunxiNeural', JSON.stringify(s2.notify));
140
+ check('rvc settings loaded from localStorage', s2.rvc.baseUrl === 'http://127.0.0.1:9999' && s2.rvc.model === '/x.pth' && s2.rvc.indexRate === 0.5, JSON.stringify(s2.rvc));
141
+ check('rawMarkdown loaded from localStorage', s2.rawMarkdown === true, 'rawMarkdown=' + s2.rawMarkdown);
142
+ // layered storage: save() must NOT persist service-class keys to localStorage
143
+ S2.save();
144
+ try {
145
+ const persisted = JSON.parse(memStore.get('dsh-tts-settings'));
146
+ const hasService = persisted && persisted.rvc &&
147
+ ('baseUrl' in persisted.rvc || 'model' in persisted.rvc || 'index' in persisted.rvc);
148
+ check('save() strips Host service keys from localStorage', hasService === false, JSON.stringify(persisted.rvc));
149
+ check('save() keeps UI prefs in localStorage', persisted.rawMarkdown === true && persisted.rvc.indexRate === 0.5, JSON.stringify(persisted));
150
+ } catch (e) {
151
+ check('save() strips Host service keys from localStorage', false, String(e && e.message || e));
152
+ }
153
+ // reset: restore defaults + drop stored settings
154
+ S2.reset();
155
+ const r = S2.get();
156
+ check('reset restores defaults', r.autoRead === false && r.voice === 'zh-CN-XiaoxuanNeural' && r.provider === 'edge-tts' && r.rvcAutoFallback === false, JSON.stringify(r));
157
+ check('reset restores notify defaults', r.notify.enabled === false && r.notify.approval === true && r.notify.approvalResult === false && r.notify.voice === 'zh-CN-XiaoxuanNeural', JSON.stringify(r.notify));
158
+ check('reset clears stored settings', !memStore.has('dsh-tts-settings'));
159
+ // corrupt stored JSON must not crash; falls back to defaults
160
+ memStore.set('dsh-tts-settings', '{not json');
161
+ const fnC = new Function('window', 'navigator', 'document', 'Audio', srcS + '\n;return window.__ModuleLoader__;');
162
+ fnC(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
163
+ const S3 = globalThis.window.__dshTtsSettings;
164
+ check('corrupt stored settings ignored (defaults)', S3.get().voice === 'zh-CN-XiaoxuanNeural');
165
+ memStore.delete('dsh-tts-settings');
166
+ } catch (e) {
167
+ check('settings persistence round-trip', false, String(e && e.stack || e));
168
+ }
169
+ } catch (e) {
170
+ check('client.js loads + apply() runs', false, String(e && e.stack || e));
171
+ failed = true;
172
+ }
173
+
174
+ // ---- render each injected component with the shim to catch t()/render errors ----
175
+ if (!failed) {
176
+ for (const { slot, fn } of injectedComponents) {
177
+ try {
178
+ const Component = fn(); // fn returns a component function
179
+ if (typeof Component !== 'function') { check(`render ${slot}`, false, 'slot fn did not return a component'); continue; }
180
+ const h = makeHookCtx();
181
+ let prev = react;
182
+ // temporarily bind react hooks to the shim's hooks
183
+ const originalReact = globalThis.__reactShim || react;
184
+ // The component was defined against `react` from the factory closure. We can't
185
+ // easily intercept that closure's react, but we CAN mock react's hooks via the
186
+ // module-level `react` object we passed. Since useState threw by default, patch it:
187
+ const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
188
+ react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
189
+ const node = Component({
190
+ useSession: sel => sel({ nodes: [] }),
191
+ messageId: 'm1',
192
+ });
193
+ react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
194
+ // `null` is a legitimate conditional render (e.g. the toast host when no
195
+ // toast is active); only `undefined`/throw counts as broken.
196
+ check(`render ${slot}`, node !== undefined, node === null ? '(conditional null)' : undefined);
197
+ } catch (e) {
198
+ check(`render ${slot}`, false, String(e && e.stack || e).slice(0, 200));
199
+ }
200
+ }
201
+ }
202
+
203
+ // P2-1: auto-read toggle is now a labeled pill (headphones + dot), distinct
204
+ // from a mic/speaker — verifies the reworked element structure renders.
205
+ {
206
+ const comp = injectedComponents.find(c => c.slot === 'conversation.input.left');
207
+ if (comp) {
208
+ try {
209
+ const h = makeHookCtx();
210
+ const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
211
+ react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
212
+ const node = comp.fn()({});
213
+ react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
214
+ const hasClass = (n, cls) => {
215
+ if (!n) return false;
216
+ if (n.props && n.props.className === cls) return true;
217
+ const ch = n.children;
218
+ if (Array.isArray(ch)) { for (const c of ch) { if (hasClass(c, cls)) return true; } }
219
+ return false;
220
+ };
221
+ check('auto-read rendered as labeled pill', hasClass(node, 'dsh-tts-auto-pill') && hasClass(node, 'dsh-tts-auto-label'), undefined);
222
+ } catch (e) {
223
+ check('auto-read rendered as labeled pill', false, String(e && e.stack || e).slice(0, 200));
224
+ }
225
+ }
226
+ }
227
+
228
+ // Toast: showToast() must render a visible toast node in shell.overlay with the
229
+ // message; dismissToast() must remove it. (This is the error-surfacing channel
230
+ // added for message read-aloud / auto-read failures + RVC fallback notices.)
231
+ {
232
+ const toastApi = globalThis.window.__dshTtsToast;
233
+ if (toastApi && typeof toastApi.show === 'function') {
234
+ const hasClass = (n, cls) => {
235
+ if (!n) return false;
236
+ if (n.props && typeof n.props.className === "string" &&
237
+ n.props.className.split(/\s+/).includes(cls)) return true;
238
+ const ch = n.children;
239
+ if (Array.isArray(ch)) { for (const c of ch) { if (hasClass(c, cls)) return true; } }
240
+ return false;
241
+ };
242
+ const allText = n => {
243
+ if (!n) return '';
244
+ if (typeof n === 'string' || typeof n === 'number') return String(n);
245
+ const ch = n.children;
246
+ if (Array.isArray(ch)) return ch.map(allText).join('');
247
+ return '';
248
+ };
249
+ const overlayComps = injectedComponents.filter(c => c.slot === 'shell.overlay');
250
+ try {
251
+ toastApi.show('语音合成失败:boom', 'error');
252
+ // render with the shim hooks
253
+ const h = makeHookCtx();
254
+ const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
255
+ react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
256
+ const rendered = overlayComps.map(({ fn }) => fn()({}));
257
+ react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
258
+ const toastNodes = rendered.filter(n => hasClass(n, 'dsh-tts-toast'));
259
+ const withText = toastNodes.filter(n => allText(n).includes('语音合成失败:boom'));
260
+ check('toast renders in shell.overlay with message', toastNodes.length >= 1 && withText.length >= 1,
261
+ `toastNodes=${toastNodes.length} textFound=${withText.length}`);
262
+ // dismiss -> no toast rendered anymore
263
+ toastApi.dismiss();
264
+ const h2 = makeHookCtx();
265
+ const orig2 = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
266
+ react.useState = h2.useState; react.useEffect = h2.useEffect; react.useRef = h2.useRef; react.useMemo = h2.useMemo;
267
+ const rendered2 = overlayComps.map(({ fn }) => fn()({}));
268
+ react.useState = orig2.useState; react.useEffect = orig2.useEffect; react.useRef = orig2.useRef; react.useMemo = orig2.useMemo;
269
+ check('toast dismissed removes toast node', rendered2.filter(n => hasClass(n, 'dsh-tts-toast')).length === 0);
270
+ } catch (e) {
271
+ check('toast renders + dismisses', false, String(e && e.stack || e).slice(0, 200));
272
+ }
273
+ } else {
274
+ check('toast hook exposed (__dshTtsToast)', false, 'hook missing');
275
+ }
276
+ }
277
+
278
+ // Settings tab renders the approval voice-alert module (title visible).
279
+ {
280
+ const comp = injectedComponents.find(c => c.slot === 'settings.plugins.tab');
281
+ if (comp) {
282
+ try {
283
+ const allText = n => {
284
+ if (!n) return '';
285
+ if (typeof n === 'string' || typeof n === 'number') return String(n);
286
+ const ch = n.children;
287
+ if (Array.isArray(ch)) return ch.map(allText).join('');
288
+ return '';
289
+ };
290
+ const h = makeHookCtx();
291
+ const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
292
+ react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
293
+ const node = comp.fn()({ useSession: sel => sel({ nodes: [] }), messageId: 'm1' });
294
+ react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
295
+ check('settings renders approval voice-alert module', allText(node).includes('事件语音提醒') && allText(node).includes('启用审批语音提醒'), undefined);
296
+ check('settings renders export-diagnostic-log button', allText(node).includes('导出诊断日志') && allText(node).includes('运行诊断'), undefined);
297
+ // Entry CTAs removed: provider switch is the only entry (no shortcut cards)
298
+ check('settings hides removed entry CTAs (edge mode)', !allText(node).includes('需要克隆音色') && !allText(node).includes('Index-TTS2 音色'), undefined);
299
+ check('settings shows rawMarkdown toggle', allText(node).includes('朗读原始'), undefined);
300
+ check('settings bottom shows bug-report hint', allText(node).includes('导出诊断日志') && allText(node).includes('红色报错') && allText(node).includes('https://github.com/1624318455/dsh-plugin-tts/issues'), undefined);
301
+ } catch (e) {
302
+ check('settings renders approval voice-alert module', false, String(e && e.stack || e).slice(0, 200));
303
+ }
304
+ }
305
+ }
306
+
307
+ // Single-file (short-text) reads must seed chunkProgress/readingIndex so the
308
+ // overlay, scroll-follow and click-to-pause activate without the chunked path.
309
+ // Regression test for "朗读高度跟随有时不生效 / 点击朗读中按钮无反应".
310
+ {
311
+ try {
312
+ const src = globalThis.__dshTtsClientSrc || '';
313
+ // 1) speakText seeds 1/1 progress right after readingSentenceIdx reset
314
+ const seedOk = /shared\.readingSentenceIdx\s*=\s*0;[\s\S]{0,1200}?shared\.chunkProgress\s*=\s*\{\s*index:\s*1,\s*total:\s*1\s*\}[\s\S]{0,200}?shared\.readingIndex\s*=\s*1;/.test(src);
315
+ check('speakText seeds 1/1 progress for single-file reads', seedOk);
316
+ // 2) overlay visible gate accepts total >= 1 (not > 1)
317
+ check('overlay visible accepts total >= 1', /cp\s*&&\s*cp\.total\s*>=\s*1/.test(src));
318
+ // 3) hidden bar must not intercept clicks: pointer-events:none by default,
319
+ // auto only under [data-visible]
320
+ const barDefaultNone = /\.dsh-tts-reading-bar\{[^}]*pointer-events:none/.test(src);
321
+ const barVisibleAuto = /\.dsh-tts-reading-overlay\[data-visible\][^{]*\.dsh-tts-reading-bar\{[^}]*pointer-events:auto/.test(src);
322
+ check('hidden bar does not intercept clicks', barDefaultNone && barVisibleAuto,
323
+ `defaultNone=${barDefaultNone} visibleAuto=${barVisibleAuto}`);
324
+ // 4) onBarClick never throws on missing event + reads live shared.speaking
325
+ const clickOk = /const onBarClick[\s\S]{0,600}?typeof e\.stopPropagation[\s\S]{0,200}?shared\.speaking\) togglePause\(\)/.test(src);
326
+ check('onBarClick hardened (no-throw + live speaking)', clickOk);
327
+ // 5) scroll-follow v2: block-index stepwise signal, visibility-driven
328
+ // minimal scrolling (no polling loop), latch-off with manual resume.
329
+ const stepOk = /const ttsBlockIdx = \(\) =>/.test(src) && /ttsBlockHead/.test(src) &&
330
+ !/ttsLiveRatio/.test(src) && !/chunkStartedAt \|\| Date\.now\(\)\)\) \/ 1000/.test(src);
331
+ const noPoll = !/setInterval\(keepVisible/.test(src) && !/setInterval\(ttsFollowTick/.test(src);
332
+ const latchOk = /ttsFollow\.latched = false/.test(src) && /follow\.resume/.test(src) &&
333
+ !/ttsUserScrollAt\.t < 5000/.test(src);
334
+ check('scroll-follow v2 stepwise signal, no poll, latch-off resume', stepOk && noPoll && latchOk,
335
+ `step=${stepOk} noPoll=${noPoll} latch=${latchOk}`);
336
+ // 5b) self-scroll fence via timestamp + scrollend (covers smooth-scroll
337
+ // duration); takeover sources are wheel/touchmove/pointer-drag only, so
338
+ // host autoscroll during streaming never latches follow off.
339
+ const selfOk = /ttsFollow\.selfUntil = Date\.now\(\) \+ 600/.test(src) && /scrollend/.test(src);
340
+ const takeOk = /onTouchMove/.test(src) && /onPointerMove/.test(src) && /onWheel/.test(src);
341
+ check('follow self-fence + gesture takeover sources', selfOk && takeOk,
342
+ `self=${selfOk} take=${takeOk}`);
343
+ // 5c) message anchor: button click climbs to the message root (WeakRef),
344
+ // fingerprint relocate survives re-renders; paragraph hits are never
345
+ // cached as anchors (fragile, single-tick use).
346
+ const anchorOk = /shared\.readingMsgEl = new WeakRef\(best\)/.test(src) &&
347
+ /shared\.readingMsgEl \? shared\.readingMsgEl\.deref\(\) : null/.test(src);
348
+ check('follow uses anchored message element', anchorOk);
349
+ // 5d) anchor survives re-render (fingerprint relocate) + wrong-element
350
+ // guard (never rubber-band on an absurd hit) + nearest-scroll.
351
+ const relocOk = /shared\.readingMsgKey = \{ head/.test(src) && /mark\("relocated"\)/.test(src);
352
+ const guardOk = /mark\("bad-target"\)/.test(src) && /block:"nearest"/.test(src);
353
+ check('anchor relocates + bad target skipped', relocOk && guardOk,
354
+ `reloc=${relocOk} guard=${guardOk}`);
355
+ // 5e) reduced-motion respected: follow bails before touching the view.
356
+ check('follow respects prefers-reduced-motion', /ttsReducedMotion\(\)/.test(src) && /mark\("reduced-motion"\)/.test(src));
357
+ // 5f) exact server spans feed the block locator when present (P1-3),
358
+ // equal-division fallback otherwise; spans reset per utterance.
359
+ const spansOk = /shared\.chunkSpans = Array\.isArray\(result\.spans\)/.test(src) &&
360
+ /shared\.chunkSpans = null/.test(src) && /sp\[idx - 1\]\[0\]/.test(src);
361
+ check('follow consumes server block spans with fallback', spansOk);
362
+ // 6) compact bar: no "n / total" counter in bar text (tooltip only)
363
+ const noCounter = !/overlay\.reading"\)\) \+ " " \+ idx/.test(src) && !/\("overlay\.paused"\) : t\("overlay\.reading"\)\) \+/.test(src);
364
+ const pausedOk = /shared\.paused \? t\("overlay\.paused"\) : t\("overlay\.reading"\)(?!\) \+)/.test(src);
365
+ check('bar compact: paused/reading text only, no counter', noCounter && pausedOk,
366
+ `noCounter=${noCounter} pausedOk=${pausedOk}`);
367
+ // 7) v2 block highlight restored as the visual anchor for follow: soft
368
+ // background, never the old box-shadow style.
369
+ const hlCss = /\.dsh-tts-sentence-active\{[^}]*background/.test(src);
370
+ const noShadowCss = !/\.dsh-tts-sentence-active\{[^}]*box-shadow/.test(src);
371
+ const addActive = /classList\.add\("dsh-tts-sentence-active"\)/.test(src);
372
+ check('block highlight restored (soft bg, no shadow)', hlCss && noShadowCss && addActive,
373
+ `css=${hlCss} noShadow=${noShadowCss} add=${addActive}`);
374
+ // 8) playback mutual exclusion: playSeq generation guards all audio
375
+ // callbacks; stop hard-aborts decode via load(); chunk queue is indexed
376
+ // (no push-on-resolve reorder).
377
+ const seqBump = /shared\.playSeq = \(shared\.playSeq \|\| 0\) \+ 1/.test(src);
378
+ const seqGuard = (src.match(/shared\.playSeq === mySeq/g) || []).length >= 4;
379
+ const loadAbort = /el\.load\(\);/.test(src);
380
+ const indexedQ = /const queue = new Array\(total\)/.test(src) && /queue\[want\] = r\.url/.test(src) && /queue\[cursor\]/.test(src);
381
+ const noPush = !/queue\.push\(r\.url\)/.test(src);
382
+ check('playback generation guards callbacks (no overlap)', seqBump && seqGuard,
383
+ `bump=${seqBump} guards=${(src.match(/shared\.playSeq === mySeq/g) || []).length}`);
384
+ check('stop aborts decode via load()', loadAbort);
385
+ check('chunk queue indexed by chunk idx (no reorder)', indexedQ && noPush,
386
+ `indexed=${indexedQ} noPush=${noPush}`);
387
+ // 9) fluency trio: skipped-slot advance, stall watchdog (text-only,
388
+ // never touches playbackRate — varispeed would deepen the timbre).
389
+ const skipSlot = /queue\[want\] = false/.test(src) && /queue\[cursor\] === false/.test(src);
390
+ const stallDog = /shared\.stalling = true/.test(src) && /chunk\.stalling/.test(src)
391
+ && !/stallStretch/.test(src) && !/\*\s*0\.85/.test(src);
392
+ check('skipped chunks advance without gap', skipSlot);
393
+ check('stall watchdog shows synthesizing, rate untouched', stallDog);
394
+ // 10) starvation clamp: a chunk decoded late must start at now, never in
395
+ // the past (past start plays immediately while bookkeeping lags, so the
396
+ // next chunk overlaps the tail — sustained RTF > 1, e.g. local CosyVoice).
397
+ const starveClamp = /Math\.max\(nextStart,\s*ctx\.currentTime/.test(src);
398
+ check('starved chunk clamps to now (no tail overlap)', starveClamp);
399
+ } catch (e) {
400
+ check('single-file progress regression checks', false, String(e && e.stack || e).slice(0, 200));
401
+ }
402
+ }
403
+
404
+ const failedCount = results.filter(r => !r.ok).length;
405
+ console.log(`\n${results.length - failedCount}/${results.length} client-load checks passed`);
406
+ process.exit(failedCount === 0 ? 0 : 1);