dsh-tui-theme 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/docs/decisions/2026-09-26-settings-generation-adaptation.md +61 -0
- package/lib/types/autoTheme.d.ts +3 -0
- package/lib/types/autoTheme.d.ts.map +1 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +70 -39
- package/lib/types/liveConfig.d.ts +88 -0
- package/lib/types/liveConfig.d.ts.map +1 -0
- package/lib/types/liveConfig.js +151 -0
- package/lib/types/settingsSection.d.ts +73 -15
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/settingsSection.js +214 -52
- package/lib/types/statusLine.d.ts.map +1 -1
- package/lib/types/statusLine.js +17 -13
- package/lib/types/themeAssets.d.ts +4 -0
- package/lib/types/themeAssets.d.ts.map +1 -1
- package/lib/types/themeAssets.js +8 -3
- package/package.json +5 -4
- package/scripts/runtime-themes-headless.mjs +75 -0
- package/scripts/validate-themes-against-host.mjs +7 -0
- package/scripts/verify-package.mjs +13 -7
- package/scripts/verify-settings-generation.mjs +306 -0
- package/scripts/verify.mjs +1573 -1264
|
@@ -240,3 +240,78 @@ assert.ok(followToast.text.includes('pink-day') && followToast.text.includes('re
|
|
|
240
240
|
assert.equal(JSON.parse(readFileSync(join(dataDir3, 'theme.json'), 'utf8')).theme, 'pink-day', 'the follow pref write still happened')
|
|
241
241
|
await mount3.fiber.dispose()
|
|
242
242
|
console.log('OK toast phase 3: apply-time self-heal and boot-follow toasts delivered on the real host')
|
|
243
|
+
|
|
244
|
+
// ── Phase 4: settings namespace registers against the REAL service, late ────
|
|
245
|
+
// The stub suites cover the namespace registration with fakes, and phase 3
|
|
246
|
+
// stands in a minimal fake at apply time; this phase mounts a real
|
|
247
|
+
// @deepseek-ai/dsh-settings SettingsProvider AFTER the plugin (and after the
|
|
248
|
+
// extensions row), so the plugin's parked ['settings'] inject fires against
|
|
249
|
+
// the genuine register()/scope/watch machinery. The end-to-end signal: a
|
|
250
|
+
// user-layer followSystem write through the real service flips theme.json.
|
|
251
|
+
const sandbox4 = mkdtempSync(join(tmpdir(), 'pink-settings-late-'))
|
|
252
|
+
process.env.USERPROFILE = sandbox4
|
|
253
|
+
process.env.HOME = sandbox4
|
|
254
|
+
const dataDir4 = join(sandbox4, '.dsh-tui')
|
|
255
|
+
mkdirSync(dataDir4, { recursive: true })
|
|
256
|
+
writeFileSync(join(dataDir4, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }, null, 2))
|
|
257
|
+
|
|
258
|
+
const { SettingsProvider } = await import(pathToFileURL(hostRequire.resolve('@deepseek-ai/dsh-settings')).href)
|
|
259
|
+
|
|
260
|
+
/** Minimal concrete provider: in-memory storage, no file, no background IO. */
|
|
261
|
+
class MemorySettingsProvider extends SettingsProvider {
|
|
262
|
+
static provide = 'settings'
|
|
263
|
+
writable = true
|
|
264
|
+
stored = {}
|
|
265
|
+
async load() {
|
|
266
|
+
return { ...this.stored }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async persist(ns, section) {
|
|
270
|
+
this.stored[ns] = { ...section }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const app4 = new Context()
|
|
275
|
+
await app4.plugin(pluginHost.default ?? pluginHost)
|
|
276
|
+
const mount4 = await mountPlugin(app4)
|
|
277
|
+
await mount4.context.plugin(pink)
|
|
278
|
+
await app4.plugin(extensions.default ?? extensions)
|
|
279
|
+
// Deliberately last: the namespace registration can only be "late" when the
|
|
280
|
+
// service arrives after everything the plugin injects at apply time.
|
|
281
|
+
await app4.plugin(MemorySettingsProvider)
|
|
282
|
+
|
|
283
|
+
const provider = app4.get('settings')
|
|
284
|
+
assert.ok(provider, 'the real dsh-settings provider must be mounted')
|
|
285
|
+
const settingsDeadline = Date.now() + 5_000
|
|
286
|
+
while (provider.get(SETTINGS_NAMESPACE) === undefined && Date.now() < settingsDeadline) {
|
|
287
|
+
await sleep(25)
|
|
288
|
+
}
|
|
289
|
+
assert.ok(
|
|
290
|
+
provider.get(SETTINGS_NAMESPACE),
|
|
291
|
+
`the plugin namespace must register with the real service within ${5_000}ms of its late arrival`,
|
|
292
|
+
)
|
|
293
|
+
await provider.update(SETTINGS_NAMESPACE, { followSystem: true })
|
|
294
|
+
assert.equal(
|
|
295
|
+
provider.get(SETTINGS_NAMESPACE)?.followSystem,
|
|
296
|
+
true,
|
|
297
|
+
'the real service must resolve the committed user layer for the namespace',
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
const prefPath4 = join(dataDir4, 'theme.json')
|
|
301
|
+
const followDeadline = Date.now() + 5_000
|
|
302
|
+
let followed = false
|
|
303
|
+
while (!followed && Date.now() < followDeadline) {
|
|
304
|
+
try {
|
|
305
|
+
followed = JSON.parse(readFileSync(prefPath4, 'utf8')).theme === 'pink-day'
|
|
306
|
+
} catch {
|
|
307
|
+
// Pref not written yet.
|
|
308
|
+
}
|
|
309
|
+
if (!followed) await sleep(25)
|
|
310
|
+
}
|
|
311
|
+
assert.equal(
|
|
312
|
+
followed,
|
|
313
|
+
true,
|
|
314
|
+
'the user-layer follow toggle must apply the cached light background through the real service',
|
|
315
|
+
)
|
|
316
|
+
await mount4.fiber.dispose()
|
|
317
|
+
console.log('OK settings: namespace registers late on the real dsh-settings service and drives the follow pref')
|
|
@@ -105,6 +105,13 @@ assert.deepEqual(
|
|
|
105
105
|
// guarantee, so the count is reported instead of pinned to a hardcoded floor.
|
|
106
106
|
console.log(`* host Theme key count: ${allKeys.length} (coverage asserted per key)`)
|
|
107
107
|
|
|
108
|
+
// The color keys the host's /settings screen paints with (screens/Settings.tsx
|
|
109
|
+
// at the pinned baseline: promptBorder card frame, permission card title,
|
|
110
|
+
// selectionBg focused row, suggestion/error/success/inactive/subtle row text
|
|
111
|
+
// and checkbox chips, warning badges). Maintained by hand because the screen
|
|
112
|
+
// has no exported key manifest; revisit this list whenever a dsh-TUI release
|
|
113
|
+
// adds a Theme key to that screen — the per-key coverage assertion below then
|
|
114
|
+
// fails loudly instead of shipping an unreadable settings card.
|
|
108
115
|
const settingsKeys = [
|
|
109
116
|
'promptBorder',
|
|
110
117
|
'selectionBg',
|
|
@@ -7,9 +7,14 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import assert from 'node:assert/strict'
|
|
9
9
|
|
|
10
|
+
// This repo deliberately ships no package-lock.json: the pinned host tarball
|
|
11
|
+
// bundles @dsh-std/* packages whose manifests still declare workspace:*, and
|
|
12
|
+
// npm replays a committed lockfile faithfully — every install (npm ci too)
|
|
13
|
+
// dies with EUNSUPPORTEDPROTOCOL. Removing the lockfile makes npm skip the
|
|
14
|
+
// bundled directories instead. The peer/dev consistency checks below therefore
|
|
15
|
+
// read package.json only; CI's contract job guards the deletion (REVIEW.md G1).
|
|
10
16
|
const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
11
17
|
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
12
|
-
const lockfile = JSON.parse(readFileSync(new URL('../package-lock.json', import.meta.url), 'utf8'))
|
|
13
18
|
const packCommand = process.platform === 'win32'
|
|
14
19
|
? { file: 'cmd.exe', args: ['/d', '/s', '/c', 'npm pack --dry-run --json --ignore-scripts'] }
|
|
15
20
|
: { file: 'npm', args: ['pack', '--dry-run', '--json', '--ignore-scripts'] }
|
|
@@ -36,6 +41,8 @@ for (const required of [
|
|
|
36
41
|
'lib/types/pluginId.d.ts',
|
|
37
42
|
'lib/types/settingsSection.js',
|
|
38
43
|
'lib/types/settingsSection.d.ts',
|
|
44
|
+
'lib/types/liveConfig.js',
|
|
45
|
+
'lib/types/liveConfig.d.ts',
|
|
39
46
|
'lib/types/statusLine.js',
|
|
40
47
|
'lib/types/statusLine.d.ts',
|
|
41
48
|
'lib/types/themeAssets.js',
|
|
@@ -58,6 +65,7 @@ for (const required of [
|
|
|
58
65
|
'docs/screenshots/settings.png',
|
|
59
66
|
'scripts/verify.mjs',
|
|
60
67
|
'scripts/verify-package.mjs',
|
|
68
|
+
'scripts/verify-settings-generation.mjs',
|
|
61
69
|
'scripts/headless-order-test.mjs',
|
|
62
70
|
'scripts/runtime-themes-headless.mjs',
|
|
63
71
|
'scripts/validate-themes-against-host.mjs',
|
|
@@ -90,12 +98,10 @@ for (const name of Object.keys(packageJson.peerDependencies)) {
|
|
|
90
98
|
assert.equal(devAccepted, true, `${name} dev pin must be accepted by its peer range`)
|
|
91
99
|
}
|
|
92
100
|
assert.equal(packageJson.dependencies?.['@deepseek-ai/schemastery'], undefined)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
assert.deepEqual(rootLock.peerDependencies, packageJson.peerDependencies)
|
|
98
|
-
assert.equal(rootLock.dependencies, undefined)
|
|
101
|
+
// The lockfile's root metadata is deliberately not compared here: this script
|
|
102
|
+
// also ships inside the published tarball (package.json `files`), and npm never
|
|
103
|
+
// packs a lockfile, so the check could not hold for a consumer. The repo-level
|
|
104
|
+
// lockfile invariant lives in the CI contract job instead (REVIEW.md G1).
|
|
99
105
|
assert.equal(existsSync(new URL('../lib/types/index.js', import.meta.url)), true)
|
|
100
106
|
|
|
101
107
|
console.log(`✓ package manifest: ${packed.name}@${packed.version}, ${files.size} files`)
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify the plugin against a real `@deepseek-ai/dsh-settings` install of the
|
|
3
|
+
* ≥0.1.7 generation — the Config-derived forms the `/settings` screen reads.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: 0.1.7 removed the namespace-registration API entirely and
|
|
6
|
+
* projects each profile entry's Cordis Config instead. A plugin that misses the
|
|
7
|
+
* transition still boots, still registers its card, and simply renders
|
|
8
|
+
* `命名空间未注册`; a card field whose Config key is not marked volatile renders
|
|
9
|
+
* `(未设置)` forever. Both are silent, so they get a gate — the reference
|
|
10
|
+
* record is docs/decisions/2026-09-24-settings-generation-adaptation.md (in the
|
|
11
|
+
* dsh-tui-find repo), whose migration checklist this script implements for
|
|
12
|
+
* dsh-tui-theme.
|
|
13
|
+
*
|
|
14
|
+
* What it checks (against the built `lib/types/`, not the sources):
|
|
15
|
+
* 1. `volatileForm(Config)` is non-empty and holds exactly the live keys
|
|
16
|
+
* (the entry is listed by `describe()` only when it is);
|
|
17
|
+
* 2. every live key is writable through the `isVolatilePath` gate;
|
|
18
|
+
* 3. the card's field paths equal the live keys — the parity that keeps a
|
|
19
|
+
* field from rendering editable while nothing serves it;
|
|
20
|
+
* 4. `projectForm` over a resolved row config yields a value for every live
|
|
21
|
+
* knob (all of them carry defaults, so nothing may read `(未设置)`);
|
|
22
|
+
* 5. the wiring against a real-shaped service (no `register`, has
|
|
23
|
+
* `configure`): page policy opts out of the auto page on the plugin's own
|
|
24
|
+
* fiber, the initial value comes from the live config, and a
|
|
25
|
+
* `loader/volatile-update` re-read sees edited values.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* npm run build && node scripts/verify-settings-generation.mjs
|
|
29
|
+
* node scripts/verify-settings-generation.mjs --settings <dir>
|
|
30
|
+
* DSH_SETTINGS_DIR=<dir> node scripts/verify-settings-generation.mjs
|
|
31
|
+
*
|
|
32
|
+
* `<dir>` is a `@deepseek-ai/dsh-settings` package directory, e.g. the one a
|
|
33
|
+
* real profile resolves:
|
|
34
|
+
* %USERPROFILE%\.dsh\profiles\node_modules\@deepseek-ai\dsh-settings
|
|
35
|
+
*
|
|
36
|
+
* Generation detection is behavioural, never a version parse: a legacy
|
|
37
|
+
* install (≤0.1.6) is reported as "nothing to verify here" and exits 0 — its
|
|
38
|
+
* path is covered by scripts/verify.mjs. So a green run with the repo's own
|
|
39
|
+
* (legacy) dependency means "the new-generation path was not exercised", not
|
|
40
|
+
* "verified"; point DSH_SETTINGS_DIR at a real 0.1.7+ install to gate it.
|
|
41
|
+
* Exit code 1 on any failed check.
|
|
42
|
+
*
|
|
43
|
+
* The probe runs from a scratch directory inside the settings tree's own
|
|
44
|
+
* `node_modules`, so the plugin's bare import (`@deepseek-ai/schemastery`)
|
|
45
|
+
* resolves exactly as it does at runtime there — the marking only happens on a
|
|
46
|
+
* schemastery that knows `.volatile()` (3.18.3+), and that is the host's copy,
|
|
47
|
+
* not this repo's. The directory is removed on the way out.
|
|
48
|
+
*/
|
|
49
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
50
|
+
import { createRequire } from 'node:module'
|
|
51
|
+
import { dirname, join } from 'node:path'
|
|
52
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
53
|
+
|
|
54
|
+
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
55
|
+
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
let settings
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
if (argv[i] === '--settings') {
|
|
60
|
+
if (argv[i + 1] === undefined) throw new Error('--settings needs a directory argument')
|
|
61
|
+
settings = argv[++i]
|
|
62
|
+
} else {
|
|
63
|
+
throw new Error(`unknown option: ${argv[i]}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { settings }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where the settings package lives: --settings, then DSH_SETTINGS_DIR, then
|
|
70
|
+
* this repo's own tree. */
|
|
71
|
+
function resolveSettingsDir(explicit) {
|
|
72
|
+
const candidate =
|
|
73
|
+
explicit ?? process.env['DSH_SETTINGS_DIR'] ?? join(repoRoot, 'node_modules', '@deepseek-ai', 'dsh-settings')
|
|
74
|
+
if (!existsSync(join(candidate, 'package.json'))) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`no @deepseek-ai/dsh-settings at ${candidate}\n` +
|
|
77
|
+
'pass --settings <dir> or set DSH_SETTINGS_DIR (a real profile resolves it under ' +
|
|
78
|
+
'<DSH_HOME>/profiles/node_modules/@deepseek-ai/dsh-settings)',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
return candidate
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const failures = []
|
|
85
|
+
function check(label, ok, detail = '') {
|
|
86
|
+
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail === '' ? '' : ` — ${detail}`}`)
|
|
87
|
+
if (!ok) failures.push(label)
|
|
88
|
+
}
|
|
89
|
+
function note(text) {
|
|
90
|
+
console.log(` · ${text}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { settings: explicitSettings } = parseArgs(process.argv.slice(2))
|
|
94
|
+
const settingsDir = resolveSettingsDir(explicitSettings)
|
|
95
|
+
const version = JSON.parse(readFileSync(join(settingsDir, 'package.json'), 'utf8')).version
|
|
96
|
+
console.log(`* @deepseek-ai/dsh-settings ${version} (${settingsDir})`)
|
|
97
|
+
|
|
98
|
+
// Generation detection: the Config-derived surface needs `lib/types/schema.js`
|
|
99
|
+
// (volatileForm/projectForm/isVolatilePath) and there must be no
|
|
100
|
+
// namespace-registration class to fall back on.
|
|
101
|
+
const schemaModule = join(settingsDir, 'lib', 'types', 'schema.js')
|
|
102
|
+
const lib = await import(pathToFileURL(join(settingsDir, 'lib', 'index.js')).href)
|
|
103
|
+
const provider =
|
|
104
|
+
lib.SettingsForms ??
|
|
105
|
+
lib.default ??
|
|
106
|
+
Object.values(lib).find(value => typeof value === 'function' && value.prototype !== undefined)
|
|
107
|
+
const legacyRegister = typeof provider?.prototype?.register === 'function'
|
|
108
|
+
if (!existsSync(schemaModule) || legacyRegister) {
|
|
109
|
+
console.log('* ≤0.1.6 generation (namespace registration): nothing to verify here.')
|
|
110
|
+
console.log(' The plugin\'s legacy path is covered by scripts/verify.mjs.')
|
|
111
|
+
console.log(' Point DSH_SETTINGS_DIR at a real ≥0.1.7 install to exercise the Config-derived path.')
|
|
112
|
+
process.exit(0)
|
|
113
|
+
}
|
|
114
|
+
console.log('* ≥0.1.7 generation (Config-derived forms): verifying the plugin card against it.')
|
|
115
|
+
|
|
116
|
+
const buildDir = join(repoRoot, 'lib', 'types')
|
|
117
|
+
for (const file of ['index.js', 'liveConfig.js', 'settingsSection.js']) {
|
|
118
|
+
if (!existsSync(join(buildDir, file))) {
|
|
119
|
+
throw new Error(`lib/types/${file} is missing — run \`npm run build\` first`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Scratch dir inside the settings tree's node_modules: bare specifiers resolve
|
|
124
|
+
// from there exactly as the plugin's own copy would at runtime.
|
|
125
|
+
const probeRoot = join(dirname(dirname(settingsDir)), `.dsh-tui-theme-settings-probe-${process.pid}`)
|
|
126
|
+
try {
|
|
127
|
+
rmSync(probeRoot, { recursive: true, force: true })
|
|
128
|
+
mkdirSync(probeRoot, { recursive: true })
|
|
129
|
+
cpSync(buildDir, join(probeRoot, 'types'), { recursive: true })
|
|
130
|
+
|
|
131
|
+
const plugin = name => import(pathToFileURL(join(probeRoot, 'types', name)).href)
|
|
132
|
+
const helper = name => import(pathToFileURL(join(settingsDir, 'lib', 'types', name)).href)
|
|
133
|
+
|
|
134
|
+
const { Config } = await plugin('index.js')
|
|
135
|
+
const { LIVE_CONFIG_KEYS, hasLiveConfigFields, readConfigValues } = await plugin('liveConfig.js')
|
|
136
|
+
const { registerPinkSettings, resolveSettingsNamespace, SETTINGS_NS } = await plugin('settingsSection.js')
|
|
137
|
+
const { volatileForm, projectForm, plainConfig, isVolatilePath } = await helper('schema.js')
|
|
138
|
+
|
|
139
|
+
// The host's own schemastery: 3.18.3+ parses marked fields into live refs.
|
|
140
|
+
const hostRequire = createRequire(join(settingsDir, 'lib', 'index.js'))
|
|
141
|
+
const z = (await import(pathToFileURL(hostRequire.resolve('@deepseek-ai/schemastery')).href)).default
|
|
142
|
+
const volatileCapable = typeof z?.string?.().volatile === 'function'
|
|
143
|
+
|
|
144
|
+
const liveKeys = [...LIVE_CONFIG_KEYS].sort()
|
|
145
|
+
const form = volatileForm(Config)
|
|
146
|
+
check('a live marker exists on the shipped Config', hasLiveConfigFields(Config))
|
|
147
|
+
check('volatileForm(Config) is non-empty (describe() lists the entry)', form !== undefined)
|
|
148
|
+
const formKeys = Object.keys(form?.dict ?? {}).sort()
|
|
149
|
+
check(
|
|
150
|
+
'live keys == form-projected keys',
|
|
151
|
+
JSON.stringify(formKeys) === JSON.stringify(liveKeys),
|
|
152
|
+
formKeys.join(', '),
|
|
153
|
+
)
|
|
154
|
+
check('every live key passes the write gate', LIVE_CONFIG_KEYS.every(key => isVolatilePath(Config, [key])))
|
|
155
|
+
|
|
156
|
+
// The namespace follows the Loader entry id on this generation (dsh-TUI
|
|
157
|
+
// #990's fragility: the host keys by entry id, so a renamed row must move
|
|
158
|
+
// the card with it) and falls back to the constant for an unusable id.
|
|
159
|
+
const withEntry = id => ({ fiber: { entry: { options: { id } } } })
|
|
160
|
+
check(
|
|
161
|
+
'namespace follows the Loader entry id',
|
|
162
|
+
resolveSettingsNamespace(withEntry('custom-theme')) === 'custom-theme',
|
|
163
|
+
resolveSettingsNamespace(withEntry('custom-theme')),
|
|
164
|
+
)
|
|
165
|
+
check(
|
|
166
|
+
'namespace falls back for an unusable entry id',
|
|
167
|
+
resolveSettingsNamespace(withEntry('Custom.TUI')) === SETTINGS_NS,
|
|
168
|
+
)
|
|
169
|
+
check('namespace defaults to the constant without a Loader entry', resolveSettingsNamespace({}) === SETTINGS_NS)
|
|
170
|
+
|
|
171
|
+
// Every live knob carries a schema default here, so the projection must
|
|
172
|
+
// serve a value for each one (the `(未设置)` symptom is a defaulted field
|
|
173
|
+
// missing from the form).
|
|
174
|
+
const unset = projectForm(form, plainConfig(Config({})))
|
|
175
|
+
const unsetMissing = LIVE_CONFIG_KEYS.filter(key => unset[key] === undefined)
|
|
176
|
+
check('every live knob is served (no (未设置))', unsetMissing.length === 0, unsetMissing.join(', '))
|
|
177
|
+
|
|
178
|
+
const rowConfig = Config({
|
|
179
|
+
followSystem: true,
|
|
180
|
+
statusGlyph: '❀',
|
|
181
|
+
statusSeparator: '✦',
|
|
182
|
+
showGlyph: true,
|
|
183
|
+
showClock: false,
|
|
184
|
+
showTurns: true,
|
|
185
|
+
statusScope: 'all-themes',
|
|
186
|
+
})
|
|
187
|
+
const view = projectForm(form, plainConfig(rowConfig))
|
|
188
|
+
check(
|
|
189
|
+
'user values survive the projection',
|
|
190
|
+
view.statusGlyph === '❀' &&
|
|
191
|
+
view.statusSeparator === '✦' &&
|
|
192
|
+
view.showClock === false &&
|
|
193
|
+
view.statusScope === 'all-themes' &&
|
|
194
|
+
view.followSystem === true,
|
|
195
|
+
JSON.stringify(view),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if (volatileCapable) {
|
|
199
|
+
check(
|
|
200
|
+
'the host sends marked fields as live refs',
|
|
201
|
+
typeof rowConfig.statusGlyph === 'object' && rowConfig.statusGlyph !== null,
|
|
202
|
+
)
|
|
203
|
+
check('apply-time config resolves through live refs', readConfigValues(rowConfig).statusGlyph === '❀')
|
|
204
|
+
} else {
|
|
205
|
+
note('this host schemastery has no .volatile(): values stay plain; meta marking still projects')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The wiring, against a service shaped like the real one: no `register`, a
|
|
209
|
+
// per-instance page policy, and the values coming from the live Config.
|
|
210
|
+
const warns = []
|
|
211
|
+
const cards = []
|
|
212
|
+
const configured = []
|
|
213
|
+
const applied = []
|
|
214
|
+
let refresh
|
|
215
|
+
const service = {
|
|
216
|
+
configure: (presentation, owner) => {
|
|
217
|
+
configured.push({ presentation, owner })
|
|
218
|
+
return () => {}
|
|
219
|
+
},
|
|
220
|
+
describe: () => [],
|
|
221
|
+
update: async () => {},
|
|
222
|
+
mutate: async () => {},
|
|
223
|
+
}
|
|
224
|
+
const sections = {
|
|
225
|
+
register: section => {
|
|
226
|
+
cards.push(section)
|
|
227
|
+
return () => {}
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
const logger = { warn: message => warns.push(String(message)), info: () => {}, error: () => {} }
|
|
231
|
+
const child = dep => ({
|
|
232
|
+
[dep]: dep === 'settings' ? service : sections,
|
|
233
|
+
effect: factory => factory(),
|
|
234
|
+
logger,
|
|
235
|
+
})
|
|
236
|
+
const ctx = {
|
|
237
|
+
get: key => (key === 'tuiSettingsSections' ? sections : undefined),
|
|
238
|
+
effect: factory => factory(),
|
|
239
|
+
inject: (deps, callback) => {
|
|
240
|
+
for (const dep of deps) callback(child(dep))
|
|
241
|
+
},
|
|
242
|
+
on: (event, listener) => {
|
|
243
|
+
check('listens on loader/volatile-update', event === 'loader/volatile-update', event)
|
|
244
|
+
refresh = listener
|
|
245
|
+
return () => {}
|
|
246
|
+
},
|
|
247
|
+
// The plugin's own fiber, as the Loader reports it: the namespace source
|
|
248
|
+
// and the owner the page policy must attach to.
|
|
249
|
+
fiber: { entry: { options: { id: SETTINGS_NS } } },
|
|
250
|
+
logger,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const live = { current: rowConfig }
|
|
254
|
+
registerPinkSettings(
|
|
255
|
+
ctx,
|
|
256
|
+
{
|
|
257
|
+
cordis: { statusGlyph: '✿', statusSeparator: '·', showGlyph: true, showClock: true, showTurns: true, statusScope: 'pink-only', statusEnabled: true, followSystem: false },
|
|
258
|
+
readLive: () => readConfigValues(live.current),
|
|
259
|
+
hasLiveFields: hasLiveConfigFields(Config),
|
|
260
|
+
onDoc: doc => applied.push(doc),
|
|
261
|
+
},
|
|
262
|
+
join(probeRoot, 'data'),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
check('the card takes the Loader entry id as its namespace', cards[0]?.ns === SETTINGS_NS, String(cards[0]?.ns))
|
|
266
|
+
check('no namespace registration is attempted', typeof service.register === 'undefined')
|
|
267
|
+
check(
|
|
268
|
+
'page policy opts out of the auto page on the plugin fiber',
|
|
269
|
+
configured.length === 1 &&
|
|
270
|
+
configured[0].presentation.auto === false &&
|
|
271
|
+
configured[0].owner === ctx.fiber,
|
|
272
|
+
)
|
|
273
|
+
const cardPaths = (cards[0]?.fields ?? []).map(field => field.path.join('.')).sort()
|
|
274
|
+
check('card fields == live keys', JSON.stringify(cardPaths) === JSON.stringify(liveKeys), cardPaths.join(', '))
|
|
275
|
+
check('initial value comes from the live config', applied.at(-1)?.statusGlyph === '❀')
|
|
276
|
+
check('a healthy ≥0.1.7 host logs no warning', warns.length === 0, warns.join(' | '))
|
|
277
|
+
|
|
278
|
+
const edited = Config({
|
|
279
|
+
followSystem: false,
|
|
280
|
+
statusGlyph: '🌸',
|
|
281
|
+
statusSeparator: '·',
|
|
282
|
+
showGlyph: false,
|
|
283
|
+
showClock: true,
|
|
284
|
+
showTurns: false,
|
|
285
|
+
statusScope: 'pink-only',
|
|
286
|
+
})
|
|
287
|
+
// Model the loader's in-place rewrite: the plugin re-reads the same object.
|
|
288
|
+
for (const key of Object.keys(edited)) live.current[key] = edited[key]
|
|
289
|
+
refresh?.()
|
|
290
|
+
check(
|
|
291
|
+
'a volatile update re-reads the edited config',
|
|
292
|
+
applied.at(-1)?.statusGlyph === '🌸' &&
|
|
293
|
+
applied.at(-1)?.showGlyph === false &&
|
|
294
|
+
applied.at(-1)?.showClock === true &&
|
|
295
|
+
applied.at(-1)?.followSystem === false,
|
|
296
|
+
JSON.stringify(applied.at(-1)),
|
|
297
|
+
)
|
|
298
|
+
} finally {
|
|
299
|
+
rmSync(probeRoot, { recursive: true, force: true })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (failures.length > 0) {
|
|
303
|
+
console.error(`\n${failures.length} check(s) failed`)
|
|
304
|
+
process.exit(1)
|
|
305
|
+
}
|
|
306
|
+
console.log('\nall checks passed')
|