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.
@@ -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 { installBundledThemes, readBundledThemes, findShadowedBundledThemes } = await import('../lib/types/themeAssets.js')
48
- const { startStatusLine, invalidateThemePrefCacheForTests } = await import('../lib/types/statusLine.js')
49
- const { setToastRetryDelaysForTests } = await import('../lib/types/toast.js')
50
- const {
51
- themeForBackground,
52
- readThemePref,
53
- writeThemePref,
54
- readFollowCache,
55
- applyCachedFollow,
56
- runFollowSystem,
57
- } = await import('../lib/types/autoTheme.js')
58
-
59
- assert.equal(name, 'dsh-tui-theme')
60
- const settle = () => new Promise(resolve => setTimeout(resolve, 0))
61
- const applyAndSettle = async (ctx, config) => {
62
- apply(ctx, config)
63
- await settle()
64
- }
65
-
66
- /** A stub Cordis-like context; every seam optional and recorded. */
67
- function makeStubCtx({ status, sections, settingsService, themes, toast, dialogs, deferThemes = false, deferToast = false, deferDialogs = false } = {}) {
68
- const record = { handlers: new Map(), disposers: [], statusCalls: [], sectionsCalls: [], registerCalls: [], watchers: [], themeRegisters: [], warnings: [], infos: [] }
69
- let availableThemes = themes
70
- let availableToast = toast
71
- let availableDialogs = dialogs
72
- const deferredThemeCallbacks = []
73
- const deferredToastCallbacks = []
74
- const deferredDialogsCallbacks = []
75
- const logger = { info: msg => record.infos.push(String(msg)), warn: msg => record.warnings.push(String(msg)), error: () => {} }
76
- const base = {
77
- logger,
78
- get(serviceName) {
79
- if (serviceName === 'tuiStatus') return status
80
- if (serviceName === 'tuiSettingsSections') return sections
81
- if (serviceName === 'tuiThemes') return availableThemes
82
- if (serviceName === 'tuiToast') return availableToast
83
- if (serviceName === 'tuiDialogs') return availableDialogs
84
- return undefined
85
- },
86
- on(event, handler) {
87
- const list = record.handlers.get(event) ?? []
88
- list.push(handler)
89
- record.handlers.set(event, list)
90
- return () => {}
91
- },
92
- effect(factory) {
93
- const dispose = factory()
94
- if (typeof dispose === 'function') record.disposers.push(dispose)
95
- },
96
- inject(deps, callback) {
97
- // Simulate cordis: the callback runs once every requested service
98
- // exists (immediately here), and property access works inside it.
99
- const services = {
100
- settings: settingsService,
101
- tuiStatus: status,
102
- tuiSettingsSections: sections,
103
- tuiThemes: availableThemes,
104
- tuiToast: availableToast,
105
- tuiDialogs: availableDialogs,
106
- }
107
- if (deferThemes && availableThemes === undefined && deps.includes('tuiThemes')) {
108
- deferredThemeCallbacks.push(callback)
109
- return
110
- }
111
- if (deferToast && availableToast === undefined && deps.includes('tuiToast')) {
112
- deferredToastCallbacks.push(callback)
113
- return
114
- }
115
- if (deferDialogs && availableDialogs === undefined && deps.includes('tuiDialogs')) {
116
- deferredDialogsCallbacks.push(callback)
117
- return
118
- }
119
- if (deps.every(dep => services[dep] !== undefined)) {
120
- const props = Object.fromEntries(deps.map(dep => [dep, services[dep]]))
121
- callback({ ...base, ...props })
122
- }
123
- },
124
- }
125
- record.activateThemes = service => {
126
- availableThemes = service
127
- for (const callback of deferredThemeCallbacks.splice(0)) callback({ ...base, tuiThemes: service })
128
- }
129
- record.activateToast = service => {
130
- availableToast = service
131
- for (const callback of deferredToastCallbacks.splice(0)) callback({ ...base, tuiToast: service })
132
- }
133
- record.activateDialogs = service => {
134
- availableDialogs = service
135
- for (const callback of deferredDialogsCallbacks.splice(0)) callback({ ...base, tuiDialogs: service })
136
- }
137
- return { ctx: base, record }
138
- }
139
-
140
- const fakeStatus = calls => ({ set(key, text) { calls.push([key, text]); return () => {} } })
141
- /**
142
- * A 0.10.1+ status service: registerView present. `refuse` simulates the
143
- * host rejecting the registration (returns undefined, warning invisible);
144
- * `throws` simulates a hostile service whose registration throws — the
145
- * plugin must warn and fall back, never propagate.
146
- */
147
- const fakeRichStatus = (calls, viewCalls, { refuse = false, throws = false } = {}) => ({
148
- set(key, text) { calls.push([key, text]); return () => {} },
149
- registerView(descriptor, identity) {
150
- viewCalls.push([descriptor, identity])
151
- if (throws) throw new Error('status view registry unavailable')
152
- if (refuse) return undefined
153
- return () => {}
154
- },
155
- })
156
- const fakeSections = calls => ({ register(section) { calls.push(section); return () => {} } })
157
- const fakeThemes = (record, { throws = false } = {}) => ({
158
- register(descriptor, identity) {
159
- record.themeRegisters.push([descriptor, identity])
160
- if (throws) throw new Error('runtime registry unavailable')
161
- return () => {}
162
- },
163
- })
164
- const fakeSettingsService = (record, doc) => ({
165
- register(namespace, schema) {
166
- record.registerCalls.push([namespace, schema])
167
- return {
168
- get: () => doc,
169
- watch(listener) { record.watchers.push(listener); return () => {} },
170
- }
171
- },
172
- })
173
- /** Records delivered toasts; the first `dropFirst` shows are dropped (no sink yet). */
174
- const fakeToast = (deliveries, { dropFirst = 0 } = {}) => {
175
- let shown = 0
176
- return {
177
- show(text, options) {
178
- shown += 1
179
- if (shown <= dropFirst) return false
180
- deliveries.push([String(text), options?.color])
181
- return true
182
- },
183
- }
184
- }
185
- /**
186
- * A tuiDialogs stub: records confirm requests, answers them from the test
187
- * via the returned queue (each entry resolves one dialog).
188
- */
189
- const fakeDialogs = (requests, answers) => ({
190
- confirm(owner, request) {
191
- requests.push({ owner, request })
192
- return new Promise(resolve => {
193
- answers.push(resolve)
194
- })
195
- },
196
- })
197
-
198
- const emit = (record, event, ...args) => {
199
- for (const handler of record.handlers.get(event) ?? []) handler(...args)
200
- }
201
-
202
- // ── 1. bare host: no seam present → theme assets still install (pure fs),
203
- // but nothing UI-facing happens ────────────────────────────────────────
204
- {
205
- const { ctx, record } = makeStubCtx()
206
- apply(ctx)
207
- assert.deepEqual(record.warnings, [])
208
- assert.equal(record.handlers.size, 0, 'no event handlers without the status seam')
209
- for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
210
- assert.equal(existsSync(join(sandboxThemes, `${theme}.json`)), true, `${theme}.json installed`)
211
- }
212
- console.log('✓ bare host (no seams): themes installed, nothing UI-facing')
213
- }
214
-
215
- // ── 2. full host: everything wired ──────────────────────────────────────────
216
- {
217
- rmSync(sandboxThemes, { recursive: true, force: true })
218
- // A pink theme is active — the default policy hides the line otherwise.
219
- mkdirSync(join(sandboxHome, '.dsh-tui'), { recursive: true })
220
- writeFileSync(join(sandboxHome, '.dsh-tui', 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
221
- const statusCalls = []
222
- const sectionsCalls = []
223
- const settingsRecord = { registerCalls: [], watchers: [] }
224
- const { ctx, record } = makeStubCtx({
225
- status: fakeStatus(statusCalls),
226
- sections: fakeSections(sectionsCalls),
227
- settingsService: fakeSettingsService(settingsRecord, {}),
228
- })
229
- await applyAndSettle(ctx)
230
-
231
- // Themes installed into the SANDBOX, never the real home.
232
- for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
233
- assert.equal(existsSync(join(sandboxThemes, `${theme}.json`)), true, `${theme}.json installed`)
234
- }
235
-
236
- // Status line: one keyed contribution with glyph · clock.
237
- assert.equal(statusCalls.length > 0, true)
238
- const [key, first] = statusCalls[0]
239
- assert.equal(key, 'dsh-tui-theme')
240
- assert.match(first, /^✿ · \d{2}:\d{2}$/)
241
-
242
- // Turns counted from session events (live, no log appends).
243
- const session = { id: 's1' }
244
- emit(record, 'session/event', session, { type: 'turn/end' })
245
- emit(record, 'session/event', session, { type: 'turn/end' })
246
- const latest = statusCalls.at(-1)[1]
247
- assert.match(latest, /^✿ · \d{2}:\d{2} · 2✦$/)
248
-
249
- // Settings namespace registered and the section declaration remains within
250
- // the shared host form contract.
251
- assert.equal(settingsRecord.registerCalls.length, 1)
252
- assert.equal(sectionsCalls.length, 1)
253
- assertSettingsContract(assert, sectionsCalls[0])
254
-
255
- // A committed /settings edit lands live on the next render.
256
- for (const watcher of settingsRecord.watchers) {
257
- watcher({ showGlyph: false, showClock: false })
258
- }
259
- emit(record, 'session/event', session, { type: 'turn/end' })
260
- assert.match(statusCalls.at(-1)[1], /^3✦$/)
261
-
262
- // Disposal never throws.
263
- for (const dispose of record.disposers) dispose()
264
- console.log('✓ full host: themes installed, status line live, settings wired')
265
- }
266
-
267
- // ── 2a. runtime themes: service owns palettes and static files stay absent ───
268
- {
269
- rmSync(sandboxThemes, { recursive: true, force: true })
270
- const registrations = []
271
- const themes = { register(descriptor, identity) { registrations.push([descriptor, identity]); return () => {} } }
272
- const { ctx } = makeStubCtx({ themes })
273
- await applyAndSettle(ctx)
274
- assert.equal(existsSync(sandboxThemes), false, 'runtime registration must not create static files')
275
- const expected = readBundledThemes().map(({ file, ...theme }) => theme)
276
- assert.deepEqual(registrations.map(([descriptor]) => descriptor), expected)
277
- assert.equal(
278
- registrations.every(([, identity]) => identity?.tuiThemes !== undefined),
279
- true,
280
- 'runtime registrations use the inject-scoped identity',
281
- )
282
- console.log('✓ runtime themes: three descriptors registered without static files')
283
- }
284
-
285
- // ── 2b. late runtime service: confirmation removes this activation's files ──
286
- {
287
- rmSync(sandboxThemes, { recursive: true, force: true })
288
- const { ctx, record } = makeStubCtx({ deferThemes: true })
289
- apply(ctx)
290
- assert.equal(existsSync(sandboxThemes), true, 'legacy fallback must install synchronously before service arrival')
291
- record.activateThemes(fakeThemes(record))
292
- await settle()
293
- assert.equal(existsSync(sandboxThemes), false, 'late runtime service must remove all fallback files and the empty directory')
294
-
295
- rmSync(sandboxThemes, { recursive: true, force: true })
296
- const protectedContext = makeStubCtx({ deferThemes: true })
297
- apply(protectedContext.ctx)
298
- assert.equal(existsSync(sandboxThemes), true, 'legacy fallback must install before protecting a user edit')
299
- const protectedTheme = join(sandboxThemes, 'pink-night.json')
300
- writeFileSync(protectedTheme, '{ "name": "pink-night", "colors": { "text": "#123456" } }')
301
- protectedContext.record.activateThemes(fakeThemes(protectedContext.record))
302
- await settle()
303
- assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), false, 'late runtime service must remove plugin-owned files')
304
- assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false, 'late runtime service must remove plugin-owned files')
305
- assert.equal(readFileSync(protectedTheme, 'utf8'), '{ "name": "pink-night", "colors": { "text": "#123456" } }')
306
- rmSync(sandboxThemes, { recursive: true, force: true })
307
- console.log('✓ runtime race: late service wins and removes static fallback files')
308
- }
309
-
310
- // ── 2c. hostile runtime service: registration failures degrade to warnings ───
311
- {
312
- const themeRecord = { themeRegisters: [] }
313
- const { ctx, record } = makeStubCtx({ themes: fakeThemes(themeRecord, { throws: true }) })
314
- await applyAndSettle(ctx)
315
- assert.equal(record.warnings.length, 3, 'each hostile registration is contained and warned')
316
- console.log('✓ hostile runtime service: registration failures warn, never propagate')
317
- }
318
-
319
- // ── 3. idempotence + user-file protection ───────────────────────────────────
320
- {
321
- const seeded = installBundledThemes()
322
- assert.equal(seeded.installed.length, 3)
323
- const again = installBundledThemes()
324
- assert.deepEqual(again.installed, [])
325
- assert.deepEqual(again.repaired, [])
326
- assert.equal(again.skipped.length, 3)
327
-
328
- // A user-edited same-named file must survive reinstallation.
329
- const userFile = join(sandboxThemes, 'pink-night.json')
330
- writeFileSync(userFile, '{ "name": "pink-night", "base": "dark", "colors": { "text": "#123456" } }')
331
- const third = installBundledThemes()
332
- assert.deepEqual(third.installed, [])
333
- assert.equal(JSON.parse(readFileSync(userFile, 'utf8')).colors.text, '#123456')
334
- console.log('✓ reinstall: skips existing files, never overwrites user edits')
335
- }
336
-
337
- // ── 4. autoInstallThemes: false on a clean sandbox ──────────────────────────
338
- {
339
- rmSync(sandboxHome, { recursive: true, force: true })
340
- mkdirSync(sandboxHome, { recursive: true })
341
- const { ctx } = makeStubCtx()
342
- await applyAndSettle(ctx, { autoInstallThemes: false })
343
- assert.equal(existsSync(join(sandboxHome, '.dsh-tui')), false, 'must not create the dir when disabled')
344
- console.log('✓ autoInstallThemes=false: leaves the themes dir untouched')
345
- }
346
-
347
- // ── 5. cached background follow: no terminal I/O ───────────────────────────
348
- {
349
- const dataDir = join(sandboxHome, '.dsh-tui')
350
- mkdirSync(dataDir, { recursive: true })
351
- assert.equal(themeForBackground(true), 'pink-day')
352
- assert.equal(themeForBackground(false), 'pink-night')
353
- assert.equal(applyCachedFollow(dataDir), undefined, 'no cache preserves the existing choice')
354
-
355
- writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
356
- assert.equal(applyCachedFollow(dataDir), 'pink-day')
357
- assert.equal(readFollowCache(dataDir).light, true)
358
- assert.equal(readThemePref(dataDir), 'pink-day')
359
-
360
- // Same value -> no rewrite churn (mtime-agnostic: pref already matches).
361
- assert.equal(applyCachedFollow(dataDir), 'pink-day')
362
-
363
- // The follow runner only applies cache and reports its structured outcome.
364
- // It accepts no stdin/stdout handles and cannot create a raw-mode lease or
365
- // input listener.
366
- const reports = []
367
- runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
368
- assert.deepEqual(reports, [{ kind: 'applied', theme: 'pink-day', changed: false }])
369
-
370
- writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
371
- runFollowSystem(dataDir, () => false, outcome => reports.push(outcome))
372
- assert.equal(readThemePref(dataDir), 'dark', 'inactive follow preserves manual choice')
373
-
374
- runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
375
- assert.deepEqual(
376
- reports.at(-1),
377
- { kind: 'applied', theme: 'pink-day', changed: true },
378
- 'a real pref flip is reported as changed',
379
- )
380
-
381
- rmSync(join(dataDir, 'theme-follow.json'), { force: true })
382
- runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
383
- assert.deepEqual(reports.at(-1), { kind: 'unavailable' })
384
- console.log('✓ follow: cached background applies without terminal I/O')
385
- }
386
-
387
- // ── 6. statusEnabled: false silences the whole line ─────────────────────────
388
- {
389
- const statusCalls = []
390
- const { ctx } = makeStubCtx({ status: fakeStatus(statusCalls) })
391
- await applyAndSettle(ctx, { statusEnabled: false })
392
- assert.equal(statusCalls.length > 0, true, 'render still ran once')
393
- // Every contribution is cleared (undefined), not rendered.
394
- for (const [, text] of statusCalls) assert.equal(text, undefined)
395
- console.log('✓ statusEnabled=false: the line contributes nothing')
396
- }
397
-
398
- // ── 7. followSystem honors the /settings user layer with cached state ───────
399
- {
400
- const dataDir = join(sandboxHome, '.dsh-tui')
401
- rmSync(join(dataDir, 'theme-follow.json'), { force: true })
402
- rmSync(join(dataDir, 'theme.json'), { force: true })
403
- const settingsRecord = { registerCalls: [], watchers: [] }
404
- const { ctx } = makeStubCtx({
405
- status: fakeStatus([]),
406
- sections: fakeSections([]),
407
- settingsService: fakeSettingsService(settingsRecord, {}),
408
- })
409
- await applyAndSettle(ctx, { followSystem: true })
410
- // Cordis layer on, empty user layer, no cache yet -> no pref churn.
411
- assert.equal(existsSync(join(dataDir, 'theme.json')), false)
412
-
413
- // User disables follow via /settings; a stale cached flip must not write.
414
- for (const w of settingsRecord.watchers) w({ followSystem: false })
415
- writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
416
- assert.equal(existsSync(join(dataDir, 'theme.json')), false, 'disabled follow must not rewrite the pref')
417
-
418
- // Re-enable → the cached background applies immediately.
419
- for (const w of settingsRecord.watchers) w({ followSystem: true })
420
- assert.equal(readThemePref(dataDir), 'pink-day')
421
- console.log('✓ followSystem: /settings toggle decides live (off = pref preserved)')
422
- }
423
-
424
- // ── 8. theme gating: the line belongs to the pink palettes ──────────────────
425
- {
426
- const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
427
- writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
428
- const statusCalls = []
429
- const settingsRecord = { registerCalls: [], watchers: [] }
430
- const { ctx, record } = makeStubCtx({
431
- status: fakeStatus(statusCalls),
432
- settingsService: fakeSettingsService(settingsRecord, {}),
433
- })
434
- await applyAndSettle(ctx)
435
- const session = { id: 'g1' }
436
-
437
- // Pink active → renders.
438
- emit(record, 'session/event', session, { type: 'turn/end' })
439
- assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 1✦$/)
440
-
441
- // Non-pink theme active → hidden by default. The pref cache is dropped so
442
- // the rewrite is visible immediately (production invalidation is the TTL).
443
- writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
444
- invalidateThemePrefCacheForTests()
445
- emit(record, 'session/event', session, { type: 'turn/end' })
446
- assert.equal(statusCalls.at(-1)[1], undefined)
447
-
448
- // Opt in via /settings → shown on non-pink too (the turn count kept
449
- // ticking while the line was hidden — turns count since the TUI started).
450
- for (const w of settingsRecord.watchers) w({ statusScope: 'all-themes' })
451
- emit(record, 'session/event', session, { type: 'turn/end' })
452
- assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 3✦$/)
453
-
454
- // Host precedence: DSH_TUI_THEME wins over the dark pref.
455
- const baselineThemeOverride = process.env.DSH_TUI_THEME
456
- try {
457
- process.env.DSH_TUI_THEME = 'pink-day'
458
- for (const w of settingsRecord.watchers) w({})
459
- emit(record, 'session/event', session, { type: 'turn/end' })
460
- assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 4✦$/)
461
- } finally {
462
- if (baselineThemeOverride === undefined) {
463
- delete process.env.DSH_TUI_THEME
464
- } else {
465
- process.env.DSH_TUI_THEME = baselineThemeOverride
466
- }
467
- }
468
- console.log('✓ theme gating: pink-only by default, opt-in shows everywhere')
469
- }
470
-
471
- // ── 9. settings service hostility: registration throws → warn, never crash ─
472
- {
473
- const throwingSettings = {
474
- register() { throw new Error('namespace already registered (hot reload)') },
475
- }
476
- const { ctx, record } = makeStubCtx({ settingsService: throwingSettings })
477
- await applyAndSettle(ctx) // must not throw
478
- assert.equal(record.warnings.length, 1, 'registration failure logs one warning')
479
- assert.match(record.warnings[0], /settings namespace registration failed/)
480
- console.log('✓ hostile settings service: registration failure warns, never propagates')
481
- }
482
-
483
- // ── 10. missing settings: never override a manual theme choice ─────────────
484
- {
485
- const dataDir = join(sandboxHome, '.dsh-tui')
486
- mkdirSync(dataDir, { recursive: true })
487
- writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
488
- writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
489
- const { ctx } = makeStubCtx()
490
- await applyAndSettle(ctx, { followSystem: true })
491
- assert.equal(readThemePref(dataDir), 'dark', 'without settings, cached follow must not override manual choice')
492
- console.log('✓ missing settings: cached follow leaves the manual choice intact')
493
- }
494
-
495
- // ── 11. filesystem commits: atomic replacement and exclusive creation ───────
496
- {
497
- const dataDir = join(sandboxHome, '.dsh-tui')
498
- mkdirSync(dataDir, { recursive: true })
499
- const pref = join(dataDir, 'theme.json')
500
- writeFileSync(pref, JSON.stringify({ theme: 'dark' }, null, 2))
501
- const originalRename = builtinFs.renameSync
502
- try {
503
- builtinFs.renameSync = () => { throw new Error('rename blocked') }
504
- syncBuiltinESMExports()
505
- assert.equal(writeThemePref('pink-day', dataDir), false)
506
- assert.equal(readThemePref(dataDir), 'dark', 'failed commit leaves the prior JSON readable')
507
- assert.equal(
508
- readdirSync(dataDir).some(file => file.startsWith('.theme.json.') && file.endsWith('.tmp')),
509
- false,
510
- 'failed commit removes its temporary file',
511
- )
512
- } finally {
513
- builtinFs.renameSync = originalRename
514
- syncBuiltinESMExports()
515
- }
516
-
517
- const targetDir = join(sandboxHome, 'race-target')
518
- const originalWrite = builtinFs.writeFileSync
519
- let racedFile
520
- try {
521
- builtinFs.writeFileSync = (path, data, options) => {
522
- const target = String(path)
523
- if (racedFile === undefined && target.startsWith(targetDir) && options?.flag === 'wx') {
524
- racedFile = target.split(/[\\/]/).at(-1)
525
- originalWrite(path, '{ "user": true }', { flag: 'wx' })
526
- }
527
- return originalWrite(path, data, options)
528
- }
529
- syncBuiltinESMExports()
530
- const result = installBundledThemes(targetDir, join(pluginRoot, 'themes'))
531
- assert.equal(typeof racedFile, 'string', 'the test injected a competing creator')
532
- assert.equal(result.skipped.includes(racedFile), true, 'EEXIST is a protected user-file skip')
533
- assert.deepEqual(JSON.parse(readFileSync(join(targetDir, racedFile), 'utf8')), { user: true })
534
- } finally {
535
- builtinFs.writeFileSync = originalWrite
536
- syncBuiltinESMExports()
537
- }
538
- console.log('✓ filesystem commits: failed atomic writes preserve JSON; competing theme creation skips')
539
- }
540
-
541
- // ── 12a. torn installation: a corrupt target is backed up and reinstalled ───
542
- {
543
- const targetDir = join(sandboxHome, 'heal-target')
544
- const sourceDir = join(pluginRoot, 'themes')
545
- mkdirSync(targetDir, { recursive: true })
546
-
547
- // A valid user file stays untouched — the never-overwrite rule wins.
548
- writeFileSync(join(targetDir, 'pink-night.json'), '{ "user": true }')
549
- // A torn write (crash mid-install) leaves invalid JSON behind.
550
- writeFileSync(join(targetDir, 'pink-day.json'), '{ "name": "pink-day", "colors": {')
551
- const heal = installBundledThemes(targetDir, sourceDir)
552
- assert.deepEqual(heal.repaired, ['pink-day.json'], 'the corrupt target is reported as repaired')
553
- assert.equal(heal.skipped.includes('pink-night.json'), true)
554
- assert.equal(heal.failed.length, 0)
555
- assert.equal(
556
- JSON.parse(readFileSync(join(targetDir, 'pink-night.json'), 'utf8')).user,
557
- true,
558
- 'valid user file untouched',
559
- )
560
- const reinstalled = JSON.parse(readFileSync(join(targetDir, 'pink-day.json'), 'utf8'))
561
- assert.equal(reinstalled.name, 'pink-day')
562
- const backups = readdirSync(targetDir).filter(entry =>
563
- entry.startsWith('pink-day.json.corrupt-'),
564
- )
565
- assert.equal(backups.length, 1, 'the damaged file is preserved as a timestamped backup')
566
- assert.equal(readFileSync(join(targetDir, backups[0]), 'utf8'), '{ "name": "pink-day", "colors": {')
567
-
568
- // Next boot: everything parses, so no churn.
569
- const steady = installBundledThemes(targetDir, sourceDir)
570
- assert.deepEqual(steady.repaired, [])
571
- assert.deepEqual(steady.installed, [])
572
- assert.equal(steady.skipped.length, 3)
573
-
574
- // A target the process cannot even read is not proven corrupt — keep skipping.
575
- const unreadable = join(targetDir, 'pink-ansi.json')
576
- const originalRead = builtinFs.readFileSync
577
- try {
578
- builtinFs.readFileSync = (path, ...rest) => {
579
- if (String(path) === unreadable) throw new Error('EBUSY: locked')
580
- return originalRead(path, ...rest)
581
- }
582
- syncBuiltinESMExports()
583
- const blocked = installBundledThemes(targetDir, sourceDir)
584
- assert.deepEqual(blocked.repaired, [])
585
- assert.equal(blocked.skipped.includes('pink-ansi.json'), true)
586
- } finally {
587
- builtinFs.readFileSync = originalRead
588
- syncBuiltinESMExports()
589
- }
590
-
591
- // A successful backup followed by a failed replacement is a failure, not a
592
- // protected skip: the target is absent until the next boot can retry.
593
- const failedHeal = join(targetDir, 'pink-ansi.json')
594
- writeFileSync(failedHeal, '{ broken')
595
- const originalWrite = builtinFs.writeFileSync
596
- try {
597
- builtinFs.writeFileSync = (path, data, options) => {
598
- if (String(path) === failedHeal && options?.flag === 'wx' && !existsSync(failedHeal)) {
599
- throw new Error('ENOSPC: replacement blocked')
600
- }
601
- return originalWrite(path, data, options)
602
- }
603
- syncBuiltinESMExports()
604
- const failed = installBundledThemes(targetDir, sourceDir)
605
- assert.equal(failed.failed.includes('pink-ansi.json'), true)
606
- assert.equal(failed.skipped.includes('pink-ansi.json'), false)
607
- } finally {
608
- builtinFs.writeFileSync = originalWrite
609
- syncBuiltinESMExports()
610
- }
611
- console.log('✓ torn installation: corrupt target backed up and reinstalled; user files and unreadable targets untouched')
612
- }
613
-
614
- // ── 12b. follow logging: the first settings doc is a baseline, not a flip ──
615
- {
616
- // Default user layer (empty doc): no spurious "follow: disabled" line.
617
- const settingsRecord = { registerCalls: [], watchers: [] }
618
- const { ctx, record } = makeStubCtx({ settingsService: fakeSettingsService(settingsRecord, {}) })
619
- await applyAndSettle(ctx)
620
- assert.equal(
621
- record.infos.some(message => message.includes('follow: disabled')),
622
- false,
623
- 'a baseline doc matching the default must not log a disabled flip',
624
- )
625
-
626
- // User layer that starts enabled: the cache applies during the baseline.
627
- const dataDir = join(sandboxHome, '.dsh-tui')
628
- writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
629
- const enabledRecord = { registerCalls: [], watchers: [] }
630
- const { ctx: enabledCtx, record: enabledInfo } = makeStubCtx({
631
- status: fakeStatus([]),
632
- sections: fakeSections([]),
633
- settingsService: fakeSettingsService(enabledRecord, { followSystem: true }),
634
- })
635
- await applyAndSettle(enabledCtx)
636
- assert.equal(readThemePref(dataDir), 'pink-day', 'an enabled baseline applies the cached background')
637
- assert.equal(
638
- enabledInfo.infos.some(message => message.includes('follow: disabled')),
639
- false,
640
- )
641
-
642
- // A real toggle keeps its log.
643
- for (const watcher of enabledRecord.watchers) watcher({ followSystem: false })
644
- assert.equal(
645
- enabledInfo.infos.filter(message => message.includes('follow: disabled')).length,
646
- 1,
647
- 'disabling follow after the baseline logs exactly once',
648
- )
649
- assert.equal(readThemePref(dataDir), 'pink-day', 'the manual choice stays intact')
650
- console.log('✓ follow logging: baseline docs stay quiet, real toggles still log')
651
- }
652
-
653
- // ── 12. status injection: session handlers die with tuiStatus activation ────
654
- {
655
- const outerHandlers = new Map()
656
- let activate
657
- const outerCtx = {
658
- on(event, handler) {
659
- const handlers = outerHandlers.get(event) ?? []
660
- handlers.push(handler)
661
- outerHandlers.set(event, handlers)
662
- return () => {}
663
- },
664
- inject(_deps, callback) { activate = callback },
665
- }
666
- const makeActivation = calls => {
667
- const handlers = new Map()
668
- const disposers = []
669
- return {
670
- tuiStatus: fakeStatus(calls),
671
- on(event, handler) {
672
- const eventHandlers = handlers.get(event) ?? []
673
- eventHandlers.push(handler)
674
- handlers.set(event, eventHandlers)
675
- return () => handlers.set(event, eventHandlers.filter(entry => entry !== handler))
676
- },
677
- effect(factory) {
678
- const dispose = factory()
679
- if (typeof dispose === 'function') disposers.push(dispose)
680
- },
681
- emit(event, ...args) {
682
- for (const handler of handlers.get(event) ?? []) handler(...args)
683
- },
684
- dispose() {
685
- handlers.clear()
686
- for (const dispose of disposers) dispose()
687
- },
688
- handlerCount(event) { return (handlers.get(event) ?? []).length },
689
- }
690
- }
691
- const effective = {
692
- statusEnabled: true,
693
- showGlyph: false,
694
- showClock: false,
695
- showTurns: true,
696
- statusScope: 'all-themes',
697
- }
698
- startStatusLine(outerCtx, () => effective)
699
-
700
- const firstCalls = []
701
- const first = makeActivation(firstCalls)
702
- activate(first)
703
- assert.equal(outerHandlers.size, 0, 'status activation must not register outer-owned session handlers')
704
- assert.equal(first.handlerCount('session/event'), 1)
705
- first.emit('session/event', { id: 'first' }, { type: 'turn/end' })
706
- assert.equal(firstCalls.at(-1)[1], '1✦')
707
- first.dispose()
708
- first.emit('session/event', { id: 'stale' }, { type: 'turn/end' })
709
- assert.equal(firstCalls.at(-1)[1], '1✦', 'disposed activation ignores later session events')
710
-
711
- const secondCalls = []
712
- const second = makeActivation(secondCalls)
713
- activate(second)
714
- second.emit('session/event', { id: 'second' }, { type: 'turn/end' })
715
- assert.equal(secondCalls.at(-1)[1], '1✦', 'replacement activation owns the only live handler')
716
- assert.equal(firstCalls.at(-1)[1], '1✦')
717
- second.dispose()
718
- console.log('✓ status lifecycle: session handlers follow tuiStatus activation')
719
- }
720
-
721
- // ── 13. toast feedback: the 0.10 seam surfaces what the logger cannot ───────
722
- {
723
- setToastRetryDelaysForTests([5, 5])
724
- const dataDir = join(sandboxHome, '.dsh-tui')
725
- mkdirSync(dataDir, { recursive: true })
726
-
727
- // 13a. Startup baseline with a disagreeing cache: one success toast that
728
- // points at /reload (the live TUI still shows the previous palette). The
729
- // tuiToast seam trails apply on a real host (extensions row), so the send
730
- // must survive on the bounded retry until the seam shows up.
731
- writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
732
- writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
733
- const baselineDeliveries = []
734
- const baselineRecord = { registerCalls: [], watchers: [] }
735
- const baselineContext = makeStubCtx({
736
- status: fakeStatus([]),
737
- sections: fakeSections([]),
738
- settingsService: fakeSettingsService(baselineRecord, { followSystem: true }),
739
- deferToast: true,
740
- })
741
- await applyAndSettle(baselineContext.ctx)
742
- assert.equal(readThemePref(dataDir), 'pink-day', 'the pref write is synchronous, independent of the toast seam')
743
- baselineContext.record.activateToast(fakeToast(baselineDeliveries))
744
- const baselineDeadline = Date.now() + 2_000
745
- while (baselineDeliveries.length === 0 && Date.now() < baselineDeadline) {
746
- await new Promise(resolve => setTimeout(resolve, 5))
747
- }
748
- assert.equal(baselineDeliveries.length, 1, 'a real baseline pref write toasts exactly once')
749
- assert.equal(baselineDeliveries[0][1], 'success')
750
- assert.match(baselineDeliveries[0][0], /pink-day/)
751
- assert.match(baselineDeliveries[0][0], /reload/)
752
-
753
- // 13b. Stable baseline (pref already matches the cache): quiet.
754
- const quietDeliveries = []
755
- const quietRecord = { registerCalls: [], watchers: [] }
756
- const { ctx: quietCtx } = makeStubCtx({
757
- status: fakeStatus([]),
758
- sections: fakeSections([]),
759
- settingsService: fakeSettingsService(quietRecord, { followSystem: true }),
760
- toast: fakeToast(quietDeliveries),
761
- })
762
- await applyAndSettle(quietCtx)
763
- assert.equal(quietDeliveries.length, 0, 'a baseline that changes nothing stays silent')
764
-
765
- // 13c. Toggle confirmations: an explicit enable always answers — with the
766
- // matching-cache confirmation, or the honest warning when nothing applies.
767
- const toggleDeliveries = []
768
- const toggleRecord = { registerCalls: [], watchers: [] }
769
- const { ctx: toggleCtx } = makeStubCtx({
770
- status: fakeStatus([]),
771
- sections: fakeSections([]),
772
- settingsService: fakeSettingsService(toggleRecord, {}),
773
- toast: fakeToast(toggleDeliveries),
774
- })
775
- await applyAndSettle(toggleCtx)
776
- for (const w of toggleRecord.watchers) w({ followSystem: true })
777
- assert.equal(toggleDeliveries.length, 1, 'toggle-on with a matching cache confirms')
778
- assert.equal(toggleDeliveries[0][1], 'success')
779
- assert.equal(/reload/.test(toggleDeliveries[0][0]), false, 'no reload hint when nothing changed')
780
- for (const w of toggleRecord.watchers) w({ followSystem: false })
781
- rmSync(join(dataDir, 'theme-follow.json'), { force: true })
782
- for (const w of toggleRecord.watchers) w({ followSystem: true })
783
- assert.equal(toggleDeliveries.length, 2, 'toggle-on without a cache answers too')
784
- assert.equal(toggleDeliveries[1][1], 'warning')
785
-
786
- // 13d. A toast dropped before the host sink exists is retried until delivered.
787
- const retryDeliveries = []
788
- const retryRecord = { registerCalls: [], watchers: [] }
789
- const { ctx: retryCtx } = makeStubCtx({
790
- status: fakeStatus([]),
791
- sections: fakeSections([]),
792
- settingsService: fakeSettingsService(retryRecord, {}),
793
- toast: fakeToast(retryDeliveries, { dropFirst: 1 }),
794
- })
795
- await applyAndSettle(retryCtx)
796
- writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: false, at: 1 }))
797
- writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-day' }, null, 2))
798
- for (const w of retryRecord.watchers) w({ followSystem: true })
799
- const retryDeadline = Date.now() + 2_000
800
- while (retryDeliveries.length === 0 && Date.now() < retryDeadline) {
801
- await new Promise(resolve => setTimeout(resolve, 5))
802
- }
803
- assert.equal(retryDeliveries.length, 1, 'a dropped toast is retried and delivered')
804
- assert.match(retryDeliveries[0][0], /pink-night/)
805
-
806
- // 13e. Toast-seam-less host (dsh-TUI < 0.10): sends are silent no-ops and
807
- // the follow feature itself is unchanged. The retry chain stays bounded:
808
- // once it gives up, a seam arriving later must not resurrect the abandoned
809
- // toast.
810
- const legacyRecord = { registerCalls: [], watchers: [] }
811
- const legacyContext = makeStubCtx({
812
- status: fakeStatus([]),
813
- sections: fakeSections([]),
814
- settingsService: fakeSettingsService(legacyRecord, {}),
815
- deferToast: true,
816
- })
817
- await applyAndSettle(legacyContext.ctx)
818
- writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
819
- writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
820
- for (const w of legacyRecord.watchers) w({ followSystem: true })
821
- assert.equal(readThemePref(dataDir), 'pink-day', 'follow works unchanged without the toast seam')
822
- await new Promise(resolve => setTimeout(resolve, 50)) // the [5, 5] chain is long exhausted
823
- const lateDeliveries = []
824
- legacyContext.record.activateToast(fakeToast(lateDeliveries))
825
- await settle()
826
- assert.equal(lateDeliveries.length, 0, 'an abandoned toast is not resurrected by a late seam')
827
-
828
- // 13f. Shadow hint: legacy same-named files that are byte-identical to the
829
- // bundled copy are pointed out once on runtime confirmation; user edits
830
- // stay unmentioned. Files this activation installed itself are excluded
831
- // (they were already removed before the check).
832
- rmSync(sandboxThemes, { recursive: true, force: true })
833
- mkdirSync(sandboxThemes, { recursive: true })
834
- for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
835
- writeFileSync(join(sandboxThemes, `${theme}.json`), readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'))
836
- }
837
- assert.deepEqual(findShadowedBundledThemes().sort(), ['pink-ansi.json', 'pink-day.json', 'pink-night.json'])
838
- const shadowDeliveries = []
839
- const shadowContext = makeStubCtx({ deferThemes: true, toast: fakeToast(shadowDeliveries) })
840
- apply(shadowContext.ctx)
841
- shadowContext.record.activateThemes(fakeThemes(shadowContext.record))
842
- await settle()
843
- assert.equal(shadowDeliveries.length, 1, 'identical legacy files are pointed out once')
844
- assert.equal(shadowDeliveries[0][1], undefined, 'the shadow hint is neutral, not an error')
845
- assert.match(shadowDeliveries[0][0], /pink-day\.json/)
846
-
847
- rmSync(sandboxThemes, { recursive: true, force: true })
848
- installBundledThemes()
849
- writeFileSync(join(sandboxThemes, 'pink-night.json'), '{ "name": "pink-night", "colors": { "text": "#123456" } }')
850
- const editDeliveries = []
851
- const editContext = makeStubCtx({ deferThemes: true, toast: fakeToast(editDeliveries) })
852
- apply(editContext.ctx)
853
- editContext.record.activateThemes(fakeThemes(editContext.record))
854
- await settle()
855
- assert.equal(editDeliveries.length, 1)
856
- assert.equal(/pink-night\.json/.test(editDeliveries[0][0]), false, 'a user-edited file is never nagged')
857
- assert.equal(/pink-day\.json/.test(editDeliveries[0][0]), true)
858
-
859
- // 13g. Corrupt-file self-heal is surfaced as a warning toast. The toast
860
- // seam trails apply on a real host, so the send waits on the retry chain.
861
- rmSync(sandboxThemes, { recursive: true, force: true })
862
- mkdirSync(sandboxThemes, { recursive: true })
863
- writeFileSync(join(sandboxThemes, 'pink-day.json'), '{ broken')
864
- const healDeliveries = []
865
- const healContext = makeStubCtx({ deferThemes: true, deferToast: true })
866
- apply(healContext.ctx)
867
- healContext.record.activateToast(fakeToast(healDeliveries))
868
- const healDeadline = Date.now() + 2_000
869
- while (healDeliveries.length === 0 && Date.now() < healDeadline) {
870
- await new Promise(resolve => setTimeout(resolve, 5))
871
- }
872
- assert.equal(healDeliveries.length, 1, 'a repaired theme file is surfaced')
873
- assert.equal(healDeliveries[0][1], 'warning')
874
- assert.match(healDeliveries[0][0], /pink-day\.json/)
875
-
876
- rmSync(sandboxThemes, { recursive: true, force: true })
877
- setToastRetryDelaysForTests([2_000, 4_000])
878
- console.log('✓ toast feedback: follow/self-heal/shadow hints delivered, dropped and seam-less sends retried, legacy hosts silent and bounded')
879
- }
880
-
881
- // ── 14. hot path: the token firehose must not reach render or the disk ──────
882
- {
883
- const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
884
- writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
885
- // Earlier scenarios ran status activations against a different pref; drop
886
- // their cached answer so this scenario starts from the fresh sandbox state.
887
- invalidateThemePrefCacheForTests()
888
- const statusCalls = []
889
- const settingsRecord = { registerCalls: [], watchers: [] }
890
- const { ctx, record } = makeStubCtx({
891
- status: fakeStatus(statusCalls),
892
- settingsService: fakeSettingsService(settingsRecord, {}),
893
- })
894
- await applyAndSettle(ctx)
895
- const session = { id: 'h1' }
896
- emit(record, 'session/event', session, { type: 'turn/end' })
897
- const baselineCalls = statusCalls.length
898
- assert.match(statusCalls.at(-1)[1], /1✦$/)
899
-
900
- // A firehose burst (streaming chunks, tool traffic, step brackets) updates
901
- // the tracked session but must render nothing and never touch the disk.
902
- const originalRead = builtinFs.readFileSync
903
- let prefReads = 0
904
- try {
905
- builtinFs.readFileSync = (path, ...rest) => {
906
- if (String(path) === themePrefPath) prefReads += 1
907
- return originalRead(path, ...rest)
908
- }
909
- syncBuiltinESMExports()
910
- for (let index = 0; index < 50; index += 1) {
911
- emit(record, 'session/event', session, {
912
- type: 'assistant/chunk',
913
- turn: 1,
914
- step: 1,
915
- chunk: { type: 'text', text: 'x' },
916
- })
917
- }
918
- emit(record, 'session/event', session, {
919
- type: 'tool/call', turn: 1, step: 1, callId: 'c1', name: 'tool', arguments: '{}',
920
- })
921
- emit(record, 'session/event', session, { type: 'step/end', turn: 1, step: 1 })
922
- emit(record, 'session/event', session, { type: 'todo/write', todos: [] })
923
- assert.equal(statusCalls.length, baselineCalls, 'firehose events render nothing')
924
- assert.equal(prefReads, 0, 'firehose events never read the theme pref')
925
-
926
- // A turn boundary renders — the mtime gate costs one stat (not counted
927
- // here), and the warm cache answers without a read, so even boundary
928
- // renders stay off the read path.
929
- emit(record, 'session/event', session, { type: 'turn/end' })
930
- assert.equal(statusCalls.length, baselineCalls + 1, 'a turn boundary renders')
931
- assert.equal(prefReads, 0, 'the warm cache serves the boundary render')
932
-
933
- // A session switch repaints at its first turn/start with a fresh count.
934
- const nextSession = { id: 'h2' }
935
- emit(record, 'session/event', nextSession, { type: 'turn/start' })
936
- assert.equal(statusCalls.length, baselineCalls + 2)
937
- assert.match(statusCalls.at(-1)[1], /0✦$/)
938
- assert.equal(prefReads, 0)
939
-
940
- // A pref rewrite (the host's /theme switch) is seen at the very next
941
- // render: the stat notices the moved mtime and pays exactly one re-read,
942
- // and the non-pink pref hides the line — no waiting out the TTL (the
943
- // stale window used to show as wrong rich-view colors).
944
- writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
945
- emit(record, 'session/event', nextSession, { type: 'turn/end' })
946
- assert.equal(statusCalls.length, baselineCalls + 3)
947
- assert.equal(statusCalls.at(-1)[1], undefined, 'the pref rewrite is seen at the next render')
948
- assert.equal(prefReads, 1, 'the moved mtime costs exactly one re-read')
949
-
950
- // An unchanged pref keeps the cache warm: the render reruns, but the
951
- // hidden line's text is unchanged so set() is deduplicated away, and
952
- // no read happens.
953
- emit(record, 'session/event', nextSession, { type: 'turn/end' })
954
- assert.equal(statusCalls.length, baselineCalls + 3, 'an unchanged line does not rewrite the store')
955
- assert.equal(prefReads, 1, 'the unchanged mtime serves the cache')
956
-
957
- // Test-only invalidation (the TTL-expiry stand-in) re-reads too.
958
- invalidateThemePrefCacheForTests()
959
- emit(record, 'session/event', nextSession, { type: 'turn/end' })
960
- assert.equal(prefReads, 2, 'invalidation re-reads the pref exactly once')
961
- assert.equal(statusCalls.at(-1)[1], undefined, 'the non-pink pref keeps the line hidden')
962
- } finally {
963
- builtinFs.readFileSync = originalRead
964
- syncBuiltinESMExports()
965
- invalidateThemePrefCacheForTests()
966
- }
967
- console.log('✓ hot path: firehose events render nothing and never read the pref; boundaries use the cache')
968
- }
969
-
970
- // ── 15. settings panel UX: groups, ornament drafts, follow format ───────────
971
- {
972
- const dataDir = join(sandboxHome, '.dsh-tui')
973
- rmSync(dataDir, { recursive: true, force: true })
974
- mkdirSync(dataDir, { recursive: true })
975
- writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
976
- invalidateThemePrefCacheForTests()
977
- const statusCalls = []
978
- const sectionsCalls = []
979
- const settingsRecord = { registerCalls: [], watchers: [] }
980
- const { ctx, record } = makeStubCtx({
981
- status: fakeStatus(statusCalls),
982
- sections: fakeSections(sectionsCalls),
983
- settingsService: fakeSettingsService(settingsRecord, {}),
984
- })
985
- await applyAndSettle(ctx)
986
- const section = sectionsCalls[0]
987
- const fieldByPath = path =>
988
- section.fields.find(field => JSON.stringify(field.path) === JSON.stringify(path))
989
-
990
- // Navigation groups: two subpages, every field assigned.
991
- assert.deepEqual(
992
- section.groups.map(group => group.id).sort(),
993
- ['follow', 'status-line'],
994
- )
995
- assert.equal(fieldByPath(['followSystem']).group, 'follow')
996
- for (const path of [['showGlyph'], ['statusGlyph'], ['showClock'], ['showTurns'], ['statusSeparator'], ['statusScope']]) {
997
- assert.equal(fieldByPath(path).group, 'status-line')
998
- }
999
-
1000
- // Ornament draft gate: 1–2 display cells, control chars refused, empty
1001
- // resets to the built-in default, unset displays the effective value.
1002
- const glyph = fieldByPath(['statusGlyph'])
1003
- assert.equal(glyph.kind, 'text')
1004
- assert.deepEqual(glyph.parse(''), { kind: 'clear' })
1005
- assert.deepEqual(glyph.parse('❀'), { kind: 'set', value: '❀' })
1006
- assert.deepEqual(glyph.parse('樱'), { kind: 'set', value: '樱' }, 'a 2-cell CJK char is allowed')
1007
- assert.equal(glyph.parse('abc'), undefined, '3 cells are rejected')
1008
- assert.equal(glyph.parse('a\u0007b'), undefined, 'control characters are rejected')
1009
- assert.deepEqual(glyph.parse(' \u00A0 '), { kind: 'clear' }, 'whitespace-only drafts reset to the default')
1010
- assert.equal(glyph.format(undefined), '✿', 'unset shows the effective default')
1011
- assert.equal(glyph.format('❀'), '❀')
1012
- const separator = fieldByPath(['statusSeparator'])
1013
- assert.equal(separator.kind, 'text')
1014
- assert.deepEqual(separator.parse('✦'), { kind: 'set', value: '✦' })
1015
- assert.equal(separator.format(undefined), '·')
1016
-
1017
- // followSystem format surfaces the cached follow state the startup would
1018
- // consult — the "toggled on, nothing happened" reason, on the surface.
1019
- const follow = fieldByPath(['followSystem'])
1020
- assert.equal(follow.format(undefined), 'off', 'the cordis layer default is off')
1021
- assert.equal(follow.format(false), 'off')
1022
- assert.equal(follow.format(true), 'on(无缓存,启动时不动)')
1023
- writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: Date.UTC(2026, 8, 12) }))
1024
- assert.match(follow.format(true), /^on(缓存: light · \d{4}-\d{2}-\d{2})$/)
1025
- writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: false }))
1026
- assert.equal(follow.format(true), 'on(缓存: dark)', 'a cache without a timestamp omits the date')
1027
-
1028
- // A committed ornament edit lands live on the scalar line.
1029
- for (const watcher of settingsRecord.watchers) {
1030
- watcher({ statusGlyph: '❀', statusSeparator: '~' })
1031
- }
1032
- emit(record, 'session/event', { id: 'u1' }, { type: 'turn/end' })
1033
- assert.match(statusCalls.at(-1)[1], /^❀ ~ \d{2}:\d{2} ~ 1✦$/)
1034
-
1035
- // Hand-edited config layers bypass the draft gate; the render side
1036
- // sanitizes anyway (control chars stripped, capped at 2 cells).
1037
- for (const watcher of settingsRecord.watchers) {
1038
- watcher({ statusGlyph: 'x\u0007yz', statusSeparator: '~' })
1039
- }
1040
- emit(record, 'session/event', { id: 'u1' }, { type: 'turn/end' })
1041
- assert.match(statusCalls.at(-1)[1], /^xy ~ /, 'control chars stripped and the rest capped at 2 cells')
1042
- console.log('✓ settings panel: two groups, ornament drafts gated, follow format shows the cache state')
1043
- }
1044
-
1045
- // ── 16. rich status view: themed one-row view on registerView-capable hosts ─
1046
- {
1047
- const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
1048
- writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
1049
- invalidateThemePrefCacheForTests()
1050
- const statusCalls = []
1051
- const viewCalls = []
1052
- const settingsRecord = { registerCalls: [], watchers: [] }
1053
- const { ctx, record } = makeStubCtx({
1054
- status: fakeRichStatus(statusCalls, viewCalls),
1055
- sections: fakeSections([]),
1056
- settingsService: fakeSettingsService(settingsRecord, {}),
1057
- })
1058
- await applyAndSettle(ctx)
1059
-
1060
- assert.equal(viewCalls.length, 1, 'the rich view is registered exactly once')
1061
- assert.equal(statusCalls.length, 0, 'the rich path never writes scalar text')
1062
- const [descriptor, identity] = viewCalls[0]
1063
- assert.equal(descriptor.key, 'dsh-tui-theme', 'the rich view keeps the shared contribution key')
1064
- assert.equal(descriptor.maxRows, 1)
1065
- assert.equal(typeof descriptor.component, 'function')
1066
- assert.equal(identity?.tuiStatus !== undefined, true, 'identity is the inject-scoped context')
1067
-
1068
- // Render the component against a minimal fake of the host kit: the element
1069
- // tree must map the snapshot to themed Text cells.
1070
- const renderComponent = () =>
1071
- descriptor.component({
1072
- React: {
1073
- createElement: (type, props, ...children) => ({ type, props, children: children.length === 1 && Array.isArray(children[0]) ? children[0] : children }),
1074
- useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
1075
- },
1076
- ui: { Box: 'box', Text: 'text' },
1077
- })
1078
- const element = renderComponent()
1079
- assert.equal(element.type, 'box', 'one row Box')
1080
- assert.equal(element.children[0].type, 'text')
1081
- assert.equal(element.children[0].props.color, 'rgb(242,123,166)', 'the glyph uses the brand key of the active palette')
1082
- assert.equal(element.children[0].children[0], '✿')
1083
- assert.equal(element.children[1].props.color, '#77646D', 'the separator uses the subtle key')
1084
- assert.match(element.children[2].children[0], /^\d{2}:\d{2}$/)
1085
- assert.equal(element.children[2].props.color, '#C4B0B9', 'the clock uses the bottom-bar inactiveShimmer key')
1086
-
1087
- // Turn boundaries push through the store and land in the next render.
1088
- const session = { id: 'r1' }
1089
- emit(record, 'session/event', session, { type: 'turn/end' })
1090
- const withTurns = renderComponent()
1091
- assert.equal(withTurns.children.at(-1).children[0], '1✦')
1092
- assert.equal(withTurns.children.at(-1).props.color, '#C4B0B9')
1093
-
1094
- // A /theme switch (the host rewrites the pref) lands at the very next
1095
- // render: rewrite the pref to pink-day and the palette moves with it,
1096
- // without any TTL wait.
1097
- writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-day' }, null, 2))
1098
- emit(record, 'session/event', session, { type: 'turn/end' })
1099
- const dayView = renderComponent()
1100
- assert.equal(dayView.children[0].props.color, 'rgb(222,110,150)', 'the glyph follows the new palette')
1101
- assert.equal(dayView.children[2].props.color, '#9E6E82', 'the clock follows the new palette immediately')
1102
-
1103
- // Toggles fold into the snapshot: all off renders nothing (no scalar
1104
- // fallback either — the rich view just shows nothing).
1105
- for (const watcher of settingsRecord.watchers) {
1106
- watcher({ showGlyph: false, showClock: false, showTurns: false })
1107
- }
1108
- emit(record, 'session/event', session, { type: 'turn/end' })
1109
- assert.equal(renderComponent(), null, 'all toggles off render nothing')
1110
-
1111
- // Master switch off: registered but never visible.
1112
- const quietCalls = []
1113
- const quietViews = []
1114
- const quietCtx = makeStubCtx({ status: fakeRichStatus(quietCalls, quietViews) })
1115
- await applyAndSettle(quietCtx.ctx, { statusEnabled: false })
1116
- assert.equal(quietViews.length, 1, 'the view is still registered')
1117
- assert.equal(
1118
- quietViews[0][0].component({
1119
- React: {
1120
- createElement: (type, props, ...children) => ({ type, props, children: children.length === 1 && Array.isArray(children[0]) ? children[0] : children }),
1121
- useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
1122
- },
1123
- ui: { Box: 'box', Text: 'text' },
1124
- }),
1125
- null,
1126
- 'statusEnabled=false renders nothing',
1127
- )
1128
- console.log('✓ rich status view: themed cells from the palette, mutual exclusion from set()')
1129
- }
1130
-
1131
- // ── 17. refused rich registration falls back to the scalar path ─────────────
1132
- {
1133
- const statusCalls = []
1134
- const viewCalls = []
1135
- const { ctx } = makeStubCtx({ status: fakeRichStatus(statusCalls, viewCalls, { refuse: true }) })
1136
- await applyAndSettle(ctx)
1137
- assert.equal(viewCalls.length, 1, 'the registration was attempted')
1138
- assert.equal(statusCalls.length > 0, true, 'a refused registration (undefined) falls back to set()')
1139
- console.log('✓ refused rich registration: the scalar path keeps the line alive')
1140
- }
1141
-
1142
- // ── 17b. hostile rich registration throws: warn, fall back, never propagate ─
1143
- {
1144
- const statusCalls = []
1145
- const viewCalls = []
1146
- const { ctx, record } = makeStubCtx({ status: fakeRichStatus(statusCalls, viewCalls, { throws: true }) })
1147
- // applyAndSettle resolving at all is the non-propagation half of the
1148
- // invariant: a synchronous throw escaping the inject callback would land here.
1149
- await applyAndSettle(ctx)
1150
- assert.equal(viewCalls.length, 1, 'the registration was attempted')
1151
- assert.equal(statusCalls.length > 0, true, 'a throwing registration falls back to set()')
1152
- assert.equal(
1153
- record.warnings.some(msg => msg.includes('rich status view registration failed')),
1154
- true,
1155
- 'the failure is warned, never propagated',
1156
- )
1157
- assert.equal(
1158
- (record.handlers.get('session/event') ?? []).length >= 1,
1159
- true,
1160
- 'the session wiring survived the throw',
1161
- )
1162
- console.log('✓ hostile rich registration: warn + scalar fallback, session wiring intact')
1163
- }
1164
-
1165
- // ── 18. shadow cleanup dialog: one confirm, byte-checked deletion ───────────
1166
- {
1167
- const seedShadow = () => {
1168
- rmSync(sandboxThemes, { recursive: true, force: true })
1169
- mkdirSync(sandboxThemes, { recursive: true })
1170
- for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
1171
- writeFileSync(join(sandboxThemes, `${theme}.json`), readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'))
1172
- }
1173
- }
1174
-
1175
- // 18a. Dialog answered with "delete": the byte-identical copies are
1176
- // removed and the result is toasted.
1177
- seedShadow()
1178
- const requests = []
1179
- const answers = []
1180
- const cleanDeliveries = []
1181
- const cleanContext = makeStubCtx({
1182
- deferThemes: true,
1183
- toast: fakeToast(cleanDeliveries),
1184
- dialogs: fakeDialogs(requests, answers),
1185
- })
1186
- apply(cleanContext.ctx)
1187
- cleanContext.record.activateThemes(fakeThemes(cleanContext.record))
1188
- await settle()
1189
- assert.equal(requests.length, 1, 'exactly one confirm dialog is offered')
1190
- assert.equal(requests[0].owner?.tuiDialogs !== undefined, true, 'the owner is the inject-scoped context')
1191
- for (const file of ['pink-night.json', 'pink-day.json', 'pink-ansi.json']) {
1192
- assert.ok(requests[0].request.message.includes(file), `the dialog names ${file}`)
1193
- }
1194
- assert.ok(requests[0].request.title.includes('旧主题文件'))
1195
- answers[0](true)
1196
- await settle()
1197
- assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), false, 'confirmation removes the shadowing copy')
1198
- assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), false, 'confirmation removes the shadowing copy')
1199
- assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false, 'confirmation removes the shadowing copy')
1200
- const cleanToast = cleanDeliveries.find(delivery => delivery[0].includes('已清理'))
1201
- assert.ok(cleanToast, 'the cleanup result is toasted')
1202
- assert.equal(cleanToast[1], 'success')
1203
-
1204
- // 18b. Declining keeps the previous behavior exactly: files stay, no
1205
- // cleanup toast.
1206
- seedShadow()
1207
- const declineRequests = []
1208
- const declineAnswers = []
1209
- const declineDeliveries = []
1210
- const declineContext = makeStubCtx({
1211
- deferThemes: true,
1212
- toast: fakeToast(declineDeliveries),
1213
- dialogs: fakeDialogs(declineRequests, declineAnswers),
1214
- })
1215
- apply(declineContext.ctx)
1216
- declineContext.record.activateThemes(fakeThemes(declineContext.record))
1217
- await settle()
1218
- assert.equal(declineRequests.length, 1, 'the dialog is offered again for the new activation')
1219
- declineAnswers[0](false)
1220
- await settle()
1221
- assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), true, 'declining keeps the file')
1222
- assert.equal(
1223
- declineDeliveries.some(delivery => delivery[0].includes('已清理')),
1224
- false,
1225
- 'no cleanup toast without a confirmation',
1226
- )
1227
-
1228
- // 18c. The dialogs seam arriving late still asks exactly once; a file the
1229
- // user edited between detection and confirmation is never deleted.
1230
- seedShadow()
1231
- writeFileSync(join(sandboxThemes, 'pink-day.json'), '{ "name": "pink-day", "colors": { "text": "#123456" } }')
1232
- const lateRequests = []
1233
- const lateAnswers = []
1234
- const lateContext = makeStubCtx({ deferThemes: true, deferDialogs: true, toast: fakeToast([]) })
1235
- apply(lateContext.ctx)
1236
- lateContext.record.activateThemes(fakeThemes(lateContext.record))
1237
- await settle()
1238
- assert.equal(lateRequests.length, 0, 'no dialog before the seam arrives')
1239
- lateContext.record.activateDialogs(fakeDialogs(lateRequests, lateAnswers))
1240
- await settle()
1241
- assert.equal(lateRequests.length, 1, 'the parked offer fires when the seam arrives')
1242
- lateAnswers[0](true)
1243
- await settle()
1244
- assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), false, 'the still-identical file is removed')
1245
- assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), true, 'the edited file survives the byte check')
1246
- assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false)
1247
-
1248
- // 18d. A hostile dialogs service must not take the activation down.
1249
- seedShadow()
1250
- const hostileContext = makeStubCtx({
1251
- deferThemes: true,
1252
- dialogs: { confirm() { throw new Error('dialog machinery broken') } },
1253
- })
1254
- apply(hostileContext.ctx)
1255
- hostileContext.record.activateThemes(fakeThemes(hostileContext.record))
1256
- await settle()
1257
- assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), true, 'a failed dialog deletes nothing')
1258
-
1259
- rmSync(sandboxThemes, { recursive: true, force: true })
1260
- console.log('✓ shadow cleanup dialog: offered once, byte-checked deletion, decline/late/hostile all safe')
1261
- }
1262
-
1263
- console.log('\nAll plugin verifications passed.')
1264
- console.log(`(sandbox used: ${sandboxHome} — the real home was never touched)`)
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)`)