dsh-tui-theme 0.5.0 → 0.6.0

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.
@@ -44,8 +44,9 @@ const builtinFs = require('node:fs')
44
44
  const sandboxThemes = join(sandboxHome, '.dsh-tui', 'themes')
45
45
 
46
46
  const { name, apply } = await import('../lib/types/index.js')
47
- const { installBundledThemes } = await import('../lib/types/themeAssets.js')
47
+ const { installBundledThemes, readBundledThemes, findShadowedBundledThemes } = await import('../lib/types/themeAssets.js')
48
48
  const { startStatusLine } = await import('../lib/types/statusLine.js')
49
+ const { setToastRetryDelaysForTests } = await import('../lib/types/toast.js')
49
50
  const {
50
51
  themeForBackground,
51
52
  readThemePref,
@@ -56,16 +57,27 @@ const {
56
57
  } = await import('../lib/types/autoTheme.js')
57
58
 
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
+ }
59
65
 
60
66
  /** A stub Cordis-like context; every seam optional and recorded. */
61
- function makeStubCtx({ status, sections, settingsService } = {}) {
62
- const record = { handlers: new Map(), disposers: [], statusCalls: [], sectionsCalls: [], registerCalls: [], watchers: [], warnings: [], infos: [] }
67
+ function makeStubCtx({ status, sections, settingsService, themes, toast, deferThemes = false, deferToast = false } = {}) {
68
+ const record = { handlers: new Map(), disposers: [], statusCalls: [], sectionsCalls: [], registerCalls: [], watchers: [], themeRegisters: [], warnings: [], infos: [] }
69
+ let availableThemes = themes
70
+ let availableToast = toast
71
+ const deferredThemeCallbacks = []
72
+ const deferredToastCallbacks = []
63
73
  const logger = { info: msg => record.infos.push(String(msg)), warn: msg => record.warnings.push(String(msg)), error: () => {} }
64
74
  const base = {
65
75
  logger,
66
76
  get(serviceName) {
67
77
  if (serviceName === 'tuiStatus') return status
68
78
  if (serviceName === 'tuiSettingsSections') return sections
79
+ if (serviceName === 'tuiThemes') return availableThemes
80
+ if (serviceName === 'tuiToast') return availableToast
69
81
  return undefined
70
82
  },
71
83
  on(event, handler) {
@@ -85,6 +97,16 @@ function makeStubCtx({ status, sections, settingsService } = {}) {
85
97
  settings: settingsService,
86
98
  tuiStatus: status,
87
99
  tuiSettingsSections: sections,
100
+ tuiThemes: availableThemes,
101
+ tuiToast: availableToast,
102
+ }
103
+ if (deferThemes && availableThemes === undefined && deps.includes('tuiThemes')) {
104
+ deferredThemeCallbacks.push(callback)
105
+ return
106
+ }
107
+ if (deferToast && availableToast === undefined && deps.includes('tuiToast')) {
108
+ deferredToastCallbacks.push(callback)
109
+ return
88
110
  }
89
111
  if (deps.every(dep => services[dep] !== undefined)) {
90
112
  const props = Object.fromEntries(deps.map(dep => [dep, services[dep]]))
@@ -92,11 +114,26 @@ function makeStubCtx({ status, sections, settingsService } = {}) {
92
114
  }
93
115
  },
94
116
  }
117
+ record.activateThemes = service => {
118
+ availableThemes = service
119
+ for (const callback of deferredThemeCallbacks.splice(0)) callback({ ...base, tuiThemes: service })
120
+ }
121
+ record.activateToast = service => {
122
+ availableToast = service
123
+ for (const callback of deferredToastCallbacks.splice(0)) callback({ ...base, tuiToast: service })
124
+ }
95
125
  return { ctx: base, record }
96
126
  }
97
127
 
98
128
  const fakeStatus = calls => ({ set(key, text) { calls.push([key, text]); return () => {} } })
99
129
  const fakeSections = calls => ({ register(section) { calls.push(section); return () => {} } })
130
+ const fakeThemes = (record, { throws = false } = {}) => ({
131
+ register(descriptor, identity) {
132
+ record.themeRegisters.push([descriptor, identity])
133
+ if (throws) throw new Error('runtime registry unavailable')
134
+ return () => {}
135
+ },
136
+ })
100
137
  const fakeSettingsService = (record, doc) => ({
101
138
  register(namespace, schema) {
102
139
  record.registerCalls.push([namespace, schema])
@@ -106,6 +143,18 @@ const fakeSettingsService = (record, doc) => ({
106
143
  }
107
144
  },
108
145
  })
146
+ /** Records delivered toasts; the first `dropFirst` shows are dropped (no sink yet). */
147
+ const fakeToast = (deliveries, { dropFirst = 0 } = {}) => {
148
+ let shown = 0
149
+ return {
150
+ show(text, options) {
151
+ shown += 1
152
+ if (shown <= dropFirst) return false
153
+ deliveries.push([String(text), options?.color])
154
+ return true
155
+ },
156
+ }
157
+ }
109
158
 
110
159
  const emit = (record, event, ...args) => {
111
160
  for (const handler of record.handlers.get(event) ?? []) handler(...args)
@@ -138,7 +187,7 @@ const emit = (record, event, ...args) => {
138
187
  sections: fakeSections(sectionsCalls),
139
188
  settingsService: fakeSettingsService(settingsRecord, {}),
140
189
  })
141
- apply(ctx)
190
+ await applyAndSettle(ctx)
142
191
 
143
192
  // Themes installed into the SANDBOX, never the real home.
144
193
  for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
@@ -176,8 +225,62 @@ const emit = (record, event, ...args) => {
176
225
  console.log('✓ full host: themes installed, status line live, settings wired')
177
226
  }
178
227
 
228
+ // ── 2a. runtime themes: service owns palettes and static files stay absent ───
229
+ {
230
+ rmSync(sandboxThemes, { recursive: true, force: true })
231
+ const registrations = []
232
+ const themes = { register(descriptor, identity) { registrations.push([descriptor, identity]); return () => {} } }
233
+ const { ctx } = makeStubCtx({ themes })
234
+ await applyAndSettle(ctx)
235
+ assert.equal(existsSync(sandboxThemes), false, 'runtime registration must not create static files')
236
+ const expected = readBundledThemes().map(({ file, ...theme }) => theme)
237
+ assert.deepEqual(registrations.map(([descriptor]) => descriptor), expected)
238
+ assert.equal(
239
+ registrations.every(([, identity]) => identity?.tuiThemes !== undefined),
240
+ true,
241
+ 'runtime registrations use the inject-scoped identity',
242
+ )
243
+ console.log('✓ runtime themes: three descriptors registered without static files')
244
+ }
245
+
246
+ // ── 2b. late runtime service: confirmation removes this activation's files ──
247
+ {
248
+ rmSync(sandboxThemes, { recursive: true, force: true })
249
+ const { ctx, record } = makeStubCtx({ deferThemes: true })
250
+ apply(ctx)
251
+ assert.equal(existsSync(sandboxThemes), true, 'legacy fallback must install synchronously before service arrival')
252
+ record.activateThemes(fakeThemes(record))
253
+ await settle()
254
+ assert.equal(existsSync(sandboxThemes), false, 'late runtime service must remove all fallback files and the empty directory')
255
+
256
+ rmSync(sandboxThemes, { recursive: true, force: true })
257
+ const protectedContext = makeStubCtx({ deferThemes: true })
258
+ apply(protectedContext.ctx)
259
+ assert.equal(existsSync(sandboxThemes), true, 'legacy fallback must install before protecting a user edit')
260
+ const protectedTheme = join(sandboxThemes, 'pink-night.json')
261
+ writeFileSync(protectedTheme, '{ "name": "pink-night", "colors": { "text": "#123456" } }')
262
+ protectedContext.record.activateThemes(fakeThemes(protectedContext.record))
263
+ await settle()
264
+ assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), false, 'late runtime service must remove plugin-owned files')
265
+ assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false, 'late runtime service must remove plugin-owned files')
266
+ assert.equal(readFileSync(protectedTheme, 'utf8'), '{ "name": "pink-night", "colors": { "text": "#123456" } }')
267
+ rmSync(sandboxThemes, { recursive: true, force: true })
268
+ console.log('✓ runtime race: late service wins and removes static fallback files')
269
+ }
270
+
271
+ // ── 2c. hostile runtime service: registration failures degrade to warnings ───
272
+ {
273
+ const themeRecord = { themeRegisters: [] }
274
+ const { ctx, record } = makeStubCtx({ themes: fakeThemes(themeRecord, { throws: true }) })
275
+ await applyAndSettle(ctx)
276
+ assert.equal(record.warnings.length, 3, 'each hostile registration is contained and warned')
277
+ console.log('✓ hostile runtime service: registration failures warn, never propagate')
278
+ }
279
+
179
280
  // ── 3. idempotence + user-file protection ───────────────────────────────────
180
281
  {
282
+ const seeded = installBundledThemes()
283
+ assert.equal(seeded.installed.length, 3)
181
284
  const again = installBundledThemes()
182
285
  assert.deepEqual(again.installed, [])
183
286
  assert.deepEqual(again.repaired, [])
@@ -197,7 +300,7 @@ const emit = (record, event, ...args) => {
197
300
  rmSync(sandboxHome, { recursive: true, force: true })
198
301
  mkdirSync(sandboxHome, { recursive: true })
199
302
  const { ctx } = makeStubCtx()
200
- apply(ctx, { autoInstallThemes: false })
303
+ await applyAndSettle(ctx, { autoInstallThemes: false })
201
304
  assert.equal(existsSync(join(sandboxHome, '.dsh-tui')), false, 'must not create the dir when disabled')
202
305
  console.log('✓ autoInstallThemes=false: leaves the themes dir untouched')
203
306
  }
@@ -218,15 +321,27 @@ const emit = (record, event, ...args) => {
218
321
  // Same value -> no rewrite churn (mtime-agnostic: pref already matches).
219
322
  assert.equal(applyCachedFollow(dataDir), 'pink-day')
220
323
 
221
- // The follow runner only applies cache and reports its result. It accepts no
222
- // stdin/stdout handles and cannot create a raw-mode lease or input listener.
223
- const logs = []
224
- runFollowSystem(dataDir, () => true, message => logs.push(message))
225
- assert.deepEqual(logs, ['follow: applied cached background (pink-day)'])
324
+ // The follow runner only applies cache and reports its structured outcome.
325
+ // It accepts no stdin/stdout handles and cannot create a raw-mode lease or
326
+ // input listener.
327
+ const reports = []
328
+ runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
329
+ assert.deepEqual(reports, [{ kind: 'applied', theme: 'pink-day', changed: false }])
226
330
 
227
331
  writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
228
- runFollowSystem(dataDir, () => false, message => logs.push(message))
332
+ runFollowSystem(dataDir, () => false, outcome => reports.push(outcome))
229
333
  assert.equal(readThemePref(dataDir), 'dark', 'inactive follow preserves manual choice')
334
+
335
+ runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
336
+ assert.deepEqual(
337
+ reports.at(-1),
338
+ { kind: 'applied', theme: 'pink-day', changed: true },
339
+ 'a real pref flip is reported as changed',
340
+ )
341
+
342
+ rmSync(join(dataDir, 'theme-follow.json'), { force: true })
343
+ runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
344
+ assert.deepEqual(reports.at(-1), { kind: 'unavailable' })
230
345
  console.log('✓ follow: cached background applies without terminal I/O')
231
346
  }
232
347
 
@@ -234,7 +349,7 @@ const emit = (record, event, ...args) => {
234
349
  {
235
350
  const statusCalls = []
236
351
  const { ctx } = makeStubCtx({ status: fakeStatus(statusCalls) })
237
- apply(ctx, { statusEnabled: false })
352
+ await applyAndSettle(ctx, { statusEnabled: false })
238
353
  assert.equal(statusCalls.length > 0, true, 'render still ran once')
239
354
  // Every contribution is cleared (undefined), not rendered.
240
355
  for (const [, text] of statusCalls) assert.equal(text, undefined)
@@ -252,7 +367,7 @@ const emit = (record, event, ...args) => {
252
367
  sections: fakeSections([]),
253
368
  settingsService: fakeSettingsService(settingsRecord, {}),
254
369
  })
255
- apply(ctx, { followSystem: true })
370
+ await applyAndSettle(ctx, { followSystem: true })
256
371
  // Cordis layer on, empty user layer, no cache yet -> no pref churn.
257
372
  assert.equal(existsSync(join(dataDir, 'theme.json')), false)
258
373
 
@@ -277,7 +392,7 @@ const emit = (record, event, ...args) => {
277
392
  status: fakeStatus(statusCalls),
278
393
  settingsService: fakeSettingsService(settingsRecord, {}),
279
394
  })
280
- apply(ctx)
395
+ await applyAndSettle(ctx)
281
396
  const session = { id: 'g1' }
282
397
 
283
398
  // Pink active → renders.
@@ -318,7 +433,7 @@ const emit = (record, event, ...args) => {
318
433
  register() { throw new Error('namespace already registered (hot reload)') },
319
434
  }
320
435
  const { ctx, record } = makeStubCtx({ settingsService: throwingSettings })
321
- apply(ctx) // must not throw
436
+ await applyAndSettle(ctx) // must not throw
322
437
  assert.equal(record.warnings.length, 1, 'registration failure logs one warning')
323
438
  assert.match(record.warnings[0], /settings namespace registration failed/)
324
439
  console.log('✓ hostile settings service: registration failure warns, never propagates')
@@ -331,7 +446,7 @@ const emit = (record, event, ...args) => {
331
446
  writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
332
447
  writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
333
448
  const { ctx } = makeStubCtx()
334
- apply(ctx, { followSystem: true })
449
+ await applyAndSettle(ctx, { followSystem: true })
335
450
  assert.equal(readThemePref(dataDir), 'dark', 'without settings, cached follow must not override manual choice')
336
451
  console.log('✓ missing settings: cached follow leaves the manual choice intact')
337
452
  }
@@ -431,6 +546,27 @@ const emit = (record, event, ...args) => {
431
546
  builtinFs.readFileSync = originalRead
432
547
  syncBuiltinESMExports()
433
548
  }
549
+
550
+ // A successful backup followed by a failed replacement is a failure, not a
551
+ // protected skip: the target is absent until the next boot can retry.
552
+ const failedHeal = join(targetDir, 'pink-ansi.json')
553
+ writeFileSync(failedHeal, '{ broken')
554
+ const originalWrite = builtinFs.writeFileSync
555
+ try {
556
+ builtinFs.writeFileSync = (path, data, options) => {
557
+ if (String(path) === failedHeal && options?.flag === 'wx' && !existsSync(failedHeal)) {
558
+ throw new Error('ENOSPC: replacement blocked')
559
+ }
560
+ return originalWrite(path, data, options)
561
+ }
562
+ syncBuiltinESMExports()
563
+ const failed = installBundledThemes(targetDir, sourceDir)
564
+ assert.equal(failed.failed.includes('pink-ansi.json'), true)
565
+ assert.equal(failed.skipped.includes('pink-ansi.json'), false)
566
+ } finally {
567
+ builtinFs.writeFileSync = originalWrite
568
+ syncBuiltinESMExports()
569
+ }
434
570
  console.log('✓ torn installation: corrupt target backed up and reinstalled; user files and unreadable targets untouched')
435
571
  }
436
572
 
@@ -439,7 +575,7 @@ const emit = (record, event, ...args) => {
439
575
  // Default user layer (empty doc): no spurious "follow: disabled" line.
440
576
  const settingsRecord = { registerCalls: [], watchers: [] }
441
577
  const { ctx, record } = makeStubCtx({ settingsService: fakeSettingsService(settingsRecord, {}) })
442
- apply(ctx)
578
+ await applyAndSettle(ctx)
443
579
  assert.equal(
444
580
  record.infos.some(message => message.includes('follow: disabled')),
445
581
  false,
@@ -455,7 +591,7 @@ const emit = (record, event, ...args) => {
455
591
  sections: fakeSections([]),
456
592
  settingsService: fakeSettingsService(enabledRecord, { followSystem: true }),
457
593
  })
458
- apply(enabledCtx)
594
+ await applyAndSettle(enabledCtx)
459
595
  assert.equal(readThemePref(dataDir), 'pink-day', 'an enabled baseline applies the cached background')
460
596
  assert.equal(
461
597
  enabledInfo.infos.some(message => message.includes('follow: disabled')),
@@ -541,5 +677,165 @@ const emit = (record, event, ...args) => {
541
677
  console.log('✓ status lifecycle: session handlers follow tuiStatus activation')
542
678
  }
543
679
 
680
+ // ── 13. toast feedback: the 0.10 seam surfaces what the logger cannot ───────
681
+ {
682
+ setToastRetryDelaysForTests([5, 5])
683
+ const dataDir = join(sandboxHome, '.dsh-tui')
684
+ mkdirSync(dataDir, { recursive: true })
685
+
686
+ // 13a. Startup baseline with a disagreeing cache: one success toast that
687
+ // points at /reload (the live TUI still shows the previous palette). The
688
+ // tuiToast seam trails apply on a real host (extensions row), so the send
689
+ // must survive on the bounded retry until the seam shows up.
690
+ writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
691
+ writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
692
+ const baselineDeliveries = []
693
+ const baselineRecord = { registerCalls: [], watchers: [] }
694
+ const baselineContext = makeStubCtx({
695
+ status: fakeStatus([]),
696
+ sections: fakeSections([]),
697
+ settingsService: fakeSettingsService(baselineRecord, { followSystem: true }),
698
+ deferToast: true,
699
+ })
700
+ await applyAndSettle(baselineContext.ctx)
701
+ assert.equal(readThemePref(dataDir), 'pink-day', 'the pref write is synchronous, independent of the toast seam')
702
+ baselineContext.record.activateToast(fakeToast(baselineDeliveries))
703
+ const baselineDeadline = Date.now() + 2_000
704
+ while (baselineDeliveries.length === 0 && Date.now() < baselineDeadline) {
705
+ await new Promise(resolve => setTimeout(resolve, 5))
706
+ }
707
+ assert.equal(baselineDeliveries.length, 1, 'a real baseline pref write toasts exactly once')
708
+ assert.equal(baselineDeliveries[0][1], 'success')
709
+ assert.match(baselineDeliveries[0][0], /pink-day/)
710
+ assert.match(baselineDeliveries[0][0], /reload/)
711
+
712
+ // 13b. Stable baseline (pref already matches the cache): quiet.
713
+ const quietDeliveries = []
714
+ const quietRecord = { registerCalls: [], watchers: [] }
715
+ const { ctx: quietCtx } = makeStubCtx({
716
+ status: fakeStatus([]),
717
+ sections: fakeSections([]),
718
+ settingsService: fakeSettingsService(quietRecord, { followSystem: true }),
719
+ toast: fakeToast(quietDeliveries),
720
+ })
721
+ await applyAndSettle(quietCtx)
722
+ assert.equal(quietDeliveries.length, 0, 'a baseline that changes nothing stays silent')
723
+
724
+ // 13c. Toggle confirmations: an explicit enable always answers — with the
725
+ // matching-cache confirmation, or the honest warning when nothing applies.
726
+ const toggleDeliveries = []
727
+ const toggleRecord = { registerCalls: [], watchers: [] }
728
+ const { ctx: toggleCtx } = makeStubCtx({
729
+ status: fakeStatus([]),
730
+ sections: fakeSections([]),
731
+ settingsService: fakeSettingsService(toggleRecord, {}),
732
+ toast: fakeToast(toggleDeliveries),
733
+ })
734
+ await applyAndSettle(toggleCtx)
735
+ for (const w of toggleRecord.watchers) w({ followSystem: true })
736
+ assert.equal(toggleDeliveries.length, 1, 'toggle-on with a matching cache confirms')
737
+ assert.equal(toggleDeliveries[0][1], 'success')
738
+ assert.equal(/reload/.test(toggleDeliveries[0][0]), false, 'no reload hint when nothing changed')
739
+ for (const w of toggleRecord.watchers) w({ followSystem: false })
740
+ rmSync(join(dataDir, 'theme-follow.json'), { force: true })
741
+ for (const w of toggleRecord.watchers) w({ followSystem: true })
742
+ assert.equal(toggleDeliveries.length, 2, 'toggle-on without a cache answers too')
743
+ assert.equal(toggleDeliveries[1][1], 'warning')
744
+
745
+ // 13d. A toast dropped before the host sink exists is retried until delivered.
746
+ const retryDeliveries = []
747
+ const retryRecord = { registerCalls: [], watchers: [] }
748
+ const { ctx: retryCtx } = makeStubCtx({
749
+ status: fakeStatus([]),
750
+ sections: fakeSections([]),
751
+ settingsService: fakeSettingsService(retryRecord, {}),
752
+ toast: fakeToast(retryDeliveries, { dropFirst: 1 }),
753
+ })
754
+ await applyAndSettle(retryCtx)
755
+ writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: false, at: 1 }))
756
+ writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-day' }, null, 2))
757
+ for (const w of retryRecord.watchers) w({ followSystem: true })
758
+ const retryDeadline = Date.now() + 2_000
759
+ while (retryDeliveries.length === 0 && Date.now() < retryDeadline) {
760
+ await new Promise(resolve => setTimeout(resolve, 5))
761
+ }
762
+ assert.equal(retryDeliveries.length, 1, 'a dropped toast is retried and delivered')
763
+ assert.match(retryDeliveries[0][0], /pink-night/)
764
+
765
+ // 13e. Toast-seam-less host (dsh-TUI < 0.10): sends are silent no-ops and
766
+ // the follow feature itself is unchanged. The retry chain stays bounded:
767
+ // once it gives up, a seam arriving later must not resurrect the abandoned
768
+ // toast.
769
+ const legacyRecord = { registerCalls: [], watchers: [] }
770
+ const legacyContext = makeStubCtx({
771
+ status: fakeStatus([]),
772
+ sections: fakeSections([]),
773
+ settingsService: fakeSettingsService(legacyRecord, {}),
774
+ deferToast: true,
775
+ })
776
+ await applyAndSettle(legacyContext.ctx)
777
+ writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
778
+ writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
779
+ for (const w of legacyRecord.watchers) w({ followSystem: true })
780
+ assert.equal(readThemePref(dataDir), 'pink-day', 'follow works unchanged without the toast seam')
781
+ await new Promise(resolve => setTimeout(resolve, 50)) // the [5, 5] chain is long exhausted
782
+ const lateDeliveries = []
783
+ legacyContext.record.activateToast(fakeToast(lateDeliveries))
784
+ await settle()
785
+ assert.equal(lateDeliveries.length, 0, 'an abandoned toast is not resurrected by a late seam')
786
+
787
+ // 13f. Shadow hint: legacy same-named files that are byte-identical to the
788
+ // bundled copy are pointed out once on runtime confirmation; user edits
789
+ // stay unmentioned. Files this activation installed itself are excluded
790
+ // (they were already removed before the check).
791
+ rmSync(sandboxThemes, { recursive: true, force: true })
792
+ mkdirSync(sandboxThemes, { recursive: true })
793
+ for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
794
+ writeFileSync(join(sandboxThemes, `${theme}.json`), readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'))
795
+ }
796
+ assert.deepEqual(findShadowedBundledThemes().sort(), ['pink-ansi.json', 'pink-day.json', 'pink-night.json'])
797
+ const shadowDeliveries = []
798
+ const shadowContext = makeStubCtx({ deferThemes: true, toast: fakeToast(shadowDeliveries) })
799
+ apply(shadowContext.ctx)
800
+ shadowContext.record.activateThemes(fakeThemes(shadowContext.record))
801
+ await settle()
802
+ assert.equal(shadowDeliveries.length, 1, 'identical legacy files are pointed out once')
803
+ assert.equal(shadowDeliveries[0][1], undefined, 'the shadow hint is neutral, not an error')
804
+ assert.match(shadowDeliveries[0][0], /pink-day\.json/)
805
+
806
+ rmSync(sandboxThemes, { recursive: true, force: true })
807
+ installBundledThemes()
808
+ writeFileSync(join(sandboxThemes, 'pink-night.json'), '{ "name": "pink-night", "colors": { "text": "#123456" } }')
809
+ const editDeliveries = []
810
+ const editContext = makeStubCtx({ deferThemes: true, toast: fakeToast(editDeliveries) })
811
+ apply(editContext.ctx)
812
+ editContext.record.activateThemes(fakeThemes(editContext.record))
813
+ await settle()
814
+ assert.equal(editDeliveries.length, 1)
815
+ assert.equal(/pink-night\.json/.test(editDeliveries[0][0]), false, 'a user-edited file is never nagged')
816
+ assert.equal(/pink-day\.json/.test(editDeliveries[0][0]), true)
817
+
818
+ // 13g. Corrupt-file self-heal is surfaced as a warning toast. The toast
819
+ // seam trails apply on a real host, so the send waits on the retry chain.
820
+ rmSync(sandboxThemes, { recursive: true, force: true })
821
+ mkdirSync(sandboxThemes, { recursive: true })
822
+ writeFileSync(join(sandboxThemes, 'pink-day.json'), '{ broken')
823
+ const healDeliveries = []
824
+ const healContext = makeStubCtx({ deferThemes: true, deferToast: true })
825
+ apply(healContext.ctx)
826
+ healContext.record.activateToast(fakeToast(healDeliveries))
827
+ const healDeadline = Date.now() + 2_000
828
+ while (healDeliveries.length === 0 && Date.now() < healDeadline) {
829
+ await new Promise(resolve => setTimeout(resolve, 5))
830
+ }
831
+ assert.equal(healDeliveries.length, 1, 'a repaired theme file is surfaced')
832
+ assert.equal(healDeliveries[0][1], 'warning')
833
+ assert.match(healDeliveries[0][0], /pink-day\.json/)
834
+
835
+ rmSync(sandboxThemes, { recursive: true, force: true })
836
+ setToastRetryDelaysForTests([2_000, 4_000])
837
+ console.log('✓ toast feedback: follow/self-heal/shadow hints delivered, dropped and seam-less sends retried, legacy hosts silent and bounded')
838
+ }
839
+
544
840
  console.log('\nAll plugin verifications passed.')
545
841
  console.log(`(sandbox used: ${sandboxHome} — the real home was never touched)`)