dsh-tui-theme 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/docs/decisions/2026-09-26-settings-generation-adaptation.md +61 -0
- package/lib/types/autoTheme.d.ts +3 -0
- package/lib/types/autoTheme.d.ts.map +1 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +70 -39
- package/lib/types/liveConfig.d.ts +88 -0
- package/lib/types/liveConfig.d.ts.map +1 -0
- package/lib/types/liveConfig.js +151 -0
- package/lib/types/settingsSection.d.ts +73 -15
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/settingsSection.js +214 -52
- package/lib/types/statusLine.d.ts.map +1 -1
- package/lib/types/statusLine.js +17 -13
- package/lib/types/themeAssets.d.ts +4 -0
- package/lib/types/themeAssets.d.ts.map +1 -1
- package/lib/types/themeAssets.js +8 -3
- package/package.json +5 -4
- package/scripts/runtime-themes-headless.mjs +75 -0
- package/scripts/validate-themes-against-host.mjs +7 -0
- package/scripts/verify-package.mjs +13 -7
- package/scripts/verify-settings-generation.mjs +306 -0
- package/scripts/verify.mjs +1573 -1264
package/scripts/verify.mjs
CHANGED
|
@@ -1,1264 +1,1573 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hermetic verification for dsh-tui-theme (no TTY, no real HOME).
|
|
3
|
-
*
|
|
4
|
-
* Points HOME/USERPROFILE at a throwaway sandbox BEFORE importing anything
|
|
5
|
-
* (the same technique the host's scripts/verify-themes.mjs uses), then:
|
|
6
|
-
*
|
|
7
|
-
* 1. Applies the plugin against a stub context with every seam absent —
|
|
8
|
-
* must be a silent no-op (the #183 discipline).
|
|
9
|
-
* 2. Applies it with all seams faked — asserts themes land in the SANDBOX
|
|
10
|
-
* ~/.dsh-tui/themes/, the status line renders (glyph · clock · turns),
|
|
11
|
-
* settings edits land live, and the /settings section is declared.
|
|
12
|
-
* 3. Re-runs installation — must skip, never overwrite, existing files
|
|
13
|
-
* (including a user-edited same-named file).
|
|
14
|
-
* 4. Applies with autoInstallThemes: false on a clean sandbox — must not
|
|
15
|
-
* create the themes directory.
|
|
16
|
-
*
|
|
17
|
-
* Run with: npm run verify (after npm run build)
|
|
18
|
-
*/
|
|
19
|
-
import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
|
|
20
|
-
import { tmpdir } from 'node:os'
|
|
21
|
-
import { join } from 'node:path'
|
|
22
|
-
import { fileURLToPath } from 'node:url'
|
|
23
|
-
import { createRequire, syncBuiltinESMExports } from 'node:module'
|
|
24
|
-
import assert from 'node:assert/strict'
|
|
25
|
-
import { assertSettingsContract } from './expected-settings-contract.mjs'
|
|
26
|
-
|
|
27
|
-
const sandboxHome = mkdtempSync(join(tmpdir(), 'pink-theme-verify-'))
|
|
28
|
-
const originalThemeOverride = process.env.DSH_TUI_THEME
|
|
29
|
-
const restoreThemeOverride = () => {
|
|
30
|
-
if (originalThemeOverride === undefined) {
|
|
31
|
-
delete process.env.DSH_TUI_THEME
|
|
32
|
-
} else {
|
|
33
|
-
process.env.DSH_TUI_THEME = originalThemeOverride
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
delete process.env.DSH_TUI_THEME
|
|
37
|
-
process.once('exit', restoreThemeOverride)
|
|
38
|
-
process.env.USERPROFILE = sandboxHome
|
|
39
|
-
process.env.HOME = sandboxHome
|
|
40
|
-
|
|
41
|
-
const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
42
|
-
const require = createRequire(import.meta.url)
|
|
43
|
-
const builtinFs = require('node:fs')
|
|
44
|
-
const sandboxThemes = join(sandboxHome, '.dsh-tui', 'themes')
|
|
45
|
-
|
|
46
|
-
const { name, apply } = await import('../lib/types/index.js')
|
|
47
|
-
const {
|
|
48
|
-
const {
|
|
49
|
-
const {
|
|
50
|
-
const {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
let
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
{
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
{
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
assert.
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
assert
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
assert.
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
for (const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
assert.equal(
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
assert.equal(
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
)
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
})
|
|
635
|
-
|
|
636
|
-
assert.equal(
|
|
637
|
-
assert.equal(
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
)
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
{
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
assert.equal(
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
//
|
|
722
|
-
{
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
assert.
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
})
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
//
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
assert.
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
assert.
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
assert.
|
|
856
|
-
assert.equal(
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
{
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
//
|
|
901
|
-
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
{
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
assert.equal(
|
|
1014
|
-
|
|
1015
|
-
assert.equal(
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
assert.
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
//
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
assert.
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
assert.equal(
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
//
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
)
|
|
1128
|
-
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
{
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
assert.equal(
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
)
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
)
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
})
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Hermetic verification for dsh-tui-theme (no TTY, no real HOME).
|
|
3
|
+
*
|
|
4
|
+
* Points HOME/USERPROFILE at a throwaway sandbox BEFORE importing anything
|
|
5
|
+
* (the same technique the host's scripts/verify-themes.mjs uses), then:
|
|
6
|
+
*
|
|
7
|
+
* 1. Applies the plugin against a stub context with every seam absent —
|
|
8
|
+
* must be a silent no-op (the #183 discipline).
|
|
9
|
+
* 2. Applies it with all seams faked — asserts themes land in the SANDBOX
|
|
10
|
+
* ~/.dsh-tui/themes/, the status line renders (glyph · clock · turns),
|
|
11
|
+
* settings edits land live, and the /settings section is declared.
|
|
12
|
+
* 3. Re-runs installation — must skip, never overwrite, existing files
|
|
13
|
+
* (including a user-edited same-named file).
|
|
14
|
+
* 4. Applies with autoInstallThemes: false on a clean sandbox — must not
|
|
15
|
+
* create the themes directory.
|
|
16
|
+
*
|
|
17
|
+
* Run with: npm run verify (after npm run build)
|
|
18
|
+
*/
|
|
19
|
+
import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
import { createRequire, syncBuiltinESMExports } from 'node:module'
|
|
24
|
+
import assert from 'node:assert/strict'
|
|
25
|
+
import { assertSettingsContract, SETTINGS_FIELDS } from './expected-settings-contract.mjs'
|
|
26
|
+
|
|
27
|
+
const sandboxHome = mkdtempSync(join(tmpdir(), 'pink-theme-verify-'))
|
|
28
|
+
const originalThemeOverride = process.env.DSH_TUI_THEME
|
|
29
|
+
const restoreThemeOverride = () => {
|
|
30
|
+
if (originalThemeOverride === undefined) {
|
|
31
|
+
delete process.env.DSH_TUI_THEME
|
|
32
|
+
} else {
|
|
33
|
+
process.env.DSH_TUI_THEME = originalThemeOverride
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
delete process.env.DSH_TUI_THEME
|
|
37
|
+
process.once('exit', restoreThemeOverride)
|
|
38
|
+
process.env.USERPROFILE = sandboxHome
|
|
39
|
+
process.env.HOME = sandboxHome
|
|
40
|
+
|
|
41
|
+
const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
42
|
+
const require = createRequire(import.meta.url)
|
|
43
|
+
const builtinFs = require('node:fs')
|
|
44
|
+
const sandboxThemes = join(sandboxHome, '.dsh-tui', 'themes')
|
|
45
|
+
|
|
46
|
+
const { name, apply, Config } = await import('../lib/types/index.js')
|
|
47
|
+
const { LIVE_CONFIG_KEYS, hasLiveConfigFields, readConfigValues } = await import('../lib/types/liveConfig.js')
|
|
48
|
+
const { resolveSettingsNamespace, SETTINGS_NS } = await import('../lib/types/settingsSection.js')
|
|
49
|
+
const { installBundledThemes, readBundledThemes, findShadowedBundledThemes } = await import('../lib/types/themeAssets.js')
|
|
50
|
+
const { startStatusLine, invalidateThemePrefCacheForTests } = await import('../lib/types/statusLine.js')
|
|
51
|
+
const { setToastRetryDelaysForTests } = await import('../lib/types/toast.js')
|
|
52
|
+
const {
|
|
53
|
+
themeForBackground,
|
|
54
|
+
readThemePref,
|
|
55
|
+
writeThemePref,
|
|
56
|
+
readFollowCache,
|
|
57
|
+
applyCachedFollow,
|
|
58
|
+
runFollowSystem,
|
|
59
|
+
} = await import('../lib/types/autoTheme.js')
|
|
60
|
+
|
|
61
|
+
assert.equal(name, 'dsh-tui-theme')
|
|
62
|
+
const settle = () => new Promise(resolve => setTimeout(resolve, 0))
|
|
63
|
+
const applyAndSettle = async (ctx, config) => {
|
|
64
|
+
apply(ctx, config)
|
|
65
|
+
await settle()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A stub Cordis-like context; every seam optional and recorded. */
|
|
69
|
+
function makeStubCtx({ status, sections, settingsService, themes, toast, dialogs, entryId, deferThemes = false, deferToast = false, deferDialogs = false } = {}) {
|
|
70
|
+
const record = { handlers: new Map(), disposers: [], statusCalls: [], sectionsCalls: [], registerCalls: [], watchers: [], themeRegisters: [], warnings: [], infos: [], children: {}, injectRequests: new Map() }
|
|
71
|
+
let availableThemes = themes
|
|
72
|
+
let availableToast = toast
|
|
73
|
+
let availableDialogs = dialogs
|
|
74
|
+
const deferredThemeCallbacks = []
|
|
75
|
+
const deferredToastCallbacks = []
|
|
76
|
+
const deferredDialogsCallbacks = []
|
|
77
|
+
const logger = { info: msg => record.infos.push(String(msg)), warn: msg => record.warnings.push(String(msg)), error: () => {} }
|
|
78
|
+
const serviceFor = dep => ({
|
|
79
|
+
settings: settingsService,
|
|
80
|
+
tuiStatus: status,
|
|
81
|
+
tuiSettingsSections: sections,
|
|
82
|
+
tuiThemes: availableThemes,
|
|
83
|
+
tuiToast: availableToast,
|
|
84
|
+
tuiDialogs: availableDialogs,
|
|
85
|
+
})[dep]
|
|
86
|
+
const base = {
|
|
87
|
+
logger,
|
|
88
|
+
// The Loader entry the host reports for this row: the ≥0.1.7 namespace
|
|
89
|
+
// and the owner of the Config the page policy must attach to.
|
|
90
|
+
fiber: entryId === undefined ? undefined : { entry: { options: { id: entryId } } },
|
|
91
|
+
get(serviceName) {
|
|
92
|
+
if (serviceName === 'tuiStatus') return status
|
|
93
|
+
if (serviceName === 'tuiSettingsSections') return sections
|
|
94
|
+
if (serviceName === 'tuiThemes') return availableThemes
|
|
95
|
+
if (serviceName === 'tuiToast') return availableToast
|
|
96
|
+
if (serviceName === 'tuiDialogs') return availableDialogs
|
|
97
|
+
return undefined
|
|
98
|
+
},
|
|
99
|
+
on(event, handler) {
|
|
100
|
+
const list = record.handlers.get(event) ?? []
|
|
101
|
+
list.push(handler)
|
|
102
|
+
record.handlers.set(event, list)
|
|
103
|
+
// Like the real `ctx.on`: the returned disposer unregisters the listener.
|
|
104
|
+
return () => {
|
|
105
|
+
const index = list.indexOf(handler)
|
|
106
|
+
if (index >= 0) list.splice(index, 1)
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
effect(factory) {
|
|
110
|
+
const dispose = factory()
|
|
111
|
+
if (typeof dispose === 'function') record.disposers.push(dispose)
|
|
112
|
+
},
|
|
113
|
+
inject(deps, callback) {
|
|
114
|
+
// Simulate cordis: the callback runs once every requested service
|
|
115
|
+
// exists (immediately here), and property access works inside it. The
|
|
116
|
+
// request is remembered so a scenario can replay the body the way a
|
|
117
|
+
// service reload does — fresh child fiber, previous one recycled.
|
|
118
|
+
const key = deps.join('+')
|
|
119
|
+
const props = () => Object.fromEntries(deps.map(dep => [dep, serviceFor(dep)]))
|
|
120
|
+
const run = () => callback(childCtx(props(), key))
|
|
121
|
+
// A deferred body becomes replayable when it actually runs for the first
|
|
122
|
+
// time, never while its service is still missing.
|
|
123
|
+
const defer = queue => {
|
|
124
|
+
queue.push(() => {
|
|
125
|
+
record.injectRequests.set(key, run)
|
|
126
|
+
run()
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
if (deferThemes && availableThemes === undefined && deps.includes('tuiThemes')) {
|
|
130
|
+
defer(deferredThemeCallbacks)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if (deferToast && availableToast === undefined && deps.includes('tuiToast')) {
|
|
134
|
+
defer(deferredToastCallbacks)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (deferDialogs && availableDialogs === undefined && deps.includes('tuiDialogs')) {
|
|
138
|
+
defer(deferredDialogsCallbacks)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (deps.every(dep => serviceFor(dep) !== undefined)) {
|
|
142
|
+
record.injectRequests.set(key, run)
|
|
143
|
+
run()
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* An `inject` child context, as cordis creates one per body run: its own
|
|
149
|
+
* effect ledger, disposed when the fiber is recycled (a service arriving
|
|
150
|
+
* again). Disposers also land in `record.disposers`, so a scenario's flat
|
|
151
|
+
* "disposal never throws" sweep still reaches them.
|
|
152
|
+
*/
|
|
153
|
+
const childCtx = (props, key) => {
|
|
154
|
+
const effects = []
|
|
155
|
+
const child = {
|
|
156
|
+
...base,
|
|
157
|
+
...props,
|
|
158
|
+
effect(factory) {
|
|
159
|
+
const dispose = factory()
|
|
160
|
+
if (typeof dispose === 'function') {
|
|
161
|
+
effects.push(dispose)
|
|
162
|
+
record.disposers.push(dispose)
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
dispose() {
|
|
166
|
+
for (const dispose of effects.splice(0)) dispose()
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
if (key !== undefined) (record.children[key] ??= []).push(child)
|
|
170
|
+
return child
|
|
171
|
+
}
|
|
172
|
+
/** Disposes the children of one `inject` request and replays its body. */
|
|
173
|
+
record.reinject = key => {
|
|
174
|
+
const replay = record.injectRequests.get(key)
|
|
175
|
+
if (replay === undefined) throw new Error(`no inject request recorded for "${key}"`)
|
|
176
|
+
for (const child of (record.children[key] ?? []).splice(0)) child.dispose()
|
|
177
|
+
replay()
|
|
178
|
+
}
|
|
179
|
+
record.activateThemes = service => {
|
|
180
|
+
availableThemes = service
|
|
181
|
+
for (const run of deferredThemeCallbacks.splice(0)) run()
|
|
182
|
+
}
|
|
183
|
+
record.activateToast = service => {
|
|
184
|
+
availableToast = service
|
|
185
|
+
for (const run of deferredToastCallbacks.splice(0)) run()
|
|
186
|
+
}
|
|
187
|
+
record.activateDialogs = service => {
|
|
188
|
+
availableDialogs = service
|
|
189
|
+
for (const run of deferredDialogsCallbacks.splice(0)) run()
|
|
190
|
+
}
|
|
191
|
+
return { ctx: base, record }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const fakeStatus = calls => ({ set(key, text) { calls.push([key, text]); return () => {} } })
|
|
195
|
+
/**
|
|
196
|
+
* A 0.10.1+ status service: registerView present. `refuse` simulates the
|
|
197
|
+
* host rejecting the registration (returns undefined, warning invisible);
|
|
198
|
+
* `throws` simulates a hostile service whose registration throws — the
|
|
199
|
+
* plugin must warn and fall back, never propagate.
|
|
200
|
+
*/
|
|
201
|
+
const fakeRichStatus = (calls, viewCalls, { refuse = false, throws = false } = {}) => ({
|
|
202
|
+
set(key, text) { calls.push([key, text]); return () => {} },
|
|
203
|
+
registerView(descriptor, identity) {
|
|
204
|
+
viewCalls.push([descriptor, identity])
|
|
205
|
+
if (throws) throw new Error('status view registry unavailable')
|
|
206
|
+
if (refuse) return undefined
|
|
207
|
+
return () => {}
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
const fakeSections = calls => ({ register(section) { calls.push(section); return () => {} } })
|
|
211
|
+
const fakeThemes = (record, { throws = false } = {}) => ({
|
|
212
|
+
register(descriptor, identity) {
|
|
213
|
+
record.themeRegisters.push([descriptor, identity])
|
|
214
|
+
if (throws) throw new Error('runtime registry unavailable')
|
|
215
|
+
return () => {}
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
const fakeSettingsService = (record, doc) => ({
|
|
219
|
+
register(namespace, schema) {
|
|
220
|
+
// Called as a method, never as a detached reference: the real provider's
|
|
221
|
+
// register() reads its own state, so losing `this` throws there — the bug
|
|
222
|
+
// runtime-themes-headless phase 4 caught against the real service. ESM is
|
|
223
|
+
// strict, so a detached call sees `this === undefined`.
|
|
224
|
+
if (this === undefined) throw new Error('register() lost its receiver')
|
|
225
|
+
record.registerCalls.push([namespace, schema])
|
|
226
|
+
return {
|
|
227
|
+
get: () => doc,
|
|
228
|
+
watch(listener) { record.watchers.push(listener); return () => {} },
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
/**
|
|
233
|
+
* A `dsh-settings` ≥0.1.7 service: no `register` at all, a per-instance page
|
|
234
|
+
* policy instead. The values do not come from here — they are the plugin's own
|
|
235
|
+
* live Config (liveConfig.readConfigValues), which the loader rewrites in
|
|
236
|
+
* place. The owner fiber is recorded so the scenario can assert the policy
|
|
237
|
+
* landed on the Config-owning plugin fiber, never the inject child.
|
|
238
|
+
*/
|
|
239
|
+
const fakeConfigSettingsService = record => ({
|
|
240
|
+
configure(presentation, owner) {
|
|
241
|
+
if (this === undefined) throw new Error('configure() lost its receiver')
|
|
242
|
+
record.configureCalls.push([presentation, owner])
|
|
243
|
+
return () => {}
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
/** The cosmokit Volatile protocol: a frozen branded ref whose value the loader
|
|
247
|
+
* rewrites (`Symbol.for('cosmokit.volatile.write')` — the probe
|
|
248
|
+
* readConfigValues reads; `extra` models a ref carrying more than `get`).
|
|
249
|
+
* `read` is a thunk so a scenario can model the in-place rewrite. */
|
|
250
|
+
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
|
|
251
|
+
const liveRef = (read, extra) => Object.freeze({ get: read, [VOLATILE_WRITE]: () => {}, ...extra })
|
|
252
|
+
/** Records delivered toasts; the first `dropFirst` shows are dropped (no sink yet). */
|
|
253
|
+
const fakeToast = (deliveries, { dropFirst = 0 } = {}) => {
|
|
254
|
+
let shown = 0
|
|
255
|
+
return {
|
|
256
|
+
show(text, options) {
|
|
257
|
+
shown += 1
|
|
258
|
+
if (shown <= dropFirst) return false
|
|
259
|
+
deliveries.push([String(text), options?.color])
|
|
260
|
+
return true
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A tuiDialogs stub: records confirm requests, answers them from the test
|
|
266
|
+
* via the returned queue (each entry resolves one dialog).
|
|
267
|
+
*/
|
|
268
|
+
const fakeDialogs = (requests, answers) => ({
|
|
269
|
+
confirm(owner, request) {
|
|
270
|
+
requests.push({ owner, request })
|
|
271
|
+
return new Promise(resolve => {
|
|
272
|
+
answers.push(resolve)
|
|
273
|
+
})
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const emit = (record, event, ...args) => {
|
|
278
|
+
for (const handler of record.handlers.get(event) ?? []) handler(...args)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── 1. bare host: no seam present → theme assets still install (pure fs),
|
|
282
|
+
// but nothing UI-facing happens ────────────────────────────────────────
|
|
283
|
+
{
|
|
284
|
+
const { ctx, record } = makeStubCtx()
|
|
285
|
+
apply(ctx)
|
|
286
|
+
assert.deepEqual(record.warnings, [])
|
|
287
|
+
assert.equal(record.handlers.size, 0, 'no event handlers without the status seam')
|
|
288
|
+
for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
289
|
+
assert.equal(existsSync(join(sandboxThemes, `${theme}.json`)), true, `${theme}.json installed`)
|
|
290
|
+
}
|
|
291
|
+
console.log('✓ bare host (no seams): themes installed, nothing UI-facing')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── 2. full host: everything wired ──────────────────────────────────────────
|
|
295
|
+
{
|
|
296
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
297
|
+
// A pink theme is active — the default policy hides the line otherwise.
|
|
298
|
+
mkdirSync(join(sandboxHome, '.dsh-tui'), { recursive: true })
|
|
299
|
+
writeFileSync(join(sandboxHome, '.dsh-tui', 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
300
|
+
const statusCalls = []
|
|
301
|
+
const sectionsCalls = []
|
|
302
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
303
|
+
const { ctx, record } = makeStubCtx({
|
|
304
|
+
status: fakeStatus(statusCalls),
|
|
305
|
+
sections: fakeSections(sectionsCalls),
|
|
306
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
307
|
+
})
|
|
308
|
+
await applyAndSettle(ctx)
|
|
309
|
+
|
|
310
|
+
// Themes installed into the SANDBOX, never the real home.
|
|
311
|
+
for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
312
|
+
assert.equal(existsSync(join(sandboxThemes, `${theme}.json`)), true, `${theme}.json installed`)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Status line: one keyed contribution with glyph · clock.
|
|
316
|
+
assert.equal(statusCalls.length > 0, true)
|
|
317
|
+
const [key, first] = statusCalls[0]
|
|
318
|
+
assert.equal(key, 'dsh-tui-theme')
|
|
319
|
+
assert.match(first, /^✿ · \d{2}:\d{2}$/)
|
|
320
|
+
|
|
321
|
+
// Turns counted from session events (live, no log appends).
|
|
322
|
+
const session = { id: 's1' }
|
|
323
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
324
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
325
|
+
const latest = statusCalls.at(-1)[1]
|
|
326
|
+
assert.match(latest, /^✿ · \d{2}:\d{2} · 2✦$/)
|
|
327
|
+
|
|
328
|
+
// Settings namespace registered and the section declaration remains within
|
|
329
|
+
// the shared host form contract.
|
|
330
|
+
assert.equal(settingsRecord.registerCalls.length, 1)
|
|
331
|
+
// The registration schema is the ≤0.1.6 host's only field list, and it is
|
|
332
|
+
// hand-written (settingsSection.registerNamespaceScope) — the one place the
|
|
333
|
+
// card's seven keys are not derived from LIVE_CONFIG_KEYS. Set equality keeps
|
|
334
|
+
// the "one editable surface" invariant true across both generations: a key
|
|
335
|
+
// added to LIVE_CONFIG_KEYS alone would render and save on a ≥0.1.7 card and
|
|
336
|
+
// be silently unpersistable on an old host.
|
|
337
|
+
assert.deepEqual(
|
|
338
|
+
Object.keys(settingsRecord.registerCalls[0][1].dict).sort(),
|
|
339
|
+
[...LIVE_CONFIG_KEYS].sort(),
|
|
340
|
+
'the ≤0.1.6 namespace schema and LIVE_CONFIG_KEYS must stay equal',
|
|
341
|
+
)
|
|
342
|
+
assert.equal(sectionsCalls.length, 1)
|
|
343
|
+
assertSettingsContract(assert, sectionsCalls[0])
|
|
344
|
+
|
|
345
|
+
// A committed /settings edit lands live on the next render.
|
|
346
|
+
for (const watcher of settingsRecord.watchers) {
|
|
347
|
+
watcher({ showGlyph: false, showClock: false })
|
|
348
|
+
}
|
|
349
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
350
|
+
assert.match(statusCalls.at(-1)[1], /^3✦$/)
|
|
351
|
+
|
|
352
|
+
// Disposal never throws.
|
|
353
|
+
for (const dispose of record.disposers) dispose()
|
|
354
|
+
console.log('✓ full host: themes installed, status line live, settings wired')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── 2a. runtime themes: service owns palettes and static files stay absent ───
|
|
358
|
+
{
|
|
359
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
360
|
+
const registrations = []
|
|
361
|
+
const themes = { register(descriptor, identity) { registrations.push([descriptor, identity]); return () => {} } }
|
|
362
|
+
const { ctx } = makeStubCtx({ themes })
|
|
363
|
+
await applyAndSettle(ctx)
|
|
364
|
+
assert.equal(existsSync(sandboxThemes), false, 'runtime registration must not create static files')
|
|
365
|
+
const expected = readBundledThemes().map(({ file, ...theme }) => theme)
|
|
366
|
+
assert.deepEqual(registrations.map(([descriptor]) => descriptor), expected)
|
|
367
|
+
assert.equal(
|
|
368
|
+
registrations.every(([, identity]) => identity?.tuiThemes !== undefined),
|
|
369
|
+
true,
|
|
370
|
+
'runtime registrations use the inject-scoped identity',
|
|
371
|
+
)
|
|
372
|
+
console.log('✓ runtime themes: three descriptors registered without static files')
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── 2b. late runtime service: confirmation removes this activation's files ──
|
|
376
|
+
{
|
|
377
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
378
|
+
const { ctx, record } = makeStubCtx({ deferThemes: true })
|
|
379
|
+
apply(ctx)
|
|
380
|
+
assert.equal(existsSync(sandboxThemes), true, 'legacy fallback must install synchronously before service arrival')
|
|
381
|
+
record.activateThemes(fakeThemes(record))
|
|
382
|
+
await settle()
|
|
383
|
+
assert.equal(existsSync(sandboxThemes), false, 'late runtime service must remove all fallback files and the empty directory')
|
|
384
|
+
|
|
385
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
386
|
+
const protectedContext = makeStubCtx({ deferThemes: true })
|
|
387
|
+
apply(protectedContext.ctx)
|
|
388
|
+
assert.equal(existsSync(sandboxThemes), true, 'legacy fallback must install before protecting a user edit')
|
|
389
|
+
const protectedTheme = join(sandboxThemes, 'pink-night.json')
|
|
390
|
+
writeFileSync(protectedTheme, '{ "name": "pink-night", "colors": { "text": "#123456" } }')
|
|
391
|
+
protectedContext.record.activateThemes(fakeThemes(protectedContext.record))
|
|
392
|
+
await settle()
|
|
393
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), false, 'late runtime service must remove plugin-owned files')
|
|
394
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false, 'late runtime service must remove plugin-owned files')
|
|
395
|
+
assert.equal(readFileSync(protectedTheme, 'utf8'), '{ "name": "pink-night", "colors": { "text": "#123456" } }')
|
|
396
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
397
|
+
console.log('✓ runtime race: late service wins and removes static fallback files')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── 2c. hostile runtime service: registration failures degrade to warnings ───
|
|
401
|
+
{
|
|
402
|
+
const themeRecord = { themeRegisters: [] }
|
|
403
|
+
const { ctx, record } = makeStubCtx({ themes: fakeThemes(themeRecord, { throws: true }) })
|
|
404
|
+
await applyAndSettle(ctx)
|
|
405
|
+
assert.equal(record.warnings.length, 3, 'each hostile registration is contained and warned')
|
|
406
|
+
console.log('✓ hostile runtime service: registration failures warn, never propagate')
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── 2d. live-config marking: the card and the marked Config keys are one set ─
|
|
410
|
+
{
|
|
411
|
+
// The marker is what a ≥0.1.7 host projects (`volatileForm`), and the card's
|
|
412
|
+
// field paths are what it serves; set equality both ways is the invariant
|
|
413
|
+
// that keeps a field from rendering editable while nothing serves it (or a
|
|
414
|
+
// marked key editable with no UI). This repo's schemastery is the 3.18.2
|
|
415
|
+
// baseline with no `.volatile()`, so this also pins the `meta` fallback a
|
|
416
|
+
// plugin resolving an old copy depends on (the病根 of dsh-TUI #990).
|
|
417
|
+
const dict = Config.dict
|
|
418
|
+
assert.equal(hasLiveConfigFields(Config), true, 'the shipped Config carries the live marker')
|
|
419
|
+
for (const key of LIVE_CONFIG_KEYS) {
|
|
420
|
+
assert.equal(dict[key]?.meta?.volatile, true, `${key} must be marked live`)
|
|
421
|
+
}
|
|
422
|
+
for (const key of ['autoInstallThemes', 'statusEnabled']) {
|
|
423
|
+
assert.equal(dict[key]?.meta?.volatile, undefined, `${key} stays a cordis-only knob`)
|
|
424
|
+
}
|
|
425
|
+
assert.deepEqual(
|
|
426
|
+
SETTINGS_FIELDS.map(([path]) => path.join('.')).sort(),
|
|
427
|
+
[...LIVE_CONFIG_KEYS].sort(),
|
|
428
|
+
'the card fields and LIVE_CONFIG_KEYS must stay equal',
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
// Unwrapping: the protocol brand is the authority (a ref may carry extra
|
|
432
|
+
// keys), a bare `{ get }` stays a fallback, and plain values pass through.
|
|
433
|
+
assert.deepEqual(
|
|
434
|
+
readConfigValues({
|
|
435
|
+
statusGlyph: liveRef(() => '❀', { id: 'live-1' }),
|
|
436
|
+
showClock: liveRef(() => true),
|
|
437
|
+
showTurns: { get: () => false },
|
|
438
|
+
statusScope: 'all-themes',
|
|
439
|
+
}),
|
|
440
|
+
{ statusGlyph: '❀', showClock: true, showTurns: false, statusScope: 'all-themes' },
|
|
441
|
+
)
|
|
442
|
+
assert.deepEqual(readConfigValues(undefined), {})
|
|
443
|
+
|
|
444
|
+
// Namespace resolution: the Loader entry id when it fits the section
|
|
445
|
+
// grammar, the documented constant otherwise (both generations agree).
|
|
446
|
+
assert.equal(SETTINGS_NS, 'dsh-tui-theme')
|
|
447
|
+
const withEntry = id => ({ fiber: { entry: { options: { id } } } })
|
|
448
|
+
assert.equal(resolveSettingsNamespace(withEntry('custom-theme')), 'custom-theme')
|
|
449
|
+
assert.equal(resolveSettingsNamespace(withEntry('Custom.TUI')), SETTINGS_NS)
|
|
450
|
+
assert.equal(resolveSettingsNamespace({}), SETTINGS_NS)
|
|
451
|
+
console.log('✓ live config: 3.18.2 meta marking, card↔key parity, ref unwrapping, namespace fallback')
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── 2e. dsh-settings ≥0.1.7: the card is fed by the live Config ─────────────
|
|
455
|
+
{
|
|
456
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
457
|
+
mkdirSync(join(sandboxHome, '.dsh-tui'), { recursive: true })
|
|
458
|
+
writeFileSync(join(sandboxHome, '.dsh-tui', 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
459
|
+
invalidateThemePrefCacheForTests()
|
|
460
|
+
const statusCalls = []
|
|
461
|
+
const sectionsCalls = []
|
|
462
|
+
const settingsRecord = { registerCalls: [], configureCalls: [] }
|
|
463
|
+
const { ctx, record } = makeStubCtx({
|
|
464
|
+
status: fakeStatus(statusCalls),
|
|
465
|
+
sections: fakeSections(sectionsCalls),
|
|
466
|
+
settingsService: fakeConfigSettingsService(settingsRecord),
|
|
467
|
+
entryId: 'dsh-tui-theme',
|
|
468
|
+
})
|
|
469
|
+
// Apply-time shape of a ≥0.1.7 host: the marked fields arrive as live refs
|
|
470
|
+
// (one of them carrying an extra key — the shape the brand probe exists
|
|
471
|
+
// for), the two cordis-only knobs as plain values. The thunks model the
|
|
472
|
+
// loader's in-place rewrite.
|
|
473
|
+
const live = { glyph: '❀', clock: false }
|
|
474
|
+
await applyAndSettle(ctx, {
|
|
475
|
+
autoInstallThemes: true,
|
|
476
|
+
statusEnabled: true,
|
|
477
|
+
followSystem: false,
|
|
478
|
+
statusGlyph: liveRef(() => live.glyph, { id: 'live-1' }),
|
|
479
|
+
statusSeparator: '·',
|
|
480
|
+
showGlyph: liveRef(() => true),
|
|
481
|
+
showClock: liveRef(() => live.clock),
|
|
482
|
+
showTurns: false,
|
|
483
|
+
statusScope: 'pink-only',
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
assert.equal(sectionsCalls.length, 1, 'the card registers exactly once')
|
|
487
|
+
assert.equal(sectionsCalls[0].ns, 'dsh-tui-theme', 'the card follows the Loader entry id')
|
|
488
|
+
assertSettingsContract(assert, sectionsCalls[0])
|
|
489
|
+
assert.equal(settingsRecord.registerCalls.length, 0, 'no namespace registration on the ≥0.1.7 generation')
|
|
490
|
+
assert.deepEqual(
|
|
491
|
+
settingsRecord.configureCalls,
|
|
492
|
+
[[{ auto: false }, ctx.fiber]],
|
|
493
|
+
'the auto page is declined on the Config-owning plugin fiber',
|
|
494
|
+
)
|
|
495
|
+
assert.deepEqual(record.warnings, [], 'a healthy ≥0.1.7 host warns about nothing')
|
|
496
|
+
assert.equal(
|
|
497
|
+
(record.handlers.get('loader/volatile-update') ?? []).length,
|
|
498
|
+
1,
|
|
499
|
+
'the volatile event is followed on the plugin context',
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
// Unwrapped refs feed the line: a raw ref fails the string check and would
|
|
503
|
+
// show the cordis default glyph instead of the configured one.
|
|
504
|
+
assert.equal(statusCalls.at(-1)[1], '❀')
|
|
505
|
+
|
|
506
|
+
// The loader rewrites the ref and announces it: the edit lands on the next
|
|
507
|
+
// render without a reload.
|
|
508
|
+
live.clock = true
|
|
509
|
+
emit(record, 'loader/volatile-update', [['showClock']])
|
|
510
|
+
emit(record, 'session/event', { id: 'n1' }, { type: 'turn/end' })
|
|
511
|
+
assert.match(statusCalls.at(-1)[1], /^❀ · \d{2}:\d{2}$/, 'a volatile update re-reads the edited config')
|
|
512
|
+
|
|
513
|
+
for (const dispose of record.disposers) dispose()
|
|
514
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
515
|
+
console.log('✓ settings ≥0.1.7: Config-derived card on the plugin fiber, live refs unwrapped, volatile update re-read')
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── 2f. ≥0.1.7 degradation is diagnosed, never a bare badge ─────────────────
|
|
519
|
+
{
|
|
520
|
+
// A settings service with neither surface (an unrecognized generation): one
|
|
521
|
+
// info line, and the card still registers.
|
|
522
|
+
const sectionsCalls = []
|
|
523
|
+
const bare = makeStubCtx({
|
|
524
|
+
sections: fakeSections(sectionsCalls),
|
|
525
|
+
settingsService: {},
|
|
526
|
+
entryId: 'dsh-tui-theme',
|
|
527
|
+
})
|
|
528
|
+
await applyAndSettle(bare.ctx)
|
|
529
|
+
assert.deepEqual(bare.record.warnings, [])
|
|
530
|
+
assert.equal(
|
|
531
|
+
bare.record.infos.some(line => line.includes('neither the namespace registration')),
|
|
532
|
+
true,
|
|
533
|
+
'the missing surface is explained',
|
|
534
|
+
)
|
|
535
|
+
assert.equal(sectionsCalls.length, 1, 'the card still registers')
|
|
536
|
+
|
|
537
|
+
// A Loader entry id the section grammar rejects: the host keys by that id,
|
|
538
|
+
// so warn and fall back to the constant for the card and the registration.
|
|
539
|
+
const fallbackCalls = []
|
|
540
|
+
const fallbackRecord = { registerCalls: [], configureCalls: [] }
|
|
541
|
+
const invalid = makeStubCtx({
|
|
542
|
+
sections: fakeSections(fallbackCalls),
|
|
543
|
+
settingsService: fakeConfigSettingsService(fallbackRecord),
|
|
544
|
+
entryId: 'Custom.TUI',
|
|
545
|
+
})
|
|
546
|
+
await applyAndSettle(invalid.ctx)
|
|
547
|
+
assert.equal(fallbackCalls[0].ns, 'dsh-tui-theme', 'an unusable entry id falls back to the constant')
|
|
548
|
+
assert.equal(
|
|
549
|
+
invalid.record.warnings.some(line => line.includes('Loader entry id')),
|
|
550
|
+
true,
|
|
551
|
+
'the entry-id mismatch is warned about',
|
|
552
|
+
)
|
|
553
|
+
assert.equal(fallbackRecord.configureCalls.length, 1, 'the page policy is still attempted')
|
|
554
|
+
// Leave the sandbox as scenario 2b did: a fresh themes dir for scenario 3.
|
|
555
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
556
|
+
console.log('✓ settings degradation: missing surface and unusable entry id are diagnosed')
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── 2g. settings service reload: the volatile listener is never stacked ─────
|
|
560
|
+
{
|
|
561
|
+
// `ctx.inject()` runs its body in a fiber cordis recycles when the service
|
|
562
|
+
// arrives again (a settings reload, an isolate/plugin restart). The listener
|
|
563
|
+
// itself must live on the plugin's own context — that is where the loader
|
|
564
|
+
// delivers `loader/volatile-update` — but a disposer owned there survives the
|
|
565
|
+
// recycling and lets the second pass add a second listener, so every later
|
|
566
|
+
// event re-reads the config twice.
|
|
567
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
568
|
+
mkdirSync(join(sandboxHome, '.dsh-tui'), { recursive: true })
|
|
569
|
+
writeFileSync(join(sandboxHome, '.dsh-tui', 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
570
|
+
invalidateThemePrefCacheForTests()
|
|
571
|
+
const settingsRecord = { registerCalls: [], configureCalls: [] }
|
|
572
|
+
const { ctx, record } = makeStubCtx({
|
|
573
|
+
status: fakeStatus([]),
|
|
574
|
+
settingsService: fakeConfigSettingsService(settingsRecord),
|
|
575
|
+
entryId: 'dsh-tui-theme',
|
|
576
|
+
})
|
|
577
|
+
let glyphReads = 0
|
|
578
|
+
await applyAndSettle(ctx, {
|
|
579
|
+
autoInstallThemes: true,
|
|
580
|
+
statusEnabled: true,
|
|
581
|
+
followSystem: false,
|
|
582
|
+
statusGlyph: liveRef(() => { glyphReads += 1; return '✿' }),
|
|
583
|
+
statusSeparator: '·',
|
|
584
|
+
showGlyph: liveRef(() => true),
|
|
585
|
+
showClock: liveRef(() => true),
|
|
586
|
+
showTurns: false,
|
|
587
|
+
statusScope: 'pink-only',
|
|
588
|
+
})
|
|
589
|
+
const listeners = () => (record.handlers.get('loader/volatile-update') ?? []).length
|
|
590
|
+
assert.equal(listeners(), 1, 'the first pass registers exactly one listener')
|
|
591
|
+
|
|
592
|
+
record.reinject('settings')
|
|
593
|
+
assert.equal(listeners(), 1, 'a second inject pass must not stack a listener')
|
|
594
|
+
const before = glyphReads
|
|
595
|
+
emit(record, 'loader/volatile-update', [['statusGlyph']])
|
|
596
|
+
assert.equal(glyphReads - before, 1, 'one event must drive exactly one re-read')
|
|
597
|
+
|
|
598
|
+
for (const dispose of record.disposers) dispose()
|
|
599
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
600
|
+
console.log('✓ settings reload: the inject child is recycled, the volatile listener is not duplicated')
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── 3. idempotence + user-file protection ───────────────────────────────────
|
|
604
|
+
{
|
|
605
|
+
const seeded = installBundledThemes()
|
|
606
|
+
assert.equal(seeded.installed.length, 3)
|
|
607
|
+
const again = installBundledThemes()
|
|
608
|
+
assert.deepEqual(again.installed, [])
|
|
609
|
+
assert.deepEqual(again.repaired, [])
|
|
610
|
+
assert.equal(again.skipped.length, 3)
|
|
611
|
+
|
|
612
|
+
// A user-edited same-named file must survive reinstallation.
|
|
613
|
+
const userFile = join(sandboxThemes, 'pink-night.json')
|
|
614
|
+
writeFileSync(userFile, '{ "name": "pink-night", "base": "dark", "colors": { "text": "#123456" } }')
|
|
615
|
+
const third = installBundledThemes()
|
|
616
|
+
assert.deepEqual(third.installed, [])
|
|
617
|
+
assert.equal(JSON.parse(readFileSync(userFile, 'utf8')).colors.text, '#123456')
|
|
618
|
+
console.log('✓ reinstall: skips existing files, never overwrites user edits')
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── 4. autoInstallThemes: false on a clean sandbox ──────────────────────────
|
|
622
|
+
{
|
|
623
|
+
rmSync(sandboxHome, { recursive: true, force: true })
|
|
624
|
+
mkdirSync(sandboxHome, { recursive: true })
|
|
625
|
+
const { ctx } = makeStubCtx()
|
|
626
|
+
await applyAndSettle(ctx, { autoInstallThemes: false })
|
|
627
|
+
assert.equal(existsSync(join(sandboxHome, '.dsh-tui')), false, 'must not create the dir when disabled')
|
|
628
|
+
console.log('✓ autoInstallThemes=false: leaves the themes dir untouched')
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── 5. cached background follow: no terminal I/O ───────────────────────────
|
|
632
|
+
{
|
|
633
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
634
|
+
mkdirSync(dataDir, { recursive: true })
|
|
635
|
+
assert.equal(themeForBackground(true), 'pink-day')
|
|
636
|
+
assert.equal(themeForBackground(false), 'pink-night')
|
|
637
|
+
assert.equal(applyCachedFollow(dataDir), undefined, 'no cache preserves the existing choice')
|
|
638
|
+
|
|
639
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
640
|
+
assert.equal(applyCachedFollow(dataDir), 'pink-day')
|
|
641
|
+
assert.equal(readFollowCache(dataDir).light, true)
|
|
642
|
+
assert.equal(readThemePref(dataDir), 'pink-day')
|
|
643
|
+
|
|
644
|
+
// Same value -> no rewrite churn (mtime-agnostic: pref already matches).
|
|
645
|
+
assert.equal(applyCachedFollow(dataDir), 'pink-day')
|
|
646
|
+
|
|
647
|
+
// The follow runner only applies cache and reports its structured outcome.
|
|
648
|
+
// It accepts no stdin/stdout handles and cannot create a raw-mode lease or
|
|
649
|
+
// input listener.
|
|
650
|
+
const reports = []
|
|
651
|
+
runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
|
|
652
|
+
assert.deepEqual(reports, [{ kind: 'applied', theme: 'pink-day', changed: false }])
|
|
653
|
+
|
|
654
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
|
|
655
|
+
runFollowSystem(dataDir, () => false, outcome => reports.push(outcome))
|
|
656
|
+
assert.equal(readThemePref(dataDir), 'dark', 'inactive follow preserves manual choice')
|
|
657
|
+
|
|
658
|
+
runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
|
|
659
|
+
assert.deepEqual(
|
|
660
|
+
reports.at(-1),
|
|
661
|
+
{ kind: 'applied', theme: 'pink-day', changed: true },
|
|
662
|
+
'a real pref flip is reported as changed',
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
rmSync(join(dataDir, 'theme-follow.json'), { force: true })
|
|
666
|
+
runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
|
|
667
|
+
assert.deepEqual(reports.at(-1), { kind: 'unavailable' })
|
|
668
|
+
console.log('✓ follow: cached background applies without terminal I/O')
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ── 6. statusEnabled: false silences the whole line ─────────────────────────
|
|
672
|
+
{
|
|
673
|
+
const statusCalls = []
|
|
674
|
+
const { ctx } = makeStubCtx({ status: fakeStatus(statusCalls) })
|
|
675
|
+
await applyAndSettle(ctx, { statusEnabled: false })
|
|
676
|
+
assert.equal(statusCalls.length > 0, true, 'render still ran once')
|
|
677
|
+
// Every contribution is cleared (undefined), not rendered.
|
|
678
|
+
for (const [, text] of statusCalls) assert.equal(text, undefined)
|
|
679
|
+
console.log('✓ statusEnabled=false: the line contributes nothing')
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── 7. followSystem honors the /settings user layer with cached state ───────
|
|
683
|
+
{
|
|
684
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
685
|
+
rmSync(join(dataDir, 'theme-follow.json'), { force: true })
|
|
686
|
+
rmSync(join(dataDir, 'theme.json'), { force: true })
|
|
687
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
688
|
+
const { ctx } = makeStubCtx({
|
|
689
|
+
status: fakeStatus([]),
|
|
690
|
+
sections: fakeSections([]),
|
|
691
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
692
|
+
})
|
|
693
|
+
await applyAndSettle(ctx, { followSystem: true })
|
|
694
|
+
// Cordis layer on, empty user layer, no cache yet -> no pref churn.
|
|
695
|
+
assert.equal(existsSync(join(dataDir, 'theme.json')), false)
|
|
696
|
+
|
|
697
|
+
// User disables follow via /settings; a stale cached flip must not write.
|
|
698
|
+
for (const w of settingsRecord.watchers) w({ followSystem: false })
|
|
699
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
700
|
+
assert.equal(existsSync(join(dataDir, 'theme.json')), false, 'disabled follow must not rewrite the pref')
|
|
701
|
+
|
|
702
|
+
// Re-enable → the cached background applies immediately.
|
|
703
|
+
for (const w of settingsRecord.watchers) w({ followSystem: true })
|
|
704
|
+
assert.equal(readThemePref(dataDir), 'pink-day')
|
|
705
|
+
console.log('✓ followSystem: /settings toggle decides live (off = pref preserved)')
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ── 8. theme gating: the line belongs to the pink palettes ──────────────────
|
|
709
|
+
{
|
|
710
|
+
const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
|
|
711
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
712
|
+
const statusCalls = []
|
|
713
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
714
|
+
const { ctx, record } = makeStubCtx({
|
|
715
|
+
status: fakeStatus(statusCalls),
|
|
716
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
717
|
+
})
|
|
718
|
+
await applyAndSettle(ctx)
|
|
719
|
+
const session = { id: 'g1' }
|
|
720
|
+
|
|
721
|
+
// Pink active → renders.
|
|
722
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
723
|
+
assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 1✦$/)
|
|
724
|
+
|
|
725
|
+
// Non-pink theme active → hidden by default. The pref cache is dropped so
|
|
726
|
+
// the rewrite is visible immediately (production invalidation is the TTL).
|
|
727
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
|
|
728
|
+
invalidateThemePrefCacheForTests()
|
|
729
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
730
|
+
assert.equal(statusCalls.at(-1)[1], undefined)
|
|
731
|
+
|
|
732
|
+
// Opt in via /settings → shown on non-pink too (the turn count kept
|
|
733
|
+
// ticking while the line was hidden — turns count since the TUI started).
|
|
734
|
+
for (const w of settingsRecord.watchers) w({ statusScope: 'all-themes' })
|
|
735
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
736
|
+
assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 3✦$/)
|
|
737
|
+
|
|
738
|
+
// Host precedence: DSH_TUI_THEME wins over the dark pref.
|
|
739
|
+
const baselineThemeOverride = process.env.DSH_TUI_THEME
|
|
740
|
+
try {
|
|
741
|
+
process.env.DSH_TUI_THEME = 'pink-day'
|
|
742
|
+
for (const w of settingsRecord.watchers) w({})
|
|
743
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
744
|
+
assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 4✦$/)
|
|
745
|
+
} finally {
|
|
746
|
+
if (baselineThemeOverride === undefined) {
|
|
747
|
+
delete process.env.DSH_TUI_THEME
|
|
748
|
+
} else {
|
|
749
|
+
process.env.DSH_TUI_THEME = baselineThemeOverride
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
console.log('✓ theme gating: pink-only by default, opt-in shows everywhere')
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ── 9. settings service hostility: registration throws → warn, never crash ─
|
|
756
|
+
{
|
|
757
|
+
const throwingSettings = {
|
|
758
|
+
register() { throw new Error('namespace already registered (hot reload)') },
|
|
759
|
+
}
|
|
760
|
+
const { ctx, record } = makeStubCtx({ settingsService: throwingSettings })
|
|
761
|
+
await applyAndSettle(ctx) // must not throw
|
|
762
|
+
assert.equal(record.warnings.length, 1, 'registration failure logs one warning')
|
|
763
|
+
assert.match(record.warnings[0], /settings namespace registration failed/)
|
|
764
|
+
console.log('✓ hostile settings service: registration failure warns, never propagates')
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ── 10. missing settings: never override a manual theme choice ─────────────
|
|
768
|
+
{
|
|
769
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
770
|
+
mkdirSync(dataDir, { recursive: true })
|
|
771
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
|
|
772
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
773
|
+
const { ctx } = makeStubCtx()
|
|
774
|
+
await applyAndSettle(ctx, { followSystem: true })
|
|
775
|
+
assert.equal(readThemePref(dataDir), 'dark', 'without settings, cached follow must not override manual choice')
|
|
776
|
+
console.log('✓ missing settings: cached follow leaves the manual choice intact')
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ── 11. filesystem commits: atomic replacement and exclusive creation ───────
|
|
780
|
+
{
|
|
781
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
782
|
+
mkdirSync(dataDir, { recursive: true })
|
|
783
|
+
const pref = join(dataDir, 'theme.json')
|
|
784
|
+
writeFileSync(pref, JSON.stringify({ theme: 'dark' }, null, 2))
|
|
785
|
+
const originalRename = builtinFs.renameSync
|
|
786
|
+
try {
|
|
787
|
+
builtinFs.renameSync = () => { throw new Error('rename blocked') }
|
|
788
|
+
syncBuiltinESMExports()
|
|
789
|
+
assert.equal(writeThemePref('pink-day', dataDir), false)
|
|
790
|
+
assert.equal(readThemePref(dataDir), 'dark', 'failed commit leaves the prior JSON readable')
|
|
791
|
+
assert.equal(
|
|
792
|
+
readdirSync(dataDir).some(file => file.startsWith('.theme.json.') && file.endsWith('.tmp')),
|
|
793
|
+
false,
|
|
794
|
+
'failed commit removes its temporary file',
|
|
795
|
+
)
|
|
796
|
+
} finally {
|
|
797
|
+
builtinFs.renameSync = originalRename
|
|
798
|
+
syncBuiltinESMExports()
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const targetDir = join(sandboxHome, 'race-target')
|
|
802
|
+
const originalWrite = builtinFs.writeFileSync
|
|
803
|
+
let racedFile
|
|
804
|
+
try {
|
|
805
|
+
builtinFs.writeFileSync = (path, data, options) => {
|
|
806
|
+
const target = String(path)
|
|
807
|
+
if (racedFile === undefined && target.startsWith(targetDir) && options?.flag === 'wx') {
|
|
808
|
+
racedFile = target.split(/[\\/]/).at(-1)
|
|
809
|
+
originalWrite(path, '{ "user": true }', { flag: 'wx' })
|
|
810
|
+
}
|
|
811
|
+
return originalWrite(path, data, options)
|
|
812
|
+
}
|
|
813
|
+
syncBuiltinESMExports()
|
|
814
|
+
const result = installBundledThemes(targetDir, join(pluginRoot, 'themes'))
|
|
815
|
+
assert.equal(typeof racedFile, 'string', 'the test injected a competing creator')
|
|
816
|
+
assert.equal(result.skipped.includes(racedFile), true, 'EEXIST is a protected user-file skip')
|
|
817
|
+
assert.deepEqual(JSON.parse(readFileSync(join(targetDir, racedFile), 'utf8')), { user: true })
|
|
818
|
+
} finally {
|
|
819
|
+
builtinFs.writeFileSync = originalWrite
|
|
820
|
+
syncBuiltinESMExports()
|
|
821
|
+
}
|
|
822
|
+
console.log('✓ filesystem commits: failed atomic writes preserve JSON; competing theme creation skips')
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ── 12a. torn installation: a corrupt target is backed up and reinstalled ───
|
|
826
|
+
{
|
|
827
|
+
const targetDir = join(sandboxHome, 'heal-target')
|
|
828
|
+
const sourceDir = join(pluginRoot, 'themes')
|
|
829
|
+
mkdirSync(targetDir, { recursive: true })
|
|
830
|
+
|
|
831
|
+
// A valid user file stays untouched — the never-overwrite rule wins.
|
|
832
|
+
writeFileSync(join(targetDir, 'pink-night.json'), '{ "user": true }')
|
|
833
|
+
// A torn write (crash mid-install) leaves invalid JSON behind.
|
|
834
|
+
writeFileSync(join(targetDir, 'pink-day.json'), '{ "name": "pink-day", "colors": {')
|
|
835
|
+
const heal = installBundledThemes(targetDir, sourceDir)
|
|
836
|
+
assert.deepEqual(heal.repaired, ['pink-day.json'], 'the corrupt target is reported as repaired')
|
|
837
|
+
assert.equal(heal.skipped.includes('pink-night.json'), true)
|
|
838
|
+
assert.equal(heal.failed.length, 0)
|
|
839
|
+
assert.equal(
|
|
840
|
+
JSON.parse(readFileSync(join(targetDir, 'pink-night.json'), 'utf8')).user,
|
|
841
|
+
true,
|
|
842
|
+
'valid user file untouched',
|
|
843
|
+
)
|
|
844
|
+
const reinstalled = JSON.parse(readFileSync(join(targetDir, 'pink-day.json'), 'utf8'))
|
|
845
|
+
assert.equal(reinstalled.name, 'pink-day')
|
|
846
|
+
const backups = readdirSync(targetDir).filter(entry =>
|
|
847
|
+
entry.startsWith('pink-day.json.corrupt-'),
|
|
848
|
+
)
|
|
849
|
+
assert.equal(backups.length, 1, 'the damaged file is preserved as a timestamped backup')
|
|
850
|
+
assert.equal(readFileSync(join(targetDir, backups[0]), 'utf8'), '{ "name": "pink-day", "colors": {')
|
|
851
|
+
|
|
852
|
+
// Next boot: everything parses, so no churn.
|
|
853
|
+
const steady = installBundledThemes(targetDir, sourceDir)
|
|
854
|
+
assert.deepEqual(steady.repaired, [])
|
|
855
|
+
assert.deepEqual(steady.installed, [])
|
|
856
|
+
assert.equal(steady.skipped.length, 3)
|
|
857
|
+
|
|
858
|
+
// A target the process cannot even read is not proven corrupt — keep skipping.
|
|
859
|
+
const unreadable = join(targetDir, 'pink-ansi.json')
|
|
860
|
+
const originalRead = builtinFs.readFileSync
|
|
861
|
+
try {
|
|
862
|
+
builtinFs.readFileSync = (path, ...rest) => {
|
|
863
|
+
if (String(path) === unreadable) throw new Error('EBUSY: locked')
|
|
864
|
+
return originalRead(path, ...rest)
|
|
865
|
+
}
|
|
866
|
+
syncBuiltinESMExports()
|
|
867
|
+
const blocked = installBundledThemes(targetDir, sourceDir)
|
|
868
|
+
assert.deepEqual(blocked.repaired, [])
|
|
869
|
+
assert.equal(blocked.skipped.includes('pink-ansi.json'), true)
|
|
870
|
+
} finally {
|
|
871
|
+
builtinFs.readFileSync = originalRead
|
|
872
|
+
syncBuiltinESMExports()
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// A successful backup followed by a failed replacement is a failure, not a
|
|
876
|
+
// protected skip: the target is absent until the next boot can retry.
|
|
877
|
+
const failedHeal = join(targetDir, 'pink-ansi.json')
|
|
878
|
+
writeFileSync(failedHeal, '{ broken')
|
|
879
|
+
const originalWrite = builtinFs.writeFileSync
|
|
880
|
+
try {
|
|
881
|
+
builtinFs.writeFileSync = (path, data, options) => {
|
|
882
|
+
if (String(path) === failedHeal && options?.flag === 'wx' && !existsSync(failedHeal)) {
|
|
883
|
+
throw new Error('ENOSPC: replacement blocked')
|
|
884
|
+
}
|
|
885
|
+
return originalWrite(path, data, options)
|
|
886
|
+
}
|
|
887
|
+
syncBuiltinESMExports()
|
|
888
|
+
const failed = installBundledThemes(targetDir, sourceDir)
|
|
889
|
+
assert.equal(failed.failed.includes('pink-ansi.json'), true)
|
|
890
|
+
assert.equal(failed.skipped.includes('pink-ansi.json'), false)
|
|
891
|
+
} finally {
|
|
892
|
+
builtinFs.writeFileSync = originalWrite
|
|
893
|
+
syncBuiltinESMExports()
|
|
894
|
+
}
|
|
895
|
+
console.log('✓ torn installation: corrupt target backed up and reinstalled; user files and unreadable targets untouched')
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ── 12b. follow logging: the first settings doc is a baseline, not a flip ──
|
|
899
|
+
{
|
|
900
|
+
// Default user layer (empty doc): no spurious "follow: disabled" line.
|
|
901
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
902
|
+
const { ctx, record } = makeStubCtx({ settingsService: fakeSettingsService(settingsRecord, {}) })
|
|
903
|
+
await applyAndSettle(ctx)
|
|
904
|
+
assert.equal(
|
|
905
|
+
record.infos.some(message => message.includes('follow: disabled')),
|
|
906
|
+
false,
|
|
907
|
+
'a baseline doc matching the default must not log a disabled flip',
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
// User layer that starts enabled: the cache applies during the baseline.
|
|
911
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
912
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
913
|
+
const enabledRecord = { registerCalls: [], watchers: [] }
|
|
914
|
+
const { ctx: enabledCtx, record: enabledInfo } = makeStubCtx({
|
|
915
|
+
status: fakeStatus([]),
|
|
916
|
+
sections: fakeSections([]),
|
|
917
|
+
settingsService: fakeSettingsService(enabledRecord, { followSystem: true }),
|
|
918
|
+
})
|
|
919
|
+
await applyAndSettle(enabledCtx)
|
|
920
|
+
assert.equal(readThemePref(dataDir), 'pink-day', 'an enabled baseline applies the cached background')
|
|
921
|
+
assert.equal(
|
|
922
|
+
enabledInfo.infos.some(message => message.includes('follow: disabled')),
|
|
923
|
+
false,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
// A real toggle keeps its log.
|
|
927
|
+
for (const watcher of enabledRecord.watchers) watcher({ followSystem: false })
|
|
928
|
+
assert.equal(
|
|
929
|
+
enabledInfo.infos.filter(message => message.includes('follow: disabled')).length,
|
|
930
|
+
1,
|
|
931
|
+
'disabling follow after the baseline logs exactly once',
|
|
932
|
+
)
|
|
933
|
+
assert.equal(readThemePref(dataDir), 'pink-day', 'the manual choice stays intact')
|
|
934
|
+
console.log('✓ follow logging: baseline docs stay quiet, real toggles still log')
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ── 12c. unreadable bundled source: the directory-level failure stays precise ──
|
|
938
|
+
{
|
|
939
|
+
const targetDir = join(sandboxHome, 'source-fail-target')
|
|
940
|
+
const sourceDir = join(pluginRoot, 'themes')
|
|
941
|
+
const originalReaddir = builtinFs.readdirSync
|
|
942
|
+
try {
|
|
943
|
+
builtinFs.readdirSync = (path, ...rest) => {
|
|
944
|
+
if (String(path) === sourceDir) throw new Error('EACCES: source unreadable')
|
|
945
|
+
return originalReaddir(path, ...rest)
|
|
946
|
+
}
|
|
947
|
+
syncBuiltinESMExports()
|
|
948
|
+
const result = installBundledThemes(targetDir, sourceDir)
|
|
949
|
+
assert.deepEqual(result.failed, [], 'no per-file failure exists when nothing was attempted')
|
|
950
|
+
assert.match(
|
|
951
|
+
result.sourceError ?? '',
|
|
952
|
+
/could not read bundled themes from .*themes.*EACCES/,
|
|
953
|
+
'the directory-level error names the source and the cause',
|
|
954
|
+
)
|
|
955
|
+
} finally {
|
|
956
|
+
builtinFs.readdirSync = originalReaddir
|
|
957
|
+
syncBuiltinESMExports()
|
|
958
|
+
}
|
|
959
|
+
console.log('✓ unreadable bundled source: precise sourceError, no bogus per-file failure')
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ── 12. status injection: session handlers die with tuiStatus activation ────
|
|
963
|
+
{
|
|
964
|
+
const outerHandlers = new Map()
|
|
965
|
+
let activate
|
|
966
|
+
const outerCtx = {
|
|
967
|
+
on(event, handler) {
|
|
968
|
+
const handlers = outerHandlers.get(event) ?? []
|
|
969
|
+
handlers.push(handler)
|
|
970
|
+
outerHandlers.set(event, handlers)
|
|
971
|
+
return () => {}
|
|
972
|
+
},
|
|
973
|
+
inject(_deps, callback) { activate = callback },
|
|
974
|
+
}
|
|
975
|
+
const makeActivation = calls => {
|
|
976
|
+
const handlers = new Map()
|
|
977
|
+
const disposers = []
|
|
978
|
+
return {
|
|
979
|
+
tuiStatus: fakeStatus(calls),
|
|
980
|
+
on(event, handler) {
|
|
981
|
+
const eventHandlers = handlers.get(event) ?? []
|
|
982
|
+
eventHandlers.push(handler)
|
|
983
|
+
handlers.set(event, eventHandlers)
|
|
984
|
+
return () => handlers.set(event, eventHandlers.filter(entry => entry !== handler))
|
|
985
|
+
},
|
|
986
|
+
effect(factory) {
|
|
987
|
+
const dispose = factory()
|
|
988
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
989
|
+
},
|
|
990
|
+
emit(event, ...args) {
|
|
991
|
+
for (const handler of handlers.get(event) ?? []) handler(...args)
|
|
992
|
+
},
|
|
993
|
+
dispose() {
|
|
994
|
+
handlers.clear()
|
|
995
|
+
for (const dispose of disposers) dispose()
|
|
996
|
+
},
|
|
997
|
+
handlerCount(event) { return (handlers.get(event) ?? []).length },
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const effective = {
|
|
1001
|
+
statusEnabled: true,
|
|
1002
|
+
showGlyph: false,
|
|
1003
|
+
showClock: false,
|
|
1004
|
+
showTurns: true,
|
|
1005
|
+
statusScope: 'all-themes',
|
|
1006
|
+
}
|
|
1007
|
+
startStatusLine(outerCtx, () => effective)
|
|
1008
|
+
|
|
1009
|
+
const firstCalls = []
|
|
1010
|
+
const first = makeActivation(firstCalls)
|
|
1011
|
+
activate(first)
|
|
1012
|
+
assert.equal(outerHandlers.size, 0, 'status activation must not register outer-owned session handlers')
|
|
1013
|
+
assert.equal(first.handlerCount('session/event'), 1)
|
|
1014
|
+
first.emit('session/event', { id: 'first' }, { type: 'turn/end' })
|
|
1015
|
+
assert.equal(firstCalls.at(-1)[1], '1✦')
|
|
1016
|
+
first.dispose()
|
|
1017
|
+
first.emit('session/event', { id: 'stale' }, { type: 'turn/end' })
|
|
1018
|
+
assert.equal(firstCalls.at(-1)[1], '1✦', 'disposed activation ignores later session events')
|
|
1019
|
+
|
|
1020
|
+
const secondCalls = []
|
|
1021
|
+
const second = makeActivation(secondCalls)
|
|
1022
|
+
activate(second)
|
|
1023
|
+
second.emit('session/event', { id: 'second' }, { type: 'turn/end' })
|
|
1024
|
+
assert.equal(secondCalls.at(-1)[1], '1✦', 'replacement activation owns the only live handler')
|
|
1025
|
+
assert.equal(firstCalls.at(-1)[1], '1✦')
|
|
1026
|
+
second.dispose()
|
|
1027
|
+
console.log('✓ status lifecycle: session handlers follow tuiStatus activation')
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// ── 13. toast feedback: the 0.10 seam surfaces what the logger cannot ───────
|
|
1031
|
+
{
|
|
1032
|
+
setToastRetryDelaysForTests([5, 5])
|
|
1033
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
1034
|
+
mkdirSync(dataDir, { recursive: true })
|
|
1035
|
+
|
|
1036
|
+
// 13a. Startup baseline with a disagreeing cache: one success toast that
|
|
1037
|
+
// points at /reload (the live TUI still shows the previous palette). The
|
|
1038
|
+
// tuiToast seam trails apply on a real host (extensions row), so the send
|
|
1039
|
+
// must survive on the bounded retry until the seam shows up.
|
|
1040
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
1041
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
1042
|
+
const baselineDeliveries = []
|
|
1043
|
+
const baselineRecord = { registerCalls: [], watchers: [] }
|
|
1044
|
+
const baselineContext = makeStubCtx({
|
|
1045
|
+
status: fakeStatus([]),
|
|
1046
|
+
sections: fakeSections([]),
|
|
1047
|
+
settingsService: fakeSettingsService(baselineRecord, { followSystem: true }),
|
|
1048
|
+
deferToast: true,
|
|
1049
|
+
})
|
|
1050
|
+
await applyAndSettle(baselineContext.ctx)
|
|
1051
|
+
assert.equal(readThemePref(dataDir), 'pink-day', 'the pref write is synchronous, independent of the toast seam')
|
|
1052
|
+
baselineContext.record.activateToast(fakeToast(baselineDeliveries))
|
|
1053
|
+
const baselineDeadline = Date.now() + 2_000
|
|
1054
|
+
while (baselineDeliveries.length === 0 && Date.now() < baselineDeadline) {
|
|
1055
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
1056
|
+
}
|
|
1057
|
+
assert.equal(baselineDeliveries.length, 1, 'a real baseline pref write toasts exactly once')
|
|
1058
|
+
assert.equal(baselineDeliveries[0][1], 'success')
|
|
1059
|
+
assert.match(baselineDeliveries[0][0], /pink-day/)
|
|
1060
|
+
assert.match(baselineDeliveries[0][0], /reload/)
|
|
1061
|
+
|
|
1062
|
+
// 13b. Stable baseline (pref already matches the cache): quiet.
|
|
1063
|
+
const quietDeliveries = []
|
|
1064
|
+
const quietRecord = { registerCalls: [], watchers: [] }
|
|
1065
|
+
const { ctx: quietCtx } = makeStubCtx({
|
|
1066
|
+
status: fakeStatus([]),
|
|
1067
|
+
sections: fakeSections([]),
|
|
1068
|
+
settingsService: fakeSettingsService(quietRecord, { followSystem: true }),
|
|
1069
|
+
toast: fakeToast(quietDeliveries),
|
|
1070
|
+
})
|
|
1071
|
+
await applyAndSettle(quietCtx)
|
|
1072
|
+
assert.equal(quietDeliveries.length, 0, 'a baseline that changes nothing stays silent')
|
|
1073
|
+
|
|
1074
|
+
// 13c. Toggle confirmations: an explicit enable always answers — with the
|
|
1075
|
+
// matching-cache confirmation, or the honest warning when nothing applies.
|
|
1076
|
+
const toggleDeliveries = []
|
|
1077
|
+
const toggleRecord = { registerCalls: [], watchers: [] }
|
|
1078
|
+
const { ctx: toggleCtx } = makeStubCtx({
|
|
1079
|
+
status: fakeStatus([]),
|
|
1080
|
+
sections: fakeSections([]),
|
|
1081
|
+
settingsService: fakeSettingsService(toggleRecord, {}),
|
|
1082
|
+
toast: fakeToast(toggleDeliveries),
|
|
1083
|
+
})
|
|
1084
|
+
await applyAndSettle(toggleCtx)
|
|
1085
|
+
for (const w of toggleRecord.watchers) w({ followSystem: true })
|
|
1086
|
+
assert.equal(toggleDeliveries.length, 1, 'toggle-on with a matching cache confirms')
|
|
1087
|
+
assert.equal(toggleDeliveries[0][1], 'success')
|
|
1088
|
+
assert.equal(/reload/.test(toggleDeliveries[0][0]), false, 'no reload hint when nothing changed')
|
|
1089
|
+
for (const w of toggleRecord.watchers) w({ followSystem: false })
|
|
1090
|
+
rmSync(join(dataDir, 'theme-follow.json'), { force: true })
|
|
1091
|
+
for (const w of toggleRecord.watchers) w({ followSystem: true })
|
|
1092
|
+
assert.equal(toggleDeliveries.length, 2, 'toggle-on without a cache answers too')
|
|
1093
|
+
assert.equal(toggleDeliveries[1][1], 'warning')
|
|
1094
|
+
|
|
1095
|
+
// 13d. A toast dropped before the host sink exists is retried until delivered.
|
|
1096
|
+
const retryDeliveries = []
|
|
1097
|
+
const retryRecord = { registerCalls: [], watchers: [] }
|
|
1098
|
+
const { ctx: retryCtx } = makeStubCtx({
|
|
1099
|
+
status: fakeStatus([]),
|
|
1100
|
+
sections: fakeSections([]),
|
|
1101
|
+
settingsService: fakeSettingsService(retryRecord, {}),
|
|
1102
|
+
toast: fakeToast(retryDeliveries, { dropFirst: 1 }),
|
|
1103
|
+
})
|
|
1104
|
+
await applyAndSettle(retryCtx)
|
|
1105
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: false, at: 1 }))
|
|
1106
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-day' }, null, 2))
|
|
1107
|
+
for (const w of retryRecord.watchers) w({ followSystem: true })
|
|
1108
|
+
const retryDeadline = Date.now() + 2_000
|
|
1109
|
+
while (retryDeliveries.length === 0 && Date.now() < retryDeadline) {
|
|
1110
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
1111
|
+
}
|
|
1112
|
+
assert.equal(retryDeliveries.length, 1, 'a dropped toast is retried and delivered')
|
|
1113
|
+
assert.match(retryDeliveries[0][0], /pink-night/)
|
|
1114
|
+
|
|
1115
|
+
// 13e. Toast-seam-less host (dsh-TUI < 0.10): sends are silent no-ops and
|
|
1116
|
+
// the follow feature itself is unchanged. The retry chain stays bounded:
|
|
1117
|
+
// once it gives up, a seam arriving later must not resurrect the abandoned
|
|
1118
|
+
// toast.
|
|
1119
|
+
const legacyRecord = { registerCalls: [], watchers: [] }
|
|
1120
|
+
const legacyContext = makeStubCtx({
|
|
1121
|
+
status: fakeStatus([]),
|
|
1122
|
+
sections: fakeSections([]),
|
|
1123
|
+
settingsService: fakeSettingsService(legacyRecord, {}),
|
|
1124
|
+
deferToast: true,
|
|
1125
|
+
})
|
|
1126
|
+
await applyAndSettle(legacyContext.ctx)
|
|
1127
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
1128
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
|
|
1129
|
+
for (const w of legacyRecord.watchers) w({ followSystem: true })
|
|
1130
|
+
assert.equal(readThemePref(dataDir), 'pink-day', 'follow works unchanged without the toast seam')
|
|
1131
|
+
await new Promise(resolve => setTimeout(resolve, 50)) // the [5, 5] chain is long exhausted
|
|
1132
|
+
const lateDeliveries = []
|
|
1133
|
+
legacyContext.record.activateToast(fakeToast(lateDeliveries))
|
|
1134
|
+
await settle()
|
|
1135
|
+
assert.equal(lateDeliveries.length, 0, 'an abandoned toast is not resurrected by a late seam')
|
|
1136
|
+
|
|
1137
|
+
// 13f. Shadow hint: legacy same-named files that are byte-identical to the
|
|
1138
|
+
// bundled copy are pointed out once on runtime confirmation; user edits
|
|
1139
|
+
// stay unmentioned. Files this activation installed itself are excluded
|
|
1140
|
+
// (they were already removed before the check).
|
|
1141
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
1142
|
+
mkdirSync(sandboxThemes, { recursive: true })
|
|
1143
|
+
for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
1144
|
+
writeFileSync(join(sandboxThemes, `${theme}.json`), readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'))
|
|
1145
|
+
}
|
|
1146
|
+
assert.deepEqual(findShadowedBundledThemes().sort(), ['pink-ansi.json', 'pink-day.json', 'pink-night.json'])
|
|
1147
|
+
const shadowDeliveries = []
|
|
1148
|
+
const shadowContext = makeStubCtx({ deferThemes: true, toast: fakeToast(shadowDeliveries) })
|
|
1149
|
+
apply(shadowContext.ctx)
|
|
1150
|
+
shadowContext.record.activateThemes(fakeThemes(shadowContext.record))
|
|
1151
|
+
await settle()
|
|
1152
|
+
assert.equal(shadowDeliveries.length, 1, 'identical legacy files are pointed out once')
|
|
1153
|
+
assert.equal(shadowDeliveries[0][1], undefined, 'the shadow hint is neutral, not an error')
|
|
1154
|
+
assert.match(shadowDeliveries[0][0], /pink-day\.json/)
|
|
1155
|
+
|
|
1156
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
1157
|
+
installBundledThemes()
|
|
1158
|
+
writeFileSync(join(sandboxThemes, 'pink-night.json'), '{ "name": "pink-night", "colors": { "text": "#123456" } }')
|
|
1159
|
+
const editDeliveries = []
|
|
1160
|
+
const editContext = makeStubCtx({ deferThemes: true, toast: fakeToast(editDeliveries) })
|
|
1161
|
+
apply(editContext.ctx)
|
|
1162
|
+
editContext.record.activateThemes(fakeThemes(editContext.record))
|
|
1163
|
+
await settle()
|
|
1164
|
+
assert.equal(editDeliveries.length, 1)
|
|
1165
|
+
assert.equal(/pink-night\.json/.test(editDeliveries[0][0]), false, 'a user-edited file is never nagged')
|
|
1166
|
+
assert.equal(/pink-day\.json/.test(editDeliveries[0][0]), true)
|
|
1167
|
+
|
|
1168
|
+
// 13g. Corrupt-file self-heal is surfaced as a warning toast. The toast
|
|
1169
|
+
// seam trails apply on a real host, so the send waits on the retry chain.
|
|
1170
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
1171
|
+
mkdirSync(sandboxThemes, { recursive: true })
|
|
1172
|
+
writeFileSync(join(sandboxThemes, 'pink-day.json'), '{ broken')
|
|
1173
|
+
const healDeliveries = []
|
|
1174
|
+
const healContext = makeStubCtx({ deferThemes: true, deferToast: true })
|
|
1175
|
+
apply(healContext.ctx)
|
|
1176
|
+
healContext.record.activateToast(fakeToast(healDeliveries))
|
|
1177
|
+
const healDeadline = Date.now() + 2_000
|
|
1178
|
+
while (healDeliveries.length === 0 && Date.now() < healDeadline) {
|
|
1179
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
1180
|
+
}
|
|
1181
|
+
assert.equal(healDeliveries.length, 1, 'a repaired theme file is surfaced')
|
|
1182
|
+
assert.equal(healDeliveries[0][1], 'warning')
|
|
1183
|
+
assert.match(healDeliveries[0][0], /pink-day\.json/)
|
|
1184
|
+
|
|
1185
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
1186
|
+
setToastRetryDelaysForTests([2_000, 4_000])
|
|
1187
|
+
console.log('✓ toast feedback: follow/self-heal/shadow hints delivered, dropped and seam-less sends retried, legacy hosts silent and bounded')
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// ── 14. hot path: the token firehose must not reach render or the disk ──────
|
|
1191
|
+
{
|
|
1192
|
+
const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
|
|
1193
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
1194
|
+
// Earlier scenarios ran status activations against a different pref; drop
|
|
1195
|
+
// their cached answer so this scenario starts from the fresh sandbox state.
|
|
1196
|
+
invalidateThemePrefCacheForTests()
|
|
1197
|
+
const statusCalls = []
|
|
1198
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
1199
|
+
const { ctx, record } = makeStubCtx({
|
|
1200
|
+
status: fakeStatus(statusCalls),
|
|
1201
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
1202
|
+
})
|
|
1203
|
+
await applyAndSettle(ctx)
|
|
1204
|
+
const session = { id: 'h1' }
|
|
1205
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1206
|
+
const baselineCalls = statusCalls.length
|
|
1207
|
+
assert.match(statusCalls.at(-1)[1], /1✦$/)
|
|
1208
|
+
|
|
1209
|
+
// A firehose burst (streaming chunks, tool traffic, step brackets) updates
|
|
1210
|
+
// the tracked session but must render nothing and never touch the disk.
|
|
1211
|
+
const originalRead = builtinFs.readFileSync
|
|
1212
|
+
let prefReads = 0
|
|
1213
|
+
try {
|
|
1214
|
+
builtinFs.readFileSync = (path, ...rest) => {
|
|
1215
|
+
if (String(path) === themePrefPath) prefReads += 1
|
|
1216
|
+
return originalRead(path, ...rest)
|
|
1217
|
+
}
|
|
1218
|
+
syncBuiltinESMExports()
|
|
1219
|
+
for (let index = 0; index < 50; index += 1) {
|
|
1220
|
+
emit(record, 'session/event', session, {
|
|
1221
|
+
type: 'assistant/chunk',
|
|
1222
|
+
turn: 1,
|
|
1223
|
+
step: 1,
|
|
1224
|
+
chunk: { type: 'text', text: 'x' },
|
|
1225
|
+
})
|
|
1226
|
+
}
|
|
1227
|
+
emit(record, 'session/event', session, {
|
|
1228
|
+
type: 'tool/call', turn: 1, step: 1, callId: 'c1', name: 'tool', arguments: '{}',
|
|
1229
|
+
})
|
|
1230
|
+
emit(record, 'session/event', session, { type: 'step/end', turn: 1, step: 1 })
|
|
1231
|
+
emit(record, 'session/event', session, { type: 'todo/write', todos: [] })
|
|
1232
|
+
assert.equal(statusCalls.length, baselineCalls, 'firehose events render nothing')
|
|
1233
|
+
assert.equal(prefReads, 0, 'firehose events never read the theme pref')
|
|
1234
|
+
|
|
1235
|
+
// A turn boundary renders — the mtime gate costs one stat (not counted
|
|
1236
|
+
// here), and the warm cache answers without a read, so even boundary
|
|
1237
|
+
// renders stay off the read path.
|
|
1238
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1239
|
+
assert.equal(statusCalls.length, baselineCalls + 1, 'a turn boundary renders')
|
|
1240
|
+
assert.equal(prefReads, 0, 'the warm cache serves the boundary render')
|
|
1241
|
+
|
|
1242
|
+
// A session switch repaints at its first turn/start with a fresh count.
|
|
1243
|
+
const nextSession = { id: 'h2' }
|
|
1244
|
+
emit(record, 'session/event', nextSession, { type: 'turn/start' })
|
|
1245
|
+
assert.equal(statusCalls.length, baselineCalls + 2)
|
|
1246
|
+
assert.match(statusCalls.at(-1)[1], /0✦$/)
|
|
1247
|
+
assert.equal(prefReads, 0)
|
|
1248
|
+
|
|
1249
|
+
// A pref rewrite (the host's /theme switch) is seen at the very next
|
|
1250
|
+
// render: the stat notices the moved mtime and pays exactly one re-read,
|
|
1251
|
+
// and the non-pink pref hides the line — no waiting out the TTL (the
|
|
1252
|
+
// stale window used to show as wrong rich-view colors).
|
|
1253
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
|
|
1254
|
+
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
1255
|
+
assert.equal(statusCalls.length, baselineCalls + 3)
|
|
1256
|
+
assert.equal(statusCalls.at(-1)[1], undefined, 'the pref rewrite is seen at the next render')
|
|
1257
|
+
assert.equal(prefReads, 1, 'the moved mtime costs exactly one re-read')
|
|
1258
|
+
|
|
1259
|
+
// An unchanged pref keeps the cache warm: the render reruns, but the
|
|
1260
|
+
// hidden line's text is unchanged so set() is deduplicated away, and
|
|
1261
|
+
// no read happens.
|
|
1262
|
+
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
1263
|
+
assert.equal(statusCalls.length, baselineCalls + 3, 'an unchanged line does not rewrite the store')
|
|
1264
|
+
assert.equal(prefReads, 1, 'the unchanged mtime serves the cache')
|
|
1265
|
+
|
|
1266
|
+
// Test-only invalidation (the TTL-expiry stand-in) re-reads too.
|
|
1267
|
+
invalidateThemePrefCacheForTests()
|
|
1268
|
+
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
1269
|
+
assert.equal(prefReads, 2, 'invalidation re-reads the pref exactly once')
|
|
1270
|
+
assert.equal(statusCalls.at(-1)[1], undefined, 'the non-pink pref keeps the line hidden')
|
|
1271
|
+
} finally {
|
|
1272
|
+
builtinFs.readFileSync = originalRead
|
|
1273
|
+
syncBuiltinESMExports()
|
|
1274
|
+
invalidateThemePrefCacheForTests()
|
|
1275
|
+
}
|
|
1276
|
+
console.log('✓ hot path: firehose events render nothing and never read the pref; boundaries use the cache')
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// ── 15. settings panel UX: groups, ornament drafts, follow format ───────────
|
|
1280
|
+
{
|
|
1281
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
1282
|
+
rmSync(dataDir, { recursive: true, force: true })
|
|
1283
|
+
mkdirSync(dataDir, { recursive: true })
|
|
1284
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
1285
|
+
invalidateThemePrefCacheForTests()
|
|
1286
|
+
const statusCalls = []
|
|
1287
|
+
const sectionsCalls = []
|
|
1288
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
1289
|
+
const { ctx, record } = makeStubCtx({
|
|
1290
|
+
status: fakeStatus(statusCalls),
|
|
1291
|
+
sections: fakeSections(sectionsCalls),
|
|
1292
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
1293
|
+
})
|
|
1294
|
+
await applyAndSettle(ctx)
|
|
1295
|
+
const section = sectionsCalls[0]
|
|
1296
|
+
const fieldByPath = path =>
|
|
1297
|
+
section.fields.find(field => JSON.stringify(field.path) === JSON.stringify(path))
|
|
1298
|
+
|
|
1299
|
+
// Navigation groups: two subpages, every field assigned.
|
|
1300
|
+
assert.deepEqual(
|
|
1301
|
+
section.groups.map(group => group.id).sort(),
|
|
1302
|
+
['follow', 'status-line'],
|
|
1303
|
+
)
|
|
1304
|
+
assert.equal(fieldByPath(['followSystem']).group, 'follow')
|
|
1305
|
+
for (const path of [['showGlyph'], ['statusGlyph'], ['showClock'], ['showTurns'], ['statusSeparator'], ['statusScope']]) {
|
|
1306
|
+
assert.equal(fieldByPath(path).group, 'status-line')
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Ornament draft gate: 1–2 display cells, control chars refused, empty
|
|
1310
|
+
// resets to the built-in default, unset displays the effective value.
|
|
1311
|
+
const glyph = fieldByPath(['statusGlyph'])
|
|
1312
|
+
assert.equal(glyph.kind, 'text')
|
|
1313
|
+
assert.deepEqual(glyph.parse(''), { kind: 'clear' })
|
|
1314
|
+
assert.deepEqual(glyph.parse('❀'), { kind: 'set', value: '❀' })
|
|
1315
|
+
assert.deepEqual(glyph.parse('樱'), { kind: 'set', value: '樱' }, 'a 2-cell CJK char is allowed')
|
|
1316
|
+
assert.equal(glyph.parse('abc'), undefined, '3 cells are rejected')
|
|
1317
|
+
assert.equal(glyph.parse('a\u0007b'), undefined, 'control characters are rejected')
|
|
1318
|
+
assert.deepEqual(glyph.parse(' \u00A0 '), { kind: 'clear' }, 'whitespace-only drafts reset to the default')
|
|
1319
|
+
assert.equal(glyph.format(undefined), '✿', 'unset shows the effective default')
|
|
1320
|
+
assert.equal(glyph.format('❀'), '❀')
|
|
1321
|
+
const separator = fieldByPath(['statusSeparator'])
|
|
1322
|
+
assert.equal(separator.kind, 'text')
|
|
1323
|
+
assert.deepEqual(separator.parse('✦'), { kind: 'set', value: '✦' })
|
|
1324
|
+
assert.equal(separator.format(undefined), '·')
|
|
1325
|
+
|
|
1326
|
+
// followSystem format surfaces the cached follow state the startup would
|
|
1327
|
+
// consult — the "toggled on, nothing happened" reason, on the surface.
|
|
1328
|
+
const follow = fieldByPath(['followSystem'])
|
|
1329
|
+
assert.equal(follow.format(undefined), 'off', 'the cordis layer default is off')
|
|
1330
|
+
assert.equal(follow.format(false), 'off')
|
|
1331
|
+
assert.equal(follow.format(true), 'on(无缓存,启动时不动)')
|
|
1332
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: Date.UTC(2026, 8, 12) }))
|
|
1333
|
+
assert.match(follow.format(true), /^on(缓存: light · \d{4}-\d{2}-\d{2})$/)
|
|
1334
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: false }))
|
|
1335
|
+
assert.equal(follow.format(true), 'on(缓存: dark)', 'a cache without a timestamp omits the date')
|
|
1336
|
+
|
|
1337
|
+
// A committed ornament edit lands live on the scalar line.
|
|
1338
|
+
for (const watcher of settingsRecord.watchers) {
|
|
1339
|
+
watcher({ statusGlyph: '❀', statusSeparator: '~' })
|
|
1340
|
+
}
|
|
1341
|
+
emit(record, 'session/event', { id: 'u1' }, { type: 'turn/end' })
|
|
1342
|
+
assert.match(statusCalls.at(-1)[1], /^❀ ~ \d{2}:\d{2} ~ 1✦$/)
|
|
1343
|
+
|
|
1344
|
+
// Hand-edited config layers bypass the draft gate; the render side
|
|
1345
|
+
// sanitizes anyway (control chars stripped, capped at 2 cells).
|
|
1346
|
+
for (const watcher of settingsRecord.watchers) {
|
|
1347
|
+
watcher({ statusGlyph: 'x\u0007yz', statusSeparator: '~' })
|
|
1348
|
+
}
|
|
1349
|
+
emit(record, 'session/event', { id: 'u1' }, { type: 'turn/end' })
|
|
1350
|
+
assert.match(statusCalls.at(-1)[1], /^xy ~ /, 'control chars stripped and the rest capped at 2 cells')
|
|
1351
|
+
console.log('✓ settings panel: two groups, ornament drafts gated, follow format shows the cache state')
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ── 16. rich status view: themed one-row view on registerView-capable hosts ─
|
|
1355
|
+
{
|
|
1356
|
+
const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
|
|
1357
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
1358
|
+
invalidateThemePrefCacheForTests()
|
|
1359
|
+
const statusCalls = []
|
|
1360
|
+
const viewCalls = []
|
|
1361
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
1362
|
+
const { ctx, record } = makeStubCtx({
|
|
1363
|
+
status: fakeRichStatus(statusCalls, viewCalls),
|
|
1364
|
+
sections: fakeSections([]),
|
|
1365
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
1366
|
+
})
|
|
1367
|
+
await applyAndSettle(ctx)
|
|
1368
|
+
|
|
1369
|
+
assert.equal(viewCalls.length, 1, 'the rich view is registered exactly once')
|
|
1370
|
+
assert.equal(statusCalls.length, 0, 'the rich path never writes scalar text')
|
|
1371
|
+
const [descriptor, identity] = viewCalls[0]
|
|
1372
|
+
assert.equal(descriptor.key, 'dsh-tui-theme', 'the rich view keeps the shared contribution key')
|
|
1373
|
+
assert.equal(descriptor.maxRows, 1)
|
|
1374
|
+
assert.equal(typeof descriptor.component, 'function')
|
|
1375
|
+
assert.equal(identity?.tuiStatus !== undefined, true, 'identity is the inject-scoped context')
|
|
1376
|
+
|
|
1377
|
+
// Render the component against a minimal fake of the host kit: the element
|
|
1378
|
+
// tree must map the snapshot to themed Text cells.
|
|
1379
|
+
const renderComponent = () =>
|
|
1380
|
+
descriptor.component({
|
|
1381
|
+
React: {
|
|
1382
|
+
createElement: (type, props, ...children) => ({ type, props, children: children.length === 1 && Array.isArray(children[0]) ? children[0] : children }),
|
|
1383
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
1384
|
+
},
|
|
1385
|
+
ui: { Box: 'box', Text: 'text' },
|
|
1386
|
+
})
|
|
1387
|
+
const element = renderComponent()
|
|
1388
|
+
assert.equal(element.type, 'box', 'one row Box')
|
|
1389
|
+
assert.equal(element.children[0].type, 'text')
|
|
1390
|
+
assert.equal(element.children[0].props.color, 'rgb(242,123,166)', 'the glyph uses the brand key of the active palette')
|
|
1391
|
+
assert.equal(element.children[0].children[0], '✿')
|
|
1392
|
+
assert.equal(element.children[1].props.color, '#77646D', 'the separator uses the subtle key')
|
|
1393
|
+
assert.match(element.children[2].children[0], /^\d{2}:\d{2}$/)
|
|
1394
|
+
assert.equal(element.children[2].props.color, '#C4B0B9', 'the clock uses the bottom-bar inactiveShimmer key')
|
|
1395
|
+
|
|
1396
|
+
// Turn boundaries push through the store and land in the next render.
|
|
1397
|
+
const session = { id: 'r1' }
|
|
1398
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1399
|
+
const withTurns = renderComponent()
|
|
1400
|
+
assert.equal(withTurns.children.at(-1).children[0], '1✦')
|
|
1401
|
+
assert.equal(withTurns.children.at(-1).props.color, '#C4B0B9')
|
|
1402
|
+
|
|
1403
|
+
// A /theme switch (the host rewrites the pref) lands at the very next
|
|
1404
|
+
// render: rewrite the pref to pink-day and the palette moves with it,
|
|
1405
|
+
// without any TTL wait.
|
|
1406
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-day' }, null, 2))
|
|
1407
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1408
|
+
const dayView = renderComponent()
|
|
1409
|
+
assert.equal(dayView.children[0].props.color, 'rgb(222,110,150)', 'the glyph follows the new palette')
|
|
1410
|
+
assert.equal(dayView.children[2].props.color, '#9E6E82', 'the clock follows the new palette immediately')
|
|
1411
|
+
|
|
1412
|
+
// Toggles fold into the snapshot: all off renders nothing (no scalar
|
|
1413
|
+
// fallback either — the rich view just shows nothing).
|
|
1414
|
+
for (const watcher of settingsRecord.watchers) {
|
|
1415
|
+
watcher({ showGlyph: false, showClock: false, showTurns: false })
|
|
1416
|
+
}
|
|
1417
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1418
|
+
assert.equal(renderComponent(), null, 'all toggles off render nothing')
|
|
1419
|
+
|
|
1420
|
+
// Master switch off: registered but never visible.
|
|
1421
|
+
const quietCalls = []
|
|
1422
|
+
const quietViews = []
|
|
1423
|
+
const quietCtx = makeStubCtx({ status: fakeRichStatus(quietCalls, quietViews) })
|
|
1424
|
+
await applyAndSettle(quietCtx.ctx, { statusEnabled: false })
|
|
1425
|
+
assert.equal(quietViews.length, 1, 'the view is still registered')
|
|
1426
|
+
assert.equal(
|
|
1427
|
+
quietViews[0][0].component({
|
|
1428
|
+
React: {
|
|
1429
|
+
createElement: (type, props, ...children) => ({ type, props, children: children.length === 1 && Array.isArray(children[0]) ? children[0] : children }),
|
|
1430
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
1431
|
+
},
|
|
1432
|
+
ui: { Box: 'box', Text: 'text' },
|
|
1433
|
+
}),
|
|
1434
|
+
null,
|
|
1435
|
+
'statusEnabled=false renders nothing',
|
|
1436
|
+
)
|
|
1437
|
+
console.log('✓ rich status view: themed cells from the palette, mutual exclusion from set()')
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// ── 17. refused rich registration falls back to the scalar path ─────────────
|
|
1441
|
+
{
|
|
1442
|
+
const statusCalls = []
|
|
1443
|
+
const viewCalls = []
|
|
1444
|
+
const { ctx } = makeStubCtx({ status: fakeRichStatus(statusCalls, viewCalls, { refuse: true }) })
|
|
1445
|
+
await applyAndSettle(ctx)
|
|
1446
|
+
assert.equal(viewCalls.length, 1, 'the registration was attempted')
|
|
1447
|
+
assert.equal(statusCalls.length > 0, true, 'a refused registration (undefined) falls back to set()')
|
|
1448
|
+
console.log('✓ refused rich registration: the scalar path keeps the line alive')
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// ── 17b. hostile rich registration throws: warn, fall back, never propagate ─
|
|
1452
|
+
{
|
|
1453
|
+
const statusCalls = []
|
|
1454
|
+
const viewCalls = []
|
|
1455
|
+
const { ctx, record } = makeStubCtx({ status: fakeRichStatus(statusCalls, viewCalls, { throws: true }) })
|
|
1456
|
+
// applyAndSettle resolving at all is the non-propagation half of the
|
|
1457
|
+
// invariant: a synchronous throw escaping the inject callback would land here.
|
|
1458
|
+
await applyAndSettle(ctx)
|
|
1459
|
+
assert.equal(viewCalls.length, 1, 'the registration was attempted')
|
|
1460
|
+
assert.equal(statusCalls.length > 0, true, 'a throwing registration falls back to set()')
|
|
1461
|
+
assert.equal(
|
|
1462
|
+
record.warnings.some(msg => msg.includes('rich status view registration failed')),
|
|
1463
|
+
true,
|
|
1464
|
+
'the failure is warned, never propagated',
|
|
1465
|
+
)
|
|
1466
|
+
assert.equal(
|
|
1467
|
+
(record.handlers.get('session/event') ?? []).length >= 1,
|
|
1468
|
+
true,
|
|
1469
|
+
'the session wiring survived the throw',
|
|
1470
|
+
)
|
|
1471
|
+
console.log('✓ hostile rich registration: warn + scalar fallback, session wiring intact')
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// ── 18. shadow cleanup dialog: one confirm, byte-checked deletion ───────────
|
|
1475
|
+
{
|
|
1476
|
+
const seedShadow = () => {
|
|
1477
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
1478
|
+
mkdirSync(sandboxThemes, { recursive: true })
|
|
1479
|
+
for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
1480
|
+
writeFileSync(join(sandboxThemes, `${theme}.json`), readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'))
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// 18a. Dialog answered with "delete": the byte-identical copies are
|
|
1485
|
+
// removed and the result is toasted.
|
|
1486
|
+
seedShadow()
|
|
1487
|
+
const requests = []
|
|
1488
|
+
const answers = []
|
|
1489
|
+
const cleanDeliveries = []
|
|
1490
|
+
const cleanContext = makeStubCtx({
|
|
1491
|
+
deferThemes: true,
|
|
1492
|
+
toast: fakeToast(cleanDeliveries),
|
|
1493
|
+
dialogs: fakeDialogs(requests, answers),
|
|
1494
|
+
})
|
|
1495
|
+
apply(cleanContext.ctx)
|
|
1496
|
+
cleanContext.record.activateThemes(fakeThemes(cleanContext.record))
|
|
1497
|
+
await settle()
|
|
1498
|
+
assert.equal(requests.length, 1, 'exactly one confirm dialog is offered')
|
|
1499
|
+
assert.equal(requests[0].owner?.tuiDialogs !== undefined, true, 'the owner is the inject-scoped context')
|
|
1500
|
+
for (const file of ['pink-night.json', 'pink-day.json', 'pink-ansi.json']) {
|
|
1501
|
+
assert.ok(requests[0].request.message.includes(file), `the dialog names ${file}`)
|
|
1502
|
+
}
|
|
1503
|
+
assert.ok(requests[0].request.title.includes('旧主题文件'))
|
|
1504
|
+
answers[0](true)
|
|
1505
|
+
await settle()
|
|
1506
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), false, 'confirmation removes the shadowing copy')
|
|
1507
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), false, 'confirmation removes the shadowing copy')
|
|
1508
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false, 'confirmation removes the shadowing copy')
|
|
1509
|
+
const cleanToast = cleanDeliveries.find(delivery => delivery[0].includes('已清理'))
|
|
1510
|
+
assert.ok(cleanToast, 'the cleanup result is toasted')
|
|
1511
|
+
assert.equal(cleanToast[1], 'success')
|
|
1512
|
+
|
|
1513
|
+
// 18b. Declining keeps the previous behavior exactly: files stay, no
|
|
1514
|
+
// cleanup toast.
|
|
1515
|
+
seedShadow()
|
|
1516
|
+
const declineRequests = []
|
|
1517
|
+
const declineAnswers = []
|
|
1518
|
+
const declineDeliveries = []
|
|
1519
|
+
const declineContext = makeStubCtx({
|
|
1520
|
+
deferThemes: true,
|
|
1521
|
+
toast: fakeToast(declineDeliveries),
|
|
1522
|
+
dialogs: fakeDialogs(declineRequests, declineAnswers),
|
|
1523
|
+
})
|
|
1524
|
+
apply(declineContext.ctx)
|
|
1525
|
+
declineContext.record.activateThemes(fakeThemes(declineContext.record))
|
|
1526
|
+
await settle()
|
|
1527
|
+
assert.equal(declineRequests.length, 1, 'the dialog is offered again for the new activation')
|
|
1528
|
+
declineAnswers[0](false)
|
|
1529
|
+
await settle()
|
|
1530
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), true, 'declining keeps the file')
|
|
1531
|
+
assert.equal(
|
|
1532
|
+
declineDeliveries.some(delivery => delivery[0].includes('已清理')),
|
|
1533
|
+
false,
|
|
1534
|
+
'no cleanup toast without a confirmation',
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
// 18c. The dialogs seam arriving late still asks exactly once; a file the
|
|
1538
|
+
// user edited between detection and confirmation is never deleted.
|
|
1539
|
+
seedShadow()
|
|
1540
|
+
writeFileSync(join(sandboxThemes, 'pink-day.json'), '{ "name": "pink-day", "colors": { "text": "#123456" } }')
|
|
1541
|
+
const lateRequests = []
|
|
1542
|
+
const lateAnswers = []
|
|
1543
|
+
const lateContext = makeStubCtx({ deferThemes: true, deferDialogs: true, toast: fakeToast([]) })
|
|
1544
|
+
apply(lateContext.ctx)
|
|
1545
|
+
lateContext.record.activateThemes(fakeThemes(lateContext.record))
|
|
1546
|
+
await settle()
|
|
1547
|
+
assert.equal(lateRequests.length, 0, 'no dialog before the seam arrives')
|
|
1548
|
+
lateContext.record.activateDialogs(fakeDialogs(lateRequests, lateAnswers))
|
|
1549
|
+
await settle()
|
|
1550
|
+
assert.equal(lateRequests.length, 1, 'the parked offer fires when the seam arrives')
|
|
1551
|
+
lateAnswers[0](true)
|
|
1552
|
+
await settle()
|
|
1553
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), false, 'the still-identical file is removed')
|
|
1554
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), true, 'the edited file survives the byte check')
|
|
1555
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false)
|
|
1556
|
+
|
|
1557
|
+
// 18d. A hostile dialogs service must not take the activation down.
|
|
1558
|
+
seedShadow()
|
|
1559
|
+
const hostileContext = makeStubCtx({
|
|
1560
|
+
deferThemes: true,
|
|
1561
|
+
dialogs: { confirm() { throw new Error('dialog machinery broken') } },
|
|
1562
|
+
})
|
|
1563
|
+
apply(hostileContext.ctx)
|
|
1564
|
+
hostileContext.record.activateThemes(fakeThemes(hostileContext.record))
|
|
1565
|
+
await settle()
|
|
1566
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), true, 'a failed dialog deletes nothing')
|
|
1567
|
+
|
|
1568
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
1569
|
+
console.log('✓ shadow cleanup dialog: offered once, byte-checked deletion, decline/late/hostile all safe')
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
console.log('\nAll plugin verifications passed.')
|
|
1573
|
+
console.log(`(sandbox used: ${sandboxHome} — the real home was never touched)`)
|