dsh-tui-theme 0.5.1 → 0.6.1
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 +90 -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/statusLine.d.ts +15 -2
- package/lib/types/statusLine.d.ts.map +1 -1
- package/lib/types/statusLine.js +46 -9
- 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 +47 -5
- 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 +396 -20
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Validate bundled themes against matching dsh-TUI source and runtime modules.
|
|
3
|
-
* DSH_TUI_SOURCE_ROOT
|
|
4
|
-
*
|
|
3
|
+
* DSH_TUI_SOURCE_ROOT and DSH_TUI_ADAPTER_DIR may point at a matching source
|
|
4
|
+
* checkout and adapter directory. When omitted, the installed dsh-TUI
|
|
5
|
+
* devDependency is used as a zero-configuration baseline.
|
|
5
6
|
* Set DSH_TUI_EXPECTED_VERSION only when an explicit release baseline needs
|
|
6
7
|
* to be pinned; ordinary development verifies the supplied host as-is.
|
|
7
8
|
*
|
|
@@ -9,25 +10,32 @@
|
|
|
9
10
|
*/
|
|
10
11
|
import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'
|
|
11
12
|
import { tmpdir } from 'node:os'
|
|
12
|
-
import { join, resolve } from 'node:path'
|
|
13
|
+
import { dirname, join, resolve } from 'node:path'
|
|
13
14
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
14
15
|
import assert from 'node:assert/strict'
|
|
15
16
|
import ts from 'typescript'
|
|
17
|
+
import { createRequire } from 'node:module'
|
|
16
18
|
|
|
17
19
|
const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
18
20
|
const sourceRoot = process.env.DSH_TUI_SOURCE_ROOT
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
21
|
+
const hostRequire = createRequire(import.meta.url)
|
|
22
|
+
const devPackageJson = hostRequire.resolve('@deepseek-harness-tui/dsh-tui/package.json')
|
|
23
|
+
const hostRoot = sourceRoot === undefined || sourceRoot === ''
|
|
24
|
+
? dirname(devPackageJson)
|
|
25
|
+
: resolve(sourceRoot)
|
|
26
|
+
const sourceMode = sourceRoot !== undefined && sourceRoot !== ''
|
|
27
|
+
const customThemePath = sourceMode
|
|
28
|
+
? join(hostRoot, 'src', 'customTheme.ts')
|
|
29
|
+
: join(hostRoot, 'lib', 'types', 'customTheme.js')
|
|
30
|
+
const themePath = sourceMode
|
|
31
|
+
? join(hostRoot, 'src', 'theme.ts')
|
|
32
|
+
: join(hostRoot, 'lib', 'types', 'theme.d.ts')
|
|
25
33
|
|
|
26
34
|
if (!existsSync(customThemePath) || !existsSync(themePath)) {
|
|
27
|
-
throw new Error(`dsh-TUI sources not found at ${hostRoot}`)
|
|
35
|
+
throw new Error(`dsh-TUI ${sourceMode ? 'sources' : 'devDependency build'} not found at ${hostRoot}`)
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
const hostPackagePath = join(hostRoot, 'package.json')
|
|
38
|
+
const hostPackagePath = sourceMode ? join(hostRoot, 'package.json') : devPackageJson
|
|
31
39
|
if (!existsSync(hostPackagePath)) {
|
|
32
40
|
throw new Error(`dsh-TUI package metadata not found at ${hostPackagePath}`)
|
|
33
41
|
}
|
|
@@ -35,10 +43,10 @@ const hostPackage = JSON.parse(readFileSync(hostPackagePath, 'utf8'))
|
|
|
35
43
|
assert.match(hostPackage.version, /^\d+\.\d+\.\d+(?:[-+].+)?$/u, 'host source must declare a version')
|
|
36
44
|
|
|
37
45
|
const adapter = process.env.DSH_TUI_ADAPTER_DIR
|
|
38
|
-
if (adapter === undefined || adapter === '') {
|
|
46
|
+
if (sourceMode && (adapter === undefined || adapter === '')) {
|
|
39
47
|
throw new Error('DSH_TUI_ADAPTER_DIR must point at dsh-TUI lib/types/dsh-adapter for this host theme validation.')
|
|
40
48
|
}
|
|
41
|
-
const adapterRoot = resolve(adapter)
|
|
49
|
+
const adapterRoot = resolve(adapter || join(hostRoot, 'lib', 'types', 'dsh-adapter'))
|
|
42
50
|
const runtimeThemePath = join(adapterRoot, '..', 'theme.js')
|
|
43
51
|
const runtimeCustomThemePath = join(adapterRoot, '..', 'customTheme.js')
|
|
44
52
|
if (!existsSync(runtimeThemePath) || !existsSync(runtimeCustomThemePath)) {
|
|
@@ -86,7 +94,7 @@ function readThemeKeysFromSource(path) {
|
|
|
86
94
|
return keys
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
const allKeys = readThemeKeysFromSource(themePath)
|
|
97
|
+
const allKeys = sourceMode ? readThemeKeysFromSource(themePath) : Object.keys(getTheme('dark'))
|
|
90
98
|
assert.deepEqual(
|
|
91
99
|
[...Object.keys(getTheme('dark'))].sort(),
|
|
92
100
|
[...allKeys].sort(),
|
|
@@ -40,6 +40,10 @@ for (const required of [
|
|
|
40
40
|
'lib/types/statusLine.d.ts',
|
|
41
41
|
'lib/types/themeAssets.js',
|
|
42
42
|
'lib/types/themeAssets.d.ts',
|
|
43
|
+
'lib/types/runtimeThemes.js',
|
|
44
|
+
'lib/types/runtimeThemes.d.ts',
|
|
45
|
+
'lib/types/toast.js',
|
|
46
|
+
'lib/types/toast.d.ts',
|
|
43
47
|
'themes/pink-night.json',
|
|
44
48
|
'themes/pink-day.json',
|
|
45
49
|
'themes/pink-ansi.json',
|
|
@@ -49,6 +53,7 @@ for (const required of [
|
|
|
49
53
|
'scripts/verify.mjs',
|
|
50
54
|
'scripts/verify-package.mjs',
|
|
51
55
|
'scripts/headless-order-test.mjs',
|
|
56
|
+
'scripts/runtime-themes-headless.mjs',
|
|
52
57
|
'scripts/validate-themes-against-host.mjs',
|
|
53
58
|
'scripts/expected-settings-contract.mjs',
|
|
54
59
|
]) {
|
|
@@ -68,7 +73,15 @@ for (const entry of exportedPaths) {
|
|
|
68
73
|
for (const name of Object.keys(packageJson.peerDependencies)) {
|
|
69
74
|
if (!name.startsWith('@deepseek-ai/')) continue
|
|
70
75
|
assert.equal(packageJson.dependencies?.[name], undefined, `${name} must not be a runtime dependency`)
|
|
71
|
-
|
|
76
|
+
const peerRange = packageJson.peerDependencies[name]
|
|
77
|
+
const devRange = packageJson.devDependencies?.[name]
|
|
78
|
+
// Development baselines may pin one concrete prerelease while the published
|
|
79
|
+
// peer contract remains a union of compatible release lines.
|
|
80
|
+
const devAccepted = peerRange.split('||').some(entry => {
|
|
81
|
+
const candidate = entry.trim()
|
|
82
|
+
return candidate === devRange || candidate.replace(/^\^/u, '') === devRange
|
|
83
|
+
})
|
|
84
|
+
assert.equal(devAccepted, true, `${name} dev pin must be accepted by its peer range`)
|
|
72
85
|
}
|
|
73
86
|
assert.equal(packageJson.dependencies?.['@deepseek-ai/schemastery'], undefined)
|
|
74
87
|
const rootLock = lockfile.packages?.['']
|
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')
|
|
48
|
-
const { startStatusLine } = await import('../lib/types/statusLine.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')
|
|
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,15 +392,17 @@ 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.
|
|
284
399
|
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
285
400
|
assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 1✦$/)
|
|
286
401
|
|
|
287
|
-
// Non-pink theme active → hidden by default.
|
|
402
|
+
// Non-pink theme active → hidden by default. The pref cache is dropped so
|
|
403
|
+
// the rewrite is visible immediately (production invalidation is the TTL).
|
|
288
404
|
writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
|
|
405
|
+
invalidateThemePrefCacheForTests()
|
|
289
406
|
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
290
407
|
assert.equal(statusCalls.at(-1)[1], undefined)
|
|
291
408
|
|
|
@@ -318,7 +435,7 @@ const emit = (record, event, ...args) => {
|
|
|
318
435
|
register() { throw new Error('namespace already registered (hot reload)') },
|
|
319
436
|
}
|
|
320
437
|
const { ctx, record } = makeStubCtx({ settingsService: throwingSettings })
|
|
321
|
-
|
|
438
|
+
await applyAndSettle(ctx) // must not throw
|
|
322
439
|
assert.equal(record.warnings.length, 1, 'registration failure logs one warning')
|
|
323
440
|
assert.match(record.warnings[0], /settings namespace registration failed/)
|
|
324
441
|
console.log('✓ hostile settings service: registration failure warns, never propagates')
|
|
@@ -331,7 +448,7 @@ const emit = (record, event, ...args) => {
|
|
|
331
448
|
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
|
|
332
449
|
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
333
450
|
const { ctx } = makeStubCtx()
|
|
334
|
-
|
|
451
|
+
await applyAndSettle(ctx, { followSystem: true })
|
|
335
452
|
assert.equal(readThemePref(dataDir), 'dark', 'without settings, cached follow must not override manual choice')
|
|
336
453
|
console.log('✓ missing settings: cached follow leaves the manual choice intact')
|
|
337
454
|
}
|
|
@@ -431,6 +548,27 @@ const emit = (record, event, ...args) => {
|
|
|
431
548
|
builtinFs.readFileSync = originalRead
|
|
432
549
|
syncBuiltinESMExports()
|
|
433
550
|
}
|
|
551
|
+
|
|
552
|
+
// A successful backup followed by a failed replacement is a failure, not a
|
|
553
|
+
// protected skip: the target is absent until the next boot can retry.
|
|
554
|
+
const failedHeal = join(targetDir, 'pink-ansi.json')
|
|
555
|
+
writeFileSync(failedHeal, '{ broken')
|
|
556
|
+
const originalWrite = builtinFs.writeFileSync
|
|
557
|
+
try {
|
|
558
|
+
builtinFs.writeFileSync = (path, data, options) => {
|
|
559
|
+
if (String(path) === failedHeal && options?.flag === 'wx' && !existsSync(failedHeal)) {
|
|
560
|
+
throw new Error('ENOSPC: replacement blocked')
|
|
561
|
+
}
|
|
562
|
+
return originalWrite(path, data, options)
|
|
563
|
+
}
|
|
564
|
+
syncBuiltinESMExports()
|
|
565
|
+
const failed = installBundledThemes(targetDir, sourceDir)
|
|
566
|
+
assert.equal(failed.failed.includes('pink-ansi.json'), true)
|
|
567
|
+
assert.equal(failed.skipped.includes('pink-ansi.json'), false)
|
|
568
|
+
} finally {
|
|
569
|
+
builtinFs.writeFileSync = originalWrite
|
|
570
|
+
syncBuiltinESMExports()
|
|
571
|
+
}
|
|
434
572
|
console.log('✓ torn installation: corrupt target backed up and reinstalled; user files and unreadable targets untouched')
|
|
435
573
|
}
|
|
436
574
|
|
|
@@ -439,7 +577,7 @@ const emit = (record, event, ...args) => {
|
|
|
439
577
|
// Default user layer (empty doc): no spurious "follow: disabled" line.
|
|
440
578
|
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
441
579
|
const { ctx, record } = makeStubCtx({ settingsService: fakeSettingsService(settingsRecord, {}) })
|
|
442
|
-
|
|
580
|
+
await applyAndSettle(ctx)
|
|
443
581
|
assert.equal(
|
|
444
582
|
record.infos.some(message => message.includes('follow: disabled')),
|
|
445
583
|
false,
|
|
@@ -455,7 +593,7 @@ const emit = (record, event, ...args) => {
|
|
|
455
593
|
sections: fakeSections([]),
|
|
456
594
|
settingsService: fakeSettingsService(enabledRecord, { followSystem: true }),
|
|
457
595
|
})
|
|
458
|
-
|
|
596
|
+
await applyAndSettle(enabledCtx)
|
|
459
597
|
assert.equal(readThemePref(dataDir), 'pink-day', 'an enabled baseline applies the cached background')
|
|
460
598
|
assert.equal(
|
|
461
599
|
enabledInfo.infos.some(message => message.includes('follow: disabled')),
|
|
@@ -541,5 +679,243 @@ const emit = (record, event, ...args) => {
|
|
|
541
679
|
console.log('✓ status lifecycle: session handlers follow tuiStatus activation')
|
|
542
680
|
}
|
|
543
681
|
|
|
682
|
+
// ── 13. toast feedback: the 0.10 seam surfaces what the logger cannot ───────
|
|
683
|
+
{
|
|
684
|
+
setToastRetryDelaysForTests([5, 5])
|
|
685
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
686
|
+
mkdirSync(dataDir, { recursive: true })
|
|
687
|
+
|
|
688
|
+
// 13a. Startup baseline with a disagreeing cache: one success toast that
|
|
689
|
+
// points at /reload (the live TUI still shows the previous palette). The
|
|
690
|
+
// tuiToast seam trails apply on a real host (extensions row), so the send
|
|
691
|
+
// must survive on the bounded retry until the seam shows up.
|
|
692
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
693
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
694
|
+
const baselineDeliveries = []
|
|
695
|
+
const baselineRecord = { registerCalls: [], watchers: [] }
|
|
696
|
+
const baselineContext = makeStubCtx({
|
|
697
|
+
status: fakeStatus([]),
|
|
698
|
+
sections: fakeSections([]),
|
|
699
|
+
settingsService: fakeSettingsService(baselineRecord, { followSystem: true }),
|
|
700
|
+
deferToast: true,
|
|
701
|
+
})
|
|
702
|
+
await applyAndSettle(baselineContext.ctx)
|
|
703
|
+
assert.equal(readThemePref(dataDir), 'pink-day', 'the pref write is synchronous, independent of the toast seam')
|
|
704
|
+
baselineContext.record.activateToast(fakeToast(baselineDeliveries))
|
|
705
|
+
const baselineDeadline = Date.now() + 2_000
|
|
706
|
+
while (baselineDeliveries.length === 0 && Date.now() < baselineDeadline) {
|
|
707
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
708
|
+
}
|
|
709
|
+
assert.equal(baselineDeliveries.length, 1, 'a real baseline pref write toasts exactly once')
|
|
710
|
+
assert.equal(baselineDeliveries[0][1], 'success')
|
|
711
|
+
assert.match(baselineDeliveries[0][0], /pink-day/)
|
|
712
|
+
assert.match(baselineDeliveries[0][0], /reload/)
|
|
713
|
+
|
|
714
|
+
// 13b. Stable baseline (pref already matches the cache): quiet.
|
|
715
|
+
const quietDeliveries = []
|
|
716
|
+
const quietRecord = { registerCalls: [], watchers: [] }
|
|
717
|
+
const { ctx: quietCtx } = makeStubCtx({
|
|
718
|
+
status: fakeStatus([]),
|
|
719
|
+
sections: fakeSections([]),
|
|
720
|
+
settingsService: fakeSettingsService(quietRecord, { followSystem: true }),
|
|
721
|
+
toast: fakeToast(quietDeliveries),
|
|
722
|
+
})
|
|
723
|
+
await applyAndSettle(quietCtx)
|
|
724
|
+
assert.equal(quietDeliveries.length, 0, 'a baseline that changes nothing stays silent')
|
|
725
|
+
|
|
726
|
+
// 13c. Toggle confirmations: an explicit enable always answers — with the
|
|
727
|
+
// matching-cache confirmation, or the honest warning when nothing applies.
|
|
728
|
+
const toggleDeliveries = []
|
|
729
|
+
const toggleRecord = { registerCalls: [], watchers: [] }
|
|
730
|
+
const { ctx: toggleCtx } = makeStubCtx({
|
|
731
|
+
status: fakeStatus([]),
|
|
732
|
+
sections: fakeSections([]),
|
|
733
|
+
settingsService: fakeSettingsService(toggleRecord, {}),
|
|
734
|
+
toast: fakeToast(toggleDeliveries),
|
|
735
|
+
})
|
|
736
|
+
await applyAndSettle(toggleCtx)
|
|
737
|
+
for (const w of toggleRecord.watchers) w({ followSystem: true })
|
|
738
|
+
assert.equal(toggleDeliveries.length, 1, 'toggle-on with a matching cache confirms')
|
|
739
|
+
assert.equal(toggleDeliveries[0][1], 'success')
|
|
740
|
+
assert.equal(/reload/.test(toggleDeliveries[0][0]), false, 'no reload hint when nothing changed')
|
|
741
|
+
for (const w of toggleRecord.watchers) w({ followSystem: false })
|
|
742
|
+
rmSync(join(dataDir, 'theme-follow.json'), { force: true })
|
|
743
|
+
for (const w of toggleRecord.watchers) w({ followSystem: true })
|
|
744
|
+
assert.equal(toggleDeliveries.length, 2, 'toggle-on without a cache answers too')
|
|
745
|
+
assert.equal(toggleDeliveries[1][1], 'warning')
|
|
746
|
+
|
|
747
|
+
// 13d. A toast dropped before the host sink exists is retried until delivered.
|
|
748
|
+
const retryDeliveries = []
|
|
749
|
+
const retryRecord = { registerCalls: [], watchers: [] }
|
|
750
|
+
const { ctx: retryCtx } = makeStubCtx({
|
|
751
|
+
status: fakeStatus([]),
|
|
752
|
+
sections: fakeSections([]),
|
|
753
|
+
settingsService: fakeSettingsService(retryRecord, {}),
|
|
754
|
+
toast: fakeToast(retryDeliveries, { dropFirst: 1 }),
|
|
755
|
+
})
|
|
756
|
+
await applyAndSettle(retryCtx)
|
|
757
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: false, at: 1 }))
|
|
758
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-day' }, null, 2))
|
|
759
|
+
for (const w of retryRecord.watchers) w({ followSystem: true })
|
|
760
|
+
const retryDeadline = Date.now() + 2_000
|
|
761
|
+
while (retryDeliveries.length === 0 && Date.now() < retryDeadline) {
|
|
762
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
763
|
+
}
|
|
764
|
+
assert.equal(retryDeliveries.length, 1, 'a dropped toast is retried and delivered')
|
|
765
|
+
assert.match(retryDeliveries[0][0], /pink-night/)
|
|
766
|
+
|
|
767
|
+
// 13e. Toast-seam-less host (dsh-TUI < 0.10): sends are silent no-ops and
|
|
768
|
+
// the follow feature itself is unchanged. The retry chain stays bounded:
|
|
769
|
+
// once it gives up, a seam arriving later must not resurrect the abandoned
|
|
770
|
+
// toast.
|
|
771
|
+
const legacyRecord = { registerCalls: [], watchers: [] }
|
|
772
|
+
const legacyContext = makeStubCtx({
|
|
773
|
+
status: fakeStatus([]),
|
|
774
|
+
sections: fakeSections([]),
|
|
775
|
+
settingsService: fakeSettingsService(legacyRecord, {}),
|
|
776
|
+
deferToast: true,
|
|
777
|
+
})
|
|
778
|
+
await applyAndSettle(legacyContext.ctx)
|
|
779
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
|
|
780
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
|
|
781
|
+
for (const w of legacyRecord.watchers) w({ followSystem: true })
|
|
782
|
+
assert.equal(readThemePref(dataDir), 'pink-day', 'follow works unchanged without the toast seam')
|
|
783
|
+
await new Promise(resolve => setTimeout(resolve, 50)) // the [5, 5] chain is long exhausted
|
|
784
|
+
const lateDeliveries = []
|
|
785
|
+
legacyContext.record.activateToast(fakeToast(lateDeliveries))
|
|
786
|
+
await settle()
|
|
787
|
+
assert.equal(lateDeliveries.length, 0, 'an abandoned toast is not resurrected by a late seam')
|
|
788
|
+
|
|
789
|
+
// 13f. Shadow hint: legacy same-named files that are byte-identical to the
|
|
790
|
+
// bundled copy are pointed out once on runtime confirmation; user edits
|
|
791
|
+
// stay unmentioned. Files this activation installed itself are excluded
|
|
792
|
+
// (they were already removed before the check).
|
|
793
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
794
|
+
mkdirSync(sandboxThemes, { recursive: true })
|
|
795
|
+
for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
796
|
+
writeFileSync(join(sandboxThemes, `${theme}.json`), readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'))
|
|
797
|
+
}
|
|
798
|
+
assert.deepEqual(findShadowedBundledThemes().sort(), ['pink-ansi.json', 'pink-day.json', 'pink-night.json'])
|
|
799
|
+
const shadowDeliveries = []
|
|
800
|
+
const shadowContext = makeStubCtx({ deferThemes: true, toast: fakeToast(shadowDeliveries) })
|
|
801
|
+
apply(shadowContext.ctx)
|
|
802
|
+
shadowContext.record.activateThemes(fakeThemes(shadowContext.record))
|
|
803
|
+
await settle()
|
|
804
|
+
assert.equal(shadowDeliveries.length, 1, 'identical legacy files are pointed out once')
|
|
805
|
+
assert.equal(shadowDeliveries[0][1], undefined, 'the shadow hint is neutral, not an error')
|
|
806
|
+
assert.match(shadowDeliveries[0][0], /pink-day\.json/)
|
|
807
|
+
|
|
808
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
809
|
+
installBundledThemes()
|
|
810
|
+
writeFileSync(join(sandboxThemes, 'pink-night.json'), '{ "name": "pink-night", "colors": { "text": "#123456" } }')
|
|
811
|
+
const editDeliveries = []
|
|
812
|
+
const editContext = makeStubCtx({ deferThemes: true, toast: fakeToast(editDeliveries) })
|
|
813
|
+
apply(editContext.ctx)
|
|
814
|
+
editContext.record.activateThemes(fakeThemes(editContext.record))
|
|
815
|
+
await settle()
|
|
816
|
+
assert.equal(editDeliveries.length, 1)
|
|
817
|
+
assert.equal(/pink-night\.json/.test(editDeliveries[0][0]), false, 'a user-edited file is never nagged')
|
|
818
|
+
assert.equal(/pink-day\.json/.test(editDeliveries[0][0]), true)
|
|
819
|
+
|
|
820
|
+
// 13g. Corrupt-file self-heal is surfaced as a warning toast. The toast
|
|
821
|
+
// seam trails apply on a real host, so the send waits on the retry chain.
|
|
822
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
823
|
+
mkdirSync(sandboxThemes, { recursive: true })
|
|
824
|
+
writeFileSync(join(sandboxThemes, 'pink-day.json'), '{ broken')
|
|
825
|
+
const healDeliveries = []
|
|
826
|
+
const healContext = makeStubCtx({ deferThemes: true, deferToast: true })
|
|
827
|
+
apply(healContext.ctx)
|
|
828
|
+
healContext.record.activateToast(fakeToast(healDeliveries))
|
|
829
|
+
const healDeadline = Date.now() + 2_000
|
|
830
|
+
while (healDeliveries.length === 0 && Date.now() < healDeadline) {
|
|
831
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
832
|
+
}
|
|
833
|
+
assert.equal(healDeliveries.length, 1, 'a repaired theme file is surfaced')
|
|
834
|
+
assert.equal(healDeliveries[0][1], 'warning')
|
|
835
|
+
assert.match(healDeliveries[0][0], /pink-day\.json/)
|
|
836
|
+
|
|
837
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
838
|
+
setToastRetryDelaysForTests([2_000, 4_000])
|
|
839
|
+
console.log('✓ toast feedback: follow/self-heal/shadow hints delivered, dropped and seam-less sends retried, legacy hosts silent and bounded')
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ── 14. hot path: the token firehose must not reach render or the disk ──────
|
|
843
|
+
{
|
|
844
|
+
const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
|
|
845
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
846
|
+
// Earlier scenarios ran status activations against a different pref; drop
|
|
847
|
+
// their cached answer so this scenario starts from the fresh sandbox state.
|
|
848
|
+
invalidateThemePrefCacheForTests()
|
|
849
|
+
const statusCalls = []
|
|
850
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
851
|
+
const { ctx, record } = makeStubCtx({
|
|
852
|
+
status: fakeStatus(statusCalls),
|
|
853
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
854
|
+
})
|
|
855
|
+
await applyAndSettle(ctx)
|
|
856
|
+
const session = { id: 'h1' }
|
|
857
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
858
|
+
const baselineCalls = statusCalls.length
|
|
859
|
+
assert.match(statusCalls.at(-1)[1], /1✦$/)
|
|
860
|
+
|
|
861
|
+
// A firehose burst (streaming chunks, tool traffic, step brackets) updates
|
|
862
|
+
// the tracked session but must render nothing and never touch the disk.
|
|
863
|
+
const originalRead = builtinFs.readFileSync
|
|
864
|
+
let prefReads = 0
|
|
865
|
+
try {
|
|
866
|
+
builtinFs.readFileSync = (path, ...rest) => {
|
|
867
|
+
if (String(path) === themePrefPath) prefReads += 1
|
|
868
|
+
return originalRead(path, ...rest)
|
|
869
|
+
}
|
|
870
|
+
syncBuiltinESMExports()
|
|
871
|
+
for (let index = 0; index < 50; index += 1) {
|
|
872
|
+
emit(record, 'session/event', session, {
|
|
873
|
+
type: 'assistant/chunk',
|
|
874
|
+
turn: 1,
|
|
875
|
+
step: 1,
|
|
876
|
+
chunk: { type: 'text', text: 'x' },
|
|
877
|
+
})
|
|
878
|
+
}
|
|
879
|
+
emit(record, 'session/event', session, {
|
|
880
|
+
type: 'tool/call', turn: 1, step: 1, callId: 'c1', name: 'tool', arguments: '{}',
|
|
881
|
+
})
|
|
882
|
+
emit(record, 'session/event', session, { type: 'step/end', turn: 1, step: 1 })
|
|
883
|
+
emit(record, 'session/event', session, { type: 'todo/write', todos: [] })
|
|
884
|
+
assert.equal(statusCalls.length, baselineCalls, 'firehose events render nothing')
|
|
885
|
+
assert.equal(prefReads, 0, 'firehose events never read the theme pref')
|
|
886
|
+
|
|
887
|
+
// A turn boundary renders — served by the warm pref cache (no disk hit
|
|
888
|
+
// within the TTL), so even boundary renders stay off the filesystem.
|
|
889
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
890
|
+
assert.equal(statusCalls.length, baselineCalls + 1, 'a turn boundary renders')
|
|
891
|
+
assert.equal(prefReads, 0, 'the warm cache serves the boundary render')
|
|
892
|
+
|
|
893
|
+
// A session switch repaints at its first turn/start with a fresh count.
|
|
894
|
+
const nextSession = { id: 'h2' }
|
|
895
|
+
emit(record, 'session/event', nextSession, { type: 'turn/start' })
|
|
896
|
+
assert.equal(statusCalls.length, baselineCalls + 2)
|
|
897
|
+
assert.match(statusCalls.at(-1)[1], /0✦$/)
|
|
898
|
+
assert.equal(prefReads, 0)
|
|
899
|
+
|
|
900
|
+
// Inside the TTL a pref rewrite is not yet visible and costs no read…
|
|
901
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
|
|
902
|
+
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
903
|
+
assert.equal(statusCalls.length, baselineCalls + 3)
|
|
904
|
+
assert.match(statusCalls.at(-1)[1], /1✦$/, 'the cached pink pref keeps the line visible')
|
|
905
|
+
assert.equal(prefReads, 0, 'the TTL serves the stale-but-pink answer without I/O')
|
|
906
|
+
|
|
907
|
+
// …after invalidation (TTL expiry in production) the next render re-reads.
|
|
908
|
+
invalidateThemePrefCacheForTests()
|
|
909
|
+
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
910
|
+
assert.equal(prefReads, 1, 'invalidation re-reads the pref exactly once')
|
|
911
|
+
assert.equal(statusCalls.at(-1)[1], undefined, 'the non-pink pref hides the line')
|
|
912
|
+
} finally {
|
|
913
|
+
builtinFs.readFileSync = originalRead
|
|
914
|
+
syncBuiltinESMExports()
|
|
915
|
+
invalidateThemePrefCacheForTests()
|
|
916
|
+
}
|
|
917
|
+
console.log('✓ hot path: firehose events render nothing and never read the pref; boundaries use the cache')
|
|
918
|
+
}
|
|
919
|
+
|
|
544
920
|
console.log('\nAll plugin verifications passed.')
|
|
545
921
|
console.log(`(sandbox used: ${sandboxHome} — the real home was never touched)`)
|