dsh-tui-theme 0.3.2 → 0.5.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 +49 -25
- package/cordis.patch.yml +3 -3
- package/docs/screenshots/settings.png +0 -0
- package/lib/types/autoTheme.d.ts +16 -50
- package/lib/types/autoTheme.d.ts.map +1 -1
- package/lib/types/autoTheme.js +40 -176
- package/lib/types/index.d.ts +2 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +36 -42
- package/lib/types/pluginId.d.ts +11 -0
- package/lib/types/pluginId.d.ts.map +1 -0
- package/lib/types/pluginId.js +10 -0
- package/lib/types/settingsSection.d.ts +2 -2
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/settingsSection.js +10 -9
- package/lib/types/statusLine.d.ts +1 -1
- package/lib/types/statusLine.d.ts.map +1 -1
- package/lib/types/statusLine.js +12 -4
- package/lib/types/themeAssets.d.ts +8 -3
- package/lib/types/themeAssets.d.ts.map +1 -1
- package/lib/types/themeAssets.js +59 -13
- package/package.json +12 -7
- package/scripts/expected-settings-contract.mjs +53 -0
- package/scripts/headless-order-test.mjs +132 -0
- package/scripts/validate-themes-against-host.mjs +199 -0
- package/scripts/verify-package.mjs +82 -0
- package/scripts/verify.mjs +545 -0
|
@@ -0,0 +1,545 @@
|
|
|
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 } = await import('../lib/types/themeAssets.js')
|
|
48
|
+
const { startStatusLine } = await import('../lib/types/statusLine.js')
|
|
49
|
+
const {
|
|
50
|
+
themeForBackground,
|
|
51
|
+
readThemePref,
|
|
52
|
+
writeThemePref,
|
|
53
|
+
readFollowCache,
|
|
54
|
+
applyCachedFollow,
|
|
55
|
+
runFollowSystem,
|
|
56
|
+
} = await import('../lib/types/autoTheme.js')
|
|
57
|
+
|
|
58
|
+
assert.equal(name, 'dsh-tui-theme')
|
|
59
|
+
|
|
60
|
+
/** 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: [] }
|
|
63
|
+
const logger = { info: msg => record.infos.push(String(msg)), warn: msg => record.warnings.push(String(msg)), error: () => {} }
|
|
64
|
+
const base = {
|
|
65
|
+
logger,
|
|
66
|
+
get(serviceName) {
|
|
67
|
+
if (serviceName === 'tuiStatus') return status
|
|
68
|
+
if (serviceName === 'tuiSettingsSections') return sections
|
|
69
|
+
return undefined
|
|
70
|
+
},
|
|
71
|
+
on(event, handler) {
|
|
72
|
+
const list = record.handlers.get(event) ?? []
|
|
73
|
+
list.push(handler)
|
|
74
|
+
record.handlers.set(event, list)
|
|
75
|
+
return () => {}
|
|
76
|
+
},
|
|
77
|
+
effect(factory) {
|
|
78
|
+
const dispose = factory()
|
|
79
|
+
if (typeof dispose === 'function') record.disposers.push(dispose)
|
|
80
|
+
},
|
|
81
|
+
inject(deps, callback) {
|
|
82
|
+
// Simulate cordis: the callback runs once every requested service
|
|
83
|
+
// exists (immediately here), and property access works inside it.
|
|
84
|
+
const services = {
|
|
85
|
+
settings: settingsService,
|
|
86
|
+
tuiStatus: status,
|
|
87
|
+
tuiSettingsSections: sections,
|
|
88
|
+
}
|
|
89
|
+
if (deps.every(dep => services[dep] !== undefined)) {
|
|
90
|
+
const props = Object.fromEntries(deps.map(dep => [dep, services[dep]]))
|
|
91
|
+
callback({ ...base, ...props })
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
return { ctx: base, record }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fakeStatus = calls => ({ set(key, text) { calls.push([key, text]); return () => {} } })
|
|
99
|
+
const fakeSections = calls => ({ register(section) { calls.push(section); return () => {} } })
|
|
100
|
+
const fakeSettingsService = (record, doc) => ({
|
|
101
|
+
register(namespace, schema) {
|
|
102
|
+
record.registerCalls.push([namespace, schema])
|
|
103
|
+
return {
|
|
104
|
+
get: () => doc,
|
|
105
|
+
watch(listener) { record.watchers.push(listener); return () => {} },
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const emit = (record, event, ...args) => {
|
|
111
|
+
for (const handler of record.handlers.get(event) ?? []) handler(...args)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── 1. bare host: no seam present → theme assets still install (pure fs),
|
|
115
|
+
// but nothing UI-facing happens ────────────────────────────────────────
|
|
116
|
+
{
|
|
117
|
+
const { ctx, record } = makeStubCtx()
|
|
118
|
+
apply(ctx)
|
|
119
|
+
assert.deepEqual(record.warnings, [])
|
|
120
|
+
assert.equal(record.handlers.size, 0, 'no event handlers without the status seam')
|
|
121
|
+
for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
122
|
+
assert.equal(existsSync(join(sandboxThemes, `${theme}.json`)), true, `${theme}.json installed`)
|
|
123
|
+
}
|
|
124
|
+
console.log('✓ bare host (no seams): themes installed, nothing UI-facing')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── 2. full host: everything wired ──────────────────────────────────────────
|
|
128
|
+
{
|
|
129
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
130
|
+
// A pink theme is active — the default policy hides the line otherwise.
|
|
131
|
+
mkdirSync(join(sandboxHome, '.dsh-tui'), { recursive: true })
|
|
132
|
+
writeFileSync(join(sandboxHome, '.dsh-tui', 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
133
|
+
const statusCalls = []
|
|
134
|
+
const sectionsCalls = []
|
|
135
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
136
|
+
const { ctx, record } = makeStubCtx({
|
|
137
|
+
status: fakeStatus(statusCalls),
|
|
138
|
+
sections: fakeSections(sectionsCalls),
|
|
139
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
140
|
+
})
|
|
141
|
+
apply(ctx)
|
|
142
|
+
|
|
143
|
+
// Themes installed into the SANDBOX, never the real home.
|
|
144
|
+
for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
145
|
+
assert.equal(existsSync(join(sandboxThemes, `${theme}.json`)), true, `${theme}.json installed`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Status line: one keyed contribution with glyph · clock.
|
|
149
|
+
assert.equal(statusCalls.length > 0, true)
|
|
150
|
+
const [key, first] = statusCalls[0]
|
|
151
|
+
assert.equal(key, 'dsh-tui-theme')
|
|
152
|
+
assert.match(first, /^✿ · \d{2}:\d{2}$/)
|
|
153
|
+
|
|
154
|
+
// Turns counted from session events (live, no log appends).
|
|
155
|
+
const session = { id: 's1' }
|
|
156
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
157
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
158
|
+
const latest = statusCalls.at(-1)[1]
|
|
159
|
+
assert.match(latest, /^✿ · \d{2}:\d{2} · 2✦$/)
|
|
160
|
+
|
|
161
|
+
// Settings namespace registered and the section declaration remains within
|
|
162
|
+
// the shared host form contract.
|
|
163
|
+
assert.equal(settingsRecord.registerCalls.length, 1)
|
|
164
|
+
assert.equal(sectionsCalls.length, 1)
|
|
165
|
+
assertSettingsContract(assert, sectionsCalls[0])
|
|
166
|
+
|
|
167
|
+
// A committed /settings edit lands live on the next render.
|
|
168
|
+
for (const watcher of settingsRecord.watchers) {
|
|
169
|
+
watcher({ showGlyph: false, showClock: false })
|
|
170
|
+
}
|
|
171
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
172
|
+
assert.match(statusCalls.at(-1)[1], /^3✦$/)
|
|
173
|
+
|
|
174
|
+
// Disposal never throws.
|
|
175
|
+
for (const dispose of record.disposers) dispose()
|
|
176
|
+
console.log('✓ full host: themes installed, status line live, settings wired')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── 3. idempotence + user-file protection ───────────────────────────────────
|
|
180
|
+
{
|
|
181
|
+
const again = installBundledThemes()
|
|
182
|
+
assert.deepEqual(again.installed, [])
|
|
183
|
+
assert.deepEqual(again.repaired, [])
|
|
184
|
+
assert.equal(again.skipped.length, 3)
|
|
185
|
+
|
|
186
|
+
// A user-edited same-named file must survive reinstallation.
|
|
187
|
+
const userFile = join(sandboxThemes, 'pink-night.json')
|
|
188
|
+
writeFileSync(userFile, '{ "name": "pink-night", "base": "dark", "colors": { "text": "#123456" } }')
|
|
189
|
+
const third = installBundledThemes()
|
|
190
|
+
assert.deepEqual(third.installed, [])
|
|
191
|
+
assert.equal(JSON.parse(readFileSync(userFile, 'utf8')).colors.text, '#123456')
|
|
192
|
+
console.log('✓ reinstall: skips existing files, never overwrites user edits')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── 4. autoInstallThemes: false on a clean sandbox ──────────────────────────
|
|
196
|
+
{
|
|
197
|
+
rmSync(sandboxHome, { recursive: true, force: true })
|
|
198
|
+
mkdirSync(sandboxHome, { recursive: true })
|
|
199
|
+
const { ctx } = makeStubCtx()
|
|
200
|
+
apply(ctx, { autoInstallThemes: false })
|
|
201
|
+
assert.equal(existsSync(join(sandboxHome, '.dsh-tui')), false, 'must not create the dir when disabled')
|
|
202
|
+
console.log('✓ autoInstallThemes=false: leaves the themes dir untouched')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── 5. cached background follow: no terminal I/O ───────────────────────────
|
|
206
|
+
{
|
|
207
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
208
|
+
mkdirSync(dataDir, { recursive: true })
|
|
209
|
+
assert.equal(themeForBackground(true), 'pink-day')
|
|
210
|
+
assert.equal(themeForBackground(false), 'pink-night')
|
|
211
|
+
assert.equal(applyCachedFollow(dataDir), undefined, 'no cache preserves the existing choice')
|
|
212
|
+
|
|
213
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
214
|
+
assert.equal(applyCachedFollow(dataDir), 'pink-day')
|
|
215
|
+
assert.equal(readFollowCache(dataDir).light, true)
|
|
216
|
+
assert.equal(readThemePref(dataDir), 'pink-day')
|
|
217
|
+
|
|
218
|
+
// Same value -> no rewrite churn (mtime-agnostic: pref already matches).
|
|
219
|
+
assert.equal(applyCachedFollow(dataDir), 'pink-day')
|
|
220
|
+
|
|
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)'])
|
|
226
|
+
|
|
227
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
|
|
228
|
+
runFollowSystem(dataDir, () => false, message => logs.push(message))
|
|
229
|
+
assert.equal(readThemePref(dataDir), 'dark', 'inactive follow preserves manual choice')
|
|
230
|
+
console.log('✓ follow: cached background applies without terminal I/O')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── 6. statusEnabled: false silences the whole line ─────────────────────────
|
|
234
|
+
{
|
|
235
|
+
const statusCalls = []
|
|
236
|
+
const { ctx } = makeStubCtx({ status: fakeStatus(statusCalls) })
|
|
237
|
+
apply(ctx, { statusEnabled: false })
|
|
238
|
+
assert.equal(statusCalls.length > 0, true, 'render still ran once')
|
|
239
|
+
// Every contribution is cleared (undefined), not rendered.
|
|
240
|
+
for (const [, text] of statusCalls) assert.equal(text, undefined)
|
|
241
|
+
console.log('✓ statusEnabled=false: the line contributes nothing')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── 7. followSystem honors the /settings user layer with cached state ───────
|
|
245
|
+
{
|
|
246
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
247
|
+
rmSync(join(dataDir, 'theme-follow.json'), { force: true })
|
|
248
|
+
rmSync(join(dataDir, 'theme.json'), { force: true })
|
|
249
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
250
|
+
const { ctx } = makeStubCtx({
|
|
251
|
+
status: fakeStatus([]),
|
|
252
|
+
sections: fakeSections([]),
|
|
253
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
254
|
+
})
|
|
255
|
+
apply(ctx, { followSystem: true })
|
|
256
|
+
// Cordis layer on, empty user layer, no cache yet -> no pref churn.
|
|
257
|
+
assert.equal(existsSync(join(dataDir, 'theme.json')), false)
|
|
258
|
+
|
|
259
|
+
// User disables follow via /settings; a stale cached flip must not write.
|
|
260
|
+
for (const w of settingsRecord.watchers) w({ followSystem: false })
|
|
261
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
262
|
+
assert.equal(existsSync(join(dataDir, 'theme.json')), false, 'disabled follow must not rewrite the pref')
|
|
263
|
+
|
|
264
|
+
// Re-enable → the cached background applies immediately.
|
|
265
|
+
for (const w of settingsRecord.watchers) w({ followSystem: true })
|
|
266
|
+
assert.equal(readThemePref(dataDir), 'pink-day')
|
|
267
|
+
console.log('✓ followSystem: /settings toggle decides live (off = pref preserved)')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── 8. theme gating: the line belongs to the pink palettes ──────────────────
|
|
271
|
+
{
|
|
272
|
+
const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
|
|
273
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
274
|
+
const statusCalls = []
|
|
275
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
276
|
+
const { ctx, record } = makeStubCtx({
|
|
277
|
+
status: fakeStatus(statusCalls),
|
|
278
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
279
|
+
})
|
|
280
|
+
apply(ctx)
|
|
281
|
+
const session = { id: 'g1' }
|
|
282
|
+
|
|
283
|
+
// Pink active → renders.
|
|
284
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
285
|
+
assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 1✦$/)
|
|
286
|
+
|
|
287
|
+
// Non-pink theme active → hidden by default.
|
|
288
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
|
|
289
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
290
|
+
assert.equal(statusCalls.at(-1)[1], undefined)
|
|
291
|
+
|
|
292
|
+
// Opt in via /settings → shown on non-pink too (the turn count kept
|
|
293
|
+
// ticking while the line was hidden — turns count since the TUI started).
|
|
294
|
+
for (const w of settingsRecord.watchers) w({ statusScope: 'all-themes' })
|
|
295
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
296
|
+
assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 3✦$/)
|
|
297
|
+
|
|
298
|
+
// Host precedence: DSH_TUI_THEME wins over the dark pref.
|
|
299
|
+
const baselineThemeOverride = process.env.DSH_TUI_THEME
|
|
300
|
+
try {
|
|
301
|
+
process.env.DSH_TUI_THEME = 'pink-day'
|
|
302
|
+
for (const w of settingsRecord.watchers) w({})
|
|
303
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
304
|
+
assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 4✦$/)
|
|
305
|
+
} finally {
|
|
306
|
+
if (baselineThemeOverride === undefined) {
|
|
307
|
+
delete process.env.DSH_TUI_THEME
|
|
308
|
+
} else {
|
|
309
|
+
process.env.DSH_TUI_THEME = baselineThemeOverride
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
console.log('✓ theme gating: pink-only by default, opt-in shows everywhere')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── 9. settings service hostility: registration throws → warn, never crash ─
|
|
316
|
+
{
|
|
317
|
+
const throwingSettings = {
|
|
318
|
+
register() { throw new Error('namespace already registered (hot reload)') },
|
|
319
|
+
}
|
|
320
|
+
const { ctx, record } = makeStubCtx({ settingsService: throwingSettings })
|
|
321
|
+
apply(ctx) // must not throw
|
|
322
|
+
assert.equal(record.warnings.length, 1, 'registration failure logs one warning')
|
|
323
|
+
assert.match(record.warnings[0], /settings namespace registration failed/)
|
|
324
|
+
console.log('✓ hostile settings service: registration failure warns, never propagates')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── 10. missing settings: never override a manual theme choice ─────────────
|
|
328
|
+
{
|
|
329
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
330
|
+
mkdirSync(dataDir, { recursive: true })
|
|
331
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
|
|
332
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
333
|
+
const { ctx } = makeStubCtx()
|
|
334
|
+
apply(ctx, { followSystem: true })
|
|
335
|
+
assert.equal(readThemePref(dataDir), 'dark', 'without settings, cached follow must not override manual choice')
|
|
336
|
+
console.log('✓ missing settings: cached follow leaves the manual choice intact')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── 11. filesystem commits: atomic replacement and exclusive creation ───────
|
|
340
|
+
{
|
|
341
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
342
|
+
mkdirSync(dataDir, { recursive: true })
|
|
343
|
+
const pref = join(dataDir, 'theme.json')
|
|
344
|
+
writeFileSync(pref, JSON.stringify({ theme: 'dark' }, null, 2))
|
|
345
|
+
const originalRename = builtinFs.renameSync
|
|
346
|
+
try {
|
|
347
|
+
builtinFs.renameSync = () => { throw new Error('rename blocked') }
|
|
348
|
+
syncBuiltinESMExports()
|
|
349
|
+
assert.equal(writeThemePref('pink-day', dataDir), false)
|
|
350
|
+
assert.equal(readThemePref(dataDir), 'dark', 'failed commit leaves the prior JSON readable')
|
|
351
|
+
assert.equal(
|
|
352
|
+
readdirSync(dataDir).some(file => file.startsWith('.theme.json.') && file.endsWith('.tmp')),
|
|
353
|
+
false,
|
|
354
|
+
'failed commit removes its temporary file',
|
|
355
|
+
)
|
|
356
|
+
} finally {
|
|
357
|
+
builtinFs.renameSync = originalRename
|
|
358
|
+
syncBuiltinESMExports()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const targetDir = join(sandboxHome, 'race-target')
|
|
362
|
+
const originalWrite = builtinFs.writeFileSync
|
|
363
|
+
let racedFile
|
|
364
|
+
try {
|
|
365
|
+
builtinFs.writeFileSync = (path, data, options) => {
|
|
366
|
+
const target = String(path)
|
|
367
|
+
if (racedFile === undefined && target.startsWith(targetDir) && options?.flag === 'wx') {
|
|
368
|
+
racedFile = target.split(/[\\/]/).at(-1)
|
|
369
|
+
originalWrite(path, '{ "user": true }', { flag: 'wx' })
|
|
370
|
+
}
|
|
371
|
+
return originalWrite(path, data, options)
|
|
372
|
+
}
|
|
373
|
+
syncBuiltinESMExports()
|
|
374
|
+
const result = installBundledThemes(targetDir, join(pluginRoot, 'themes'))
|
|
375
|
+
assert.equal(typeof racedFile, 'string', 'the test injected a competing creator')
|
|
376
|
+
assert.equal(result.skipped.includes(racedFile), true, 'EEXIST is a protected user-file skip')
|
|
377
|
+
assert.deepEqual(JSON.parse(readFileSync(join(targetDir, racedFile), 'utf8')), { user: true })
|
|
378
|
+
} finally {
|
|
379
|
+
builtinFs.writeFileSync = originalWrite
|
|
380
|
+
syncBuiltinESMExports()
|
|
381
|
+
}
|
|
382
|
+
console.log('✓ filesystem commits: failed atomic writes preserve JSON; competing theme creation skips')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── 12a. torn installation: a corrupt target is backed up and reinstalled ───
|
|
386
|
+
{
|
|
387
|
+
const targetDir = join(sandboxHome, 'heal-target')
|
|
388
|
+
const sourceDir = join(pluginRoot, 'themes')
|
|
389
|
+
mkdirSync(targetDir, { recursive: true })
|
|
390
|
+
|
|
391
|
+
// A valid user file stays untouched — the never-overwrite rule wins.
|
|
392
|
+
writeFileSync(join(targetDir, 'pink-night.json'), '{ "user": true }')
|
|
393
|
+
// A torn write (crash mid-install) leaves invalid JSON behind.
|
|
394
|
+
writeFileSync(join(targetDir, 'pink-day.json'), '{ "name": "pink-day", "colors": {')
|
|
395
|
+
const heal = installBundledThemes(targetDir, sourceDir)
|
|
396
|
+
assert.deepEqual(heal.repaired, ['pink-day.json'], 'the corrupt target is reported as repaired')
|
|
397
|
+
assert.equal(heal.skipped.includes('pink-night.json'), true)
|
|
398
|
+
assert.equal(heal.failed.length, 0)
|
|
399
|
+
assert.equal(
|
|
400
|
+
JSON.parse(readFileSync(join(targetDir, 'pink-night.json'), 'utf8')).user,
|
|
401
|
+
true,
|
|
402
|
+
'valid user file untouched',
|
|
403
|
+
)
|
|
404
|
+
const reinstalled = JSON.parse(readFileSync(join(targetDir, 'pink-day.json'), 'utf8'))
|
|
405
|
+
assert.equal(reinstalled.name, 'pink-day')
|
|
406
|
+
const backups = readdirSync(targetDir).filter(entry =>
|
|
407
|
+
entry.startsWith('pink-day.json.corrupt-'),
|
|
408
|
+
)
|
|
409
|
+
assert.equal(backups.length, 1, 'the damaged file is preserved as a timestamped backup')
|
|
410
|
+
assert.equal(readFileSync(join(targetDir, backups[0]), 'utf8'), '{ "name": "pink-day", "colors": {')
|
|
411
|
+
|
|
412
|
+
// Next boot: everything parses, so no churn.
|
|
413
|
+
const steady = installBundledThemes(targetDir, sourceDir)
|
|
414
|
+
assert.deepEqual(steady.repaired, [])
|
|
415
|
+
assert.deepEqual(steady.installed, [])
|
|
416
|
+
assert.equal(steady.skipped.length, 3)
|
|
417
|
+
|
|
418
|
+
// A target the process cannot even read is not proven corrupt — keep skipping.
|
|
419
|
+
const unreadable = join(targetDir, 'pink-ansi.json')
|
|
420
|
+
const originalRead = builtinFs.readFileSync
|
|
421
|
+
try {
|
|
422
|
+
builtinFs.readFileSync = (path, ...rest) => {
|
|
423
|
+
if (String(path) === unreadable) throw new Error('EBUSY: locked')
|
|
424
|
+
return originalRead(path, ...rest)
|
|
425
|
+
}
|
|
426
|
+
syncBuiltinESMExports()
|
|
427
|
+
const blocked = installBundledThemes(targetDir, sourceDir)
|
|
428
|
+
assert.deepEqual(blocked.repaired, [])
|
|
429
|
+
assert.equal(blocked.skipped.includes('pink-ansi.json'), true)
|
|
430
|
+
} finally {
|
|
431
|
+
builtinFs.readFileSync = originalRead
|
|
432
|
+
syncBuiltinESMExports()
|
|
433
|
+
}
|
|
434
|
+
console.log('✓ torn installation: corrupt target backed up and reinstalled; user files and unreadable targets untouched')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── 12b. follow logging: the first settings doc is a baseline, not a flip ──
|
|
438
|
+
{
|
|
439
|
+
// Default user layer (empty doc): no spurious "follow: disabled" line.
|
|
440
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
441
|
+
const { ctx, record } = makeStubCtx({ settingsService: fakeSettingsService(settingsRecord, {}) })
|
|
442
|
+
apply(ctx)
|
|
443
|
+
assert.equal(
|
|
444
|
+
record.infos.some(message => message.includes('follow: disabled')),
|
|
445
|
+
false,
|
|
446
|
+
'a baseline doc matching the default must not log a disabled flip',
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
// User layer that starts enabled: the cache applies during the baseline.
|
|
450
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
451
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
452
|
+
const enabledRecord = { registerCalls: [], watchers: [] }
|
|
453
|
+
const { ctx: enabledCtx, record: enabledInfo } = makeStubCtx({
|
|
454
|
+
status: fakeStatus([]),
|
|
455
|
+
sections: fakeSections([]),
|
|
456
|
+
settingsService: fakeSettingsService(enabledRecord, { followSystem: true }),
|
|
457
|
+
})
|
|
458
|
+
apply(enabledCtx)
|
|
459
|
+
assert.equal(readThemePref(dataDir), 'pink-day', 'an enabled baseline applies the cached background')
|
|
460
|
+
assert.equal(
|
|
461
|
+
enabledInfo.infos.some(message => message.includes('follow: disabled')),
|
|
462
|
+
false,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
// A real toggle keeps its log.
|
|
466
|
+
for (const watcher of enabledRecord.watchers) watcher({ followSystem: false })
|
|
467
|
+
assert.equal(
|
|
468
|
+
enabledInfo.infos.filter(message => message.includes('follow: disabled')).length,
|
|
469
|
+
1,
|
|
470
|
+
'disabling follow after the baseline logs exactly once',
|
|
471
|
+
)
|
|
472
|
+
assert.equal(readThemePref(dataDir), 'pink-day', 'the manual choice stays intact')
|
|
473
|
+
console.log('✓ follow logging: baseline docs stay quiet, real toggles still log')
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── 12. status injection: session handlers die with tuiStatus activation ────
|
|
477
|
+
{
|
|
478
|
+
const outerHandlers = new Map()
|
|
479
|
+
let activate
|
|
480
|
+
const outerCtx = {
|
|
481
|
+
on(event, handler) {
|
|
482
|
+
const handlers = outerHandlers.get(event) ?? []
|
|
483
|
+
handlers.push(handler)
|
|
484
|
+
outerHandlers.set(event, handlers)
|
|
485
|
+
return () => {}
|
|
486
|
+
},
|
|
487
|
+
inject(_deps, callback) { activate = callback },
|
|
488
|
+
}
|
|
489
|
+
const makeActivation = calls => {
|
|
490
|
+
const handlers = new Map()
|
|
491
|
+
const disposers = []
|
|
492
|
+
return {
|
|
493
|
+
tuiStatus: fakeStatus(calls),
|
|
494
|
+
on(event, handler) {
|
|
495
|
+
const eventHandlers = handlers.get(event) ?? []
|
|
496
|
+
eventHandlers.push(handler)
|
|
497
|
+
handlers.set(event, eventHandlers)
|
|
498
|
+
return () => handlers.set(event, eventHandlers.filter(entry => entry !== handler))
|
|
499
|
+
},
|
|
500
|
+
effect(factory) {
|
|
501
|
+
const dispose = factory()
|
|
502
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
503
|
+
},
|
|
504
|
+
emit(event, ...args) {
|
|
505
|
+
for (const handler of handlers.get(event) ?? []) handler(...args)
|
|
506
|
+
},
|
|
507
|
+
dispose() {
|
|
508
|
+
handlers.clear()
|
|
509
|
+
for (const dispose of disposers) dispose()
|
|
510
|
+
},
|
|
511
|
+
handlerCount(event) { return (handlers.get(event) ?? []).length },
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const effective = {
|
|
515
|
+
statusEnabled: true,
|
|
516
|
+
showGlyph: false,
|
|
517
|
+
showClock: false,
|
|
518
|
+
showTurns: true,
|
|
519
|
+
statusScope: 'all-themes',
|
|
520
|
+
}
|
|
521
|
+
startStatusLine(outerCtx, () => effective)
|
|
522
|
+
|
|
523
|
+
const firstCalls = []
|
|
524
|
+
const first = makeActivation(firstCalls)
|
|
525
|
+
activate(first)
|
|
526
|
+
assert.equal(outerHandlers.size, 0, 'status activation must not register outer-owned session handlers')
|
|
527
|
+
assert.equal(first.handlerCount('session/event'), 1)
|
|
528
|
+
first.emit('session/event', { id: 'first' }, { type: 'turn/end' })
|
|
529
|
+
assert.equal(firstCalls.at(-1)[1], '1✦')
|
|
530
|
+
first.dispose()
|
|
531
|
+
first.emit('session/event', { id: 'stale' }, { type: 'turn/end' })
|
|
532
|
+
assert.equal(firstCalls.at(-1)[1], '1✦', 'disposed activation ignores later session events')
|
|
533
|
+
|
|
534
|
+
const secondCalls = []
|
|
535
|
+
const second = makeActivation(secondCalls)
|
|
536
|
+
activate(second)
|
|
537
|
+
second.emit('session/event', { id: 'second' }, { type: 'turn/end' })
|
|
538
|
+
assert.equal(secondCalls.at(-1)[1], '1✦', 'replacement activation owns the only live handler')
|
|
539
|
+
assert.equal(firstCalls.at(-1)[1], '1✦')
|
|
540
|
+
second.dispose()
|
|
541
|
+
console.log('✓ status lifecycle: session handlers follow tuiStatus activation')
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
console.log('\nAll plugin verifications passed.')
|
|
545
|
+
console.log(`(sandbox used: ${sandboxHome} — the real home was never touched)`)
|