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.
- package/README.md +25 -6
- package/lib/types/autoTheme.d.ts +14 -1
- package/lib/types/autoTheme.d.ts.map +1 -1
- package/lib/types/autoTheme.js +10 -5
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +83 -8
- package/lib/types/runtimeThemes.d.ts +9 -0
- package/lib/types/runtimeThemes.d.ts.map +1 -0
- package/lib/types/runtimeThemes.js +39 -0
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/settingsSection.js +5 -2
- package/lib/types/themeAssets.d.ts +23 -0
- package/lib/types/themeAssets.d.ts.map +1 -1
- package/lib/types/themeAssets.js +126 -12
- package/lib/types/toast.d.ts +40 -0
- package/lib/types/toast.d.ts.map +1 -0
- package/lib/types/toast.js +91 -0
- package/package.json +49 -7
- package/scripts/headless-order-test.mjs +23 -11
- package/scripts/runtime-themes-headless.mjs +180 -0
- package/scripts/validate-themes-against-host.mjs +22 -14
- package/scripts/verify-package.mjs +14 -1
- package/scripts/verify.mjs +314 -18
package/scripts/verify.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
222
|
-
// stdin/stdout handles and cannot create a raw-mode lease or
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)`)
|